Compare commits
1 Commits
main
...
4097cb2530
| Author | SHA1 | Date | |
|---|---|---|---|
| 4097cb2530 |
30
.gitignore
vendored
30
.gitignore
vendored
@@ -4,33 +4,3 @@
|
|||||||
bal-electrum-plugin.zip
|
bal-electrum-plugin.zip
|
||||||
electrum-src/
|
electrum-src/
|
||||||
preview_*.png
|
preview_*.png
|
||||||
.env
|
|
||||||
|
|
||||||
# Virtual environment
|
|
||||||
venv/
|
|
||||||
.venv/
|
|
||||||
|
|
||||||
# Node modules
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Editor temp files
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*.bak
|
|
||||||
|
|
||||||
# Local tooling / scratch files (not part of the plugin)
|
|
||||||
TODO_LIST.md
|
|
||||||
opencode.json
|
|
||||||
package.json
|
|
||||||
package-lock.json
|
|
||||||
pyrightconfig.json
|
|
||||||
|
|
||||||
# Debug / scratch files
|
|
||||||
debug.py
|
|
||||||
init.ol
|
|
||||||
temp*
|
|
||||||
tmp*
|
|
||||||
|
|
||||||
# Release artifacts
|
|
||||||
bal_v*.zip.*
|
|
||||||
tests/karen7
|
|
||||||
|
|||||||
@@ -1,231 +0,0 @@
|
|||||||
# 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).
|
|
||||||
97
AGENTS.md
97
AGENTS.md
@@ -1,97 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
BAL — Bitcoin After Life, an Electrum plugin (inheritance / dead-man's-switch).
|
|
||||||
Source-of-truth docs: `README.md`, `HANDOFF.md`, `COMPATIBILITY.md`.
|
|
||||||
|
|
||||||
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`).
|
|
||||||
|
|
||||||
## Build / release
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha256)
|
|
||||||
./make-release.sh [v0.x.y] # bump manifest version, tag, sign, push Gitea release
|
|
||||||
```
|
|
||||||
|
|
||||||
- `make-release.sh` requires gpg and Gitea credentials (`~/.git-credentials`
|
|
||||||
or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the
|
|
||||||
version there, never invent a new source of truth.
|
|
||||||
- Remote is Gitea (`origin` = bitcoin-after.life). `.env` holds a Gitea token
|
|
||||||
(gitignored, never commit it).
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
998
CHANGELOG.md
998
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
|||||||
# Wallet Compatibility
|
|
||||||
|
|
||||||
BAL (Bitcoin After Life) builds and signs Electrum transactions using
|
|
||||||
Electrum's own wallet and signing infrastructure. Its compatibility therefore
|
|
||||||
depends on the wallet type in use.
|
|
||||||
|
|
||||||
| Wallet type | Status | Notes |
|
|
||||||
|-------------------------------------------------|-----------------------------|-------|
|
|
||||||
| Standard wallet (single-signature, seed-based) | ✅ Supported | Primary, fully tested target |
|
|
||||||
| Hardware wallets (Ledger, Trezor, Coldcard, BitBox02, Jade, KeepKey, etc.) | ✅ Supported | Any hardware wallet supported by Electrum itself |
|
|
||||||
| Multisig wallets | ❌ Not yet supported | Known limitation identified 2026-07-18. Support is planned for a future plugin release. |
|
|
||||||
| Electrum TrustedCoin (2FA) wallets | ❓ Unknown / unsupported | Known limitation identified 2026-07-18. It has not yet been determined whether or when this will be addressed. |
|
|
||||||
|
|
||||||
## What "not supported" means in practice
|
|
||||||
|
|
||||||
For multisig and TrustedCoin (2FA) wallets, BAL's behavior has not been
|
|
||||||
verified and should be considered **unreliable**. Do not rely on BAL to
|
|
||||||
protect an inheritance set up on one of these wallet types until this document
|
|
||||||
is updated to mark them as supported.
|
|
||||||
|
|
||||||
## Electrum version compatibility
|
|
||||||
|
|
||||||
See [`README.md`](README.md) for supported Electrum versions (currently 4.7.2
|
|
||||||
and 4.8.0).
|
|
||||||
|
|
||||||
## QR wire-format compatibility
|
|
||||||
|
|
||||||
BAL exports/imports wills as QR codes. **BAL QR** (the default) is the plugin's
|
|
||||||
own frame format and is only understood by BAL itself. The export page also
|
|
||||||
supports **BC-UR v1**, **BC-UR v2** and **BBQR**:
|
|
||||||
|
|
||||||
| Format | Wire appearance | Interop target |
|
|
||||||
|-----------|----------------------------|------------------------------------------------------|
|
|
||||||
| BAL QR | `BAL1<total><index><flag>…` (v2) / `BALQR1\|total\|index\|…` (legacy import-only) | Past/other BAL versions: **v2 exports are NOT readable by old builds**; old `BALQR1` exports still import here (default, best-of compression, flag `0` = plain, `Z` = deflate) |
|
|
||||||
| BC-UR v1 | `ur:bytes/<bc32>` | Blockchain Commons / Coldcard-style UR (BC32, SHA-256 digest, part counts per part) |
|
|
||||||
| BC-UR v2 | `ur:bytes/<seq>-<seqlen>/<bytewords>` | BC-UR 2.x fountain codes (CBOR parts, CRC-32, bytewords-minimal) |
|
|
||||||
| 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, duplicate
|
|
||||||
and (for UR v2) partially-lost fountain frames are handled. Interop is
|
|
||||||
validation-tested against the reference C++ bc-ur encoder output and the
|
|
||||||
BCR-2020-004/005 BC32 test vectors; it has not yet been cross-verified against
|
|
||||||
third-party libraries (`ur`, `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.
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# Compatibility Roadmap: Multisig and TrustedCoin (2FA) Wallets
|
|
||||||
|
|
||||||
Status document, 2026-07-19. Companion to [`COMPATIBILITY.md`](COMPATIBILITY.md):
|
|
||||||
that file states *what* is supported today; this one explains *why* multisig and
|
|
||||||
TrustedCoin (2FA) wallets are currently unsupported and *how* support can be
|
|
||||||
added.
|
|
||||||
|
|
||||||
## Root cause (common to both)
|
|
||||||
|
|
||||||
BAL currently does two things that are only valid for standard
|
|
||||||
(single-signature) wallets:
|
|
||||||
|
|
||||||
1. **It builds transactions itself** with `PartialTransaction.from_io(...)`
|
|
||||||
(`bal/core/will.py`), bypassing `wallet.make_unsigned_transaction`.
|
|
||||||
2. **It signs with a single call** — `wallet.sign_transaction(tx, password)`
|
|
||||||
(`bal/gui/qt/window.py`, `sign_transactions`) — and considers the will ready
|
|
||||||
when `tx.is_complete()` is true.
|
|
||||||
|
|
||||||
On a standard wallet one signature completes the transaction. On multisig and
|
|
||||||
2FA wallets **one signature is not enough**: the transaction stays incomplete,
|
|
||||||
is never marked `COMPLETE`, and can never be pushed to will-executors.
|
|
||||||
|
|
||||||
A secondary single-sig assumption: `plugin.py` uses `wallet.get_keystore()`
|
|
||||||
(singular); multisig wallets expose `get_keystores()` (plural).
|
|
||||||
|
|
||||||
## Multisig wallets — solvable, medium/large effort
|
|
||||||
|
|
||||||
A 2-of-3 multisig wallet typically holds **only one** of the required private
|
|
||||||
keys locally; the other cosigners hold theirs. `wallet.sign_transaction` adds
|
|
||||||
the local signature only, and BAL has no flow to collect the missing ones.
|
|
||||||
|
|
||||||
**Proposed solution: the standard PSBT coordination round** (the same flow
|
|
||||||
Electrum itself uses for multisig spending):
|
|
||||||
|
|
||||||
1. Build and sign locally as today.
|
|
||||||
2. If the transaction is not complete, **export the partially-signed
|
|
||||||
transaction(s)** (file and/or QR) and mark the will with a new status such
|
|
||||||
as `WAITING_COSIGNERS`.
|
|
||||||
3. Each cosigner signs in their own Electrum (native feature — no new
|
|
||||||
software needed on their side).
|
|
||||||
4. BAL **re-imports and merges the signatures**; once complete, the will is
|
|
||||||
pushed to will-executors as today.
|
|
||||||
|
|
||||||
Notes and caveats:
|
|
||||||
|
|
||||||
- **Chained will transactions** (a will tx spending the change of a previous
|
|
||||||
will tx) remain workable: with segwit, the txid of an unsigned/partially
|
|
||||||
signed transaction is already stable, so the whole chain can be exported as
|
|
||||||
a batch of PSBTs in one round.
|
|
||||||
- **Every rebuild requires a new cosigner round.** Check Alive postponements
|
|
||||||
and balance-change rebuilds re-sign the will, so each of them needs the
|
|
||||||
cosigners again. This is inherent to multisig and must be clearly
|
|
||||||
communicated in the UI.
|
|
||||||
- Implementation surface: export/import/merge pipeline, GUI for it, the new
|
|
||||||
status in the transaction list, and tests.
|
|
||||||
|
|
||||||
Target: **next plugin release**, as announced.
|
|
||||||
|
|
||||||
## TrustedCoin (2FA) wallets — harder, with one blocking unknown
|
|
||||||
|
|
||||||
An Electrum 2FA wallet (`Wallet_2fa`, defined in Electrum's `trustedcoin`
|
|
||||||
plugin) is technically a **2-of-3 multisig whose second signer is the
|
|
||||||
TrustedCoin server**:
|
|
||||||
|
|
||||||
- signing requires a **one-time password (OTP) per transaction**
|
|
||||||
(`server.sign(short_id, raw_tx, otp)`);
|
|
||||||
- the server co-signs only transactions that include **its billing fee**,
|
|
||||||
which Electrum adds inside `Wallet_2fa.make_unsigned_transaction` — a code
|
|
||||||
path BAL currently bypasses (see root cause #1).
|
|
||||||
|
|
||||||
So today: no billing output, no OTP prompt, local signature only → incomplete
|
|
||||||
transaction.
|
|
||||||
|
|
||||||
Even with full integration (building via the wallet's
|
|
||||||
`make_unsigned_transaction`, adding the OTP prompt flow), one **decisive
|
|
||||||
unknown** remains: will transactions carry a **locktime years in the future**.
|
|
||||||
Whether the TrustedCoin server agrees to co-sign a transaction with such a
|
|
||||||
far-future `nLockTime` is an undocumented server-side policy. If it refuses,
|
|
||||||
2FA support is **not achievable** without TrustedCoin's cooperation. This is
|
|
||||||
why `COMPATIBILITY.md` marks 2FA as *unknown*.
|
|
||||||
|
|
||||||
**Proposed plan:**
|
|
||||||
|
|
||||||
1. **Empirical test on testnet** (cheap, decisive): create a test 2FA wallet,
|
|
||||||
build a far-future-locktime transaction through the proper 2FA path, and
|
|
||||||
check whether the server signs it.
|
|
||||||
2. If it signs → implement support: build via `make_unsigned_transaction`
|
|
||||||
(billing output included), integrate the OTP prompt, and document that
|
|
||||||
every rebuild costs one OTP round and TrustedCoin fees.
|
|
||||||
3. If it refuses → document 2FA as unsupported, with the practical
|
|
||||||
workaround: Electrum allows disabling 2FA by restoring the wallet from the
|
|
||||||
full seed, which turns it into a standard wallet — fully supported by BAL.
|
|
||||||
|
|
||||||
## Recommended order of work
|
|
||||||
|
|
||||||
1. **Multisig first**: deterministic path, standard Electrum tooling, already
|
|
||||||
announced for the next release.
|
|
||||||
2. **TrustedCoin empirical test in parallel**: low cost, and its outcome
|
|
||||||
decides whether 2FA support is feasible at all.
|
|
||||||
275
HANDOFF.md
275
HANDOFF.md
@@ -10,7 +10,7 @@
|
|||||||
## 0. TL;DR — what this project is
|
## 0. TL;DR — what this project is
|
||||||
|
|
||||||
- **Product:** BAL ("Bitcoin After Life") — an inheritance plugin for the
|
- **Product:** BAL ("Bitcoin After Life") — an inheritance plugin for the
|
||||||
**Electrum 4.7.2 and 4.8.0** Bitcoin wallet (Qt / **PyQt6**).
|
**Electrum 4.7.2** Bitcoin wallet (Qt / **PyQt6**).
|
||||||
- **Form:** external **ZIP plugin** (not bundled in Electrum). The user
|
- **Form:** external **ZIP plugin** (not bundled in Electrum). The user
|
||||||
installs the ZIP from Electrum's plugin manager.
|
installs the ZIP from Electrum's plugin manager.
|
||||||
- **What it does:** lets a wallet owner pre-build, sign and (later) broadcast
|
- **What it does:** lets a wallet owner pre-build, sign and (later) broadcast
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
services) can be paid a fee to broadcast the inheritance when due. The owner
|
services) can be paid a fee to broadcast the inheritance when due. The owner
|
||||||
periodically proves they are alive ("check-alive"); if the deadline passes,
|
periodically proves they are alive ("check-alive"); if the deadline passes,
|
||||||
the inheritance becomes spendable.
|
the inheritance becomes spendable.
|
||||||
- **Current version:** see the `"version"` field of `bal/manifest.json` (the single source of truth; read at runtime via `get_version()` in `bal/core/plugin_base.py`).
|
- **Current version:** see `bal/VERSION` (last shipped: **0.4.8**).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,41 +55,29 @@ These are non-negotiable. They come from the owner directly.
|
|||||||
|
|
||||||
```
|
```
|
||||||
bal/ <- the plugin package (this is what ships in the ZIP)
|
bal/ <- the plugin package (this is what ships in the ZIP)
|
||||||
__init__.py <- package docstring (no version here anymore)
|
__init__.py <- __version__ (one of 4 version files)
|
||||||
manifest.json <- plugin manifest, "version" field (SINGLE SOURCE OF TRUTH for the version)
|
VERSION <- plain-text version (one of 4 version files)
|
||||||
qt.py <- zipimport shim used when loaded as an external ZIP plugin
|
manifest.json <- plugin manifest, "version" field (one of 4)
|
||||||
cmdline.py <- CLI entry-point shim (Electrum gui_name='cmdline')
|
|
||||||
core/
|
core/
|
||||||
plugin_base.py <- get_version() reads the version from manifest.json (zip-safe)
|
plugin_base.py <- __version__ "AUTOMATICALLY GENERATED" (one of 4)
|
||||||
heirs.py <- HEIRS + transaction building (prepare_lists,
|
heirs.py <- HEIRS + transaction building (prepare_lists,
|
||||||
prepare_transactions, buildTransactions). CORE LOGIC.
|
prepare_transactions, buildTransactions). CORE LOGIC.
|
||||||
will.py <- Will/WillItem, validation (check_amounts, check_will),
|
will.py <- Will/WillItem, validation (check_amounts, check_will),
|
||||||
exceptions (AmountException, WillExpiredException, ...).
|
exceptions (AmountException, WillExpiredException, ...).
|
||||||
willexecutors.py <- remote will-executor services handling (is_selected / is_valid,
|
willexecutors.py <- remote will-executor services handling.
|
||||||
parallel push/check).
|
|
||||||
util.py <- locktime parsing/most helpers (timestamps only).
|
util.py <- locktime parsing/most helpers (timestamps only).
|
||||||
checkalive.py <- resolve_date_to_check, check_alive_expired (GUI-free).
|
|
||||||
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 <- BalWindow, the per-wallet controller (build_will, check_will,
|
window.py <- BalWalletWindow (build_will, check_will, get_transactions).
|
||||||
get_transactions, merge_will, on_close, menubar wiring).
|
lists.py, calendar.py, theme.py, window_utils.py, ...
|
||||||
plugin.py <- Electrum @hooks entry point (init_qt, tools menu, settings dialog).
|
tests/ <- pytest suite (see run command below).
|
||||||
lists.py, calendar.py, theme.py (status colours), window_utils.py
|
electrum-src/ <- a copy of Electrum source, used ONLY for tests
|
||||||
wallet_util/ <- standalone wallet-inspection helpers, no Qt
|
(PYTHONPATH=electrum-src). NOT shipped in the ZIP.
|
||||||
tests/ <- standalone test scripts (see Section 3).
|
build_zip.py <- builds the shippable ZIP (37 files).
|
||||||
docs/ <- user manual + inheritance-options guide (.md sources).
|
|
||||||
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.
|
||||||
@@ -99,73 +87,46 @@ HANDOFF.md <- this file.
|
|||||||
|
|
||||||
## 3. How to build, test and lint
|
## 3. How to build, test and lint
|
||||||
|
|
||||||
Two separate venvs — using the wrong one is the #1 mistake:
|
Run everything from `/home/user/webapp`.
|
||||||
|
|
||||||
- **Runtime env** (Electrum + PyQt6, has `electrum` importable):
|
|
||||||
`source "$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
|
||||||
source "$BAL_HOME/electrum/env/bin/activate"
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
|
||||||
python3 tests/test_core_heirs.py # core, no Qt needed
|
tests/test_core_*.py tests/test_gui_*.py \
|
||||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need offscreen
|
tests/test_anticipate_past_locktime.py tests/test_anticipate_manual_locktime.py \
|
||||||
|
tests/test_group_b_auto_sign.py tests/test_group_c_settings.py \
|
||||||
|
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
|
||||||
|
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
|
||||||
|
tests/test_group_h_v048.py -q
|
||||||
```
|
```
|
||||||
|
|
||||||
Most core tests run offline (no wallet/network). Some files
|
|
||||||
(`tests/test_group_*.py`, `tests/test_no_willexecutor_karen7.py`,
|
|
||||||
`parallel_ping_test.py`) exercise will-executor/network flows and need the live
|
|
||||||
servers — don't rely on them for quick verification.
|
|
||||||
|
|
||||||
**Current state of the suite: 427 tests collected.** The offline subset passes
|
|
||||||
(414 passed) apart from pre-existing failures that are NOT yours to fix without
|
|
||||||
asking: 13 failures in `tests/test_core_will_invalidate.py` (a `None` fee when a
|
|
||||||
UTXO has no fee value, `bal/core/will.py:482`) and 1 collection error in
|
|
||||||
`tests/test_group_i_basic_checkalive.py` (missing
|
|
||||||
`BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS`).
|
|
||||||
|
|
||||||
**Lint (only NEW errors matter; ignore pre-existing noise):**
|
**Lint (only NEW errors matter; ignore pre-existing noise):**
|
||||||
```bash
|
```bash
|
||||||
./venv/bin/ruff check <files> \
|
ruff check <files> | grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" \
|
||||||
| grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" | grep -vE "F401|F403|F405|F841"
|
| grep -vE "F401|F403|F405|F841"
|
||||||
```
|
```
|
||||||
Ruff is NOT clean repo-wide (hundreds of pre-existing errors in `bal/` and
|
Pre-existing, KNOWN-OK ruff noise: `F401/F403/F405` (star-imports via
|
||||||
`tests/`); do NOT run `--fix` wholesale — just avoid adding new violations.
|
|
||||||
Pre-existing, KNOWN-OK noise: `F401/F403/F405` (star-imports via
|
|
||||||
`from .common import *`) and 2× `F841` (an unused `e` in two `except` blocks).
|
`from .common import *`) and 2× `F841` (an unused `e` in two `except` blocks).
|
||||||
Do NOT "fix" these unless asked — they are intentional / out of scope.
|
Do NOT "fix" these unless asked — they are intentional / out of scope.
|
||||||
|
|
||||||
**Build the ZIP (always clear caches first so zipimport doesn't ship stale .pyc):**
|
**Build the ZIP (always clear caches first so zipimport doesn't ship stale .pyc):**
|
||||||
```bash
|
```bash
|
||||||
find bal -name "__pycache__" -type d -exec rm -rf {} + ; find bal -name "*.pyc" -delete
|
find bal -name "__pycache__" -type d -exec rm -rf {} + ; find bal -name "*.pyc" -delete
|
||||||
python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha256; 36 files)
|
python3 build_zip.py bal-electrum-plugin-vX.Y.Z.zip # produces 37 files
|
||||||
```
|
```
|
||||||
|
|
||||||
**Bump version — ONE file only (single source of truth):**
|
**Bump version — there are FOUR files, keep them in sync:**
|
||||||
```
|
```
|
||||||
|
bal/core/plugin_base.py -> __version__ = "X.Y.Z" # AUTOMATICALLY GENERATED DO NOT EDIT
|
||||||
|
bal/__init__.py -> __version__ = "X.Y.Z"
|
||||||
|
bal/VERSION -> X.Y.Z
|
||||||
bal/manifest.json -> "version": "X.Y.Z",
|
bal/manifest.json -> "version": "X.Y.Z",
|
||||||
```
|
```
|
||||||
The code reads this at runtime via `get_version()` in `bal/core/plugin_base.py` (exposed as the `BalPlugin.version` property), so there is nothing else to keep in sync. There is no longer a `bal/VERSION` file nor a hardcoded `__version__`.
|
|
||||||
|
|
||||||
**IMPORTANT for the owner when testing:** after installing a ZIP, the owner
|
**IMPORTANT for the owner when testing:** after installing a ZIP, the owner
|
||||||
must **fully restart Electrum** (not just reload the plugin) — Electrum's
|
must **fully restart Electrum** (not just reload the plugin) — Electrum's
|
||||||
`zipimport` caches modules, so a partial reload runs stale code.
|
`zipimport` caches modules, so a partial reload runs stale code.
|
||||||
|
|
||||||
**Automated release:** use `./make-release.sh` to run the full release flow
|
|
||||||
(tests, lint, build, GPG sign, SHA-256, Electrum test pause, Gitea release).
|
|
||||||
See Section 5 for details.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Key technical knowledge (hard-won — saves you hours)
|
## 4. Key technical knowledge (hard-won — saves you hours)
|
||||||
@@ -264,49 +225,25 @@ See Section 5 for details.
|
|||||||
|
|
||||||
## 5. Git / delivery workflow
|
## 5. Git / delivery workflow
|
||||||
|
|
||||||
- **Branch:** work directly on `main` (no PR flow anymore). Push straight to
|
- **Branch:** work on `genspark_ai_developer`. Open PRs into `main`.
|
||||||
`origin/main` (Gitea).
|
|
||||||
- **Commit policy:** ZIP-FIRST — build a test ZIP, let the owner confirm it
|
- **Commit policy:** ZIP-FIRST — build a test ZIP, let the owner confirm it
|
||||||
works, THEN commit. (This differs from "commit after every change"; the owner
|
works, THEN commit. (This differs from "commit after every change"; the owner
|
||||||
explicitly prefers ZIP-first because they manually test each build.)
|
explicitly prefers ZIP-first because they manually test each build.)
|
||||||
- Before pushing: check `git status`/`git diff`, stage only the intended files
|
- Before opening/updating a PR: `git fetch origin main`, rebase, resolve
|
||||||
(never secrets), commit with a concise message, then push to `origin/main`.
|
conflicts preferring remote `main` unless a local change is essential,
|
||||||
|
squash local commits into ONE comprehensive commit, push (force if needed),
|
||||||
|
then create/update the PR and SHARE the PR URL with the owner.
|
||||||
- **ZIPs are NOT committed** (`.gitignore` excludes `*.zip`). They are
|
- **ZIPs are NOT committed** (`.gitignore` excludes `*.zip`). They are
|
||||||
distributed via **Gitea Releases** using `make-release.sh`.
|
distributed via **GitHub Releases** (`gh release create vX.Y.Z file.zip ...`).
|
||||||
- **Release process** (`make-release.sh`):
|
The newest release is the "Latest" and is the owner's convenient download.
|
||||||
1. Version bump in `bal/manifest.json` (single source of truth)
|
- Deliverable ZIPs are ALSO uploaded with the file-wrapper tool so the owner can
|
||||||
2. Clean `__pycache__` and `.pyc` files
|
download them directly from chat.
|
||||||
3. Run full test suite
|
- **Auth note:** if `git push` / `gh` fails with "Invalid username or token",
|
||||||
4. Lint with ruff (skip if not installed)
|
re-run the GitHub environment setup, then retry.
|
||||||
5. Build ZIP via `build_zip.py` (deterministic order, SHA-256, manifest check)
|
- PR history for this line of work: **#13** (v0.4.7), **#14** (docs/DUST section +
|
||||||
6. GPG sign: `.asc` (armor) + `.sig` (binary) with key `A847D004DB91610711CA6A0DFE756706E833E0D1`
|
translation), **#15** (v0.4.8). All merged into `main`.
|
||||||
7. Export public key as `svatantrya.asc`
|
- Releases: latest is **v0.4.8** (asset `bal-electrum-plugin-v0.4.8.zip`);
|
||||||
8. SHA-256 checksum
|
v0.4.7 kept in history.
|
||||||
9. Interactive pause for Electrum testing (ZIP-FIRST policy)
|
|
||||||
10. Create Gitea tag, push, create release, upload 5 assets (ZIP + .asc + .sig + .sha256 + svatantrya.asc)
|
|
||||||
- **Usage:**
|
|
||||||
```bash
|
|
||||||
./make-release.sh # read version from bal/manifest.json
|
|
||||||
./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
|
|
||||||
```
|
|
||||||
- **Release assets** (5 files):
|
|
||||||
- `bal_vX.Y.Z.zip` — the plugin
|
|
||||||
- `bal_vX.Y.Z.zip.asc` — GPG signature (armor)
|
|
||||||
- `bal_vX.Y.Z.zip.sig` — GPG signature (binary)
|
|
||||||
- `bal_vX.Y.Z.zip.sha256` — SHA-256 checksum
|
|
||||||
- `svatantrya.asc` — signing public key
|
|
||||||
- **GPG verification instructions** (included in release body):
|
|
||||||
```bash
|
|
||||||
gpg --fetch-key https://bitcoin-after.life/svatantrya.asc
|
|
||||||
gpg --verify bal_vX.Y.Z.zip.asc bal_vX.Y.Z.zip
|
|
||||||
```
|
|
||||||
- **Auth note:** if `git push` or Gitea API fails with "invalid credentials",
|
|
||||||
update `GITEA_TOKEN` env var or `~/.git-credentials`, then retry.
|
|
||||||
- Older PR history (pre-`main` direct workflow): **#13** (v0.4.7), **#14**
|
|
||||||
(docs/DUST section + translation), **#15** (v0.4.8), **#4** (v0.6.1 —
|
|
||||||
manifest.json version); all merged into `main`.
|
|
||||||
- Releases: latest is **v0.7.0**; v0.6.1, v0.6.0 and v0.5.18 before it; the older
|
|
||||||
v0.2.x line is kept in history.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -335,31 +272,6 @@ See Section 5 for details.
|
|||||||
(#7b) "Balance is too low… Skipped" recoloured ORANGE + space fix; Reset button
|
(#7b) "Balance is too low… Skipped" recoloured ORANGE + space fix; Reset button
|
||||||
renamed "Reset to Default Setting". 8 new tests (`test_group_h_v048.py`).
|
renamed "Reset to Default Setting". 8 new tests (`test_group_h_v048.py`).
|
||||||
266 tests pass.
|
266 tests pass.
|
||||||
- **v0.5.1 — v0.5.10** — BASIC/ADVANCED ("user type") mode work: Windows
|
|
||||||
settings-dialog flicker fix; Check Alive shown read-only in BASIC; BASIC
|
|
||||||
builds the will against "now" (`date_to_check = now()`, Check Alive fully
|
|
||||||
ignored); ADVANCED defaults to RAW (1y/30d); consistent Raw/Date default per
|
|
||||||
mode; Check Alive red-highlight fixes; clearer "could not build the will"
|
|
||||||
message; CHECK no longer resets a manual Date/RAW choice.
|
|
||||||
- **v0.5.11** — Electrum **4.8.0** compatibility (the `json_db.register_dict`
|
|
||||||
DB-registration API was removed in 4.8; the plugin now supports 4.7.2 and
|
|
||||||
4.8.0).
|
|
||||||
- **v0.5.12 — v0.5.18** — Check Alive soft-red highlight removed; short Tor
|
|
||||||
(.onion) will-executor URLs; KeyError fix on .onion executor actions; skip
|
|
||||||
.onion executors from download when Electrum is not on Tor; crash fix on a
|
|
||||||
non-dict welist response; clearer message when the list download
|
|
||||||
fails/times out over Tor.
|
|
||||||
- **v0.6.0** — version bump for the official repository release.
|
|
||||||
- **v0.6.1** — version read from `bal/manifest.json` (single source of truth);
|
|
||||||
`bal/VERSION` file removed.
|
|
||||||
- **#47 / #48 (post-v0.6.1)** — `is_selected`/`is_valid` fee bounds (extremes
|
|
||||||
allowed) and the `merge_will` missing-`date_to_check` crash fix (see
|
|
||||||
CHANGELOG).
|
|
||||||
- **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
|
||||||
@@ -380,98 +292,13 @@ See Section 5 for details.
|
|||||||
## 7. How to resume (checklist for the next AI)
|
## 7. How to resume (checklist for the next AI)
|
||||||
|
|
||||||
1. Read this file, then `CHANGELOG.md` (last entries) and `.agent_memory_tasks.md`.
|
1. Read this file, then `CHANGELOG.md` (last entries) and `.agent_memory_tasks.md`.
|
||||||
2. Confirm the environment: `git status`, current branch, and the `"version"` field of `bal/manifest.json`.
|
2. Confirm the environment: `git status`, current branch, `bal/VERSION`.
|
||||||
3. Run the offline test files (Section 3) — expect the offline subset to pass
|
3. Run the full test suite (Section 3) — expect all green (266 as of v0.4.8).
|
||||||
(414 passed; the 13 pre-existing failures in `test_core_will_invalidate.py`
|
|
||||||
and the 1 collection error in `test_group_i_basic_checkalive.py` are NOT
|
|
||||||
yours to fix without asking).
|
|
||||||
4. Talk to the owner in **Italian**, write everything else in **English**.
|
4. Talk to the owner in **Italian**, write everything else in **English**.
|
||||||
5. For any change: present a PLAN, wait for "OK" (R4), then implement, test,
|
5. For any change: present a PLAN, wait for "OK" (R4), then implement, test,
|
||||||
build a ZIP, let the owner test, and only commit after explicit confirmation.
|
build a ZIP, let the owner test, and only commit after explicit confirmation.
|
||||||
6. Keep credit usage low: summarize, don't paste big code blocks; batch work.
|
6. Keep credit usage low: summarize, don't paste big code blocks; batch work.
|
||||||
7. When the owner confirms a ZIP works: commit (ZIP-first) directly on `main`,
|
7. When the owner confirms a ZIP works: commit (ZIP-first), sync with `main`,
|
||||||
push to `origin/main`, then run `./make-release.sh` to create the Gitea
|
squash to one commit, push, open a PR, merge it, then create/refresh a GitHub
|
||||||
**Release** with the ZIP + signatures attached (it becomes the owner's
|
**Release** with the ZIP attached (it becomes the owner's "Latest" download).
|
||||||
"Latest" download). Always give the owner the Release URL.
|
Always give the owner the PR URL and the Release URL.
|
||||||
|
|
||||||
### 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).
|
|
||||||
|
|
||||||
### In progress: animated-QR interop (BC-UR v1/v2, BBQR)
|
|
||||||
|
|
||||||
- `bal/core/animated_qr.py` implements stdlib-only codecs for **BC-UR v1**
|
|
||||||
(BC32 + SHA-256 digest; the bech32_bis checksum variant per
|
|
||||||
BCR-2020-004/005), **BC-UR v2** (CBOR part structure, bytewords-minimal,
|
|
||||||
CRC-32, xoshiro256-based fountain with alias-sampled mixing) and **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.
|
|
||||||
- Current status as of this session: reference parity, GUI, and tests done;
|
|
||||||
not yet committed.
|
|
||||||
- **BC32/bytewords/codec parity:** BC32 reproduces the BCR-2020-004/005
|
|
||||||
test vectors (`Hello, world`, `Hello world`, the long seed vector);
|
|
||||||
bytewords-minimal round-trips with CRC rejection; UR v2 part encode +
|
|
||||||
decode is byte-exact against the reference C++ bc-ur encoder for a
|
|
||||||
single part, seq_len=2 (12 frames) and seq_len=7 (3 sampled mixes),
|
|
||||||
validating CBOR framing, bytewords, alias+ary-threshold sampling,
|
|
||||||
xoshiro256** and the XOR mix.
|
|
||||||
- **Sessions:** UR v2 single-part (no seq header), out-of-order frames,
|
|
||||||
duplicate drops, solve with a missing pure fragment (a second redundant
|
|
||||||
mixed wave is emitted by `ur2_frames`), UR v1 single-part
|
|
||||||
(digest-less `ur:bytes/<bc32>` accepted) and multipart, BBQR full-frame
|
|
||||||
decode in any order for Z/2/H encodings.
|
|
||||||
- **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` gained a Format selector (BAL QR default,
|
|
||||||
BC-UR v1, BC-UR v2, BBQR) reusing the QR-size presets; the importer now
|
|
||||||
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 across formats.
|
|
||||||
- **Verification:** `tests/test_core_animated_qr.py` (32 tests incl. the
|
|
||||||
C++-reference parity vectors and BC32 spec vectors) and the extended
|
|
||||||
`tests/test_gui_qr_transfer.py` pass; ruff clean on the new/changed
|
|
||||||
files; pyright 0 errors; smoke test, `build_zip.py` and
|
|
||||||
`external_zip_test.py` green. Only the pre-existing failures remain
|
|
||||||
(`test_bt_to_date_*`, fee-exceeds-balance, karen7 pollution).
|
|
||||||
- **Any remaining work:** manual on-device walkthrough of the QR path with
|
|
||||||
the new formats; optionally validate against third-party libraries
|
|
||||||
(`ur`, `bbqr`) once available; add the docstrings/branch notes already
|
|
||||||
captured in `ag1.md`/`ag2.md` context where needed.
|
|
||||||
|
|
||||||
**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).
|
|
||||||
|
|||||||
@@ -1,415 +0,0 @@
|
|||||||
# 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`, `check_alive_expired` (riferimento temporale unico per ogni check).
|
|
||||||
- `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 `check_alive_expired`. |
|
|
||||||
| `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.
|
|
||||||
@@ -1,499 +0,0 @@
|
|||||||
# 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.
|
|
||||||
124
README.md
124
README.md
@@ -5,11 +5,10 @@ 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 **refactored and extended** version of the original
|
This repository contains a **behavior-preserving refactor** of the original
|
||||||
plugin. The logic was reorganized to cleanly separate **business logic** from the
|
plugin. The logic was kept byte-identical wherever possible; only the file
|
||||||
**PyQt GUI**, and new features have been added including a headless CLI,
|
layout was reorganized to cleanly separate **business logic** from the
|
||||||
auto-rebuild on new transactions, OP_RETURN heirs, and configurable calendar
|
**PyQt GUI**.
|
||||||
reminders.
|
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
@@ -17,22 +16,12 @@ reminders.
|
|||||||
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 BAL QR will-transfer wire format / chunk scheduler
|
|
||||||
│ ├── animated_qr.py BC-UR v1/v2 + BBQR codecs (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
|
||||||
@@ -41,28 +30,18 @@ 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 README.md
|
├── icons/ wallet_util/ LICENSE VERSION README.md
|
||||||
build_zip.py builds a clean, zipimport-friendly distribution zip
|
build_zip.py builds a clean, zipimport-friendly distribution zip
|
||||||
tests/ smoke + external-zip regression tests
|
tests/ smoke + external-zip regression tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- **Electrum 4.7.2 or 4.8.0** — the plugin detects which wallet-DB
|
- **Electrum 4.7.2** — the last stable release exposing `json_db.register_dict`,
|
||||||
registration API is available (`json_db.register_dict` on 4.7.2,
|
which this plugin relies on. Newer versions removed it.
|
||||||
`stored_dict.register_name` on 4.8.0) and adapts automatically.
|
|
||||||
- **PyQt6** (bundled with the Electrum desktop GUI).
|
- **PyQt6** (bundled with the Electrum desktop GUI).
|
||||||
|
|
||||||
## Wallet compatibility
|
|
||||||
|
|
||||||
BAL currently supports **standard (single-signature) wallets** and
|
|
||||||
**hardware wallets** supported by Electrum. **Multisig wallets** and
|
|
||||||
**Electrum TrustedCoin (2FA) wallets** are **not yet supported** — see
|
|
||||||
[`COMPATIBILITY.md`](COMPATIBILITY.md) for the full compatibility matrix and
|
|
||||||
current status.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Build the distribution archive
|
### Build the distribution archive
|
||||||
@@ -89,67 +68,6 @@ 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 default **BAL QR** format already
|
|
||||||
compressed whenever that is smaller (best-of zlib, flag per frame); 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 BAL QR wire-format spec.
|
|
||||||
|
|
||||||
### Animated-QR formats (interop)
|
|
||||||
|
|
||||||
BAL QR is the default export format, but the export page's **Format** selector
|
|
||||||
also emits **BC-UR v1** (`ur:bytes`, BC32 + SHA-256), **BC-UR v2**
|
|
||||||
(`ur:bytes`, CBOR fountain codes) and **BBQR** (`B$…`, Coinkite, used by
|
|
||||||
BitKit) animated-QR sequences. The importer auto-detects the format of each
|
|
||||||
code it sees, so any of the four formats can be imported on a BAL device, and
|
|
||||||
a BAL export can be imported by any tool that understands these standards.
|
|
||||||
UR v2 imports tolerate out-of-order and duplicate frames (fountain decoding);
|
|
||||||
BBQR frames may arrive in any order. Rotation/redundancy caps and the
|
|
||||||
32 MB message limit (zlib-bomb guard) bound 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
|
||||||
@@ -158,17 +76,12 @@ to broadcast it (they collect fees). Because the locktime is baked into the
|
|||||||
signed transaction, simply changing the delivery time later is **not enough**:
|
signed transaction, simply changing the delivery time later is **not enough**:
|
||||||
the old, already-signed transaction keeps living on the will-executors.
|
the old, already-signed transaction keeps living on the will-executors.
|
||||||
|
|
||||||
The plugin handles the cases as follows (triggered when you press
|
The plugin handles the two cases as follows (triggered when you press
|
||||||
**Prepare** on the **WILL** tab):
|
**Tools → Prepare**):
|
||||||
|
|
||||||
* **Anticipate** (new delivery time *earlier* than the signed locktime, still
|
* **Anticipate** (new delivery time *earlier* than the signed locktime): the
|
||||||
in the future): a plain **rebuild** — the transactions are re-created with
|
will is treated as expired and you are asked to **invalidate** the old
|
||||||
the new, earlier locktime. **No on-chain invalidation and no Bitcoin fee**,
|
transaction on-chain, then rebuild.
|
||||||
even if the will was already signed/sent: moving the date earlier only makes
|
|
||||||
the inheritance available *sooner*, so there is no early-execution risk.
|
|
||||||
* **Expire** (new delivery time now in the **past**): the will is genuinely
|
|
||||||
expired and you are asked to **invalidate** the old transaction on-chain,
|
|
||||||
then rebuild.
|
|
||||||
* **Postpone** (new delivery time *later* than the signed locktime) on a will
|
* **Postpone** (new delivery time *later* than the signed locktime) on a will
|
||||||
that was already **signed and/or pushed**: the previously committed coins
|
that was already **signed and/or pushed**: the previously committed coins
|
||||||
must be invalidated on-chain **first**, otherwise a will-executor could
|
must be invalidated on-chain **first**, otherwise a will-executor could
|
||||||
@@ -199,15 +112,14 @@ state.
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Run the tests with the **runtime environment** active (see `HANDOFF.md` §3 for
|
|
||||||
the two venvs and how to activate them):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# imports + behavior
|
# imports + behavior
|
||||||
QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py electrum.plugins.bal
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
|
||||||
|
python3 tests/smoke_test.py electrum.plugins.bal
|
||||||
|
|
||||||
# external-zip loading regression (run after build_zip.py)
|
# external-zip loading regression
|
||||||
QT_QPA_PLATFORM=offscreen python3 tests/external_zip_test.py bal-electrum-plugin.zip
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
|
||||||
|
python3 tests/external_zip_test.py bal-electrum-plugin.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
## ⚠️ Safety
|
## ⚠️ Safety
|
||||||
|
|||||||
10
android/.gitignore
vendored
10
android/.gitignore
vendored
@@ -1,10 +0,0 @@
|
|||||||
.gradle/
|
|
||||||
build/
|
|
||||||
local.properties
|
|
||||||
.idea/
|
|
||||||
*.apk
|
|
||||||
*.aab
|
|
||||||
captures/
|
|
||||||
.externalNativeBuild/
|
|
||||||
.cxx/
|
|
||||||
*.hprof
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
# BAL Reader (Android)
|
|
||||||
|
|
||||||
A minimal Android app that reads a Bitcoin will exported by the
|
|
||||||
[BAL Electrum plugin](https://bitcoin-after.life) directly from your screen,
|
|
||||||
then lets you view, copy, share, or save the recovered data. **Reader only** —
|
|
||||||
it never signs or broadcasts.
|
|
||||||
|
|
||||||
It decodes **all four** transfer formats the plugin can export, auto-detecting
|
|
||||||
the format from the first frame:
|
|
||||||
|
|
||||||
- **BAL QR** (the plugin's default), single- and multi-frame, plain and
|
|
||||||
zlib-compressed, compact `BAL1` header (legacy `BALQR1` frames are still
|
|
||||||
accepted on import);
|
|
||||||
- **BC-UR v1** (single-part and `NofM` multipart);
|
|
||||||
- **BC-UR v2** (single-part and XOR-fountain multipart — it tolerates dropped,
|
|
||||||
repeated, and out-of-order frames);
|
|
||||||
- **BBQR** (`Z`/`H`/`2` encodings).
|
|
||||||
|
|
||||||
Both payload kinds are handled: the **whole-will JSON** and the plain
|
|
||||||
**transaction-hex list**.
|
|
||||||
|
|
||||||
## How decoding works
|
|
||||||
|
|
||||||
The app does not reimplement the QR formats. It bundles the plugin's own
|
|
||||||
codec modules — `bal/core/__init__.py`, `bal/core/animated_qr.py`,
|
|
||||||
`bal/core/qrtransfer.py` — and runs them verbatim through **Chaquopy** (CPython
|
|
||||||
on Android). Kotlin is only camera glue and UI:
|
|
||||||
|
|
||||||
```
|
|
||||||
Camera (CameraX) → ML Kit QR detection (on-device, no API key)
|
|
||||||
→ BalDecoder.add(text) → bal.core.animated_qr.AnimatedQrSession
|
|
||||||
→ when done → BalDecoder.finish()
|
|
||||||
= session.resolve() → qrtransfer.decode_transfer()
|
|
||||||
→ balreader.payload.decode_will_payload()
|
|
||||||
→ ResultActivity: view / copy / share / save
|
|
||||||
```
|
|
||||||
|
|
||||||
The decode tail mirrors the plugin's import dialog function-for-function, and
|
|
||||||
`android/test_chain/verify_chain.py` proves the bundled code decodes every
|
|
||||||
format the way the desktop import does (including scrambled, duplicated, and
|
|
||||||
missing frames).
|
|
||||||
|
|
||||||
## Repository layout
|
|
||||||
|
|
||||||
```
|
|
||||||
android/
|
|
||||||
├── app/src/main/
|
|
||||||
│ ├── AndroidManifest.xml
|
|
||||||
│ ├── java/life/after/bitcoin/
|
|
||||||
│ │ ├── BalDecoder.kt Chaquopy bridge over the bundled codecs
|
|
||||||
│ │ ├── MainActivity.kt camera + ML Kit scan loop + progress
|
|
||||||
│ │ └── ResultActivity.kt viewer (copy / share / save)
|
|
||||||
│ └── python/ bundled Python (regenerate, do not hand-edit)
|
|
||||||
│ ├── bal/core/ SYNCED COPY of the plugin codecs
|
|
||||||
│ └── balreader/payload.py verbatim copy of dialogs.decode_will_payload
|
|
||||||
├── scripts/
|
|
||||||
│ ├── sync_codecs.py re-copy + verify the bundled codecs
|
|
||||||
│ └── build_apk.py resync codecs, run Gradle, print APK + sha256
|
|
||||||
└── test_chain/verify_chain.py decode-chain simulation for all formats
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
You need Android Studio (Jellyfish or newer), JDK 17, an Android SDK with
|
|
||||||
platform 35, and a network connection for the first Gradle sync.
|
|
||||||
|
|
||||||
1. Open this `android/` folder in Android Studio and let it sync (it will
|
|
||||||
fetch the Gradle wrapper 8.14, AGP 8.10.0, Kotlin 2.0.21, Chaquopy 17.0.0,
|
|
||||||
CameraX 1.3.4, and ML Kit).
|
|
||||||
2. Connect a phone (API 24+) or start an emulator and press **Run**.
|
|
||||||
3. Grant the camera permission when asked.
|
|
||||||
|
|
||||||
Alternatively, from the command line (from the repository root):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 android/scripts/build_apk.py # debug APK + sha256
|
|
||||||
python3 android/scripts/build_apk.py --release # (unsigned) release APK
|
|
||||||
```
|
|
||||||
|
|
||||||
The script re-synchronises the bundled codec modules first (so the APK always
|
|
||||||
carries the current `bal/core` sources), runs `./gradlew`, and prints the APK
|
|
||||||
path, size and sha256. Flags: `--no-sync` (skip the re-sync), `--offline`
|
|
||||||
(Gradle without downloads), `--clean`, `--verbose`.
|
|
||||||
|
|
||||||
Equivalent raw Gradle call:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd android
|
|
||||||
./gradlew assembleDebug # APK: android/app/build/outputs/apk/debug/app-debug.apk
|
|
||||||
```
|
|
||||||
|
|
||||||
### If the Gradle wrapper jar is missing
|
|
||||||
|
|
||||||
`gradle/wrapper/gradle-wrapper.jar` is committed so `./gradlew` works out of
|
|
||||||
the box. If it is ever absent, Android Studio regenerates it on the first
|
|
||||||
sync; no manual steps needed.
|
|
||||||
|
|
||||||
## Use
|
|
||||||
|
|
||||||
1. In Electrum + BAL, open the will's **export** dialog.
|
|
||||||
2. Pick a format — start with the default **BAL QR**, then try **BC-UR v1**,
|
|
||||||
**BC-UR v2**, and **BBQR**.
|
|
||||||
3. Make sure the wording toggle shows a payload (business logic), then display
|
|
||||||
the animated QR and keep it on screen.
|
|
||||||
4. Point the phone at the screen. The header shows the detected format and
|
|
||||||
`received / total`; scanning stops automatically when the transfer is
|
|
||||||
complete.
|
|
||||||
5. On the result screen: **Copy** the raw transfer, **Share** it, **Save** it
|
|
||||||
as `will.json` (whole will) or `will_tx.txt` (transaction list), or press
|
|
||||||
**Scan another**.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- Keep the phone still and the whole QR inside the frame (the codec dedups
|
|
||||||
repeated frames, so a slow capture is fine).
|
|
||||||
- If the camera glares off the screen, reduce brightness or tilt slightly.
|
|
||||||
- If scanning jumps between exports, the app detects the format switch,
|
|
||||||
resets, and asks you to let it re-scan.
|
|
||||||
|
|
||||||
## Keeping the bundled code in sync with the plugin
|
|
||||||
|
|
||||||
The codecs under `app/src/main/python/bal/core/` are **committed copies** for
|
|
||||||
deterministic builds, but they must stay identical to the plugin. Re-run this
|
|
||||||
after changing `bal/core/animated_qr.py` or `bal/core/qrtransfer.py` (and
|
|
||||||
after any change to `decode_will_payload` in `bal/gui/qt/dialogs.py`, which
|
|
||||||
mirrors `balreader/payload.py`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 android/scripts/sync_codecs.py # copy
|
|
||||||
python3 android/scripts/sync_codecs.py --check # verify only (CI-friendly)
|
|
||||||
python3 android/test_chain/verify_chain.py # full decode-chain regression
|
|
||||||
```
|
|
||||||
|
|
||||||
`verify_chain.py` fails if the app's `balreader/payload.py` ever drifts from
|
|
||||||
the plugin's `decode_will_payload` (AST identity + result parity).
|
|
||||||
|
|
||||||
## Version pins (see `PLAN_ANDROID_READER.md`)
|
|
||||||
|
|
||||||
| Item | Version |
|
|
||||||
|---|---|
|
|
||||||
| AGP | 8.10.0 |
|
|
||||||
| Gradle | 8.14 (wrapper) |
|
|
||||||
| Kotlin | 2.0.21 |
|
|
||||||
| Chaquopy | 17.0.0 (Python 3.12) |
|
|
||||||
| compile / target / min SDK | 35 / 35 / 24 |
|
|
||||||
| CameraX | 1.3.4 |
|
|
||||||
| ML Kit barcode-scanning | 17.3.0 |
|
|
||||||
| JDK | 17 |
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT. The bundled Python codec files inherit the plugin's MIT license
|
|
||||||
(`bal/LICENSE`); see `app/src/main/python/bal/` for attribution.
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("com.android.application")
|
|
||||||
id("org.jetbrains.kotlin.android")
|
|
||||||
id("com.chaquo.python")
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "life.after.bitcoin"
|
|
||||||
compileSdk = 35
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "life.after.bitcoin"
|
|
||||||
minSdk = 24
|
|
||||||
targetSdk = 35
|
|
||||||
versionCode = 1
|
|
||||||
versionName = "0.1.0"
|
|
||||||
|
|
||||||
// Chaquopy requires explicit ABI filters. Python 3.12 ships only for
|
|
||||||
// 64-bit ABIs: phones (arm64-v8a) + the common emulator image (x86_64).
|
|
||||||
ndk {
|
|
||||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
isMinifyEnabled = false
|
|
||||||
proguardFiles(
|
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
||||||
"proguard-rules.pro",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
buildFeatures {
|
|
||||||
viewBinding = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
chaquopy {
|
|
||||||
defaultConfig {
|
|
||||||
version = "3.12"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation("androidx.core:core-ktx:1.13.1")
|
|
||||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
|
||||||
implementation("androidx.activity:activity-ktx:1.9.3")
|
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
|
||||||
|
|
||||||
// CameraX
|
|
||||||
implementation("androidx.camera:camera-core:1.3.4")
|
|
||||||
implementation("androidx.camera:camera-camera2:1.3.4")
|
|
||||||
implementation("androidx.camera:camera-lifecycle:1.3.4")
|
|
||||||
implementation("androidx.camera:camera-view:1.3.4")
|
|
||||||
|
|
||||||
// ML Kit on-device barcode scanning (QR only, no API key)
|
|
||||||
implementation("com.google.mlkit:barcode-scanning:17.3.0")
|
|
||||||
}
|
|
||||||
5
android/app/proguard-rules.pro
vendored
5
android/app/proguard-rules.pro
vendored
@@ -1,5 +0,0 @@
|
|||||||
# Chaquopy Python runtime.
|
|
||||||
-keep class com.chaquo.python.** { *; }
|
|
||||||
|
|
||||||
# ML Kit barcode scanning.
|
|
||||||
-keep class com.google.mlkit.** { *; }
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<uses-feature
|
|
||||||
android:name="android.hardware.camera"
|
|
||||||
android:required="true" />
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:allowBackup="true"
|
|
||||||
android:icon="@drawable/ic_launcher"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:theme="@style/Theme.BalReader">
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:screenOrientation="portrait">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".ResultActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:parentActivityName=".MainActivity" />
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
package life.after.bitcoin
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.chaquo.python.PyObject
|
|
||||||
import com.chaquo.python.PyException
|
|
||||||
import com.chaquo.python.Python
|
|
||||||
import com.chaquo.python.android.AndroidPlatform
|
|
||||||
import org.json.JSONException
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chaquopy bridge over the plugin's animated-QR codecs
|
|
||||||
* (``bal.core.animated_qr``, ``bal.core.qrtransfer``, bundled verbatim under
|
|
||||||
* ``app/src/main/python``).
|
|
||||||
*
|
|
||||||
* The decode tail mirrors the plugin's import dialog exactly, and runs
|
|
||||||
* entirely inside Python (``balreader.bridge.finish``) so no container
|
|
||||||
* conversion happens across the bridge:
|
|
||||||
*
|
|
||||||
* session.resolve() -> qrtransfer.decode_transfer() -> decode_will_payload()
|
|
||||||
*
|
|
||||||
* Kotlin only feeds frames, reads progress, and renders the JSON the bridge
|
|
||||||
* returns.
|
|
||||||
*/
|
|
||||||
class BalDecoder(private val context: Context) {
|
|
||||||
|
|
||||||
/** Outcome of feeding one scanned frame to the session. */
|
|
||||||
enum class AddResult {
|
|
||||||
/** A new frame was accepted. */
|
|
||||||
OK,
|
|
||||||
|
|
||||||
/** The frame was already present (duplicate); ignore. */
|
|
||||||
DUP,
|
|
||||||
|
|
||||||
/** The frame was not a supported QR transfer; ignore. */
|
|
||||||
GARBAGE,
|
|
||||||
|
|
||||||
/** The QR switched to a different transfer; caller should rescan. */
|
|
||||||
CONFLICT,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fully decoded transfer, mirroring the plugin's import tail. */
|
|
||||||
data class DecodedResult(
|
|
||||||
val kind: String, // "will", "txs" or "error"
|
|
||||||
val payload: String, // raw transfer text (JSON or joined tx hexes)
|
|
||||||
val parts: List<String> // [whole-will JSON] or [tx hex strings]
|
|
||||||
)
|
|
||||||
|
|
||||||
private val python: Python by lazy {
|
|
||||||
if (!Python.isStarted()) {
|
|
||||||
Python.start(AndroidPlatform(context))
|
|
||||||
}
|
|
||||||
Python.getInstance()
|
|
||||||
}
|
|
||||||
private val animatedQr by lazy { python.getModule("bal.core.animated_qr") }
|
|
||||||
private val bridge by lazy { python.getModule("balreader.bridge") }
|
|
||||||
|
|
||||||
private var session: PyObject? = null
|
|
||||||
|
|
||||||
/** Start a fresh receive session (clears any accumulated frames). */
|
|
||||||
fun reset() {
|
|
||||||
session = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sessionOrCreate(): PyObject {
|
|
||||||
val current = session
|
|
||||||
if (current != null) {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
return animatedQr.callAttr("AnimatedQrSession").also { session = it }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Feed one scanned frame string; see [AddResult] for semantics. */
|
|
||||||
fun add(text: String): AddResult {
|
|
||||||
return try {
|
|
||||||
when (sessionOrCreate().callAttr("add_part", text).toString()) {
|
|
||||||
"dup" -> AddResult.DUP
|
|
||||||
else -> AddResult.OK
|
|
||||||
}
|
|
||||||
} catch (e: PyException) {
|
|
||||||
val msg = e.message ?: ""
|
|
||||||
// TransferConflictError: "Switched QR format mid-import (.. -> ..)".
|
|
||||||
if (msg.contains("Switched QR format")) {
|
|
||||||
AddResult.CONFLICT
|
|
||||||
} else {
|
|
||||||
AddResult.GARBAGE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The detected wire format ("balqr"/"ur1"/"ur2"/"bbqr"), or null. */
|
|
||||||
val format: String?
|
|
||||||
get() = runCatching {
|
|
||||||
session?.get("format")?.toString()?.takeIf { it != "None" }
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
/** Number of distinct frames accepted. */
|
|
||||||
val received: Int
|
|
||||||
get() = session?.get("received")?.toInt() ?: 0
|
|
||||||
|
|
||||||
/** Total frames expected for the current transfer (0 until known). */
|
|
||||||
val total: Int
|
|
||||||
get() = session?.get("total")?.toInt() ?: 0
|
|
||||||
|
|
||||||
/** True once the whole transfer has been captured. */
|
|
||||||
val done: Boolean
|
|
||||||
get() = session?.get("done")?.toBoolean() ?: false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the completed session into a [DecodedResult]. The decoding runs
|
|
||||||
* in Python (``balreader.bridge.finish``) using the exact same three steps
|
|
||||||
* as the plugin's import dialog.
|
|
||||||
*/
|
|
||||||
fun finish(): DecodedResult {
|
|
||||||
val jsonText = bridge.callAttr("finish", sessionOrCreate()).toString()
|
|
||||||
return try {
|
|
||||||
val obj = JSONObject(jsonText)
|
|
||||||
val partsArray = obj.getJSONArray("parts")
|
|
||||||
val parts = (0 until partsArray.length()).map { partsArray.getString(it) }
|
|
||||||
DecodedResult(
|
|
||||||
kind = obj.getString("kind"),
|
|
||||||
payload = obj.getString("payload"),
|
|
||||||
parts = parts,
|
|
||||||
)
|
|
||||||
} catch (e: JSONException) {
|
|
||||||
DecodedResult(kind = "error", payload = jsonText, parts = emptyList())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package life.after.bitcoin
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Paint
|
|
||||||
import android.graphics.RectF
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import android.view.View
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Horizontal progress bar showing how many QR frames of the current transfer
|
|
||||||
* have been captured (`received / total`), with a filled mint segment
|
|
||||||
* proportional to the fraction. A thin decorative strip over the camera
|
|
||||||
* preview; the exact count stays in the header's "n / N" label.
|
|
||||||
*/
|
|
||||||
class FrameProgressBar @JvmOverloads constructor(
|
|
||||||
context: Context,
|
|
||||||
attrs: AttributeSet? = null,
|
|
||||||
) : View(context, attrs) {
|
|
||||||
|
|
||||||
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
color = ContextCompat.getColor(context, R.color.frame_fill)
|
|
||||||
}
|
|
||||||
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
color = ContextCompat.getColor(context, R.color.frame_track)
|
|
||||||
}
|
|
||||||
private val trackRect = RectF()
|
|
||||||
private val fillRect = RectF()
|
|
||||||
private val cornerRadius = dp(3f)
|
|
||||||
|
|
||||||
private var fraction = 0f
|
|
||||||
|
|
||||||
/** Reset to an empty bar. */
|
|
||||||
fun reset() {
|
|
||||||
fraction = 0f
|
|
||||||
invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the fill to [received] out of [total] frames captured.
|
|
||||||
* A zero/unknown total clears the bar.
|
|
||||||
*/
|
|
||||||
fun set(total: Int, received: Int) {
|
|
||||||
fraction = if (total > 0) {
|
|
||||||
received.toFloat() / total.toFloat()
|
|
||||||
} else {
|
|
||||||
0f
|
|
||||||
}
|
|
||||||
invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun dp(value: Float): Float =
|
|
||||||
resources.displayMetrics.density * value
|
|
||||||
|
|
||||||
override fun onDraw(canvas: Canvas) {
|
|
||||||
super.onDraw(canvas)
|
|
||||||
if (width <= 0 || height <= 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
trackRect.set(0f, 0f, width.toFloat(), height.toFloat())
|
|
||||||
canvas.drawRoundRect(trackRect, cornerRadius, cornerRadius, trackPaint)
|
|
||||||
if (fraction <= 0f) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val fillWidth = width * fraction.coerceIn(0f, 1f)
|
|
||||||
fillRect.set(0f, 0f, fillWidth, height.toFloat())
|
|
||||||
canvas.drawRoundRect(fillRect, cornerRadius, cornerRadius, fillPaint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
package life.after.bitcoin
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.util.Log
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.camera.core.CameraSelector
|
|
||||||
import androidx.camera.core.ImageAnalysis
|
|
||||||
import androidx.camera.core.ImageProxy
|
|
||||||
import androidx.camera.core.Preview
|
|
||||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
|
||||||
import com.google.mlkit.vision.barcode.BarcodeScanner
|
|
||||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
|
||||||
import com.google.mlkit.vision.barcode.common.Barcode
|
|
||||||
import com.google.mlkit.vision.common.InputImage
|
|
||||||
import life.after.bitcoin.BalDecoder.AddResult
|
|
||||||
import life.after.bitcoin.databinding.ActivityMainBinding
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
|
||||||
|
|
||||||
private lateinit var binding: ActivityMainBinding
|
|
||||||
private lateinit var decoder: BalDecoder
|
|
||||||
private lateinit var barcodeScanner: BarcodeScanner
|
|
||||||
|
|
||||||
private val analyzerExecutor = Executors.newSingleThreadExecutor()
|
|
||||||
private var finished = false
|
|
||||||
private var cameraBound = false
|
|
||||||
private var lastAnalysisMs = 0L
|
|
||||||
|
|
||||||
private val formatLabels: Map<String, String> by lazy {
|
|
||||||
mapOf(
|
|
||||||
"balqr" to getString(R.string.format_balqr),
|
|
||||||
"ur1" to getString(R.string.format_ur1),
|
|
||||||
"ur2" to getString(R.string.format_ur2),
|
|
||||||
"bbqr" to getString(R.string.format_bbqr),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val requestCameraPermission =
|
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
|
||||||
if (granted) {
|
|
||||||
startCamera()
|
|
||||||
} else {
|
|
||||||
Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
|
||||||
setContentView(binding.root)
|
|
||||||
|
|
||||||
decoder = BalDecoder(applicationContext)
|
|
||||||
barcodeScanner = BarcodeScanning.getClient(
|
|
||||||
BarcodeScannerOptions.Builder()
|
|
||||||
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
|
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
|
||||||
== PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
startCamera()
|
|
||||||
} else {
|
|
||||||
requestCameraPermission.launch(Manifest.permission.CAMERA)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
// Returning from the result screen starts a new scan.
|
|
||||||
if (finished) {
|
|
||||||
finished = false
|
|
||||||
decoder.reset()
|
|
||||||
binding.tvFormat.text = getString(R.string.format_placeholder)
|
|
||||||
binding.tvProgress.text = "0 / 0"
|
|
||||||
binding.tvStatus.setText(R.string.status_waiting)
|
|
||||||
binding.frameBar.reset()
|
|
||||||
}
|
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
|
||||||
== PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
startCamera()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
cameraProvider?.unbindAll()
|
|
||||||
barcodeScanner.close()
|
|
||||||
analyzerExecutor.shutdown()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
private var cameraProvider: ProcessCameraProvider? = null
|
|
||||||
|
|
||||||
private fun startCamera() {
|
|
||||||
if (cameraBound) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val providerFuture = ProcessCameraProvider.getInstance(this)
|
|
||||||
providerFuture.addListener({
|
|
||||||
val provider = providerFuture.get()
|
|
||||||
cameraProvider = provider
|
|
||||||
|
|
||||||
val preview = Preview.Builder().build()
|
|
||||||
preview.setSurfaceProvider(binding.previewView.surfaceProvider)
|
|
||||||
val analysis = ImageAnalysis.Builder()
|
|
||||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
|
||||||
.build()
|
|
||||||
analysis.setAnalyzer(analyzerExecutor) { proxy -> analyze(proxy) }
|
|
||||||
|
|
||||||
try {
|
|
||||||
provider.unbindAll()
|
|
||||||
provider.bindToLifecycle(
|
|
||||||
this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
|
|
||||||
)
|
|
||||||
cameraBound = true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to bind camera", e)
|
|
||||||
}
|
|
||||||
}, ContextCompat.getMainExecutor(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun analyze(proxy: ImageProxy) {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (finished || now - lastAnalysisMs < 100) {
|
|
||||||
proxy.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lastAnalysisMs = now
|
|
||||||
val image = proxy.image
|
|
||||||
if (image == null) {
|
|
||||||
proxy.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val input = InputImage.fromMediaImage(image, proxy.imageInfo.rotationDegrees)
|
|
||||||
barcodeScanner.process(input)
|
|
||||||
.addOnSuccessListener { barcodes ->
|
|
||||||
for (barcode in barcodes) {
|
|
||||||
val value = barcode.rawValue
|
|
||||||
if (!value.isNullOrEmpty()) {
|
|
||||||
handleFrame(value)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.addOnCompleteListener { proxy.close() }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Frame analysis failure", e)
|
|
||||||
proxy.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleFrame(value: String) {
|
|
||||||
if (finished) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
when (decoder.add(value)) {
|
|
||||||
AddResult.OK -> {
|
|
||||||
Log.i(TAG, "frame ok fmt=${decoder.format} rcvd=${decoder.received}/${decoder.total} done=${decoder.done}")
|
|
||||||
binding.tvFormat.text = decoder.format?.let { formatLabels[it] }
|
|
||||||
?: getString(R.string.format_placeholder)
|
|
||||||
binding.tvProgress.text =
|
|
||||||
getString(R.string.progress_fmt, decoder.received, decoder.total)
|
|
||||||
binding.frameBar.set(decoder.total, decoder.received)
|
|
||||||
if (decoder.done) {
|
|
||||||
finishScan()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AddResult.DUP -> Log.i(TAG, "frame dup")
|
|
||||||
AddResult.GARBAGE -> Log.w(TAG, "frame garbage")
|
|
||||||
AddResult.CONFLICT -> {
|
|
||||||
Log.w(TAG, "format conflict - resetting")
|
|
||||||
decoder.reset()
|
|
||||||
binding.tvFormat.text = getString(R.string.format_placeholder)
|
|
||||||
binding.tvProgress.text = "0 / 0"
|
|
||||||
binding.tvStatus.setText(R.string.conflict_message)
|
|
||||||
binding.frameBar.reset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun finishScan() {
|
|
||||||
if (finished) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
finished = true
|
|
||||||
val result = decoder.finish()
|
|
||||||
Log.i(TAG, "FINISH kind=${result.kind} payload=${result.payload.length}B parts=${result.parts.size}")
|
|
||||||
val intent = Intent(this, ResultActivity::class.java).apply {
|
|
||||||
putExtra(ResultActivity.EXTRA_KIND, result.kind)
|
|
||||||
putExtra(ResultActivity.EXTRA_PAYLOAD, result.payload)
|
|
||||||
putStringArrayListExtra(ResultActivity.EXTRA_PARTS, ArrayList(result.parts))
|
|
||||||
}
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "BalReader"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package life.after.bitcoin
|
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import life.after.bitcoin.databinding.ActivityResultBinding
|
|
||||||
import org.json.JSONException
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
class ResultActivity : AppCompatActivity() {
|
|
||||||
|
|
||||||
private lateinit var binding: ActivityResultBinding
|
|
||||||
private var kind = "txs"
|
|
||||||
private var payload = ""
|
|
||||||
private var parts: List<String> = emptyList()
|
|
||||||
|
|
||||||
private val saveWillPicker =
|
|
||||||
registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri: Uri? ->
|
|
||||||
saveTo(uri)
|
|
||||||
}
|
|
||||||
private val saveTxsPicker =
|
|
||||||
registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri: Uri? ->
|
|
||||||
saveTo(uri)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveTo(uri: Uri?) {
|
|
||||||
if (uri != null) {
|
|
||||||
contentResolver.openOutputStream(uri)?.use { it.write(payload.toByteArray()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
binding = ActivityResultBinding.inflate(layoutInflater)
|
|
||||||
setContentView(binding.root)
|
|
||||||
|
|
||||||
kind = intent.getStringExtra(EXTRA_KIND) ?: "txs"
|
|
||||||
payload = intent.getStringExtra(EXTRA_PAYLOAD) ?: ""
|
|
||||||
parts = intent.getStringArrayListExtra(EXTRA_PARTS) ?: emptyList()
|
|
||||||
|
|
||||||
binding.tvKind.text =
|
|
||||||
getString(if (kind == "will") R.string.result_kind_will else R.string.result_kind_txs)
|
|
||||||
binding.tvContent.text = pretty(payload)
|
|
||||||
|
|
||||||
binding.btnCopy.setOnClickListener {
|
|
||||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("BAL transfer", payload))
|
|
||||||
Toast.makeText(this, R.string.copied_toast, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
binding.btnShare.setOnClickListener {
|
|
||||||
val send = Intent(Intent.ACTION_SEND).apply {
|
|
||||||
type = "text/plain"
|
|
||||||
putExtra(Intent.EXTRA_TEXT, payload)
|
|
||||||
}
|
|
||||||
startActivity(Intent.createChooser(send, null))
|
|
||||||
}
|
|
||||||
binding.btnSave.setOnClickListener { saveFile() }
|
|
||||||
binding.btnScanAnother.setOnClickListener { finish() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pretty(json: String): String {
|
|
||||||
if (kind == "will") {
|
|
||||||
try {
|
|
||||||
return JSONObject(json).toString(2)
|
|
||||||
} catch (_: JSONException) {
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Transaction list: one numbered line per tx.
|
|
||||||
if (parts.isNotEmpty()) {
|
|
||||||
return parts.mapIndexed { i, tx -> "%d. %s".format(i + 1, tx) }
|
|
||||||
.joinToString("\n")
|
|
||||||
}
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveFile() {
|
|
||||||
val name = if (kind == "will") {
|
|
||||||
getString(R.string.save_file_will)
|
|
||||||
} else {
|
|
||||||
getString(R.string.save_file_txs)
|
|
||||||
}
|
|
||||||
// ActivityResultContracts.CreateDocument takes the suggested file name;
|
|
||||||
// it maps it to ACTION_CREATE_DOCUMENT + EXTRA_TITLE internally.
|
|
||||||
val picker = if (kind == "will") saveWillPicker else saveTxsPicker
|
|
||||||
picker.launch(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val EXTRA_KIND = "kind"
|
|
||||||
const val EXTRA_PAYLOAD = "payload"
|
|
||||||
const val EXTRA_PARTS = "parts"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""
|
|
||||||
bal.core
|
|
||||||
========
|
|
||||||
|
|
||||||
Pure business-logic layer of the Bitcoin After Life (BAL) Electrum plugin.
|
|
||||||
|
|
||||||
Everything in this sub-package MUST stay completely free of any GUI / Qt
|
|
||||||
imports. The rule of thumb is:
|
|
||||||
|
|
||||||
* ``bal.core`` -> "what the plugin does" (inheritance rules, building
|
|
||||||
and validating transactions, talking to
|
|
||||||
will-executor servers, persistence helpers).
|
|
||||||
* ``bal.gui`` -> "how it looks" (Qt widgets, dialogs, list views).
|
|
||||||
|
|
||||||
Keeping the two apart is the main motivation behind this rewrite: the original
|
|
||||||
code mixed transaction-building logic and presentation inside a single
|
|
||||||
4000-line ``qt.py`` module, which made the delicate Bitcoin logic hard to audit.
|
|
||||||
|
|
||||||
No behaviour is changed with respect to the original plugin; the code has only
|
|
||||||
been reorganised and documented.
|
|
||||||
"""
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,304 +0,0 @@
|
|||||||
"""
|
|
||||||
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);
|
|
||||||
* splits that string into fixed-size ``BAL1<TTT><iii><flag>`` frames for
|
|
||||||
multi-QR export, and reassembles/validates them on import.
|
|
||||||
|
|
||||||
Wire format (v2, compact)
|
|
||||||
-------------------------
|
|
||||||
A frame is::
|
|
||||||
|
|
||||||
BAL1<TTT><iii><flag><payload>
|
|
||||||
|
|
||||||
* ``BAL1`` - magic + format era (4 chars).
|
|
||||||
* ``TTT`` - frame total as exactly 3 base36 digits (1-based, cap 46655).
|
|
||||||
* ``iii`` - frame index as exactly 3 base36 digits (1-based).
|
|
||||||
* ``flag`` - one char: ``Z`` (zlib + base64) or ``0`` (plain ASCII).
|
|
||||||
* ``payload`` - every other character of the frame; the payloads of all
|
|
||||||
frames, concatenated in index order, rebuild the transfer string.
|
|
||||||
|
|
||||||
The fixed 11-char header replaces the legacy ``BALQR1|N|i|flags|`` form
|
|
||||||
(same 5 pieces of information) without any pipe separator, so the whole
|
|
||||||
frame is scan-friendly and the overhead no longer grows with the frame
|
|
||||||
count. Legacy ``BALQR1|…`` frames are still accepted on import.
|
|
||||||
|
|
||||||
The audio-modem channel deliberately bypasses the framing helpers here
|
|
||||||
(PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and
|
|
||||||
carries the whole transfer string in a single blob, 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
|
|
||||||
|
|
||||||
MAGIC = "BALQR"
|
|
||||||
VERSION = 1
|
|
||||||
FLAG_COMPRESSED = "Z"
|
|
||||||
FLAG_PLAIN = "0"
|
|
||||||
|
|
||||||
# 4 standard presets (label, payload budget in bytes 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),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Smallest allowed payload budget per frame, below which the frame header
|
|
||||||
# could consume the whole budget.
|
|
||||||
MIN_CHUNK_SIZE = 40
|
|
||||||
|
|
||||||
# Legacy wire format (still imported); the exporter emits the v2 form below.
|
|
||||||
_FRAME_MAGIC_V1 = MAGIC + str(VERSION)
|
|
||||||
|
|
||||||
# Compact v2 wire format: fixed-width base36 count fields, no separators.
|
|
||||||
_FRAME_MAGIC_V2 = "BAL1"
|
|
||||||
_BASE36_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
||||||
_BASE36_WIDTH = 3
|
|
||||||
_HEADER_V2_LEN = len(_FRAME_MAGIC_V2) + 2 * _BASE36_WIDTH + 1
|
|
||||||
_MAX_TOTAL = 36 ** _BASE36_WIDTH - 1
|
|
||||||
|
|
||||||
|
|
||||||
class QrTransferError(ValueError):
|
|
||||||
"""Base error for will QR / audio transfer processing."""
|
|
||||||
|
|
||||||
|
|
||||||
class MissingFramesError(QrTransferError):
|
|
||||||
"""Some frame indices of a multi-QR transfer are missing."""
|
|
||||||
|
|
||||||
def __init__(self, missing):
|
|
||||||
self.missing = list(missing)
|
|
||||||
super().__init__("Missing QR frames: {}".format(self.missing))
|
|
||||||
|
|
||||||
|
|
||||||
class InconsistentTotalError(QrTransferError):
|
|
||||||
"""Frames disagree about the advertised frame total."""
|
|
||||||
|
|
||||||
|
|
||||||
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. The optional flag
|
|
||||||
of the frame header lets the importer reverse this automatically.
|
|
||||||
"""
|
|
||||||
return __compress("\n".join(tx_strings), enabled=compress)
|
|
||||||
|
|
||||||
|
|
||||||
def encode_transfer_best(tx_strings):
|
|
||||||
"""Encode ``tx_strings`` with the smaller of plain vs compressed form.
|
|
||||||
|
|
||||||
Returns ``(transfer_string, compressed: bool)``. Compressed wins only
|
|
||||||
when zlib + base64 really is shorter (best-of, never larger).
|
|
||||||
"""
|
|
||||||
joined = "\n".join(tx_strings)
|
|
||||||
plain = joined
|
|
||||||
compressed = __compress(joined, enabled=True)
|
|
||||||
if len(compressed) < len(plain):
|
|
||||||
return compressed, True
|
|
||||||
return plain, False
|
|
||||||
|
|
||||||
|
|
||||||
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 split_frames(transfer_string, chunk_size, compressed=False):
|
|
||||||
"""Split ``transfer_string`` into full compact ``BAL1`` frames.
|
|
||||||
|
|
||||||
Every returned frame has the fixed 11-char v2 header followed by its
|
|
||||||
share of the payload, so each frame is at most ``chunk_size`` characters
|
|
||||||
long. ``compressed`` stamps the ``Z`` flag into every frame so the
|
|
||||||
importer knows how to reverse the encoding.
|
|
||||||
|
|
||||||
Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold
|
|
||||||
the header plus any payload, or when the transfer needs more than
|
|
||||||
:data:`_MAX_TOTAL` frames.
|
|
||||||
"""
|
|
||||||
flag = FLAG_COMPRESSED if compressed else FLAG_PLAIN
|
|
||||||
total = __compute_total(len(transfer_string), chunk_size)
|
|
||||||
budget = chunk_size - _HEADER_V2_LEN
|
|
||||||
frames = []
|
|
||||||
pos = 0
|
|
||||||
length = len(transfer_string)
|
|
||||||
for index in range(1, total + 1):
|
|
||||||
end = min(pos + budget, length)
|
|
||||||
frames.append(
|
|
||||||
_FRAME_MAGIC_V2
|
|
||||||
+ _base36(total)
|
|
||||||
+ _base36(index)
|
|
||||||
+ flag
|
|
||||||
+ transfer_string[pos:end]
|
|
||||||
)
|
|
||||||
pos = end
|
|
||||||
if pos < length:
|
|
||||||
# __compute_total guarantees this cannot happen; keep a safety net.
|
|
||||||
raise QrTransferError("internal error: frames did not cover the transfer string")
|
|
||||||
return frames
|
|
||||||
|
|
||||||
|
|
||||||
def parse_frame(frame):
|
|
||||||
"""Parse a single frame.
|
|
||||||
|
|
||||||
Accepts both the legacy ``BALQR1|total|index|flags|payload`` form and
|
|
||||||
the compact ``BAL1<total><index><flag><payload>`` v2 form.
|
|
||||||
|
|
||||||
Returns ``(total, index, compressed: bool, payload: str)``. Raises
|
|
||||||
:class:`QrTransferError` on malformed input (bad magic/version, wrong
|
|
||||||
arity, non-integer or out-of-range frame numbers, unknown flags).
|
|
||||||
"""
|
|
||||||
if frame.startswith(_FRAME_MAGIC_V2):
|
|
||||||
return _parse_v2(frame)
|
|
||||||
return _parse_v1(frame)
|
|
||||||
|
|
||||||
|
|
||||||
def assemble(frames, total):
|
|
||||||
"""Concatenate frame payloads back into a transfer string.
|
|
||||||
|
|
||||||
``frames`` maps 1-based index -> payload. Every index ``1..total`` must
|
|
||||||
be present (else :class:`MissingFramesError`) and no index may exceed
|
|
||||||
``total`` (else :class:`InconsistentTotalError`).
|
|
||||||
"""
|
|
||||||
if total < 1:
|
|
||||||
raise QrTransferError("invalid frame total")
|
|
||||||
missing = [index for index in range(1, total + 1) if index not in frames]
|
|
||||||
if missing:
|
|
||||||
raise MissingFramesError(missing)
|
|
||||||
extra = [index for index in frames if index > total]
|
|
||||||
if extra:
|
|
||||||
raise InconsistentTotalError()
|
|
||||||
return "".join(frames[index] for index in range(1, total + 1))
|
|
||||||
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
def _base36(n):
|
|
||||||
"""Zero-padded :data:`_BASE36_WIDTH` base36 render of ``n``."""
|
|
||||||
if not 0 <= n <= _MAX_TOTAL:
|
|
||||||
raise QrTransferError("BAL QR part number out of range: {}".format(n))
|
|
||||||
chars = []
|
|
||||||
for _ in range(_BASE36_WIDTH):
|
|
||||||
chars.append(_BASE36_DIGITS[n % 36])
|
|
||||||
n //= 36
|
|
||||||
return "".join(reversed(chars))
|
|
||||||
|
|
||||||
|
|
||||||
def _base36_decode(text):
|
|
||||||
"""Inverse of :func:`_base36`; raises ``ValueError`` on bad input."""
|
|
||||||
if len(text) != _BASE36_WIDTH or any(c not in _BASE36_DIGITS for c in text):
|
|
||||||
raise ValueError(text)
|
|
||||||
n = 0
|
|
||||||
for c in text:
|
|
||||||
n = n * 36 + _BASE36_DIGITS.index(c)
|
|
||||||
return n
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_v1(frame):
|
|
||||||
parts = frame.split("|", maxsplit=4)
|
|
||||||
if len(parts) != 5:
|
|
||||||
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
|
||||||
magic_seen, total_s, index_s, flags, payload = parts
|
|
||||||
if magic_seen != _FRAME_MAGIC_V1:
|
|
||||||
raise QrTransferError("Not a BAL will QR (unknown magic/version)")
|
|
||||||
try:
|
|
||||||
total = int(total_s)
|
|
||||||
index = int(index_s)
|
|
||||||
except ValueError as e:
|
|
||||||
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e
|
|
||||||
if total < 1 or not 1 <= index <= total:
|
|
||||||
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
|
||||||
if flags not in ("", FLAG_COMPRESSED):
|
|
||||||
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
|
||||||
return total, index, flags == FLAG_COMPRESSED, payload
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_v2(frame):
|
|
||||||
if len(frame) < _HEADER_V2_LEN:
|
|
||||||
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
|
||||||
# Magic is length _FRAME_MAGIC_V2; the two base36 fields and the flag
|
|
||||||
# make up the rest of the fixed header.
|
|
||||||
offset = len(_FRAME_MAGIC_V2)
|
|
||||||
total_s = frame[offset : offset + _BASE36_WIDTH]
|
|
||||||
index_s = frame[offset + _BASE36_WIDTH : offset + 2 * _BASE36_WIDTH]
|
|
||||||
flag = frame[offset + 2 * _BASE36_WIDTH]
|
|
||||||
try:
|
|
||||||
total = _base36_decode(total_s)
|
|
||||||
index = _base36_decode(index_s)
|
|
||||||
except ValueError:
|
|
||||||
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from None
|
|
||||||
if total < 1 or not 1 <= index <= total:
|
|
||||||
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
|
||||||
if flag not in (FLAG_PLAIN, FLAG_COMPRESSED):
|
|
||||||
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
|
||||||
payload = frame[_HEADER_V2_LEN:]
|
|
||||||
return total, index, flag == FLAG_COMPRESSED, payload
|
|
||||||
|
|
||||||
|
|
||||||
def __compute_total(transfer_len, chunk_size):
|
|
||||||
"""Smallest frame count whose budget covers the whole transfer string.
|
|
||||||
|
|
||||||
The v2 header is fixed-width, so the budget is constant and the count is
|
|
||||||
a plain ceiling division, capped at :data:`_MAX_TOTAL`.
|
|
||||||
"""
|
|
||||||
if chunk_size < MIN_CHUNK_SIZE:
|
|
||||||
raise QrTransferError(
|
|
||||||
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
|
|
||||||
)
|
|
||||||
budget = chunk_size - _HEADER_V2_LEN
|
|
||||||
if budget <= 0:
|
|
||||||
raise QrTransferError(
|
|
||||||
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
|
|
||||||
)
|
|
||||||
total = -(-transfer_len // budget)
|
|
||||||
if total < 1:
|
|
||||||
total = 1
|
|
||||||
if total > _MAX_TOTAL:
|
|
||||||
raise QrTransferError(
|
|
||||||
"BAL QR transfer demands too many frames: {}".format(total)
|
|
||||||
)
|
|
||||||
return total
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Android reader helpers built on the bundled plugin codecs."""
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""Kotlin-facing helper: runs the plugin's exact import tail and returns JSON.
|
|
||||||
|
|
||||||
A serializable JSON contract keeps the Chaquopy bridge tiny on the Kotlin side
|
|
||||||
and avoids exposing ``PyObject`` tuple/container indexing to it. The steps are
|
|
||||||
the same three calls the plugin's import dialog performs:
|
|
||||||
|
|
||||||
session.resolve() -> (transfer_text, compressed)
|
|
||||||
qrtransfer.decode_transfer -> parts
|
|
||||||
balreader.payload.decode_will_payload -> ("will"|"txs", data)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from bal.core import qrtransfer as _qrtransfer
|
|
||||||
from balreader import payload as _payload
|
|
||||||
|
|
||||||
|
|
||||||
def finish(session):
|
|
||||||
"""Run the import tail on a live ``AnimatedQrSession``.
|
|
||||||
|
|
||||||
Returns a JSON string ``{"kind": ..., "payload": ..., "parts": [...]}``
|
|
||||||
with ``kind`` either ``"will"`` or ``"txs"``. On any failure it returns
|
|
||||||
``{"kind": "error", "payload": <message>, "parts": []}`` so a misbehaving
|
|
||||||
session can never crash the UI thread.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
transfer, compressed = session.resolve()
|
|
||||||
parts = list(_qrtransfer.decode_transfer(transfer, compressed))
|
|
||||||
payload = "\n".join(parts)
|
|
||||||
kind, _data = _payload.decode_will_payload(payload)
|
|
||||||
return json.dumps({"kind": kind, "payload": payload, "parts": parts})
|
|
||||||
except Exception as exc: # noqa: BLE001 - defensive bridge boundary
|
|
||||||
return json.dumps({"kind": "error", "payload": str(exc), "parts": []})
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""Will-payload autodetection for the BAL Reader app.
|
|
||||||
|
|
||||||
This file is a verbatim copy of ``decode_will_payload`` from
|
|
||||||
``bal/gui/qt/dialogs.py``. ``android/test_chain/verify_chain.py`` compares the
|
|
||||||
two functions result-for-result so they can never drift apart.
|
|
||||||
|
|
||||||
Keep the function body identical to the plugin source.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def decode_will_payload(text) -> tuple[Any, Any]:
|
|
||||||
"""Autodetect: whole-will JSON or transaction list?
|
|
||||||
|
|
||||||
Returns ``("will", dict_of_willitems_data)`` when ``text`` is a JSON
|
|
||||||
object whose values are dicts containing a ``"tx"`` key (the whole-will
|
|
||||||
format produced by :meth:`BalWindow.export_json_file` and friends).
|
|
||||||
Otherwise returns ``("txs", [tx_strings])`` where the transaction
|
|
||||||
strings were split on commas and/or newlines.
|
|
||||||
"""
|
|
||||||
text = text.strip()
|
|
||||||
try:
|
|
||||||
data = json.loads(text)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
data = None
|
|
||||||
if isinstance(data, dict) and data:
|
|
||||||
if all(isinstance(v, dict) and "tx" in v for v in data.values()):
|
|
||||||
return ("will", data)
|
|
||||||
parts = [p for p in re.split(r"[,\r\n]+", text) if p.strip()]
|
|
||||||
return ("txs", parts)
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#0B3D2E"
|
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M14,14h22v22h-22z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#0B3D2E"
|
|
||||||
android:pathData="M19,19h12v12h-12z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M72,14h22v22h-22z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#0B3D2E"
|
|
||||||
android:pathData="M77,19h12v12h-12z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M14,72h22v22h-22z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#0B3D2E"
|
|
||||||
android:pathData="M19,77h12v12h-12z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M14,42h4v4h-4z M22,42h4v4h-4z M14,50h4v4h-4z M22,50h4v4h-4z M14,58h4v4h-4z M30,42h4v4h-4z M30,50h4v4h-4z M14,66h4v4h-4z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M52,14h4v4h-4z M60,14h4v4h-4z M52,22h4v4h-4z M68,14h4v4h-4z M52,30h4v4h-4z M56,42h4v4h-4z M64,42h4v4h-4z M56,50h4v4h-4z M72,42h4v4h-4z M56,58h4v4h-4z M64,58h4v4h-4z M56,66h4v4h-4z M64,66h4v4h-4z M72,58h4v4h-4z M64,74h4v4h-4z M72,66h4v4h-4z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="@android:color/black">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:paddingHorizontal="12dp"
|
|
||||||
android:paddingVertical="8dp"
|
|
||||||
android:background="#1A1A1A">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_format"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text="@string/format_placeholder"
|
|
||||||
android:textColor="#9BE8C0"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_progress"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="0 / 0"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<life.after.bitcoin.FrameProgressBar
|
|
||||||
android:id="@+id/frame_bar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="6dp"
|
|
||||||
android:layout_marginHorizontal="12dp"
|
|
||||||
android:layout_marginTop="4dp"
|
|
||||||
android:layout_marginBottom="4dp" />
|
|
||||||
|
|
||||||
<androidx.camera.view.PreviewView
|
|
||||||
android:id="@+id/preview_view"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_status"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:padding="10dp"
|
|
||||||
android:gravity="center"
|
|
||||||
android:text="@string/status_waiting"
|
|
||||||
android:textColor="#CFCFCF"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
</LinearLayout>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="12dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_kind"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="16sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="?android:attr/textColorPrimary"
|
|
||||||
android:paddingBottom="8dp" />
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:background="?android:attr/colorBackground">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_content"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:textIsSelectable="true"
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textColor="?android:attr/textColorPrimary"
|
|
||||||
android:padding="8dp" />
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:paddingTop="12dp">
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btn_copy"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text="@string/action_copy" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btn_share"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text="@string/action_share" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btn_save"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text="@string/action_save" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btn_scan_another"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/action_scan_another" />
|
|
||||||
</LinearLayout>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="frame_fill">#9BE8C0</color>
|
|
||||||
<color name="frame_track">#3A3A3A</color>
|
|
||||||
</resources>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<string name="app_name">BAL Reader</string>
|
|
||||||
|
|
||||||
<string name="status_waiting">Point the camera at the QR screen</string>
|
|
||||||
<string name="format_placeholder">—</string>
|
|
||||||
<string name="progress_fmt">%1$d / %2$d</string>
|
|
||||||
|
|
||||||
<string name="format_balqr">BAL QR</string>
|
|
||||||
<string name="format_ur1">BC-UR v1</string>
|
|
||||||
<string name="format_ur2">BC-UR v2</string>
|
|
||||||
<string name="format_bbqr">BBQR</string>
|
|
||||||
|
|
||||||
<string name="result_kind_will">Whole will (JSON)</string>
|
|
||||||
<string name="result_kind_txs">Transaction list</string>
|
|
||||||
<string name="action_copy">Copy</string>
|
|
||||||
<string name="action_share">Share</string>
|
|
||||||
<string name="action_save">Save</string>
|
|
||||||
<string name="action_scan_another">Scan another</string>
|
|
||||||
<string name="copied_toast">Transfer copied to clipboard</string>
|
|
||||||
<string name="save_file_will">will.json</string>
|
|
||||||
<string name="save_file_txs">will_tx.txt</string>
|
|
||||||
<string name="permission_denied">Camera permission is required to scan QR codes.</string>
|
|
||||||
<string name="conflict_message">The QR switched to a different transfer. Let it rescan.</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<style name="Theme.BalReader" parent="Theme.AppCompat.DayNight.NoActionBar" />
|
|
||||||
</resources>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
// Top-level build file: plugin versions only. See the docs for the full
|
|
||||||
// compatibility matrix (Chaquopy 17 requires AGP 7.3-9.2 and minSdk 24).
|
|
||||||
plugins {
|
|
||||||
id("com.android.application") version "8.10.0" apply false
|
|
||||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
|
||||||
id("com.chaquo.python") version "17.0.0" apply false
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
|
||||||
org.gradle.parallel=true
|
|
||||||
android.useAndroidX=true
|
|
||||||
android.nonTransitiveRClass=true
|
|
||||||
kotlin.code.style=official
|
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@@ -1,7 +0,0 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
|
||||||
distributionPath=wrapper/dists
|
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
|
|
||||||
networkTimeout=10000
|
|
||||||
validateDistributionUrl=true
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
|
||||||
zipStorePath=wrapper/dists
|
|
||||||
251
android/gradlew
vendored
251
android/gradlew
vendored
@@ -1,251 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
#
|
|
||||||
# Copyright © 2015-2021 the original authors.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: Apache-2.0
|
|
||||||
#
|
|
||||||
|
|
||||||
##############################################################################
|
|
||||||
#
|
|
||||||
# Gradle start up script for POSIX generated by Gradle.
|
|
||||||
#
|
|
||||||
# Important for running:
|
|
||||||
#
|
|
||||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
|
||||||
# noncompliant, but you have some other compliant shell such as ksh or
|
|
||||||
# bash, then to run this script, type that shell name before the whole
|
|
||||||
# command line, like:
|
|
||||||
#
|
|
||||||
# ksh Gradle
|
|
||||||
#
|
|
||||||
# Busybox and similar reduced shells will NOT work, because this script
|
|
||||||
# requires all of these POSIX shell features:
|
|
||||||
# * functions;
|
|
||||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
|
||||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
|
||||||
# * compound commands having a testable exit status, especially «case»;
|
|
||||||
# * various built-in commands including «command», «set», and «ulimit».
|
|
||||||
#
|
|
||||||
# Important for patching:
|
|
||||||
#
|
|
||||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
|
||||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
|
||||||
#
|
|
||||||
# The "traditional" practice of packing multiple parameters into a
|
|
||||||
# space-separated string is a well documented source of bugs and security
|
|
||||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
|
||||||
# options in "$@", and eventually passing that to Java.
|
|
||||||
#
|
|
||||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
|
||||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
|
||||||
# see the in-line comments for details.
|
|
||||||
#
|
|
||||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
|
||||||
# Darwin, MinGW, and NonStop.
|
|
||||||
#
|
|
||||||
# (3) This script is generated from the Groovy template
|
|
||||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
|
||||||
# within the Gradle project.
|
|
||||||
#
|
|
||||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
|
||||||
#
|
|
||||||
##############################################################################
|
|
||||||
|
|
||||||
# Attempt to set APP_HOME
|
|
||||||
|
|
||||||
# Resolve links: $0 may be a link
|
|
||||||
app_path=$0
|
|
||||||
|
|
||||||
# Need this for daisy-chained symlinks.
|
|
||||||
while
|
|
||||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
|
||||||
[ -h "$app_path" ]
|
|
||||||
do
|
|
||||||
ls=$( ls -ld "$app_path" )
|
|
||||||
link=${ls#*' -> '}
|
|
||||||
case $link in #(
|
|
||||||
/*) app_path=$link ;; #(
|
|
||||||
*) app_path=$APP_HOME$link ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# This is normally unused
|
|
||||||
# shellcheck disable=SC2034
|
|
||||||
APP_BASE_NAME=${0##*/}
|
|
||||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
|
||||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
|
||||||
|
|
||||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
|
||||||
MAX_FD=maximum
|
|
||||||
|
|
||||||
warn () {
|
|
||||||
echo "$*"
|
|
||||||
} >&2
|
|
||||||
|
|
||||||
die () {
|
|
||||||
echo
|
|
||||||
echo "$*"
|
|
||||||
echo
|
|
||||||
exit 1
|
|
||||||
} >&2
|
|
||||||
|
|
||||||
# OS specific support (must be 'true' or 'false').
|
|
||||||
cygwin=false
|
|
||||||
msys=false
|
|
||||||
darwin=false
|
|
||||||
nonstop=false
|
|
||||||
case "$( uname )" in #(
|
|
||||||
CYGWIN* ) cygwin=true ;; #(
|
|
||||||
Darwin* ) darwin=true ;; #(
|
|
||||||
MSYS* | MINGW* ) msys=true ;; #(
|
|
||||||
NONSTOP* ) nonstop=true ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
CLASSPATH="\\\"\\\""
|
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
|
||||||
if [ -n "$JAVA_HOME" ] ; then
|
|
||||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
|
||||||
# IBM's JDK on AIX uses strange locations for the executables
|
|
||||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
|
||||||
else
|
|
||||||
JAVACMD=$JAVA_HOME/bin/java
|
|
||||||
fi
|
|
||||||
if [ ! -x "$JAVACMD" ] ; then
|
|
||||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
location of your Java installation."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
JAVACMD=java
|
|
||||||
if ! command -v java >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
location of your Java installation."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Increase the maximum file descriptors if we can.
|
|
||||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
|
||||||
case $MAX_FD in #(
|
|
||||||
max*)
|
|
||||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
|
||||||
# shellcheck disable=SC2039,SC3045
|
|
||||||
MAX_FD=$( ulimit -H -n ) ||
|
|
||||||
warn "Could not query maximum file descriptor limit"
|
|
||||||
esac
|
|
||||||
case $MAX_FD in #(
|
|
||||||
'' | soft) :;; #(
|
|
||||||
*)
|
|
||||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
|
||||||
# shellcheck disable=SC2039,SC3045
|
|
||||||
ulimit -n "$MAX_FD" ||
|
|
||||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Collect all arguments for the java command, stacking in reverse order:
|
|
||||||
# * args from the command line
|
|
||||||
# * the main class name
|
|
||||||
# * -classpath
|
|
||||||
# * -D...appname settings
|
|
||||||
# * --module-path (only if needed)
|
|
||||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
|
||||||
|
|
||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
|
||||||
if "$cygwin" || "$msys" ; then
|
|
||||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
|
||||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
|
||||||
|
|
||||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
|
||||||
|
|
||||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
|
||||||
for arg do
|
|
||||||
if
|
|
||||||
case $arg in #(
|
|
||||||
-*) false ;; # don't mess with options #(
|
|
||||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
|
||||||
[ -e "$t" ] ;; #(
|
|
||||||
*) false ;;
|
|
||||||
esac
|
|
||||||
then
|
|
||||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
|
||||||
fi
|
|
||||||
# Roll the args list around exactly as many times as the number of
|
|
||||||
# args, so each arg winds up back in the position where it started, but
|
|
||||||
# possibly modified.
|
|
||||||
#
|
|
||||||
# NB: a `for` loop captures its iteration list before it begins, so
|
|
||||||
# changing the positional parameters here affects neither the number of
|
|
||||||
# iterations, nor the values presented in `arg`.
|
|
||||||
shift # remove old arg
|
|
||||||
set -- "$@" "$arg" # push replacement arg
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
|
||||||
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
|
||||||
|
|
||||||
# Collect all arguments for the java command:
|
|
||||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
|
||||||
# and any embedded shellness will be escaped.
|
|
||||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
|
||||||
# treated as '${Hostname}' itself on the command line.
|
|
||||||
|
|
||||||
set -- \
|
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
|
||||||
-classpath "$CLASSPATH" \
|
|
||||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
|
||||||
"$@"
|
|
||||||
|
|
||||||
# Stop when "xargs" is not available.
|
|
||||||
if ! command -v xargs >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
die "xargs is not available"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Use "xargs" to parse quoted args.
|
|
||||||
#
|
|
||||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
|
||||||
#
|
|
||||||
# In Bash we could simply go:
|
|
||||||
#
|
|
||||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
|
||||||
# set -- "${ARGS[@]}" "$@"
|
|
||||||
#
|
|
||||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
|
||||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
|
||||||
# character that might be a shell metacharacter, then use eval to reverse
|
|
||||||
# that process (while maintaining the separation between arguments), and wrap
|
|
||||||
# the whole thing up as a single "set" statement.
|
|
||||||
#
|
|
||||||
# This will of course break if any of these variables contains a newline or
|
|
||||||
# an unmatched quote.
|
|
||||||
#
|
|
||||||
|
|
||||||
eval "set -- $(
|
|
||||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
|
||||||
xargs -n1 |
|
|
||||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
|
||||||
tr '\n' ' '
|
|
||||||
)" '"$@"'
|
|
||||||
|
|
||||||
exec "$JAVACMD" "$@"
|
|
||||||
94
android/gradlew.bat
vendored
94
android/gradlew.bat
vendored
@@ -1,94 +0,0 @@
|
|||||||
@rem
|
|
||||||
@rem Copyright 2015 the original author or authors.
|
|
||||||
@rem
|
|
||||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
@rem you may not use this file except in compliance with the License.
|
|
||||||
@rem You may obtain a copy of the License at
|
|
||||||
@rem
|
|
||||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
@rem
|
|
||||||
@rem Unless required by applicable law or agreed to in writing, software
|
|
||||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
@rem See the License for the specific language governing permissions and
|
|
||||||
@rem limitations under the License.
|
|
||||||
@rem
|
|
||||||
@rem SPDX-License-Identifier: Apache-2.0
|
|
||||||
@rem
|
|
||||||
|
|
||||||
@if "%DEBUG%"=="" @echo off
|
|
||||||
@rem ##########################################################################
|
|
||||||
@rem
|
|
||||||
@rem Gradle startup script for Windows
|
|
||||||
@rem
|
|
||||||
@rem ##########################################################################
|
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
|
||||||
@rem This is normally unused
|
|
||||||
set APP_BASE_NAME=%~n0
|
|
||||||
set APP_HOME=%DIRNAME%
|
|
||||||
|
|
||||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
|
||||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
|
||||||
|
|
||||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
|
||||||
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
|
||||||
|
|
||||||
@rem Find java.exe
|
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
|
||||||
|
|
||||||
set JAVA_EXE=java.exe
|
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
|
||||||
|
|
||||||
echo. 1>&2
|
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
|
||||||
echo. 1>&2
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
|
||||||
echo location of your Java installation. 1>&2
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
|
||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
|
||||||
|
|
||||||
echo. 1>&2
|
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
|
||||||
echo. 1>&2
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
|
||||||
echo location of your Java installation. 1>&2
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:execute
|
|
||||||
@rem Setup the command line
|
|
||||||
|
|
||||||
set CLASSPATH=
|
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
|
||||||
|
|
||||||
:end
|
|
||||||
@rem End local scope for the variables with windows NT shell
|
|
||||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
|
||||||
|
|
||||||
:fail
|
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
|
||||||
rem the _cmd.exe /c_ return code!
|
|
||||||
set EXIT_CODE=%ERRORLEVEL%
|
|
||||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
|
||||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
|
||||||
exit /b %EXIT_CODE%
|
|
||||||
|
|
||||||
:mainEnd
|
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
|
||||||
|
|
||||||
:omega
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Build the BAL Reader Android APK via Gradle.
|
|
||||||
|
|
||||||
Re-synchronises the bundled codec modules into the app (so the APK always
|
|
||||||
carries the current ``bal/core`` sources), then invokes the Gradle wrapper to
|
|
||||||
produce the APK, and finally prints the artifact path, size and sha256.
|
|
||||||
|
|
||||||
Run from the repository root (any Python 3.8+, needs JDK 17 and an Android
|
|
||||||
SDK; the first build also needs a network connection for Gradle downloads):
|
|
||||||
|
|
||||||
python3 android/scripts/build_apk.py # debug APK
|
|
||||||
python3 android/scripts/build_apk.py --release # (unsigned) release APK
|
|
||||||
python3 android/scripts/build_apk.py --no-sync # skip codec re-sync
|
|
||||||
python3 android/scripts/build_apk.py --offline # gradle --offline
|
|
||||||
|
|
||||||
The APK is written under ``android/app/build/outputs/apk/``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
ANDROID_DIR = REPO_ROOT / "android"
|
|
||||||
GRADLEW = ANDROID_DIR / "gradlew"
|
|
||||||
LOCAL_PROPERTIES = ANDROID_DIR / "local.properties"
|
|
||||||
WRAPPER_JAR = ANDROID_DIR / "gradle" / "wrapper" / "gradle-wrapper.jar"
|
|
||||||
|
|
||||||
VARIANTS = {
|
|
||||||
"debug": "assembleDebug",
|
|
||||||
"release": "assembleRelease",
|
|
||||||
}
|
|
||||||
APK_REL = {
|
|
||||||
"debug": Path("app") / "build" / "outputs" / "apk" / "debug" / "app-debug.apk",
|
|
||||||
"release": Path("app") / "build" / "outputs" / "apk" / "release" / "app-release-unsigned.apk",
|
|
||||||
}
|
|
||||||
|
|
||||||
SYNC_SCRIPT = ANDROID_DIR / "scripts" / "sync_codecs.py"
|
|
||||||
|
|
||||||
|
|
||||||
def sha256(data: bytes) -> str:
|
|
||||||
return hashlib.sha256(data).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def run(cmd, cwd, verbose: bool) -> int:
|
|
||||||
if verbose:
|
|
||||||
print("+", " ".join(str(c) for c in cmd))
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd, cwd=str(cwd), capture_output=not verbose, text=True
|
|
||||||
)
|
|
||||||
if not verbose:
|
|
||||||
sys.stdout.write(result.stdout)
|
|
||||||
sys.stderr.write(result.stderr)
|
|
||||||
return result.returncode
|
|
||||||
|
|
||||||
|
|
||||||
def check_prerequisites() -> None:
|
|
||||||
if not (GRADLEW.exists() and WRAPPER_JAR.exists()):
|
|
||||||
sys.exit(
|
|
||||||
"error: gradle wrapper is incomplete ({} missing).\n"
|
|
||||||
"hint: run `gradle wrapper` in android/ once, or re-clone.".format(
|
|
||||||
WRAPPER_JAR if not WRAPPER_JAR.exists() else GRADLEW
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
java_ok = None
|
|
||||||
try:
|
|
||||||
out = subprocess.run(
|
|
||||||
["java", "-version"], capture_output=True, text=True, check=False
|
|
||||||
).stderr
|
|
||||||
match = re.search(r'version "(?:1\.)?(\d+)', out)
|
|
||||||
java_ok = int(match.group(1)) if match else None
|
|
||||||
except FileNotFoundError:
|
|
||||||
java_ok = None
|
|
||||||
if java_ok is None:
|
|
||||||
sys.exit("error: no JDK found on PATH (need JDK 17 for AGP 8.10).")
|
|
||||||
if java_ok < 17:
|
|
||||||
sys.exit("error: JDK {} on PATH, but the Android build needs JDK 17.".format(java_ok))
|
|
||||||
|
|
||||||
sdk = None
|
|
||||||
if LOCAL_PROPERTIES.exists():
|
|
||||||
for line in LOCAL_PROPERTIES.read_text().splitlines():
|
|
||||||
if line.startswith("sdk.dir="):
|
|
||||||
sdk = line.split("=", 1)[1]
|
|
||||||
sdk = sdk or os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")
|
|
||||||
if not sdk or not Path(sdk).exists():
|
|
||||||
sys.exit(
|
|
||||||
"error: Android SDK not found.\n"
|
|
||||||
"hint: set sdk.dir in android/local.properties or ANDROID_HOME."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None) -> int:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument(
|
|
||||||
"--release",
|
|
||||||
action="store_true",
|
|
||||||
help="build the (unsigned) release APK instead of the debug APK",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-sync",
|
|
||||||
action="store_true",
|
|
||||||
help="skip re-synchronising the bundled codec modules",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--offline",
|
|
||||||
action="store_true",
|
|
||||||
help="pass --offline to Gradle (no dependency downloads)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--clean",
|
|
||||||
action="store_true",
|
|
||||||
help="run the Gradle clean task before building",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--verbose", action="store_true", help="stream Gradle output"
|
|
||||||
)
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
check_prerequisites()
|
|
||||||
variant = "release" if args.release else "debug"
|
|
||||||
|
|
||||||
if not args.no_sync:
|
|
||||||
sync = subprocess.run(
|
|
||||||
[sys.executable, str(SYNC_SCRIPT)], cwd=str(REPO_ROOT), check=False
|
|
||||||
)
|
|
||||||
if sync.returncode != 0:
|
|
||||||
print("error: codec re-sync failed; refusing to build a stale APK.")
|
|
||||||
return sync.returncode
|
|
||||||
|
|
||||||
tasks = []
|
|
||||||
if args.clean:
|
|
||||||
tasks.append("clean")
|
|
||||||
tasks.append(VARIANTS[variant])
|
|
||||||
cmd = [str(GRADLEW)]
|
|
||||||
if args.offline:
|
|
||||||
cmd.append("--offline")
|
|
||||||
cmd.extend(tasks)
|
|
||||||
|
|
||||||
rc = run(cmd, cwd=ANDROID_DIR, verbose=args.verbose)
|
|
||||||
if rc != 0:
|
|
||||||
print("error: Gradle {} failed (exit {}).".format(" ".join(tasks), rc))
|
|
||||||
return rc
|
|
||||||
|
|
||||||
apk = ANDROID_DIR / APK_REL[variant]
|
|
||||||
if not apk.exists():
|
|
||||||
print("error: expected APK not found at {}".format(apk))
|
|
||||||
return 1
|
|
||||||
data = apk.read_bytes()
|
|
||||||
print("APK : {}".format(apk.relative_to(REPO_ROOT)))
|
|
||||||
print("size : {} bytes".format(len(data)))
|
|
||||||
print("sha256: {}".format(sha256(data)))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(sys.argv[1:]))
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Synchronise the BAL QR codec modules into the Android app.
|
|
||||||
|
|
||||||
Copies ``bal/core/{__init__,animated_qr,qrtransfer}.py`` from the plugin repo
|
|
||||||
into ``android/app/src/main/python/bal/core/`` so Chaquopy ships exactly the
|
|
||||||
same code the desktop plugin runs. Afterwards the copies are imported
|
|
||||||
standalone and used for one quick encode/decode round trip.
|
|
||||||
|
|
||||||
Run from the repository root (any Python 3.8+, no dependencies):
|
|
||||||
|
|
||||||
python3 android/scripts/sync_codecs.py
|
|
||||||
python3 android/scripts/sync_codecs.py --check # no writes
|
|
||||||
|
|
||||||
Re-run whenever ``bal/core/animated_qr.py`` or ``bal/core/qrtransfer.py``
|
|
||||||
changes; the bundled copies are committed for deterministic builds.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
CORE_SRC = REPO_ROOT / "bal" / "core"
|
|
||||||
PYTHON_DEST = REPO_ROOT / "android" / "app" / "src" / "main" / "python"
|
|
||||||
BAL_CORE_DEST = PYTHON_DEST / "bal" / "core"
|
|
||||||
|
|
||||||
FILES = ("__init__.py", "animated_qr.py", "qrtransfer.py")
|
|
||||||
|
|
||||||
ROUNDTRIP = (
|
|
||||||
"import sys; "
|
|
||||||
"sys.path.insert(0, {dest!r}); "
|
|
||||||
"from bal.core.animated_qr import AnimatedQrSession, ur2_frames; "
|
|
||||||
"import bal.core.qrtransfer as qtf; "
|
|
||||||
"frames = ur2_frames(b'roundtrip-check', 400); "
|
|
||||||
"assert frames, 'no frames produced'; "
|
|
||||||
"s = AnimatedQrSession(); "
|
|
||||||
"assert all(s.add_part(f) == 'ok' for f in frames); "
|
|
||||||
"transfer, compressed = s.resolve(); "
|
|
||||||
"assert not compressed and transfer == 'roundtrip-check', 'roundtrip failed'; "
|
|
||||||
"print('standalone import + roundtrip OK'); "
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def sha256(data: bytes) -> str:
|
|
||||||
return hashlib.sha256(data).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def check_up_to_date() -> int:
|
|
||||||
outdated = []
|
|
||||||
for name in FILES:
|
|
||||||
src = (CORE_SRC / name).read_bytes()
|
|
||||||
dst = BAL_CORE_DEST / name
|
|
||||||
if not dst.exists() or dst.read_bytes() != src:
|
|
||||||
outdated.append(name)
|
|
||||||
if outdated:
|
|
||||||
print("OUT OF DATE: {}".format(", ".join(outdated)))
|
|
||||||
print("run: python3 android/scripts/sync_codecs.py")
|
|
||||||
return 1
|
|
||||||
print("codec bundles are up to date")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None) -> int:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument(
|
|
||||||
"--check",
|
|
||||||
action="store_true",
|
|
||||||
help="verify the bundled copies are up to date without writing",
|
|
||||||
)
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
if args.check:
|
|
||||||
return check_up_to_date()
|
|
||||||
|
|
||||||
BAL_CORE_DEST.mkdir(parents=True, exist_ok=True)
|
|
||||||
for name in FILES:
|
|
||||||
src = (CORE_SRC / name).read_bytes()
|
|
||||||
dst = BAL_CORE_DEST / name
|
|
||||||
dst.write_bytes(src)
|
|
||||||
print("synced {:16s} sha256={}".format(name, sha256(src)[:16]))
|
|
||||||
|
|
||||||
run = subprocess.run(
|
|
||||||
[sys.executable, "-c", ROUNDTRIP.format(dest=str(PYTHON_DEST))],
|
|
||||||
cwd=REPO_ROOT,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
if run.returncode != 0:
|
|
||||||
print(run.stdout, end="")
|
|
||||||
print(run.stderr, end="")
|
|
||||||
return 1
|
|
||||||
print(run.stdout.strip())
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(sys.argv[1:]))
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
pluginManagement {
|
|
||||||
repositories {
|
|
||||||
google {
|
|
||||||
content {
|
|
||||||
includeGroupByRegex("com\\.android.*")
|
|
||||||
includeGroupByRegex("com\\.google.*")
|
|
||||||
includeGroupByRegex("androidx.*")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dependencyResolutionManagement {
|
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rootProject.name = "BALReader"
|
|
||||||
include(":app")
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Verify the Android app can decode every frame format the plugin exports.
|
|
||||||
|
|
||||||
Simulates the exact runtime path of the APK on the development machine:
|
|
||||||
|
|
||||||
* imports ``bal.core`` and ``balreader.payload`` from the *bundled* copies in
|
|
||||||
``android/app/src/main/python`` (the code Chaquopy actually ships);
|
|
||||||
* generates frames exactly as the plugin's export page does
|
|
||||||
(``split_frames`` for BAL QR, ``encode_animated_frames`` semantics for
|
|
||||||
UR v1 / UR v2 / BBQR);
|
|
||||||
* drives an :class:`~bal.core.animated_qr.AnimatedQrSession` the way
|
|
||||||
``BalDecoder.add`` does (scrambled input, duplicates, dropped frames);
|
|
||||||
* runs the app's ``finish()`` chain (``resolve()`` -> ``decode_transfer()``
|
|
||||||
-> ``decode_will_payload()``) and checks the result;
|
|
||||||
* cross-checks the app's ``decode_will_payload`` copy result-for-result (and
|
|
||||||
AST-for-AST) against the plugin's original in ``bal/gui/qt/dialogs.py``.
|
|
||||||
|
|
||||||
Run from the repository root (any Python 3.8+, no dependencies):
|
|
||||||
|
|
||||||
python3 android/test_chain/verify_chain.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import json
|
|
||||||
import random
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
APP_PYTHON = REPO_ROOT / "android" / "app" / "src" / "main" / "python"
|
|
||||||
DIALOGS = REPO_ROOT / "bal" / "gui" / "qt" / "dialogs.py"
|
|
||||||
|
|
||||||
sys.path.insert(0, str(APP_PYTHON))
|
|
||||||
|
|
||||||
from bal.core import animated_qr as aq # noqa: E402
|
|
||||||
from bal.core import qrtransfer as qtf # noqa: E402
|
|
||||||
|
|
||||||
from balreader import bridge as bridge_codec # noqa: E402
|
|
||||||
from balreader import payload as payload_codec # noqa: E402
|
|
||||||
|
|
||||||
PASSED = 0
|
|
||||||
|
|
||||||
|
|
||||||
def ok(condition, label):
|
|
||||||
global PASSED
|
|
||||||
if not condition:
|
|
||||||
raise AssertionError("FAILED: " + label)
|
|
||||||
PASSED += 1
|
|
||||||
|
|
||||||
|
|
||||||
def check_imported_bundle():
|
|
||||||
for module in (aq, qtf, payload_codec):
|
|
||||||
assert module.__file__ is not None
|
|
||||||
path = str(Path(module.__file__).resolve())
|
|
||||||
assert path.startswith(str(APP_PYTHON)), path
|
|
||||||
ok(True, "all modules imported from the bundled android/ copies")
|
|
||||||
|
|
||||||
|
|
||||||
def find_function(tree, name):
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
if isinstance(node, ast.FunctionDef) and node.name == name:
|
|
||||||
return node
|
|
||||||
raise RuntimeError("{} not found".format(name))
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Fixtures
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
TXS = ["{:064x}".format(i) for i in range(1, 8)]
|
|
||||||
WILL_ITEMS = {
|
|
||||||
"imp{}".format(i): {
|
|
||||||
"tx": "{:064x}".format(i + 1),
|
|
||||||
"addr": "bc1qdeadbeef{:x}".format(i),
|
|
||||||
"amount": 100000 + i,
|
|
||||||
"tag": "heiress-{}".format(i),
|
|
||||||
"metadata": {},
|
|
||||||
"notify": "mail-{}@example.invalid".format(i),
|
|
||||||
}
|
|
||||||
for i in range(3)
|
|
||||||
}
|
|
||||||
WILL_JSON_COMPACT = json.dumps(WILL_ITEMS, separators=(",", ":"))
|
|
||||||
WILL_JSON_PRETTY = json.dumps(WILL_ITEMS, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Parity: app's decode_will_payload vs the plugin's dialogs.py original
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
def build_extracted_and_app_function():
|
|
||||||
dialogs_source = DIALOGS.read_text()
|
|
||||||
app_source = Path(payload_codec.__file__).read_text()
|
|
||||||
dialogs_node = find_function(ast.parse(dialogs_source), "decode_will_payload")
|
|
||||||
app_node = find_function(ast.parse(app_source), "decode_will_payload")
|
|
||||||
ok(
|
|
||||||
ast.dump(app_node) == ast.dump(dialogs_node),
|
|
||||||
"decode_will_payload AST identical between app copy and plugin",
|
|
||||||
)
|
|
||||||
namespace = {}
|
|
||||||
exec("import json\nimport re\nfrom typing import Any", namespace)
|
|
||||||
exec(compile(ast.Module(body=[dialogs_node], type_ignores=[]), "dialogs.py", "exec"), namespace)
|
|
||||||
return namespace["decode_will_payload"]
|
|
||||||
|
|
||||||
|
|
||||||
def run_payload_parity_cases():
|
|
||||||
plugin_decode = build_extracted_and_app_function()
|
|
||||||
samples = {
|
|
||||||
"will-compact": WILL_JSON_COMPACT,
|
|
||||||
"will-pretty": WILL_JSON_PRETTY,
|
|
||||||
"txs-newlines": "\n".join(TXS),
|
|
||||||
"txs-comma-crlf": ",\r\n".join(TXS[:3]),
|
|
||||||
"single-tx": TXS[0],
|
|
||||||
"json-array": json.dumps(TXS),
|
|
||||||
"not-json-dict": "hello world",
|
|
||||||
"empty": "",
|
|
||||||
"whitespace": " \n\t ",
|
|
||||||
}
|
|
||||||
for label, text in samples.items():
|
|
||||||
app_result = payload_codec.decode_will_payload(text)
|
|
||||||
plugin_result = plugin_decode(text)
|
|
||||||
ok(
|
|
||||||
app_result == plugin_result,
|
|
||||||
"payload parity for {!r}".format(label),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Full decode chain (the app's BalDecoder.finish())
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
def app_chain(transfer_text, compressed):
|
|
||||||
parts = qtf.decode_transfer(transfer_text, compressed)
|
|
||||||
payload = "\n".join(parts)
|
|
||||||
kind, data = payload_codec.decode_will_payload(payload)
|
|
||||||
return parts, payload, kind, data
|
|
||||||
|
|
||||||
|
|
||||||
def feed_frame_set(session, frames, *, drop=None, order=None):
|
|
||||||
indexes = list(range(len(frames)))
|
|
||||||
if drop:
|
|
||||||
indexes = [i for i in indexes if i not in drop]
|
|
||||||
if order is not None:
|
|
||||||
indexes = list(order)
|
|
||||||
for i in indexes:
|
|
||||||
session.add_part(frames[i])
|
|
||||||
|
|
||||||
|
|
||||||
def make_frames(transfer_text, fmt, budget_chars, *, compressed=False):
|
|
||||||
payload = transfer_text.encode("utf-8")
|
|
||||||
if fmt == "balqr":
|
|
||||||
return qtf.split_frames(transfer_text, budget_chars, compressed=compressed)
|
|
||||||
if fmt == "ur1":
|
|
||||||
return aq.ur1_frames(payload, budget_chars)
|
|
||||||
if fmt == "ur2":
|
|
||||||
return aq.ur2_frames(payload, budget_chars)
|
|
||||||
if fmt == "bbqr":
|
|
||||||
return aq.bbqr_frames(payload, budget_chars, encoding="Z")
|
|
||||||
raise AssertionError("unknown format " + fmt)
|
|
||||||
|
|
||||||
|
|
||||||
def run_transport_case(transport, budget_chars, transfer_text, compressed=False):
|
|
||||||
frames = make_frames(transfer_text, transport, budget_chars, compressed=compressed)
|
|
||||||
session = aq.AnimatedQrSession()
|
|
||||||
rng = random.Random(len(transfer_text) + len(transport.encode()))
|
|
||||||
order = [i for i in range(len(frames))]
|
|
||||||
rng.shuffle(order)
|
|
||||||
feed_frame_set(session, frames, order=order)
|
|
||||||
ok(session.done, "{} (.{} chars) reaches done in scrambled order".format(transport, budget_chars))
|
|
||||||
if transport == "ur2":
|
|
||||||
# Fountain indexes can range wider than seq_len, so received may
|
|
||||||
# exceed (or fall short of) total; only progress and completion matter.
|
|
||||||
ok(session.total >= 1 and session.received >= 1,
|
|
||||||
"{} reports positive progress".format(transport))
|
|
||||||
else:
|
|
||||||
ok(
|
|
||||||
session.received == session.total,
|
|
||||||
"{} received matches total".format(transport),
|
|
||||||
)
|
|
||||||
ok(
|
|
||||||
session.received == len(frames) and session.total == len(frames),
|
|
||||||
"{} received/total equals frame count".format(transport),
|
|
||||||
)
|
|
||||||
transfer, compressed_flag = session.resolve()
|
|
||||||
ok(transfer == transfer_text, "{} restores exact transfer text".format(transport))
|
|
||||||
parts, payload, kind, data = app_chain(transfer, compressed_flag)
|
|
||||||
bridge_json = json.loads(bridge_codec.finish(session))
|
|
||||||
ok(
|
|
||||||
bridge_json == {"kind": kind, "payload": payload, "parts": parts},
|
|
||||||
"{} bridge.finish JSON matches the app chain".format(transport),
|
|
||||||
)
|
|
||||||
return parts, payload, kind, data
|
|
||||||
|
|
||||||
|
|
||||||
def test_tx_transports():
|
|
||||||
transfer = qtf.encode_transfer(TXS, compress=False)
|
|
||||||
for transport in ("balqr", "ur1", "ur2", "bbqr"):
|
|
||||||
parts, payload, kind, data = run_transport_case(transport, 400, transfer)
|
|
||||||
ok(kind == "txs", "{} classifies as txs".format(transport))
|
|
||||||
ok(parts == TXS and payload == "\n".join(TXS), "{} yields the tx list".format(transport))
|
|
||||||
|
|
||||||
|
|
||||||
def test_compressed_bal_transport():
|
|
||||||
transfer = qtf.encode_transfer(TXS, compress=True)
|
|
||||||
frames = make_frames(transfer, "balqr", 400, compressed=True)
|
|
||||||
session = aq.AnimatedQrSession()
|
|
||||||
feed_frame_set(session, frames)
|
|
||||||
ok(session.done, "compressed BAL QR done")
|
|
||||||
parts, payload, kind, data = app_chain(*session.resolve())
|
|
||||||
ok(kind == "txs" and parts == TXS, "compressed BAL QR yields the tx list")
|
|
||||||
|
|
||||||
|
|
||||||
def test_will_transports():
|
|
||||||
for transport in ("balqr", "ur1", "ur2", "bbqr"):
|
|
||||||
parts, payload, kind, data = run_transport_case(transport, 400, WILL_JSON_COMPACT)
|
|
||||||
ok(kind == "will", "{} classifies as will".format(transport))
|
|
||||||
ok(data == WILL_ITEMS, "{} restores the whole-will dict".format(transport))
|
|
||||||
# Pretty JSON works too (blank lines are whitespace for json.loads).
|
|
||||||
parts, payload, kind, data = run_transport_case("ur2", 400, WILL_JSON_PRETTY)
|
|
||||||
ok(kind == "will" and data == WILL_ITEMS, "pretty JSON transport restores the will dict")
|
|
||||||
|
|
||||||
|
|
||||||
def test_duplicates_are_ignored():
|
|
||||||
# UR v1 (multi-fragment) dedups by fragment index; UR v2 fountains never
|
|
||||||
# report "dup" (they dedup internally), matching the plugin's behaviour.
|
|
||||||
frames = make_frames(WILL_JSON_COMPACT, "ur1", 200)
|
|
||||||
ok(len(frames) >= 2, "UR v1 yields multiple fragments (got {})".format(len(frames)))
|
|
||||||
session = aq.AnimatedQrSession()
|
|
||||||
for i in range(len(frames)):
|
|
||||||
first = session.add_part(frames[i])
|
|
||||||
dup = session.add_part(frames[i])
|
|
||||||
ok(first == "ok", "fresh UR v1 frame reported as ok")
|
|
||||||
ok(dup == "dup", "duplicate UR v1 frame reported as dup")
|
|
||||||
ok(session.received == len(frames), "duplicates do not inflate received")
|
|
||||||
ok(session.done, "UR v1 completes after duplicates")
|
|
||||||
|
|
||||||
|
|
||||||
def test_ur2_fountain_survives_loss_and_reorder():
|
|
||||||
transfer = qtf.encode_transfer(TXS, compress=False)
|
|
||||||
frames = make_frames(transfer, "ur2", 120)
|
|
||||||
ok(len(frames) >= 6, "fountain yields multiple frames (got {})".format(len(frames)))
|
|
||||||
drop = (0, 2, len(frames) - 1)
|
|
||||||
session = aq.AnimatedQrSession()
|
|
||||||
rng = random.Random(99)
|
|
||||||
order = [i for i in range(len(frames)) if i not in drop]
|
|
||||||
rng.shuffle(order)
|
|
||||||
feed_frame_set(session, frames, order=order, drop=drop)
|
|
||||||
ok(session.done, "fountain completes after dropped + reordered frames")
|
|
||||||
transfer, compressed_flag = session.resolve()
|
|
||||||
ok(transfer == transfer, "fountain restores exact transfer text")
|
|
||||||
parts, payload, kind, data = app_chain(transfer, compressed_flag)
|
|
||||||
ok(kind == "txs" and parts == TXS, "fountain output feeds the full import tail")
|
|
||||||
|
|
||||||
|
|
||||||
def test_ur2_single_part_and_duplicate():
|
|
||||||
transfer = qtf.encode_transfer(TXS, compress=False)
|
|
||||||
frames = make_frames(transfer, "ur2", 2000)
|
|
||||||
ok(len(frames) == 1, "large budget yields a single-part UR v2 frame")
|
|
||||||
session = aq.AnimatedQrSession()
|
|
||||||
session.add_part(frames[0])
|
|
||||||
ok(session.done and session.received == 1, "single-part UR v2 completes")
|
|
||||||
|
|
||||||
|
|
||||||
def test_bbqr_all_three_encodings():
|
|
||||||
for encoding in ("Z", "H", "2"):
|
|
||||||
frames = aq.bbqr_frames(WILL_JSON_COMPACT.encode(), 120, encoding=encoding)
|
|
||||||
session = aq.AnimatedQrSession()
|
|
||||||
feed_frame_set(session, frames)
|
|
||||||
ok(session.done, "BBQR {} done".format(encoding))
|
|
||||||
transfer, compressed = session.resolve()
|
|
||||||
parts, payload, kind, data = app_chain(transfer, compressed)
|
|
||||||
ok(kind == "will" and data == WILL_ITEMS, "BBQR {} restores the will".format(encoding))
|
|
||||||
|
|
||||||
|
|
||||||
def test_mid_transfer_conflict():
|
|
||||||
ur1 = aq.ur1_frames(b"transfer-one", 400)
|
|
||||||
ur2 = aq.ur2_frames(b"transfer-two", 400)
|
|
||||||
session = aq.AnimatedQrSession()
|
|
||||||
session.add_part(ur1[0])
|
|
||||||
try:
|
|
||||||
session.add_part(ur2[0])
|
|
||||||
except aq.TransferConflictError:
|
|
||||||
ok(True, "format switch raises TransferConflictError")
|
|
||||||
else:
|
|
||||||
ok(False, "format switch raised TransferConflictError")
|
|
||||||
|
|
||||||
|
|
||||||
def test_garbage_and_single_line():
|
|
||||||
session = aq.AnimatedQrSession()
|
|
||||||
try:
|
|
||||||
session.add_part("this is not a QR transfer")
|
|
||||||
except aq.FormatNotDetectedError:
|
|
||||||
ok(True, "garbage raises FormatNotDetectedError")
|
|
||||||
else:
|
|
||||||
ok(False, "garbage raised FormatNotDetectedError")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
check_imported_bundle()
|
|
||||||
run_payload_parity_cases()
|
|
||||||
test_tx_transports()
|
|
||||||
test_compressed_bal_transport()
|
|
||||||
test_will_transports()
|
|
||||||
test_duplicates_are_ignored()
|
|
||||||
test_ur2_fountain_survives_loss_and_reorder()
|
|
||||||
test_ur2_single_part_and_duplicate()
|
|
||||||
test_bbqr_all_three_encodings()
|
|
||||||
test_mid_transfer_conflict()
|
|
||||||
test_garbage_and_single_line()
|
|
||||||
print("verify_chain: {} checks passed".format(PASSED))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
"""Standalone QR emitter for testing the Android reader on a real camera.
|
|
||||||
|
|
||||||
Replicates exactly what the plugin's QR export page puts on screen
|
|
||||||
(``BalQrExportWidget``): the same ``encode_transfer`` + per-format frame
|
|
||||||
encoders from ``bal.core``, rendered one QR at a time with ``qrcode``.
|
|
||||||
|
|
||||||
Run from the repo root with the runtime venv (has ``bal``, ``qrcode``,
|
|
||||||
PyQt6):
|
|
||||||
|
|
||||||
source "$BAL_HOME/electrum/env/bin/activate"
|
|
||||||
QT_QPA_PLATFORM=xcb python3 android/tools/emitter.py --format ur2 --loop
|
|
||||||
|
|
||||||
Controls:
|
|
||||||
Left/Right previous / next frame
|
|
||||||
Space toggle autoplay
|
|
||||||
L toggle loop (default off)
|
|
||||||
Q / Esc quit
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
||||||
|
|
||||||
import qrcode # noqa: E402
|
|
||||||
from PyQt6.QtCore import Qt, QTimer # noqa: E402
|
|
||||||
from PyQt6.QtGui import QColor, QImage, QPainter, QPixmap # noqa: E402
|
|
||||||
from PyQt6.QtWidgets import QLabel, QMainWindow, QWidget # noqa: E402
|
|
||||||
|
|
||||||
from bal.core import animated_qr as aq # noqa: E402
|
|
||||||
from bal.core import qrtransfer as qtf # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def build_frames(tx_strings, fmt, budget):
|
|
||||||
"""Frames exactly as BalQrExportWidget._refresh_frames produces them."""
|
|
||||||
transfer = qtf.encode_transfer(tx_strings, compress=False)
|
|
||||||
if fmt == "balqr":
|
|
||||||
return qtf.split_frames(transfer, budget, compressed=False)
|
|
||||||
payload = transfer.encode("utf-8")
|
|
||||||
if fmt == "ur1":
|
|
||||||
return aq.ur1_frames(payload, budget)
|
|
||||||
if fmt == "ur2":
|
|
||||||
return aq.ur2_frames(payload, budget)
|
|
||||||
if fmt == "bbqr":
|
|
||||||
return aq.bbqr_frames(payload, budget, encoding="Z")
|
|
||||||
raise SystemExit("unknown format: {}".format(fmt))
|
|
||||||
|
|
||||||
|
|
||||||
def load_payload(args):
|
|
||||||
"""Return ``(tx_strings, description)`` mirroring ``_payload_strings``."""
|
|
||||||
if args.will is not None:
|
|
||||||
will = json.loads(Path(args.will).read_text())
|
|
||||||
return (
|
|
||||||
[json.dumps(will, ensure_ascii=False)],
|
|
||||||
"whole-will JSON ({})".format(Path(args.will).name),
|
|
||||||
)
|
|
||||||
if args.txs is not None:
|
|
||||||
raw = Path(args.txs).read_text()
|
|
||||||
return [line.strip() for line in raw.split() if line.strip()], "txs"
|
|
||||||
raise SystemExit("give --will FILE or --txs FILE")
|
|
||||||
|
|
||||||
|
|
||||||
def qr_pixmap(text, size):
|
|
||||||
"""Render ``text`` as a QR code fitted to a ``size``x``size`` image."""
|
|
||||||
qr = qrcode.QRCode(border=2, error_correction=qrcode.constants.ERROR_CORRECT_M)
|
|
||||||
qr.add_data(text)
|
|
||||||
qr.make(fit=True)
|
|
||||||
matrix = qr.modules
|
|
||||||
n = len(matrix)
|
|
||||||
border = qr.border
|
|
||||||
scale = max(1, size // (n + 2 * border))
|
|
||||||
dim = (n + 2 * border) * scale
|
|
||||||
image = QImage(dim, dim, QImage.Format.Format_RGB32)
|
|
||||||
image.fill(Qt.GlobalColor.white)
|
|
||||||
painter = QPainter(image)
|
|
||||||
painter.fillRect(0, 0, dim, dim, QColor("white"))
|
|
||||||
painter.setBrush(QColor("black"))
|
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
|
||||||
for y, row in enumerate(matrix):
|
|
||||||
for x, on in enumerate(row):
|
|
||||||
if on:
|
|
||||||
painter.fillRect(
|
|
||||||
(x + border) * scale, (y + border) * scale, scale, scale,
|
|
||||||
QColor("black"),
|
|
||||||
)
|
|
||||||
painter.end()
|
|
||||||
return QPixmap.fromImage(image).scaled(
|
|
||||||
size, size, Qt.AspectRatioMode.KeepAspectRatio,
|
|
||||||
Qt.TransformationMode.SmoothTransformation,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EmitterWindow(QMainWindow):
|
|
||||||
def __init__(self, frames, description, fmt, fps, loop):
|
|
||||||
super().__init__()
|
|
||||||
self.frames = frames
|
|
||||||
self.fps = fps
|
|
||||||
self.loop = loop
|
|
||||||
self.index = 0
|
|
||||||
self.autoplay = True
|
|
||||||
|
|
||||||
central = QWidget(self)
|
|
||||||
self.setCentralWidget(central)
|
|
||||||
self.pix = QLabel(central)
|
|
||||||
self.pix.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
||||||
self.caption = QLabel(central)
|
|
||||||
self.caption.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
||||||
|
|
||||||
import PyQt6.QtWidgets as qt # noqa: N813 - local import for clarity
|
|
||||||
|
|
||||||
v = qt.QVBoxLayout(central)
|
|
||||||
v.addWidget(self.pix, 1)
|
|
||||||
v.addWidget(self.caption)
|
|
||||||
|
|
||||||
self.setWindowTitle("BAL Reader emitter — {}".format(fmt))
|
|
||||||
self.resize(900, 1000)
|
|
||||||
|
|
||||||
self.timer = QTimer(self)
|
|
||||||
self.timer.timeout.connect(self._step)
|
|
||||||
self.timer.start(int(1000 / self.fps))
|
|
||||||
|
|
||||||
self._render()
|
|
||||||
self.caption.setText(
|
|
||||||
"{desc} | {fmt} | frame {i}/{n} | autoplay={auto} loop={loop}".format(
|
|
||||||
desc=description, fmt=fmt, i=self.index + 1, n=len(self.frames),
|
|
||||||
auto="on" if self.autoplay else "off", loop="on" if loop else "off",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.show()
|
|
||||||
|
|
||||||
def _render(self):
|
|
||||||
self.pix.setPixmap(qr_pixmap(self.frames[self.index], min(self.width() - 60, 900)))
|
|
||||||
|
|
||||||
def _update_caption(self):
|
|
||||||
auto = "on" if self.autoplay else "off"
|
|
||||||
loop = "on" if self.loop else "off"
|
|
||||||
self.caption.setText(
|
|
||||||
"frame {i}/{n} ({fmt}) | autoplay={auto} loop={loop}".format(
|
|
||||||
i=self.index + 1, n=len(self.frames), fmt="", auto=auto, loop=loop)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _step(self):
|
|
||||||
if self.index + 1 < len(self.frames):
|
|
||||||
self.index += 1
|
|
||||||
elif self.loop:
|
|
||||||
self.index = 0
|
|
||||||
else:
|
|
||||||
self.autoplay = False
|
|
||||||
self.timer.stop()
|
|
||||||
self._render()
|
|
||||||
self._update_caption()
|
|
||||||
|
|
||||||
def keyPressEvent(self, event): # noqa: N802 - Qt override name
|
|
||||||
key = event.key()
|
|
||||||
if key == Qt.Key.Key_Right:
|
|
||||||
self.autoplay = False
|
|
||||||
self.index = min(self.index + 1, len(self.frames) - 1)
|
|
||||||
self._render()
|
|
||||||
elif key == Qt.Key.Key_Left:
|
|
||||||
self.autoplay = False
|
|
||||||
self.index = max(self.index - 1, 0)
|
|
||||||
self._render()
|
|
||||||
elif key == Qt.Key.Key_Space:
|
|
||||||
self.autoplay = not self.autoplay
|
|
||||||
if self.autoplay:
|
|
||||||
self.timer.start(int(1000 / self.fps))
|
|
||||||
else:
|
|
||||||
self.timer.stop()
|
|
||||||
elif key == Qt.Key.Key_L:
|
|
||||||
self.loop = not self.loop
|
|
||||||
elif key in (Qt.Key.Key_Q, Qt.Key.Key_Escape):
|
|
||||||
self.close()
|
|
||||||
self._update_caption()
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv):
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("--format", choices=["balqr", "ur1", "ur2", "bbqr"],
|
|
||||||
default="balqr")
|
|
||||||
parser.add_argument("--budget", type=int, default=400)
|
|
||||||
parser.add_argument("--fps", type=float, default=1.0)
|
|
||||||
parser.add_argument("--loop", action="store_true")
|
|
||||||
parser.add_argument("--will", help="whole-will JSON file")
|
|
||||||
parser.add_argument("--txs", help="file with serialized tx strings")
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
tx_strings, description = load_payload(args)
|
|
||||||
frames = build_frames(tx_strings, args.format, args.budget)
|
|
||||||
if len(frames) == 1:
|
|
||||||
print("single-frame transfer ready ({} bytes)".format(len(frames[0])), file=sys.stderr)
|
|
||||||
else:
|
|
||||||
print("{} frames ready".format(len(frames)), file=sys.stderr)
|
|
||||||
|
|
||||||
app = __import__("PyQt6.QtWidgets", fromlist=["QApplication"]).QApplication([])
|
|
||||||
EmitterWindow(frames, description, args.format, args.fps, args.loop)
|
|
||||||
app.exec()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main(sys.argv[1:])
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"imp-heiress-1": {
|
|
||||||
"tx": "0f1e2d3c4b5a69788796a5b4c3d2e1f0112233445566778899aabbccddeeff00",
|
|
||||||
"addr": "bc1qdeadbeef0",
|
|
||||||
"amount": 100000,
|
|
||||||
"tag": "heiress-1",
|
|
||||||
"metadata": {},
|
|
||||||
"notify": "heir1@example.invalid"
|
|
||||||
},
|
|
||||||
"imp-heiress-2": {
|
|
||||||
"tx": "112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011",
|
|
||||||
"addr": "bc1qdeadbeef1",
|
|
||||||
"amount": 200000,
|
|
||||||
"tag": "heiress-2",
|
|
||||||
"metadata": {},
|
|
||||||
"notify": "heir2@example.invalid"
|
|
||||||
},
|
|
||||||
"imp-heiress-3": {
|
|
||||||
"tx": "a1b2c3d4e5f60718293a4b5c6d7e8f901a2b3c4d5e6f708192a3b4c5d6e7f809",
|
|
||||||
"addr": "bc1qdeadbeef2",
|
|
||||||
"amount": 300000,
|
|
||||||
"tag": "heiress-3",
|
|
||||||
"metadata": {},
|
|
||||||
"notify": "heir3@example.invalid"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,8 +12,8 @@ servers.
|
|||||||
already-signed will is handled safely. Postponing a signed/sent will first
|
already-signed will is handled safely. Postponing a signed/sent will first
|
||||||
asks you to invalidate the old transaction on-chain (so a will-executor can
|
asks you to invalidate the old transaction on-chain (so a will-executor can
|
||||||
never broadcast the earlier-locktime transaction and execute the inheritance
|
never broadcast the earlier-locktime transaction and execute the inheritance
|
||||||
too early), then lets you rebuild and re-send the new one via the
|
too early), then lets you rebuild and re-send the new one via
|
||||||
**Prepare** button on the **WILL** tab.
|
**Tools → Prepare**.
|
||||||
- **"Server" column**: the will transaction list shows whether each transaction
|
- **"Server" column**: the will transaction list shows whether each transaction
|
||||||
is actually stored on the will-executor servers
|
is actually stored on the will-executor servers
|
||||||
(`Confirmed on server`, `Sent (not checked)`, `Send failed`,
|
(`Confirmed on server`, `Sent (not checked)`, `Send failed`,
|
||||||
|
|||||||
1
bal/VERSION
Normal file
1
bal/VERSION
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0.6.0
|
||||||
@@ -24,18 +24,11 @@ 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``; the command-line/daemon entry point is ``cmdline.py``
|
from ``gui.qt.plugin``.
|
||||||
(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
|
||||||
@@ -43,89 +36,4 @@ The plugin supports Electrum 4.7.2 and 4.8.0 with PyQt6. Electrum 4.8.0 removed
|
|||||||
available and adapts, so both releases keep working.
|
available and adapts, so both releases keep working.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# The plugin version is NOT defined here. It lives only in ``bal/manifest.json``
|
__version__ = "0.5.18"
|
||||||
# (the single source of truth) and is read at runtime via ``get_version()`` in
|
|
||||||
# ``bal/core/plugin_base.py`` (exposed as the ``BalPlugin.version`` property).
|
|
||||||
# Keeping a hardcoded ``__version__`` here would just be a stale duplicate.
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# 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()
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
"""
|
|
||||||
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``.
|
|
||||||
"""
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,159 +0,0 @@
|
|||||||
"""
|
|
||||||
bal.core.checkalive
|
|
||||||
===================
|
|
||||||
|
|
||||||
The "Check Alive" policy: the single reference timestamp (``date_to_check``)
|
|
||||||
against which every will-validity check is evaluated, and the BASIC/ADVANCED
|
|
||||||
mode rules that decide it.
|
|
||||||
|
|
||||||
Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the
|
|
||||||
postpone/invalidate flow; the decision that it *should* be raised lives here.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime, 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):
|
|
||||||
self.timestamp_to_check = timestamp_to_check
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return "Check alive expired please update it: {}".format(
|
|
||||||
datetime.fromtimestamp(self.timestamp_to_check, tz=timezone.utc).isoformat()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_date_to_check(
|
|
||||||
is_basic_mode: bool,
|
|
||||||
will_settings: Any,
|
|
||||||
now: float | None = None,
|
|
||||||
built_locktime: float | int | None = None,
|
|
||||||
) -> float:
|
|
||||||
"""Return the reference timestamp for every will-validity check.
|
|
||||||
|
|
||||||
``date_to_check`` is the single reference timestamp that EVERY downstream
|
|
||||||
check reads: the build filter, the heir count, ``check_will_expired``,
|
|
||||||
``check_amounts`` and the locktime-vs-threshold guard.
|
|
||||||
|
|
||||||
* BASIC mode: the Check Alive is hidden and NOT editable, so it must never
|
|
||||||
govern those checks. ``date_to_check`` is set to *now*: every check is
|
|
||||||
evaluated against the current moment (the Check Alive effectively does not
|
|
||||||
exist) while the delivery locktime is still fully enforced.
|
|
||||||
* ADVANCED mode: the user-controlled stored threshold is used as-is. An
|
|
||||||
ABSOLUTE threshold is returned unchanged; a RELATIVE one (``"30d"``/``"1y"``)
|
|
||||||
means "N days BEFORE the delivery date" and is resolved against the stored
|
|
||||||
locktime (matching the date the settings widget displays), so it stays in
|
|
||||||
lockstep with the built transactions instead of drifting with the clock.
|
|
||||||
|
|
||||||
A RELATIVE stored locktime is resolved against the frozen delivery date of
|
|
||||||
the built will (``built_locktime``, the locktime inside the signed tx) when
|
|
||||||
one exists: the will's real delivery date is authoritative, and resolving
|
|
||||||
the relative locktime from *now* would drift ``date_to_check`` past the
|
|
||||||
frozen tx locktime so an unchanged will wrongly reads as expired (asking to
|
|
||||||
invalidate) every day. Without a built will the legacy forward-from-now
|
|
||||||
resolution is kept.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
is_basic_mode: ``True`` for the SIMPLE / BASIC user type.
|
|
||||||
will_settings: the per-wallet settings dict (``"threshold"`` and
|
|
||||||
``"locktime"`` keys).
|
|
||||||
now: overridable clock for tests; defaults to ``datetime.now()``.
|
|
||||||
built_locktime: the absolute locktime frozen inside the built will's
|
|
||||||
transactions (``None`` when there is no built will yet).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The reference timestamp (float, UNIX seconds).
|
|
||||||
"""
|
|
||||||
if is_basic_mode:
|
|
||||||
return (now if now is not None else datetime.now(tz=timezone.utc).timestamp())
|
|
||||||
|
|
||||||
threshold = BalTimestamp(will_settings["threshold"])
|
|
||||||
# A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery":
|
|
||||||
# the settings widget resolves it as real_threshold = locktime - N days.
|
|
||||||
# Resolving it FORWARD from now (BalTimestamp.to_timestamp) turns
|
|
||||||
# date_to_check into a moving target that disagrees with the fixed
|
|
||||||
# locktime of the built transactions (and with the date shown in the UI),
|
|
||||||
# which can wrongly mark the will as expired/postponed. Resolve it
|
|
||||||
# against the delivery date instead.
|
|
||||||
if threshold.unit is not None:
|
|
||||||
locktime_raw = will_settings.get("locktime")
|
|
||||||
if locktime_raw is None:
|
|
||||||
# No delivery reference to anchor to: fall back to the legacy
|
|
||||||
# forward-from-now resolution.
|
|
||||||
return threshold.to_timestamp()
|
|
||||||
locktime_dt = BalTimestamp(locktime_raw).to_date(now)
|
|
||||||
# A RELATIVE stored locktime ("2y") is itself a moving target; when a
|
|
||||||
# will has already been built, its frozen delivery date (the tx
|
|
||||||
# locktime) is the authoritative anchor (see docstring).
|
|
||||||
if BalTimestamp(locktime_raw).unit is not None and built_locktime:
|
|
||||||
locktime_dt = BalTimestamp(int(built_locktime)).to_date(now)
|
|
||||||
return threshold.to_date(locktime_dt, reverse=True).timestamp()
|
|
||||||
return threshold.to_timestamp()
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_guard_threshold(
|
|
||||||
is_basic_mode: bool,
|
|
||||||
will_settings: Any,
|
|
||||||
now: 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**. 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 (``"30d"``/``"1y"``, meaning
|
|
||||||
"N days BEFORE the delivery"): the threshold is anchored to the locktime
|
|
||||||
resolved forward from *now* (the settings' own delivery reading, never a
|
|
||||||
built tx), keeping both sides of the comparison in the same reference
|
|
||||||
frame, as the settings widget displays it.
|
|
||||||
|
|
||||||
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()
|
|
||||||
now_dt = (
|
|
||||||
datetime.fromtimestamp(now, tz=timezone.utc) if now is not None else None
|
|
||||||
)
|
|
||||||
locktime_dt = BalTimestamp(will_settings["locktime"]).to_date(now_dt)
|
|
||||||
return threshold.to_date(locktime_dt, reverse=True).timestamp()
|
|
||||||
|
|
||||||
|
|
||||||
def check_alive_expired(
|
|
||||||
is_basic_mode: bool, date_to_check: float, now: float | None = None
|
|
||||||
) -> bool:
|
|
||||||
"""True when the Check Alive guard should fire (``CheckAliveError``).
|
|
||||||
|
|
||||||
Only ADVANCED mode can be "expired": in BASIC mode the Check Alive is inert
|
|
||||||
by construction, so a passed check-alive date must never force a postpone or
|
|
||||||
rewrite of the will.
|
|
||||||
"""
|
|
||||||
if is_basic_mode:
|
|
||||||
return False
|
|
||||||
current = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
|
|
||||||
return date_to_check < current
|
|
||||||
@@ -21,8 +21,6 @@ Will-executor "heirs" are synthetic entries whose key starts with the
|
|||||||
``w!ll3x3c"`` marker; they are skipped by most heir comparisons.
|
``w!ll3x3c"`` marker; they are skipped by most heir comparisons.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import inspect
|
|
||||||
import math
|
import math
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
@@ -33,7 +31,6 @@ from typing import (
|
|||||||
Dict,
|
Dict,
|
||||||
Optional,
|
Optional,
|
||||||
Tuple,
|
Tuple,
|
||||||
cast,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
import dns
|
import dns
|
||||||
@@ -59,7 +56,7 @@ from electrum.util import (
|
|||||||
write_json_file,
|
write_json_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .util import Util, copy_structure
|
from .util import Util
|
||||||
from .willexecutors import Willexecutors
|
from .willexecutors import Willexecutors
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -68,19 +65,6 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
_logger = get_logger(__name__)
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _query_txt_records(url: str) -> Tuple[Any, bool]:
|
|
||||||
"""Resolve TXT records with DNSSEC validation.
|
|
||||||
|
|
||||||
``electrum.dnssec.query`` became an ``async`` function in Electrum 4.7.2,
|
|
||||||
so adapt the call for this synchronous context while staying compatible
|
|
||||||
with older synchronous implementations.
|
|
||||||
"""
|
|
||||||
query = dnssec.query
|
|
||||||
if inspect.iscoroutinefunction(query):
|
|
||||||
return asyncio.run(query(url, dns.rdatatype.TXT))
|
|
||||||
return cast(Tuple[Any, bool], query(url, dns.rdatatype.TXT))
|
|
||||||
|
|
||||||
# Column layout of a stored heir list. These indices are part of the on-disk
|
# Column layout of a stored heir list. These indices are part of the on-disk
|
||||||
# wallet format and are relied upon all over the codebase, so they must NEVER
|
# wallet format and are relied upon all over the codebase, so they must NEVER
|
||||||
# be reordered.
|
# be reordered.
|
||||||
@@ -91,8 +75,6 @@ HEIR_REAL_AMOUNT = 3 # resolved amount once percentages are computed
|
|||||||
HEIR_DUST_AMOUNT = 4 # amount when below dust threshold (marked "DUST: ...")
|
HEIR_DUST_AMOUNT = 4 # amount when below dust threshold (marked "DUST: ...")
|
||||||
TRANSACTION_LABEL = "inheritance transaction"
|
TRANSACTION_LABEL = "inheritance transaction"
|
||||||
|
|
||||||
OP_RETURN_PREFIX = "OP_RETURN:"
|
|
||||||
|
|
||||||
|
|
||||||
class AliasNotFoundException(Exception):
|
class AliasNotFoundException(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -104,27 +86,6 @@ def reduce_outputs(in_amount, out_amount, fee, outputs):
|
|||||||
output.value = math.floor((in_amount - fee) / out_amount * output.value)
|
output.value = math.floor((in_amount - fee) / out_amount * output.value)
|
||||||
|
|
||||||
|
|
||||||
def is_op_return_address(address: str) -> bool:
|
|
||||||
return str(address).startswith(OP_RETURN_PREFIX)
|
|
||||||
|
|
||||||
|
|
||||||
def get_op_return_hex(address: str) -> Optional[str]:
|
|
||||||
if is_op_return_address(address):
|
|
||||||
return address[len(OP_RETURN_PREFIX):]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def validate_op_return_hex(data_hex: str) -> None:
|
|
||||||
try:
|
|
||||||
data = bytes.fromhex(data_hex)
|
|
||||||
except ValueError:
|
|
||||||
raise NotAnAddress(f"OP_RETURN data is not valid hex: {data_hex}") from None
|
|
||||||
if len(data) > 80:
|
|
||||||
raise NotAnAddress(
|
|
||||||
f"OP_RETURN data too long ({len(data)} bytes, max 80)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_op_return_script(data_hex: str) -> bytes:
|
def create_op_return_script(data_hex: str) -> bytes:
|
||||||
"""Crea scriptpubkey OP_RETURN in bytes"""
|
"""Crea scriptpubkey OP_RETURN in bytes"""
|
||||||
data = bytes.fromhex(data_hex)
|
data = bytes.fromhex(data_hex)
|
||||||
@@ -171,22 +132,14 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
|
|||||||
heir[HEIR_REAL_AMOUNT]
|
heir[HEIR_REAL_AMOUNT]
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
if is_op_return_address(heir[HEIR_ADDRESS]):
|
real_amount = heir[HEIR_REAL_AMOUNT]
|
||||||
data_hex = heir[HEIR_ADDRESS][len(OP_RETURN_PREFIX):]
|
outputs.append(
|
||||||
op_return_script = create_op_return_script(data_hex)
|
PartialTxOutput.from_address_and_value(
|
||||||
outputs.append(
|
heir[HEIR_ADDRESS], real_amount
|
||||||
PartialTxOutput(value=0, scriptpubkey=op_return_script)
|
|
||||||
)
|
)
|
||||||
description += f"{name}\n"
|
)
|
||||||
else:
|
out_amount += real_amount
|
||||||
real_amount = heir[HEIR_REAL_AMOUNT]
|
description += f"{name}\n"
|
||||||
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
|
||||||
@@ -221,7 +174,7 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
|
|||||||
change = get_change_output(wallet, in_amount, out_amount, fee)
|
change = get_change_output(wallet, in_amount, out_amount, fee)
|
||||||
if change:
|
if change:
|
||||||
outputs.append(change)
|
outputs.append(change)
|
||||||
for _ in range(0, 100):
|
for i in range(0, 100):
|
||||||
random.shuffle(outputs)
|
random.shuffle(outputs)
|
||||||
|
|
||||||
#op_return_text = "Hello Bal!"
|
#op_return_text = "Hello Bal!"
|
||||||
@@ -277,7 +230,6 @@ def get_utxos_from_inputs(tx_inputs, tx, utxos):
|
|||||||
|
|
||||||
# TODO calculate de minimum inputs to be invalidated
|
# TODO calculate de minimum inputs to be invalidated
|
||||||
def invalidate_inheritance_transactions(wallet):
|
def invalidate_inheritance_transactions(wallet):
|
||||||
_logger.debug("invalidate tx in heir method")
|
|
||||||
# listids = []
|
# listids = []
|
||||||
utxos = {}
|
utxos = {}
|
||||||
dtxs = {}
|
dtxs = {}
|
||||||
@@ -297,7 +249,7 @@ def invalidate_inheritance_transactions(wallet):
|
|||||||
del dtxs[txid]
|
del dtxs[txid]
|
||||||
|
|
||||||
utxos = {}
|
utxos = {}
|
||||||
for _, tx in dtxs.items():
|
for txid, tx in dtxs.items():
|
||||||
get_utxos_from_inputs(tx.inputs(), tx, utxos)
|
get_utxos_from_inputs(tx.inputs(), tx, utxos)
|
||||||
|
|
||||||
utxos = sorted(utxos.items(), key=lambda item: len(item[1]))
|
utxos = sorted(utxos.items(), key=lambda item: len(item[1]))
|
||||||
@@ -312,6 +264,39 @@ def invalidate_inheritance_transactions(wallet):
|
|||||||
remaining[key] = value
|
remaining[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def print_transaction(heirs, tx, locktimes, tx_fees):
|
||||||
|
jtx = tx.to_json()
|
||||||
|
print(f"TX: {tx.txid()}\t-\tLocktime: {jtx['locktime']}")
|
||||||
|
print("---")
|
||||||
|
for inp in jtx["inputs"]:
|
||||||
|
print(f"{inp['address']}: {inp['value_sats']}")
|
||||||
|
print("---")
|
||||||
|
for out in jtx["outputs"]:
|
||||||
|
heirname = ""
|
||||||
|
for key in heirs.keys():
|
||||||
|
heir = heirs[key]
|
||||||
|
if heir[HEIR_ADDRESS] == out["address"] and str(heir[HEIR_LOCKTIME]) == str(
|
||||||
|
jtx["locktime"]
|
||||||
|
):
|
||||||
|
heirname = key
|
||||||
|
print(f"{heirname}\t{out['address']}: {out['value_sats']}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
size = tx.estimated_size()
|
||||||
|
print(
|
||||||
|
"fee: {}\texpected: {}\tsize: {}".format(
|
||||||
|
tx.input_value() - tx.output_value(), size * tx_fees, size
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
try:
|
||||||
|
print(tx.serialize_to_network())
|
||||||
|
except Exception:
|
||||||
|
print("impossible to serialize")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
def get_change_output(wallet, in_amount, out_amount, fee):
|
def get_change_output(wallet, in_amount, out_amount, fee):
|
||||||
change_amount = int(in_amount - out_amount - fee)
|
change_amount = int(in_amount - out_amount - fee)
|
||||||
if change_amount > wallet.dust_threshold():
|
if change_amount > wallet.dust_threshold():
|
||||||
@@ -321,14 +306,40 @@ def get_change_output(wallet, in_amount, out_amount, fee):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _json_safe(value, _path="heirs"):
|
def _json_safe(value, _path="heirs", _depth=0):
|
||||||
"""Backward-compatible alias of :func:`bal.core.util.copy_structure`.
|
"""Return a JSON-serializable deep copy of *value*.
|
||||||
|
|
||||||
Kept so call sites that imported ``_json_safe`` directly keep working; the
|
The wallet DB persists the heirs dict via ``json_db.put``, which calls
|
||||||
actual implementation (a JSON-safe, deepcopy-free clone) lives in
|
``copy.deepcopy`` on the value. If any nested element is a live runtime
|
||||||
``bal.core.util`` so every copy path shares one code base.
|
object (e.g. one holding a ``threading.RLock``), deepcopy raises
|
||||||
|
``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.
|
||||||
"""
|
"""
|
||||||
return copy_structure(value, _path=_path)
|
# 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): _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):
|
||||||
@@ -337,10 +348,6 @@ 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)
|
||||||
@@ -393,9 +400,6 @@ class Heirs(dict, Logger):
|
|||||||
amount = 0
|
amount = 0
|
||||||
for key, v in heir_list.items():
|
for key, v in heir_list.items():
|
||||||
try:
|
try:
|
||||||
if is_op_return_address(v[HEIR_ADDRESS]):
|
|
||||||
heir_list[key].insert(HEIR_REAL_AMOUNT, 0)
|
|
||||||
continue
|
|
||||||
column = HEIR_AMOUNT
|
column = HEIR_AMOUNT
|
||||||
if real:
|
if real:
|
||||||
column = HEIR_REAL_AMOUNT
|
column = HEIR_REAL_AMOUNT
|
||||||
@@ -447,12 +451,6 @@ class Heirs(dict, Logger):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if is_op_return_address(self[key][HEIR_ADDRESS]):
|
|
||||||
heir = list(self[key])
|
|
||||||
heir.insert(HEIR_REAL_AMOUNT, 0)
|
|
||||||
fixed_heirs[key] = heir
|
|
||||||
_logger.debug(f"OP_RETURN heir {key} excluded from amount calculation")
|
|
||||||
continue
|
|
||||||
if Util.is_perc(self[key][HEIR_AMOUNT]):
|
if Util.is_perc(self[key][HEIR_AMOUNT]):
|
||||||
percent_amount += float(self[key][HEIR_AMOUNT][:-1])
|
percent_amount += float(self[key][HEIR_AMOUNT][:-1])
|
||||||
percent_heirs[key] = list(self[key])
|
percent_heirs[key] = list(self[key])
|
||||||
@@ -480,8 +478,7 @@ class Heirs(dict, Logger):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def prepare_lists(
|
def prepare_lists(
|
||||||
self, balance, total_fees, wallet, willexecutor: Optional[dict] = None,
|
self, balance, total_fees, wallet, willexecutor=False, from_locktime=0
|
||||||
from_locktime=0, max_fee=None,
|
|
||||||
):
|
):
|
||||||
if balance<total_fees or balance < wallet.dust_threshold():
|
if balance<total_fees or balance < wallet.dust_threshold():
|
||||||
raise BalanceTooLowException(balance,wallet.dust_threshold(),total_fees)
|
raise BalanceTooLowException(balance,wallet.dust_threshold(),total_fees)
|
||||||
@@ -496,12 +493,8 @@ class Heirs(dict, Logger):
|
|||||||
if int(Util.int_locktime(locktime)) > int(from_locktime):
|
if int(Util.int_locktime(locktime)) > int(from_locktime):
|
||||||
try:
|
try:
|
||||||
base_fee = int(willexecutor["base_fee"])
|
base_fee = int(willexecutor["base_fee"])
|
||||||
if max_fee is not None and base_fee > max_fee:
|
|
||||||
raise WillExecutorFeeTooHighException(
|
|
||||||
willexecutor, max_fee
|
|
||||||
)
|
|
||||||
willexecutors_amount += base_fee
|
willexecutors_amount += base_fee
|
||||||
h: list = [None] * 4
|
h = [None] * 4
|
||||||
h[HEIR_AMOUNT] = base_fee
|
h[HEIR_AMOUNT] = base_fee
|
||||||
h[HEIR_REAL_AMOUNT] = base_fee
|
h[HEIR_REAL_AMOUNT] = base_fee
|
||||||
h[HEIR_LOCKTIME] = locktime
|
h[HEIR_LOCKTIME] = locktime
|
||||||
@@ -593,8 +586,6 @@ class Heirs(dict, Logger):
|
|||||||
heir[HEIR_REAL_AMOUNT]
|
heir[HEIR_REAL_AMOUNT]
|
||||||
):
|
):
|
||||||
valid_real_heirs += 1
|
valid_real_heirs += 1
|
||||||
elif len(heir) > HEIR_REAL_AMOUNT and is_op_return_address(heir[HEIR_ADDRESS]):
|
|
||||||
valid_real_heirs += 1
|
|
||||||
if real_heirs > 0 and valid_real_heirs == 0:
|
if real_heirs > 0 and valid_real_heirs == 0:
|
||||||
raise HeirAmountIsDustException(
|
raise HeirAmountIsDustException(
|
||||||
"All heirs' shares are below the dust limit"
|
"All heirs' shares are below the dust limit"
|
||||||
@@ -608,35 +599,8 @@ 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
|
||||||
):
|
):
|
||||||
# Reset the diagnostic reason at the start of every build attempt.
|
Heirs._validate(self)
|
||||||
#
|
|
||||||
# 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
|
||||||
@@ -648,43 +612,35 @@ class Heirs(dict, Logger):
|
|||||||
self.decimal_point = bal_plugin.get_decimal_point()
|
self.decimal_point = bal_plugin.get_decimal_point()
|
||||||
no_willexecutors = bal_plugin.NO_WILLEXECUTOR.get()
|
no_willexecutors = bal_plugin.NO_WILLEXECUTOR.get()
|
||||||
for utxo in utxos:
|
for utxo in utxos:
|
||||||
if utxo.value_sats() > 0:
|
if utxo.value_sats() > 0 * tx_fees:
|
||||||
balance += utxo.value_sats()
|
balance += utxo.value_sats()
|
||||||
len_utxo_set += 1
|
len_utxo_set += 1
|
||||||
available_utxos.append(utxo)
|
available_utxos.append(utxo)
|
||||||
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) and Willexecutors.is_valid(willexecutor, max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(), dust=wallet.dust_threshold())):
|
if not Willexecutors.is_selected(willexecutor) or willexecutor["base_fee"] < wallet.dust_threshold():
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
willexecutor["url"] = url
|
willexecutor["url"] = url
|
||||||
elif j == -1:
|
elif j == -1:
|
||||||
if not no_willexecutors:
|
if not no_willexecutors:
|
||||||
continue
|
continue
|
||||||
url = willexecutor = None
|
url = willexecutor = False
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
processed_willexecutors += 1
|
|
||||||
fees = {}
|
fees = {}
|
||||||
i = 0
|
i = 0
|
||||||
txs = {}
|
|
||||||
while i < 10:
|
while i < 10:
|
||||||
txs = {}
|
txs = {}
|
||||||
redo = False
|
redo = False
|
||||||
@@ -695,15 +651,9 @@ 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:
|
||||||
@@ -712,33 +662,19 @@ 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(
|
||||||
"build transactions: error preparing transactions "
|
f"build transactions: error preparing transactions: {e}"
|
||||||
"for will-executor %s: %r",
|
|
||||||
(willexecutor or {}).get("url", "(none)"),
|
|
||||||
e,
|
|
||||||
)
|
)
|
||||||
break
|
try:
|
||||||
|
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
|
||||||
@@ -762,26 +698,12 @@ 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(
|
||||||
@@ -854,7 +776,7 @@ class Heirs(dict, Logger):
|
|||||||
# support email-style addresses, per the OA standard
|
# support email-style addresses, per the OA standard
|
||||||
url = url.replace("@", ".")
|
url = url.replace("@", ".")
|
||||||
try:
|
try:
|
||||||
records, validated = _query_txt_records(url)
|
records, validated = dnssec.query(url, dns.rdatatype.TXT)
|
||||||
except DNSException as e:
|
except DNSException as e:
|
||||||
_logger.info(f"Error resolving openalias: {repr(e)}")
|
_logger.info(f"Error resolving openalias: {repr(e)}")
|
||||||
return None
|
return None
|
||||||
@@ -863,78 +785,60 @@ class Heirs(dict, Logger):
|
|||||||
string = to_string(record.strings[0], "utf8")
|
string = to_string(record.strings[0], "utf8")
|
||||||
if string.startswith("oa1:" + prefix):
|
if string.startswith("oa1:" + prefix):
|
||||||
address = cls.find_regex(string, r"recipient_address=([A-Za-z0-9]+)")
|
address = cls.find_regex(string, r"recipient_address=([A-Za-z0-9]+)")
|
||||||
if not address:
|
|
||||||
continue
|
|
||||||
name = cls.find_regex(string, r"recipient_name=([^;]+)")
|
name = cls.find_regex(string, r"recipient_name=([^;]+)")
|
||||||
if not name:
|
if not name:
|
||||||
name = address
|
name = address
|
||||||
|
if not address:
|
||||||
|
continue
|
||||||
return address, name, validated
|
return address, name, validated
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_regex(haystack, needle) -> Optional[str]:
|
def find_regex(haystack, needle):
|
||||||
regex = re.compile(needle)
|
regex = re.compile(needle)
|
||||||
try:
|
try:
|
||||||
return regex.search(haystack).groups()[0]
|
return regex.search(haystack).groups()[0]
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def validate_address(address):
|
def validate_address(address):
|
||||||
if is_op_return_address(address):
|
|
||||||
data_hex = address[len(OP_RETURN_PREFIX):]
|
|
||||||
validate_op_return_hex(data_hex)
|
|
||||||
return address
|
|
||||||
if not bitcoin.is_address(address, net=constants.net):
|
if not bitcoin.is_address(address, net=constants.net):
|
||||||
raise NotAnAddress(f"not an address,{address}")
|
raise NotAnAddress(f"not an address,{address}")
|
||||||
return address
|
return address
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def validate_amount(amount):
|
def validate_amount(amount):
|
||||||
try:
|
try:
|
||||||
famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount)
|
famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount)
|
||||||
if famount <= 0.00000001:
|
if famount <= 0.00000001:
|
||||||
raise AmountNotValid(f"amount have to be positive {famount} < 0")
|
raise AmountNotValid(f"amount have to be positive {famount} < 0")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise AmountNotValid(f"amount not properly formatted, {e}") from e
|
raise AmountNotValid(f"amount not properly formatted, {e}")
|
||||||
return amount
|
return amount
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def validate_locktime(locktime, timestamp_to_check=False):
|
def validate_locktime(locktime, timestamp_to_check=False):
|
||||||
try:
|
try:
|
||||||
if timestamp_to_check:
|
if timestamp_to_check:
|
||||||
if Util.parse_locktime_string(locktime, None) < timestamp_to_check:
|
if Util.parse_locktime_string(locktime, None) < timestamp_to_check:
|
||||||
raise HeirExpiredException()
|
raise HeirExpiredException()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise LocktimeNotValid(f"locktime string not properly formatted, {e}") from e
|
raise LocktimeNotValid(f"locktime string not properly formatted, {e}")
|
||||||
return locktime
|
return locktime
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def validate_heir(k, v, timestamp_to_check=False):
|
def validate_heir(k, v, timestamp_to_check=False):
|
||||||
address = Heirs.validate_address(v[HEIR_ADDRESS])
|
address = Heirs.validate_address(v[HEIR_ADDRESS])
|
||||||
if is_op_return_address(v[HEIR_ADDRESS]):
|
amount = Heirs.validate_amount(v[HEIR_AMOUNT])
|
||||||
amount = "0"
|
|
||||||
else:
|
|
||||||
amount = Heirs.validate_amount(v[HEIR_AMOUNT])
|
|
||||||
locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check)
|
locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check)
|
||||||
return (address, amount, locktime)
|
return (address, amount, locktime)
|
||||||
|
|
||||||
@staticmethod
|
def _validate(data, timestamp_to_check=False):
|
||||||
def _validate(data, timestamp_to_check=False, persist=True):
|
|
||||||
|
|
||||||
for k, v in list(data.items()):
|
for k, v in list(data.items()):
|
||||||
if k == "heirs":
|
if k == "heirs":
|
||||||
return Heirs._validate(v, timestamp_to_check, persist)
|
return Heirs._validate(v, timestamp_to_check)
|
||||||
try:
|
try:
|
||||||
Heirs.validate_heir(k, v, timestamp_to_check)
|
Heirs.validate_heir(k, v, timestamp_to_check)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.info(f"exception heir removed {e}")
|
_logger.info(f"exception heir removed {e}")
|
||||||
if persist:
|
data.pop(k)
|
||||||
data.pop(k)
|
|
||||||
else:
|
|
||||||
# Drop the invalid heir in memory only, so the overridden
|
|
||||||
# Heirs.pop (which calls save()) is not triggered: a build
|
|
||||||
# must not silently delete heirs from the wallet.
|
|
||||||
dict.pop(data, k)
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -970,19 +874,6 @@ class WillExecutorFeeException(Exception):
|
|||||||
return "WillExecutorFeeException: {} fee:{}".format(
|
return "WillExecutorFeeException: {} fee:{}".format(
|
||||||
self.willexecutor["url"], self.willexecutor["base_fee"]
|
self.willexecutor["url"], self.willexecutor["base_fee"]
|
||||||
)
|
)
|
||||||
|
|
||||||
class WillExecutorFeeTooHighException(Exception):
|
|
||||||
def __init__(self, willexecutor, max_fee):
|
|
||||||
self.willexecutor = willexecutor
|
|
||||||
self.max_fee = max_fee
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return "WillExecutorFeeTooHighException: {} fee:{} > max:{}".format(
|
|
||||||
self.willexecutor["url"],
|
|
||||||
self.willexecutor["base_fee"],
|
|
||||||
self.max_fee,
|
|
||||||
)
|
|
||||||
|
|
||||||
class BalanceTooLowException(Exception):
|
class BalanceTooLowException(Exception):
|
||||||
def __init__(self,balance, dust_threshold, fees):
|
def __init__(self,balance, dust_threshold, fees):
|
||||||
self.balance=balance
|
self.balance=balance
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
"""
|
|
||||||
bal.core.input_rules
|
|
||||||
====================
|
|
||||||
|
|
||||||
Pure, GUI-free rules for the plugin's text-input widgets: locktime bounds and
|
|
||||||
acceptance, the RAW locktime sanitisation ("30d"/"1y"), and the percentage-or-
|
|
||||||
amount field normalisation.
|
|
||||||
|
|
||||||
These used to live inside the Qt widget classes in ``bal.gui.qt.widgets``.
|
|
||||||
Keeping them here makes them testable without Qt and lets any GUI front-end
|
|
||||||
reuse the exact same parsing rules.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any, Optional, Tuple, Union
|
|
||||||
|
|
||||||
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"NLOCKTIME_BLOCKHEIGHT_MAX",
|
|
||||||
"NLOCKTIME_MAX",
|
|
||||||
"NLOCKTIME_MIN",
|
|
||||||
"LockTimeEditor",
|
|
||||||
"normalize_locktime_raw_text",
|
|
||||||
"normalize_perc_amount_text",
|
|
||||||
"parse_perc_amount",
|
|
||||||
"replace_dy_suffixes",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class LockTimeEditor:
|
|
||||||
"""Acceptance bounds shared by the RAW and Date locktime editors.
|
|
||||||
|
|
||||||
A Qt widget mixin historically; the pure parts (bounds and acceptance test)
|
|
||||||
live here so they can be tested and reused without Qt. Widget subclasses
|
|
||||||
override ``min_allowed_value``/``max_allowed_value`` to tighten the bounds.
|
|
||||||
"""
|
|
||||||
|
|
||||||
min_allowed_value = NLOCKTIME_MIN
|
|
||||||
max_allowed_value = NLOCKTIME_MAX
|
|
||||||
alarm = None
|
|
||||||
|
|
||||||
def get_value(self) -> Optional[int]:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def set_value(self, x: Any, force=True) -> None:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def is_acceptable_locktime(cls, x: Any) -> bool:
|
|
||||||
"""True when ``x`` (string or int) is within the allowed locktime bounds.
|
|
||||||
|
|
||||||
An empty/falsy value is accepted (the field is not yet filled in).
|
|
||||||
"""
|
|
||||||
if not x: # e.g. empty string
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
x = int(x)
|
|
||||||
except Exception as _e:
|
|
||||||
return False
|
|
||||||
return cls.min_allowed_value <= x <= cls.max_allowed_value
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_max_allowed_timestamp() -> int:
|
|
||||||
"""Highest locktime timestamp accepted on this platform.
|
|
||||||
|
|
||||||
On 32-bit ``time_t`` Windows builds ``datetime.fromtimestamp`` overflows
|
|
||||||
past 2038, so the ceiling is clamped to INT32_MAX (see #6170).
|
|
||||||
"""
|
|
||||||
ts = NLOCKTIME_MAX
|
|
||||||
# Test if this value is within the valid timestamp limits (which is
|
|
||||||
# platform-dependent). see #6170
|
|
||||||
try:
|
|
||||||
datetime.fromtimestamp(ts)
|
|
||||||
except (OSError, OverflowError):
|
|
||||||
ts = 2**31 - 1 # INT32_MAX
|
|
||||||
datetime.fromtimestamp(ts) # test if raises
|
|
||||||
return ts
|
|
||||||
|
|
||||||
|
|
||||||
def replace_dy_suffixes(text: str) -> str:
|
|
||||||
"""Strip the relative-time suffixes (d/y) from ``text``.
|
|
||||||
|
|
||||||
Only days ("d") and years ("y") are supported. The block-height suffix
|
|
||||||
("b") was removed (A1): locktimes are always timestamps now.
|
|
||||||
"""
|
|
||||||
return str(text).replace("d", "").replace("y", "")
|
|
||||||
|
|
||||||
|
|
||||||
def _checkbdy(s: str, pos: int, appendix: str) -> Tuple[int, str]:
|
|
||||||
"""Keep a ``d``/``y`` suffix typed right after an existing suffix.
|
|
||||||
|
|
||||||
When the character just before ``pos`` equals ``appendix``, the text is
|
|
||||||
re-normalised so only one suffix remains.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
charpos = pos - 1
|
|
||||||
charpos = max(0, charpos)
|
|
||||||
charpos = min(len(s) - 1, charpos)
|
|
||||||
if appendix == s[charpos]:
|
|
||||||
s = replace_dy_suffixes(s) + appendix
|
|
||||||
pos = charpos
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return pos, s
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_locktime_raw_text(
|
|
||||||
text: str, pos: int
|
|
||||||
) -> Tuple[str, bool, bool, int]:
|
|
||||||
"""Sanitise the RAW locktime field text.
|
|
||||||
|
|
||||||
Only digits plus the day ("d") and year ("y") suffixes are kept; the block
|
|
||||||
suffix ("b") is removed (A1). Exactly one ``d``/``y`` suffix survives.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: the raw field text.
|
|
||||||
pos: the cursor position within ``text``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised
|
|
||||||
text and ``new_pos`` the adjusted cursor position.
|
|
||||||
"""
|
|
||||||
text = text.strip()
|
|
||||||
chars = "0123456789dy"
|
|
||||||
pos = len("".join([i for i in text[:pos] if i in chars]))
|
|
||||||
s = "".join([i for i in text if i in chars])
|
|
||||||
isdays = False
|
|
||||||
isyears = False
|
|
||||||
|
|
||||||
pos, s = _checkbdy(s, pos, "d")
|
|
||||||
pos, s = _checkbdy(s, pos, "y")
|
|
||||||
|
|
||||||
if "d" in s:
|
|
||||||
isdays = True
|
|
||||||
if "y" in s:
|
|
||||||
isyears = True
|
|
||||||
|
|
||||||
if isdays:
|
|
||||||
s = replace_dy_suffixes(s) + "d"
|
|
||||||
if isyears:
|
|
||||||
s = replace_dy_suffixes(s) + "y"
|
|
||||||
return s, isdays, isyears, pos
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_perc_amount_text(text: str, decimal_point: str) -> Tuple[str, bool]:
|
|
||||||
"""Sanitise the amount-or-percentage field text.
|
|
||||||
|
|
||||||
Keeps digits, ``%`` and the decimal point; a trailing ``%`` marks the value
|
|
||||||
as a percentage (``is_perc``). At most 8 decimal digits after the point.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: the raw field text.
|
|
||||||
decimal_point: the decimal separator character (``electrum`` uses
|
|
||||||
``DECIMAL_POINT``, locale dependent).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``(clean, is_perc)``.
|
|
||||||
"""
|
|
||||||
text = text.strip()
|
|
||||||
chars = "0123456789%"
|
|
||||||
chars += decimal_point
|
|
||||||
|
|
||||||
s = "".join([i for i in text if i in chars])
|
|
||||||
|
|
||||||
if "%" in s:
|
|
||||||
is_perc = True
|
|
||||||
s = s.replace("%", "")
|
|
||||||
else:
|
|
||||||
is_perc = False
|
|
||||||
|
|
||||||
if decimal_point in s:
|
|
||||||
p = s.find(decimal_point)
|
|
||||||
s = s.replace(decimal_point, "")
|
|
||||||
s = s[:p] + decimal_point + s[p : p + 8]
|
|
||||||
if is_perc:
|
|
||||||
s += "%"
|
|
||||||
|
|
||||||
return s, is_perc
|
|
||||||
|
|
||||||
|
|
||||||
def parse_perc_amount(text: str, decimal_point: str) -> Union[None, Decimal, int]:
|
|
||||||
"""Parse an amount-or-percentage field text into a numeric value.
|
|
||||||
|
|
||||||
Returns ``None`` when the text cannot be parsed.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
text = text.replace(decimal_point, ".")
|
|
||||||
text = text.replace("%", "")
|
|
||||||
return Decimal(text)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
@@ -21,60 +21,18 @@ 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 datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
_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
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -109,9 +67,7 @@ def get_will(x):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Electrum >= 4.8.0
|
# Electrum >= 4.8.0
|
||||||
from electrum.stored_dict import (
|
from electrum.stored_dict import register_name as _electrum_register_name
|
||||||
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)."""
|
||||||
@@ -121,7 +77,7 @@ except ImportError:
|
|||||||
# Electrum <= 4.7.2
|
# Electrum <= 4.7.2
|
||||||
def _register_will_dict(name, method, _type=None):
|
def _register_will_dict(name, method, _type=None):
|
||||||
"""Register a plugin dict in the wallet DB (Electrum <= 4.7.2 API)."""
|
"""Register a plugin dict in the wallet DB (Electrum <= 4.7.2 API)."""
|
||||||
json_db.register_dict(name, method, _type) # pyright: ignore[reportAttributeAccessIssue]
|
json_db.register_dict(name, method, _type)
|
||||||
|
|
||||||
|
|
||||||
_register_will_dict("heirs", tuple)
|
_register_will_dict("heirs", tuple)
|
||||||
@@ -164,6 +120,9 @@ class BalPlugin(BasePlugin):
|
|||||||
layer (or unit tests) can use the plugin logic without importing Qt.
|
layer (or unit tests) can use the plugin logic without importing Qt.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_version = None
|
||||||
|
__version__ = "0.5.18" # AUTOMATICALLY GENERATED DO NOT EDIT
|
||||||
|
|
||||||
# Command used to open an .ics calendar file, per operating system.
|
# Command used to open an .ics calendar file, per operating system.
|
||||||
default_app = {
|
default_app = {
|
||||||
"Linux": "xdg-open",
|
"Linux": "xdg-open",
|
||||||
@@ -172,21 +131,25 @@ class BalPlugin(BasePlugin):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Human-readable chain name ("bitcoin", "testnet", "regtest", ...).
|
# Human-readable chain name ("bitcoin", "testnet", "regtest", ...).
|
||||||
# Must be a classproperty (not a plain class attribute) because the class
|
chainname = (
|
||||||
# is defined before constants.net is set to the correct network — a plain
|
constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin"
|
||||||
# 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):
|
||||||
"""Plugin version, read from ``bal/manifest.json`` (single source of
|
"""Return the plugin version, read once from the ``VERSION`` file."""
|
||||||
truth). See :func:`get_version`."""
|
if not self._version:
|
||||||
return get_version()
|
try:
|
||||||
|
f = ""
|
||||||
|
with open("{}/VERSION".format(self.plugin_dir), "r") as fi:
|
||||||
|
f = str(fi.read())
|
||||||
|
self._version = f.strip()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"failed to get version: {e}")
|
||||||
|
self._version = "unknown"
|
||||||
|
return self._version
|
||||||
|
|
||||||
def __init__(self, parent, config, name):
|
def __init__(self, parent, config, name):
|
||||||
self.logger = get_logger(__name__)
|
self.logger = get_logger(__name__)
|
||||||
@@ -234,21 +197,6 @@ class BalPlugin(BasePlugin):
|
|||||||
self.PREVIEW = BalConfig(config, "bal_preview", True)
|
self.PREVIEW = BalConfig(config, "bal_preview", True)
|
||||||
self.SAVE_TXS = BalConfig(config, "bal_save_txs", True)
|
self.SAVE_TXS = BalConfig(config, "bal_save_txs", True)
|
||||||
|
|
||||||
# SAVE_HISTORY (history persistence): when enabled, the valid will
|
|
||||||
# transactions are saved into the wallet's LOCAL history (the History
|
|
||||||
# tab) after every check, each with a configurable label. Default ON.
|
|
||||||
self.SAVE_HISTORY = BalConfig(config, "bal_save_history", True)
|
|
||||||
|
|
||||||
# HISTORY_LABEL: label text applied to the will transactions saved into
|
|
||||||
# the wallet's local history. May contain the "{willexecutor}" token,
|
|
||||||
# which is replaced with the will-executor URL of each will item at
|
|
||||||
# save time.
|
|
||||||
self.HISTORY_LABEL = BalConfig(
|
|
||||||
config,
|
|
||||||
"bal_history_label",
|
|
||||||
"BitcoinAfterLife inheritance transaction - {willexecutor}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# AUTO_SIGN (Group B / B2): when enabled, pressing "Check" will, after
|
# AUTO_SIGN (Group B / B2): when enabled, pressing "Check" will, after
|
||||||
# querying the will-executor servers, automatically sign the will
|
# querying the will-executor servers, automatically sign the will
|
||||||
# transactions and broadcast them to their will-executors, without the
|
# transactions and broadcast them to their will-executors, without the
|
||||||
@@ -257,37 +205,12 @@ 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 bytes, used
|
|
||||||
# per QR frame when exporting/importing a will through the QR channel.
|
|
||||||
# The settings dialog offers the 4 standard presets of
|
|
||||||
# bal.core.qrtransfer.CHUNK_PRESETS; this stores the selected budget.
|
|
||||||
# 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
|
||||||
@@ -305,9 +228,6 @@ class BalPlugin(BasePlugin):
|
|||||||
# follows what is saved in that wallet (the default only applies when no
|
# follows what is saved in that wallet (the default only applies when no
|
||||||
# value has been stored yet, i.e. new wallets).
|
# value has been stored yet, i.e. new wallets).
|
||||||
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", False)
|
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", False)
|
||||||
self.MAX_WILLEXECUTOR_FEE = BalConfig(
|
|
||||||
config, "bal_max_willexecutor_fee", 500000
|
|
||||||
)
|
|
||||||
self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True)
|
self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True)
|
||||||
self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)
|
self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)
|
||||||
self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True)
|
self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True)
|
||||||
@@ -337,10 +257,7 @@ class BalPlugin(BasePlugin):
|
|||||||
config, "bal_event_summary", "BAL -Will execution of $wallet_name"
|
config, "bal_event_summary", "BAL -Will execution of $wallet_name"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default will-executor servers, keyed by network. These addresses are
|
# Default will-executor servers, keyed by network.
|
||||||
# the ones currently reported by each server's <chain>/info endpoint and
|
|
||||||
# are refreshed again on ping; testnet/testnet4 must NOT be regtest
|
|
||||||
# (bcrt1...) addresses, which are invalid on those networks.
|
|
||||||
self.WILLEXECUTORS = BalConfig(
|
self.WILLEXECUTORS = BalConfig(
|
||||||
config,
|
config,
|
||||||
"bal_willexecutors",
|
"bal_willexecutors",
|
||||||
@@ -359,7 +276,7 @@ class BalPlugin(BasePlugin):
|
|||||||
"base_fee": 100000,
|
"base_fee": 100000,
|
||||||
"status": "New",
|
"status": "New",
|
||||||
"info": "Bitcoin After Life Will Executor",
|
"info": "Bitcoin After Life Will Executor",
|
||||||
"address": "tb1qp5tmrvtm6dmz23mzkf55n5d53xh39wt0gwpp5m",
|
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
||||||
"selected": True,
|
"selected": True,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -368,7 +285,7 @@ class BalPlugin(BasePlugin):
|
|||||||
"base_fee": 100000,
|
"base_fee": 100000,
|
||||||
"status": "New",
|
"status": "New",
|
||||||
"info": "Bitcoin After Life Will Executor",
|
"info": "Bitcoin After Life Will Executor",
|
||||||
"address": "tb1qfj5ewmczg8ck2z0eeff6uysdrxd4qdy2ltrx5a",
|
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
||||||
"selected": True,
|
"selected": True,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -439,7 +356,7 @@ class BalPlugin(BasePlugin):
|
|||||||
"""Fill in any missing will-setting with its default value."""
|
"""Fill in any missing will-setting with its default value."""
|
||||||
defaults = BalPlugin.default_will_settings()
|
defaults = BalPlugin.default_will_settings()
|
||||||
if not will_settings:
|
if not will_settings:
|
||||||
will_settings = {}
|
will_settings = []
|
||||||
if int(will_settings.get("baltx_fees", 0)) < 1:
|
if int(will_settings.get("baltx_fees", 0)) < 1:
|
||||||
will_settings["baltx_fees"] = defaults['baltx_fees']
|
will_settings["baltx_fees"] = defaults['baltx_fees']
|
||||||
if not will_settings.get("threshold"):
|
if not will_settings.get("threshold"):
|
||||||
@@ -461,7 +378,7 @@ class BalPlugin(BasePlugin):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def default_will_settings():
|
def default_will_settings():
|
||||||
"""Default will settings: a fee rate plus absolute threshold/locktime."""
|
"""Default will settings: a fee rate plus absolute threshold/locktime."""
|
||||||
will_settings: dict[str, float] = {"baltx_fees": 20}
|
will_settings = {"baltx_fees": 20}
|
||||||
will_settings.update(BalPlugin.default_will_settings_absolute())
|
will_settings.update(BalPlugin.default_will_settings_absolute())
|
||||||
return will_settings
|
return will_settings
|
||||||
|
|
||||||
@@ -469,8 +386,8 @@ class BalPlugin(BasePlugin):
|
|||||||
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 (from today)."""
|
||||||
relative_dates = BalPlugin.default_will_settings_relative()
|
relative_dates = BalPlugin.default_will_settings_relative()
|
||||||
today = datetime.now(tz=timezone.utc).date()
|
today = date.today()
|
||||||
dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc)
|
dt = datetime(today.year, today.month, today.day, 0, 0, 0)
|
||||||
threshold = (
|
threshold = (
|
||||||
dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days())
|
dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days())
|
||||||
).timestamp()
|
).timestamp()
|
||||||
@@ -494,12 +411,10 @@ class BalTimestamp:
|
|||||||
* an integer -> an absolute UNIX timestamp (``unit is None``)
|
* an integer -> an absolute UNIX timestamp (``unit is None``)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
value: int
|
value = None
|
||||||
unit: str | None
|
unit = None
|
||||||
|
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
self.value = 1
|
|
||||||
self.unit = None
|
|
||||||
str_value = str(value)
|
str_value = str(value)
|
||||||
if str_value and str_value[-1].lower() in ("y", "d"):
|
if str_value and str_value[-1].lower() in ("y", "d"):
|
||||||
self.value = int(str_value[:-1])
|
self.value = int(str_value[:-1])
|
||||||
@@ -528,14 +443,14 @@ class BalTimestamp:
|
|||||||
We clamp out-of-range timestamps to INT32_MAX, mirroring Electrum's own
|
We clamp out-of-range timestamps to INT32_MAX, mirroring Electrum's own
|
||||||
``get_max_allowed_timestamp`` workaround (see Electrum issue #6170).
|
``get_max_allowed_timestamp`` workaround (see Electrum issue #6170).
|
||||||
"""
|
"""
|
||||||
int32_max = 2 ** 31 - 1
|
INT32_MAX = 2 ** 31 - 1
|
||||||
try:
|
try:
|
||||||
return datetime.fromtimestamp(ts, tz=timezone.utc)
|
return datetime.fromtimestamp(ts)
|
||||||
except (OSError, OverflowError, ValueError):
|
except (OSError, OverflowError, ValueError):
|
||||||
try:
|
try:
|
||||||
return datetime.fromtimestamp(min(int(ts), int32_max), tz=timezone.utc)
|
return datetime.fromtimestamp(min(int(ts), INT32_MAX))
|
||||||
except (OSError, OverflowError, ValueError):
|
except (OSError, OverflowError, ValueError):
|
||||||
return datetime.fromtimestamp(int32_max, tz=timezone.utc)
|
return datetime.fromtimestamp(INT32_MAX)
|
||||||
|
|
||||||
def to_date(self, from_date=None, reverse=False):
|
def to_date(self, from_date=None, reverse=False):
|
||||||
"""Resolve to a ``datetime``.
|
"""Resolve to a ``datetime``.
|
||||||
@@ -548,7 +463,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(tz=timezone.utc)
|
from_date = datetime.now()
|
||||||
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
|
||||||
|
|||||||
@@ -1,304 +0,0 @@
|
|||||||
"""
|
|
||||||
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);
|
|
||||||
* splits that string into fixed-size ``BAL1<TTT><iii><flag>`` frames for
|
|
||||||
multi-QR export, and reassembles/validates them on import.
|
|
||||||
|
|
||||||
Wire format (v2, compact)
|
|
||||||
-------------------------
|
|
||||||
A frame is::
|
|
||||||
|
|
||||||
BAL1<TTT><iii><flag><payload>
|
|
||||||
|
|
||||||
* ``BAL1`` - magic + format era (4 chars).
|
|
||||||
* ``TTT`` - frame total as exactly 3 base36 digits (1-based, cap 46655).
|
|
||||||
* ``iii`` - frame index as exactly 3 base36 digits (1-based).
|
|
||||||
* ``flag`` - one char: ``Z`` (zlib + base64) or ``0`` (plain ASCII).
|
|
||||||
* ``payload`` - every other character of the frame; the payloads of all
|
|
||||||
frames, concatenated in index order, rebuild the transfer string.
|
|
||||||
|
|
||||||
The fixed 11-char header replaces the legacy ``BALQR1|N|i|flags|`` form
|
|
||||||
(same 5 pieces of information) without any pipe separator, so the whole
|
|
||||||
frame is scan-friendly and the overhead no longer grows with the frame
|
|
||||||
count. Legacy ``BALQR1|…`` frames are still accepted on import.
|
|
||||||
|
|
||||||
The audio-modem channel deliberately bypasses the framing helpers here
|
|
||||||
(PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and
|
|
||||||
carries the whole transfer string in a single blob, 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
|
|
||||||
|
|
||||||
MAGIC = "BALQR"
|
|
||||||
VERSION = 1
|
|
||||||
FLAG_COMPRESSED = "Z"
|
|
||||||
FLAG_PLAIN = "0"
|
|
||||||
|
|
||||||
# 4 standard presets (label, payload budget in bytes 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),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Smallest allowed payload budget per frame, below which the frame header
|
|
||||||
# could consume the whole budget.
|
|
||||||
MIN_CHUNK_SIZE = 40
|
|
||||||
|
|
||||||
# Legacy wire format (still imported); the exporter emits the v2 form below.
|
|
||||||
_FRAME_MAGIC_V1 = MAGIC + str(VERSION)
|
|
||||||
|
|
||||||
# Compact v2 wire format: fixed-width base36 count fields, no separators.
|
|
||||||
_FRAME_MAGIC_V2 = "BAL1"
|
|
||||||
_BASE36_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
||||||
_BASE36_WIDTH = 3
|
|
||||||
_HEADER_V2_LEN = len(_FRAME_MAGIC_V2) + 2 * _BASE36_WIDTH + 1
|
|
||||||
_MAX_TOTAL = 36 ** _BASE36_WIDTH - 1
|
|
||||||
|
|
||||||
|
|
||||||
class QrTransferError(ValueError):
|
|
||||||
"""Base error for will QR / audio transfer processing."""
|
|
||||||
|
|
||||||
|
|
||||||
class MissingFramesError(QrTransferError):
|
|
||||||
"""Some frame indices of a multi-QR transfer are missing."""
|
|
||||||
|
|
||||||
def __init__(self, missing):
|
|
||||||
self.missing = list(missing)
|
|
||||||
super().__init__("Missing QR frames: {}".format(self.missing))
|
|
||||||
|
|
||||||
|
|
||||||
class InconsistentTotalError(QrTransferError):
|
|
||||||
"""Frames disagree about the advertised frame total."""
|
|
||||||
|
|
||||||
|
|
||||||
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. The optional flag
|
|
||||||
of the frame header lets the importer reverse this automatically.
|
|
||||||
"""
|
|
||||||
return __compress("\n".join(tx_strings), enabled=compress)
|
|
||||||
|
|
||||||
|
|
||||||
def encode_transfer_best(tx_strings):
|
|
||||||
"""Encode ``tx_strings`` with the smaller of plain vs compressed form.
|
|
||||||
|
|
||||||
Returns ``(transfer_string, compressed: bool)``. Compressed wins only
|
|
||||||
when zlib + base64 really is shorter (best-of, never larger).
|
|
||||||
"""
|
|
||||||
joined = "\n".join(tx_strings)
|
|
||||||
plain = joined
|
|
||||||
compressed = __compress(joined, enabled=True)
|
|
||||||
if len(compressed) < len(plain):
|
|
||||||
return compressed, True
|
|
||||||
return plain, False
|
|
||||||
|
|
||||||
|
|
||||||
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 split_frames(transfer_string, chunk_size, compressed=False):
|
|
||||||
"""Split ``transfer_string`` into full compact ``BAL1`` frames.
|
|
||||||
|
|
||||||
Every returned frame has the fixed 11-char v2 header followed by its
|
|
||||||
share of the payload, so each frame is at most ``chunk_size`` characters
|
|
||||||
long. ``compressed`` stamps the ``Z`` flag into every frame so the
|
|
||||||
importer knows how to reverse the encoding.
|
|
||||||
|
|
||||||
Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold
|
|
||||||
the header plus any payload, or when the transfer needs more than
|
|
||||||
:data:`_MAX_TOTAL` frames.
|
|
||||||
"""
|
|
||||||
flag = FLAG_COMPRESSED if compressed else FLAG_PLAIN
|
|
||||||
total = __compute_total(len(transfer_string), chunk_size)
|
|
||||||
budget = chunk_size - _HEADER_V2_LEN
|
|
||||||
frames = []
|
|
||||||
pos = 0
|
|
||||||
length = len(transfer_string)
|
|
||||||
for index in range(1, total + 1):
|
|
||||||
end = min(pos + budget, length)
|
|
||||||
frames.append(
|
|
||||||
_FRAME_MAGIC_V2
|
|
||||||
+ _base36(total)
|
|
||||||
+ _base36(index)
|
|
||||||
+ flag
|
|
||||||
+ transfer_string[pos:end]
|
|
||||||
)
|
|
||||||
pos = end
|
|
||||||
if pos < length:
|
|
||||||
# __compute_total guarantees this cannot happen; keep a safety net.
|
|
||||||
raise QrTransferError("internal error: frames did not cover the transfer string")
|
|
||||||
return frames
|
|
||||||
|
|
||||||
|
|
||||||
def parse_frame(frame):
|
|
||||||
"""Parse a single frame.
|
|
||||||
|
|
||||||
Accepts both the legacy ``BALQR1|total|index|flags|payload`` form and
|
|
||||||
the compact ``BAL1<total><index><flag><payload>`` v2 form.
|
|
||||||
|
|
||||||
Returns ``(total, index, compressed: bool, payload: str)``. Raises
|
|
||||||
:class:`QrTransferError` on malformed input (bad magic/version, wrong
|
|
||||||
arity, non-integer or out-of-range frame numbers, unknown flags).
|
|
||||||
"""
|
|
||||||
if frame.startswith(_FRAME_MAGIC_V2):
|
|
||||||
return _parse_v2(frame)
|
|
||||||
return _parse_v1(frame)
|
|
||||||
|
|
||||||
|
|
||||||
def assemble(frames, total):
|
|
||||||
"""Concatenate frame payloads back into a transfer string.
|
|
||||||
|
|
||||||
``frames`` maps 1-based index -> payload. Every index ``1..total`` must
|
|
||||||
be present (else :class:`MissingFramesError`) and no index may exceed
|
|
||||||
``total`` (else :class:`InconsistentTotalError`).
|
|
||||||
"""
|
|
||||||
if total < 1:
|
|
||||||
raise QrTransferError("invalid frame total")
|
|
||||||
missing = [index for index in range(1, total + 1) if index not in frames]
|
|
||||||
if missing:
|
|
||||||
raise MissingFramesError(missing)
|
|
||||||
extra = [index for index in frames if index > total]
|
|
||||||
if extra:
|
|
||||||
raise InconsistentTotalError()
|
|
||||||
return "".join(frames[index] for index in range(1, total + 1))
|
|
||||||
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
def _base36(n):
|
|
||||||
"""Zero-padded :data:`_BASE36_WIDTH` base36 render of ``n``."""
|
|
||||||
if not 0 <= n <= _MAX_TOTAL:
|
|
||||||
raise QrTransferError("BAL QR part number out of range: {}".format(n))
|
|
||||||
chars = []
|
|
||||||
for _ in range(_BASE36_WIDTH):
|
|
||||||
chars.append(_BASE36_DIGITS[n % 36])
|
|
||||||
n //= 36
|
|
||||||
return "".join(reversed(chars))
|
|
||||||
|
|
||||||
|
|
||||||
def _base36_decode(text):
|
|
||||||
"""Inverse of :func:`_base36`; raises ``ValueError`` on bad input."""
|
|
||||||
if len(text) != _BASE36_WIDTH or any(c not in _BASE36_DIGITS for c in text):
|
|
||||||
raise ValueError(text)
|
|
||||||
n = 0
|
|
||||||
for c in text:
|
|
||||||
n = n * 36 + _BASE36_DIGITS.index(c)
|
|
||||||
return n
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_v1(frame):
|
|
||||||
parts = frame.split("|", maxsplit=4)
|
|
||||||
if len(parts) != 5:
|
|
||||||
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
|
||||||
magic_seen, total_s, index_s, flags, payload = parts
|
|
||||||
if magic_seen != _FRAME_MAGIC_V1:
|
|
||||||
raise QrTransferError("Not a BAL will QR (unknown magic/version)")
|
|
||||||
try:
|
|
||||||
total = int(total_s)
|
|
||||||
index = int(index_s)
|
|
||||||
except ValueError as e:
|
|
||||||
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e
|
|
||||||
if total < 1 or not 1 <= index <= total:
|
|
||||||
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
|
||||||
if flags not in ("", FLAG_COMPRESSED):
|
|
||||||
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
|
||||||
return total, index, flags == FLAG_COMPRESSED, payload
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_v2(frame):
|
|
||||||
if len(frame) < _HEADER_V2_LEN:
|
|
||||||
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
|
||||||
# Magic is length _FRAME_MAGIC_V2; the two base36 fields and the flag
|
|
||||||
# make up the rest of the fixed header.
|
|
||||||
offset = len(_FRAME_MAGIC_V2)
|
|
||||||
total_s = frame[offset : offset + _BASE36_WIDTH]
|
|
||||||
index_s = frame[offset + _BASE36_WIDTH : offset + 2 * _BASE36_WIDTH]
|
|
||||||
flag = frame[offset + 2 * _BASE36_WIDTH]
|
|
||||||
try:
|
|
||||||
total = _base36_decode(total_s)
|
|
||||||
index = _base36_decode(index_s)
|
|
||||||
except ValueError:
|
|
||||||
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from None
|
|
||||||
if total < 1 or not 1 <= index <= total:
|
|
||||||
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
|
||||||
if flag not in (FLAG_PLAIN, FLAG_COMPRESSED):
|
|
||||||
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
|
||||||
payload = frame[_HEADER_V2_LEN:]
|
|
||||||
return total, index, flag == FLAG_COMPRESSED, payload
|
|
||||||
|
|
||||||
|
|
||||||
def __compute_total(transfer_len, chunk_size):
|
|
||||||
"""Smallest frame count whose budget covers the whole transfer string.
|
|
||||||
|
|
||||||
The v2 header is fixed-width, so the budget is constant and the count is
|
|
||||||
a plain ceiling division, capped at :data:`_MAX_TOTAL`.
|
|
||||||
"""
|
|
||||||
if chunk_size < MIN_CHUNK_SIZE:
|
|
||||||
raise QrTransferError(
|
|
||||||
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
|
|
||||||
)
|
|
||||||
budget = chunk_size - _HEADER_V2_LEN
|
|
||||||
if budget <= 0:
|
|
||||||
raise QrTransferError(
|
|
||||||
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
|
|
||||||
)
|
|
||||||
total = -(-transfer_len // budget)
|
|
||||||
if total < 1:
|
|
||||||
total = 1
|
|
||||||
if total > _MAX_TOTAL:
|
|
||||||
raise QrTransferError(
|
|
||||||
"BAL QR transfer demands too many frames: {}".format(total)
|
|
||||||
)
|
|
||||||
return total
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
"""
|
|
||||||
bal.core.reminders
|
|
||||||
==================
|
|
||||||
|
|
||||||
Pure, GUI-free logic for the dead-man's-switch calendar reminders: choosing the
|
|
||||||
reminder offsets (BASIC vs ADVANCED modes) and rendering them as an RFC-5545
|
|
||||||
iCalendar (.ics) document.
|
|
||||||
|
|
||||||
Everything in this module is stdlib-only, so it can be imported and tested
|
|
||||||
without Electrum or Qt (e.g. in the lint venv).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
def compute_reminder_offsets(days, count):
|
|
||||||
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
|
|
||||||
|
|
||||||
Group D / D1. The reminders are spread uniformly across the check-alive
|
|
||||||
period and always fall *before* the delivery deadline, i.e. every returned
|
|
||||||
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
* ``count`` is the requested number of reminders (the settings dialog
|
|
||||||
caps it at 5, default 3).
|
|
||||||
* at most ONE reminder per available day: the effective number is
|
|
||||||
``min(count, days)``;
|
|
||||||
* with ``days`` available days, offsets are chosen as evenly spaced
|
|
||||||
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
|
|
||||||
= the first day of the period), de-duplicated and returned sorted
|
|
||||||
descending (earliest reminder first).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
days: number of whole days between check-alive and the deadline.
|
|
||||||
count: requested number of reminders.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A list of integer day-offsets (each ``>= 1``), e.g. ``[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,
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""Build the ``.ics`` content with one VEVENT per reminder date.
|
|
||||||
|
|
||||||
Group D / D1 (revised): N *separate* VEVENTs, one per reminder date, so the
|
|
||||||
user sees several distinct appointments in their calendar. The reminder
|
|
||||||
offsets come from :func:`basic_reminder_offsets` (BASIC mode) or
|
|
||||||
:func:`compute_reminder_offsets` (ADVANCED mode, spread over the check-alive
|
|
||||||
period).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
locktime: the delivery deadline (datetime).
|
|
||||||
basic_mode: use the fixed BASIC offsets instead of spreading over the
|
|
||||||
check-alive period.
|
|
||||||
description: raw EVENT_DESCRIPTION template; ``$wallet_name`` and
|
|
||||||
``$heirs_complete`` placeholders are substituted and escaped.
|
|
||||||
summary: raw EVENT_SUMMARY template; ``$wallet_name`` is substituted.
|
|
||||||
wallet_name: label used in the UID and template substitutions.
|
|
||||||
heirs_details: pre-formatted heir list injected into ``description``.
|
|
||||||
version: plugin version, embedded in the PRODID line.
|
|
||||||
num_reminders: requested reminder count (ADVANCED mode only).
|
|
||||||
now: "today" reference; defaults to ``datetime.now()``.
|
|
||||||
threshold: check-alive date (ADVANCED mode only; required there).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The ``.ics`` content string, or ``None`` when no reminder falls in the
|
|
||||||
future (the delivery date is too close or already passed) so the caller
|
|
||||||
can show a warning instead of producing an empty-looking file.
|
|
||||||
"""
|
|
||||||
now = now if now is not None else datetime.now()
|
|
||||||
|
|
||||||
if basic_mode:
|
|
||||||
days_to_deadline = (locktime - now).days
|
|
||||||
offsets = basic_reminder_offsets(days_to_deadline)
|
|
||||||
else:
|
|
||||||
if threshold is None:
|
|
||||||
raise ValueError("threshold is required in ADVANCED mode")
|
|
||||||
days = (locktime - threshold).days
|
|
||||||
offsets = compute_reminder_offsets(days, num_reminders)
|
|
||||||
|
|
||||||
# ToDo #2: no future reminder means there are no events to write. Return
|
|
||||||
# None so the caller shows a clear warning instead of an empty .ics file.
|
|
||||||
if not offsets:
|
|
||||||
return None
|
|
||||||
|
|
||||||
event_description = ical_escape(
|
|
||||||
f"{description}"
|
|
||||||
.replace("$wallet_name", str(wallet_name))
|
|
||||||
.replace("$heirs_complete", heirs_details)
|
|
||||||
)
|
|
||||||
summary_base = f"{summary}".replace("$wallet_name", str(wallet_name))
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
"BEGIN:VCALENDAR",
|
|
||||||
"VERSION:2.0",
|
|
||||||
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{version}",
|
|
||||||
]
|
|
||||||
|
|
||||||
# One separate VEVENT per reminder offset (its own date in the calendar).
|
|
||||||
total = len(offsets)
|
|
||||||
for idx, offset in enumerate(offsets, start=1):
|
|
||||||
# The visible date of this event: "offset" days before the deadline.
|
|
||||||
event_dt = format_time(locktime - timedelta(days=offset))
|
|
||||||
# Suffix the summary so the N events are easy to tell apart.
|
|
||||||
event_summary = ical_escape(f"{summary_base} (reminder {idx}/{total})")
|
|
||||||
lines.extend([
|
|
||||||
"BEGIN:VEVENT",
|
|
||||||
# Offset in the UID keeps each event unique (no merging).
|
|
||||||
f"UID:bal-{str(wallet_name)}-{offset}d",
|
|
||||||
f"DTSTAMP:{format_time(now)}",
|
|
||||||
f"DTSTART:{event_dt}",
|
|
||||||
f"DTEND:{event_dt}",
|
|
||||||
f"SUMMARY:{event_summary}",
|
|
||||||
f"DESCRIPTION:{event_description}",
|
|
||||||
"END:VEVENT",
|
|
||||||
])
|
|
||||||
|
|
||||||
lines.append("END:VCALENDAR")
|
|
||||||
lines = [s.rstrip("\r\n") for s in lines]
|
|
||||||
return "\r\n".join(lines) + "\r\n"
|
|
||||||
267
bal/core/util.py
267
bal/core/util.py
@@ -18,14 +18,10 @@ original implementation.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import bisect
|
import bisect
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
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*.
|
||||||
@@ -38,41 +34,6 @@ _logger = get_logger(__name__)
|
|||||||
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)."""
|
||||||
@@ -141,7 +102,7 @@ class Util:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
now = datetime.now(tz=timezone.utc)
|
now = datetime.now()
|
||||||
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":
|
||||||
@@ -169,72 +130,6 @@ class Util:
|
|||||||
+ days * 60 * 60 * 24
|
+ days * 60 * 60 * 24
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _relative_days(value):
|
|
||||||
"""Duration in days of a relative ``"Nd"``/``"Ny"`` recipe.
|
|
||||||
|
|
||||||
Returns ``None`` when the value is not a relative recipe (an absolute
|
|
||||||
timestamp, a plain number, or garbage).
|
|
||||||
"""
|
|
||||||
s = str(value)
|
|
||||||
if s and s[-1] in "yYdD":
|
|
||||||
try:
|
|
||||||
n = int(s[:-1])
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return n * 365 if s[-1] in "yY" else n
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def resolve_locktime_against_tx(current, built, tx_locktime):
|
|
||||||
"""Resolve a locktime recipe against the moment the signed tx was built.
|
|
||||||
|
|
||||||
A RELATIVE recipe stored in the wallet (``"1y"``/``"30d"``) is a moving
|
|
||||||
target: parsing it against *now* on every check drifts it one day per
|
|
||||||
day away from the fixed locktime frozen inside the signed Bitcoin
|
|
||||||
transaction, so an UNCHANGED will is mistaken for a POSTPONE and the
|
|
||||||
plugin asks to invalidate it every day (reported bug). This resolves
|
|
||||||
the current recipe against the build moment instead, recovered from the
|
|
||||||
signed transaction's locktime and the recipe that was actually frozen
|
|
||||||
at build time (``built``, the value stored in the will item):
|
|
||||||
|
|
||||||
build_moment = tx_locktime - duration(built)
|
|
||||||
expected = build_moment + duration(current)
|
|
||||||
|
|
||||||
An unchanged recipe therefore resolves to exactly ``tx_locktime``
|
|
||||||
(coherent), a lengthened one resolves later (postpone) and a shortened
|
|
||||||
one earlier (anticipate).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
current: the current locktime recipe (relative or absolute).
|
|
||||||
built: the recipe frozen at build time (stored in the will item).
|
|
||||||
tx_locktime: the absolute locktime frozen inside the signed tx.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: the resolved absolute locktime (UNIX timestamp).
|
|
||||||
"""
|
|
||||||
current_days = Util._relative_days(current)
|
|
||||||
built_days = Util._relative_days(built)
|
|
||||||
if current_days is None:
|
|
||||||
# Absolute current date: compare directly against the frozen tx.
|
|
||||||
try:
|
|
||||||
return int(current)
|
|
||||||
except Exception:
|
|
||||||
return Util.parse_locktime_string(current)
|
|
||||||
if built_days is None or not tx_locktime:
|
|
||||||
# The stored recipe was absolute (a fixed date) or the tx has no
|
|
||||||
# usable locktime: there is no relative anchor to recover the build
|
|
||||||
# moment, so fall back to the legacy forward-from-now resolution.
|
|
||||||
return Util.parse_locktime_string(current)
|
|
||||||
try:
|
|
||||||
base = datetime.fromtimestamp(int(tx_locktime), 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
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -478,9 +373,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, tz=timezone.utc)
|
dt = datetime.fromtimestamp(locktime)
|
||||||
except (OverflowError, OSError, ValueError):
|
except (OverflowError, OSError, ValueError):
|
||||||
dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1), tz=timezone.utc)
|
dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1))
|
||||||
dt -= timedelta(seconds=seconds)
|
dt -= timedelta(seconds=seconds)
|
||||||
out = dt.timestamp()
|
out = dt.timestamp()
|
||||||
|
|
||||||
@@ -488,6 +383,34 @@ 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)."""
|
||||||
@@ -502,6 +425,32 @@ 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
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -544,106 +493,6 @@ class Util:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_available_utxos(wallet, history_label, will_locktime=None):
|
|
||||||
"""Return the wallet's UTXOs as seen by the plugin's flows.
|
|
||||||
|
|
||||||
``wallet.get_utxos()`` drops any output that a wallet-LOCAL transaction
|
|
||||||
marks as spent. The plugin itself creates such local spenders when it
|
|
||||||
saves an incomplete will transaction into the local history; a *later*
|
|
||||||
will transaction stored there (a replacement/future will with a locktime
|
|
||||||
strictly after ``will_locktime``) must not hide the coins from the will
|
|
||||||
being checked or rebuilt. This view therefore restores those coins.
|
|
||||||
|
|
||||||
A local spender is ignored (the coin is kept available) only when ALL of
|
|
||||||
these hold:
|
|
||||||
|
|
||||||
* it is a wallet-local or future transaction (not broadcast),
|
|
||||||
* its wallet label matches the BAL history label template (after the
|
|
||||||
"{willexecutor}" substitution),
|
|
||||||
* the stored spender's locktime is strictly LATER than ``will_locktime``.
|
|
||||||
|
|
||||||
Real (broadcast/confirmed) spenders are never ignored. With a falsy
|
|
||||||
``will_locktime`` this returns ``wallet.get_utxos()`` unchanged.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
wallet: The Electrum wallet object.
|
|
||||||
history_label: The BAL history label template (may contain
|
|
||||||
"{willexecutor}").
|
|
||||||
will_locktime: Reference locktime of the will being operated on.
|
|
||||||
"""
|
|
||||||
if not wallet or not will_locktime:
|
|
||||||
return list(wallet.get_utxos()) if wallet else []
|
|
||||||
adb = getattr(wallet, "adb", None)
|
|
||||||
if adb is None or not hasattr(adb, "get_addr_outputs"):
|
|
||||||
return list(wallet.get_utxos())
|
|
||||||
addresses = (
|
|
||||||
wallet.get_addresses() if hasattr(wallet, "get_addresses") else []
|
|
||||||
)
|
|
||||||
utxos = []
|
|
||||||
for addr in addresses:
|
|
||||||
try:
|
|
||||||
outputs = adb.get_addr_outputs(addr)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
for utxo in outputs.values():
|
|
||||||
if utxo.spent_height is None:
|
|
||||||
utxos.append(utxo)
|
|
||||||
continue
|
|
||||||
spender = getattr(utxo, "spent_txid", None)
|
|
||||||
if spender and Util._is_ignorable_local_spender(
|
|
||||||
wallet, spender, history_label, will_locktime
|
|
||||||
):
|
|
||||||
utxos.append(utxo)
|
|
||||||
return utxos
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_ignorable_local_spender(wallet, spender, history_label, will_locktime):
|
|
||||||
"""True when the local ``spender`` tx is a later BAL history will tx.
|
|
||||||
|
|
||||||
See ``get_available_utxos`` for the exact conditions. Defensive: any
|
|
||||||
lookup failure makes this return False, so a spender is never ignored
|
|
||||||
on uncertain data.
|
|
||||||
"""
|
|
||||||
adb = wallet.adb
|
|
||||||
try:
|
|
||||||
height = int(adb.get_tx_height(spender).height())
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
if height not in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
label = wallet.get_label_for_txid(spender)
|
|
||||||
except Exception:
|
|
||||||
label = None
|
|
||||||
if not label or not Util._label_matches_history(label, history_label):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
stored = adb.db.get_transaction(spender)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
if stored is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
return int(stored.locktime) > int(will_locktime)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _label_matches_history(label, history_label):
|
|
||||||
"""True when ``label`` is the ``history_label`` template with the
|
|
||||||
"{willexecutor}" token substituted by some (possibly empty) executor URL.
|
|
||||||
"""
|
|
||||||
token = "{willexecutor}"
|
|
||||||
if token in history_label:
|
|
||||||
prefix, suffix = history_label.split(token, 1)
|
|
||||||
return (
|
|
||||||
label.startswith(prefix)
|
|
||||||
and label.endswith(suffix)
|
|
||||||
and len(label) >= len(prefix) + len(suffix)
|
|
||||||
)
|
|
||||||
return label == history_label
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def cmp_output(outputa, outputb):
|
def cmp_output(outputa, outputb):
|
||||||
"""Two outputs are equal when both address and value match."""
|
"""Two outputs are equal when both address and value match."""
|
||||||
|
|||||||
530
bal/core/will.py
530
bal/core/will.py
@@ -26,9 +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.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
|
||||||
from electrum.i18n import _
|
from electrum.i18n import _
|
||||||
from electrum.logging import Logger, get_logger
|
from electrum.logging import Logger, get_logger
|
||||||
from electrum.transaction import (
|
from electrum.transaction import (
|
||||||
@@ -40,12 +40,10 @@ from electrum.transaction import (
|
|||||||
tx_from_any,
|
tx_from_any,
|
||||||
)
|
)
|
||||||
from electrum.util import (
|
from electrum.util import (
|
||||||
UnrelatedTransactionException,
|
|
||||||
bfh,
|
bfh,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .heirs import WillExecutorFeeTooHighException
|
from .util import Util
|
||||||
from .util import Util, copy_structure
|
|
||||||
from .willexecutors import Willexecutors
|
from .willexecutors import Willexecutors
|
||||||
|
|
||||||
MIN_LOCKTIME = 1
|
MIN_LOCKTIME = 1
|
||||||
@@ -74,6 +72,11 @@ 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():
|
||||||
@@ -102,6 +105,15 @@ 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:
|
||||||
@@ -143,7 +155,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], wallet=wallet)
|
willitems[wid] = WillItem(will[wid])
|
||||||
will = willitems
|
will = willitems
|
||||||
errors = {}
|
errors = {}
|
||||||
for wid in will:
|
for wid in will:
|
||||||
@@ -165,7 +177,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] = ow.copy()
|
will[wid] = WillItem(ow.to_dict())
|
||||||
|
|
||||||
for i in range(0, len(outputs)):
|
for i in range(0, len(outputs)):
|
||||||
Will.change_input(
|
Will.change_input(
|
||||||
@@ -207,8 +219,13 @@ class Will:
|
|||||||
if ow.we["url"] == nw.we["url"]:
|
if ow.we["url"] == nw.we["url"]:
|
||||||
if int(ow.we["base_fee"]) > int(nw.we["base_fee"]):
|
if int(ow.we["base_fee"]) > int(nw.we["base_fee"]):
|
||||||
return anticipate
|
return anticipate
|
||||||
elif int(ow.tx_fees) != int(nw.tx_fees):
|
else:
|
||||||
return anticipate
|
if int(ow.tx_fees) != int(nw.tx_fees):
|
||||||
|
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]):
|
||||||
@@ -429,25 +446,19 @@ class Will:
|
|||||||
for wid in will:
|
for wid in will:
|
||||||
wtx = will[wid].tx
|
wtx = will[wid].tx
|
||||||
found = False
|
found = False
|
||||||
inp = None
|
|
||||||
for inp in wtx.inputs():
|
for inp in wtx.inputs():
|
||||||
if inp.prevout.txid.hex() in will:
|
if inp.prevout.txid.hex() in will:
|
||||||
found = True
|
found = True
|
||||||
break
|
break
|
||||||
if not found and inp is not None:
|
if not found:
|
||||||
out[inp.prevout.to_str()] = inp
|
out[inp.prevout.to_str()] = inp
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def invalidate_will(will, wallet, fees_per_byte, history_label=None,
|
def invalidate_will(will, wallet, fees_per_byte):
|
||||||
will_locktime=None):
|
|
||||||
_logger.debug("invalidate tx in will module")
|
|
||||||
will_only_valid = Will.only_valid_list(will)
|
will_only_valid = Will.only_valid_list(will)
|
||||||
inputs = Will.get_all_inputs(will_only_valid)
|
inputs = Will.get_all_inputs(will_only_valid)
|
||||||
if history_label is not None and will_locktime is not None:
|
utxos = wallet.get_utxos()
|
||||||
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)
|
||||||
@@ -461,13 +472,11 @@ class Will:
|
|||||||
utxo_to_spend = []
|
utxo_to_spend = []
|
||||||
for utxo in utxos:
|
for utxo in utxos:
|
||||||
if utxo.is_coinbase_output() and utxo.block_height < current_height+100:
|
if utxo.is_coinbase_output() and utxo.block_height < current_height+100:
|
||||||
_logger.debug("is not mature coinbase output")
|
|
||||||
continue
|
continue
|
||||||
utxo_str = utxo.prevout.to_str()
|
utxo_str = utxo.prevout.to_str()
|
||||||
if utxo_str in prevout_to_spend:
|
if utxo_str in prevout_to_spend:
|
||||||
balance += utxo.value_sats()
|
balance += inputs[utxo_str][0][2].value_sats()
|
||||||
utxo_to_spend.append(utxo)
|
utxo_to_spend.append(utxo)
|
||||||
_logger.debug("utxo to spend: {}".format(utxo_to_spend))
|
|
||||||
if len(utxo_to_spend) > 0:
|
if len(utxo_to_spend) > 0:
|
||||||
change_addresses = wallet.get_change_addresses_for_new_transaction()
|
change_addresses = wallet.get_change_addresses_for_new_transaction()
|
||||||
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
|
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
|
||||||
@@ -499,10 +508,9 @@ 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):
|
||||||
@@ -526,26 +534,10 @@ class Will:
|
|||||||
wi.set_status("INVALIDATED", True)
|
wi.set_status("INVALIDATED", True)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# The funding outpoint is not part of the will tree:
|
if wallet.db.get_transaction(wi._id):
|
||||||
# decide from whether a broadcast transaction really
|
|
||||||
# spends it (a wallet-local history copy of the same
|
|
||||||
# will tx must neither turn the item CONFIRMED nor
|
|
||||||
# INVALIDATED - it is just a persistence artifact).
|
|
||||||
stored = None
|
|
||||||
if wallet and getattr(wallet, "db", None):
|
|
||||||
try:
|
|
||||||
stored = wallet.db.get_transaction(wi._id)
|
|
||||||
except Exception:
|
|
||||||
stored = None
|
|
||||||
spender_height = Will._funding_spender_height(wallet, inp)
|
|
||||||
if spender_height is None:
|
|
||||||
if stored:
|
|
||||||
continue
|
|
||||||
wi.set_status("INVALIDATED", True)
|
|
||||||
elif spender_height == 0:
|
|
||||||
wi.set_status("MEMPOOL", True)
|
|
||||||
else:
|
|
||||||
wi.set_status("CONFIRMED", True)
|
wi.set_status("CONFIRMED", True)
|
||||||
|
else:
|
||||||
|
wi.set_status("INVALIDATED", True)
|
||||||
|
|
||||||
for child in wi.search(all_inputs):
|
for child in wi.search(all_inputs):
|
||||||
if child.tx.locktime < wi.tx.locktime:
|
if child.tx.locktime < wi.tx.locktime:
|
||||||
@@ -583,22 +575,14 @@ class Will:
|
|||||||
for inp in w.tx.inputs():
|
for inp in w.tx.inputs():
|
||||||
inp_str = Util.utxo_to_str(inp)
|
inp_str = Util.utxo_to_str(inp)
|
||||||
if inp_str not in utxos_list:
|
if inp_str not in utxos_list:
|
||||||
if not wallet or not getattr(wallet, "adb", None):
|
if wallet:
|
||||||
continue
|
height = Will.check_tx_height(w.tx, wallet)
|
||||||
height = Will.check_tx_height(w.tx, wallet)
|
if height < 0:
|
||||||
if height < 0:
|
|
||||||
# The will tx itself is not on-chain. A missing
|
|
||||||
# funding UTXO is only a real problem when a
|
|
||||||
# broadcast transaction actually spends it; a
|
|
||||||
# wallet-local (history) copy of the same will tx
|
|
||||||
# marks the funding spent locally and must not
|
|
||||||
# invalidate the will.
|
|
||||||
if Will._funding_really_spent(wallet, inp_str):
|
|
||||||
Will.set_invalidate(wid, willtree)
|
Will.set_invalidate(wid, willtree)
|
||||||
elif height == 0:
|
elif height == 0:
|
||||||
w.set_status("MEMPOOL", True)
|
w.set_status("MEMPOOL", True)
|
||||||
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"):
|
||||||
@@ -614,8 +598,7 @@ class Will:
|
|||||||
# Will.reflect_to_children(wc)
|
# Will.reflect_to_children(wc)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust,
|
def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust):
|
||||||
max_fee=None):
|
|
||||||
fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = (
|
fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = (
|
||||||
heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True)
|
heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True)
|
||||||
)
|
)
|
||||||
@@ -631,92 +614,13 @@ class Will:
|
|||||||
raise PercAmountException(f"Perc amount({perc_amount}) =! 100%")
|
raise PercAmountException(f"Perc amount({perc_amount}) =! 100%")
|
||||||
|
|
||||||
for url, wex in willexecutors.items():
|
for url, wex in willexecutors.items():
|
||||||
if Willexecutors.is_selected(wex) and Willexecutors.is_valid(wex, max_fee=max_fee, dust=dust):
|
if Willexecutors.is_selected(wex):
|
||||||
if max_fee is not None and int(wex["base_fee"]) > max_fee:
|
|
||||||
raise WillExecutorFeeTooHighException(wex, max_fee)
|
|
||||||
temp_balance = wallet_balance - int(wex["base_fee"])
|
temp_balance = wallet_balance - int(wex["base_fee"])
|
||||||
if fixed_amount >= temp_balance:
|
if fixed_amount >= temp_balance:
|
||||||
raise FixedAmountException(
|
raise FixedAmountException(
|
||||||
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
|
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _funding_really_spent(wallet, inp_str):
|
|
||||||
"""True when a broadcast transaction really spends the ``txid:n`` outpoint.
|
|
||||||
|
|
||||||
``wallet.adb.get_spender`` discards wallet-local spenders (the stored
|
|
||||||
will tx from the local history) and future transactions, so this is True
|
|
||||||
only when the funding was consumed by a real on-chain/mempool tx.
|
|
||||||
"""
|
|
||||||
if not wallet or not getattr(wallet, "adb", None):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
return wallet.adb.get_spender(inp_str) is not None
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error(f"get_spender failed for {inp_str}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _funding_spender_height(wallet, inp_str):
|
|
||||||
"""Mined height of the broadcast tx spending ``inp_str``, or None.
|
|
||||||
|
|
||||||
Returns ``None`` when no broadcast transaction spends the outpoint (a
|
|
||||||
wallet-local history spender or a future tx are ignored by
|
|
||||||
``adb.get_spender``). The height is 0 for a mempool spender and positive
|
|
||||||
for a confirmed one.
|
|
||||||
"""
|
|
||||||
if not wallet or not getattr(wallet, "adb", None):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
spender = wallet.adb.get_spender(inp_str)
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error(f"get_spender failed for {inp_str}: {e}")
|
|
||||||
return None
|
|
||||||
if spender is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return int(wallet.adb.get_tx_height(spender).height())
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error(f"get_tx_height failed for {spender}: {e}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _absorb_history_signatures(will, wallet):
|
|
||||||
"""Merge signatures from the wallet's stored local copy of each will tx.
|
|
||||||
|
|
||||||
An incomplete will transaction saved into the local history (see
|
|
||||||
``save_valid_transactions_to_history``) may later accumulate signatures
|
|
||||||
(e.g. after a manual merge from a more complete copy). The in-memory
|
|
||||||
will item would otherwise miss those signatures on the next check. For
|
|
||||||
every item whose stored wallet copy is the same partial transaction the
|
|
||||||
signatures are merged into the in-memory one and, if it becomes fully
|
|
||||||
signed, the item is marked COMPLETE.
|
|
||||||
|
|
||||||
This method must never raise: history absorption is a convenience on top
|
|
||||||
of the will check, so any failure is logged and ignored.
|
|
||||||
"""
|
|
||||||
if not wallet or not getattr(wallet, "db", None):
|
|
||||||
return
|
|
||||||
for wi in will.values():
|
|
||||||
try:
|
|
||||||
if (
|
|
||||||
wi.tx is None
|
|
||||||
or not isinstance(wi.tx, PartialTransaction)
|
|
||||||
or wi.tx.is_complete()
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
stored = wallet.db.get_transaction(wi._id)
|
|
||||||
if (
|
|
||||||
not isinstance(stored, Transaction)
|
|
||||||
or stored.txid() != wi.tx.txid()
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
wi.tx.combine_with_other_psbt(stored)
|
|
||||||
if wi.tx.is_complete():
|
|
||||||
wi.set_status("COMPLETE", True)
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error(f"absorb history signatures failed for item {wi._id}: {e}")
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check_will(will, all_utxos, wallet, timestamp_to_check):
|
def check_will(will, all_utxos, wallet, timestamp_to_check):
|
||||||
"""Validate a will against the current wallet state.
|
"""Validate a will against the current wallet state.
|
||||||
@@ -732,7 +636,6 @@ class Will:
|
|||||||
timestamp_to_check: The reference UNIX timestamp (usually "now")
|
timestamp_to_check: The reference UNIX timestamp (usually "now")
|
||||||
used to decide whether any transaction has expired.
|
used to decide whether any transaction has expired.
|
||||||
"""
|
"""
|
||||||
Will._absorb_history_signatures(will, wallet)
|
|
||||||
Will.add_willtree(will)
|
Will.add_willtree(will)
|
||||||
utxos_list = Will.utxos_strs(all_utxos)
|
utxos_list = Will.utxos_strs(all_utxos)
|
||||||
|
|
||||||
@@ -746,228 +649,6 @@ class Will:
|
|||||||
|
|
||||||
Will.search_rai(all_inputs, all_utxos, will, wallet)
|
Will.search_rai(all_inputs, all_utxos, will, wallet)
|
||||||
|
|
||||||
Will.check_signatures(will, wallet)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def save_valid_transactions_to_history(will, wallet, history_label):
|
|
||||||
"""Keep the wallet's LOCAL history in sync with the current will state.
|
|
||||||
|
|
||||||
Called after the will has been built/signed/checked (see the
|
|
||||||
SAVE_HISTORY / HISTORY_LABEL settings). A will transaction belongs in the
|
|
||||||
local history while it is still "New" (not yet fully signed - i.e. an
|
|
||||||
incomplete partial transaction), and must be removed once it becomes
|
|
||||||
"Complete" (fully signed), because at that point it is ready to be
|
|
||||||
broadcast and will appear in the history on its own.
|
|
||||||
|
|
||||||
For every will item that is valid and whose transaction has a txid it:
|
|
||||||
|
|
||||||
1. decodes the label template, replacing "{willexecutor}" with the
|
|
||||||
will-executor URL of the item,
|
|
||||||
2. if the transaction is NOT complete, stores it via
|
|
||||||
``wallet.adb.add_transaction`` (merging signatures when an
|
|
||||||
already-stored partial transaction is upgraded by a more complete
|
|
||||||
one) and tags it with the decoded label,
|
|
||||||
3. if the transaction IS complete, does not store it: its matching
|
|
||||||
local-history entry is removed by the cleanup below.
|
|
||||||
|
|
||||||
Finally it deletes every wallet-local transaction whose label exactly
|
|
||||||
matches the decoded label of a current valid item but that is no longer
|
|
||||||
among the just-saved transactions, so fully-signed, rebuilt or replaced
|
|
||||||
wills do not pile up stale entries.
|
|
||||||
|
|
||||||
This method must never raise: history persistence is a convenience on
|
|
||||||
top of the will check, so any failure is logged and ignored.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
will: The will dictionary (WillItem entries keyed by txid).
|
|
||||||
wallet: The Electrum wallet object (may be falsy for offline
|
|
||||||
checks, in which case this is a no-op).
|
|
||||||
history_label: The label template to apply (may contain
|
|
||||||
"{willexecutor}").
|
|
||||||
"""
|
|
||||||
if not wallet or not getattr(wallet, "adb", None):
|
|
||||||
return
|
|
||||||
saved_txids = []
|
|
||||||
try:
|
|
||||||
current_labels = {
|
|
||||||
history_label.replace(
|
|
||||||
"{willexecutor}", (wi.we or {}).get("url", "")
|
|
||||||
)
|
|
||||||
for wi in will.values()
|
|
||||||
if wi.get_status("VALID")
|
|
||||||
and wi.tx is not None
|
|
||||||
and wi.tx.txid() is not None
|
|
||||||
}
|
|
||||||
for wi in will.values():
|
|
||||||
if not wi.get_status("VALID"):
|
|
||||||
continue
|
|
||||||
if wi.tx is None or wi.tx.txid() is None:
|
|
||||||
continue
|
|
||||||
# Fully-signed (complete) transactions must NOT be saved: they
|
|
||||||
# are removed from the local history so the list does not show a
|
|
||||||
# placeholder for a transaction that will appear on its own once
|
|
||||||
# broadcast/confirmed. Only the not-yet-complete "New" items are
|
|
||||||
# stored. Note that fully-segwit partial txs have a txid even
|
|
||||||
# when incomplete, so the txid() check alone is not enough.
|
|
||||||
if wi.tx.is_complete():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
txid = wi.tx.txid()
|
|
||||||
label = history_label.replace(
|
|
||||||
"{willexecutor}", (wi.we or {}).get("url", "")
|
|
||||||
)
|
|
||||||
Will._add_transaction_to_history(wallet, wi.tx, txid)
|
|
||||||
try:
|
|
||||||
wallet.set_label(txid, label)
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error(f"set_label failed for {txid}: {e}")
|
|
||||||
saved_txids.append(txid)
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error(f"save to history failed for item {wi._id}: {e}")
|
|
||||||
# Delete stale wallet-local txs whose label matches a current valid
|
|
||||||
# item but that are no longer among the saved ones. This removes
|
|
||||||
# entries for fully-signed (complete) items and for rebuilt/replaced
|
|
||||||
# wills with the same executor.
|
|
||||||
for txid, label in Will._wallet_labels(wallet):
|
|
||||||
if txid in saved_txids:
|
|
||||||
continue
|
|
||||||
if label not in current_labels:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
wallet.adb.remove_transaction(txid)
|
|
||||||
try:
|
|
||||||
wallet.set_label(txid, None)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error(f"remove from history failed for {txid}: {e}")
|
|
||||||
try:
|
|
||||||
wallet.save_db()
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error(f"save_db failed after history update: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error(f"save_valid_transactions_to_history failed: {e}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def 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)
|
||||||
@@ -1096,7 +777,7 @@ class Will:
|
|||||||
timestamp_to_check: Reference UNIX timestamp (usually "now").
|
timestamp_to_check: Reference UNIX timestamp (usually "now").
|
||||||
"""
|
"""
|
||||||
_logger.info("check if some transaction is expired")
|
_logger.info("check if some transaction is expired")
|
||||||
for _inputs, wid in all_inputs_min_locktime.items():
|
for prevout_str, wid in all_inputs_min_locktime.items():
|
||||||
for w in wid:
|
for w in wid:
|
||||||
if w[1].get_status("VALID"):
|
if w[1].get_status("VALID"):
|
||||||
locktime = int(wid[0][1].tx.locktime)
|
locktime = int(wid[0][1].tx.locktime)
|
||||||
@@ -1173,6 +854,8 @@ class Will:
|
|||||||
if heir := heirs.get(wheir, None):
|
if heir := heirs.get(wheir, None):
|
||||||
|
|
||||||
if heir[0] == their[0] and heir[1] == their[1]:
|
if heir[0] == their[0] and heir[1] == their[1]:
|
||||||
|
# The requested (possibly new) locktime for this heir.
|
||||||
|
new_locktime = Util.parse_locktime_string(heir[2])
|
||||||
# IMPORTANT: compare against the locktime that is
|
# IMPORTANT: compare against the locktime that is
|
||||||
# actually frozen inside the already-signed Bitcoin
|
# actually frozen inside the already-signed Bitcoin
|
||||||
# transaction (w.tx.locktime), NOT against their[2].
|
# transaction (w.tx.locktime), NOT against their[2].
|
||||||
@@ -1183,16 +866,6 @@ class Will:
|
|||||||
# undetected. w.tx.locktime is immutable once signed
|
# undetected. w.tx.locktime is immutable once signed
|
||||||
# and is exactly what the will-executors hold.
|
# and is exactly what the will-executors hold.
|
||||||
tx_locktime = int(w.tx.locktime)
|
tx_locktime = int(w.tx.locktime)
|
||||||
# The requested (possibly new) locktime for this heir.
|
|
||||||
# A RELATIVE recipe ("1y"/"30d") is resolved against
|
|
||||||
# the moment the signed tx was built, NOT against now:
|
|
||||||
# re-parsing it from "now" drifts it one day per day
|
|
||||||
# away from the frozen tx locktime, so an UNCHANGED
|
|
||||||
# will would be read as a POSTPONE and the plugin
|
|
||||||
# would ask to invalidate it every day.
|
|
||||||
new_locktime = Util.resolve_locktime_against_tx(
|
|
||||||
heir[2], their[2], tx_locktime
|
|
||||||
)
|
|
||||||
if new_locktime == tx_locktime:
|
if new_locktime == tx_locktime:
|
||||||
# Unchanged: this heir is still coherent.
|
# Unchanged: this heir is still coherent.
|
||||||
count = heirs_found.get(wheir, 0)
|
count = heirs_found.get(wheir, 0)
|
||||||
@@ -1256,15 +929,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) and Willexecutors.is_valid(we):
|
if Willexecutors.is_selected(we):
|
||||||
if url not in willexecutors_found:
|
if url not in willexecutors_found:
|
||||||
_logger.debug(f"will-executor: {url} not fount")
|
_logger.debug(f"will-executor: {url} not fount")
|
||||||
raise WillExecutorNotPresent(url)
|
raise WillExecutorNotPresent(url)
|
||||||
@@ -1301,7 +974,6 @@ class WillItem(Logger):
|
|||||||
"MEMPOOL": ["Mempool", False],
|
"MEMPOOL": ["Mempool", False],
|
||||||
"PUSH_FAIL": ["Push failed", False],
|
"PUSH_FAIL": ["Push failed", False],
|
||||||
"PUSHED": ["Pushed", False],
|
"PUSHED": ["Pushed", False],
|
||||||
"PARTIALLY_SIGNED": ["Partially Signed", False],
|
|
||||||
"REPLACED": ["Replaced", False],
|
"REPLACED": ["Replaced", False],
|
||||||
"RESTORED": ["Restored", False],
|
"RESTORED": ["Restored", False],
|
||||||
"UPDATED": ["Updated", False],
|
"UPDATED": ["Updated", False],
|
||||||
@@ -1360,85 +1032,51 @@ 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(w, WillItem):
|
if isinstance(
|
||||||
# Copy a WillItem WITHOUT deepcopy. Serialize it to its plain-dict
|
w,
|
||||||
# form and deserialize from there: the tx is re-parsed into a fresh
|
WillItem,
|
||||||
# object, STATUS is rebuilt from the clones below and heirs /
|
):
|
||||||
# will-executors are cloned recursively, so the copy shares no
|
self.__dict__ = w.__dict__.copy()
|
||||||
# mutable state with the source. See also copy().
|
|
||||||
data = w.to_dict()
|
|
||||||
data["heirs"] = copy_structure(w.heirs) if w.heirs is not None else None
|
|
||||||
data["willexecutor"] = (
|
|
||||||
copy_structure(w.we) if w.we is not None else None
|
|
||||||
)
|
|
||||||
if not _id:
|
|
||||||
_id = w._id
|
|
||||||
w = data
|
|
||||||
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:
|
else:
|
||||||
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", 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:
|
||||||
|
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,
|
||||||
@@ -1450,10 +1088,6 @@ class WillItem(Logger):
|
|||||||
"time": self.time,
|
"time": self.time,
|
||||||
"change": self.change,
|
"change": self.change,
|
||||||
"baltx_fees": self.tx_fees,
|
"baltx_fees": self.tx_fees,
|
||||||
"sigs_required": self.sigs_required,
|
|
||||||
"sigs_have": self.sigs_have,
|
|
||||||
"Father": self.father,
|
|
||||||
"Children": self.children,
|
|
||||||
}
|
}
|
||||||
for key in self.STATUS:
|
for key in self.STATUS:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -17,16 +17,13 @@ interaction is handled by the Qt layer.
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from aiohttp import ClientResponse
|
from aiohttp import ClientResponse
|
||||||
from electrum import bitcoin, constants
|
|
||||||
from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
|
|
||||||
from electrum.i18n import _
|
from electrum.i18n import _
|
||||||
from electrum.logging import get_logger
|
from electrum.logging import get_logger
|
||||||
from electrum.network import Network
|
from electrum.network import Network
|
||||||
|
|
||||||
from .plugin_base import BalPlugin, get_version
|
from .plugin_base import BalPlugin
|
||||||
|
|
||||||
# Per-request timeout (seconds) for interactive operations (ping / info /
|
# Per-request timeout (seconds) for interactive operations (ping / info /
|
||||||
# list download). These fail fast (no retries) so a dead server does not
|
# list download). These fail fast (no retries) so a dead server does not
|
||||||
@@ -112,6 +109,8 @@ def is_tor_active():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
chainname = BalPlugin.chainname
|
||||||
|
|
||||||
|
|
||||||
class Willexecutors:
|
class Willexecutors:
|
||||||
|
|
||||||
@@ -144,19 +143,19 @@ class Willexecutors:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def save(bal_plugin, willexecutors):
|
def save(bal_plugin, willexecutors):
|
||||||
_logger.debug(f"save {willexecutors},{BalPlugin.chainname}")
|
_logger.debug(f"save {willexecutors},{chainname}")
|
||||||
aw = bal_plugin.WILLEXECUTORS.get()
|
aw = bal_plugin.WILLEXECUTORS.get()
|
||||||
aw[BalPlugin.chainname] = willexecutors
|
aw[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: Any = None, force=False, task=True
|
bal_plugin, update=False, bal_window=False, force=False, task=True
|
||||||
):
|
):
|
||||||
willexecutors = bal_plugin.WILLEXECUTORS.get()
|
willexecutors = bal_plugin.WILLEXECUTORS.get()
|
||||||
willexecutors = willexecutors.get(BalPlugin.chainname, {})
|
willexecutors = willexecutors.get(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):
|
||||||
@@ -170,7 +169,7 @@ class Willexecutors:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
del willexecutors[w]
|
del willexecutors[w]
|
||||||
bal = bal_plugin.WILLEXECUTORS.default.get(BalPlugin.chainname, {})
|
bal = bal_plugin.WILLEXECUTORS.default.get(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")
|
||||||
@@ -215,20 +214,6 @@ class Willexecutors:
|
|||||||
willexecutor["selected"] = False
|
willexecutor["selected"] = False
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def is_valid(willexecutor, max_fee=None, dust=None):
|
|
||||||
if not willexecutor:
|
|
||||||
return False
|
|
||||||
address = willexecutor.get("address", "")
|
|
||||||
if not address or not bitcoin.is_address(address, net=constants.net):
|
|
||||||
return False
|
|
||||||
base_fee = int(willexecutor.get("base_fee", 0))
|
|
||||||
if dust is not None and base_fee < dust:
|
|
||||||
return False
|
|
||||||
if max_fee is not None and base_fee > max_fee:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_willexecutor_transactions(will, force=False):
|
def get_willexecutor_transactions(will, force=False):
|
||||||
willexecutors = {}
|
willexecutors = {}
|
||||||
@@ -287,48 +272,55 @@ class Willexecutors:
|
|||||||
raise Exception("You are offline.")
|
raise Exception("You are offline.")
|
||||||
_logger.debug(f"<-- {method} {url} {data}")
|
_logger.debug(f"<-- {method} {url} {data}")
|
||||||
headers = {}
|
headers = {}
|
||||||
headers["user-agent"] = f"BalPlugin v:{get_version()}"
|
headers["user-agent"] = f"BalPlugin v:{BalPlugin.__version__}"
|
||||||
headers["Content-Type"] = "text/plain"
|
headers["Content-Type"] = "text/plain"
|
||||||
if not handle_response:
|
if not handle_response:
|
||||||
handle_response = Willexecutors.handle_response
|
handle_response = Willexecutors.handle_response
|
||||||
attempts = max_retries + 1
|
try:
|
||||||
for attempt in range(attempts):
|
if method == "get":
|
||||||
try:
|
response = Network.send_http_on_proxy(
|
||||||
if method == "get":
|
method,
|
||||||
response = Network.send_http_on_proxy(
|
url,
|
||||||
method,
|
params=data,
|
||||||
url,
|
headers=headers,
|
||||||
params=data,
|
on_finish=handle_response,
|
||||||
headers=headers,
|
timeout=timeout,
|
||||||
on_finish=handle_response,
|
)
|
||||||
timeout=timeout,
|
elif method == "post":
|
||||||
)
|
response = Network.send_http_on_proxy(
|
||||||
elif method == "post":
|
method,
|
||||||
response = Network.send_http_on_proxy(
|
url,
|
||||||
method,
|
body=data,
|
||||||
url,
|
headers=headers,
|
||||||
body=data,
|
on_finish=handle_response,
|
||||||
headers=headers,
|
timeout=timeout,
|
||||||
on_finish=handle_response,
|
)
|
||||||
timeout=timeout,
|
else:
|
||||||
)
|
raise Exception(f"unexpected {method=!r}")
|
||||||
else:
|
except TimeoutError:
|
||||||
raise Exception(f"unexpected {method=!r}")
|
if count_reply < max_retries:
|
||||||
_logger.debug(f"--> {response}")
|
_logger.debug(
|
||||||
return response
|
f"timeout({count_reply}) error: retry in {retry_sleep} sec..."
|
||||||
except TimeoutError:
|
)
|
||||||
if attempt < max_retries:
|
if retry_sleep:
|
||||||
_logger.debug(
|
time.sleep(retry_sleep)
|
||||||
f"timeout({attempt}) error: "
|
return Willexecutors.send_request(
|
||||||
f"retry in {retry_sleep} sec..."
|
method,
|
||||||
)
|
url,
|
||||||
if retry_sleep:
|
data,
|
||||||
time.sleep(retry_sleep)
|
timeout=timeout,
|
||||||
else:
|
handle_response=handle_response,
|
||||||
_logger.debug(f"Too many timeouts: {attempt}")
|
count_reply=count_reply + 1,
|
||||||
except Exception as e:
|
max_retries=max_retries,
|
||||||
raise e
|
retry_sleep=retry_sleep,
|
||||||
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):
|
||||||
@@ -339,11 +331,15 @@ class Willexecutors:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def handle_response(resp: ClientResponse):
|
async def handle_response(resp: ClientResponse):
|
||||||
resp.raise_for_status()
|
|
||||||
r = await resp.text()
|
r = await resp.text()
|
||||||
try:
|
try:
|
||||||
|
|
||||||
r = json.loads(r)
|
r = json.loads(r)
|
||||||
except json.JSONDecodeError:
|
# url = Willexecutors.get_we_url_from_response(resp)
|
||||||
|
# r["url"]= url
|
||||||
|
# r["status"]=resp.status
|
||||||
|
except Exception as e:
|
||||||
|
_logger.debug(f"error handling response:{e}")
|
||||||
pass
|
pass
|
||||||
return r
|
return r
|
||||||
|
|
||||||
@@ -366,25 +362,23 @@ 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"] + "/" + BalPlugin.chainname + "/pushtxs",
|
willexecutor["url"] + "/" + 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(
|
raise Exception("empty reply from:{willexecutor['url']}")
|
||||||
f"empty reply from:{willexecutor['url']}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.debug(f"error:{e}")
|
_logger.debug(f"error:{e}")
|
||||||
if str(e) == "already present":
|
if str(e) == "already present":
|
||||||
raise Willexecutors.AlreadyPresentException() from None
|
raise Willexecutors.AlreadyPresentException()
|
||||||
out = False
|
out = False
|
||||||
willexecutor["broadcast_status"] = _("Failed")
|
willexecutor["broadcast_status"] = _("Failed")
|
||||||
|
|
||||||
@@ -406,38 +400,15 @@ 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 + "/" + BalPlugin.chainname + "/info",
|
"get", url + "/" + 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):
|
||||||
address = w.get("address")
|
willexecutor["url"] = url
|
||||||
if not isinstance(address, str) or not bitcoin.is_address(
|
willexecutor["status"] = 200
|
||||||
address, net=constants.net
|
willexecutor["base_fee"] = w["base_fee"]
|
||||||
):
|
willexecutor["address"] = w["address"]
|
||||||
_logger.warning(
|
willexecutor["info"] = w["info"]
|
||||||
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"
|
||||||
@@ -480,7 +451,8 @@ class Willexecutors:
|
|||||||
Returns:
|
Returns:
|
||||||
The same ``willexecutors`` mapping, updated in place.
|
The same ``willexecutors`` mapping, updated in place.
|
||||||
"""
|
"""
|
||||||
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
from concurrent.futures import ThreadPoolExecutor, wait
|
||||||
|
from concurrent.futures import FIRST_COMPLETED
|
||||||
|
|
||||||
items = list(willexecutors.items())
|
items = list(willexecutors.items())
|
||||||
if not items:
|
if not items:
|
||||||
@@ -560,7 +532,8 @@ class Willexecutors:
|
|||||||
Returns ``{url: (ok, exception_or_None)}`` for the servers that
|
Returns ``{url: (ok, exception_or_None)}`` for the servers that
|
||||||
answered in time (timed-out servers are reported via ``on_timeout``).
|
answered in time (timed-out servers are reported via ``on_timeout``).
|
||||||
"""
|
"""
|
||||||
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
from concurrent.futures import ThreadPoolExecutor, wait
|
||||||
|
from concurrent.futures import FIRST_COMPLETED
|
||||||
|
|
||||||
targets = [(url, we) for url, we in willexecutors.items() if "txs" in we]
|
targets = [(url, we) for url, we in willexecutors.items() if "txs" in we]
|
||||||
results = {}
|
results = {}
|
||||||
@@ -677,7 +650,8 @@ class Willexecutors:
|
|||||||
Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers
|
Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers
|
||||||
that answered in time.
|
that answered in time.
|
||||||
"""
|
"""
|
||||||
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
from concurrent.futures import ThreadPoolExecutor, wait
|
||||||
|
from concurrent.futures import FIRST_COMPLETED
|
||||||
|
|
||||||
targets = [(wid, url) for wid, url in items if url]
|
targets = [(wid, url) for wid, url in items if url]
|
||||||
results = {}
|
results = {}
|
||||||
@@ -768,14 +742,7 @@ class Willexecutors:
|
|||||||
else:
|
else:
|
||||||
willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko"))
|
willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko"))
|
||||||
willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False)
|
willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False)
|
||||||
address = old_willexecutor.get("address", willexecutor.get("address", ""))
|
willexecutor["address"]=old_willexecutor.get("address",willexecutor.get("address",""))
|
||||||
if address and not bitcoin.is_address(address, net=constants.net):
|
|
||||||
_logger.warning(
|
|
||||||
f"invalid address {address!r} for executor {url}, "
|
|
||||||
f"falling back to empty"
|
|
||||||
)
|
|
||||||
address = ""
|
|
||||||
willexecutor["address"] = address
|
|
||||||
willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code"))
|
willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code"))
|
||||||
|
|
||||||
|
|
||||||
@@ -786,25 +753,16 @@ 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/{BalPlugin.chainname}?page=0&limit=100",
|
f"{welist_server}data/{chainname}?page=0&limit=100",
|
||||||
)
|
)
|
||||||
if not isinstance(willexecutors, dict):
|
# del willexecutors["status"]
|
||||||
_logger.warning(
|
|
||||||
f"unexpected download_list response type: "
|
|
||||||
f"{type(willexecutors).__name__}"
|
|
||||||
)
|
|
||||||
return {}
|
|
||||||
for w in willexecutors:
|
for w in willexecutors:
|
||||||
if w not in ("status", "url"):
|
if w not in ("status", "url"):
|
||||||
if not isinstance(willexecutors.get(w), dict):
|
|
||||||
_logger.warning(
|
|
||||||
f"malformed entry {w!r} in executor list, "
|
|
||||||
f"type={type(willexecutors.get(w)).__name__}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
Willexecutors.initialize_willexecutor(
|
Willexecutors.initialize_willexecutor(
|
||||||
willexecutors[w], w, None, old_willexecutors.get(w,None)
|
willexecutors[w], w, None, old_willexecutors.get(w,None)
|
||||||
)
|
)
|
||||||
|
# bal_plugin.WILLEXECUTORS.set(l)
|
||||||
|
# bal_plugin.config.set_key(bal_plugin.WILLEXECUTORS,l,save=True)
|
||||||
return willexecutors
|
return willexecutors
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -836,12 +794,6 @@ class Willexecutors:
|
|||||||
"post", url + "/searchtx", data=txid.encode("ascii"),
|
"post", url + "/searchtx", data=txid.encode("ascii"),
|
||||||
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
|
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
|
||||||
)
|
)
|
||||||
if not isinstance(w, dict):
|
|
||||||
_logger.warning(
|
|
||||||
f"unexpected check_transaction response type "
|
|
||||||
f"from {url}: {type(w).__name__}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
return w
|
return w
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error(f"error contacting {url} for checking txs {e}")
|
_logger.error(f"error contacting {url} for checking txs {e}")
|
||||||
|
|||||||
@@ -7,21 +7,12 @@ iCalendar (.ics) generation and "open with default calendar app" helper.
|
|||||||
When a will is built, the plugin can create a calendar event reminding the user
|
When a will is built, the plugin can create a calendar event reminding the user
|
||||||
to "check in" before the locktime expires. This module turns the event data
|
to "check in" before the locktime expires. This module turns the event data
|
||||||
into an RFC-5545 .ics file and opens it with the OS default application.
|
into an RFC-5545 .ics file and opens it with the OS default application.
|
||||||
|
|
||||||
The pure RFC-5545 logic (offsets, escaping, folding, the unified .ics builder,
|
|
||||||
``write_temp_ics``) lives in :mod:`bal.core.reminders`; this module keeps only
|
|
||||||
the Qt button and the OS/subprocess glue.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
from .common import *
|
||||||
import subprocess
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
|
|
||||||
from electrum.gui.qt.util import getSaveFileName
|
|
||||||
from PyQt6.QtGui import QAction
|
from PyQt6.QtGui import QAction
|
||||||
from PyQt6.QtWidgets import QInputDialog, QMenu, QToolButton
|
from PyQt6.QtWidgets import QToolButton
|
||||||
|
|
||||||
from ...core.reminders import write_temp_ics
|
|
||||||
from .common import _, _logger
|
|
||||||
|
|
||||||
|
|
||||||
class BalCalendarButton(QToolButton):
|
class BalCalendarButton(QToolButton):
|
||||||
@@ -65,7 +56,7 @@ class BalCalendarButton(QToolButton):
|
|||||||
try:
|
try:
|
||||||
content = self._ics_provider()
|
content = self._ics_provider()
|
||||||
if content:
|
if content:
|
||||||
self._calendar_temp_path = write_temp_ics(content)
|
self._calendar_temp_path = BalCalendar.write_temp_ics(content)
|
||||||
else:
|
else:
|
||||||
self._calendar_temp_path = None
|
self._calendar_temp_path = None
|
||||||
self._bal_window.show_warning(
|
self._bal_window.show_warning(
|
||||||
@@ -88,8 +79,7 @@ class BalCalendarButton(QToolButton):
|
|||||||
path = self._ensure_ics()
|
path = self._ensure_ics()
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
import shlex
|
import shlex, subprocess
|
||||||
import subprocess
|
|
||||||
if self._bal_window.bal_plugin.is_basic_mode():
|
if self._bal_window.bal_plugin.is_basic_mode():
|
||||||
app = self._bal_window.bal_plugin.CALENDAR_APP.default
|
app = self._bal_window.bal_plugin.CALENDAR_APP.default
|
||||||
else:
|
else:
|
||||||
@@ -157,6 +147,13 @@ class BalCalendarButton(QToolButton):
|
|||||||
|
|
||||||
|
|
||||||
class BalCalendar:
|
class BalCalendar:
|
||||||
|
@staticmethod
|
||||||
|
def write_temp_ics(content):
|
||||||
|
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
|
||||||
|
with os.fdopen(fd, "wb") as f:
|
||||||
|
f.write(content.encode("utf-8"))
|
||||||
|
return path
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def open_with_default_app(calendar_app, path):
|
def open_with_default_app(calendar_app, path):
|
||||||
_logger.debug("opening calendar app")
|
_logger.debug("opening calendar app")
|
||||||
@@ -185,3 +182,52 @@ class BalCalendar:
|
|||||||
if os.path.isdir(desktop):
|
if os.path.isdir(desktop):
|
||||||
return desktop
|
return desktop
|
||||||
return home
|
return home
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_time(time):
|
||||||
|
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
#return time.astimezone(timezone.utc).strftime("%Y%m%d")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ical_escape(text: str) -> str:
|
||||||
|
# escape per RFC5545: backslash, ; , newlines
|
||||||
|
text = text.encode("utf-8")
|
||||||
|
text = (
|
||||||
|
text.replace(b"\\", b"\\\\")
|
||||||
|
.replace(b";", b"\\;")
|
||||||
|
.replace(b",", b"\\,")
|
||||||
|
)
|
||||||
|
out =""
|
||||||
|
temp=text.split(b"\r\n")
|
||||||
|
for s in temp:
|
||||||
|
encoded= s
|
||||||
|
cut =0
|
||||||
|
while len(encoded) >75:
|
||||||
|
cut+=5
|
||||||
|
encoded=f"{s[:len(s)-cut]}"
|
||||||
|
if encoded[-1]==b"\\" and encoded[-2]!=b"\\\\":
|
||||||
|
cut += 1
|
||||||
|
encoded=f"{s[:len(s)-cut]}"
|
||||||
|
encoded=f"{encoded}...\r\n".encode("utf-8")
|
||||||
|
if cut>0:
|
||||||
|
out+=str(f"{s[:len(s)-cut].decode()}...\r\n")
|
||||||
|
else:
|
||||||
|
out+=str(f"{s.decode()}\r\n")
|
||||||
|
|
||||||
|
return out[:-2]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def fold_ical_line(line: str, limit: int = 75) -> str:
|
||||||
|
# ritorna linee separate da CRLF e folding con spazio iniziale sulle righe successive
|
||||||
|
encoded = line.encode("utf-8")
|
||||||
|
parts = []
|
||||||
|
while len(encoded) > limit:
|
||||||
|
# taglia senza spezzare byte UTF-8
|
||||||
|
cut = limit
|
||||||
|
while (encoded[cut] & 0xC0) == 0x80: # byte di continuazione UTF-8
|
||||||
|
cut -= 1
|
||||||
|
parts.append(encoded[:cut].decode("utf-8"))
|
||||||
|
encoded = encoded[cut:]
|
||||||
|
parts.append(encoded.decode("utf-8"))
|
||||||
|
return "\r\n ".join(parts)
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ hosts a few GUI helpers that do not deserve a module of their own:
|
|||||||
* :func:`add_widget` - add a labelled widget (plus optional help) to a grid.
|
* :func:`add_widget` - add a labelled widget (plus optional help) to a grid.
|
||||||
* :func:`log_error` - format an exception traceback for a dialog.
|
* :func:`log_error` - format an exception traceback for a dialog.
|
||||||
* :func:`export_meta_gui` - export plugin metadata to a JSON file.
|
* :func:`export_meta_gui` - export plugin metadata to a JSON file.
|
||||||
(:class:`CheckAliveError` now lives in ``bal.core.checkalive``.)
|
* :class:`CheckAliveError`- raised when the "check alive" date is in the past.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
import enum
|
import enum
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -26,143 +27,62 @@ from decimal import Decimal
|
|||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import Any, Callable, Mapping, Optional, Union
|
from typing import Any, Callable, Mapping, Optional, Union
|
||||||
|
|
||||||
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
|
from electrum.bitcoin import (NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX,
|
||||||
from electrum.gui.common_qt.util import draw_qr
|
NLOCKTIME_MIN)
|
||||||
from electrum.gui.qt.amountedit import BTCAmountEdit
|
from electrum.gui.qt.amountedit import BTCAmountEdit
|
||||||
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
|
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
|
||||||
from electrum.gui.qt.my_treeview import MyTreeView
|
from electrum.gui.qt.my_treeview import MyTreeView
|
||||||
from electrum.gui.qt.password_dialog import PasswordDialog
|
from electrum.gui.qt.password_dialog import PasswordDialog
|
||||||
from electrum.gui.qt.transaction_dialog import TxDialog
|
from electrum.gui.qt.transaction_dialog import TxDialog
|
||||||
from electrum.gui.qt.util import (
|
from electrum.gui.qt.util import (Buttons, CancelButton, ColorScheme,
|
||||||
Buttons,
|
EnterButton, HelpButton, MessageBoxMixin,
|
||||||
CancelButton,
|
OkButton, TaskThread, WindowModalDialog,
|
||||||
ColorScheme,
|
char_width_in_lineedit, getSaveFileName,
|
||||||
EnterButton,
|
import_meta_gui, read_QIcon_from_bytes,
|
||||||
HelpButton,
|
read_QPixmap_from_bytes, webopen)
|
||||||
MessageBoxMixin,
|
|
||||||
OkButton,
|
|
||||||
TaskThread,
|
|
||||||
WaitingDialog,
|
|
||||||
WindowModalDialog,
|
|
||||||
char_width_in_lineedit,
|
|
||||||
getOpenFileName,
|
|
||||||
getSaveFileName,
|
|
||||||
import_meta_gui,
|
|
||||||
read_QIcon_from_bytes,
|
|
||||||
read_QPixmap_from_bytes,
|
|
||||||
webopen,
|
|
||||||
)
|
|
||||||
from electrum.i18n import _
|
from electrum.i18n import _
|
||||||
from electrum.logging import get_logger
|
from electrum.logging import get_logger
|
||||||
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError
|
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError
|
||||||
from electrum.payment_identifier import PaymentIdentifier
|
from electrum.payment_identifier import PaymentIdentifier
|
||||||
from electrum.plugin import hook
|
from electrum.plugin import hook
|
||||||
from electrum.transaction import SerializationError, Transaction, tx_from_any
|
from electrum.transaction import SerializationError, Transaction, tx_from_any
|
||||||
from electrum.util import (
|
from electrum.util import (DECIMAL_POINT, FileExportFailed, UserCancelled,
|
||||||
DECIMAL_POINT,
|
decimal_point_to_base_unit_name, read_json_file,
|
||||||
FileExportFailed,
|
write_json_file)
|
||||||
FileImportFailed,
|
from PyQt6.QtCore import (QDateTime, QModelIndex, QPersistentModelIndex, QSize,
|
||||||
UserCancelled,
|
Qt, QTimer, pyqtSignal)
|
||||||
decimal_point_to_base_unit_name,
|
from PyQt6.QtGui import (QColor, QPainter, QPalette, QStandardItem,
|
||||||
read_json_file,
|
QStandardItemModel)
|
||||||
write_json_file,
|
from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox,
|
||||||
)
|
QComboBox, QDateTimeEdit, QGridLayout, QHBoxLayout,
|
||||||
from PyQt6.QtCore import (
|
QInputDialog, QLabel, QLineEdit, QTextEdit, QMenu,
|
||||||
QDateTime,
|
QMenuBar, QPushButton, QScrollArea, QSizePolicy,
|
||||||
QModelIndex,
|
QSpinBox, QStackedWidget, QStyle, QStyleOptionFrame,
|
||||||
QPersistentModelIndex,
|
QVBoxLayout, QWidget, QDialog)
|
||||||
QSize,
|
|
||||||
Qt,
|
|
||||||
QTimer,
|
|
||||||
pyqtSignal,
|
|
||||||
)
|
|
||||||
from PyQt6.QtGui import QColor, QPainter, QPalette, QStandardItem, QStandardItemModel
|
|
||||||
from PyQt6.QtWidgets import (
|
|
||||||
QAbstractItemView,
|
|
||||||
QAbstractSpinBox,
|
|
||||||
QApplication,
|
|
||||||
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.util import Util, copy_structure
|
from ...core.heirs import (HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT,
|
||||||
from ...core.will import (
|
HeirAmountIsDustException, Heirs)
|
||||||
AmountException,
|
from ...core.util import Util
|
||||||
HeirChangeException,
|
from ...core.will import (AmountException, HeirChangeException,
|
||||||
HeirNotFoundException,
|
HeirNotFoundException, NoHeirsException,
|
||||||
NoHeirsException,
|
NotCompleteWillException, NoWillExecutorNotPresent,
|
||||||
NotCompleteWillException,
|
TxFeesChangedException, Will,
|
||||||
NoWillExecutorNotPresent,
|
WillexecutorChangeException, WillExecutorNotPresent,
|
||||||
TxFeesChangedException,
|
WillExpiredException, WillItem, WillPostponedException)
|
||||||
Will,
|
from ...core.willexecutors import Willexecutors
|
||||||
WillexecutorChangeException,
|
from ...core.willexecutors import is_onion_url, is_tor_active # noqa: F401
|
||||||
WillExecutorNotPresent,
|
|
||||||
WillExpiredException,
|
|
||||||
WillItem,
|
|
||||||
WillPostponedException,
|
|
||||||
)
|
|
||||||
from ...core.willexecutors import ( # noqa: F401
|
|
||||||
Willexecutors,
|
|
||||||
is_onion_url,
|
|
||||||
is_tor_active,
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Presentation helpers ---
|
# --- Presentation helpers ---
|
||||||
from .theme import (
|
from .theme import server_status_text, server_status_tooltip, status_color
|
||||||
server_status_text,
|
from .window_utils import (bring_to_front, show_modal, show_on_top,
|
||||||
server_status_tooltip,
|
stop_thread, top_level_of)
|
||||||
signature_suffix,
|
|
||||||
status_color,
|
|
||||||
)
|
|
||||||
from .window_utils import (
|
|
||||||
bring_to_front,
|
|
||||||
show_modal,
|
|
||||||
show_on_top,
|
|
||||||
stop_thread,
|
|
||||||
top_level_of,
|
|
||||||
)
|
|
||||||
|
|
||||||
_logger = get_logger(__name__)
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal naming)
|
class shown_cv:
|
||||||
_type = bool
|
_type = bool
|
||||||
|
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
@@ -185,6 +105,18 @@ def add_widget(grid, label, widget, row, help_):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class CheckAliveError(Exception):
|
||||||
|
def __init__(self, timestamp_to_check):
|
||||||
|
self.timestamp_to_check = timestamp_to_check
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "Check alive expired please update it: {}".format(
|
||||||
|
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def log_error(exec_info, window=None):
|
def log_error(exec_info, window=None):
|
||||||
"""Log an error and optionally show it.
|
"""Log an error and optionally show it.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,111 +14,12 @@ construction) for all business actions, so the heavy logic stays in ``window``
|
|||||||
and ``dialogs``.
|
and ``dialogs``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from .common import *
|
||||||
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
from PyQt6.QtWidgets import QLineEdit as _QLineEdit
|
from .widgets import BalCheckBox, PercAmountEdit, WillSettingsWidget
|
||||||
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
|
from PyQt6.QtWidgets import QMessageBox
|
||||||
|
from PyQt6.QtWidgets import QStyledItemDelegate, QLineEdit as _QLineEdit
|
||||||
from .common import (
|
|
||||||
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,
|
|
||||||
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,
|
|
||||||
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):
|
||||||
@@ -183,7 +84,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
self.bal_window.new_heir_dialog(edit_key)
|
self.bal_window.new_heir_dialog(edit_key)
|
||||||
|
|
||||||
def on_edited(self, idx, edit_key, *, text):
|
def on_edited(self, idx, edit_key, *, text):
|
||||||
prior_name = self.bal_window.heirs.get(edit_key)
|
original = prior_name = self.bal_window.heirs.get(edit_key)
|
||||||
if not prior_name:
|
if not prior_name:
|
||||||
return
|
return
|
||||||
col = idx.column()
|
col = idx.column()
|
||||||
@@ -204,7 +105,12 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
try:
|
try:
|
||||||
self.bal_window.set_heir(prior_name)
|
self.bal_window.set_heir(prior_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.update()
|
pass
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -251,17 +157,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
heir = self.bal_window.heirs[key]
|
heir = self.bal_window.heirs[key]
|
||||||
labels = [""] * len(self.Columns)
|
labels = [""] * len(self.Columns)
|
||||||
labels[self.Columns.NAME] = key
|
labels[self.Columns.NAME] = key
|
||||||
if is_op_return_address(heir[0]):
|
labels[self.Columns.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
|
||||||
)
|
)
|
||||||
@@ -288,7 +184,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
set_current = QPersistentModelIndex(idx)
|
set_current = QPersistentModelIndex(idx)
|
||||||
try:
|
try:
|
||||||
self.will_settings_widget.on_locktime_change()
|
self.will_settings_widget.on_locktime_change()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
pass
|
||||||
self.set_current_idx(set_current)
|
self.set_current_idx(set_current)
|
||||||
# FIXME refresh loses sort order; so set "default" here:
|
# FIXME refresh loses sort order; so set "default" here:
|
||||||
@@ -308,15 +204,15 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
menu.addAction(_("Import"), self.bal_window.import_heirs)
|
menu.addAction(_("Import"), self.bal_window.import_heirs)
|
||||||
menu.addAction(_("Export"), lambda: self.bal_window.export_heirs())
|
menu.addAction(_("Export"), lambda: self.bal_window.export_heirs())
|
||||||
|
|
||||||
new_heir_button = QPushButton(_("New Heir"))
|
newHeirButton = QPushButton(_("New Heir"))
|
||||||
new_heir_button.clicked.connect(self.bal_window.new_heir_dialog)
|
newHeirButton.clicked.connect(self.bal_window.new_heir_dialog)
|
||||||
|
|
||||||
widget = QWidget(self)
|
widget = QWidget(self)
|
||||||
layout = QHBoxLayout(widget)
|
layout = QHBoxLayout(widget)
|
||||||
self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
|
self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
|
||||||
|
|
||||||
layout.addWidget(self.will_settings_widget)
|
layout.addWidget(self.will_settings_widget)
|
||||||
layout.addWidget(new_heir_button)
|
layout.addWidget(newHeirButton)
|
||||||
|
|
||||||
toolbar.insertWidget(2, widget)
|
toolbar.insertWidget(2, widget)
|
||||||
|
|
||||||
@@ -375,7 +271,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
self.setModel(QStandardItemModel(self))
|
self.setModel(QStandardItemModel(self))
|
||||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||||
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
|
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.setSortingEnabled(True)
|
self.setSortingEnabled(True)
|
||||||
@@ -401,64 +297,36 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
selected_keys.append(sel_key)
|
selected_keys.append(sel_key)
|
||||||
if selected_keys and idx.isValid():
|
if selected_keys and idx.isValid():
|
||||||
column_title = self.model().horizontalHeaderItem(column).text()
|
column_title = self.model().horizontalHeaderItem(column).text()
|
||||||
|
# column_data = "\n".join(
|
||||||
|
# self.model().itemFromIndex(s_idx).text()
|
||||||
|
# for s_idx in self.selected_in_column(column)
|
||||||
|
# )
|
||||||
|
|
||||||
menu.addAction(
|
menu.addAction(
|
||||||
_("details").format(column_title),
|
_("details").format(column_title),
|
||||||
lambda: self.show_transaction(selected_keys),
|
lambda: self.show_transaction(selected_keys),
|
||||||
).setEnabled(len(selected_keys) == 1)
|
).setEnabled(len(selected_keys) < 2)
|
||||||
menu.addAction(
|
|
||||||
_("sign").format(column_title),
|
|
||||||
lambda: self.sign_transactions(selected_keys),
|
|
||||||
).setEnabled(
|
|
||||||
any(_can_sign(self.will.get(k)) for k in selected_keys)
|
|
||||||
)
|
|
||||||
menu.addAction(
|
|
||||||
_("broadcast").format(column_title),
|
|
||||||
lambda: self.broadcast_transactions(selected_keys),
|
|
||||||
).setEnabled(
|
|
||||||
any(_can_broadcast(self.will.get(k)) for k in selected_keys)
|
|
||||||
)
|
|
||||||
menu.addAction(
|
menu.addAction(
|
||||||
_("check ").format(column_title),
|
_("check ").format(column_title),
|
||||||
lambda: self.check_transactions(selected_keys),
|
lambda: self.check_transactions(selected_keys),
|
||||||
)
|
)
|
||||||
|
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
|
||||||
menu.addSeparator()
|
try:
|
||||||
menu.addAction(
|
self.importaction = self.menu.addAction(
|
||||||
_("copy id").format(column_title),
|
_("Import"), self.import_will
|
||||||
lambda: self.copy_txids(selected_keys),
|
)
|
||||||
)
|
except Exception:
|
||||||
menu.addAction(
|
pass
|
||||||
_("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):
|
||||||
keys = [k for k in selected_keys if self.is_deletable(k)]
|
for key in selected_keys:
|
||||||
for key in keys:
|
|
||||||
del self.will[key]
|
del self.will[key]
|
||||||
try:
|
try:
|
||||||
del self.bal_window.willitems[key]
|
del self.bal_window.willitems[key]
|
||||||
@@ -470,75 +338,6 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
pass
|
pass
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def sign_transactions(self, selected_keys):
|
|
||||||
"""Sign all selected transactions that are not fully signed yet."""
|
|
||||||
keys = [
|
|
||||||
k for k in selected_keys
|
|
||||||
if _can_sign(self.will.get(k))
|
|
||||||
]
|
|
||||||
if keys:
|
|
||||||
self.bal_window.ask_password_and_sign_transactions(
|
|
||||||
callback=self.update, txids=keys
|
|
||||||
)
|
|
||||||
|
|
||||||
def broadcast_transactions(self, selected_keys):
|
|
||||||
"""Force-broadcast all selected fully-signed transactions.
|
|
||||||
|
|
||||||
``force=True`` makes the will-executors re-accept transactions that
|
|
||||||
were already pushed before.
|
|
||||||
"""
|
|
||||||
keys = [
|
|
||||||
k for k in selected_keys
|
|
||||||
if _can_broadcast(self.will.get(k))
|
|
||||||
]
|
|
||||||
if keys:
|
|
||||||
self.bal_window.broadcast_transactions(force=True, txids=keys)
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
def copy_txids(self, selected_keys):
|
|
||||||
"""Copy the selected transaction IDs to the clipboard (one per line)."""
|
|
||||||
self.place_text_on_clipboard(
|
|
||||||
"\n".join(selected_keys), title=_("Transaction IDs")
|
|
||||||
)
|
|
||||||
|
|
||||||
def copy_tx_hexes(self, selected_keys):
|
|
||||||
"""Copy the selected transactions (serialised hex) to the clipboard."""
|
|
||||||
hexes = "\n".join(str(self.will[k].tx) for k in selected_keys)
|
|
||||||
self.place_text_on_clipboard(hexes, title=_("Transactions"))
|
|
||||||
|
|
||||||
def merge_from_txn(self):
|
|
||||||
"""Merge a transaction read from the clipboard, or from a file if the
|
|
||||||
clipboard does not contain a valid transaction.
|
|
||||||
"""
|
|
||||||
tx = None
|
|
||||||
try:
|
|
||||||
tx = tx_from_any(QApplication.clipboard().text())
|
|
||||||
except Exception:
|
|
||||||
tx = None
|
|
||||||
if tx is None:
|
|
||||||
filename = getOpenFileName(
|
|
||||||
parent=self.bal_window.window,
|
|
||||||
title=_("Open transaction file"),
|
|
||||||
filter="All files (*)",
|
|
||||||
config=self.bal_window.window.config,
|
|
||||||
)
|
|
||||||
if not filename:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
with open(filename, "r") as f:
|
|
||||||
data = f.read()
|
|
||||||
tx = tx_from_any(data)
|
|
||||||
except Exception as e:
|
|
||||||
self.bal_window.show_error(
|
|
||||||
_("Invalid transaction file: {}").format(e)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self.bal_window.merge_single_transaction(tx)
|
|
||||||
except Exception as e:
|
|
||||||
self.bal_window.show_error(str(e))
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
def check_transactions(self, selected_keys):
|
def check_transactions(self, selected_keys):
|
||||||
wout = {}
|
wout = {}
|
||||||
for k in selected_keys:
|
for k in selected_keys:
|
||||||
@@ -586,8 +385,8 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
if bal_tx.we:
|
if bal_tx.we:
|
||||||
we = bal_tx.we["url"]
|
we = bal_tx.we["url"]
|
||||||
labels[self.Columns.WILLEXECUTOR] = we
|
labels[self.Columns.WILLEXECUTOR] = we
|
||||||
status = bal_tx.status + signature_suffix(bal_tx)
|
status = bal_tx.status
|
||||||
if len(status) > 53:
|
if len(bal_tx.status) > 53:
|
||||||
status = "...{}".format(status[-50:])
|
status = "...{}".format(status[-50:])
|
||||||
labels[self.Columns.STATUS] = status
|
labels[self.Columns.STATUS] = status
|
||||||
# Dedicated, always-readable label describing whether the inheritance
|
# Dedicated, always-readable label describing whether the inheritance
|
||||||
@@ -663,12 +462,9 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
menu.addAction(_("Prepare"), self.build_transactions)
|
menu.addAction(_("Prepare"), self.build_transactions)
|
||||||
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
|
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
|
||||||
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
|
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
|
||||||
# Export/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)
|
||||||
menu.addAction(_("Import"), self.import_will)
|
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
|
||||||
menu.addAction(_("Merge"), self.merge_will)
|
self.importaction = menu.addAction(_("Import"), self.import_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)
|
||||||
@@ -733,17 +529,15 @@ 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_dialog()
|
self.bal_window.export_will()
|
||||||
|
self.update()
|
||||||
|
|
||||||
def import_will(self):
|
def import_will(self):
|
||||||
self.bal_window.import_will_dialog()
|
self.bal_window.import_will()
|
||||||
|
|
||||||
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)
|
||||||
@@ -962,7 +756,6 @@ class WillExecutorListWidget(MyTreeView):
|
|||||||
idx = self.indexAt(position)
|
idx = self.indexAt(position)
|
||||||
column = idx.column() or self.Columns.URL
|
column = idx.column() or self.Columns.URL
|
||||||
selected_keys = []
|
selected_keys = []
|
||||||
sel_key = None
|
|
||||||
for s_idx in self.selected_in_column(self.Columns.URL):
|
for s_idx in self.selected_in_column(self.Columns.URL):
|
||||||
item = self.model().itemFromIndex(s_idx)
|
item = self.model().itemFromIndex(s_idx)
|
||||||
# Use the FULL url stored in the key role, NOT item.data(0): the
|
# Use the FULL url stored in the key role, NOT item.data(0): the
|
||||||
@@ -978,15 +771,6 @@ class WillExecutorListWidget(MyTreeView):
|
|||||||
# self.model().itemFromIndex(s_idx).text()
|
# self.model().itemFromIndex(s_idx).text()
|
||||||
# for s_idx in self.selected_in_column(column)
|
# for s_idx in self.selected_in_column(column)
|
||||||
# )
|
# )
|
||||||
# When exactly ONE cell is selected, offer "Copy" to copy the value
|
|
||||||
# of that cell to the clipboard (e.g. a single url / address / fee).
|
|
||||||
# This list has no ``main_window``, so use QApplication directly.
|
|
||||||
if len(self.selectionModel().selectedIndexes()) == 1:
|
|
||||||
cell_value = self.model().itemFromIndex(idx).text()
|
|
||||||
menu.addAction(
|
|
||||||
_("Copy"),
|
|
||||||
lambda: QApplication.clipboard().setText(cell_value),
|
|
||||||
)
|
|
||||||
if Willexecutors.is_selected(self._bal_parent.willexecutors_list[sel_key]):
|
if Willexecutors.is_selected(self._bal_parent.willexecutors_list[sel_key]):
|
||||||
menu.addAction(
|
menu.addAction(
|
||||||
_("deselect").format(column_title),
|
_("deselect").format(column_title),
|
||||||
@@ -1145,17 +929,6 @@ class WillExecutorListWidget(MyTreeView):
|
|||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
items.append(QStandardItem(e))
|
items.append(QStandardItem(e))
|
||||||
|
|
||||||
max_fee = self._bal_parent.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
|
|
||||||
dust = self._bal_parent.bal_window.window.wallet.dust_threshold()
|
|
||||||
if not Willexecutors.is_valid(value, max_fee=max_fee, dust=dust):
|
|
||||||
grey = QColor("#808080")
|
|
||||||
for item in items:
|
|
||||||
font = item.font()
|
|
||||||
font.setItalic(True)
|
|
||||||
item.setFont(font)
|
|
||||||
item.setForeground(grey)
|
|
||||||
|
|
||||||
items[self.Columns.SELECTED].setEditable(False)
|
items[self.Columns.SELECTED].setEditable(False)
|
||||||
items[self.Columns.URL].setEditable(True)
|
items[self.Columns.URL].setEditable(True)
|
||||||
items[self.Columns.ADDRESS].setEditable(True)
|
items[self.Columns.ADDRESS].setEditable(True)
|
||||||
@@ -1235,44 +1008,13 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
b.clicked.connect(self.import_file)
|
b.clicked.connect(self.import_file)
|
||||||
buttonbox.addWidget(b)
|
buttonbox.addWidget(b)
|
||||||
|
|
||||||
def _menu_button(label):
|
b = QPushButton(_("Export"))
|
||||||
btn = QToolButton()
|
b.clicked.connect(self.export_file)
|
||||||
btn.setText(_(label))
|
buttonbox.addWidget(b)
|
||||||
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
|
||||||
buttonbox.addWidget(btn)
|
|
||||||
return btn
|
|
||||||
|
|
||||||
export_btn = _menu_button("Export")
|
b = QPushButton(_("Ping All"))
|
||||||
export_menu = QMenu(export_btn)
|
b.clicked.connect(self.update_willexecutors)
|
||||||
export_menu.addAction(_("Export all"), lambda: self.export_file())
|
buttonbox.addWidget(b)
|
||||||
export_menu.addAction(
|
|
||||||
_("Export selected"), lambda: self.export_file(subset="selected")
|
|
||||||
)
|
|
||||||
export_menu.addAction(
|
|
||||||
_("Export only valid"), lambda: self.export_file(subset="valid")
|
|
||||||
)
|
|
||||||
export_btn.setMenu(export_menu)
|
|
||||||
|
|
||||||
ping_btn = _menu_button("Ping All")
|
|
||||||
ping_menu = QMenu(ping_btn)
|
|
||||||
ping_menu.addAction(_("Ping all"), lambda: self.update_willexecutors())
|
|
||||||
ping_menu.addAction(
|
|
||||||
_("Ping selected"), lambda: self.ping_selected_willexecutors()
|
|
||||||
)
|
|
||||||
ping_btn.setMenu(ping_menu)
|
|
||||||
|
|
||||||
select_btn = _menu_button("Select All")
|
|
||||||
select_menu = QMenu(select_btn)
|
|
||||||
select_menu.addAction(_("Select all"), lambda: self.set_select_all(True))
|
|
||||||
select_menu.addAction(
|
|
||||||
_("Select only valid"), lambda: self.set_select_all(True, only_valid=True)
|
|
||||||
)
|
|
||||||
select_menu.addAction(_("Deselect all"), lambda: self.set_select_all(False))
|
|
||||||
select_menu.addAction(
|
|
||||||
_("Deselect only invalid"),
|
|
||||||
lambda: self.set_select_all(False, only_valid=True),
|
|
||||||
)
|
|
||||||
select_btn.setMenu(select_menu)
|
|
||||||
|
|
||||||
vbox.addLayout(buttonbox)
|
vbox.addLayout(buttonbox)
|
||||||
# self.will_executor_list_widget.update()
|
# self.will_executor_list_widget.update()
|
||||||
@@ -1393,7 +1135,6 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
add_another_btn.clicked.connect(add_another)
|
add_another_btn.clicked.connect(add_another)
|
||||||
else:
|
else:
|
||||||
self._add_another = False
|
self._add_another = False
|
||||||
add_another_btn = None
|
|
||||||
|
|
||||||
row = 0
|
row = 0
|
||||||
grid.addWidget(QLabel(_("URL")), row, 0)
|
grid.addWidget(QLabel(_("URL")), row, 0)
|
||||||
@@ -1486,68 +1227,13 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
|
|
||||||
self.bal_window.download_list(self.bal_window.willexecutors, on_success)
|
self.bal_window.download_list(self.bal_window.willexecutors, on_success)
|
||||||
|
|
||||||
def export_file(self, subset=None):
|
def export_file(self, path):
|
||||||
data = self.export_data(subset)
|
|
||||||
if subset and not data:
|
|
||||||
self.show_message(_("No will-executor matches the selected filter"))
|
|
||||||
return
|
|
||||||
export_meta_gui(
|
export_meta_gui(
|
||||||
self.bal_window.window,
|
self.bal_window.window, "willexecutors.json", self.export_json_file
|
||||||
"willexecutors.json",
|
|
||||||
partial(self.export_json_file, subset=subset),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def export_data(self, subset=None):
|
def export_json_file(self, path):
|
||||||
data = self.willexecutors_list
|
write_json_file(path, self.willexecutors_list)
|
||||||
if subset == "selected":
|
|
||||||
data = {
|
|
||||||
url: we
|
|
||||||
for url, we in data.items()
|
|
||||||
if Willexecutors.is_selected(we)
|
|
||||||
}
|
|
||||||
elif subset == "valid":
|
|
||||||
valid = self._validity()
|
|
||||||
data = {
|
|
||||||
url: we for url, we in data.items() if valid.get(url, False)
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
|
|
||||||
def export_json_file(self, path, subset=None):
|
|
||||||
write_json_file(path, self.export_data(subset))
|
|
||||||
|
|
||||||
def _validity(self):
|
|
||||||
max_fee = self.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
|
|
||||||
dust = self.bal_window.window.wallet.dust_threshold()
|
|
||||||
return {
|
|
||||||
url: Willexecutors.is_valid(we, max_fee=max_fee, dust=dust)
|
|
||||||
for url, we in self.willexecutors_list.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
def _selected_willexecutors(self):
|
|
||||||
return {
|
|
||||||
url: we
|
|
||||||
for url, we in self.willexecutors_list.items()
|
|
||||||
if Willexecutors.is_selected(we)
|
|
||||||
}
|
|
||||||
|
|
||||||
def set_select_all(self, select, only_valid=False):
|
|
||||||
"""Apply a bulk selection across all will-executors.
|
|
||||||
|
|
||||||
``select=True`` selects all (or only the valid ones when
|
|
||||||
``only_valid=True``, deselecting the invalid ones); ``select=False``
|
|
||||||
deselects all (or only the invalid ones when ``only_valid=True``,
|
|
||||||
leaving the valid ones selected).
|
|
||||||
"""
|
|
||||||
valid = self._validity() if only_valid else None
|
|
||||||
_apply_select_all(self.willexecutors_list, select, valid)
|
|
||||||
self.save_willexecutors()
|
|
||||||
|
|
||||||
def ping_selected_willexecutors(self):
|
|
||||||
wes = self._selected_willexecutors()
|
|
||||||
if not wes:
|
|
||||||
self.show_message(_("No will-executor is selected"))
|
|
||||||
return
|
|
||||||
self.update_willexecutors(wes)
|
|
||||||
|
|
||||||
def import_file(self):
|
def import_file(self):
|
||||||
import_meta_gui(
|
import_meta_gui(
|
||||||
|
|||||||
@@ -15,40 +15,14 @@ and cached in ``self.bal_windows``.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from electrum.gui.qt.main_window import StatusBarButton
|
from electrum.gui.qt.main_window import StatusBarButton
|
||||||
from electrum.plugin import hook
|
|
||||||
from electrum.util import EventListener, event_listener
|
|
||||||
from PyQt6.QtWidgets import QLayout
|
|
||||||
|
|
||||||
from ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size
|
from .common import *
|
||||||
from .common import (
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
BalPlugin,
|
from .common import read_QIcon_from_bytes
|
||||||
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):
|
||||||
@@ -63,7 +37,7 @@ def _window_key(window):
|
|||||||
return id(window)
|
return id(window)
|
||||||
|
|
||||||
|
|
||||||
class Plugin(BalPlugin, EventListener):
|
class Plugin(BalPlugin):
|
||||||
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)
|
||||||
@@ -72,10 +46,6 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
# 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):
|
||||||
@@ -118,11 +88,9 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
from electrum.gui.qt.plugins_dialog import (
|
from electrum.gui.qt.plugins_dialog import PluginsDialog
|
||||||
PluginsDialog as plugins_dialog, # noqa: N813
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
plugins_dialog = None
|
PluginsDialog = None
|
||||||
app = QApplication.instance()
|
app = QApplication.instance()
|
||||||
if app is None:
|
if app is None:
|
||||||
return []
|
return []
|
||||||
@@ -138,7 +106,7 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
for w in app.topLevelWidgets():
|
for w in app.topLevelWidgets():
|
||||||
try:
|
try:
|
||||||
is_match = False
|
is_match = False
|
||||||
if plugins_dialog is not None and isinstance(w, plugins_dialog):
|
if PluginsDialog is not None and isinstance(w, PluginsDialog):
|
||||||
is_match = True
|
is_match = True
|
||||||
elif type(w).__name__ == "PluginsDialog":
|
elif type(w).__name__ == "PluginsDialog":
|
||||||
is_match = True
|
is_match = True
|
||||||
@@ -174,17 +142,17 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
each is guarded independently.
|
each is guarded independently.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from PyQt6.QtWidgets import QDialog as qdialog # noqa: N813
|
from PyQt6.QtWidgets import QDialog
|
||||||
except Exception:
|
except Exception:
|
||||||
qdialog = None
|
QDialog = None
|
||||||
# 1) reject() / done(): the reliable way to end an exec() modal loop.
|
# 1) reject() / done(): the reliable way to end an exec() modal loop.
|
||||||
if qdialog is not None and isinstance(d, qdialog):
|
if QDialog is not None and isinstance(d, QDialog):
|
||||||
try:
|
try:
|
||||||
d.reject()
|
d.reject()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.debug("reject() failed: {}".format(e))
|
_logger.debug("reject() failed: {}".format(e))
|
||||||
try:
|
try:
|
||||||
d.done(qdialog.DialogCode.Rejected)
|
d.done(QDialog.DialogCode.Rejected)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.debug("done() failed: {}".format(e))
|
_logger.debug("done() failed: {}".format(e))
|
||||||
# 2) close(): covers non-QDialog top-levels and is a harmless extra.
|
# 2) close(): covers non-QDialog top-levels and is a harmless extra.
|
||||||
@@ -204,9 +172,9 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
it and closes it themselves (it must not linger in the background).
|
it and closes it themselves (it must not linger in the background).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from PyQt6.QtCore import QTimer as qtimer # noqa: N813
|
from PyQt6.QtCore import QTimer
|
||||||
except Exception:
|
except Exception:
|
||||||
qtimer = None
|
QTimer = None
|
||||||
# Schedule of retry delays (ms) measured from each call.
|
# Schedule of retry delays (ms) measured from each call.
|
||||||
retry_delays = [400, 800, 1500]
|
retry_delays = [400, 800, 1500]
|
||||||
dialogs = Plugin._find_plugins_manager_dialogs()
|
dialogs = Plugin._find_plugins_manager_dialogs()
|
||||||
@@ -222,8 +190,8 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
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),
|
||||||
)
|
)
|
||||||
@@ -355,44 +323,6 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
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")
|
||||||
@@ -506,46 +436,13 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
# persisted NUM_REMINDERS config (default 3), with a range of 1..5.
|
# persisted NUM_REMINDERS config (default 3), with a range of 1..5.
|
||||||
heir_num_reminders = BalSpinBox(self.NUM_REMINDERS, minimum=1, maximum=5)
|
heir_num_reminders = BalSpinBox(self.NUM_REMINDERS, minimum=1, maximum=5)
|
||||||
|
|
||||||
# Max willexecutor fee spin box. Maximum fee (in satoshi) allowed for
|
# "No will-executor TX" checkbox. Bound to the persisted NO_WILLEXECUTOR
|
||||||
# a single will-executor. If a will-executor charges more, the will
|
# config (default ON, see plugin_base.py), the SAME config used by the
|
||||||
# will not be built. Default 500,000 satoshi (0.005 BTC).
|
# checkbox inside the "Build your will" wizard's will-executor download
|
||||||
heir_max_willexecutor_fee = BalSpinBox(
|
# window, so the two stay in sync automatically. When enabled the plugin
|
||||||
self.MAX_WILLEXECUTOR_FEE, minimum=0, maximum=10000000
|
# also builds a will that does not require a will-executor (e.g. it can
|
||||||
)
|
# 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)
|
|
||||||
|
|
||||||
# QR Code Size selector (will transfer via QR). A 4-standard-size combo
|
|
||||||
# bound to the QR_CHUNK_SIZE config (payload budget in bytes per frame).
|
|
||||||
# Ordered low -> high so the user picks the resolution matching their
|
|
||||||
# camera. Visible to all users (BASIC and ADVANCED).
|
|
||||||
qr_size_combo = QComboBox()
|
|
||||||
qr_size_combo.addItems([label for label, _budget in CHUNK_PRESETS])
|
|
||||||
qr_size_combo.setCurrentIndex(
|
|
||||||
preset_index_for_chunk_size(int(self.QR_CHUNK_SIZE.get()))
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_qr_size_change(index):
|
|
||||||
self.QR_CHUNK_SIZE.set(CHUNK_PRESETS[index][1])
|
|
||||||
|
|
||||||
qr_size_combo.currentIndexChanged.connect(on_qr_size_change)
|
|
||||||
|
|
||||||
# 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:
|
||||||
@@ -601,10 +498,8 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
lbl_event_description, edit_event_description, help_event_description,
|
lbl_event_description, edit_event_description, help_event_description,
|
||||||
lbl_calendar_app, edit_calendar_app, help_calendar_app,
|
lbl_calendar_app, edit_calendar_app, help_calendar_app,
|
||||||
lbl_auto_sign, heir_auto_sign, help_auto_sign,
|
lbl_auto_sign, heir_auto_sign, help_auto_sign,
|
||||||
lbl_save_history, heir_save_history, help_save_history,
|
|
||||||
lbl_history_label, edit_history_label, help_history_label,
|
|
||||||
reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10,
|
reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10,
|
||||||
reset_btn_11, reset_btn_12, reset_btn_auto_sign):
|
reset_btn_auto_sign):
|
||||||
w.setVisible(not basic)
|
w.setVisible(not basic)
|
||||||
# Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on
|
# Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on
|
||||||
# a real USER TYPE change (not inside update_all/CHECK), so pressing
|
# a real USER TYPE change (not inside update_all/CHECK), so pressing
|
||||||
@@ -630,20 +525,6 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
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")
|
||||||
@@ -663,10 +544,6 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
widget.setCurrentIndex(
|
widget.setCurrentIndex(
|
||||||
1 if str(cfg.default).lower() == "advanced" else 0
|
1 if str(cfg.default).lower() == "advanced" else 0
|
||||||
)
|
)
|
||||||
elif kind == "qr_size":
|
|
||||||
widget.setCurrentIndex(
|
|
||||||
preset_index_for_chunk_size(int(cfg.default))
|
|
||||||
)
|
|
||||||
btn.clicked.connect(reset)
|
btn.clicked.connect(reset)
|
||||||
return btn
|
return btn
|
||||||
|
|
||||||
@@ -737,21 +614,28 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
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)
|
||||||
# Max willexecutor fee: maximum fee (in satoshi) allowed for a single
|
# "Add transaction without will-executor" setting (formerly labelled
|
||||||
# will-executor. Visible to all users (BASIC and ADVANCED).
|
# "No will-executor TX"). When ON the plugin ALSO builds the backup
|
||||||
|
# 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,
|
||||||
"Max Will-Executor Fee (satoshi)",
|
"Add transaction without willexecutor",
|
||||||
heir_max_willexecutor_fee,
|
heir_no_willexecutor,
|
||||||
4,
|
4,
|
||||||
(
|
(
|
||||||
"Maximum fee (in satoshi) allowed to be paid to a single "
|
"Create a will that does not require a Will-executor; it can be "
|
||||||
"will-executor. If a will-executor charges more than this, "
|
"saved, for example, on a USB stick, and a copy can be given to "
|
||||||
"the will will not be built.\n"
|
"the heirs."
|
||||||
"Default: 500,000 satoshi (0.005 BTC)."
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
grid.addWidget(_make_reset_btn(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"), 4, 3)
|
grid.addWidget(_make_reset_btn(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"), 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(
|
||||||
@@ -836,37 +720,6 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
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
|
||||||
@@ -874,76 +727,15 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
# 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, 13, 0)
|
grid.addWidget(heir_repush, 11, 0)
|
||||||
grid.addWidget(
|
grid.addWidget(
|
||||||
HelpButton(
|
HelpButton(
|
||||||
"Broadcast all transactions to willexecutors including those already pushed"
|
"Broadcast all transactions to willexecutors including those already pushed"
|
||||||
),
|
),
|
||||||
13,
|
11,
|
||||||
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)
|
|
||||||
|
|
||||||
# "QR Code Size" row (always visible, BASIC + ADVANCED). Default QR
|
|
||||||
# size used when exporting a will via QR codes; changeable per export
|
|
||||||
# inside the export dialog itself.
|
|
||||||
lbl_qr_size = QLabel(_("QR Code Size"))
|
|
||||||
help_qr_size = HelpButton(
|
|
||||||
"Payload size of a single QR code when exporting a will via QR.\n\n"
|
|
||||||
"Larger QR codes hold more data (fewer shots) but are easier to "
|
|
||||||
"scan with a high-resolution camera; smaller QR codes scan fine "
|
|
||||||
"even with low-resolution cameras but require more shots.\n"
|
|
||||||
"The same selector is available inside the export dialog."
|
|
||||||
)
|
|
||||||
grid.addWidget(lbl_qr_size, 16, 0)
|
|
||||||
grid.addWidget(qr_size_combo, 16, 1)
|
|
||||||
grid.addWidget(help_qr_size, 16, 2)
|
|
||||||
reset_btn_qr_size = _make_reset_btn(
|
|
||||||
self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"
|
|
||||||
)
|
|
||||||
grid.addWidget(reset_btn_qr_size, 16, 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 #
|
||||||
@@ -968,16 +760,11 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
(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.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"),
|
(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"),
|
||||||
(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"),
|
|
||||||
(self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"),
|
|
||||||
]
|
]
|
||||||
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.
|
||||||
@@ -998,14 +785,6 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
widget.setCurrentIndex(
|
widget.setCurrentIndex(
|
||||||
1 if str(cfg.default).lower() == "advanced" else 0
|
1 if str(cfg.default).lower() == "advanced" else 0
|
||||||
)
|
)
|
||||||
elif kind == "qr_size":
|
|
||||||
widget.setCurrentIndex(
|
|
||||||
preset_index_for_chunk_size(int(cfg.default))
|
|
||||||
)
|
|
||||||
# Re-sync the history-label field's enabled state after a reset: the
|
|
||||||
# reset restores SAVE_HISTORY to its default, so the field must
|
|
||||||
# follow the (default) checkbox state again.
|
|
||||||
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
|
|
||||||
# Refresh the open BAL windows so any dependent view (e.g. the
|
# Refresh the open BAL windows so any dependent view (e.g. the
|
||||||
# editable-dates state is not in this list, but hide filters are)
|
# editable-dates state is not in this list, but hide filters are)
|
||||||
# reflects the reset values.
|
# reflects the reset values.
|
||||||
|
|||||||
@@ -54,31 +54,12 @@ def status_color(will_item) -> str:
|
|||||||
return "#e83845" # red - failed to push to will-executor
|
return "#e83845" # red - failed to push to will-executor
|
||||||
elif will_item.get_status("PUSHED"):
|
elif will_item.get_status("PUSHED"):
|
||||||
return "#73f3c8" # teal - pushed to will-executor
|
return "#73f3c8" # teal - pushed to will-executor
|
||||||
elif will_item.get_status("PARTIALLY_SIGNED"):
|
|
||||||
return "#ffb347" # amber - some signatures present, more needed
|
|
||||||
elif will_item.get_status("COMPLETE"):
|
elif will_item.get_status("COMPLETE"):
|
||||||
return "#2bc8ed" # blue - signed
|
return "#2bc8ed" # blue - signed
|
||||||
else:
|
else:
|
||||||
return _DEFAULT_COLOR
|
return _DEFAULT_COLOR
|
||||||
|
|
||||||
|
|
||||||
def signature_suffix(will_item) -> str:
|
|
||||||
"""Return the ``" (added/required)"`` suffix for a non-signed will item.
|
|
||||||
|
|
||||||
Used by the transaction list and the detail view to show how many of the
|
|
||||||
required signatures have already been added, e.g. ``"(1/2)"`` for a 2-of-3
|
|
||||||
transaction carrying one signature. Returns ``""`` for signed transactions
|
|
||||||
or when the required count is unknown (no descriptor available yet).
|
|
||||||
"""
|
|
||||||
if will_item.get_status("COMPLETE"):
|
|
||||||
return ""
|
|
||||||
required = int(getattr(will_item, "sigs_required", 0) or 0)
|
|
||||||
added = int(getattr(will_item, "sigs_have", 0) or 0)
|
|
||||||
if not required:
|
|
||||||
return ""
|
|
||||||
return " ({}/{})".format(added, required)
|
|
||||||
|
|
||||||
|
|
||||||
def server_status_text(will_item) -> str:
|
def server_status_text(will_item) -> str:
|
||||||
"""Return a short, human-readable label describing the state of a will
|
"""Return a short, human-readable label describing the state of a will
|
||||||
item on the will-executor servers (the online inheritance backup).
|
item on the will-executor servers (the online inheritance backup).
|
||||||
|
|||||||
@@ -18,67 +18,89 @@ Contents:
|
|||||||
* WillWidget - single will-tx box
|
* WillWidget - single will-tx box
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from .common import *
|
||||||
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
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,
|
|
||||||
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,
|
|
||||||
getSaveFileName,
|
|
||||||
log_error,
|
|
||||||
os,
|
|
||||||
partial,
|
|
||||||
pyqtSignal,
|
|
||||||
read_QIcon_from_bytes,
|
|
||||||
signature_suffix,
|
|
||||||
status_color,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .window import BalWindow
|
def compute_reminder_offsets(days, count):
|
||||||
|
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
|
||||||
|
|
||||||
|
Group D / D1. The reminders are spread uniformly across the check-alive
|
||||||
|
period and always fall *before* the delivery deadline, i.e. every returned
|
||||||
|
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
* ``count`` is the requested number of reminders (the settings dialog
|
||||||
|
caps it at 5, default 3).
|
||||||
|
* at most ONE reminder per available day: the effective number is
|
||||||
|
``min(count, days)``;
|
||||||
|
* with ``days`` available days, offsets are chosen as evenly spaced
|
||||||
|
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
|
||||||
|
= the first day of the period), de-duplicated and returned sorted
|
||||||
|
descending (earliest reminder first).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: number of whole days between check-alive and the deadline.
|
||||||
|
count: requested number of reminders.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
|
||||||
|
``days=30, count=3``. Empty if there is no room for any reminder.
|
||||||
|
"""
|
||||||
|
# No room for any reminder (deadline today or already passed).
|
||||||
|
if days < 1 or count < 1:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Never more reminders than available days (one per day at most).
|
||||||
|
effective = min(int(count), int(days))
|
||||||
|
|
||||||
|
# A single reminder: put it one day before the deadline.
|
||||||
|
if effective == 1:
|
||||||
|
return [1]
|
||||||
|
|
||||||
|
# Spread "effective" points evenly inside [1, days]. Using i/(effective-1)
|
||||||
|
# for i in 0..effective-1 gives fractions 0..1; map them onto [1, days].
|
||||||
|
# This places the first reminder at the start of the period (offset ~days)
|
||||||
|
# and the last one one day before the deadline (offset 1).
|
||||||
|
offsets = set()
|
||||||
|
for i in range(effective):
|
||||||
|
frac = i / (effective - 1) # 0.0 .. 1.0
|
||||||
|
# offset = days at frac 0 (start), 1 at frac 1 (just before deadline).
|
||||||
|
offset = round(days - frac * (days - 1))
|
||||||
|
offset = max(1, min(days, offset))
|
||||||
|
offsets.add(offset)
|
||||||
|
|
||||||
|
# Sorted descending: earliest reminder (largest offset) first.
|
||||||
|
return sorted(offsets, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Fixed reminder offsets (in days BEFORE the delivery date) used in BASIC mode.
|
||||||
|
# In BASIC the check-alive parameter is hidden/unmanaged, so reminders cannot be
|
||||||
|
# spread over it; instead the owner asked for three fixed reminders: 30, 10 and
|
||||||
|
# 1 day before the inheritance delivery date.
|
||||||
|
BASIC_REMINDER_OFFSETS = (30, 10, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def basic_reminder_offsets(days_to_deadline):
|
||||||
|
"""Return the BASIC-mode reminder offsets that still fall in the future.
|
||||||
|
|
||||||
|
BASIC mode uses the fixed offsets in ``BASIC_REMINDER_OFFSETS`` (30, 10 and
|
||||||
|
1 day before the delivery date). Any offset that would land in the past is
|
||||||
|
dropped, because a reminder before "today" is useless: if the delivery date
|
||||||
|
is only ``days_to_deadline`` days away, only the offsets that are ``<=
|
||||||
|
days_to_deadline`` are kept.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days_to_deadline: whole days from now until the delivery date.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of integer day-offsets (each ``>= 1``), sorted as in
|
||||||
|
``BASIC_REMINDER_OFFSETS`` (descending: earliest reminder first). Empty
|
||||||
|
when the delivery date is less than one day away.
|
||||||
|
"""
|
||||||
|
horizon = max(int(days_to_deadline), 0)
|
||||||
|
return [off for off in BASIC_REMINDER_OFFSETS if 1 <= off <= horizon]
|
||||||
|
|
||||||
|
|
||||||
class ClickableLabel(QLabel):
|
class ClickableLabel(QLabel):
|
||||||
@@ -192,12 +214,39 @@ class BalTxFeesWidget(QWidget):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
class _LockTimeEditor(LockTimeEditor):
|
class _LockTimeEditor:
|
||||||
"""Qt-side locktime editor base.
|
min_allowed_value = NLOCKTIME_MIN
|
||||||
|
max_allowed_value = NLOCKTIME_MAX
|
||||||
|
alarm = None
|
||||||
|
|
||||||
|
def get_value(self) -> Optional[int]:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def set_value(self, x: Any, force=True) -> None:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_acceptable_locktime(cls, x: Any) -> bool:
|
||||||
|
if not x: # e.g. empty string
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
x = int(x)
|
||||||
|
except Exception as _e:
|
||||||
|
return False
|
||||||
|
return cls.min_allowed_value <= x <= cls.max_allowed_value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_max_allowed_timestamp() -> int:
|
||||||
|
ts = NLOCKTIME_MAX
|
||||||
|
# Test if this value is within the valid timestamp limits (which is platform-dependent).
|
||||||
|
# see #6170
|
||||||
|
try:
|
||||||
|
datetime.fromtimestamp(ts)
|
||||||
|
except (OSError, OverflowError):
|
||||||
|
ts = 2**31 - 1 # INT32_MAX
|
||||||
|
datetime.fromtimestamp(ts) # test if raises
|
||||||
|
return ts
|
||||||
|
|
||||||
The pure acceptance bounds and helpers live in ``bal.core.input_rules``;
|
|
||||||
this is just the mixin that lets Qt widgets reuse them.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
||||||
@@ -518,16 +567,51 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
|
|||||||
self.isyears = False
|
self.isyears = False
|
||||||
self.time_edit = time_edit
|
self.time_edit = time_edit
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def replace_str(text):
|
||||||
|
"""Strip the relative-time suffixes (d/y) from the text.
|
||||||
|
|
||||||
|
Only days ("d") and years ("y") are supported. The block-height
|
||||||
|
suffix ("b") was removed (A1): locktimes are always timestamps now.
|
||||||
|
"""
|
||||||
|
return str(text).replace("d", "").replace("y", "")
|
||||||
|
|
||||||
|
def checkbdy(self, s, pos, appendix):
|
||||||
|
try:
|
||||||
|
charpos = pos - 1
|
||||||
|
charpos = max(0, charpos)
|
||||||
|
charpos = min(len(s) - 1, charpos)
|
||||||
|
if appendix == s[charpos]:
|
||||||
|
s = self.replace_str(s) + appendix
|
||||||
|
pos = charpos
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return pos, s
|
||||||
|
|
||||||
def numbify(self):
|
def numbify(self):
|
||||||
# Only digits plus the day ("d") and year ("y") suffixes are accepted.
|
# Only digits plus the day ("d") and year ("y") suffixes are accepted.
|
||||||
# The block-height suffix ("b") was removed (A1): locktimes are always
|
# The block-height suffix ("b") was removed (A1): locktimes are always
|
||||||
# UNIX timestamps now, so block-relative input is no longer allowed.
|
# UNIX timestamps now, so block-relative input is no longer allowed.
|
||||||
# The sanitisation itself lives in bal.core.input_rules.
|
|
||||||
text = self.text().strip()
|
text = self.text().strip()
|
||||||
|
chars = "0123456789dy"
|
||||||
pos = self.cursorPosition()
|
pos = self.cursorPosition()
|
||||||
s, isdays, isyears, pos = normalize_locktime_raw_text(text, pos)
|
pos = len("".join([i for i in text[:pos] if i in chars]))
|
||||||
self.isdays = isdays
|
s = "".join([i for i in text if i in chars])
|
||||||
self.isyears = isyears
|
self.isdays = False
|
||||||
|
self.isyears = False
|
||||||
|
|
||||||
|
pos, s = self.checkbdy(s, pos, "d")
|
||||||
|
pos, s = self.checkbdy(s, pos, "y")
|
||||||
|
|
||||||
|
if "d" in s:
|
||||||
|
self.isdays = True
|
||||||
|
if "y" in s:
|
||||||
|
self.isyears = True
|
||||||
|
|
||||||
|
if self.isdays:
|
||||||
|
s = self.replace_str(s) + "d"
|
||||||
|
if self.isyears:
|
||||||
|
s = self.replace_str(s) + "y"
|
||||||
self.blockSignals(True)
|
self.blockSignals(True)
|
||||||
self.setText(s)
|
self.setText(s)
|
||||||
self.blockSignals(False)
|
self.blockSignals(False)
|
||||||
@@ -606,20 +690,15 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
x = int(x)
|
x = int(x)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
x = QDateTime.currentDateTime().timestamp()
|
x = QDateTime.currentDateTime().timestamp()
|
||||||
finally:
|
finally:
|
||||||
# Use the overflow-safe converter: on Windows datetime.fromtimestamp
|
# Use the overflow-safe converter: on Windows datetime.fromtimestamp
|
||||||
# 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))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -989,12 +1068,11 @@ class WillSettingsWidget(QWidget):
|
|||||||
sees several distinct appointments in their calendar.
|
sees several distinct appointments in their calendar.
|
||||||
|
|
||||||
The number of events is read from the NUM_REMINDERS setting (default 3,
|
The number of events is read from the NUM_REMINDERS setting (default 3,
|
||||||
capped at 5 by the settings dialog). Their dates are computed by
|
capped at 5 by the settings dialog). Their dates are computed with
|
||||||
``bal.core.reminders.build_ics_reminders`` (offsets spread uniformly
|
``compute_reminder_offsets``: the offsets are spread uniformly across the
|
||||||
across the check-alive period; the LAST event always falls one day
|
check-alive period and the LAST event always falls one day before the
|
||||||
before the delivery deadline / locktime). If the period is shorter than
|
delivery deadline (locktime). If the period is shorter than the
|
||||||
the requested number of reminders, at most one event per day is
|
requested number of reminders, at most one event per day is produced.
|
||||||
produced.
|
|
||||||
|
|
||||||
Each event:
|
Each event:
|
||||||
* is placed on ``locktime - offset`` days (its own visible date);
|
* is placed on ``locktime - offset`` days (its own visible date);
|
||||||
@@ -1009,23 +1087,103 @@ class WillSettingsWidget(QWidget):
|
|||||||
path the user picks in the save dialog (default name "BAL_will_event.ics"
|
path the user picks in the save dialog (default name "BAL_will_event.ics"
|
||||||
on the Desktop).
|
on the Desktop).
|
||||||
"""
|
"""
|
||||||
# The .ics content (offsets, escaping, folding, VEVENT layout) is built
|
now = BalCalendar.format_time(datetime.now())
|
||||||
# by the pure ``build_ics_reminders`` in bal.core.reminders. When no
|
|
||||||
# reminder falls in the future it returns None (ToDo #2) and the user is
|
|
||||||
# warned instead of getting an empty-looking file.
|
|
||||||
ics_content = self._ics_provider()
|
|
||||||
if not ics_content:
|
|
||||||
self.bal_window.show_warning(
|
|
||||||
_(
|
|
||||||
"No reminders were saved: the delivery date is too "
|
|
||||||
"close (or already passed)"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
|
# locktime = delivery deadline. It is exposed by the date widget as
|
||||||
|
# ``.alarm`` and already reflects the (possibly auto-anticipated) minimum
|
||||||
|
# transaction locktime, so the calendar uses the correct delivery date.
|
||||||
|
locktime = self.widgets["locktime"].alarm
|
||||||
|
|
||||||
|
# BASIC vs ADVANCED reminder strategy.
|
||||||
|
#
|
||||||
|
# In ADVANCED mode the reminders are spread uniformly across the
|
||||||
|
# check-alive (threshold) period, ending one day before the deadline.
|
||||||
|
#
|
||||||
|
# In BASIC mode the check-alive parameter is NOT shown nor managed by the
|
||||||
|
# user (it stays at an arbitrary default), so spreading reminders over it
|
||||||
|
# is meaningless. The owner asked that, in BASIC, the calendar simply
|
||||||
|
# saves the inheritance delivery date with three fixed reminders: 30 days
|
||||||
|
# before, 10 days before and 1 day before. We also drop any fixed offset
|
||||||
|
# that would fall in the past (a reminder before "today" is useless), so
|
||||||
|
# a short-dated will still gets the reminders that are still in the
|
||||||
|
# future.
|
||||||
|
if self.bal_window.bal_plugin.is_basic_mode():
|
||||||
|
# Whole days from now until the delivery date. Fixed offsets (30, 10,
|
||||||
|
# 1 day before) are applied by basic_reminder_offsets, which also
|
||||||
|
# drops any offset that would fall in the past.
|
||||||
|
days_to_deadline = (locktime - datetime.now()).days
|
||||||
|
offsets = basic_reminder_offsets(days_to_deadline)
|
||||||
|
else:
|
||||||
|
# ADVANCED: spread reminders over the check-alive period as before.
|
||||||
|
threshold = self.widgets["threshold"].alarm
|
||||||
|
# Whole days available between check-alive and the deadline.
|
||||||
|
days = (locktime - threshold).days
|
||||||
|
# How many reminders the user asked for (default 3 if unreadable).
|
||||||
|
try:
|
||||||
|
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
|
||||||
|
except Exception:
|
||||||
|
count = 3
|
||||||
|
# Day-offsets BEFORE the deadline, e.g. [30, 16, 1]. The list always
|
||||||
|
# ends with 1 (one day before the locktime) when >= 2 reminders fit.
|
||||||
|
offsets = compute_reminder_offsets(days, count)
|
||||||
|
|
||||||
|
# Per-event heir details and the shared description/summary templates.
|
||||||
|
heirs_details = "\r\n".join(
|
||||||
|
f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}"
|
||||||
|
for heir in self.bal_window.heirs
|
||||||
|
)
|
||||||
|
# BASIC mode: use factory defaults (the hidden settings are ignored).
|
||||||
|
# ADVANCED mode: use the user-configured values.
|
||||||
|
if self.bal_window.bal_plugin.is_basic_mode():
|
||||||
|
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
||||||
|
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
||||||
|
else:
|
||||||
|
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
|
||||||
|
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
|
||||||
|
event_description = BalCalendar.ical_escape(
|
||||||
|
f"{raw_description}"
|
||||||
|
.replace("$wallet_name", str(self.bal_window.wallet))
|
||||||
|
.replace("$heirs_complete", heirs_details)
|
||||||
|
)
|
||||||
|
summary_base = (
|
||||||
|
f"{raw_summary}"
|
||||||
|
.replace("$wallet_name", str(self.bal_window.wallet))
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{BalPlugin.__version__}",
|
||||||
|
]
|
||||||
|
|
||||||
|
# One separate VEVENT per reminder offset (its own date in the calendar).
|
||||||
|
total = len(offsets)
|
||||||
|
for idx, offset in enumerate(offsets, start=1):
|
||||||
|
# The visible date of this event: "offset" days before the deadline.
|
||||||
|
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
|
||||||
|
# Suffix the summary so the N events are easy to tell apart.
|
||||||
|
summary = BalCalendar.ical_escape(
|
||||||
|
f"{summary_base} (reminder {idx}/{total})"
|
||||||
|
)
|
||||||
|
lines.extend([
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
# Offset in the UID keeps each event unique (no merging).
|
||||||
|
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
|
||||||
|
f"DTSTAMP:{now}",
|
||||||
|
f"DTSTART:{event_dt}",
|
||||||
|
f"DTEND:{event_dt}",
|
||||||
|
f"SUMMARY:{summary}",
|
||||||
|
f"DESCRIPTION:{event_description}",
|
||||||
|
"END:VEVENT",
|
||||||
|
])
|
||||||
|
|
||||||
|
lines.append("END:VCALENDAR")
|
||||||
|
|
||||||
|
lines = [s.rstrip("\r\n") for s in lines]
|
||||||
|
ics_content = "\r\n".join(lines) + "\r\n"
|
||||||
# Keep the generated .ics in a temp file; it is copied to the path the
|
# Keep the generated .ics in a temp file; it is copied to the path the
|
||||||
# user picks below.
|
# user picks below.
|
||||||
self.temp_path = write_temp_ics(ics_content)
|
self.temp_path = BalCalendar.write_temp_ics(ics_content)
|
||||||
|
|
||||||
# Group D / D1b: always ask the user WHERE to save the .ics file (the
|
# Group D / D1b: always ask the user WHERE to save the .ics file (the
|
||||||
# plugin no longer tries to open it with a calendar app). The save
|
# plugin no longer tries to open it with a calendar app). The save
|
||||||
@@ -1078,31 +1236,26 @@ class WillSettingsWidget(QWidget):
|
|||||||
def _ics_provider(self):
|
def _ics_provider(self):
|
||||||
"""Return the .ics content for the current locktime/threshold values.
|
"""Return the .ics content for the current locktime/threshold values.
|
||||||
|
|
||||||
Used by :class:`BalCalendarButton` as its content provider. The whole
|
Used by :class:`BalCalendarButton` as its content provider.
|
||||||
document (reminder offsets, escaping, folding, VEVENT layout) is built
|
|
||||||
by the pure :func:`bal.core.reminders.build_ics_reminders`.
|
|
||||||
"""
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
try:
|
try:
|
||||||
locktime = self.widgets["locktime"].alarm
|
locktime = self.widgets["locktime"].alarm
|
||||||
|
|
||||||
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
|
if self.bal_window.bal_plugin.is_basic_mode():
|
||||||
if basic_mode:
|
days_to_deadline = (locktime - datetime.now()).days
|
||||||
# BASIC mode: use factory defaults (the hidden settings are
|
offsets = basic_reminder_offsets(days_to_deadline)
|
||||||
# ignored) and the fixed 30/10/1 offsets.
|
|
||||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
|
||||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
|
||||||
threshold = None
|
|
||||||
num_reminders = 3
|
|
||||||
else:
|
else:
|
||||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
|
|
||||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
|
|
||||||
threshold = self.widgets["threshold"].alarm
|
threshold = self.widgets["threshold"].alarm
|
||||||
|
days = (locktime - threshold).days
|
||||||
try:
|
try:
|
||||||
num_reminders = int(
|
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
|
||||||
self.bal_window.bal_plugin.NUM_REMINDERS.get()
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
num_reminders = 3
|
count = 3
|
||||||
|
offsets = compute_reminder_offsets(days, count)
|
||||||
|
|
||||||
|
now = BalCalendar.format_time(datetime.now())
|
||||||
|
|
||||||
heirs_details = "\r\n".join(
|
heirs_details = "\r\n".join(
|
||||||
f" {heir} - {self.bal_window.heirs[heir][0]}, "
|
f" {heir} - {self.bal_window.heirs[heir][0]}, "
|
||||||
@@ -1110,17 +1263,50 @@ class WillSettingsWidget(QWidget):
|
|||||||
for heir in self.bal_window.heirs
|
for heir in self.bal_window.heirs
|
||||||
)
|
)
|
||||||
|
|
||||||
return build_ics_reminders(
|
if self.bal_window.bal_plugin.is_basic_mode():
|
||||||
locktime=locktime,
|
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
||||||
basic_mode=basic_mode,
|
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
||||||
description=raw_description,
|
else:
|
||||||
summary=raw_summary,
|
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
|
||||||
wallet_name=str(self.bal_window.wallet),
|
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
|
||||||
heirs_details=heirs_details,
|
|
||||||
version=self.bal_window.bal_plugin.version,
|
event_description = BalCalendar.ical_escape(
|
||||||
num_reminders=num_reminders,
|
f"{raw_description}"
|
||||||
threshold=threshold,
|
.replace("$wallet_name", str(self.bal_window.wallet))
|
||||||
|
.replace("$heirs_complete", heirs_details)
|
||||||
)
|
)
|
||||||
|
summary_base = (
|
||||||
|
f"{raw_summary}"
|
||||||
|
.replace("$wallet_name", str(self.bal_window.wallet))
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//Bitcoin After Life//Electrum Plugin/"
|
||||||
|
f"{BalPlugin.__version__}",
|
||||||
|
]
|
||||||
|
|
||||||
|
total = len(offsets)
|
||||||
|
for idx, offset in enumerate(offsets, start=1):
|
||||||
|
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
|
||||||
|
summary = BalCalendar.ical_escape(
|
||||||
|
f"{summary_base} (reminder {idx}/{total})"
|
||||||
|
)
|
||||||
|
lines.extend([
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
|
||||||
|
f"DTSTAMP:{now}",
|
||||||
|
f"DTSTART:{event_dt}",
|
||||||
|
f"DTEND:{event_dt}",
|
||||||
|
f"SUMMARY:{summary}",
|
||||||
|
f"DESCRIPTION:{event_description}",
|
||||||
|
"END:VEVENT",
|
||||||
|
])
|
||||||
|
|
||||||
|
lines.append("END:VCALENDAR")
|
||||||
|
lines = [s.rstrip("\r\n") for s in lines]
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error(f"failed to generate .ics: {e}")
|
_logger.error(f"failed to generate .ics: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -1163,20 +1349,40 @@ class PercAmountEdit(BTCAmountEdit):
|
|||||||
super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
|
super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
|
||||||
|
|
||||||
def numbify(self):
|
def numbify(self):
|
||||||
# The text sanitisation lives in bal.core.input_rules.
|
|
||||||
text = self.text().strip()
|
text = self.text().strip()
|
||||||
if text == "!":
|
if text == "!":
|
||||||
self.shortcut.emit()
|
self.shortcut.emit()
|
||||||
return
|
return
|
||||||
pos = self.cursorPosition()
|
pos = self.cursorPosition()
|
||||||
s, self.is_perc = normalize_perc_amount_text(text, DECIMAL_POINT)
|
chars = "0123456789%"
|
||||||
|
chars += DECIMAL_POINT
|
||||||
|
|
||||||
|
s = "".join([i for i in text if i in chars])
|
||||||
|
|
||||||
|
if "%" in s:
|
||||||
|
self.is_perc = True
|
||||||
|
s = s.replace("%", "")
|
||||||
|
else:
|
||||||
|
self.is_perc = False
|
||||||
|
|
||||||
|
if DECIMAL_POINT in s:
|
||||||
|
p = s.find(DECIMAL_POINT)
|
||||||
|
s = s.replace(DECIMAL_POINT, "")
|
||||||
|
s = s[:p] + DECIMAL_POINT + s[p : p + 8]
|
||||||
|
if self.is_perc:
|
||||||
|
s += "%"
|
||||||
|
|
||||||
self.setText(s)
|
self.setText(s)
|
||||||
self.setModified(self.hasFocus())
|
self.setModified(self.hasFocus())
|
||||||
self.setCursorPosition(pos)
|
self.setCursorPosition(pos)
|
||||||
|
|
||||||
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
|
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
|
||||||
return parse_perc_amount(text, DECIMAL_POINT)
|
try:
|
||||||
|
text = text.replace(DECIMAL_POINT, ".")
|
||||||
|
text = text.replace("%", "")
|
||||||
|
return (Decimal)(text)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def _get_text_from_amount(self, amount):
|
def _get_text_from_amount(self, amount):
|
||||||
out = super()._get_text_from_amount(amount)
|
out = super()._get_text_from_amount(amount)
|
||||||
@@ -1189,15 +1395,15 @@ class PercAmountEdit(BTCAmountEdit):
|
|||||||
if self.base_unit:
|
if self.base_unit:
|
||||||
panel = QStyleOptionFrame()
|
panel = QStyleOptionFrame()
|
||||||
self.initStyleOption(panel)
|
self.initStyleOption(panel)
|
||||||
text_rect = self.style().subElementRect(
|
textRect = self.style().subElementRect(
|
||||||
QStyle.SubElement.SE_LineEditContents, panel, self
|
QStyle.SubElement.SE_LineEditContents, panel, self
|
||||||
)
|
)
|
||||||
text_rect.adjust(2, 0, -10, 0)
|
textRect.adjust(2, 0, -10, 0)
|
||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.setPen(ColorScheme.GRAY.as_color())
|
painter.setPen(ColorScheme.GRAY.as_color())
|
||||||
if len(self.text()) == 0:
|
if len(self.text()) == 0:
|
||||||
painter.drawText(
|
painter.drawText(
|
||||||
text_rect,
|
textRect,
|
||||||
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
|
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
|
||||||
self.base_unit() + " or perc value",
|
self.base_unit() + " or perc value",
|
||||||
)
|
)
|
||||||
@@ -1276,11 +1482,11 @@ class BalSpinBox(QSpinBox):
|
|||||||
|
|
||||||
|
|
||||||
class WillWidget(QWidget):
|
class WillWidget(QWidget):
|
||||||
def __init__(self, father=None, parent=None, will=None):
|
def __init__(self, father=None, parent=None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
vlayout = QVBoxLayout()
|
vlayout = QVBoxLayout()
|
||||||
self.setLayout(vlayout)
|
self.setLayout(vlayout)
|
||||||
self.will = will if will is not None else parent.bal_window.willitems
|
self.will = parent.bal_window.willitems
|
||||||
self._bal_parent = parent
|
self._bal_parent = parent
|
||||||
for w in self.will:
|
for w in self.will:
|
||||||
if (
|
if (
|
||||||
@@ -1307,10 +1513,7 @@ class WillWidget(QWidget):
|
|||||||
willpushbutton = QPushButton(w)
|
willpushbutton = QPushButton(w)
|
||||||
|
|
||||||
willpushbutton.clicked.connect(
|
willpushbutton.clicked.connect(
|
||||||
partial(
|
partial(self._bal_parent.bal_window.show_transaction, txid=w)
|
||||||
self._bal_parent.bal_window.show_transaction,
|
|
||||||
tx=self.will[w].tx,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
detaillayout.addWidget(willpushbutton)
|
detaillayout.addWidget(willpushbutton)
|
||||||
locktime = str(BalTimestamp(self.will[w].tx.locktime))
|
locktime = str(BalTimestamp(self.will[w].tx.locktime))
|
||||||
@@ -1332,33 +1535,17 @@ class WillWidget(QWidget):
|
|||||||
fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3)
|
fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3)
|
||||||
fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)"
|
fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)"
|
||||||
detaillayout.addWidget(qlabel("Transaction fees:", fees_str))
|
detaillayout.addWidget(qlabel("Transaction fees:", fees_str))
|
||||||
detaillayout.addWidget(
|
detaillayout.addWidget(qlabel("Status:", self.will[w].status))
|
||||||
qlabel("Status:", self.will[w].status + signature_suffix(self.will[w]))
|
|
||||||
)
|
|
||||||
detaillayout.addWidget(QLabel(""))
|
detaillayout.addWidget(QLabel(""))
|
||||||
detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
|
detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
|
||||||
for heir_name in self.will[w].heirs:
|
for heir in self.will[w].heirs:
|
||||||
if 'w!ll3x3c"' in heir_name:
|
if 'w!ll3x3c"' not in heir:
|
||||||
continue
|
decoded_amount = Util.decode_amount(
|
||||||
h = self.will[w].heirs[heir_name]
|
self.will[w].heirs[heir][3], self._bal_parent.decimal_point
|
||||||
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_name,
|
heir, f"{decoded_amount} {self._bal_parent.base_unit_name}"
|
||||||
f"{decoded_amount} {self._bal_parent.base_unit_name} "
|
|
||||||
f"[{h[0]}]",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if self.will[w].we:
|
if self.will[w].we:
|
||||||
@@ -1374,10 +1561,6 @@ 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(
|
||||||
@@ -1387,6 +1570,6 @@ class WillWidget(QWidget):
|
|||||||
detailw.setPalette(pal)
|
detailw.setPalette(pal)
|
||||||
|
|
||||||
hlayout.addWidget(detailw)
|
hlayout.addWidget(detailw)
|
||||||
hlayout.addWidget(WillWidget(w, parent=parent, will=self.will))
|
hlayout.addWidget(WillWidget(w, parent=parent))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1199
bal/gui/qt/window.py
1199
bal/gui/qt/window.py
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "bal",
|
"name": "bal",
|
||||||
"fullname": "Bitcoin After Life",
|
"fullname": "Bitcoin After Life",
|
||||||
"version": "0.7.0",
|
"version": "0.5.18",
|
||||||
"description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.",
|
"description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.",
|
||||||
"author": "Svatantrya",
|
"author": "Svatantrya",
|
||||||
"licence": "MIT",
|
"licence": "MIT",
|
||||||
"available_for": [
|
"available_for": ["qt"],
|
||||||
"qt",
|
|
||||||
"cmdline"
|
|
||||||
],
|
|
||||||
"icon": "icons/bal32x32.png"
|
"icon": "icons/bal32x32.png"
|
||||||
}
|
}
|
||||||
@@ -6,12 +6,11 @@
|
|||||||
|
|
||||||
Documentation for the **BAL** open‑source Electrum plugin for Bitcoin digital
|
Documentation for the **BAL** open‑source Electrum plugin for Bitcoin digital
|
||||||
inheritance. Everything here is plain Markdown + images (and optional styled
|
inheritance. Everything here is plain Markdown + images (and optional styled
|
||||||
HTML), so it renders directly on any forge (Gitea/GitHub) and in any browser —
|
HTML), so it renders directly on GitHub and via GitHub Pages — **no PDF needed**.
|
||||||
**no PDF needed**.
|
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
| Document | Markdown | Styled HTML |
|
| Document | Markdown (GitHub) | Styled HTML |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **User Manual (revB)** — full plugin manual with screenshots | [`manual/README.md`](./manual/README.md) | [`manual/manual.html`](./manual/manual.html) |
|
| **User Manual (revB)** — full plugin manual with screenshots | [`manual/README.md`](./manual/README.md) | [`manual/manual.html`](./manual/manual.html) |
|
||||||
| **Inheritance Options Guide** — every change (date earlier/later, add/remove heir, change %, fees, executors) + decision flow chart + transaction states & server effects | [`inheritance-options.md`](./inheritance-options.md) | [`inheritance-options.html`](./inheritance-options.html) |
|
| **Inheritance Options Guide** — every change (date earlier/later, add/remove heir, change %, fees, executors) + decision flow chart + transaction states & server effects | [`inheritance-options.md`](./inheritance-options.md) | [`inheritance-options.html`](./inheritance-options.html) |
|
||||||
@@ -24,8 +23,9 @@ HTML), so it renders directly on any forge (Gitea/GitHub) and in any browser —
|
|||||||
|
|
||||||
## Viewing the HTML versions
|
## Viewing the HTML versions
|
||||||
|
|
||||||
- Online: serve the `docs/` folder as static files (e.g. Pages on Gitea or
|
- On GitHub Pages: enable Pages for this repository (Settings → Pages → deploy
|
||||||
GitHub) and open `manual/manual.html`.
|
from branch, folder `/docs`), then open
|
||||||
|
`https://<owner>.github.io/<repo>/manual/manual.html`.
|
||||||
- Offline: download the `docs/` folder and open the `.html` files in any browser
|
- Offline: download the `docs/` folder and open the `.html` files in any browser
|
||||||
(the styled manual works fully offline; the inheritance‑options page loads
|
(the styled manual works fully offline; the inheritance‑options page loads
|
||||||
Mermaid from a CDN for the live diagram, and also ships a static SVG fallback).
|
Mermaid from a CDN for the live diagram, and also ships a static SVG fallback).
|
||||||
|
|||||||
@@ -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.7.0. Behaviour is derived directly from
|
<footer>This document reflects BAL plugin v0.4.7. 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>
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ important ones:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `VALID` | The item is the current, usable plan | default `True`; cleared by INVALIDATED/REPLACED/CONFIRMED/MEMPOOL |
|
| `VALID` | The item is the current, usable plan | default `True`; cleared by INVALIDATED/REPLACED/CONFIRMED/MEMPOOL |
|
||||||
| `COMPLETE` (*Signed*) | The transaction has been **signed** | after you press **Sign** |
|
| `COMPLETE` (*Signed*) | The transaction has been **signed** | after you press **Sign** |
|
||||||
| `PARTIALLY_SIGNED` | Only **some** of the required signatures are present | a multisig will after a partial sign (cleared by `COMPLETE`) |
|
|
||||||
| `PUSHED` | The signed tx was **sent to the will‑executor(s)** | after **Broadcast** to executors |
|
| `PUSHED` | The signed tx was **sent to the will‑executor(s)** | after **Broadcast** to executors |
|
||||||
| `CHECKED` | The will‑executor **confirmed** it holds the tx | after a successful server **Check** (implies `PUSHED`) |
|
| `CHECKED` | The will‑executor **confirmed** it holds the tx | after a successful server **Check** (implies `PUSHED`) |
|
||||||
| `CHECK_FAIL` | The server **check failed** | a queried executor did not return the tx |
|
| `CHECK_FAIL` | The server **check failed** | a queried executor did not return the tx |
|
||||||
@@ -66,7 +65,6 @@ Flag transitions enforced by `set_status` (the safety rules baked in the code):
|
|||||||
- Setting `CONFIRMED` / `MEMPOOL` → clears `INVALIDATED`.
|
- Setting `CONFIRMED` / `MEMPOOL` → clears `INVALIDATED`.
|
||||||
- Setting `PUSHED` → clears `PUSH_FAIL` **and** `CHECK_FAIL`.
|
- Setting `PUSHED` → clears `PUSH_FAIL` **and** `CHECK_FAIL`.
|
||||||
- Setting `CHECKED` → implies `PUSHED` (and clears `PUSH_FAIL`).
|
- Setting `CHECKED` → implies `PUSHED` (and clears `PUSH_FAIL`).
|
||||||
- Setting `COMPLETE` → clears `PARTIALLY_SIGNED`.
|
|
||||||
|
|
||||||
### How states map to row colour in the list
|
### How states map to row colour in the list
|
||||||
|
|
||||||
@@ -85,8 +83,7 @@ wins:
|
|||||||
| 7 | `CHECKED` | green | `#8afa6c` |
|
| 7 | `CHECKED` | green | `#8afa6c` |
|
||||||
| 8 | `PUSH_FAIL` | red | `#e83845` |
|
| 8 | `PUSH_FAIL` | red | `#e83845` |
|
||||||
| 9 | `PUSHED` | teal | `#73f3c8` |
|
| 9 | `PUSHED` | teal | `#73f3c8` |
|
||||||
| 10 | `PARTIALLY_SIGNED` | amber | `#ffb347` |
|
| 10 | `COMPLETE` (signed, **not** yet pushed) | blue | `#2bc8ed` |
|
||||||
| 11 | `COMPLETE` (signed, **not** yet pushed) | blue | `#2bc8ed` |
|
|
||||||
| — | none of the above (e.g. plain `VALID`, prepared) | default white | `#ffffff` |
|
| — | none of the above (e.g. plain `VALID`, prepared) | default white | `#ffffff` |
|
||||||
|
|
||||||
> **Note (v0.3.3 fix):** a will that is *signed but not yet broadcast*
|
> **Note (v0.3.3 fix):** a will that is *signed but not yet broadcast*
|
||||||
@@ -357,5 +354,5 @@ that limit.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*This document reflects the current BAL plugin (v0.7.0). Behaviour is derived
|
*This document reflects BAL plugin v0.4.7. Behaviour is derived directly from
|
||||||
directly from `core/will.py`, `core/heirs.py` and `gui/qt/window.py`.*
|
`core/will.py`, `core/heirs.py` and `gui/qt/window.py`.*
|
||||||
|
|||||||
@@ -76,22 +76,6 @@ inheritance cases.
|
|||||||
*Figure 2 — the parameters on the HEIRS tab: (1) Delivery Time, (2) Check Alive,
|
*Figure 2 — the parameters on the HEIRS tab: (1) Delivery Time, (2) Check Alive,
|
||||||
(3) Fees.*
|
(3) Fees.*
|
||||||
|
|
||||||
### User type: BASIC / ADVANCED
|
|
||||||
|
|
||||||
The plugin has two usage modes, chosen from the plugin settings
|
|
||||||
(**Tools → Plugins → BAL**, *User Type* selector):
|
|
||||||
|
|
||||||
- **BASIC** (default) — hides the advanced controls: the **Delivery Time** is
|
|
||||||
entered only as a precise **Date** (the relative **RAW** durations and the
|
|
||||||
Raw/Date selector are hidden), the **Check Alive** field is hidden, and the
|
|
||||||
postpone-on-open behaviour is disabled.
|
|
||||||
- **ADVANCED** — reveals the **Raw/Date selector** (relative durations such as
|
|
||||||
`1y` or `30d`) and the **Check Alive** field, and enables the postpone
|
|
||||||
behaviour described below. Switching to ADVANCED requires typing the
|
|
||||||
confirmation phrase **"at My Risk"**.
|
|
||||||
|
|
||||||
The rest of this section describes the full (ADVANCED) parameter set.
|
|
||||||
|
|
||||||
### 1 — Delivery Time (Locktime)
|
### 1 — Delivery Time (Locktime)
|
||||||
|
|
||||||
Indicates the date on which the inheritance of your wallet on the blockchain
|
Indicates the date on which the inheritance of your wallet on the blockchain
|
||||||
@@ -110,11 +94,6 @@ If you choose **Raw**, you can insert various options based on a suffix:
|
|||||||
|
|
||||||
*(i.e. check whether you are still alive, and then postpone the inheritance.)*
|
*(i.e. check whether you are still alive, and then postpone the inheritance.)*
|
||||||
|
|
||||||
> **NB:** the **Check Alive** parameter is available only in **ADVANCED** mode.
|
|
||||||
> In **BASIC** (default) it is hidden and the plugin re-evaluates the will
|
|
||||||
> against "now" every time you open Electrum, so the postpone behaviour
|
|
||||||
> described here does not apply.
|
|
||||||
|
|
||||||
This parameter — settable as relative (`RAW`) or absolute (`DATE`) — indicates
|
This parameter — settable as relative (`RAW`) or absolute (`DATE`) — indicates
|
||||||
the time by which the inheritance will **not** be changed by postponing it.
|
the time by which the inheritance will **not** be changed by postponing it.
|
||||||
|
|
||||||
@@ -244,9 +223,6 @@ plugin will notify you that you need to update the inheritance.
|
|||||||
|
|
||||||
## RAW settings
|
## RAW settings
|
||||||
|
|
||||||
> **NB:** relative (**RAW**) durations are available only in **ADVANCED** mode;
|
|
||||||
> in **BASIC** the Delivery Time is entered only as a precise date.
|
|
||||||
|
|
||||||
If you set, for example, `RAW‑1d` and it is, say, 5 p.m., the plugin will not
|
If you set, for example, `RAW‑1d` and it is, say, 5 p.m., the plugin will not
|
||||||
execute the inheritance precisely 24 hours later (5 p.m. the next day) but will
|
execute the inheritance precisely 24 hours later (5 p.m. the next day) but will
|
||||||
roughly estimate the blockchain block number corresponding to that time — so
|
roughly estimate the blockchain block number corresponding to that time — so
|
||||||
@@ -263,8 +239,7 @@ with a tolerance of a few hours.
|
|||||||
If you want a quick test run, enter an upcoming legacy date/time (e.g. 18 hours
|
If you want a quick test run, enter an upcoming legacy date/time (e.g. 18 hours
|
||||||
later). For such short intervals the **Check Alive** could create problems, so
|
later). For such short intervals the **Check Alive** could create problems, so
|
||||||
set the Check Alive parameter **in the past** (a date before today) — e.g. a
|
set the Check Alive parameter **in the past** (a date before today) — e.g. a
|
||||||
previous month. *(The Check Alive only exists in **ADVANCED** mode; in
|
previous month.
|
||||||
**BASIC** this is not needed.)*
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -360,7 +335,7 @@ inheritance:
|
|||||||
|
|
||||||
## Will‑Executor service list
|
## Will‑Executor service list
|
||||||
|
|
||||||
This window opens from the Electrum menu, **Tools → Will‑Executors**, and shows
|
This window opens from the Electrum menu, **Tools → Will‑executor**, and shows
|
||||||
the official list of will‑executor servers.
|
the official list of will‑executor servers.
|
||||||
|
|
||||||
If you want to make changes — such as adding an additional will‑executor server —
|
If you want to make changes — such as adding an additional will‑executor server —
|
||||||
@@ -434,16 +409,14 @@ transactions can have in the WILL tab, on each will‑executor that is online.
|
|||||||
| # | Status | Meaning | Colour | HEX |
|
| # | Status | Meaning | Colour | HEX |
|
||||||
|---|--------|---------|--------|-----|
|
|---|--------|---------|--------|-----|
|
||||||
| 1 | **New** | TX new inheritance | White (transparent) | `#FFFFFF` |
|
| 1 | **New** | TX new inheritance | White (transparent) | `#FFFFFF` |
|
||||||
| 2 | **Partially signed** | TX has some, but not all, of the required signatures | Amber | `#FFB347` |
|
| 2 | **Signed** | TX inheritance signed into the wallet | Azure | `#2BC8ED` |
|
||||||
| 3 | **Signed** | TX inheritance signed into the wallet | Azure | `#2BC8ED` |
|
| 3 | **Pushed** | TX sent to will‑executor | Azure‑green | `#73F3C8` |
|
||||||
| 4 | **Pushed** | TX sent to will‑executor | Azure‑green | `#73F3C8` |
|
| 4 | **Checked** | TX actually present in the will‑executor | Bright green | `#8AFA6C` |
|
||||||
| 5 | **Checked** | TX actually present in the will‑executor | Bright green | `#8AFA6C` |
|
| 5 | **Confirmed** | TX confirmed in the blockchain | Gray | `#BFBFBF` |
|
||||||
| 6 | **Confirmed** | TX confirmed in the blockchain | Gray | `#BFBFBF` |
|
| 6 | **Pending** | TX awaiting confirmation on blockchain | Yellow | `#FFCE30` |
|
||||||
| 7 | **Pending** | TX awaiting confirmation on blockchain | Yellow | `#FFCE30` |
|
| 7 | **Failed** | Communication failure with will‑executor | Red | `#E83845` |
|
||||||
| 8 | **Failed** | Communication failure with will‑executor | Red | `#E83845` |
|
| 8 | **Invalidated** | UTXO input is no longer available | Orange | `#F87838` |
|
||||||
| 9 | **Invalidated** | UTXO input is no longer available | Orange | `#F87838` |
|
| 9 | **Replaced** | A backdated‑locktime transaction spends the same input | Violet | `#FF97E9` |
|
||||||
| 10 | **Replaced** | A backdated‑locktime transaction spends the same input | Violet | `#FF97E9` |
|
|
||||||
| 11 | **Updated** | TX re‑issued keeping the same locktime and heirs | Light violet | `#B266B2` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -475,27 +448,6 @@ 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -548,32 +500,6 @@ 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
285
make-release.sh
@@ -1,285 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# make-release.sh — Create a Gitea release for bal-electrum-plugin
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./make-release.sh # read version from bal/manifest.json
|
|
||||||
# ./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
|
|
||||||
#
|
|
||||||
# Requires: git, gpg, curl, python3, sha256sum
|
|
||||||
# Optional: ruff (lint skipped if not installed)
|
|
||||||
# Credentials: ~/.git-credentials or GITEA_USER / GITEA_TOKEN env vars
|
|
||||||
|
|
||||||
set -eo pipefail
|
|
||||||
|
|
||||||
# ── helpers ──────────────────────────────────────────────────────────
|
|
||||||
die() { echo "Error: $*" >&2; exit 1; }
|
|
||||||
info() { echo ""; echo "── $* ──"; }
|
|
||||||
|
|
||||||
# ── 0. Resolve version ──────────────────────────────────────────────
|
|
||||||
MANIFEST="bal/manifest.json"
|
|
||||||
[ -f "$MANIFEST" ] || die "manifest not found: $MANIFEST"
|
|
||||||
|
|
||||||
# read current version from manifest
|
|
||||||
CURRENT_VER=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['version'])")
|
|
||||||
[ -n "$CURRENT_VER" ] || die "cannot read version from $MANIFEST"
|
|
||||||
|
|
||||||
ARG="${1:-}"
|
|
||||||
if [ -n "$ARG" ]; then
|
|
||||||
# normalise: accept "v0.6.2" or "0.6.2"
|
|
||||||
NEW_VER="${ARG#v}"
|
|
||||||
TAG="v${NEW_VER}"
|
|
||||||
else
|
|
||||||
TAG="v${CURRENT_VER}"
|
|
||||||
NEW_VER=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== Release ${TAG} ==="
|
|
||||||
echo "Current manifest version: ${CURRENT_VER}"
|
|
||||||
[ -n "$NEW_VER" ] && echo "New version (will bump): ${NEW_VER}"
|
|
||||||
|
|
||||||
# ── 1. Bump version in manifest (if arg provided) ──────────────────
|
|
||||||
if [ -n "$NEW_VER" ] && [ "$NEW_VER" != "$CURRENT_VER" ]; then
|
|
||||||
info "[1/10] Bumping version to ${NEW_VER} in ${MANIFEST}"
|
|
||||||
python3 -c "
|
|
||||||
import json
|
|
||||||
f = open('${MANIFEST}')
|
|
||||||
d = json.load(f); f.close()
|
|
||||||
d['version'] = '${NEW_VER}'
|
|
||||||
json.dump(d, open('${MANIFEST}', 'w'), indent=4, ensure_ascii=False)
|
|
||||||
print(json.dumps(d, indent=4, ensure_ascii=False))
|
|
||||||
"
|
|
||||||
git add "$MANIFEST"
|
|
||||||
else
|
|
||||||
info "[1/10] Version already ${CURRENT_VER}, no bump needed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 2. Clean caches ─────────────────────────────────────────────────
|
|
||||||
info "[2/10] Cleaning __pycache__ and .pyc"
|
|
||||||
find bal -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
|
|
||||||
find bal -name "*.pyc" -delete 2>/dev/null || true
|
|
||||||
find bal -name "*.pyo" -delete 2>/dev/null || true
|
|
||||||
|
|
||||||
# ── 3. Run tests ────────────────────────────────────────────────────
|
|
||||||
info "[3/10] Running test suite"
|
|
||||||
if QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
|
|
||||||
tests/test_core_*.py \
|
|
||||||
tests/test_anticipate_past_locktime.py tests/test_anticipate_manual_locktime.py \
|
|
||||||
tests/test_group_b_auto_sign.py tests/test_group_c_settings.py \
|
|
||||||
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
|
|
||||||
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
|
|
||||||
tests/test_group_h_v048.py \
|
|
||||||
-q 2>&1; then
|
|
||||||
echo "All tests passed."
|
|
||||||
else
|
|
||||||
die "Tests failed — aborting release."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 4. Lint (optional) ─────────────────────────────────────────────
|
|
||||||
info "[4/10] Lint with ruff"
|
|
||||||
if command -v ruff &>/dev/null; then
|
|
||||||
RUFF_ERRORS=$(ruff check bal/ 2>&1 \
|
|
||||||
| grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" \
|
|
||||||
| grep -vE "F401|F403|F405|F841" || true)
|
|
||||||
if [ -n "$RUFF_ERRORS" ]; then
|
|
||||||
echo "New ruff errors:"
|
|
||||||
echo "$RUFF_ERRORS"
|
|
||||||
die "Lint errors found — fix before releasing."
|
|
||||||
else
|
|
||||||
echo "Lint clean (ignoring known pre-existing warnings)."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "ruff not installed — skipping lint."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 5. Build ZIP via build_zip.py ───────────────────────────────────
|
|
||||||
info "[5/10] Building ZIP"
|
|
||||||
ZIP_NAME="bal_${TAG}.zip"
|
|
||||||
python3 build_zip.py "$ZIP_NAME"
|
|
||||||
|
|
||||||
# ── 6. GPG sign (armor + binary) + export public key ───────────────
|
|
||||||
info "[6/10] Signing with GPG"
|
|
||||||
GPG_KEY="A847D004DB91610711CA6A0DFE756706E833E0D1"
|
|
||||||
gpg --default-key "$GPG_KEY" --batch --yes --armor --detach-sign "$ZIP_NAME"
|
|
||||||
gpg --default-key "$GPG_KEY" --batch --yes --detach-sign "$ZIP_NAME"
|
|
||||||
ASC_FILE="${ZIP_NAME}.asc"
|
|
||||||
SIG_FILE="${ZIP_NAME}.sig"
|
|
||||||
PGP_FILE="svatantrya.asc"
|
|
||||||
gpg --armor --export "$GPG_KEY" > "$PGP_FILE"
|
|
||||||
echo " Signed: $ASC_FILE"
|
|
||||||
echo " Signed: $SIG_FILE"
|
|
||||||
echo " Public key: $PGP_FILE"
|
|
||||||
|
|
||||||
# ── 7. SHA-256 checksum ────────────────────────────────────────────
|
|
||||||
info "[7/10] Computing SHA-256"
|
|
||||||
SHA256_HASH=$(sha256sum "$ZIP_NAME" | cut -d' ' -f1)
|
|
||||||
echo "${SHA256_HASH} ${ZIP_NAME}" > "${ZIP_NAME}.sha256"
|
|
||||||
echo " SHA-256: ${SHA256_HASH}"
|
|
||||||
|
|
||||||
# ── 8. Pause for Electrum test ─────────────────────────────────────
|
|
||||||
info "[8/10] Test in Electrum (ZIP-FIRST policy)"
|
|
||||||
echo ""
|
|
||||||
echo " ZIP ready: $(pwd)/${ZIP_NAME}"
|
|
||||||
echo ""
|
|
||||||
echo " Install it in Electrum (Tools -> Plugins -> Install from file)."
|
|
||||||
echo " IMPORTANT: fully restart Electrum (not just reload the plugin)."
|
|
||||||
echo ""
|
|
||||||
read -r -p " Does the plugin work correctly in Electrum? [y/N] " CONFIRM
|
|
||||||
case "$CONFIRM" in
|
|
||||||
[yY][eE][sS]|[yY]) echo " Confirmed." ;;
|
|
||||||
*) die "Aborted by user." ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# ── 9. Git tag + push ──────────────────────────────────────────────
|
|
||||||
info "[9/10] Creating and pushing tag ${TAG}"
|
|
||||||
ORIGIN_URL="$(git remote get-url origin 2>/dev/null || true)"
|
|
||||||
[ -n "$ORIGIN_URL" ] || die "no git remote 'origin' found"
|
|
||||||
|
|
||||||
GITEA_HOST="$(echo "$ORIGIN_URL" | sed -n 's|https://\([^/]*\)/.*|\1|p')"
|
|
||||||
[ -n "$GITEA_HOST" ] || die "cannot parse Gitea host from origin URL"
|
|
||||||
|
|
||||||
TARGET_REPO="bitcoinafterlife/bal-electrum-plugin"
|
|
||||||
|
|
||||||
# credentials
|
|
||||||
GITEA_USER="${GITEA_USER:-}"
|
|
||||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
|
||||||
if [ -z "$GITEA_USER" ] && [ -z "$GITEA_TOKEN" ]; then
|
|
||||||
if [ -f ~/.git-credentials ]; then
|
|
||||||
CREDS_LINE="$(grep "${GITEA_HOST}" ~/.git-credentials | head -n1)"
|
|
||||||
if [ -n "$CREDS_LINE" ]; then
|
|
||||||
CREDS="$(echo "$CREDS_LINE" | sed -n 's|https://\([^@]*\)@.*|\1|p')"
|
|
||||||
GITEA_USER="$(echo "$CREDS" | cut -d: -f1)"
|
|
||||||
GITEA_PASS="$(echo "$CREDS" | cut -d: -f2-)"
|
|
||||||
GITEA_TOKEN="$GITEA_PASS"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
[ -n "$GITEA_TOKEN" ] || die "GITEA_TOKEN not set and no credentials in ~/.git-credentials"
|
|
||||||
|
|
||||||
API="https://$GITEA_HOST/gitea/api/v1"
|
|
||||||
|
|
||||||
# create annotated tag
|
|
||||||
git tag -d "$TAG" 2>/dev/null || true
|
|
||||||
git tag -a "$TAG" -m "$TAG" HEAD
|
|
||||||
|
|
||||||
# push tag
|
|
||||||
REMOTE_NAME="gitea-target"
|
|
||||||
REMOTE_URL="https://$GITEA_USER:$GITEA_TOKEN@$GITEA_HOST/gitea/$TARGET_REPO.git"
|
|
||||||
git remote rm "$REMOTE_NAME" 2>/dev/null || true
|
|
||||||
git remote add "$REMOTE_NAME" "$REMOTE_URL"
|
|
||||||
echo " Pushing tag ${TAG} to ${TARGET_REPO}..."
|
|
||||||
git push "$REMOTE_NAME" "$TAG" --force
|
|
||||||
|
|
||||||
# ── 10. Create release + upload assets ──────────────────────────────
|
|
||||||
info "[10/10] Creating Gitea release"
|
|
||||||
|
|
||||||
RELEASE_BODY=$(python3 -c "
|
|
||||||
import json
|
|
||||||
|
|
||||||
sha256 = '${SHA256_HASH}'
|
|
||||||
zip_name = '${ZIP_NAME}'
|
|
||||||
asc_name = '${ASC_FILE}'
|
|
||||||
sig_name = '${SIG_FILE}'
|
|
||||||
pgp_file = '${PGP_FILE}'
|
|
||||||
tag = '${TAG}'
|
|
||||||
|
|
||||||
body = f'''Release {tag}
|
|
||||||
|
|
||||||
## SHA-256 Checksum
|
|
||||||
|
|
||||||
\`\`\`
|
|
||||||
{sha256} {zip_name}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Verify SHA-256
|
|
||||||
|
|
||||||
\`\`\`bash
|
|
||||||
sha256sum -c {zip_name}.sha256
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Download
|
|
||||||
|
|
||||||
- \`{zip_name}\` - Plugin BAL {tag}
|
|
||||||
- \`{asc_name}\` - GPG signature (armor)
|
|
||||||
- \`{sig_name}\` - GPG signature (binary)
|
|
||||||
- \`{pgp_file}\` - Signing public key ([also available online](https://bitcoin-after.life/svatantrya.asc))
|
|
||||||
|
|
||||||
## GPG Verification
|
|
||||||
|
|
||||||
### Import the signing key
|
|
||||||
|
|
||||||
\`\`\`bash
|
|
||||||
gpg --fetch-key https://bitcoin-after.life/svatantrya.asc
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Or download \`{pgp_file}\` from the assets above:
|
|
||||||
|
|
||||||
\`\`\`bash
|
|
||||||
gpg --import {pgp_file}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Verify the signature (armor)
|
|
||||||
|
|
||||||
\`\`\`bash
|
|
||||||
gpg --verify {asc_name} {zip_name}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Verify the signature (binary)
|
|
||||||
|
|
||||||
\`\`\`bash
|
|
||||||
gpg --verify {sig_name} {zip_name}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Expected output:
|
|
||||||
\`\`\`
|
|
||||||
gpg: Good signature from "Svātantrya <svatantrya@bitcoin-after.life>"
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Fingerprint: \`A847D004DB91610711CA6A0DFE756706E833E0D1\`
|
|
||||||
Public key: https://bitcoin-after.life/svatantrya.asc'''
|
|
||||||
|
|
||||||
print(json.dumps({'body': body}, ensure_ascii=False))
|
|
||||||
")
|
|
||||||
|
|
||||||
RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-X POST "${API}/repos/${TARGET_REPO}/releases" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":${RELEASE_BODY},\"draft\":false,\"prerelease\":false}")
|
|
||||||
|
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
|
||||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
|
||||||
|
|
||||||
if [ "$HTTP_CODE" != "201" ]; then
|
|
||||||
echo "Error creating release: HTTP $HTTP_CODE"
|
|
||||||
echo "$BODY"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
RELEASE_ID=$(echo "$BODY" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
|
|
||||||
HTML_URL=$(echo "$BODY" | python3 -c "import json,sys; print(json.load(sys.stdin)['html_url'])")
|
|
||||||
echo " Release created: $HTML_URL (ID: $RELEASE_ID)"
|
|
||||||
|
|
||||||
# upload assets
|
|
||||||
for FILE in "$ZIP_NAME" "$ASC_FILE" "$SIG_FILE" "${ZIP_NAME}.sha256" "$PGP_FILE"; do
|
|
||||||
BASENAME=$(basename "$FILE")
|
|
||||||
echo " Uploading $BASENAME ..."
|
|
||||||
UPLOAD=$(curl -s -w "\n%{http_code}" -H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-X POST "${API}/repos/${TARGET_REPO}/releases/${RELEASE_ID}/assets" \
|
|
||||||
-F "attachment=@${FILE}" -F "name=${BASENAME}")
|
|
||||||
UPLOAD_CODE=$(echo "$UPLOAD" | tail -1)
|
|
||||||
if [ "$UPLOAD_CODE" == "201" ]; then
|
|
||||||
echo " OK ($BASENAME)"
|
|
||||||
else
|
|
||||||
echo " FAILED ($BASENAME) - HTTP $UPLOAD_CODE"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Done ==="
|
|
||||||
echo "Release: $HTML_URL"
|
|
||||||
echo "Assets:"
|
|
||||||
echo " ${ZIP_NAME}"
|
|
||||||
echo " ${ASC_FILE}"
|
|
||||||
echo " ${SIG_FILE}"
|
|
||||||
echo " ${ZIP_NAME}.sha256"
|
|
||||||
echo " ${PGP_FILE}"
|
|
||||||
echo "SHA-256: ${SHA256_HASH}"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
[tool.ruff]
|
|
||||||
line-length = 88
|
|
||||||
target-version = "py312"
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
select =["E", "W", "F", "I", "N", "B"]
|
|
||||||
ignore = ["E501"]
|
|
||||||
|
|
||||||
[tool.ruff.lint.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
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
|
||||||
|
|
||||||
mQENBGfgPmMBCAC4VXQn/ofBGPn/Wr9dF4tM/4uYNcWLvvz+/+TQsCi/bv4GG6jf
|
|
||||||
6Ttlg4TDwqF3JlZ1YfPImcdWKxr9is4fyq12OEZvz12LoFEJG8+0NdJrCoT2sm2f
|
|
||||||
yGmWKgZqRzH9LVBtIOOQIrXF3PdE0X77trWnSFrK/qAv9dszYiVOk9IBwUVI/3Wp
|
|
||||||
PN5EV7zqbCjYvzD0Hxl2sFzZKqsZCsiy70PJtaJKvKISd8RVTNuIiwZj0gu6hCSa
|
|
||||||
ZnBr5SLLr56YO4xaTzYNYh7XIEaQXZTHugEJbwygfZajnJ8gC91wWB3BsxVeHDdm
|
|
||||||
uDy1VGkAs65qvRn9ml5udmnEIPoEsS95HblpABEBAAG0K1N2xIF0YW50cnlhIDxz
|
|
||||||
dmF0YW50cnlhQGJpdGNvaW4tYWZ0ZXIubGlmZT6JAU4EEwEKADgWIQSoR9AE25Fh
|
|
||||||
BxHKag3+dWcG6DPg0QUCZ+A+YwIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAK
|
|
||||||
CRD+dWcG6DPg0coSCACbu3/tqMTwWTqRzXedl6VTGng+qeYfA5NYUaRgZeQYcVWM
|
|
||||||
sUi4dTAthBUxU3axfcu3V/Vkonn/Hrghdjh94lfpNsgdBNi3c2elI1rHT3Yobkj+
|
|
||||||
ZsMEj91VlqV81uPFzfq8a/Pp7RIDhy1FJbIunmjnpD3GeJ7vVt76OOcyjV5hkGR0
|
|
||||||
YJ4JX9O1OOC6wqgR2HVCvXTw/3JhNbj4TS8wr7GGsVWwiotAwZw506vspQRBqeYB
|
|
||||||
T5Wo2lpEQtagWzIHtgy4A2iAoLQ45E0T1lkr+mZa3V7sucS6W/UXI7HTvqC7wbku
|
|
||||||
jef6Hxwzzw83TWqPkd4wywuHsDZ3+DTcIDaqP/ROuQENBGfgPmMBCAC69Y2n2Ogi
|
|
||||||
T7i4Pm4J0cQxLaqwvox3GWSRuBG0QlhsBr0ER5j5fRRDH85P/WyTcvs4/9mIZsSl
|
|
||||||
JyQH/Lfetr/76pFCyc2zhKxxS1miG3RWOuM7BOKbRjjiieBa6XAiyWStKp2ij8a/
|
|
||||||
kpqqgulLe1Tiq2SRPA8etqHGd7oR02fbEvzmsgiVqFOz3/tozp2jdC7zCKnp+XFZ
|
|
||||||
xMKqhIMgfZAxRmVl/qImH944ffcJU6M+qjEL3ENXpuDXpMSWI/indlbK06+R/UPA
|
|
||||||
hOxCOUSRPTeHzhQrJYUgH6Q6Q/cijpTQHVQFFqLXRKGgK7oE1QhmiNGNBeCVF7DP
|
|
||||||
hpcWrnUkY/xFABEBAAGJATYEGAEKACAWIQSoR9AE25FhBxHKag3+dWcG6DPg0QUC
|
|
||||||
Z+A+YwIbDAAKCRD+dWcG6DPg0ToJB/4t2V4FMqd2q00Sd+HmttZoAWNuklui8wO4
|
|
||||||
nrjfh3Rt0ZBYYk+egZXzPx8lr42Ec8T4h24oJPovMlDu1xN9seQDbVaYC1ICVsnp
|
|
||||||
6/yfh+elYT5egaAxm9oP9+lQHBB/qZNKrfAssMuVQOrVh5E+XxSz+KG28dQnCYUT
|
|
||||||
L0k5PCO1f4Jz4XZd5AunVbMQ4J1JawUDoEb/w3Mn9ALDMsdAcOYC6pGhFtV88cqu
|
|
||||||
IO/ekQV+M8LpRwyh+CiPzgqtN3Z09wHLXFUJYBixXrYbXxAbSqe0PhqAhEKApk2c
|
|
||||||
4vVkSTAi+bNpkt0QgJ194iTyK20jVw3/roq7sUtDD4FrUoQb7llP
|
|
||||||
=6EE4
|
|
||||||
-----END PGP PUBLIC KEY BLOCK-----
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
"""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,8 +33,7 @@ def _active_source_without_strings(module) -> str:
|
|||||||
if isinstance(node.value, str) and hasattr(node, "end_lineno"):
|
if isinstance(node.value, str) and hasattr(node, "end_lineno"):
|
||||||
self.spans.append((node.lineno, node.end_lineno))
|
self.spans.append((node.lineno, node.end_lineno))
|
||||||
self.generic_visit(node)
|
self.generic_visit(node)
|
||||||
s = _S()
|
s = _S(); s.visit(tree)
|
||||||
s.visit(tree)
|
|
||||||
drop = set()
|
drop = set()
|
||||||
for a, b in s.spans:
|
for a, b in s.spans:
|
||||||
drop.update(range(a, b + 1))
|
drop.update(range(a, b + 1))
|
||||||
@@ -46,13 +45,12 @@ def _active_source_without_strings(module) -> str:
|
|||||||
|
|
||||||
def main(pkg: str) -> int:
|
def main(pkg: str) -> int:
|
||||||
from PyQt6.QtWidgets import QApplication, QDialog, QWidget
|
from PyQt6.QtWidgets import QApplication, QDialog, QWidget
|
||||||
_app = QApplication.instance() or QApplication(sys.argv)
|
app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
wu = importlib.import_module(pkg + ".gui.qt.window_utils")
|
wu = importlib.import_module(pkg + ".gui.qt.window_utils")
|
||||||
|
|
||||||
# top_level_of: returns the top-level container of a child widget
|
# top_level_of: returns the top-level container of a child widget
|
||||||
w = QWidget()
|
w = QWidget(); child = QWidget(w)
|
||||||
child = QWidget(w)
|
|
||||||
assert wu.top_level_of(child) is w
|
assert wu.top_level_of(child) is w
|
||||||
assert wu.top_level_of(None) is None
|
assert wu.top_level_of(None) is None
|
||||||
print("[OK] top_level_of")
|
print("[OK] top_level_of")
|
||||||
|
|||||||
11949
tests/karen7
Normal file
11949
tests/karen7
Normal file
File diff suppressed because one or more lines are too long
@@ -31,7 +31,7 @@ N = 8 # number of servers
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
we_mod = importlib.import_module(f"{PKG}.core.willexecutors")
|
we_mod = importlib.import_module(f"{PKG}.core.willexecutors")
|
||||||
we_cls = we_mod.Willexecutors
|
W = we_mod.Willexecutors
|
||||||
|
|
||||||
# ---- 1) ping_servers_parallel: time ~= slowest, not sum ----
|
# ---- 1) ping_servers_parallel: time ~= slowest, not sum ----
|
||||||
def slow_get_info(url, we, **kwargs):
|
def slow_get_info(url, we, **kwargs):
|
||||||
@@ -43,8 +43,8 @@ def main():
|
|||||||
we["status"] = 200
|
we["status"] = 200
|
||||||
return we
|
return we
|
||||||
|
|
||||||
orig_get_info = we_cls.get_info_task
|
orig_get_info = W.get_info_task
|
||||||
we_cls.get_info_task = staticmethod(slow_get_info)
|
W.get_info_task = staticmethod(slow_get_info)
|
||||||
try:
|
try:
|
||||||
wes = {}
|
wes = {}
|
||||||
for i in range(N):
|
for i in range(N):
|
||||||
@@ -57,7 +57,7 @@ def main():
|
|||||||
seen.append((url, ok))
|
seen.append((url, ok))
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
we_cls.ping_servers_parallel(wes, on_each=on_each, max_workers=N)
|
W.ping_servers_parallel(wes, on_each=on_each, max_workers=N)
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
|
|
||||||
# Sequential would take ~ N * SLOW. Parallel must be far less.
|
# Sequential would take ~ N * SLOW. Parallel must be far less.
|
||||||
@@ -81,15 +81,15 @@ def main():
|
|||||||
assert we["status"] == "KO", (url, we)
|
assert we["status"] == "KO", (url, we)
|
||||||
print("[OK] ping results written back into the willexecutors mapping")
|
print("[OK] ping results written back into the willexecutors mapping")
|
||||||
finally:
|
finally:
|
||||||
we_cls.get_info_task = orig_get_info
|
W.get_info_task = orig_get_info
|
||||||
|
|
||||||
# ---- 2) push_transactions_parallel: time ~= slowest, not sum ----
|
# ---- 2) push_transactions_parallel: time ~= slowest, not sum ----
|
||||||
def slow_push(we, **kwargs):
|
def slow_push(we, **kwargs):
|
||||||
time.sleep(SLOW)
|
time.sleep(SLOW)
|
||||||
return "fail" not in we["url"]
|
return "fail" not in we["url"]
|
||||||
|
|
||||||
orig_push = we_cls.push_transactions_to_willexecutor
|
orig_push = W.push_transactions_to_willexecutor
|
||||||
we_cls.push_transactions_to_willexecutor = staticmethod(slow_push)
|
W.push_transactions_to_willexecutor = staticmethod(slow_push)
|
||||||
try:
|
try:
|
||||||
wes = {}
|
wes = {}
|
||||||
for i in range(N):
|
for i in range(N):
|
||||||
@@ -106,7 +106,7 @@ def main():
|
|||||||
pushed.append((url, ok))
|
pushed.append((url, ok))
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
results = we_cls.push_transactions_parallel(wes, on_each=on_each_push,
|
results = W.push_transactions_parallel(wes, on_each=on_each_push,
|
||||||
max_workers=N)
|
max_workers=N)
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
|
|
||||||
@@ -117,11 +117,11 @@ def main():
|
|||||||
f"(sequential would be ~{sequential:.2f}s)")
|
f"(sequential would be ~{sequential:.2f}s)")
|
||||||
|
|
||||||
assert len(results) == N, results
|
assert len(results) == N, results
|
||||||
for url, (ok, _exc) in results.items():
|
for url, (ok, exc) in results.items():
|
||||||
assert ok == ("good" in url), (url, ok)
|
assert ok == ("good" in url), (url, ok)
|
||||||
print("[OK] push results correct for every server")
|
print("[OK] push results correct for every server")
|
||||||
finally:
|
finally:
|
||||||
we_cls.push_transactions_to_willexecutor = orig_push
|
W.push_transactions_to_willexecutor = orig_push
|
||||||
|
|
||||||
# ---- 2b) global deadline: a hung server must not block past `deadline` ----
|
# ---- 2b) global deadline: a hung server must not block past `deadline` ----
|
||||||
def hanging_push(we, **kwargs):
|
def hanging_push(we, **kwargs):
|
||||||
@@ -129,8 +129,8 @@ def main():
|
|||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
orig_push2 = we_cls.push_transactions_to_willexecutor
|
orig_push2 = W.push_transactions_to_willexecutor
|
||||||
we_cls.push_transactions_to_willexecutor = staticmethod(hanging_push)
|
W.push_transactions_to_willexecutor = staticmethod(hanging_push)
|
||||||
try:
|
try:
|
||||||
wes = {
|
wes = {
|
||||||
"https://fast.example": {
|
"https://fast.example": {
|
||||||
@@ -146,7 +146,7 @@ def main():
|
|||||||
return True
|
return True
|
||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
return True
|
return True
|
||||||
we_cls.push_transactions_to_willexecutor = staticmethod(fast_or_hang)
|
W.push_transactions_to_willexecutor = staticmethod(fast_or_hang)
|
||||||
|
|
||||||
timed_out = []
|
timed_out = []
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ def main():
|
|||||||
timed_out.append(url)
|
timed_out.append(url)
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
we_cls.push_transactions_parallel(
|
W.push_transactions_parallel(
|
||||||
wes, max_workers=2, deadline=1.0, on_timeout=on_timeout
|
wes, max_workers=2, deadline=1.0, on_timeout=on_timeout
|
||||||
)
|
)
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
@@ -163,7 +163,7 @@ def main():
|
|||||||
print(f"[OK] global deadline enforced: returned in {elapsed:.1f}s, "
|
print(f"[OK] global deadline enforced: returned in {elapsed:.1f}s, "
|
||||||
f"hung server reported via on_timeout")
|
f"hung server reported via on_timeout")
|
||||||
finally:
|
finally:
|
||||||
we_cls.push_transactions_to_willexecutor = orig_push2
|
W.push_transactions_to_willexecutor = orig_push2
|
||||||
|
|
||||||
# ---- 2c) on_tick is fired periodically from the CALLING thread ----
|
# ---- 2c) on_tick is fired periodically from the CALLING thread ----
|
||||||
# The elapsed-time counter is driven by an on_tick callback called from the
|
# The elapsed-time counter is driven by an on_tick callback called from the
|
||||||
@@ -175,8 +175,8 @@ def main():
|
|||||||
time.sleep(SLOW * 6) # ~3s, long enough for several ticks
|
time.sleep(SLOW * 6) # ~3s, long enough for several ticks
|
||||||
return True
|
return True
|
||||||
|
|
||||||
orig_push3 = we_cls.push_transactions_to_willexecutor
|
orig_push3 = W.push_transactions_to_willexecutor
|
||||||
we_cls.push_transactions_to_willexecutor = staticmethod(slow_push2)
|
W.push_transactions_to_willexecutor = staticmethod(slow_push2)
|
||||||
try:
|
try:
|
||||||
wes = {
|
wes = {
|
||||||
"https://tick.example": {
|
"https://tick.example": {
|
||||||
@@ -191,7 +191,7 @@ def main():
|
|||||||
ticks.append(time.time())
|
ticks.append(time.time())
|
||||||
tick_threads.add(threading.current_thread())
|
tick_threads.add(threading.current_thread())
|
||||||
|
|
||||||
we_cls.push_transactions_parallel(
|
W.push_transactions_parallel(
|
||||||
wes, max_workers=1, on_tick=on_tick, tick_interval=0.5
|
wes, max_workers=1, on_tick=on_tick, tick_interval=0.5
|
||||||
)
|
)
|
||||||
# ~3s push with 0.5s ticks => at least a few ticks.
|
# ~3s push with 0.5s ticks => at least a few ticks.
|
||||||
@@ -202,7 +202,7 @@ def main():
|
|||||||
)
|
)
|
||||||
print(f"[OK] on_tick fired {len(ticks)} times from the calling thread")
|
print(f"[OK] on_tick fired {len(ticks)} times from the calling thread")
|
||||||
finally:
|
finally:
|
||||||
we_cls.push_transactions_to_willexecutor = orig_push3
|
W.push_transactions_to_willexecutor = orig_push3
|
||||||
|
|
||||||
# ---- 2d) check_transactions_parallel: parallel + deadline + on_tick ----
|
# ---- 2d) check_transactions_parallel: parallel + deadline + on_tick ----
|
||||||
# Pressing "Check" verifies each will-executor still holds its tx. This used
|
# Pressing "Check" verifies each will-executor still holds its tx. This used
|
||||||
@@ -214,8 +214,8 @@ def main():
|
|||||||
time.sleep(SLOW)
|
time.sleep(SLOW)
|
||||||
return {"tx": "ok"} if "good" in url else None
|
return {"tx": "ok"} if "good" in url else None
|
||||||
|
|
||||||
orig_check = we_cls.check_transaction
|
orig_check = W.check_transaction
|
||||||
we_cls.check_transaction = staticmethod(slow_check)
|
W.check_transaction = staticmethod(slow_check)
|
||||||
try:
|
try:
|
||||||
targets = []
|
targets = []
|
||||||
for i in range(N):
|
for i in range(N):
|
||||||
@@ -228,7 +228,7 @@ def main():
|
|||||||
checked.append((wid, res))
|
checked.append((wid, res))
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
results = we_cls.check_transactions_parallel(
|
results = W.check_transactions_parallel(
|
||||||
targets, on_each=on_each_check, max_workers=N
|
targets, on_each=on_each_check, max_workers=N
|
||||||
)
|
)
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
@@ -239,7 +239,7 @@ def main():
|
|||||||
print(f"[OK] check parallel: {elapsed:.2f}s for {N} servers "
|
print(f"[OK] check parallel: {elapsed:.2f}s for {N} servers "
|
||||||
f"(sequential would be ~{sequential:.2f}s)")
|
f"(sequential would be ~{sequential:.2f}s)")
|
||||||
finally:
|
finally:
|
||||||
we_cls.check_transaction = orig_check
|
W.check_transaction = orig_check
|
||||||
|
|
||||||
# 2d-bis) global deadline + on_tick from the calling thread
|
# 2d-bis) global deadline + on_tick from the calling thread
|
||||||
def hanging_check(txid, url, **kwargs):
|
def hanging_check(txid, url, **kwargs):
|
||||||
@@ -248,8 +248,8 @@ def main():
|
|||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
return {"tx": "ok"}
|
return {"tx": "ok"}
|
||||||
|
|
||||||
orig_check2 = we_cls.check_transaction
|
orig_check2 = W.check_transaction
|
||||||
we_cls.check_transaction = staticmethod(hanging_check)
|
W.check_transaction = staticmethod(hanging_check)
|
||||||
try:
|
try:
|
||||||
targets = [
|
targets = [
|
||||||
("idf", "https://fast.example"),
|
("idf", "https://fast.example"),
|
||||||
@@ -268,7 +268,7 @@ def main():
|
|||||||
tick_threads.add(threading.current_thread())
|
tick_threads.add(threading.current_thread())
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
we_cls.check_transactions_parallel(
|
W.check_transactions_parallel(
|
||||||
targets, max_workers=2, deadline=2.0,
|
targets, max_workers=2, deadline=2.0,
|
||||||
on_timeout=on_timeout_check, on_tick=on_tick_check,
|
on_timeout=on_timeout_check, on_tick=on_tick_check,
|
||||||
tick_interval=0.5,
|
tick_interval=0.5,
|
||||||
@@ -282,7 +282,7 @@ def main():
|
|||||||
print(f"[OK] check global deadline enforced ({elapsed:.1f}s), on_tick "
|
print(f"[OK] check global deadline enforced ({elapsed:.1f}s), on_tick "
|
||||||
f"fired {len(ticks)}x from the calling thread")
|
f"fired {len(ticks)}x from the calling thread")
|
||||||
finally:
|
finally:
|
||||||
we_cls.check_transaction = orig_check2
|
W.check_transaction = orig_check2
|
||||||
|
|
||||||
# ---- 3) the wizard's loop_push must use the parallel helper ----
|
# ---- 3) the wizard's loop_push must use the parallel helper ----
|
||||||
# The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push.
|
# The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push.
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import sys
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
|
||||||
from PyQt6.QtCore import Qt
|
from PyQt6.QtCore import Qt
|
||||||
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
|
|
||||||
|
|
||||||
# Same colors as BalBuildWillDialog
|
# Same colors as BalBuildWillDialog
|
||||||
COLOR_WARNING = "#cfa808"
|
COLOR_WARNING = "#cfa808"
|
||||||
|
|||||||
@@ -17,15 +17,10 @@ import os
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PyQt6.QtCore import Qt # noqa: E402
|
|
||||||
from PyQt6.QtWidgets import ( # noqa: E402
|
from PyQt6.QtWidgets import ( # noqa: E402
|
||||||
QApplication,
|
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||||
QHBoxLayout,
|
|
||||||
QLabel,
|
|
||||||
QPushButton,
|
|
||||||
QVBoxLayout,
|
|
||||||
QWidget,
|
|
||||||
)
|
)
|
||||||
|
from PyQt6.QtCore import Qt # noqa: E402
|
||||||
|
|
||||||
COLOR_OK = "#05ad05"
|
COLOR_OK = "#05ad05"
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ import sys
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
|
||||||
from PyQt6.QtCore import Qt
|
from PyQt6.QtCore import Qt
|
||||||
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
|
|
||||||
|
|
||||||
COLOR_ERROR = "#ff0000"
|
COLOR_ERROR = "#ff0000"
|
||||||
COLOR_OK = "#05ad05"
|
COLOR_OK = "#05ad05"
|
||||||
|
|||||||
@@ -20,17 +20,12 @@ import os
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PyQt6.QtCore import QSize # noqa: E402
|
|
||||||
from PyQt6.QtGui import QIcon, QPixmap # noqa: E402
|
|
||||||
from PyQt6.QtWidgets import ( # noqa: E402
|
from PyQt6.QtWidgets import ( # noqa: E402
|
||||||
QApplication,
|
QApplication, QWidget, QHBoxLayout, QPushButton, QComboBox, QLineEdit,
|
||||||
QComboBox,
|
|
||||||
QHBoxLayout,
|
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
|
||||||
QPushButton,
|
|
||||||
QWidget,
|
|
||||||
)
|
)
|
||||||
|
from PyQt6.QtGui import QIcon, QPixmap # noqa: E402
|
||||||
|
from PyQt6.QtCore import QSize, Qt # noqa: E402
|
||||||
|
|
||||||
ICON_PATH = os.path.join(os.path.dirname(__file__), "..", "bal", "icons",
|
ICON_PATH = os.path.join(os.path.dirname(__file__), "..", "bal", "icons",
|
||||||
"wizard.png")
|
"wizard.png")
|
||||||
|
|||||||
@@ -27,19 +27,12 @@ import os
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PyQt6.QtCore import Qt # noqa: E402
|
|
||||||
from PyQt6.QtGui import QFontMetrics # noqa: E402
|
|
||||||
from PyQt6.QtWidgets import ( # noqa: E402
|
from PyQt6.QtWidgets import ( # noqa: E402
|
||||||
QApplication,
|
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QComboBox,
|
||||||
QComboBox,
|
QLineEdit, QSpinBox, QLabel,
|
||||||
QHBoxLayout,
|
|
||||||
QLabel,
|
|
||||||
QLineEdit,
|
|
||||||
QSpinBox,
|
|
||||||
QToolButton,
|
|
||||||
QVBoxLayout,
|
|
||||||
QWidget,
|
|
||||||
)
|
)
|
||||||
|
from PyQt6.QtGui import QFontMetrics # noqa: E402
|
||||||
|
from PyQt6.QtCore import Qt # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _char_w():
|
def _char_w():
|
||||||
|
|||||||
792
tests/samanta7
792
tests/samanta7
File diff suppressed because one or more lines are too long
@@ -21,21 +21,18 @@ Run:
|
|||||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
|
import copy
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.util import copy_structure
|
|
||||||
from bal.core.will import (
|
from bal.core.will import (
|
||||||
HeirNotFoundException,
|
WillItem, Will,
|
||||||
NoHeirsException,
|
NotCompleteWillException, HeirNotFoundException, NoHeirsException,
|
||||||
NotCompleteWillException,
|
TxFeesChangedException, WillExpiredException,
|
||||||
TxFeesChangedException,
|
|
||||||
Will,
|
|
||||||
WillExpiredException,
|
|
||||||
WillItem,
|
|
||||||
)
|
)
|
||||||
|
from bal.core.util import Util
|
||||||
|
|
||||||
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0.
|
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0.
|
||||||
_VALID_TX_HEX = (
|
_VALID_TX_HEX = (
|
||||||
@@ -58,7 +55,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_structure(heirs),
|
"heirs": copy.deepcopy(heirs),
|
||||||
"willexecutor": None,
|
"willexecutor": None,
|
||||||
"status": "",
|
"status": "",
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -67,7 +64,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 = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
item.STATUS = copy.deepcopy(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:
|
||||||
@@ -118,7 +115,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_structure(heirs),
|
will_heirs=heirs, current_heirs=copy.deepcopy(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,28 +26,30 @@ def main():
|
|||||||
from PyQt6.QtWidgets import QApplication # noqa
|
from PyQt6.QtWidgets import QApplication # noqa
|
||||||
_app = QApplication.instance() or QApplication([])
|
_app = QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
|
||||||
# 1) Core modules import (these must be GUI-free).
|
# 1) Core modules import (these must be GUI-free).
|
||||||
bal = imp_core("bal", "core.plugin_base")
|
bal = imp_core("bal", "core.plugin_base")
|
||||||
util = imp_core("util", "core.util")
|
util = imp_core("util", "core.util")
|
||||||
heirs = imp_core("heirs", "core.heirs")
|
heirs = imp_core("heirs", "core.heirs")
|
||||||
will = imp_core("will", "core.will")
|
will = imp_core("will", "core.will")
|
||||||
_we = imp_core("willexecutors", "core.willexecutors")
|
we = imp_core("willexecutors", "core.willexecutors")
|
||||||
|
|
||||||
# 2) GUI module imports.
|
# 2) GUI module imports.
|
||||||
qt = imp_gui()
|
qt = imp_gui()
|
||||||
|
|
||||||
# 3) Behaviour checks (pure logic, must be identical across versions).
|
# 3) Behaviour checks (pure logic, must be identical across versions).
|
||||||
bal_timestamp = bal.BalTimestamp
|
BalTimestamp = bal.BalTimestamp
|
||||||
assert bal_timestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
|
assert BalTimestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
|
||||||
assert bal_timestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
|
assert BalTimestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
|
||||||
assert str(bal_timestamp("7d")) == "7d", "BalTimestamp str"
|
assert str(BalTimestamp("7d")) == "7d", "BalTimestamp str"
|
||||||
|
|
||||||
util_cls = util.Util
|
Util = util.Util
|
||||||
assert util_cls.is_perc("50%") is True
|
assert Util.is_perc("50%") is True
|
||||||
assert util_cls.is_perc("100") is False
|
assert Util.is_perc("100") is False
|
||||||
assert util_cls.text_to_hex("BAL") == "42414c"
|
assert Util.text_to_hex("BAL") == "42414c"
|
||||||
assert util_cls.hex_to_text("42414c") == "BAL"
|
assert Util.hex_to_text("42414c") == "BAL"
|
||||||
assert util_cls.int_locktime(days=1) == 86400
|
assert Util.int_locktime(days=1) == 86400
|
||||||
|
|
||||||
# heirs constants must keep the same column layout (very delicate!)
|
# heirs constants must keep the same column layout (very delicate!)
|
||||||
assert heirs.HEIR_ADDRESS == 0
|
assert heirs.HEIR_ADDRESS == 0
|
||||||
|
|||||||
@@ -27,19 +27,19 @@ Run:
|
|||||||
tests/test_anticipate_manual_locktime.py -q
|
tests/test_anticipate_manual_locktime.py -q
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
|
import copy
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
import pytest # noqa: E402 # pyright: ignore[reportMissingImports]
|
import pytest # noqa: E402
|
||||||
|
|
||||||
from bal.core.util import copy_structure # noqa: E402
|
|
||||||
from bal.core.will import ( # noqa: E402
|
from bal.core.will import ( # noqa: E402
|
||||||
NotCompleteWillException,
|
|
||||||
Will,
|
|
||||||
WillExpiredException,
|
|
||||||
WillItem,
|
WillItem,
|
||||||
|
Will,
|
||||||
|
NotCompleteWillException,
|
||||||
|
WillExpiredException,
|
||||||
)
|
)
|
||||||
|
|
||||||
# A valid serialized tx (1 input + 1 output, version 2).
|
# A valid serialized tx (1 input + 1 output, version 2).
|
||||||
@@ -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_structure(heirs),
|
"heirs": copy.deepcopy(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 = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
item.STATUS = copy.deepcopy(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 os
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.util import LOCKTIME_THRESHOLD, Util
|
from bal.core.util import Util, LOCKTIME_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,570 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
"""
|
|
||||||
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")
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
"""
|
|
||||||
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()
|
|
||||||
@@ -1,482 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for ``bal.core.animated_qr`` (BC-UR v1, BC-UR v2, BBQR interop).
|
|
||||||
|
|
||||||
Validates the self-contained codecs against the published spec vectors
|
|
||||||
(BCR-2020-004/005 BC32, BCR-2020-012 bytewords) and against byte-exact
|
|
||||||
output captured from the reference C++ bc-ur encoder (fountain/xoshiro/
|
|
||||||
alias-sampler parity), plus round trips, out-of-order assembly, missing-part
|
|
||||||
fountain solving and malformed-input rejection for all four formats.
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _payload(plen: int) -> bytes:
|
|
||||||
"""Deterministic payload matching the C++ reference driver (``(i*7)&0xff``)."""
|
|
||||||
return bytes((i * 7) & 0xFF for i in range(plen))
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# BC32 (BCR-2020-004 / bcr-2020-005 rev1 reference implementation vectors)
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_bc32_official_vectors():
|
|
||||||
cases = [
|
|
||||||
(b"Hello, world", "fpjkcmr09ss8wmmjd3jq6ax7w9"),
|
|
||||||
(b"Hello world", "fpjkcmr0ypmk7unvvsh4ra4j"),
|
|
||||||
(
|
|
||||||
bytes.fromhex("d934063e82001eec0585ee41ab5d8e4b703a4be1f73aec21e143912c56"),
|
|
||||||
"my6qv05zqq0wcpv9aeq6khvwfdcr5jlp7uawcg0pgwgjc4shjm6xu",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
for payload, encoded in cases:
|
|
||||||
assert aq.bc32_encode(payload) == encoded
|
|
||||||
assert aq.bc32_decode(encoded) == payload
|
|
||||||
|
|
||||||
|
|
||||||
def test_bc32_checksum_rejected():
|
|
||||||
good = aq.bc32_encode(b"Hello, world")
|
|
||||||
corrupted = good[:-1] + ("a" if good[-1] != "a" else "b")
|
|
||||||
try:
|
|
||||||
aq.bc32_decode(corrupted)
|
|
||||||
except aq.AnimatedQrError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
raise AssertionError("expected AnimatedQrError for corrupted BC32")
|
|
||||||
|
|
||||||
|
|
||||||
def test_bc32_bad_char_rejected():
|
|
||||||
try:
|
|
||||||
aq.bc32_decode("1" * 26)
|
|
||||||
except aq.AnimatedQrError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
raise AssertionError("expected AnimatedQrError for '1' (not in alphabet)")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Bytewords (BCR-2020-012)
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_bytewords_minimal_roundtrip():
|
|
||||||
samples = [bytes(range(256)), _payload(59), b"\x00"] + [
|
|
||||||
os.urandom(64) for _ in range(4)
|
|
||||||
]
|
|
||||||
for data in samples:
|
|
||||||
words = aq.bytewords_minimal_encode(data)
|
|
||||||
assert len(words) == (len(data) + 4) * 2 # 2 chars per byte incl. CRC
|
|
||||||
assert aq.bytewords_minimal_decode(words) == data
|
|
||||||
|
|
||||||
|
|
||||||
def test_bytewords_rejects_corrupted_crc():
|
|
||||||
data = _payload(40)
|
|
||||||
words = aq.bytewords_minimal_encode(data)
|
|
||||||
flip = "a" if words[-1] != "a" else "b"
|
|
||||||
try:
|
|
||||||
aq.bytewords_minimal_decode(words[:-1] + flip)
|
|
||||||
except aq.AnimatedQrError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
raise AssertionError("expected AnimatedQrError for corrupted CRC")
|
|
||||||
|
|
||||||
|
|
||||||
def test_bytewords_rejects_odd_length():
|
|
||||||
try:
|
|
||||||
aq.bytewords_minimal_decode("abc")
|
|
||||||
except aq.AnimatedQrError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
raise AssertionError("expected AnimatedQrError for odd-length bytewords")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# BC-UR v2: byte-exact parity with the reference C++ encoder
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
# Reference frames from the bc-ur C++ fountain encoder
|
|
||||||
# (payload x=(i*7)&0xFF, cbor wrapped, single-part and multipart).
|
|
||||||
REF_V2_SINGLE_12 = "ur:bytes/gsaeatbabzcecndrehetfhfggtoeemhpmo"
|
|
||||||
|
|
||||||
REF_V2_MULTI_59 = [
|
|
||||||
"ur:bytes/2-2/lpaoaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeeccasket",
|
|
||||||
"ur:bytes/3-2/lpaxaocsfscyrpdpjzbyhdcthdfraeatbabzcecndrehetfhfggtghhpidinjoktkblplkmunyoypdperpryssimryrldt",
|
|
||||||
"ur:bytes/4-2/lpaaaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaefeimteue",
|
|
||||||
"ur:bytes/5-2/lpahaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssgdaontls",
|
|
||||||
"ur:bytes/6-2/lpamaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssisescmwt",
|
|
||||||
"ur:bytes/7-2/lpataocsfscyrpdpjzbyhdcthdfraeatbabzcecndrehetfhfggtghhpidinjoktkblplkmunyoypdperprysslsspplgm",
|
|
||||||
"ur:bytes/8-2/lpayaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeonlrzebg",
|
|
||||||
"ur:bytes/9-2/lpasaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeaaryknzt",
|
|
||||||
"ur:bytes/10-2/lpbkaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnsslotsfrfn",
|
|
||||||
"ur:bytes/11-2/lpbdaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssdtwyrstd",
|
|
||||||
"ur:bytes/12-2/lpbnaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaegswnvdin",
|
|
||||||
"ur:bytes/13-2/lpbtaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnsshknlptee",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Reference message for the 59-byte payload: byte-string head (0x58,0x3b) + data.
|
|
||||||
REF_V2_MULTI_59_MSG = bytes([0x58, 0x3B]) + _payload(59)
|
|
||||||
|
|
||||||
|
|
||||||
def test_v2_single_part_matches_reference():
|
|
||||||
frames = aq.ur2_frames(_payload(12), len(REF_V2_SINGLE_12))
|
|
||||||
assert frames == [REF_V2_SINGLE_12]
|
|
||||||
|
|
||||||
|
|
||||||
def test_v2_reference_frames_decode_and_reencode_exactly():
|
|
||||||
message = REF_V2_MULTI_59_MSG
|
|
||||||
fragment_len = -(-len(message) // 2)
|
|
||||||
for frame in REF_V2_MULTI_59:
|
|
||||||
seq, seq_len, message_len, checksum, data = aq.ur2_parse_part(frame)
|
|
||||||
assert seq_len == 2
|
|
||||||
assert message_len == len(message)
|
|
||||||
assert checksum == aq.crc32_int(message)
|
|
||||||
assert len(data) == fragment_len
|
|
||||||
# re-encoding the parsed values reproduces the reference line exactly
|
|
||||||
assert aq._ur2_part_string(seq, seq_len, message_len, checksum, data) == frame
|
|
||||||
# our choose_fragments + partition + xor reproduces the reference data
|
|
||||||
indexes = aq.choose_fragments(seq, seq_len, checksum)
|
|
||||||
assert seq_num_indexes_valid(seq, seq_len, indexes)
|
|
||||||
mixed = aq._mix_fragments(aq._partition_message(message, fragment_len), indexes, fragment_len)
|
|
||||||
assert mixed == data
|
|
||||||
|
|
||||||
|
|
||||||
def seq_num_indexes_valid(seq, seq_len, indexes):
|
|
||||||
# pure part for seq <= seq_len contains exactly fragment seq-1
|
|
||||||
if seq <= seq_len:
|
|
||||||
return indexes == {seq - 1}
|
|
||||||
return set(indexes) <= set(range(seq_len)) and bool(indexes)
|
|
||||||
|
|
||||||
|
|
||||||
def test_v2_multipart_encoder_matches_reference_from_seq2():
|
|
||||||
# Our frames start at seq 1 (spec-aligned); parts seq 2.. must equal the
|
|
||||||
# reference (which starts at seq 2 due to first_seq_num=1).
|
|
||||||
mine = aq.ur2_frames(_payload(59), 120)
|
|
||||||
assert mine[0].split("/", 1)[1].startswith("1-2") or "1-2" in mine[0].split("/")[1]
|
|
||||||
assert mine[1:4] == REF_V2_MULTI_59[:3]
|
|
||||||
|
|
||||||
|
|
||||||
def test_v2_reference_seq7_mix_parity():
|
|
||||||
# Higher-degree mixed parts (seq_len=7) also match: message uses the
|
|
||||||
# reference head 0x58|0x00 for the 256-byte driver payload.
|
|
||||||
message = bytes([0x58, 0x00]) + _payload(256)
|
|
||||||
seq_len = 7
|
|
||||||
fragment_len = -(-len(message) // seq_len)
|
|
||||||
frames = [
|
|
||||||
"ur:bytes/9-7/lpasatcfadaocyfysnjlsrhddaykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtntoxpyprrhrtsttotluovlwdwnsrfejzhd",
|
|
||||||
"ur:bytes/10-7/lpbkatcfadaocyfysnjlsrhddazeahbnbwcycldedlenfsfygrgmhkhniojtkpkelslememkneolpmqzrksasotitsuevwwpwfzswzpmdrvo",
|
|
||||||
"ur:bytes/11-7/lpbdatcfadaocyfysnjlsrhddawkwtbbbefnaefnbebbjojybebnaebndybbbewkwtceaecedyeebebbjobnaebnbeeedybbbeztwproyapd",
|
|
||||||
]
|
|
||||||
for frame in frames:
|
|
||||||
seq, sl, mlen, checksum, data = aq.ur2_parse_part(frame)
|
|
||||||
assert sl == seq_len and mlen == len(message)
|
|
||||||
assert checksum == aq.crc32_int(message)
|
|
||||||
mixed = aq._mix_fragments(
|
|
||||||
aq._partition_message(message, fragment_len),
|
|
||||||
aq.choose_fragments(seq, seq_len, checksum),
|
|
||||||
fragment_len,
|
|
||||||
)
|
|
||||||
assert mixed == data
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# BC-UR v2: sessions / fountain decoding
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_v2_roundtrip_in_order():
|
|
||||||
payload = ("BAL transfer " * 9).encode()
|
|
||||||
frames = aq.ur2_frames(payload, 120)
|
|
||||||
seq_len = int(frames[0].split("/")[1].split("-")[1])
|
|
||||||
assert len(frames) == 2 * seq_len # pure wave + redundant mixed wave
|
|
||||||
session = aq.AnimatedQrSession()
|
|
||||||
for frame in frames:
|
|
||||||
session.add_part(frame)
|
|
||||||
assert session.done
|
|
||||||
assert session.received == session.total
|
|
||||||
text, _ = session.resolve()
|
|
||||||
assert text == payload.decode()
|
|
||||||
|
|
||||||
|
|
||||||
def test_v2_out_of_order_and_duplicate():
|
|
||||||
payload = ("BAL transfer " * 9).encode()
|
|
||||||
frames = aq.ur2_frames(payload, 120)
|
|
||||||
order = list(range(len(frames)))
|
|
||||||
random.Random(11).shuffle(order)
|
|
||||||
session = aq.AnimatedQrSession()
|
|
||||||
for i in order:
|
|
||||||
status = session.add_part(frames[i])
|
|
||||||
assert status in ("ok", "dup")
|
|
||||||
session.add_part(frames[0]) # duplicate of an already-received part
|
|
||||||
assert session.done
|
|
||||||
assert session.resolve()[0] == payload.decode()
|
|
||||||
|
|
||||||
|
|
||||||
def test_v2_solves_without_a_pure_fragment():
|
|
||||||
payload = ("BAL transfer " * 9).encode()
|
|
||||||
frames = aq.ur2_frames(payload, 120)
|
|
||||||
session = aq.AnimatedQrSession()
|
|
||||||
for frame in frames[1:]: # drop the first pure fragment
|
|
||||||
session.add_part(frame)
|
|
||||||
assert session.done
|
|
||||||
assert session.resolve()[0] == payload.decode()
|
|
||||||
|
|
||||||
|
|
||||||
def test_v2_single_part_import():
|
|
||||||
session = aq.AnimatedQrSession()
|
|
||||||
session.add_part(REF_V2_SINGLE_12)
|
|
||||||
assert session.done and session.total == 1
|
|
||||||
assert session.resolve()[0] == _payload(12).decode("latin-1")
|
|
||||||
|
|
||||||
|
|
||||||
def test_v2_conflicting_transfer_rejected():
|
|
||||||
payload_a = b"AAAAAAAAAAAAAAAA"
|
|
||||||
payload_b = b"BBBBBBBBBBBBBBBB"
|
|
||||||
fa = aq.ur2_frames(payload_a, 500)[0]
|
|
||||||
fb = aq.ur2_frames(payload_b, 500)[0]
|
|
||||||
session = aq.AnimatedQrSession()
|
|
||||||
session.add_part(fa)
|
|
||||||
try:
|
|
||||||
session.add_part(fb)
|
|
||||||
except aq.TransferConflictError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
raise AssertionError("expected TransferConflictError for a different transfer")
|
|
||||||
|
|
||||||
|
|
||||||
def test_v2_corrupt_crc_rejected():
|
|
||||||
frame = list(REF_V2_MULTI_59[0])
|
|
||||||
idx = len(frame) - 1
|
|
||||||
frame[idx] = "a" if frame[idx] != "a" else "b"
|
|
||||||
try:
|
|
||||||
aq.ur2_parse_part("".join(frame))
|
|
||||||
except aq.AnimatedQrError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
raise AssertionError("expected AnimatedQrError for a corrupt v2 part")
|
|
||||||
|
|
||||||
|
|
||||||
def test_v2_session_cap_rejected():
|
|
||||||
part = aq._ur2_part_string(1, 30000, 100, 1234, b"\x00" * 100)
|
|
||||||
session = aq._Ur2Session()
|
|
||||||
try:
|
|
||||||
session.add(part)
|
|
||||||
except aq.SessionLimitError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
raise AssertionError("expected SessionLimitError for oversized seq_len")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# BC-UR v1
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_v1_multipart_roundtrip():
|
|
||||||
payload = ("v1 transfer payload " * 6).encode()
|
|
||||||
frames = aq.ur1_frames(payload, 120)
|
|
||||||
assert len(frames) > 1
|
|
||||||
session = aq.AnimatedQrSession()
|
|
||||||
for frame in reversed(frames):
|
|
||||||
session.add_part(frame)
|
|
||||||
assert session.done
|
|
||||||
assert session.resolve()[0] == payload.decode()
|
|
||||||
|
|
||||||
|
|
||||||
def test_v1_single_part_roundtrip():
|
|
||||||
payload = b"hello, bal"
|
|
||||||
frames = aq.ur1_frames(payload, 400)
|
|
||||||
assert len(frames) == 1
|
|
||||||
session = aq.AnimatedQrSession()
|
|
||||||
session.add_part(frames[0])
|
|
||||||
assert session.done and session.total == 1
|
|
||||||
assert session.resolve()[0] == payload.decode()
|
|
||||||
|
|
||||||
|
|
||||||
def test_v1_headerless_single_part_import():
|
|
||||||
# bcr-2020-005 rev1 allows omitting the sequence header + digest entirely.
|
|
||||||
payload = b"hello, bal"
|
|
||||||
message = aq.cbor_byte_string(payload)
|
|
||||||
single = "ur:bytes/" + aq.bc32_encode(message)
|
|
||||||
assert aq.detect_format(single) == "ur1"
|
|
||||||
session = aq.AnimatedQrSession()
|
|
||||||
session.add_part(single)
|
|
||||||
assert session.done
|
|
||||||
assert session.resolve()[0] == payload.decode()
|
|
||||||
|
|
||||||
|
|
||||||
def test_v1_digest_mismatch_rejected():
|
|
||||||
frame = aq.ur1_frames(b"hello, bal", 400)[0]
|
|
||||||
tampered = frame[:-4] + "abcd"
|
|
||||||
session = aq.AnimatedQrSession()
|
|
||||||
session.add_part(tampered)
|
|
||||||
try:
|
|
||||||
session.resolve()
|
|
||||||
except aq.ChecksumError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
raise AssertionError("expected ChecksumError for a tampered v1 digest")
|
|
||||||
|
|
||||||
|
|
||||||
def test_v1_part_numbers_validated():
|
|
||||||
for bad in (
|
|
||||||
"ur:bytes/0of1/{}full".format("x" * 51),
|
|
||||||
"ur:bytes/2of1/{}full".format("x" * 51),
|
|
||||||
"ur:bytes/1of0/{}full".format("x" * 51),
|
|
||||||
"ur:bytes/1aof1/{}full".format("x" * 51),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
aq.ur1_parse_part(bad)
|
|
||||||
except aq.AnimatedQrError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
raise AssertionError("expected AnimatedQrError for: {}".format(bad))
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# 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_all_formats():
|
|
||||||
assert aq.detect_format("BALQR1|1|1||payload") == "balqr"
|
|
||||||
assert aq.detect_format("BAL1" + "001" + "001" + "0" + "payload") == "balqr"
|
|
||||||
assert aq.detect_format(aq.ur1_frames(b"x", 400)[0]) == "ur1"
|
|
||||||
assert aq.detect_format(aq.ur2_frames(b"x", 400)[0]) == "ur2"
|
|
||||||
assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "bbqr"
|
|
||||||
assert aq.detect_format(REF_V2_SINGLE_12) == "ur2"
|
|
||||||
assert aq.detect_format("ur:bytes/" + aq.bc32_encode(aq.cbor_byte_string(b"x"))) == "ur1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_format_rejects_garbage():
|
|
||||||
for text in ("", "hello world", "BALQ|1|1||a", "ur:", "ur:txn/xyz"):
|
|
||||||
assert aq.detect_format(text) is None, text
|
|
||||||
# Lenient prefix probe: a string that merely *starts* with "balqr" is
|
|
||||||
# reported as balqr (the strict parse then rejects it downstream).
|
|
||||||
assert aq.detect_format("BALQRX|1|1||a") == "balqr"
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_for_detection_keys():
|
|
||||||
bal = aq.parse_for_detection("BALQR1|3|2||payload")
|
|
||||||
assert bal == ("balqr", "balqr:3", 3, 2)
|
|
||||||
# Compact v2 frame (fixed 11-char header) is detected too.
|
|
||||||
bal_v2 = aq.parse_for_detection("BAL1" + "007" + "004" + "0" + "payload")
|
|
||||||
assert bal_v2 == ("balqr", "balqr:7", 7, 4)
|
|
||||||
v2 = aq.parse_for_detection(aq.ur2_frames(b"x"*50, 400)[0])
|
|
||||||
assert v2[0] == "ur2" and v2[2] == 1 and v2[3] == 1
|
|
||||||
v1 = aq.parse_for_detection(aq.ur1_frames(b"x"*50, 120)[0])
|
|
||||||
assert v1[0] == "ur1" and v1[2] > 1 and 1 <= v1[3] <= v1[2]
|
|
||||||
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():
|
|
||||||
for fmt in ("balqr", "ur1", "ur2", "bbqr"):
|
|
||||||
assert aq.format_name(fmt)
|
|
||||||
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")
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for ``bal.core.checkalive`` (pure, GUI-free).
|
|
||||||
|
|
||||||
Covers the CheckAliveError exception and the BASIC/ADVANCED date_to_check
|
|
||||||
policy (``resolve_date_to_check`` / ``check_alive_expired``).
|
|
||||||
|
|
||||||
Run:
|
|
||||||
source "$BAL_HOME/electrum/env/bin/activate"
|
|
||||||
python3 tests/test_core_checkalive.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
|
||||||
|
|
||||||
from bal.core.checkalive import ( # noqa: E402 (path insert above)
|
|
||||||
CheckAliveError,
|
|
||||||
check_alive_expired,
|
|
||||||
resolve_date_to_check,
|
|
||||||
resolve_guard_threshold,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# CheckAliveError
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
|
|
||||||
def test_check_alive_error_default():
|
|
||||||
err = CheckAliveError(1000000)
|
|
||||||
assert err.timestamp_to_check == 1000000
|
|
||||||
|
|
||||||
|
|
||||||
def test_check_alive_error_str():
|
|
||||||
err = CheckAliveError(1000000)
|
|
||||||
s = str(err)
|
|
||||||
assert "Check alive expired" in s
|
|
||||||
assert "1970" in s
|
|
||||||
|
|
||||||
|
|
||||||
def test_check_alive_error_subclass():
|
|
||||||
assert issubclass(CheckAliveError, Exception)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# resolve_date_to_check
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
|
|
||||||
def test_basic_mode_uses_now():
|
|
||||||
fake_now = 1_800_000_000.0
|
|
||||||
assert resolve_date_to_check(True, {}, now=fake_now) == fake_now
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_uses_threshold_absolute():
|
|
||||||
threshold = time.time() + 5 * 86400
|
|
||||||
settings = {"threshold": threshold}
|
|
||||||
result = resolve_date_to_check(False, settings)
|
|
||||||
assert abs(result - threshold) < 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_parses_relative_threshold():
|
|
||||||
# A relative threshold means "N days BEFORE the delivery": it resolves
|
|
||||||
# against the stored locktime (backwards), not forward from now.
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
fake_now = 1_800_000_000.0
|
|
||||||
locktime = fake_now + 90 * 86400
|
|
||||||
settings = {"threshold": "30d", "locktime": locktime}
|
|
||||||
result = resolve_date_to_check(False, settings, now=fake_now)
|
|
||||||
# date_to_check = (locktime, midnight-normalised) - 30 days.
|
|
||||||
expected = (datetime.fromtimestamp(locktime, tz=timezone.utc)
|
|
||||||
.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
- timedelta(days=30)).timestamp()
|
|
||||||
assert abs(result - expected) < 1
|
|
||||||
# 90d delivery with a 30d window: the window starts 60 days after now.
|
|
||||||
assert result > fake_now
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_relative_threshold_anchored_to_locktime():
|
|
||||||
"""A relative threshold never drifts with the clock: re-resolving it a day
|
|
||||||
later, with the same fixed absolute locktime, yields the same date."""
|
|
||||||
fake_now = 1_800_000_000.0
|
|
||||||
locktime = fake_now + 90 * 86400
|
|
||||||
settings = {"threshold": "30d", "locktime": locktime}
|
|
||||||
first = resolve_date_to_check(False, settings, now=fake_now)
|
|
||||||
# Next day: same stored settings (the fixed delivery), a later clock.
|
|
||||||
second = resolve_date_to_check(False, settings, now=fake_now + 86400)
|
|
||||||
assert first == second
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_relative_threshold_with_relative_locktime():
|
|
||||||
"""A relative locktime is resolved against 'now' first, then the relative
|
|
||||||
threshold counts N days back from it (matches the settings widget)."""
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from bal.core.plugin_base import BalTimestamp
|
|
||||||
|
|
||||||
fake_now = 1_800_000_000.0
|
|
||||||
settings = {"threshold": "30d", "locktime": "90d"}
|
|
||||||
result = resolve_date_to_check(False, settings, now=fake_now)
|
|
||||||
# Recompute the expected value with the same resolution rules:
|
|
||||||
# locktime = now + 90d (midnight-normalised), threshold = locktime - 30d.
|
|
||||||
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc))
|
|
||||||
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
|
|
||||||
assert abs(result - expected) < 1
|
|
||||||
assert result > fake_now
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_relative_threshold_no_locktime_falls_back():
|
|
||||||
# Without a locktime reference, fall back to the legacy forward resolution.
|
|
||||||
settings = {"threshold": "30d"}
|
|
||||||
result = resolve_date_to_check(False, settings)
|
|
||||||
assert result > time.time()
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_relative_locktime_anchored_to_built_tx():
|
|
||||||
"""A RELATIVE stored locktime is anchored to the built will's frozen
|
|
||||||
delivery date (built_locktime), not to "now": an unchanged will must not
|
|
||||||
read as expired as the clock advances (the karen7 daily-invalidate bug)."""
|
|
||||||
frozen = 1817424000 # frozen tx locktime (2027-08-05 00:00 UTC), built 2026-08-05
|
|
||||||
settings = {"threshold": "30d", "locktime": "2y"}
|
|
||||||
# On build day the frozen delivery is authoritative: date_to_check is
|
|
||||||
# frozen - 30d and NEVER drifts, however much later the clock gets.
|
|
||||||
first = resolve_date_to_check(
|
|
||||||
False, settings, now=1_800_000_000.0, built_locktime=frozen
|
|
||||||
)
|
|
||||||
assert abs(first - (frozen - 30 * 86400)) < 1
|
|
||||||
later = resolve_date_to_check(
|
|
||||||
False, settings, now=1_800_000_000.0 + 10 * 86400, built_locktime=frozen
|
|
||||||
)
|
|
||||||
assert first == later
|
|
||||||
# The check window must start BEFORE the frozen delivery (never expired).
|
|
||||||
assert first < frozen
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
|
|
||||||
"""Without a built will there is no anchor: keeps the legacy now-based
|
|
||||||
resolution (a moving target, used only before the first build)."""
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from bal.core.plugin_base import BalTimestamp
|
|
||||||
|
|
||||||
fake_now = 1_800_000_000.0
|
|
||||||
settings = {"threshold": "30d", "locktime": "90d"}
|
|
||||||
result = resolve_date_to_check(False, settings, now=fake_now)
|
|
||||||
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc))
|
|
||||||
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).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."""
|
|
||||||
from bal.core.plugin_base import BalTimestamp
|
|
||||||
|
|
||||||
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_relative_fresh_anchor():
|
|
||||||
"""A relative threshold must be anchored to the FRESH locktime so the
|
|
||||||
guard and the settings always share one reference frame.
|
|
||||||
|
|
||||||
Regression for the false positive where a still-valid built will frozen at
|
|
||||||
a LONGER delivery ("2y") anchored ``date_to_check`` beyond the currently
|
|
||||||
stored shorter delivery ("1y"): the old guard compared the fresh "1y"
|
|
||||||
locktime against that anchored threshold and wrongly fired "locktime is
|
|
||||||
lower than threshold", even though the settings themselves are consistent
|
|
||||||
(locktime is 30d AFTER the threshold).
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
# The helper takes no built anchor: a frozen "2y" built will must NOT
|
|
||||||
# contaminate the result, although resolve_date_to_check (the expiry
|
|
||||||
# reference) legitimately keeps using it.
|
|
||||||
frozen_two_years = locktime + 365 * 86400
|
|
||||||
anchored = resolve_date_to_check(
|
|
||||||
False, settings, now=fake_now, built_locktime=frozen_two_years
|
|
||||||
)
|
|
||||||
assert anchored > threshold # built anchor pushes date_to_check forward...
|
|
||||||
assert locktime < anchored # ...which is exactly what used to fire the bug
|
|
||||||
|
|
||||||
|
|
||||||
def test_guard_threshold_relative_locktime_absolute_threshold():
|
|
||||||
fake_now = 1_800_000_000.0
|
|
||||||
threshold = fake_now + 200 * 86400
|
|
||||||
settings = {"locktime": "1y", "threshold": threshold}
|
|
||||||
assert resolve_guard_threshold(False, settings, now=fake_now) == threshold
|
|
||||||
# "1y" from now is later than the stored absolute threshold: allowed.
|
|
||||||
locktime = _guard_locktime(settings, fake_now)
|
|
||||||
assert locktime > threshold
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# check_alive_expired
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
|
|
||||||
def test_basic_mode_never_expired():
|
|
||||||
assert check_alive_expired(True, 1_000_000_000.0) is False
|
|
||||||
assert check_alive_expired(True, time.time() - 10_000) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_expired_when_past():
|
|
||||||
assert check_alive_expired(False, time.time() - 10_000) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_not_expired_when_future():
|
|
||||||
assert check_alive_expired(False, time.time() + 10_000) is False
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Main
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
for name in sorted(dir()):
|
|
||||||
if name.startswith("test_"):
|
|
||||||
globals()[name]()
|
|
||||||
print(f" [OK] {name}")
|
|
||||||
print("[OK] All core checkalive tests passed")
|
|
||||||
@@ -9,34 +9,22 @@ Run:
|
|||||||
python3 tests/test_core_heirs.py
|
python3 tests/test_core_heirs.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.heirs import (
|
from bal.core.heirs import (
|
||||||
HEIR_ADDRESS,
|
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
|
||||||
HEIR_AMOUNT,
|
HEIR_DUST_AMOUNT, TRANSACTION_LABEL,
|
||||||
HEIR_DUST_AMOUNT,
|
|
||||||
HEIR_LOCKTIME,
|
|
||||||
HEIR_REAL_AMOUNT,
|
|
||||||
OP_RETURN_PREFIX,
|
|
||||||
TRANSACTION_LABEL,
|
|
||||||
AliasNotFoundException,
|
|
||||||
AmountNotValid,
|
|
||||||
BalanceTooLowException,
|
|
||||||
HeirAmountIsDustException,
|
|
||||||
Heirs,
|
|
||||||
LocktimeNotValid,
|
|
||||||
NoHeirsException,
|
|
||||||
NotAnAddress,
|
|
||||||
WillExecutorFeeException,
|
|
||||||
create_op_return_script,
|
create_op_return_script,
|
||||||
get_op_return_hex,
|
AliasNotFoundException,
|
||||||
is_op_return_address,
|
NotAnAddress, AmountNotValid, LocktimeNotValid,
|
||||||
validate_op_return_hex,
|
HeirExpiredException, HeirAmountIsDustException,
|
||||||
|
NoHeirsException, WillExecutorFeeException,
|
||||||
|
BalanceTooLowException,
|
||||||
|
Heirs,
|
||||||
)
|
)
|
||||||
from bal.core.util import Util
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Constants
|
# Constants
|
||||||
@@ -80,7 +68,7 @@ def test_op_return_empty():
|
|||||||
def test_op_return_too_big():
|
def test_op_return_too_big():
|
||||||
try:
|
try:
|
||||||
create_op_return_script("ab" * 81) # 81 bytes > max 80
|
create_op_return_script("ab" * 81) # 81 bytes > max 80
|
||||||
raise AssertionError("expected ValueError")
|
assert False, "expected ValueError"
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -168,32 +156,6 @@ 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)
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -215,13 +177,13 @@ def test_validate_amount():
|
|||||||
# Invalid
|
# Invalid
|
||||||
try:
|
try:
|
||||||
Heirs.validate_amount("0.000000001")
|
Heirs.validate_amount("0.000000001")
|
||||||
raise AssertionError("expected AmountNotValid")
|
assert False, "expected AmountNotValid"
|
||||||
except AmountNotValid:
|
except AmountNotValid:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
Heirs.validate_amount("-1")
|
Heirs.validate_amount("-1")
|
||||||
raise AssertionError("expected AmountNotValid")
|
assert False, "expected AmountNotValid"
|
||||||
except AmountNotValid:
|
except AmountNotValid:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -245,7 +207,7 @@ def test_validate_locktime_expired():
|
|||||||
past = int(time.time()) - 86400 # yesterday
|
past = int(time.time()) - 86400 # yesterday
|
||||||
try:
|
try:
|
||||||
Heirs.validate_locktime(str(past), timestamp_to_check=past + 1)
|
Heirs.validate_locktime(str(past), timestamp_to_check=past + 1)
|
||||||
raise AssertionError("expected LocktimeNotValid")
|
assert False, "expected LocktimeNotValid"
|
||||||
except LocktimeNotValid:
|
except LocktimeNotValid:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -296,99 +258,9 @@ def test_validate_removes_invalid():
|
|||||||
assert "alice" in result or True # may or may not pass address check
|
assert "alice" in result or True # may or may not pass address check
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# OP_RETURN helpers
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def test_op_return_prefix_constant():
|
|
||||||
assert OP_RETURN_PREFIX == "OP_RETURN:"
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_op_return_address():
|
|
||||||
assert is_op_return_address("OP_RETURN:48656c6c6f")
|
|
||||||
assert not is_op_return_address("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq")
|
|
||||||
assert not is_op_return_address("")
|
|
||||||
assert not is_op_return_address("OP_RETURN")
|
|
||||||
assert not is_op_return_address("OP_RETURNX:")
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_op_return_hex():
|
|
||||||
assert get_op_return_hex("OP_RETURN:48656c6c6f") == "48656c6c6f"
|
|
||||||
assert get_op_return_hex("bc1q...") is None
|
|
||||||
assert get_op_return_hex("") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_op_return_hex_valid():
|
|
||||||
validate_op_return_hex("48656c6c6f")
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_op_return_hex_invalid():
|
|
||||||
try:
|
|
||||||
validate_op_return_hex("nothex!!")
|
|
||||||
raise AssertionError("expected NotAnAddress")
|
|
||||||
except NotAnAddress:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_op_return_hex_too_long():
|
|
||||||
try:
|
|
||||||
validate_op_return_hex("ab" * 81)
|
|
||||||
raise AssertionError("expected NotAnAddress")
|
|
||||||
except NotAnAddress:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_op_return_hex_empty():
|
|
||||||
validate_op_return_hex("")
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_address_op_return():
|
|
||||||
addr = "OP_RETURN:48656c6c6f"
|
|
||||||
result = Heirs.validate_address(addr)
|
|
||||||
assert result == addr
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_heir_op_return():
|
|
||||||
k = "test_op_return"
|
|
||||||
v = ["OP_RETURN:48656c6c6f", "0", "30d"]
|
|
||||||
result = Heirs.validate_heir(k, v)
|
|
||||||
assert result[0] == "OP_RETURN:48656c6c6f"
|
|
||||||
assert result[1] == "0"
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Heirs class OP_RETURN integration
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def test_heirs_fixed_percent_skips_op_return():
|
|
||||||
class FakeWallet:
|
|
||||||
class FakeDB:
|
|
||||||
def __init__(self):
|
|
||||||
self._data = {}
|
|
||||||
def get(self, key, default=None):
|
|
||||||
return self._data.get(key, default)
|
|
||||||
def put(self, key, value):
|
|
||||||
self._data[key] = value
|
|
||||||
def __init__(self):
|
|
||||||
self.db = self.FakeDB()
|
|
||||||
self.dust_threshold = lambda: 500
|
|
||||||
wallet = FakeWallet()
|
|
||||||
heirs = Heirs(wallet)
|
|
||||||
heirs["op_ret"] = ["OP_RETURN:48656c6c6f", "0", "9999999999"]
|
|
||||||
heirs["normal"] = ["addr1", "10000", "9999999999"]
|
|
||||||
fixed_h, fixed_amt, perc_h, perc_amt, fixed_with_dust = (
|
|
||||||
heirs.fixed_percent_lists_amount(0, 500)
|
|
||||||
)
|
|
||||||
assert "op_ret" in fixed_h
|
|
||||||
assert "normal" in fixed_h
|
|
||||||
assert fixed_h["op_ret"][HEIR_REAL_AMOUNT] == 0
|
|
||||||
assert fixed_h["normal"][HEIR_REAL_AMOUNT] == 10000
|
|
||||||
assert fixed_amt == 10000 # OP_RETURN adds 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
for name in sorted(dir()):
|
for name in sorted(dir()):
|
||||||
if name.startswith("test_"):
|
if name.startswith("test_"):
|
||||||
globals()[name]()
|
globals()[name]()
|
||||||
print(f" [OK] {name}")
|
print(f" [OK] {name}")
|
||||||
print("[OK] All heirs tests passed")
|
print(f"[OK] All heirs tests passed")
|
||||||
|
|||||||
@@ -8,20 +8,19 @@ Run:
|
|||||||
QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py
|
QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.heirs import (
|
from bal.core.heirs import (
|
||||||
HEIR_AMOUNT,
|
Heirs, create_op_return_script, reduce_outputs,
|
||||||
Heirs,
|
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
|
||||||
create_op_return_script,
|
|
||||||
reduce_outputs,
|
|
||||||
)
|
)
|
||||||
from bal.core.willexecutors import Willexecutors
|
from bal.core.willexecutors import Willexecutors
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Heirs db-dependent methods
|
# Heirs db-dependent methods
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -140,7 +139,6 @@ def test_prepare_lists_mixed_dust_continues():
|
|||||||
"dust": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", "1%", "30d"],
|
"dust": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", "1%", "30d"],
|
||||||
})
|
})
|
||||||
raised = False
|
raised = False
|
||||||
result = None
|
|
||||||
try:
|
try:
|
||||||
result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
|
result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
|
||||||
except HeirAmountIsDustException:
|
except HeirAmountIsDustException:
|
||||||
@@ -167,7 +165,6 @@ def test_prepare_lists_multi_locktime_continues():
|
|||||||
"late_valid": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 5000, "60d"],
|
"late_valid": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 5000, "60d"],
|
||||||
})
|
})
|
||||||
raised = False
|
raised = False
|
||||||
result = None
|
|
||||||
try:
|
try:
|
||||||
result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
|
result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
|
||||||
except HeirAmountIsDustException:
|
except HeirAmountIsDustException:
|
||||||
@@ -191,7 +188,7 @@ def test_validate_address_invalid():
|
|||||||
from bal.core.heirs import NotAnAddress
|
from bal.core.heirs import NotAnAddress
|
||||||
try:
|
try:
|
||||||
Heirs.validate_address("bad")
|
Heirs.validate_address("bad")
|
||||||
raise AssertionError("should have raised")
|
assert False, "should have raised"
|
||||||
except NotAnAddress:
|
except NotAnAddress:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user