Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
4a33116b66
|
|||
|
fb88d7540c
|
|||
| 9c4697c923 | |||
| 42f05d3c4f | |||
| 3a9ee5adb9 | |||
| 2c9f6bdd9d | |||
| 4dfb3fc41c | |||
| 1ae1617172 | |||
| 125bf09b9a | |||
| b5752a42f0 | |||
| ce659048ca | |||
| c80f0e1524 | |||
| e8e4d5e451 | |||
| e74c348bbb | |||
| fdcab7c6bd | |||
| eac03da530 | |||
| 167d1228b2 | |||
| 8649556a18 | |||
| 5e53a87bb6 | |||
| 251fd93cbd | |||
| bdeef3dc6a | |||
| 6f7a364bc6 | |||
| a1b13947c3 | |||
| 6e0ae7e4ed | |||
| 9275e9c6a2 | |||
| 658e3dca0b | |||
| c82b5dda37 | |||
| deca1a548e | |||
| db573aa1b3 | |||
| 326b3f6f76 | |||
| 943731c070 | |||
|
|
40675ac986 | ||
|
|
73be102f7c | ||
| 37a417ae59 |
30
.gitignore
vendored
30
.gitignore
vendored
@@ -4,3 +4,33 @@
|
||||
bal-electrum-plugin.zip
|
||||
electrum-src/
|
||||
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
|
||||
|
||||
231
.opencode/plans/plan_android_reader.md
Normal file
231
.opencode/plans/plan_android_reader.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# PLAN_ANDROID_READER.md — BAL Reader: an Android QR will reader
|
||||
|
||||
**Date:** 2026-09-09 · **Status:** proposed · **Ties into:** BAL plugin QR export (BALQR / BC-UR v1 / BC-UR v2 / BBQR)
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Ship a minimal single-activity Android app that reads any QR will exported by the BAL
|
||||
Electrum plugin, decodes all four supported wire formats, and lets the user view, copy,
|
||||
share, or save the recovered will data. The app is a **reader only** — it never signs or
|
||||
broadcasts.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
**In scope**
|
||||
|
||||
- Continuous camera capture (CameraX + ML Kit on-device barcode scanning, QR-only).
|
||||
- Auto-detection of the four formats (`detect_format`: `"balqr" / "ur1" / "ur2" / "bbqr"`).
|
||||
- Order-independent frame assembly: duplicates ignored, out-of-order accepted, UR v2
|
||||
XOR-fountain redundancy exploited, session-level reset on transfer switch.
|
||||
- Whole-will JSON **and** tx-hex-list payloads, both decoded to a readable view.
|
||||
- Copy raw transfer text / Share / Save via Storage Access Framework.
|
||||
- Verified decode chain runnable on the dev machine (no Android needed).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Signing, broadcasting, password handling, wallet integration.
|
||||
- Export/authoring QR codes *from* the phone.
|
||||
- Audio-modem transport (unchanged, plugin-only).
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 Why Chaquopy (reuse the tested Python codecs)
|
||||
|
||||
`bal/core/animated_qr.py` (1178 lines) and `bal/core/qrtransfer.py` (212 lines) are
|
||||
**pure stdlib** (`base64`, `hashlib`, `zlib`), GUI-free, Python-3.8-clean (verified: no
|
||||
walrus/match/`X|Y`/PEP-585 generics), and `bal/core/__init__.py` is an empty docstring.
|
||||
Chaquopy bundles CPython into the APK, so **the exact, battle-tested codec modules run
|
||||
unchanged on Android** with zero port risk. The Kotlin side is only camera glue + UI.
|
||||
|
||||
### 3.2 Decode chain (mirror of the plugin's import tail)
|
||||
|
||||
The plugin's `_review_and_sign` (dialogs.py:3792) does exactly:
|
||||
|
||||
1. `session.resolve()` -> `(transfer_text, compressed: bool)`
|
||||
(UR v1/v2/BBQR for the first pair; `(text, flag)` for BAL QR).
|
||||
2. `decode_transfer(transfer_text, compressed)` -> list of parts (tx hexes, or the single
|
||||
whole-will JSON).
|
||||
3. `"\n".join(parts)` -> opaque payload.
|
||||
4. `decode_will_payload(payload)` -> `("will", dict)` **or** `("txs", [strings])`.
|
||||
|
||||
The app reuses all of it verbatim through the `AnimatedQrSession` facade (`add_part`
|
||||
auto-detects per frame, dedups, tracks `received/total/done`).
|
||||
|
||||
### 3.3 Data flow
|
||||
|
||||
```
|
||||
CameraX ImageAnalysis -> ML Kit BarcodeScanning (QR) -> raw string
|
||||
-> BalDecoder.add(text) [Chaquopy -> AnimatedQrSession.add_part]
|
||||
-> status: format chip + received/total, duplicate-tolerant
|
||||
-> when done: auto-stop -> BalDecoder.finish() (4-step decode)
|
||||
-> ResultActivity: JSON view OR tx-list view + Copy/Share/Save
|
||||
```
|
||||
|
||||
## 4. Repository layout (new `android/` subfolder)
|
||||
|
||||
```
|
||||
android/
|
||||
README.md # build (Android Studio), usage, codec-sync rule, MIT note
|
||||
settings.gradle.kts
|
||||
build.gradle.kts # root: plugins (AGP 8.10, Kotlin 2.0.21, Chaquopy 17, apply false)
|
||||
gradle.properties
|
||||
gradlew, gradlew.bat
|
||||
gradle/wrapper/ # gradle-wrapper.properties + gradle-wrapper.jar (fetched; see §7)
|
||||
scripts/
|
||||
sync_codecs.py # copy bal/core/{__init__,animated_qr,qrtransfer}.py -> app python dir; import-check
|
||||
test_chain/
|
||||
verify_chain.py # full decode-chain simulation, runs on dev machine
|
||||
app/
|
||||
build.gradle.kts # com.android.application + com.chaquo.python; CameraX/ML Kit deps
|
||||
src/main/
|
||||
AndroidManifest.xml # CAMERA permission
|
||||
java/life/after/bitcoin/
|
||||
MainActivity.kt # permission + PreviewView + ImageAnalysis + status header
|
||||
BalDecoder.kt # Chaquopy bridge (§5.1)
|
||||
ResultActivity.kt # viewer + copy/share/save (§5.3)
|
||||
res/layout/activity_main.xml, activity_result.xml
|
||||
res/values/strings.xml
|
||||
python/bal/core/ # SYNCED COPIES (committed, deterministic; regenerate with script)
|
||||
__init__.py
|
||||
animated_qr.py
|
||||
qrtransfer.py
|
||||
```
|
||||
|
||||
## 5. Component specifications
|
||||
|
||||
### 5.1 `BalDecoder` (Kotlin, Chaquopy bridge)
|
||||
|
||||
- Lazy init: `Python.start(AndroidPlatform(context))`, `getModule("bal.core.animated_qr")`.
|
||||
- `fun add(text: String): String` -> `session.add_part(text)`; surfaces `"ok"`/`"dup"`;
|
||||
maps `AnimatedQrError` subclasses to a result the UI can ignore (garbage frames) vs
|
||||
reset (transfer switch -> `TransferConflictError` -> tell user to rescan).
|
||||
- `val format: String?` (`"balqr"/"ur1"/"ur2"/"bbqr"`), `val received: Int`,
|
||||
`val total: Int`, `val done: Boolean` (auto-converted by Chaquopy).
|
||||
- `fun finish(): DecodedResult` - the 4-step chain; returns
|
||||
`data class DecodedResult(kind: "will"|"txs", data: Map<String,Any>|List<String>, rawTransfer: String)`.
|
||||
- `fun reset()` -> new `AnimatedQrSession` (new scan).
|
||||
|
||||
### 5.2 `MainActivity` (camera + scan loop)
|
||||
|
||||
- Launches CameraX via `ProcessCameraProvider`; `PreviewView` fills screen; camera
|
||||
permission via `ActivityResultContracts.RequestPermission`.
|
||||
- `ImageAnalysis` `STRATEGY_KEEP_ONLY_LATEST`; analyzer throttled (~100 ms) calls ML Kit
|
||||
`BarcodeScanning` with `Barcode.FORMAT_QR_CODE`.
|
||||
- Thread-safe feed to `BalDecoder` (analyzer runs on a background executor); UI status
|
||||
via `runOnUiThread`.
|
||||
- Header row: format chip + `received/total`; on `done` -> stop analyzer -> launch
|
||||
`ResultActivity` (results as Parcelable); "New scan" restarts.
|
||||
- Garbage / incomplete frames silently ignored (same policy as the plugin); a
|
||||
`TransferConflictError` mid-scan resets the session and signals the user to rescan.
|
||||
|
||||
### 5.3 `ResultActivity` (viewer)
|
||||
|
||||
- `kind == "will"`: whole-will JSON - each item's `tx` hex shown truncated with full view
|
||||
on demand.
|
||||
- `kind == "txs"`: list of tx hexes.
|
||||
- Action bar: **Copy** (raw transfer text to clipboard), **Share** (ACTION_SEND
|
||||
text/plain), **Save** (SAF `ACTION_CREATE_DOCUMENT` -> `will.json` / `will_tx.txt`).
|
||||
- "Scan another" button -> finish -> back to camera.
|
||||
|
||||
### 5.4 `scripts/sync_codecs.py`
|
||||
|
||||
- Copies the 3 files from `bal/core/` -> `app/src/main/python/bal/core/` (idempotent).
|
||||
- Post-copy check (dev machine): import `bal.core.animated_qr`, run one UR v2 frame +
|
||||
decode cycle to prove the copy imports standalone.
|
||||
- Documented as the rule after any codec change (README).
|
||||
|
||||
### 5.5 `test_chain/verify_chain.py`
|
||||
|
||||
Simulates the exact APK runtime path on the dev machine (no Android). Generates frames
|
||||
precisely as the export page does, then feeds `AnimatedQrSession.add_part` in
|
||||
scrambled/duplicated/dropped order and asserts correct results for:
|
||||
|
||||
- BAL QR multi-frame, plain **and** compressed (`Z` flag): `split_frames(encode_transfer(...))`.
|
||||
- UR v1 single-part (`ur1_frames` headerless) and `1ofN` multipart.
|
||||
- UR v2 single-part and fountain multipart with >=1 frame dropped and >=1 duplicated
|
||||
(exercises the XOR solve).
|
||||
- BBQR `Z`/`H`/`2`: `bbqr_frames(..., encoding=...)`, with the `Z` auto-decompress branch.
|
||||
- payload kinds: whole-will JSON **and** tx-hex list.
|
||||
- transfer-switch: feed a frame of a different transfer mid-session -> expect conflict, as
|
||||
the UI will.
|
||||
|
||||
Runs under the runtime venv:
|
||||
`source .../electrum/env/bin/activate && QT_QPA_PLATFORM=offscreen python3 android/test_chain/verify_chain.py`
|
||||
|
||||
## 6. Wire-format reference (bundled codecs)
|
||||
|
||||
- **BAL QR**: `BALQR1|<total>|<index>|<flags>|<payload>`, flags `""` or `Z` (zlib+base64).
|
||||
Concatenate payloads 1..total -> transfer string -> `decode_transfer` splits on `\n`.
|
||||
- **BC-UR v1**: multipart `ur:bytes/<seq>of<seq_len>/<sha256-bc32-digest>/<bc32-frag>`;
|
||||
single-part `ur:bytes/<bc32>` (digest-less). BC32 = bech32_bis (XOR `0x3FFFFFFF`)
|
||||
5-bit alphabet.
|
||||
- **BC-UR v2**: multipart `ur:bytes/<seq>-of-<seq_len>/<bytewords-minimal-part>` +
|
||||
single-part headerless; part body = CBOR `[seq, seq_len, msg_len, crc32, data]` +
|
||||
per-part CRC-32, bytewords-minimal; fountain via `choose_fragments` (xoshiro256** +
|
||||
alias/threshold), mixed by XOR - decoder solves from any sufficient subset.
|
||||
- **BBQR**: `B$<encoding><type><base36 total><base36 index><payload>`; encodings `H`
|
||||
(upper hex), `2` (base32nal), `Z` (deflate `wbits=-10` -> base32).
|
||||
|
||||
## 7. Toolchain & versions
|
||||
|
||||
| Item | Version | Notes |
|
||||
|---|---|---|
|
||||
| Chaquopy | 17.0.0 | Python 3.10-3.14, AGP 7.3-9.2, minSdk 24 |
|
||||
| AGP | 8.10.0 | in Chaquopy 17 range |
|
||||
| Gradle wrapper | 8.14 | required by AGP 8.10 |
|
||||
| Kotlin | 2.0.21 | |
|
||||
| compile/target SDK | 35 / min 24 | |
|
||||
| JDK | 17 (machine has OpenJDK 17) | |
|
||||
| CameraX | 1.3.4 | `camera-core`, `camera-camera2`, `camera-lifecycle`, `camera-view` |
|
||||
| ML Kit barcode | 17.3.0 | on-device, no API key |
|
||||
| Python (codec) | 3.12 (Chaquopy) | codecs verified 3.8-clean |
|
||||
|
||||
Dev machine: Android Studio + JDK 17 present; SDK platforms/build-tools/gradle absent ->
|
||||
the **first real APK build happens in Android Studio with network**. `gradle-wrapper.jar`
|
||||
is fetched via `curl` (canonical location) so `./gradlew` works; if network is blocked,
|
||||
README documents Android Studio regenerating it.
|
||||
|
||||
## 8. Build & run outline (for README)
|
||||
|
||||
1. Open `android/` in Android Studio (or `./gradlew assembleDebug`).
|
||||
2. Allow Gradle to fetch wrapper/deps (network).
|
||||
3. Install on device; grant camera permission.
|
||||
4. In the plugin: export dialog -> pick format (start with BAL QR default; also test
|
||||
UR v1/UR v2/BBQR) -> show the animated QR on screen.
|
||||
5. Point camera at screen; watch `received/total`; decoded result appears ->
|
||||
Copy/Share/Save.
|
||||
|
||||
## 9. Verification
|
||||
|
||||
**On this machine (no Android needed)**
|
||||
|
||||
1. `python3 android/scripts/sync_codecs.py` -> copy + import/roundtrip sanity.
|
||||
2. `QT_QPA_PLATFORM=offscreen python3 android/test_chain/verify_chain.py` -> all
|
||||
formats/payload kinds/conflict cases pass.
|
||||
3. `ruff check android/scripts/sync_codecs.py android/test_chain/verify_chain.py` -> clean.
|
||||
4. `pytest tests/test_core_*.py tests/test_gui_*.py` -> still **445 passed**
|
||||
(payload code untouched; only new files).
|
||||
5. `python3 build_zip.py` unaffected (no change under `bal/`).
|
||||
|
||||
**On-device (manual, user)**
|
||||
|
||||
- Walk the 4 formats against the plugin's export page, single- and multi-frame
|
||||
(animated), on a real phone.
|
||||
- Confirm format chip, progress, auto-finish, Copy/Share/Save.
|
||||
|
||||
**Boundary** - no APK is produced by this machine's environment; the artifact is a
|
||||
complete, independently buildable source tree + verified codec path.
|
||||
|
||||
## 10. Risks & notes
|
||||
|
||||
- First Gradle sync needs network (deps + wrapper). Pinned versions are conservative;
|
||||
knobs documented.
|
||||
- Bundled codec copies must be regenerated after any `bal/core/animated_qr.py` /
|
||||
`qrtransfer.py` change - handled by `sync_codecs.py` + README note (copies are
|
||||
committed for deterministic builds).
|
||||
- Animated QR reading depends on the camera catching enough distinct frames (ML Kit
|
||||
analyzer keeps scanning; the session dedups and accepts out-of-order). Slow phone
|
||||
screens / glare may raise time-to-complete - expected, same as the plugin.
|
||||
- App name/package (`life.after.bitcoin`, label "BAL Reader") are placeholders - trivial
|
||||
to change.
|
||||
- MIT: bundled codec files inherit the plugin's MIT license (noted in README).
|
||||
97
AGENTS.md
Normal file
97
AGENTS.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 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).
|
||||
170
AUDIO_MODEM_DEBIAN.md
Normal file
170
AUDIO_MODEM_DEBIAN.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Audio MODEM on Debian — setup & troubleshooting
|
||||
|
||||
How to make the optional **audio channel** of BAL (and Electrum's own
|
||||
`audio_modem` plugin) work on Debian/Ubuntu. The channel lets you send a will
|
||||
to another device as acoustic OFDM tones instead of scanning QR codes.
|
||||
|
||||
Recommended reading before starting: `CHANGELOG.md` entry 56
|
||||
(Audio-environment notes) and `HANDOFF.md` (Dev-box audio prerequisites).
|
||||
|
||||
---
|
||||
|
||||
## 1. What you need (three independent pieces)
|
||||
|
||||
| Piece | Provides | Where it comes from |
|
||||
|--------------------------|--------------------------------------------|--------------------------------------|
|
||||
| `amodem` (Python) | OFDM modulation/demodulation | `pip install amodem` (any venv) |
|
||||
| `libportaudio.so` | sound I/O backend used by `amodem.audio` | Debian package `libportaudio2` (+ dev symlink, see §2) |
|
||||
| Electrum `audio_modem` | the plugin whose `_send`/`_recv` BAL reuses | built into Electrum |
|
||||
|
||||
BAL shows the audio buttons only when the plugin is **enabled** (Tools →
|
||||
Plugins → Audio Modem) and `amodem` is importable.
|
||||
|
||||
> On a headless/CI box there is no speaker/mic, but the channel can still be
|
||||
> verified with the **sink-monitor loopback** in §4.
|
||||
|
||||
---
|
||||
|
||||
## 2. The two line fixes (this is the part everyone forgets)
|
||||
|
||||
Debian ships a versioned `libportaudio.so.2` but **not** the unversioned
|
||||
`libportaudio.so` that old `amodem` code uses, and `amodem` uses NumPy APIs
|
||||
removed in NumPy 2.x. Both fail **silently** (the plugin's `_send` runs the
|
||||
load inside a `WaitingDialog` thread without an `on_error` handler).
|
||||
|
||||
### 2a. PortAudio unversioned symlink
|
||||
|
||||
Install the dev package (creates the unversioned symlink), or create it by
|
||||
hand:
|
||||
|
||||
```bash
|
||||
sudo apt install libportaudio2 libportaudio-dev # preferred
|
||||
# or, without the package:
|
||||
sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 \
|
||||
/usr/lib/x86_64-linux-gnu/libportaudio.so
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
python3 -c "import amodem.audio; print(amodem.audio.Interface(config=None).load('libportaudio.so').call('GetVersionText'))"
|
||||
# b'PortAudio V19...' <-- success
|
||||
```
|
||||
|
||||
> **No-sudo alternative** (fine for one-shot tests): point `LD_LIBRARY_PATH`
|
||||
> at a directory containing a `libportaudio.so` symlink to the `.so.2`:
|
||||
> ```bash
|
||||
> mkdir -p /tmp/portaudio_stub
|
||||
> ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /tmp/portaudio_stub/libportaudio.so
|
||||
> export LD_LIBRARY_PATH=/tmp/portaudio_stub:$LD_LIBRARY_PATH
|
||||
> ```
|
||||
|
||||
### 2b. amodem vs NumPy 2.x (`tostring` removed)
|
||||
|
||||
`amodem` 1.16.0 calls `numpy.ndarray.tostring()`, removed in NumPy 2.x
|
||||
(≥ 2.4.6 dies with `AttributeError` on the first sample write, so **no carrier
|
||||
is ever emitted**). Either pin NumPy < 2, or patch the single line in the
|
||||
installed package:
|
||||
|
||||
```bash
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
python3 -m pip install "numpy<2" # option A (downgrade)
|
||||
# option B (patch; path depends on your site-packages):
|
||||
sed -i "s/sym.astype('int16').tostring()/sym.astype('int16').tobytes()/" \
|
||||
"$BAL_HOME/electrum/env/lib/python3.11/site-packages/amodem/common.py"
|
||||
```
|
||||
|
||||
> This must be done on **every** machine that receives/sends audio (both ends
|
||||
> of the channel use the same code), and again after reinstalling/upgrading
|
||||
> `amodem`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Environment checklist (dev box, already applied)
|
||||
|
||||
These were applied on the current dev box and do NOT need to be re-done:
|
||||
|
||||
- `amodem` installed in the runtime venv (`1.16.0`).
|
||||
- `amodem/common.py` patched `tostring()` → `tobytes()`.
|
||||
- System symlink or `LD_LIBRARY_PATH` stub for `libportaudio.so`.
|
||||
- PulseAudio running; default sink `ALC236 Analog`, default source DMIC.
|
||||
|
||||
Check them in one command:
|
||||
|
||||
```bash
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
python3 - <<'EOF'
|
||||
import amodem, ctypes, numpy, zlib
|
||||
print("amodem", amodem.__version__)
|
||||
print("numpy", numpy.__version__, "(2.x needs the tobytes patch)")
|
||||
import amodem.audio
|
||||
amodem.audio.Interface(config=None).load("libportaudio.so")
|
||||
print("libportaudio.so loaded OK (symlink or LD_LIBRARY_PATH in place)")
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Verifying the channel (no speakers/mic needed)
|
||||
|
||||
Full **send → sink → sink-monitor → recv** round-trip on one machine:
|
||||
|
||||
```bash
|
||||
# 1) route capture at the loop and remember the original source
|
||||
MON="$(pactl get-default-sink).monitor"; ORIG=$(pactl get-default-source)
|
||||
pactl set-default-source "$MON"
|
||||
|
||||
# 2) run the round-trip (uses zlib-compressed payload like the plugin)
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
timeout 90 python3 /tmp/opencode/bal_audio_loopback.py
|
||||
# expected: bitrate 1.0 kbps ... send done ... RECV OK
|
||||
|
||||
# 3) restore the original source
|
||||
pactl set-default-source "$ORIG"
|
||||
```
|
||||
|
||||
Any payload you like: `python3 /tmp/opencode/bal_audio_loopback.py "BALQR|1|1|0|hi"`.
|
||||
|
||||
With real speakers + mic instead, skip the `pactl` swapping, put the devices
|
||||
close, keep volumes high, and run the same script.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing through the real GUI
|
||||
|
||||
1. **Tools → (Plugins) → Audio Modem** → enable it. If asked for settings,
|
||||
pick a bitrate: default `slowest()` is ~1.0–1.2 kbps (a ~2 KB will takes
|
||||
~15–20 s of audio); higher bitrates are faster but less robust.
|
||||
2. Wallet A → BAL will list → **Export → QR Codes → Audio…**
|
||||
(the audio transport sends the raw newline-joined tx list, no BAL framing).
|
||||
3. Wallet B → will list → **Import via QR → Audio…** → wait for
|
||||
"Waiting for audio (... kbps)…", a loading cursor while demodulating,
|
||||
then the decoded slots appear → review/sign wizard opens.
|
||||
4. One machine only: apply the §4 monitor trick in the shell where Electrum
|
||||
runs (export plays to the sink; import records from the sink monitor).
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| No sound at all, no error anywhere in the log | `libportaudio.so` not loadable (silent) | §2a symlink or `LD_LIBRARY_PATH` stub |
|
||||
| Sound played, "Timeout waiting for carrier" on the receive end | Capture routed to the wrong device / mic muted / no speakers | §4 monitor trick; `pactl` source check; raise volume; move devices closer |
|
||||
| "Decoding failed" after carrier, no payload | Send side died with numpy `tostring` → nothing modulated | §2b patch or `numpy<2` on BOTH machines |
|
||||
| Buttons "Audio…" missing in BAL dialogs | `audio_modem` disabled in Plugins, or `amodem` not importable in the running venv | Enable plugin; `pip install amodem` |
|
||||
| Audio too long / too slow | 1 kbps default | Raise bitrate in Audio Modem settings dialog |
|
||||
|
||||
---
|
||||
|
||||
## 7. No-sudo quick reference (all commands)
|
||||
|
||||
```bash
|
||||
python3 -m pip install amodem
|
||||
mkdir -p /tmp/portaudio_stub
|
||||
ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /tmp/portaudio_stub/libportaudio.so
|
||||
export LD_LIBRARY_PATH=/tmp/portaudio_stub:$LD_LIBRARY_PATH
|
||||
# numpy >= 2 (one of):
|
||||
pip install "numpy<2" # or patch amodem/common.py tobytes
|
||||
```
|
||||
998
CHANGELOG.md
998
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
49
COMPATIBILITY.md
Normal file
49
COMPATIBILITY.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 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.
|
||||
99
COMPATIBILITY_ROADMAP.md
Normal file
99
COMPATIBILITY_ROADMAP.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Compatibility Roadmap: Multisig and TrustedCoin (2FA) Wallets
|
||||
|
||||
Status document, 2026-07-19. Companion to [`COMPATIBILITY.md`](COMPATIBILITY.md):
|
||||
that file states *what* is supported today; this one explains *why* multisig and
|
||||
TrustedCoin (2FA) wallets are currently unsupported and *how* support can be
|
||||
added.
|
||||
|
||||
## Root cause (common to both)
|
||||
|
||||
BAL currently does two things that are only valid for standard
|
||||
(single-signature) wallets:
|
||||
|
||||
1. **It builds transactions itself** with `PartialTransaction.from_io(...)`
|
||||
(`bal/core/will.py`), bypassing `wallet.make_unsigned_transaction`.
|
||||
2. **It signs with a single call** — `wallet.sign_transaction(tx, password)`
|
||||
(`bal/gui/qt/window.py`, `sign_transactions`) — and considers the will ready
|
||||
when `tx.is_complete()` is true.
|
||||
|
||||
On a standard wallet one signature completes the transaction. On multisig and
|
||||
2FA wallets **one signature is not enough**: the transaction stays incomplete,
|
||||
is never marked `COMPLETE`, and can never be pushed to will-executors.
|
||||
|
||||
A secondary single-sig assumption: `plugin.py` uses `wallet.get_keystore()`
|
||||
(singular); multisig wallets expose `get_keystores()` (plural).
|
||||
|
||||
## Multisig wallets — solvable, medium/large effort
|
||||
|
||||
A 2-of-3 multisig wallet typically holds **only one** of the required private
|
||||
keys locally; the other cosigners hold theirs. `wallet.sign_transaction` adds
|
||||
the local signature only, and BAL has no flow to collect the missing ones.
|
||||
|
||||
**Proposed solution: the standard PSBT coordination round** (the same flow
|
||||
Electrum itself uses for multisig spending):
|
||||
|
||||
1. Build and sign locally as today.
|
||||
2. If the transaction is not complete, **export the partially-signed
|
||||
transaction(s)** (file and/or QR) and mark the will with a new status such
|
||||
as `WAITING_COSIGNERS`.
|
||||
3. Each cosigner signs in their own Electrum (native feature — no new
|
||||
software needed on their side).
|
||||
4. BAL **re-imports and merges the signatures**; once complete, the will is
|
||||
pushed to will-executors as today.
|
||||
|
||||
Notes and caveats:
|
||||
|
||||
- **Chained will transactions** (a will tx spending the change of a previous
|
||||
will tx) remain workable: with segwit, the txid of an unsigned/partially
|
||||
signed transaction is already stable, so the whole chain can be exported as
|
||||
a batch of PSBTs in one round.
|
||||
- **Every rebuild requires a new cosigner round.** Check Alive postponements
|
||||
and balance-change rebuilds re-sign the will, so each of them needs the
|
||||
cosigners again. This is inherent to multisig and must be clearly
|
||||
communicated in the UI.
|
||||
- Implementation surface: export/import/merge pipeline, GUI for it, the new
|
||||
status in the transaction list, and tests.
|
||||
|
||||
Target: **next plugin release**, as announced.
|
||||
|
||||
## TrustedCoin (2FA) wallets — harder, with one blocking unknown
|
||||
|
||||
An Electrum 2FA wallet (`Wallet_2fa`, defined in Electrum's `trustedcoin`
|
||||
plugin) is technically a **2-of-3 multisig whose second signer is the
|
||||
TrustedCoin server**:
|
||||
|
||||
- signing requires a **one-time password (OTP) per transaction**
|
||||
(`server.sign(short_id, raw_tx, otp)`);
|
||||
- the server co-signs only transactions that include **its billing fee**,
|
||||
which Electrum adds inside `Wallet_2fa.make_unsigned_transaction` — a code
|
||||
path BAL currently bypasses (see root cause #1).
|
||||
|
||||
So today: no billing output, no OTP prompt, local signature only → incomplete
|
||||
transaction.
|
||||
|
||||
Even with full integration (building via the wallet's
|
||||
`make_unsigned_transaction`, adding the OTP prompt flow), one **decisive
|
||||
unknown** remains: will transactions carry a **locktime years in the future**.
|
||||
Whether the TrustedCoin server agrees to co-sign a transaction with such a
|
||||
far-future `nLockTime` is an undocumented server-side policy. If it refuses,
|
||||
2FA support is **not achievable** without TrustedCoin's cooperation. This is
|
||||
why `COMPATIBILITY.md` marks 2FA as *unknown*.
|
||||
|
||||
**Proposed plan:**
|
||||
|
||||
1. **Empirical test on testnet** (cheap, decisive): create a test 2FA wallet,
|
||||
build a far-future-locktime transaction through the proper 2FA path, and
|
||||
check whether the server signs it.
|
||||
2. If it signs → implement support: build via `make_unsigned_transaction`
|
||||
(billing output included), integrate the OTP prompt, and document that
|
||||
every rebuild costs one OTP round and TrustedCoin fees.
|
||||
3. If it refuses → document 2FA as unsupported, with the practical
|
||||
workaround: Electrum allows disabling 2FA by restoring the wallet from the
|
||||
full seed, which turns it into a standard wallet — fully supported by BAL.
|
||||
|
||||
## Recommended order of work
|
||||
|
||||
1. **Multisig first**: deterministic path, standard Electrum tooling, already
|
||||
announced for the next release.
|
||||
2. **TrustedCoin empirical test in parallel**: low cost, and its outcome
|
||||
decides whether 2FA support is feasible at all.
|
||||
275
HANDOFF.md
275
HANDOFF.md
@@ -10,7 +10,7 @@
|
||||
## 0. TL;DR — what this project is
|
||||
|
||||
- **Product:** BAL ("Bitcoin After Life") — an inheritance plugin for the
|
||||
**Electrum 4.7.2** Bitcoin wallet (Qt / **PyQt6**).
|
||||
**Electrum 4.7.2 and 4.8.0** Bitcoin wallet (Qt / **PyQt6**).
|
||||
- **Form:** external **ZIP plugin** (not bundled in Electrum). The user
|
||||
installs the ZIP from Electrum's plugin manager.
|
||||
- **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
|
||||
periodically proves they are alive ("check-alive"); if the deadline passes,
|
||||
the inheritance becomes spendable.
|
||||
- **Current version:** see `bal/VERSION` (last shipped: **0.4.8**).
|
||||
- **Current version:** see the `"version"` field of `bal/manifest.json` (the single source of truth; read at runtime via `get_version()` in `bal/core/plugin_base.py`).
|
||||
|
||||
---
|
||||
|
||||
@@ -55,29 +55,41 @@ These are non-negotiable. They come from the owner directly.
|
||||
|
||||
```
|
||||
bal/ <- the plugin package (this is what ships in the ZIP)
|
||||
__init__.py <- __version__ (one of 4 version files)
|
||||
VERSION <- plain-text version (one of 4 version files)
|
||||
manifest.json <- plugin manifest, "version" field (one of 4)
|
||||
__init__.py <- package docstring (no version here anymore)
|
||||
manifest.json <- plugin manifest, "version" field (SINGLE SOURCE OF TRUTH for the version)
|
||||
qt.py <- zipimport shim used when loaded as an external ZIP plugin
|
||||
cmdline.py <- CLI entry-point shim (Electrum gui_name='cmdline')
|
||||
core/
|
||||
plugin_base.py <- __version__ "AUTOMATICALLY GENERATED" (one of 4)
|
||||
plugin_base.py <- get_version() reads the version from manifest.json (zip-safe)
|
||||
heirs.py <- HEIRS + transaction building (prepare_lists,
|
||||
prepare_transactions, buildTransactions). CORE LOGIC.
|
||||
will.py <- Will/WillItem, validation (check_amounts, check_will),
|
||||
exceptions (AmountException, WillExpiredException, ...).
|
||||
willexecutors.py <- remote will-executor services handling.
|
||||
willexecutors.py <- remote will-executor services handling (is_selected / is_valid,
|
||||
parallel push/check).
|
||||
util.py <- locktime parsing/most helpers (timestamps only).
|
||||
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/
|
||||
common.py <- shared imports; every gui module does
|
||||
`from .common import *`. Add new shared imports HERE.
|
||||
dialogs.py <- the big build/sign/broadcast dialog
|
||||
(BalBuildWillDialog, task_phase1/2), wizard glue.
|
||||
widgets.py <- WillSettingsWidget + wizard widgets/labels.
|
||||
window.py <- BalWalletWindow (build_will, check_will, get_transactions).
|
||||
lists.py, calendar.py, theme.py, window_utils.py, ...
|
||||
tests/ <- pytest suite (see run command below).
|
||||
electrum-src/ <- a copy of Electrum source, used ONLY for tests
|
||||
(PYTHONPATH=electrum-src). NOT shipped in the ZIP.
|
||||
build_zip.py <- builds the shippable ZIP (37 files).
|
||||
window.py <- BalWindow, the per-wallet controller (build_will, check_will,
|
||||
get_transactions, merge_will, on_close, menubar wiring).
|
||||
plugin.py <- Electrum @hooks entry point (init_qt, tools menu, settings dialog).
|
||||
lists.py, calendar.py, theme.py (status colours), window_utils.py
|
||||
wallet_util/ <- standalone wallet-inspection helpers, no Qt
|
||||
tests/ <- standalone test scripts (see Section 3).
|
||||
docs/ <- user manual + inheritance-options guide (.md sources).
|
||||
build_zip.py <- builds the shippable ZIP (36 files).
|
||||
CHANGELOG.md <- numbered task log (English).
|
||||
.agent_memory_tasks.md <- terse internal memory notes per task batch.
|
||||
HANDOFF.md <- this file.
|
||||
@@ -87,46 +99,73 @@ HANDOFF.md <- this file.
|
||||
|
||||
## 3. How to build, test and lint
|
||||
|
||||
Run everything from `/home/user/webapp`.
|
||||
Two separate venvs — using the wrong one is the #1 mistake:
|
||||
|
||||
- **Runtime env** (Electrum + PyQt6, has `electrum` importable):
|
||||
`source "$BAL_HOME/electrum/env/bin/activate"` — an editable
|
||||
install of the Electrum **4.8.0** checkout at
|
||||
`$BAL_HOME/electrum`. Use it for anything that imports
|
||||
`electrum`, runs GUI code, or runs tests. The plugin's `bal/` directory is
|
||||
symlinked into `electrum/electrum/plugins/bal` (internal-plugin install used
|
||||
during dev).
|
||||
- **Lint venv** (repo-local `venv/`): ruff, black, flake8 only. It cannot
|
||||
import `electrum` or `PyQt6`. Do NOT use it to run tests.
|
||||
|
||||
Run everything from the repo root (the checkout directory; set
|
||||
`BAL_HOME` to its parent to use `$BAL_HOME/electrum`).
|
||||
|
||||
**Tests are standalone scripts (not pytest):** each `tests/test_*.py` runs its
|
||||
`test_*` functions from `if __name__ == "__main__"`. Run a file directly:
|
||||
|
||||
**Full test suite (expected: 266 passed as of v0.4.8):**
|
||||
```bash
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
|
||||
tests/test_core_*.py tests/test_gui_*.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
|
||||
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
|
||||
```
|
||||
|
||||
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):**
|
||||
```bash
|
||||
ruff check <files> | grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" \
|
||||
| grep -vE "F401|F403|F405|F841"
|
||||
./venv/bin/ruff check <files> \
|
||||
| grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" | grep -vE "F401|F403|F405|F841"
|
||||
```
|
||||
Pre-existing, KNOWN-OK ruff noise: `F401/F403/F405` (star-imports via
|
||||
Ruff is NOT clean repo-wide (hundreds of pre-existing errors in `bal/` and
|
||||
`tests/`); do NOT run `--fix` wholesale — just avoid adding new violations.
|
||||
Pre-existing, KNOWN-OK noise: `F401/F403/F405` (star-imports via
|
||||
`from .common import *`) and 2× `F841` (an unused `e` in two `except` blocks).
|
||||
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):**
|
||||
```bash
|
||||
find bal -name "__pycache__" -type d -exec rm -rf {} + ; find bal -name "*.pyc" -delete
|
||||
python3 build_zip.py bal-electrum-plugin-vX.Y.Z.zip # produces 37 files
|
||||
python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha256; 36 files)
|
||||
```
|
||||
|
||||
**Bump version — there are FOUR files, keep them in sync:**
|
||||
**Bump version — ONE file only (single source of truth):**
|
||||
```
|
||||
bal/core/plugin_base.py -> __version__ = "X.Y.Z" # AUTOMATICALLY GENERATED DO NOT EDIT
|
||||
bal/__init__.py -> __version__ = "X.Y.Z"
|
||||
bal/VERSION -> X.Y.Z
|
||||
bal/manifest.json -> "version": "X.Y.Z",
|
||||
```
|
||||
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
|
||||
must **fully restart Electrum** (not just reload the plugin) — Electrum's
|
||||
`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)
|
||||
@@ -225,25 +264,49 @@ must **fully restart Electrum** (not just reload the plugin) — Electrum's
|
||||
|
||||
## 5. Git / delivery workflow
|
||||
|
||||
- **Branch:** work on `genspark_ai_developer`. Open PRs into `main`.
|
||||
- **Branch:** work directly on `main` (no PR flow anymore). Push straight to
|
||||
`origin/main` (Gitea).
|
||||
- **Commit policy:** ZIP-FIRST — build a test ZIP, let the owner confirm it
|
||||
works, THEN commit. (This differs from "commit after every change"; the owner
|
||||
explicitly prefers ZIP-first because they manually test each build.)
|
||||
- Before opening/updating a PR: `git fetch origin main`, rebase, resolve
|
||||
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.
|
||||
- Before pushing: check `git status`/`git diff`, stage only the intended files
|
||||
(never secrets), commit with a concise message, then push to `origin/main`.
|
||||
- **ZIPs are NOT committed** (`.gitignore` excludes `*.zip`). They are
|
||||
distributed via **GitHub Releases** (`gh release create vX.Y.Z file.zip ...`).
|
||||
The newest release is the "Latest" and is the owner's convenient download.
|
||||
- Deliverable ZIPs are ALSO uploaded with the file-wrapper tool so the owner can
|
||||
download them directly from chat.
|
||||
- **Auth note:** if `git push` / `gh` fails with "Invalid username or token",
|
||||
re-run the GitHub environment setup, then retry.
|
||||
- PR history for this line of work: **#13** (v0.4.7), **#14** (docs/DUST section +
|
||||
translation), **#15** (v0.4.8). All merged into `main`.
|
||||
- Releases: latest is **v0.4.8** (asset `bal-electrum-plugin-v0.4.8.zip`);
|
||||
v0.4.7 kept in history.
|
||||
distributed via **Gitea Releases** using `make-release.sh`.
|
||||
- **Release process** (`make-release.sh`):
|
||||
1. Version bump in `bal/manifest.json` (single source of truth)
|
||||
2. Clean `__pycache__` and `.pyc` files
|
||||
3. Run full test suite
|
||||
4. Lint with ruff (skip if not installed)
|
||||
5. Build ZIP via `build_zip.py` (deterministic order, SHA-256, manifest check)
|
||||
6. GPG sign: `.asc` (armor) + `.sig` (binary) with key `A847D004DB91610711CA6A0DFE756706E833E0D1`
|
||||
7. Export public key as `svatantrya.asc`
|
||||
8. SHA-256 checksum
|
||||
9. Interactive pause for Electrum testing (ZIP-FIRST policy)
|
||||
10. Create Gitea tag, push, create release, upload 5 assets (ZIP + .asc + .sig + .sha256 + svatantrya.asc)
|
||||
- **Usage:**
|
||||
```bash
|
||||
./make-release.sh # read version from bal/manifest.json
|
||||
./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
|
||||
```
|
||||
- **Release assets** (5 files):
|
||||
- `bal_vX.Y.Z.zip` — the plugin
|
||||
- `bal_vX.Y.Z.zip.asc` — GPG signature (armor)
|
||||
- `bal_vX.Y.Z.zip.sig` — GPG signature (binary)
|
||||
- `bal_vX.Y.Z.zip.sha256` — SHA-256 checksum
|
||||
- `svatantrya.asc` — signing public key
|
||||
- **GPG verification instructions** (included in release body):
|
||||
```bash
|
||||
gpg --fetch-key https://bitcoin-after.life/svatantrya.asc
|
||||
gpg --verify bal_vX.Y.Z.zip.asc bal_vX.Y.Z.zip
|
||||
```
|
||||
- **Auth note:** if `git push` or Gitea API fails with "invalid credentials",
|
||||
update `GITEA_TOKEN` env var or `~/.git-credentials`, then retry.
|
||||
- Older PR history (pre-`main` direct workflow): **#13** (v0.4.7), **#14**
|
||||
(docs/DUST section + translation), **#15** (v0.4.8), **#4** (v0.6.1 —
|
||||
manifest.json version); all merged into `main`.
|
||||
- Releases: latest is **v0.7.0**; v0.6.1, v0.6.0 and v0.5.18 before it; the older
|
||||
v0.2.x line is kept in history.
|
||||
|
||||
---
|
||||
|
||||
@@ -272,6 +335,31 @@ must **fully restart Electrum** (not just reload the plugin) — Electrum's
|
||||
(#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`).
|
||||
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)
|
||||
- **SUSPENDED — "(UTC)" label in the wizard.** The owner asked to show an
|
||||
@@ -292,13 +380,98 @@ must **fully restart Electrum** (not just reload the plugin) — Electrum's
|
||||
## 7. How to resume (checklist for the next AI)
|
||||
|
||||
1. Read this file, then `CHANGELOG.md` (last entries) and `.agent_memory_tasks.md`.
|
||||
2. Confirm the environment: `git status`, current branch, `bal/VERSION`.
|
||||
3. Run the full test suite (Section 3) — expect all green (266 as of v0.4.8).
|
||||
2. Confirm the environment: `git status`, current branch, and the `"version"` field of `bal/manifest.json`.
|
||||
3. Run the offline test files (Section 3) — expect the offline subset to pass
|
||||
(414 passed; the 13 pre-existing failures in `test_core_will_invalidate.py`
|
||||
and the 1 collection error in `test_group_i_basic_checkalive.py` are NOT
|
||||
yours to fix without asking).
|
||||
4. Talk to the owner in **Italian**, write everything else in **English**.
|
||||
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.
|
||||
6. Keep credit usage low: summarize, don't paste big code blocks; batch work.
|
||||
7. When the owner confirms a ZIP works: commit (ZIP-first), sync with `main`,
|
||||
squash to one commit, push, open a PR, merge it, then create/refresh a GitHub
|
||||
**Release** with the ZIP attached (it becomes the owner's "Latest" download).
|
||||
Always give the owner the PR URL and the Release URL.
|
||||
7. When the owner confirms a ZIP works: commit (ZIP-first) directly on `main`,
|
||||
push to `origin/main`, then run `./make-release.sh` to create the Gitea
|
||||
**Release** with the ZIP + signatures attached (it becomes the owner's
|
||||
"Latest" download). Always give the owner the Release URL.
|
||||
|
||||
### In progress: QR / audio will transfer (branch `feature/bal-qr-transfer`)
|
||||
|
||||
- The full QR-transfer feature (P0–P6) is implemented, tested and committed on
|
||||
`feature/bal-qr-transfer` (commits `d288b55`, `ce3e36d`, pushed to
|
||||
`origin`). PR creation URL:
|
||||
`https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin/pulls/new/feature/bal-qr-transfer`
|
||||
- Included: core scheduler (`bal/core/qrtransfer.py`), `QR_CHUNK_SIZE`
|
||||
setting (4 export presets), export/import dialogs + review/sign wizard +
|
||||
lists/window wiring, export filters, auto slideshow with per-second rate +
|
||||
loop option, audio send/receive buttons, and the crash fixes
|
||||
(`status` default, `invalidate_will` guard). Docs: README, CHANGELOG entry
|
||||
56, QML_PLAN, `AUDIO_MODEM_DEBIAN.md`.
|
||||
- Follow-up refactor (CHANGELOG entry 57): all `copy.deepcopy` removed —
|
||||
`copy_structure()` in `bal/core/util.py`, `WillItem.copy()` / ctor
|
||||
serialize/deserialize, `copy_status_table()`. Working tree clean after the
|
||||
branch's three commits.
|
||||
- Verification: batch 377 passed / 2 pre-existing `test_bt_to_date_*`
|
||||
failures; ruff no new violations; smoke + `build_zip.py` +
|
||||
external-zip OK; pyright clean. The isolated
|
||||
`test_heir_relative_anchor.py::test_karen7_frozen_delivery_not_expired`
|
||||
failure is pre-existing test pollution (fails identically on clean HEAD,
|
||||
passes inside the full batch) — not caused by entry 57.
|
||||
- Remaining: manual on-device walkthrough of the QR path (and, if wanted,
|
||||
the audio path — buttons only appear when the `audio_modem` plugin +
|
||||
`amodem` are installed; see the prerequisites below).
|
||||
|
||||
### 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).
|
||||
|
||||
415
PLAN_CMDLINE_PLUGIN.md
Normal file
415
PLAN_CMDLINE_PLUGIN.md
Normal file
@@ -0,0 +1,415 @@
|
||||
# Piano: supporto da riga di comando (CLI) per il plugin BAL
|
||||
|
||||
> **Stato**: solo piano. Nessun codice viene modificato finché il piano non viene approvato.
|
||||
>
|
||||
> **Versione di riferimento**: commit `2221389` (`core: anchor relative locktime/threshold recipes...`), working tree pulito.
|
||||
|
||||
---
|
||||
|
||||
## 1. Obiettivo
|
||||
|
||||
Rendere il plugin **Bitcoin After Life** utilizzabile da riga di comando / daemon
|
||||
di Electrum, senza GUI Qt, esponendo comandi per:
|
||||
|
||||
1. **Willexecutors** — elenco, aggiunta, modifica, selezione, eliminazione, import/export, ping, download lista.
|
||||
2. **Heirs** — elenco, aggiunta, modifica, eliminazione, import/export.
|
||||
3. **Impostazioni** — lettura e modifica (`settings set chiave=valore`), reset a default.
|
||||
4. **Will** — ciclo di vita completo: visualizza stato, check di coerenza, prepara/ricostruisci, firma, import/merge, esporta, invalida, trasmette ai will-executor, verifica lato will-executor (searchtx).
|
||||
|
||||
Il tutto riusando **esclusivamente la logica già presente in `bal/core/`** (che è
|
||||
già GUI-free) e senza importare mai PyQt.
|
||||
|
||||
---
|
||||
|
||||
## 2. Stato attuale (verificato sul codice)
|
||||
|
||||
### 2.1 Meccanica di Electrum (4.8.0, checkout `electrum/`)
|
||||
|
||||
Ho verificato sul codice reale (`electrum/commands.py`, `electrum/plugin.py`,
|
||||
`electrum/daemon.py`, `run_electrum`) i punti che governano i comandi dei plugin:
|
||||
|
||||
- **Registrazione comandi**: `@plugin_command(s, plugin_name)` in
|
||||
`electrum/commands.py:2317`. Un comando plugin:
|
||||
- è **sempre** un `async def`;
|
||||
- viene registrato come `bal_<nome_funzione>` su `Commands` (quindi anche nel parser CLI);
|
||||
- **forza il flag `'n'`** (richiede rete/daemon): *tutti* i comandi plugin richiedono un daemon in esecuzione e NON funzionano con `--offline`;
|
||||
- alla chiamata inietta `plugin = daemon._plugins.get_plugin('bal')` (riga 2337).
|
||||
- **Pre-parse CLI** (`run_electrum` riga 425): `Plugins(tmp_config, cmd_only=True)` importa solo l'`__init__.py` di ogni plugin abilitato per registrare i comandi nel parser. In modalità `cmd_only` il filtro `available_for` viene **saltato** (`plugin.py:128`), ma serve `config['plugins.bal.enabled'] is True` (`plugin.py:117`).
|
||||
- **Daemon** (`daemon.py:626`): `Plugins(self.config, 'cmdline')`. Qui il filtro `available_for` **vale**: il plugin deve dichiarare `"cmdline"`.
|
||||
- **Caricamento entry-point** (`plugin.py:622`): il daemon importa `electrum.plugins.bal.<gui_name>` con `gui_name='cmdline'`, quindi serve un modulo `bal/cmdline.py` con una classe `Plugin`.
|
||||
- **Iniezione wallet**: il decorator `@command` (righe 170-194) gestisce i flag:
|
||||
- `'w'` → risolve e inietta `wallet` da `daemon.get_wallet(wallet_path)` (il wallet deve essere già caricato con `electrum load_wallet`);
|
||||
- `'p'` → richiede `--password` (o wallet già sbloccato) per le operazioni di firma.
|
||||
- **Output**: il valore di ritorno del comando viene stampato come JSON da `run_electrum` (righe 626-630); in modalità daemon gli errori `UserFacingException` vengono stampati con exit code 1.
|
||||
|
||||
### 2.2 Il plugin (bal v0.6.1)
|
||||
|
||||
- `bal/core/` è già GUI-free e contiene tutta la logica riutilizzabile:
|
||||
- `heirs.py` — `Heirs` (dict persistito in wallet DB, chiave `"heirs"`), validazione (`validate_heir`, `_validate`), `import_file`/`export_file`, `get_transactions`/`buildTransactions`.
|
||||
- `willexecutors.py` — `Willexecutors` (config `bal_willexecutors`, chiave per `chainname`), `get_willexecutors`, `save`, `initialize_willexecutor`, `is_selected`, `is_valid`, `ping_servers_parallel`, `push_transactions_parallel`, `check_transactions_parallel`, `check_transaction`, `download_list`, `get_willexecutors_list_from_json`.
|
||||
- `will.py` — `Will` (statiche) e `WillItem` (stato per-tx: `VALID/COMPLETE/PUSHED/CHECKED/...`), `is_will_valid`, `check_will`, `check_willexecutors_and_heirs`, `invalidate_will`, `normalize_will`, `get_min_locktime`, `get_tx_from_any`, `set_check_willexecutor`, `save_valid_transactions_to_history`.
|
||||
- `plugin_base.py` — `BalPlugin` (tutte le `BalConfig`: chiavi `bal_*`), `BalTimestamp`, `get_version`, registrazione dei dict `heirs`/`will`/`will_settings` nel wallet DB.
|
||||
- `checkalive.py` — `resolve_date_to_check`, `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.
|
||||
499
PLAN_QR_TRANSFER.md
Normal file
499
PLAN_QR_TRANSFER.md
Normal file
@@ -0,0 +1,499 @@
|
||||
# PLAN — Will transfer via QR codes / audio modem (Qt now, QML planned)
|
||||
|
||||
> Goal: let the user move an inheritance ("will") between devices over two
|
||||
> air-gap channels:
|
||||
>
|
||||
> 1. **QR codes** (primary): export the **valid** inheritance transactions as
|
||||
> a sequence of QR codes, and import them back on another machine with the
|
||||
> camera;
|
||||
> 2. **Audio modem** (secondary, when Electrum's `audio_modem` plugin is
|
||||
> enabled): send/receive the same payload through the PC speaker +
|
||||
> microphone.
|
||||
>
|
||||
> Both channels converge on the same review-and-sign flow afterwards.
|
||||
>
|
||||
> Status: APPROVED by owner (2026-08-25). No code written yet — this document
|
||||
> is the implementation contract. Work top-down through §9 Checklist.
|
||||
>
|
||||
> Chat language: Italian; this document is in English (global rule R1).
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- **Desktop PyQt6 GUI** (primary, implemented by this plan):
|
||||
- New plugin setting: QR chunk size, offered as **4 standard presets**.
|
||||
- *"Export via QR"* action: serializes the **valid** will transactions,
|
||||
optionally compresses, splits the resulting string into fixed-size frames,
|
||||
shows one QR at a time with prev/next navigation, live re-chunking and
|
||||
progress (`i di N`).
|
||||
- *"Import via QR"* action: camera capture dialog with a slot grid
|
||||
(1..N); the user selects which shot he is about to capture, scans, the
|
||||
frame lands in its slot; when 1..N are filled the payload is assembled,
|
||||
parsed into `WillItem`s, validity-checked locally.
|
||||
- **Post-capture flow (owner decision D6, amended)**: after capture
|
||||
completes there is NO read-only preview. Instead a review-and-sign wizard
|
||||
walks through every transaction one at a time showing **outputs
|
||||
(address + amount), total outputs, total fees**, signs it (wallet
|
||||
password asked once), and at the end **proposes exporting the signed
|
||||
transactions** (to file and/or back via QR).
|
||||
- **Audio-modem channel** (owner decision D7): when the Electrum
|
||||
`audio_modem` plugin is enabled and available, the export dialog gains a
|
||||
*"Send via Audio Modem…"* button and the import dialog a *"Receive via
|
||||
Audio Modem…"* button, reusing the same transfer string (no QR framing).
|
||||
- **QML**: document-only update to `QML_PLAN.md` adding dedicated view specs
|
||||
(owner decision D1). No QML code in this feature.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Implementing the QML frontend (gated behind `QML_PLAN.md` Phases 0–2).
|
||||
- Merging imported wills into the live wallet state (existing Merge flows
|
||||
stay unchanged).
|
||||
- Broadcast of the reviewed transactions (user exports them; broadcasting
|
||||
remains an explicit action elsewhere).
|
||||
- CLI/cmdline parity for QR transfer.
|
||||
|
||||
---
|
||||
|
||||
## 2. Owner decisions (locked)
|
||||
|
||||
| # | Decision |
|
||||
|---|----------|
|
||||
| D1 | QML part = update `QML_PLAN.md` document only; implementation later. |
|
||||
| D2 | Frames carry a small **compact** ASCII header (`BAL1<total><index><flag>`, fixed 11 chars, base36 count fields) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. Legacy `BALQR1\|N\|i\|flags\|` export removed; legacy import kept. |
|
||||
| D3 | Payload = **serialized transaction strings only** (`str(wi.tx)`), NOT the JSON will dump. Loses statuses/metadata on purpose; import rebuilds items like `merge_single_transaction` does. |
|
||||
| D4 | Compression (zlib+base64 over the whole payload) exported as **best-of**: `encode_transfer_best` ships compressed when it is shorter, plain otherwise; flag `0` = plain, `Z` = compressed. No user-facing checkbox. |
|
||||
| D5 | 4 standard size presets: **~150 / ~400 / ~900 / ~1800 bytes** of payload per QR (low-res cams → high-res cams). Error-correction level fixed **M**. Stored as plugin config default; selectable again inside the export dialog. |
|
||||
| D6 | After capture completes: **review + sign each tx one at a time** (show outputs, total outputs, total fees), then **propose export of the signed txs** (file and/or QR). Supersedes the earlier "WillDetailDialog preview" answer. |
|
||||
| D7 | Add an **audio-modem transfer path** gated on Electrum's `audio_modem` plugin being enabled and available (`amodem` importable). Same payload semantics as QR (transfer string of serialized txs), but NO BAL frame chunking — `amodem` handles transport framing internally. Buttons simply hidden when the plugin is absent/unavailable (info message pointing at `pip install amodem` when enabled-but-broken); graceful degradation, never a hard dependency. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Verified facts (research done on the local checkouts)
|
||||
|
||||
All verified by reading source; references are `file:line`.
|
||||
|
||||
| # | Fact | Where |
|
||||
|---|------|-------|
|
||||
| F1 | Export today: `BalWindow.export_will()` writes `{wid: WillItem.to_dict()}` JSON; subsets All/Valid/Valid-NC built in `WillList` | `bal/gui/qt/window.py:1605-1621`, `lists.py:666-669, 743-767` |
|
||||
| F2 | Batch signer: `BalWindow.sign_transactions(password, will, txids)` loops valid txs, resolves input values from change prevouts (`txin._trusted_value_sats` …), calls `wallet.sign_transaction`, updates `COMPLETE` + signature counts | `window.py:1017-1086` |
|
||||
| F3 | External-will signing already supported (`will=` param, nothing saved to live wallet/history) | `window.py:1443-1484` |
|
||||
| F4 | Single-tx import precedent: `merge_single_transaction` wraps `WillItem({"tx": str(tx)}, _id=tx.txid(), wallet=...)` | `window.py:1715-1724` |
|
||||
| F5 | Local validity recomputation recipe (no network): `add_willtree` → `Util.get_available_utxos` → `check_invalidated` → `search_rai` → `check_signatures` | `window.py:1678-1701` |
|
||||
| F6 | Plugin config accessor pattern `BalConfig`; keys declared in ctor | `bal/core/plugin_base.py:130-154, 211-264` |
|
||||
| F7 | Plugin settings dialog: grid rows 0..12, `add_widget(grid,label,widget,row,help)` + `_make_reset_btn(cfgvar,widget,kind)`; ADVANCED-only rows wrapped with `_hide_if_basic(...)` | `bal/gui/qt/plugin.py:442-850` (rows at 677-850) |
|
||||
| F8 | `BalDialog(parent, bal_plugin, title=None, icon=...)` base class anchors to top-level window | `bal/gui/qt/dialogs.py:92-129` |
|
||||
| F9 | Fee display precedent: `fee = tx.input_value() - tx.output_value()`, fee rate = `fee / tx.estimated_size()` | `bal/gui/qt/widgets.py:1319-1328` |
|
||||
| F10 | Input-value resolution helper exists: `Will.add_info_from_will(will, wid, wallet)` sets trusted input values from sibling will change outputs | `bal/core/will.py:118-136` |
|
||||
| F11 | `str(tx)` = `tx.serialize()`: raw hex for complete txs; `PartialTransaction.serialize()` → base64 PSBT. Both accepted by `tx_from_any` (= `Will.get_tx_from_any`). This is exactly how BAL persists/reloads txs today | `electrum/transaction.py:907, 2539`; `will.py:106-113`; `window.py:1041-1044` |
|
||||
| F12 | `QRCodeWidget` exists but **hardcodes `ERROR_CORRECT_L`** → cannot satisfy D5/M; must render our own `qrcode` instance | `electrum/gui/qt/qrcodewidget.py:35-37` |
|
||||
| F13 | Camera scanning one-shot API with OS-permission handling: `scan_qrcode_from_camera(*, parent, config, callback(success: bool, error: str, data: Optional[str]))`; on Linux uses zbar CLI backend | `electrum/gui/qt/qrreader/__init__.py:47-64` |
|
||||
| F14 | QR painting without PIL: `draw_qr(qr, paint_device, ...)` from `electrum.gui.common_qt.util` (what `QRCodeWidget.paintEvent` uses) | `electrum/gui/qt/qrcodewidget.py:63-72` |
|
||||
| F15 | QR capacity sanity (byte mode, EC **M**): v40-M ≈ 2331 B ≥ 1800 ✓; v10-M ≈ 213 B ≥ 150 ✓; the `qrcode` lib auto-picks the version | `qrcode` lib |
|
||||
| F16 | `build_zip.py` walks the tree with `os.walk` → new `.py` files ship automatically | `build_zip.py:39-47` |
|
||||
| F17 | QML fork already has `QRImage.qml`, `QRScan.qml`, `ScanDialog.qml`; ScanDialog carries upstream comment "currently not used on android … qt6 camera support stops crashing" | `electrum/gui/qml/components/ScanDialog.qml:8-9` |
|
||||
| F18 | `QML_PLAN.md` currently defers chunked multi-QR streams (Phase 3 note + risk R6) — this feature supersedes that deferral | `QML_PLAN.md:228-231, 304` |
|
||||
| F19 | House test conventions: `def test_*` + `if __name__ == "__main__"` + `sys.path.insert(0, ..pardir)`; run standalone or via pytest | `tests/test_core_heirs.py:1-24` |
|
||||
| F20 | Fork ships an `audio_modem` plugin. `_send(parent, blob)` zlib-compresses an **ASCII** blob, plays it via speaker through `amodem` inside a `WaitingDialog`; bit-rate selectable in the plugin's own settings (default = `amodem.config.slowest()`) | `electrum/plugins/audio_modem/qt.py:96-110` |
|
||||
| F21 | `_recv(parent)` records from mic and delivers the decompressed ASCII text by calling `parent.setText(blob)` — the only integration contract is "an object with `setText(str)`"; there is no callback API | `audio_modem/qt.py:112-127` |
|
||||
| F22 | Plugin lookup for enabled plugins: `window.plugins.get(name)` → instance or `None` (`Plugins.get`, electrum/plugin.py:575-576); availability check is the plugin's own `is_available()` (imports `amodem`). **`amodem` is NOT installed in the runtime env today** → optional dependency (pip `amodem` + libportaudio); feature must degrade gracefully | `electrum/plugin.py:575`, runtime-env check |
|
||||
| F23 | `amodem.main.send/recv` stream the whole blob with their own framing/training → BAL must NOT apply QR frame chunking on this channel; and since `_send` compresses internally, BAL sends the **plain** transfer string to avoid double compression | consequence of F20/F21 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Wire format specification
|
||||
|
||||
### 4.1 Transfer string
|
||||
|
||||
```
|
||||
transfer_string = "\n".join( tx_str(tx) for tx in valid_txs_sorted_by_txid )
|
||||
```
|
||||
|
||||
- `tx_str(tx)` = `str(tx)` (F11): hex for complete txs, base64-PSBT for
|
||||
partially-signed ones. Neither alphabet contains `\n` or `|`, so both are
|
||||
safe delimiters.
|
||||
- Ordering: ascending `tx.txid()` → deterministic output for identical input.
|
||||
- If compression enabled (D4):
|
||||
`transfer_string = base64_ascii( zlib_compress( transfer_string ) )`.
|
||||
|
||||
### 4.2 Frame layout (one frame = content of ONE QR code)
|
||||
|
||||
Wire format v2 (compact, current export):
|
||||
|
||||
```
|
||||
BAL1<TTT><iii><F><payload>
|
||||
```
|
||||
|
||||
- Magic+version literal `BAL1` (reject anything else with a clear message).
|
||||
- `<TTT>` = `<iii>` — **base36** zero-padded 3-char strings (`000`…`ZZZ`),
|
||||
representing total N and index i, `1 ≤ i ≤ N ≤ 46655`. Fixed width means a
|
||||
3-digit count field costs the same for a 1-frame or a 46655-frame transfer.
|
||||
- `<F>`: single flag char — `0` ⇒ plain, `Z` ⇒ zlib+base64 compressed.
|
||||
- `<payload>`: the i-th slice of `transfer_string`, exactly
|
||||
`chunk_size` bytes each (last slice may be shorter). No separators: both
|
||||
base36 count fields are fixed-width, so the header is unambiguously 11
|
||||
chars and the payload starts at offset 11.
|
||||
- Header overhead is a constant **11 bytes** → effective payload =
|
||||
`chunk_size − 11`; the chunker slices the transfer string so that
|
||||
**header+payload ≤ preset size**.
|
||||
|
||||
Legacy frames `BALQR1|<total>|<index>|<flags>|<payload>` (variable-width
|
||||
decimal header, pipe-separated) are still **imported** by `parse_frame`
|
||||
(`_parse_v1`), so old exports keep working; the exporter emits v2 only.
|
||||
That is the one deliberate compatibility break: **v2 frames are not readable
|
||||
by builds older than this change.**
|
||||
|
||||
### 4.3 Size presets (D5)
|
||||
|
||||
| Preset label | Payload budget (bytes/frame) | Typical QR version @EC-M |
|
||||
|--------------|------------------------------|--------------------------|
|
||||
| Small (low-res cameras) | 150 | ~v10 |
|
||||
| Medium | 400 | ~v15 |
|
||||
| Large | 900 | ~v22 |
|
||||
| XL (high-res cameras) | 1800 | ~v40 |
|
||||
|
||||
EC level fixed **M** for scan reliability (D5). Presets live in
|
||||
`bal/core/qrtransfer.py::CHUNK_PRESETS` so core tests can cover them.
|
||||
|
||||
### 4.4 Audio-modem channel (D7, F20-F23)
|
||||
|
||||
- Payload = the **plain** `transfer_string` of §4.1 — no BAL frames
|
||||
(`split_frames`/`parse_frame` are QR-only), no BAL compression (the plugin
|
||||
compresses internally; double compression wastes airtime).
|
||||
- The existing core functions `encode_transfer(tx_strings,
|
||||
compress=False)` + `decode_transfer(text, compressed=False)` are reused
|
||||
unchanged; only the transport differs.
|
||||
- Bit-rate is owned by the audio_modem plugin's settings dialog — BAL adds
|
||||
no setting of its own.
|
||||
|
||||
---
|
||||
|
||||
## 5. New core module — `bal/core/qrtransfer.py`
|
||||
|
||||
GUI-free (never imports Qt — house rule). Public API:
|
||||
|
||||
```python
|
||||
MAGIC = "BALQR"
|
||||
VERSION = 1
|
||||
FLAG_COMPRESSED = "Z"
|
||||
CHUNK_PRESETS = [(label_en, budget_bytes), ...] # §4.3 table
|
||||
|
||||
def encode_transfer(tx_strings: list[str], compress: bool = False) -> str
|
||||
"""Join -> optional zlib+base64 -> return transfer_string."""
|
||||
|
||||
def split_frames(transfer_string: str, chunk_size: int) -> list[str]
|
||||
"""Slice into frames 'BAL1<TTT><iii><F>payload' (v2) — header+payload <=
|
||||
chunk_size. Raises QrTransferError over the 46655-frame base36 cap, or
|
||||
ValueError if chunk_size < MIN_CHUNK_SIZE."""
|
||||
|
||||
def encode_transfer_best(tx_strings: list[str]) -> tuple[str, bool]
|
||||
"""-> (transfer_string, compressed); ships the shorter of plain vs
|
||||
zlib+base64 so the export emits the densest frames."""
|
||||
|
||||
def parse_frame(frame: str) -> tuple[int, int, bool, str]
|
||||
"""-> (total, index, compressed, payload); accepts v2 'BAL1…' and legacy
|
||||
'BALQR1|…' (wrapped as _parse_v1/_parse_v2); ValueError on bad magic/
|
||||
version/arity/non-numeric fields."""
|
||||
|
||||
def assemble(frames: dict[int, str]) -> str
|
||||
"""Validate indices form exactly range(1..max_total) (taken from any
|
||||
frame header), concatenate payloads in order, decode flags ->
|
||||
transfer_string. Raises MissingFramesError(indexes) / InconsistentTotalError."""
|
||||
|
||||
def decode_transfer(transfer_string: str, compressed: bool) -> list[str]
|
||||
"""Inverse of encode_transfer -> list of tx strings."""
|
||||
```
|
||||
|
||||
Plus exceptions `QrTransferError(ValueError)`, `MissingFramesError`,
|
||||
`InconsistentTotalError`. All docstrings/comments English; ruff-clean
|
||||
(line-length 88, E501 ignored).
|
||||
|
||||
---
|
||||
|
||||
## 6. Settings (Qt)
|
||||
|
||||
1. `bal/core/plugin_base.py`: after `REBUILD_ON_CLOSE` (~line 264) add
|
||||
|
||||
```python
|
||||
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
|
||||
```
|
||||
|
||||
2. `bal/gui/qt/plugin.py::settings_dialog` (rows end at 12, ~line 844):
|
||||
append row **13** — visible in BASIC and ADVANCED (do NOT wrap with
|
||||
`_hide_if_basic`):
|
||||
|
||||
- Label: `"QR Code Size"`
|
||||
- `QComboBox` fed from `CHUNK_PRESETS`; item text e.g.
|
||||
`"Small — ~150 bytes/QR (low-res cameras)"`; `currentIndexChanged`
|
||||
→ `self.QR_CHUNK_SIZE.set(budget_bytes)`; initial index from
|
||||
`QR_CHUNK_SIZE.get()` (fallback to nearest preset if the stored value
|
||||
was customized).
|
||||
- `HelpButton` text: explains trade-off (small QR = more shots but easier
|
||||
to scan with poor cameras; large QR = fewer shots, needs good camera)
|
||||
and that the size can also be changed inside the export dialog.
|
||||
- Reset button via existing `_make_reset_btn(self.QR_CHUNK_SIZE, combo, ...)`
|
||||
pattern (plugin.py:684).
|
||||
|
||||
---
|
||||
|
||||
## 7. Qt export flow
|
||||
|
||||
### 7.1 Entry point
|
||||
|
||||
`bal/gui/qt/lists.py::WillList.create_toolbar` (menu block lines 666-670):
|
||||
|
||||
```python
|
||||
export_menu.addAction(_("Via QR…"), self.export_will_valid_qr)
|
||||
```
|
||||
|
||||
New `WillList.export_will_valid_qr()` mirrors `export_will_valid`
|
||||
(lists.py:743-754): builds `{wid: wi}` subset of `VALID` items, empty →
|
||||
`show_message(_("No valid will item to export"))`, else
|
||||
`self.bal_window.export_will_via_qr(will=subset)`.
|
||||
|
||||
### 7.2 `BalWindow.export_will_via_qr(will=None)` (new, `window.py` near
|
||||
`export_will`)
|
||||
|
||||
- Collect `tx_strings = [str(wi.tx) for wid, wi in sorted-by-txid ...]`
|
||||
(F11).
|
||||
- Mark exported items `EXPORTED` (parity with `export_json_file`,
|
||||
window.py:1607-1609) — only when `will` came from the live list.
|
||||
- Open `WillQrExportDialog(self, tx_strings)`.
|
||||
|
||||
Shared helper used by both dialogs:
|
||||
|
||||
```python
|
||||
def get_audio_modem_plugin(self): # on BalWindow
|
||||
"""Return the loaded audio_modem plugin if enabled AND available
|
||||
(amodem importable), else None. Never raises."""
|
||||
p = self.window.plugins.get("audio_modem") # F22
|
||||
return p if p is not None and p.is_available() else None
|
||||
```
|
||||
|
||||
### 7.3 `WillQrExportDialog(BalDialog)` (new class in `dialogs.py`)
|
||||
|
||||
Layout:
|
||||
|
||||
```
|
||||
[Size ▾ Small/Medium/Large/XL] ← compressed best-of automatically (no checkbox)
|
||||
[ QR image ] ← BalQrImage (see below)
|
||||
«i di N» [◀ Prev] [Next ▶]
|
||||
[Save current QR as PNG…] [Send via Audio Modem…] [Close]
|
||||
```
|
||||
|
||||
Behaviour:
|
||||
|
||||
- On any control change: rebuild `split_frames(encode_transfer_best(...))`,
|
||||
reset index to frame 1, refresh counter (owner requirement: "cambiare la
|
||||
risoluzione").
|
||||
- `BalQrImage(QWidget)` ≈ trimmed copy of `QRCodeWidget`
|
||||
(`electrum/gui/qt/qrcodewidget.py:21-72`) but constructing
|
||||
`qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2)` and painting
|
||||
via `electrum.gui.common_qt.util.draw_qr` (F12/F14). ~30 lines.
|
||||
- Prev/Next wrap or disable at ends (disable chosen: clearer).
|
||||
- PNG export optional convenience via existing
|
||||
`getSaveFileName` + `QWidget.grab()` (same trick as
|
||||
`qrcodewidget.py:110`).
|
||||
- **Send via Audio Modem…** (D7): shown only when
|
||||
`bal_window.get_audio_modem_plugin()` returns a usable instance (below);
|
||||
otherwise hidden. Handler: re-encode the payload **plain**
|
||||
(`encode_transfer(tx_strings, compress=False)`) and call the plugin's
|
||||
`_send(parent=self, blob=transfer_string)` — its own WaitingDialog owns
|
||||
progress/cancellation (F20). Tooltip when hidden is unnecessary; instead,
|
||||
if the plugin is enabled but `is_available()` is False, show an info
|
||||
message pointing to `pip install amodem` + portaudio (F22).
|
||||
|
||||
---
|
||||
|
||||
## 8. Qt import flow + review/sign wizard
|
||||
|
||||
### 8.1 Entry point
|
||||
|
||||
`lists.py` toolbar menu, next to Import/Merge (lines 670-671):
|
||||
|
||||
```python
|
||||
menu.addAction(_("Import via QR…"), lambda: self.bal_window.import_will_via_qr())
|
||||
```
|
||||
|
||||
`BalWindow.import_will_via_qr()` opens `WillQrImportDialog(self)`.
|
||||
|
||||
### 8.2 `WillQrImportDialog(BalDialog)`
|
||||
|
||||
State: `self.frames: dict[int, str]`, `self.total: int | None`,
|
||||
`self.target_index: int | None`.
|
||||
|
||||
Layout:
|
||||
|
||||
```
|
||||
«Captured k of N» [Scan ▶] [Reset]
|
||||
[slot grid: push-buttons 1..N; states: empty / filled ✓ / selected-target]
|
||||
hint line («Select a slot, then scan» / «Scan the first QR»)
|
||||
[Receive via Audio Modem…] [Review & Sign ▶] [Close]
|
||||
```
|
||||
|
||||
Behaviour:
|
||||
|
||||
- **Scan** → `scan_qrcode_from_camera(parent=self,
|
||||
config=self.bal_window.window.config, callback=self._on_scan)`
|
||||
(F13). One-shot per press; dialog stays open between shots (simplest,
|
||||
matches Electrum UX; no continuous mode).
|
||||
- `_on_scan(success, error, data)`:
|
||||
- failure → `show_error(error)` (covers missing zbar/camera too);
|
||||
- `parse_frame` errors → `show_warning(_("Not a BAL will QR"))`;
|
||||
- first valid frame adopts `total` and materializes the slot grid;
|
||||
- frame whose `total` ≠ adopted total → warn + offer Reset (user may have
|
||||
restarted the export with another size);
|
||||
- valid → `frames[index] = payload`; auto-advance `target_index` to the
|
||||
lowest missing index; refresh grid + counter.
|
||||
- Clicking an empty slot sets `target_index` (owner requirement: manual
|
||||
shot selection); a filled slot click asks to overwrite.
|
||||
- **Receive via Audio Modem…** (D7): shown only when
|
||||
`bal_window.get_audio_modem_plugin()` returns a usable instance. Handler:
|
||||
build a tiny adapter object exposing `setText(str)` that stores the text
|
||||
and invokes the shared post-receive continuation, then call
|
||||
`plugin._recv(parent=self, ...)`-style flow (F21 contract). On success the
|
||||
received string is treated as the **whole payload**: skip frames/slots
|
||||
entirely → `decode_transfer(text, compressed=False)` → continue at §8.2's
|
||||
item-building step (WillItem construction + validity pass + wizard).
|
||||
Errors from the modem surface through the plugin's own dialog; empty
|
||||
result (user cancelled) is silently ignored.
|
||||
- **Review & Sign** enabled only when `set(frames) == set(range(1, N+1))`:
|
||||
runs `assemble` + `decode_transfer` → `list[str]`; any `QrTransferError`
|
||||
surfaces as `show_error` and keeps the dialog open.
|
||||
- Build items exactly like `merge_single_transaction` (F4):
|
||||
`WillItem({"tx": s}, wallet=self.wallet)` per string; failures per-string
|
||||
are collected and reported at the end (bad string ≠ fatal for the rest).
|
||||
- Local validity pass (F5 recipe) on the resulting dict; items failing
|
||||
`VALID` are dropped and listed in a warning. Set
|
||||
`wi.set_status("IMPORTED", True)` on survivors (mirrors
|
||||
`import_will_into_details`, window.py:1753-1754).
|
||||
- Then `close()` and start the wizard (§8.3) with the valid subset. Empty
|
||||
result → stop with a message.
|
||||
|
||||
### 8.3 `WillTxReviewSignDialog(BalDialog)` — post-capture wizard (D6)
|
||||
|
||||
Constructed with `(bal_window, willitems: dict[str, WillItem])` — the
|
||||
imported subset lives **outside** the live wallet state (external mode,
|
||||
F3).
|
||||
|
||||
Flow:
|
||||
|
||||
1. **Password once**: `password = bal_window.get_wallet_password()`
|
||||
(window.py:1088-1100). Returns `False` on cancel → abort wizard; `None`
|
||||
means unencrypted wallet → proceed without password.
|
||||
2. **Per-transaction page** (one `QStackedWidget` step per tx, ordered by
|
||||
txid like export):
|
||||
|
||||
```
|
||||
Tx 2 of 5 — a1b2…c3d1 (short txid)
|
||||
Locktime: 2033-04-05 Status: unsigned (0/1 sigs)
|
||||
┌ outputs ─────────────────────────────────┐
|
||||
│ bc1q…heir1 0,042 BTC │
|
||||
│ bc1q…willexec fee 0,00012 BTC │
|
||||
│ bc1q…change 0,00988 BTC │
|
||||
└───────────────────────────────────────────┘
|
||||
Total outputs: 0,052 BTC Fees: 420 sat (1.2 sat/vB)
|
||||
[Sign & Next ▶] [Skip] [Cancel all]
|
||||
```
|
||||
- Outputs from `tx.outputs()` (address via `TxOutput.get_ui_address_str()`
|
||||
style helpers already imported in the qt layer; value via
|
||||
`bal_window.window.format_amount`).
|
||||
- Totals: `output_value()` sum; fees via `input_value() - output_value()`
|
||||
after resolving inputs with `Will.add_info_from_will(will, wid, wallet)`
|
||||
(F10); `-1`/unknown handled like widgets.py:1319-1324 (F9).
|
||||
3. **Sign & Next** → sign this single tx through a **refactored helper**
|
||||
extracted from the loop body of `sign_transactions`
|
||||
(window.py:1037-1083 → `_sign_single_tx(tx, willitems, password)` kept
|
||||
byte-equivalent; batch method calls the helper per iteration so existing
|
||||
behaviour/tests are unaffected). Update `COMPLETE`/sig-counts exactly as
|
||||
today; then advance.
|
||||
4. **Skip** leaves the tx untouched and advances. **Cancel all** stops; the
|
||||
already-signed txs remain in the wizard's local dict (still exportable —
|
||||
confirmation dialog warns about skipped ones).
|
||||
5. **Summary page**: `signed X of Y`, skipped/failed lists, then:
|
||||
|
||||
```
|
||||
[Save signed file…] [Show QR…] [Close]
|
||||
```
|
||||
- *Save file* = existing JSON path: `export_meta_gui(window,
|
||||
"will.json", writer)` writing `{wid: wi.to_dict()}` of the signed
|
||||
subset (same serializer as `export_json_file`, window.py:1605).
|
||||
- *Show QR* = `WillQrExportDialog` over `[str(wi.tx)]` of the signed
|
||||
subset (the online machine can scan them straight into Merge).
|
||||
- Nothing touches `self.willitems`/history (external-mode rule, F3).
|
||||
|
||||
---
|
||||
|
||||
## 9. Checklist (execution order — tick here when resuming work)
|
||||
|
||||
- [x] **P0** `bal/core/qrtransfer.py` + unit tests `tests/test_core_qr_transfer.py`
|
||||
(cases: round-trip plain/compressed; boundaries: len%size==0, size>len,
|
||||
min-size guard; bad magic/version; missing middle frame; duplicate
|
||||
overwrite; inconsistent totals; multi-PSBT mixes; presets sanity vs
|
||||
qrcode capacities F15). Run:
|
||||
`QT_QPA_PLATFORM=offscreen python3 tests/test_core_qr_transfer.py`
|
||||
- [x] **P1** Settings: `QR_CHUNK_SIZE` config var + settings-dialog row 16
|
||||
(ø16) + reset kind (§6). Verify in `QT_QPA_PLATFORM=offscreen` GUI run.
|
||||
- [x] **P2** Export: `BalWindow.export_will_via_qr`, `get_audio_modem_plugin`
|
||||
helper, `WillList` menu action, `WillQrExportDialog` + `BalQrImage`,
|
||||
audio-modem send button (§7).
|
||||
- [x] **P3** Import: `import_will_via_qr`, `WillQrImportDialog` (§8.2),
|
||||
incl. camera error paths, audio-modem receive button (local mirror of
|
||||
`_recv`, `setText` sink replaced by a callback), plain-payload fast
|
||||
path into the wizard.
|
||||
- [x] **P4** Wizard: `_prepare_and_sign_tx` refactor + `WillTxReviewSignDialog`
|
||||
(§8.3). Regression-gate: full batch sign still green
|
||||
(`tests/test_core_*.py` offline batch; `tests/test_gui_*.py` batch
|
||||
including new `tests/test_gui_qr_transfer.py`).
|
||||
- [x] **P5** Docs & QML plan sync: update `QML_PLAN.md` — Phase 2 models +=
|
||||
`BalQrTransferModel` (thin QObject over `bal.core.qrtransfer`),
|
||||
Phase 3 += dedicated views `BalQrExportPage.qml` /
|
||||
`BalQrImportPage.qml` (slot grid + `QRScan` reuse), delete the
|
||||
"chunked streams deferred" note, rewrite R6 mitigation, add Android
|
||||
caveat quoting F17 with file/paste fallback; README/HANDOFF sections;
|
||||
CHANGELOG numbered entry 56 at END (house rule).
|
||||
- [x] **P6** Release hygiene: `python3 build_zip.py` +
|
||||
`QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py
|
||||
electrum.plugins.bal` + external-zip test; ruff (repo venv
|
||||
`venv/bin/ruff`) no NEW violations; pyright false-positive policy per
|
||||
AGENTS.md. Version bump only via `make-release.sh` (owner-driven).
|
||||
Docs: document audio-modem as OPTIONAL channel — requires the
|
||||
Electrum `audio_modem` plugin enabled plus `pip install amodem`
|
||||
and libportaudio (not installed in the dev runtime env today, F22);
|
||||
manual test matrix gains an audiomodem round-trip row (two machines,
|
||||
default slowest bitrate) marked optional/skippable when hardware
|
||||
unavailable.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| High frame counts annoy users (e.g. 40+ QR at 150 B) | Presets span 150→1800; compress option; counter always visible |
|
||||
| Big QR versions fail on cheap cameras | EC=M fixed; Small preset targets low-res cams (D5 rationale) |
|
||||
| User rescans old export with different total | `InconsistentTotalError` → clear warning + Reset (§8.2) |
|
||||
| `_sign_single_tx` refactor regresses batch signing | Byte-equivalent extraction; batch callers unchanged; offline core tests gate P4 |
|
||||
| Imported txs reference UTXOs the importing wallet doesn't know | Validity pass drops them with an explicit report instead of silently merging garbage |
|
||||
| zbar/camera unavailable (esp. Windows/macOS packaging) | `scan_qrcode_from_camera` error path → suggest file export/import fallback |
|
||||
| `amodem`/portaudio not installed (current dev env state, F22) or audio_modem plugin disabled | Buttons simply hidden; QR/file remain the primary channels; P6 documents the optional dependency |
|
||||
| Audio transfer fails mid-way (noise, wrong volume) | Plugin's WaitingDialog surfaces the error; user retries — nothing to clean up on BAL side (single atomic blob, no slot state touched) |
|
||||
| Very slow airtime at default slowest bitrate | Bitrate is selectable in the audio_modem plugin's own settings (F20); BAL adds no knob; tooltip in export dialog hints at large payloads |
|
||||
| Qt6 camera instability on Android (future QML work) | Recorded as caveat in QML_PLAN update (P5), file/paste stays the primary mobile fallback |
|
||||
|
||||
---
|
||||
|
||||
## 11. Findings log (append-only)
|
||||
|
||||
- 2026-08-25: plan drafted after code exploration; owner answered D1-D6
|
||||
(D6 amended live from "preview dialog" to "review+sign wizard").
|
||||
- Verified F12 (QRCodeWidget hardcodes EC-L) and F11 (str(tx) round-trip
|
||||
guarantees) — both shaped §§4/7.
|
||||
- 2026-08-25: owner requested an audio-modem transfer path → researched
|
||||
`electrum/plugins/audio_modem/qt.py`, added D7 + F20-F23, §4.4, buttons
|
||||
in §§7.3/8.2, checklist/risk updates. Key constraint found: `_recv`'s
|
||||
only contract is `parent.setText(blob)` (F21) → thin adapter object; and
|
||||
BAL must not chunk/compress on this channel (F23). `amodem` is NOT in
|
||||
the runtime env yet — feature is strictly optional.
|
||||
124
README.md
124
README.md
@@ -5,10 +5,11 @@ Free and decentralized **Bitcoin inheritance** support for the
|
||||
that transfer your funds to your heirs if you stop refreshing them
|
||||
(dead-man's switch), optionally relayed by will-executor servers.
|
||||
|
||||
This repository contains a **behavior-preserving refactor** of the original
|
||||
plugin. The logic was kept byte-identical wherever possible; only the file
|
||||
layout was reorganized to cleanly separate **business logic** from the
|
||||
**PyQt GUI**.
|
||||
This repository contains a **refactored and extended** version of the original
|
||||
plugin. The logic was reorganized to cleanly separate **business logic** from the
|
||||
**PyQt GUI**, and new features have been added including a headless CLI,
|
||||
auto-rebuild on new transactions, OP_RETURN heirs, and configurable calendar
|
||||
reminders.
|
||||
|
||||
## Repository layout
|
||||
|
||||
@@ -16,12 +17,22 @@ layout was reorganized to cleanly separate **business logic** from the
|
||||
bal/ the installable Electrum plugin package
|
||||
├── manifest.json plugin metadata (Electrum reads this)
|
||||
├── 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)
|
||||
│ ├── util.py
|
||||
│ ├── plugin_base.py
|
||||
│ ├── heirs.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
|
||||
│ ├── theme.py status → color mapping
|
||||
│ ├── common.py shared imports / helpers
|
||||
@@ -30,18 +41,28 @@ bal/ the installable Electrum plugin package
|
||||
│ ├── dialogs.py dialog windows
|
||||
│ ├── lists.py tree/list views
|
||||
│ ├── window.py per-wallet GUI controller
|
||||
│ ├── window_utils.py GUI utility helpers
|
||||
│ └── plugin.py Plugin (Electrum @hooks → GUI)
|
||||
├── icons/ wallet_util/ LICENSE VERSION README.md
|
||||
├── icons/ wallet_util/ LICENSE README.md
|
||||
build_zip.py builds a clean, zipimport-friendly distribution zip
|
||||
tests/ smoke + external-zip regression tests
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Electrum 4.7.2** — the last stable release exposing `json_db.register_dict`,
|
||||
which this plugin relies on. Newer versions removed it.
|
||||
- **Electrum 4.7.2 or 4.8.0** — the plugin detects which wallet-DB
|
||||
registration API is available (`json_db.register_dict` on 4.7.2,
|
||||
`stored_dict.register_name` on 4.8.0) and adapts automatically.
|
||||
- **PyQt6** (bundled with the Electrum desktop GUI).
|
||||
|
||||
## 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
|
||||
|
||||
### Build the distribution archive
|
||||
@@ -68,6 +89,67 @@ Copy the `bal/` directory into your Electrum installation's
|
||||
`electrum/plugins/` directory, so that `electrum/plugins/bal/manifest.json`
|
||||
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
|
||||
|
||||
A will transaction is signed with a **fixed, immutable locktime** and then
|
||||
@@ -76,12 +158,17 @@ to broadcast it (they collect fees). Because the locktime is baked into the
|
||||
signed transaction, simply changing the delivery time later is **not enough**:
|
||||
the old, already-signed transaction keeps living on the will-executors.
|
||||
|
||||
The plugin handles the two cases as follows (triggered when you press
|
||||
**Tools → Prepare**):
|
||||
The plugin handles the cases as follows (triggered when you press
|
||||
**Prepare** on the **WILL** tab):
|
||||
|
||||
* **Anticipate** (new delivery time *earlier* than the signed locktime): the
|
||||
will is treated as expired and you are asked to **invalidate** the old
|
||||
transaction on-chain, then rebuild.
|
||||
* **Anticipate** (new delivery time *earlier* than the signed locktime, still
|
||||
in the future): a plain **rebuild** — the transactions are re-created with
|
||||
the new, earlier locktime. **No on-chain invalidation and no Bitcoin fee**,
|
||||
even if the will was already signed/sent: moving the date earlier only makes
|
||||
the inheritance available *sooner*, so there is no early-execution risk.
|
||||
* **Expire** (new delivery time now in the **past**): the will is genuinely
|
||||
expired and you are asked to **invalidate** the old transaction on-chain,
|
||||
then rebuild.
|
||||
* **Postpone** (new delivery time *later* than the signed locktime) on a will
|
||||
that was already **signed and/or pushed**: the previously committed coins
|
||||
must be invalidated on-chain **first**, otherwise a will-executor could
|
||||
@@ -112,14 +199,15 @@ state.
|
||||
|
||||
## Testing
|
||||
|
||||
Run the tests with the **runtime environment** active (see `HANDOFF.md` §3 for
|
||||
the two venvs and how to activate them):
|
||||
|
||||
```bash
|
||||
# imports + behavior
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
|
||||
python3 tests/smoke_test.py electrum.plugins.bal
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py electrum.plugins.bal
|
||||
|
||||
# external-zip loading regression
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
|
||||
python3 tests/external_zip_test.py bal-electrum-plugin.zip
|
||||
# external-zip loading regression (run after build_zip.py)
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/external_zip_test.py bal-electrum-plugin.zip
|
||||
```
|
||||
|
||||
## ⚠️ Safety
|
||||
|
||||
10
android/.gitignore
vendored
Normal file
10
android/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
*.apk
|
||||
*.aab
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.hprof
|
||||
153
android/README.md
Normal file
153
android/README.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 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.
|
||||
67
android/app/build.gradle.kts
Normal file
67
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,67 @@
|
||||
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
Normal file
5
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Chaquopy Python runtime.
|
||||
-keep class com.chaquo.python.** { *; }
|
||||
|
||||
# ML Kit barcode scanning.
|
||||
-keep class com.google.mlkit.** { *; }
|
||||
31
android/app/src/main/AndroidManifest.xml
Normal file
31
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<?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>
|
||||
129
android/app/src/main/java/life/after/bitcoin/BalDecoder.kt
Normal file
129
android/app/src/main/java/life/after/bitcoin/BalDecoder.kt
Normal file
@@ -0,0 +1,129 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
209
android/app/src/main/java/life/after/bitcoin/MainActivity.kt
Normal file
209
android/app/src/main/java/life/after/bitcoin/MainActivity.kt
Normal file
@@ -0,0 +1,209 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
100
android/app/src/main/java/life/after/bitcoin/ResultActivity.kt
Normal file
100
android/app/src/main/java/life/after/bitcoin/ResultActivity.kt
Normal file
@@ -0,0 +1,100 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
21
android/app/src/main/python/bal/core/__init__.py
Normal file
21
android/app/src/main/python/bal/core/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
1180
android/app/src/main/python/bal/core/animated_qr.py
Normal file
1180
android/app/src/main/python/bal/core/animated_qr.py
Normal file
File diff suppressed because it is too large
Load Diff
304
android/app/src/main/python/bal/core/qrtransfer.py
Normal file
304
android/app/src/main/python/bal/core/qrtransfer.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
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
android/app/src/main/python/balreader/__init__.py
Normal file
1
android/app/src/main/python/balreader/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Android reader helpers built on the bundled plugin codecs."""
|
||||
33
android/app/src/main/python/balreader/bridge.py
Normal file
33
android/app/src/main/python/balreader/bridge.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""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": []})
|
||||
33
android/app/src/main/python/balreader/payload.py
Normal file
33
android/app/src/main/python/balreader/payload.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""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)
|
||||
34
android/app/src/main/res/drawable/ic_launcher.xml
Normal file
34
android/app/src/main/res/drawable/ic_launcher.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<?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>
|
||||
59
android/app/src/main/res/layout/activity_main.xml
Normal file
59
android/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,59 @@
|
||||
<?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>
|
||||
67
android/app/src/main/res/layout/activity_result.xml
Normal file
67
android/app/src/main/res/layout/activity_result.xml
Normal file
@@ -0,0 +1,67 @@
|
||||
<?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>
|
||||
5
android/app/src/main/res/values/colors.xml
Normal file
5
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="frame_fill">#9BE8C0</color>
|
||||
<color name="frame_track">#3A3A3A</color>
|
||||
</resources>
|
||||
24
android/app/src/main/res/values/strings.xml
Normal file
24
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<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>
|
||||
3
android/app/src/main/res/values/themes.xml
Normal file
3
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<style name="Theme.BalReader" parent="Theme.AppCompat.DayNight.NoActionBar" />
|
||||
</resources>
|
||||
7
android/build.gradle.kts
Normal file
7
android/build.gradle.kts
Normal file
@@ -0,0 +1,7 @@
|
||||
// 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
|
||||
}
|
||||
5
android/gradle.properties
Normal file
5
android/gradle.properties
Normal file
@@ -0,0 +1,5 @@
|
||||
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
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
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
Executable file
251
android/gradlew
vendored
Executable file
@@ -0,0 +1,251 @@
|
||||
#!/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
Normal file
94
android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@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
|
||||
161
android/scripts/build_apk.py
Executable file
161
android/scripts/build_apk.py
Executable file
@@ -0,0 +1,161 @@
|
||||
#!/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:]))
|
||||
99
android/scripts/sync_codecs.py
Normal file
99
android/scripts/sync_codecs.py
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/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:]))
|
||||
23
android/settings.gradle.kts
Normal file
23
android/settings.gradle.kts
Normal file
@@ -0,0 +1,23 @@
|
||||
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")
|
||||
314
android/test_chain/verify_chain.py
Normal file
314
android/test_chain/verify_chain.py
Normal file
@@ -0,0 +1,314 @@
|
||||
#!/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())
|
||||
202
android/tools/emitter.py
Normal file
202
android/tools/emitter.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""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:])
|
||||
26
android/tools/sample_will.json
Normal file
26
android/tools/sample_will.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"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
|
||||
asks you to invalidate the old transaction on-chain (so a will-executor can
|
||||
never broadcast the earlier-locktime transaction and execute the inheritance
|
||||
too early), then lets you rebuild and re-send the new one via
|
||||
**Tools → Prepare**.
|
||||
too early), then lets you rebuild and re-send the new one via the
|
||||
**Prepare** button on the **WILL** tab.
|
||||
- **"Server" column**: the will transaction list shows whether each transaction
|
||||
is actually stored on the will-executor servers
|
||||
(`Confirmed on server`, `Sent (not checked)`, `Send failed`,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
0.5.18
|
||||
@@ -24,11 +24,18 @@ distinct sub-packages:
|
||||
lists.py Tree/list views (heirs, preview, will-executors)
|
||||
window.py BalWindow controller (per-wallet GUI state)
|
||||
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
|
||||
cmdline.py Thin loader shim re-exporting `Plugin` for the daemon
|
||||
|
||||
Electrum discovers the plugin through ``manifest.json`` and loads the GUI
|
||||
entry point from ``qt.py`` (the shim), which imports the real ``Plugin``
|
||||
from ``gui.qt.plugin``.
|
||||
from ``gui.qt.plugin``; the command-line/daemon entry point is ``cmdline.py``
|
||||
(the shim), which imports ``Plugin`` from ``cli.plugin``.
|
||||
|
||||
The plugin supports Electrum 4.7.2 and 4.8.0 with PyQt6. Electrum 4.8.0 removed
|
||||
``json_db.register_dict`` and replaced it with the path-based
|
||||
@@ -36,4 +43,89 @@ The plugin supports Electrum 4.7.2 and 4.8.0 with PyQt6. Electrum 4.8.0 removed
|
||||
available and adapts, so both releases keep working.
|
||||
"""
|
||||
|
||||
__version__ = "0.5.18"
|
||||
# The plugin version is NOT defined here. It lives only in ``bal/manifest.json``
|
||||
# (the single source of truth) and is read at runtime via ``get_version()`` in
|
||||
# ``bal/core/plugin_base.py`` (exposed as the ``BalPlugin.version`` property).
|
||||
# Keeping a hardcoded ``__version__`` here would just be a stale duplicate.
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLI command registration
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Electrum's CLI pre-parse (run_electrum calls ``Plugins(config, cmd_only=True)``)
|
||||
# only imports the plugin package ``__init__`` to discover its commands.
|
||||
# Importing ``bal.cli.commands`` here registers every ``bal_*`` command with
|
||||
# ``electrum.commands`` (``known_commands`` + the ``Commands`` class), so the
|
||||
# commands become available on the command line and over JSON-RPC without any Qt.
|
||||
#
|
||||
# The import must be zip-safe: when the plugin is loaded as an external zip,
|
||||
# Electrum registers the package under the synthetic name
|
||||
# ``electrum_external_plugins.bal``, but the module's ``__package__`` is only
|
||||
# ``bal`` (the zip-internal directory name), which is not present in
|
||||
# ``sys.modules`` and cannot be used for sub-module imports. We therefore
|
||||
# resolve the real package name and import through ``importlib`` (the same
|
||||
# trick as ``qt.py``).
|
||||
import importlib
|
||||
import sys as _sys
|
||||
|
||||
|
||||
def _resolve_package_name() -> str:
|
||||
"""Return the name this package is registered under in ``sys.modules``.
|
||||
|
||||
Internal plugins are imported as ``electrum.plugins.bal`` (a normal import,
|
||||
so ``__package__`` is already correct). External zip plugins are imported
|
||||
under the synthetic name ``electrum_external_plugins.bal`` with
|
||||
``__package__`` set to just the zip-internal directory name (``bal``); only
|
||||
the synthetic name is present in ``sys.modules``.
|
||||
"""
|
||||
pkg = __package__ or "bal"
|
||||
if pkg in _sys.modules:
|
||||
return pkg
|
||||
synthetic = "electrum_external_plugins." + __name__
|
||||
if synthetic in _sys.modules:
|
||||
return synthetic
|
||||
return pkg
|
||||
|
||||
|
||||
def _ensure_parent_packages(pkg_name: str) -> None:
|
||||
"""Backfill missing ancestor packages in ``sys.modules``.
|
||||
|
||||
When loaded from a zip as an external plugin, Electrum only executes the
|
||||
package ``__init__``; the synthetic root package (``electrum_external_plugins``)
|
||||
may be missing, which would break sub-module imports. We stub it out as a
|
||||
namespace package so ``importlib`` can still resolve its children (same
|
||||
helper as ``qt.py``).
|
||||
"""
|
||||
parts = pkg_name.split(".")
|
||||
for i in range(1, len(parts)):
|
||||
ancestor = ".".join(parts[:i])
|
||||
if ancestor in _sys.modules:
|
||||
continue
|
||||
try:
|
||||
importlib.import_module(ancestor)
|
||||
except Exception:
|
||||
import types
|
||||
|
||||
module = types.ModuleType(ancestor)
|
||||
module.__path__ = [] # mark as a (namespace) package
|
||||
_sys.modules[ancestor] = module
|
||||
|
||||
|
||||
def _register_cli_commands() -> None:
|
||||
"""Import ``bal.cli.commands`` so Electrum registers the ``bal_*`` commands.
|
||||
|
||||
Guarded so a dual install (internal package AND external zip) cannot
|
||||
register the same command names twice, which would make
|
||||
``electrum.commands.plugin_command`` raise
|
||||
"Command name bal_... already exists".
|
||||
"""
|
||||
from electrum import commands as _electrum_commands
|
||||
|
||||
if getattr(_electrum_commands, "_bal_cli_commands_registered", False):
|
||||
return
|
||||
pkg = _resolve_package_name()
|
||||
_ensure_parent_packages(pkg)
|
||||
importlib.import_module(pkg + ".cli.commands")
|
||||
_electrum_commands._bal_cli_commands_registered = True
|
||||
|
||||
|
||||
_register_cli_commands()
|
||||
|
||||
19
bal/cli/__init__.py
Normal file
19
bal/cli/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
bal.cli
|
||||
=======
|
||||
|
||||
Headless command-line layer of the Bitcoin After Life (BAL) Electrum plugin.
|
||||
|
||||
This sub-package implements the ``"cmdline"`` front-end: it exposes the
|
||||
plugin's functionality through Electrum ``bal_*`` commands while reusing only
|
||||
the GUI-free logic from ``bal.core``. Like ``bal.core``, it MUST never import
|
||||
PyQt or ``electrum.gui``.
|
||||
|
||||
* ``bal.cli.commands`` -> the ``@plugin_command`` transport layer
|
||||
* ``bal.cli.controller`` -> headless replica of the Qt flows (later phases)
|
||||
* ``bal.cli.plugin`` -> ``Plugin(BalPlugin)`` entry point for the daemon
|
||||
|
||||
Electrum discovers the plugin through ``manifest.json`` (``available_for``
|
||||
includes ``"cmdline"``) and loads the entry point from ``cmdline.py``, a thin
|
||||
zip-safe shim following the same pattern as ``qt.py``.
|
||||
"""
|
||||
424
bal/cli/commands.py
Normal file
424
bal/cli/commands.py
Normal file
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
bal.cli.commands
|
||||
================
|
||||
|
||||
CLI commands (``bal_*``) for the Bitcoin After Life plugin.
|
||||
|
||||
This module is the *transport layer* of the command-line front-end: every
|
||||
function is a coroutine decorated with ``@plugin_command`` so Electrum exposes
|
||||
it as ``bal_<name>`` both on the command line and over JSON-RPC. The functions
|
||||
validate their arguments and delegate the real work to
|
||||
:mod:`bal.cli.controller` (a headless replica of the Qt flows); this module
|
||||
never imports Qt.
|
||||
|
||||
It must stay lightweight: Electrum imports it during the CLI pre-parse
|
||||
(``run_electrum`` calls ``Plugins(config, cmd_only=True)``) and on every
|
||||
GUI/daemon startup, before any wallet or network object exists. The heavy
|
||||
imports (``bal.core``, the controller) happen lazily inside each command.
|
||||
|
||||
Flags (see ``electrum.commands.plugin_command``):
|
||||
|
||||
* ``n`` -> requires a running daemon/network (always set for plugins);
|
||||
* ``w`` -> resolves and injects the wallet from the daemon;
|
||||
* ``p`` -> requires the wallet password (for signing).
|
||||
"""
|
||||
|
||||
from electrum.commands import plugin_command
|
||||
from electrum.util import UserFacingException
|
||||
|
||||
from .controller import BalController, _user_facing
|
||||
|
||||
plugin_name = "bal"
|
||||
|
||||
|
||||
def _controller(plugin, wallet):
|
||||
"""Build the headless controller, or fail with a clear message."""
|
||||
if plugin is None:
|
||||
raise UserFacingException("the bal plugin is not enabled in this daemon")
|
||||
if wallet is None:
|
||||
raise UserFacingException("wallet not loaded")
|
||||
return BalController(plugin, wallet)
|
||||
|
||||
|
||||
def _call(plugin, wallet, method, *args, **kwargs):
|
||||
controller = _controller(plugin, wallet)
|
||||
try:
|
||||
return getattr(controller, method)(*args, **kwargs)
|
||||
except Exception as e:
|
||||
raise _user_facing(e) from e
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Settings
|
||||
# --------------------------------------------------------------------------- #
|
||||
@plugin_command("n", plugin_name)
|
||||
async def settings_list(self, plugin=None):
|
||||
"""List all BAL plugin configuration options (key, name and value).
|
||||
|
||||
Returns a JSON object mapping every BAL configuration option (``bal_*``)
|
||||
to an object with ``value``, ``default`` and ``name``.
|
||||
"""
|
||||
return _call(plugin, None, "settings_list")
|
||||
|
||||
|
||||
@plugin_command("n", plugin_name)
|
||||
async def settings_get(self, key, plugin=None):
|
||||
"""Show the current value of one BAL configuration option.
|
||||
|
||||
arg:str:key:The configuration key (e.g. ``bal_tx_fees``).
|
||||
"""
|
||||
return _call(plugin, None, "settings_get", key)
|
||||
|
||||
|
||||
@plugin_command("n", plugin_name)
|
||||
async def settings_set(self, key, value, plugin=None):
|
||||
"""Set a BAL configuration option (booleans, integers, strings, JSON).
|
||||
|
||||
arg:str:key:The configuration key (e.g. ``bal_user_type``).
|
||||
arg:str:value:The new value; JSON for object-typed keys such as ``bal_will_settings``.
|
||||
"""
|
||||
return _call(plugin, None, "settings_set", key, value)
|
||||
|
||||
|
||||
@plugin_command("n", plugin_name)
|
||||
async def settings_reset(self, key, plugin=None):
|
||||
"""Reset a BAL configuration option to its default value.
|
||||
|
||||
arg:str:key:The configuration key (e.g. ``bal_tx_fees``).
|
||||
"""
|
||||
return _call(plugin, None, "settings_reset", key)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Heirs
|
||||
# --------------------------------------------------------------------------- #
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_list(self, wallet=None, plugin=None):
|
||||
"""List the heirs of the current wallet.
|
||||
|
||||
Returns a JSON object mapping heir names to their ``[address, amount,
|
||||
locktime]`` values.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_list")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_show(self, name, wallet=None, plugin=None):
|
||||
"""Show the details of a single heir.
|
||||
|
||||
arg:str:name:The heir name.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_show", name)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_add(self, name, address, amount, locktime=None, wallet=None, plugin=None):
|
||||
"""Add (or replace) an heir in the current wallet.
|
||||
|
||||
arg:str:name:The heir name.
|
||||
arg:str:address:The destination address (or ``OP_RETURN:<hex>`` for an OP_RETURN heir).
|
||||
arg:str:amount:The amount in satoshis or a percentage like ``50%%``.
|
||||
arg:str:locktime:The delivery locktime (absolute timestamp or ``30d``/``1y``); defaults to the will locktime.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_add", name, address, amount, locktime)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_update(
|
||||
self,
|
||||
name,
|
||||
address=None,
|
||||
amount=None,
|
||||
locktime=None,
|
||||
wallet=None,
|
||||
plugin=None,
|
||||
):
|
||||
"""Update an existing heir (only the given fields).
|
||||
|
||||
arg:str:name:The heir name.
|
||||
arg:str:address:The new destination address.
|
||||
arg:str:amount:The new amount in satoshis or a percentage.
|
||||
arg:str:locktime:The new delivery locktime.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_update", name, address, amount, locktime)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_delete(self, names, wallet=None, plugin=None):
|
||||
"""Delete one or more heirs.
|
||||
|
||||
arg:json:names:A JSON array of heir names (e.g. ``["Alice","Bob"]``).
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_delete", names)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_import(self, path, wallet=None, plugin=None):
|
||||
"""Import heirs from a JSON file (validated, merged).
|
||||
|
||||
arg:str:path:Path to the JSON file.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_import", path)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_export(self, path, wallet=None, plugin=None):
|
||||
"""Export the heirs to a JSON file.
|
||||
|
||||
arg:str:path:Destination file path.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_export", path)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Will-Executors
|
||||
# --------------------------------------------------------------------------- #
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_list(self, wallet=None, plugin=None):
|
||||
"""List the will-executors for the current network.
|
||||
|
||||
Returns a JSON object mapping executor URLs to their records (address,
|
||||
base_fee, status, info, selected, ...).
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_list")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_show(self, url, wallet=None, plugin=None):
|
||||
"""Show the details of a single will-executor.
|
||||
|
||||
arg:str:url:The will-executor URL.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_show", url)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_add(
|
||||
self,
|
||||
url,
|
||||
address="",
|
||||
base_fee=0,
|
||||
info=None,
|
||||
wallet=None,
|
||||
plugin=None,
|
||||
):
|
||||
"""Add a new will-executor (not selected by default).
|
||||
|
||||
arg:str:url:The will-executor base URL.
|
||||
arg:str:address:The executor fee address for this network.
|
||||
arg:int:base_fee:The executor base fee in satoshis.
|
||||
arg:str:info:A human-readable description.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_add", url, address, base_fee, info)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_update(
|
||||
self,
|
||||
url,
|
||||
address=None,
|
||||
base_fee=None,
|
||||
info=None,
|
||||
promo_code=None,
|
||||
rename_to=None,
|
||||
wallet=None,
|
||||
plugin=None,
|
||||
):
|
||||
"""Update an existing will-executor (only the given fields).
|
||||
|
||||
arg:str:url:The will-executor URL to update.
|
||||
arg:str:address:The new fee address.
|
||||
arg:int:base_fee:The new base fee in satoshis.
|
||||
arg:str:info:The new description.
|
||||
arg:str:promo_code:The new promo code.
|
||||
arg:str:rename_to:Optionally move the record to a new URL.
|
||||
"""
|
||||
return _call(
|
||||
plugin,
|
||||
wallet,
|
||||
"willexecutors_update",
|
||||
url,
|
||||
address,
|
||||
base_fee,
|
||||
info,
|
||||
promo_code,
|
||||
rename_to,
|
||||
)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_select(
|
||||
self, url, value=True, wallet=None, plugin=None
|
||||
):
|
||||
"""Select (or deselect) a will-executor.
|
||||
|
||||
arg:str:url:The will-executor URL.
|
||||
arg:bool:value:True to select, False to deselect.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_select", [url], value)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_delete(self, urls, wallet=None, plugin=None):
|
||||
"""Delete one or more will-executors.
|
||||
|
||||
arg:json:urls:A JSON array of executor URLs (e.g. ``["https://we.example.com"]``).
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_delete", urls)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_ping(self, urls=None, wallet=None, plugin=None):
|
||||
"""Ping the selected (or the given) will-executor servers.
|
||||
|
||||
Updates status/base_fee/address from each server and saves. Returns
|
||||
``{url: {status, ok}}``.
|
||||
|
||||
arg:json:urls:Optional JSON array of URLs to ping; defaults to the selected executors.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_ping", urls)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_download(self, wallet=None, plugin=None):
|
||||
"""Download the will-executor list from the welist server and merge it.
|
||||
|
||||
Returns the number of records downloaded and the new total.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_download")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_import(self, path, wallet=None, plugin=None):
|
||||
"""Import will-executors from a JSON file (``{url: record}``).
|
||||
|
||||
arg:str:path:Path to the JSON file.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_import", path)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_export(self, path, wallet=None, plugin=None):
|
||||
"""Export the will-executors to a JSON file.
|
||||
|
||||
arg:str:path:Destination file path.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_export", path)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Will
|
||||
# --------------------------------------------------------------------------- #
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_status(self, wallet=None, plugin=None):
|
||||
"""Show the current will: per-transaction status, locktime and executors.
|
||||
|
||||
Returns a JSON object with a per-txid detail list and global status counts.
|
||||
"""
|
||||
return _call(plugin, wallet, "will_status")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_check(self, wallet=None, plugin=None):
|
||||
"""Check the local coherence of the will (heirs, executors, fees, locktime).
|
||||
|
||||
Returns ``{"valid": true}`` when coherent, or raises a descriptive error.
|
||||
"""
|
||||
return _call(plugin, wallet, "will_check")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_prepare(self, wallet=None, plugin=None):
|
||||
"""Run the full prepare/inheritance flow (check, rebuild, persist).
|
||||
|
||||
Returns a JSON object with ``result`` (``coherent``, ``rebuilt``,
|
||||
``expired``, ``postponed``) and, when needed, the invalidation
|
||||
transaction to sign and broadcast.
|
||||
"""
|
||||
return _call(plugin, wallet, "prepare_will")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_autorebuild(self, wallet=None, plugin=None):
|
||||
"""Run the automatic rebuild flow in one shot (check, rebuild, sign, push).
|
||||
|
||||
The same flow the GUI runs automatically on new wallet transactions:
|
||||
the delivery date is anticipated by one day to orphan the old will on-chain
|
||||
and, only when the anticipated locktime crosses the Check Alive threshold
|
||||
(or the threshold is already in the past), an invalidation transaction is
|
||||
returned instead. Signing needs a passwordless wallet.
|
||||
|
||||
Returns a JSON object with ``result``: ``valid``, ``no_heirs``,
|
||||
``invalidated`` (with ``invalidation_tx``), ``nothing``,
|
||||
``needs_signing`` or ``rebuilt``.
|
||||
"""
|
||||
return _call(plugin, wallet, "auto_rebuild")
|
||||
|
||||
|
||||
@plugin_command("nwp", plugin_name)
|
||||
async def will_sign(self, txid=None, password=None, wallet=None, plugin=None):
|
||||
"""Sign the valid, not-yet-complete will transactions (or just one).
|
||||
|
||||
Updates the COMPLETE status and the signature counters and persists.
|
||||
|
||||
arg:str:txid:Optional transaction id to sign; signs all valid ones when omitted.
|
||||
"""
|
||||
txids = [txid] if txid is not None else None
|
||||
txs = _call(plugin, wallet, "sign_transactions", password, txids)
|
||||
return {wid: str(tx) for wid, tx in txs.items()}
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_broadcast(
|
||||
self, txid=None, force=False, wallet=None, plugin=None
|
||||
):
|
||||
"""Send the signed will transactions to their will-executors (in parallel).
|
||||
|
||||
Updates the PUSHED/PUSH_FAIL statuses and persists. Returns ``{url: status}``.
|
||||
|
||||
arg:str:txid:Optional transaction id to broadcast; all valid+signed ones when omitted.
|
||||
arg:bool:force:Force re-pushing transactions already marked as PUSHED.
|
||||
"""
|
||||
txids = [txid] if txid is not None else None
|
||||
return _call(plugin, wallet, "push_transactions_to_willexecutors", force, txids)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_export(self, path, wallet=None, plugin=None):
|
||||
"""Export the whole will to a JSON file.
|
||||
|
||||
arg:str:path:Destination file path.
|
||||
"""
|
||||
return _call(plugin, wallet, "export_will", path)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_import_merge(self, path, wallet=None, plugin=None):
|
||||
"""Merge a will file into the current will (PSBTs and statuses are merged).
|
||||
|
||||
arg:str:path:Path to the will JSON file.
|
||||
"""
|
||||
return _call(plugin, wallet, "merge_will_from_file", path)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_invalidate(self, wallet=None, plugin=None):
|
||||
"""Build the on-chain invalidation transaction for the current will.
|
||||
|
||||
Returns ``{txid, tx}`` (or nulls when there is nothing to invalidate); the
|
||||
transaction still needs to be signed and broadcast.
|
||||
"""
|
||||
return _call(plugin, wallet, "invalidate_will_command")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_check_executor(self, txid=None, wallet=None, plugin=None):
|
||||
"""Ask the will-executors whether they hold our pushed transactions.
|
||||
|
||||
Runs the searchtx check in parallel, applies the per-item status and
|
||||
persists. Returns ``{txid: {url, pushed, checked, check_fail}}``.
|
||||
|
||||
arg:str:txid:Optional transaction id to check; checks all pending ones when omitted.
|
||||
"""
|
||||
txids = [txid] if txid is not None else None
|
||||
return _call(plugin, wallet, "check_transactions", txids)
|
||||
1274
bal/cli/controller.py
Normal file
1274
bal/cli/controller.py
Normal file
File diff suppressed because it is too large
Load Diff
21
bal/cli/plugin.py
Normal file
21
bal/cli/plugin.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
bal.cli.plugin
|
||||
==============
|
||||
|
||||
The headless (command-line) entry point of the plugin.
|
||||
|
||||
:class:`Plugin` subclasses :class:`bal.core.plugin_base.BalPlugin` without
|
||||
adding any Qt hooks or per-window state. Electrum instantiates this class when
|
||||
the plugin runs with ``gui_name='cmdline'`` (the daemon loads
|
||||
``bal/cmdline.py``, which re-exports it), and it is the object injected as
|
||||
``plugin`` into every ``bal_*`` command by ``electrum.commands.plugin_command``.
|
||||
"""
|
||||
|
||||
from ..core.plugin_base import BalPlugin
|
||||
|
||||
|
||||
class Plugin(BalPlugin):
|
||||
"""Minimal ``BasePlugin`` subclass for the command-line front-end."""
|
||||
|
||||
def __init__(self, parent, config, name):
|
||||
BalPlugin.__init__(self, parent, config, name)
|
||||
69
bal/cmdline.py
Normal file
69
bal/cmdline.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
bal.cmdline
|
||||
===========
|
||||
|
||||
Compatibility shim for Electrum's plugin loader (command-line front-end).
|
||||
|
||||
Electrum loads a plugin with ``gui_name='cmdline'`` by importing the
|
||||
``cmdline`` module of the plugin package and looking for a ``Plugin`` class.
|
||||
The real implementation lives in the ``bal.cli`` sub-package, so this module
|
||||
re-exports ``Plugin`` from ``bal.cli.plugin``.
|
||||
|
||||
Like ``qt.py``, this file is not a one-line relative import because the very
|
||||
same code may be loaded as an *external* plugin from a ``.zip``, where Electrum
|
||||
imports the package under the synthetic top-level name
|
||||
``electrum_external_plugins.bal`` and never registers the intermediate parent
|
||||
packages. See the module docstring of ``bal.qt`` for the full rationale. The
|
||||
shim resolves the run-time package name, backfills the missing parents into
|
||||
``sys.modules`` and imports the real implementation via
|
||||
:func:`importlib.import_module`.
|
||||
|
||||
Unlike ``qt.py``, this module MUST never import PyQt (the daemon loads it in a
|
||||
headless process).
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
|
||||
def _ensure_parent_packages(pkg_name: str) -> None:
|
||||
"""Make sure every ancestor package of *pkg_name* is in ``sys.modules``.
|
||||
|
||||
When loaded from a zip as an external plugin, Electrum only executes the
|
||||
plugin package ``__init__`` and the ``cmdline`` module. The synthetic root
|
||||
package (e.g. ``electrum_external_plugins``) and any intermediate packages
|
||||
may be missing from ``sys.modules``, which breaks relative/absolute
|
||||
sub-module imports. We backfill them here using this module's own loader
|
||||
so that ``importlib`` can find sibling sub-packages.
|
||||
"""
|
||||
parts = pkg_name.split(".")
|
||||
# Walk from the top-most ancestor down to (but not including) pkg_name.
|
||||
for i in range(1, len(parts)):
|
||||
ancestor = ".".join(parts[:i])
|
||||
if ancestor in sys.modules:
|
||||
continue
|
||||
try:
|
||||
importlib.import_module(ancestor)
|
||||
except Exception:
|
||||
# The synthetic root (e.g. 'electrum_external_plugins') often has no
|
||||
# real spec. Create a minimal namespace package stub so that the
|
||||
# import machinery can still resolve its children.
|
||||
import types
|
||||
|
||||
module = types.ModuleType(ancestor)
|
||||
module.__path__ = [] # mark as a (namespace) package
|
||||
sys.modules[ancestor] = module
|
||||
|
||||
|
||||
# The package this module belongs to. Could be 'electrum.plugins.bal' (internal)
|
||||
# or 'electrum_external_plugins.bal' (external zip), depending on how Electrum
|
||||
# loaded us.
|
||||
_PKG = __package__ or "bal"
|
||||
|
||||
_ensure_parent_packages(_PKG)
|
||||
|
||||
# Import the real implementation using the fully-qualified, run-time package
|
||||
# name so it works regardless of the synthetic prefix Electrum assigned.
|
||||
_plugin_module = importlib.import_module(_PKG + ".cli.plugin")
|
||||
|
||||
Plugin = _plugin_module.Plugin # noqa: F401 (re-exported for Electrum)
|
||||
1180
bal/core/animated_qr.py
Normal file
1180
bal/core/animated_qr.py
Normal file
File diff suppressed because it is too large
Load Diff
159
bal/core/checkalive.py
Normal file
159
bal/core/checkalive.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
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,6 +21,8 @@ Will-executor "heirs" are synthetic entries whose key starts with the
|
||||
``w!ll3x3c"`` marker; they are skipped by most heir comparisons.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import math
|
||||
import random
|
||||
import re
|
||||
@@ -31,6 +33,7 @@ from typing import (
|
||||
Dict,
|
||||
Optional,
|
||||
Tuple,
|
||||
cast,
|
||||
)
|
||||
|
||||
import dns
|
||||
@@ -56,7 +59,7 @@ from electrum.util import (
|
||||
write_json_file,
|
||||
)
|
||||
|
||||
from .util import Util
|
||||
from .util import Util, copy_structure
|
||||
from .willexecutors import Willexecutors
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -65,6 +68,19 @@ if TYPE_CHECKING:
|
||||
|
||||
_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
|
||||
# wallet format and are relied upon all over the codebase, so they must NEVER
|
||||
# be reordered.
|
||||
@@ -75,6 +91,8 @@ HEIR_REAL_AMOUNT = 3 # resolved amount once percentages are computed
|
||||
HEIR_DUST_AMOUNT = 4 # amount when below dust threshold (marked "DUST: ...")
|
||||
TRANSACTION_LABEL = "inheritance transaction"
|
||||
|
||||
OP_RETURN_PREFIX = "OP_RETURN:"
|
||||
|
||||
|
||||
class AliasNotFoundException(Exception):
|
||||
pass
|
||||
@@ -86,6 +104,27 @@ def reduce_outputs(in_amount, out_amount, fee, outputs):
|
||||
output.value = math.floor((in_amount - fee) / out_amount * output.value)
|
||||
|
||||
|
||||
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:
|
||||
"""Crea scriptpubkey OP_RETURN in bytes"""
|
||||
data = bytes.fromhex(data_hex)
|
||||
@@ -132,14 +171,22 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
|
||||
heir[HEIR_REAL_AMOUNT]
|
||||
):
|
||||
try:
|
||||
real_amount = heir[HEIR_REAL_AMOUNT]
|
||||
outputs.append(
|
||||
PartialTxOutput.from_address_and_value(
|
||||
heir[HEIR_ADDRESS], real_amount
|
||||
if is_op_return_address(heir[HEIR_ADDRESS]):
|
||||
data_hex = heir[HEIR_ADDRESS][len(OP_RETURN_PREFIX):]
|
||||
op_return_script = create_op_return_script(data_hex)
|
||||
outputs.append(
|
||||
PartialTxOutput(value=0, scriptpubkey=op_return_script)
|
||||
)
|
||||
)
|
||||
out_amount += real_amount
|
||||
description += f"{name}\n"
|
||||
description += f"{name}\n"
|
||||
else:
|
||||
real_amount = heir[HEIR_REAL_AMOUNT]
|
||||
outputs.append(
|
||||
PartialTxOutput.from_address_and_value(
|
||||
heir[HEIR_ADDRESS], real_amount
|
||||
)
|
||||
)
|
||||
out_amount += real_amount
|
||||
description += f"{name}\n"
|
||||
except BitcoinException as e:
|
||||
_logger.info("exception decoding output {} - {}".format(type(e), e))
|
||||
heir[HEIR_REAL_AMOUNT] = e
|
||||
@@ -174,7 +221,7 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
|
||||
change = get_change_output(wallet, in_amount, out_amount, fee)
|
||||
if change:
|
||||
outputs.append(change)
|
||||
for i in range(0, 100):
|
||||
for _ in range(0, 100):
|
||||
random.shuffle(outputs)
|
||||
|
||||
#op_return_text = "Hello Bal!"
|
||||
@@ -230,6 +277,7 @@ def get_utxos_from_inputs(tx_inputs, tx, utxos):
|
||||
|
||||
# TODO calculate de minimum inputs to be invalidated
|
||||
def invalidate_inheritance_transactions(wallet):
|
||||
_logger.debug("invalidate tx in heir method")
|
||||
# listids = []
|
||||
utxos = {}
|
||||
dtxs = {}
|
||||
@@ -249,7 +297,7 @@ def invalidate_inheritance_transactions(wallet):
|
||||
del dtxs[txid]
|
||||
|
||||
utxos = {}
|
||||
for txid, tx in dtxs.items():
|
||||
for _, tx in dtxs.items():
|
||||
get_utxos_from_inputs(tx.inputs(), tx, utxos)
|
||||
|
||||
utxos = sorted(utxos.items(), key=lambda item: len(item[1]))
|
||||
@@ -264,39 +312,6 @@ def invalidate_inheritance_transactions(wallet):
|
||||
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):
|
||||
change_amount = int(in_amount - out_amount - fee)
|
||||
if change_amount > wallet.dust_threshold():
|
||||
@@ -306,40 +321,14 @@ def get_change_output(wallet, in_amount, out_amount, fee):
|
||||
return out
|
||||
|
||||
|
||||
def _json_safe(value, _path="heirs", _depth=0):
|
||||
"""Return a JSON-serializable deep copy of *value*.
|
||||
def _json_safe(value, _path="heirs"):
|
||||
"""Backward-compatible alias of :func:`bal.core.util.copy_structure`.
|
||||
|
||||
The wallet DB persists the heirs dict via ``json_db.put``, which calls
|
||||
``copy.deepcopy`` on the value. If any nested element is a live runtime
|
||||
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.
|
||||
Kept so call sites that imported ``_json_safe`` directly keep working; the
|
||||
actual implementation (a JSON-safe, deepcopy-free clone) lives in
|
||||
``bal.core.util`` so every copy path shares one code base.
|
||||
"""
|
||||
# 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)
|
||||
return copy_structure(value, _path=_path)
|
||||
|
||||
|
||||
class Heirs(dict, Logger):
|
||||
@@ -348,6 +337,10 @@ class Heirs(dict, Logger):
|
||||
Logger.__init__(self)
|
||||
self.db = wallet.db
|
||||
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", {})
|
||||
try:
|
||||
self.update(d)
|
||||
@@ -400,6 +393,9 @@ class Heirs(dict, Logger):
|
||||
amount = 0
|
||||
for key, v in heir_list.items():
|
||||
try:
|
||||
if is_op_return_address(v[HEIR_ADDRESS]):
|
||||
heir_list[key].insert(HEIR_REAL_AMOUNT, 0)
|
||||
continue
|
||||
column = HEIR_AMOUNT
|
||||
if real:
|
||||
column = HEIR_REAL_AMOUNT
|
||||
@@ -451,6 +447,12 @@ class Heirs(dict, Logger):
|
||||
)
|
||||
)
|
||||
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]):
|
||||
percent_amount += float(self[key][HEIR_AMOUNT][:-1])
|
||||
percent_heirs[key] = list(self[key])
|
||||
@@ -478,7 +480,8 @@ class Heirs(dict, Logger):
|
||||
)
|
||||
|
||||
def prepare_lists(
|
||||
self, balance, total_fees, wallet, willexecutor=False, from_locktime=0
|
||||
self, balance, total_fees, wallet, willexecutor: Optional[dict] = None,
|
||||
from_locktime=0, max_fee=None,
|
||||
):
|
||||
if balance<total_fees or balance < wallet.dust_threshold():
|
||||
raise BalanceTooLowException(balance,wallet.dust_threshold(),total_fees)
|
||||
@@ -493,8 +496,12 @@ class Heirs(dict, Logger):
|
||||
if int(Util.int_locktime(locktime)) > int(from_locktime):
|
||||
try:
|
||||
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
|
||||
h = [None] * 4
|
||||
h: list = [None] * 4
|
||||
h[HEIR_AMOUNT] = base_fee
|
||||
h[HEIR_REAL_AMOUNT] = base_fee
|
||||
h[HEIR_LOCKTIME] = locktime
|
||||
@@ -586,6 +593,8 @@ class Heirs(dict, Logger):
|
||||
heir[HEIR_REAL_AMOUNT]
|
||||
):
|
||||
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:
|
||||
raise HeirAmountIsDustException(
|
||||
"All heirs' shares are below the dust limit"
|
||||
@@ -599,8 +608,35 @@ class Heirs(dict, Logger):
|
||||
def buildTransactions(
|
||||
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
|
||||
):
|
||||
Heirs._validate(self)
|
||||
# Reset the diagnostic reason at the start of every build attempt.
|
||||
#
|
||||
# WHY: when the build produced nothing, the GUI used to show a fixed
|
||||
# list of three "possible reasons" (low balance / dust shares /
|
||||
# check-alive after the delivery date). In practice the real cause is
|
||||
# often NONE of those three - several code paths below simply return
|
||||
# an empty result with no explanation at all, so the user was shown
|
||||
# three guesses that were all wrong. Each such path now records WHY
|
||||
# it gave up, and BalBuildWillDialog names the actual cause.
|
||||
#
|
||||
# Codes: NO_HEIRS, NO_UTXO, NO_WILLEXECUTOR_USABLE, NO_FUTURE_DATE,
|
||||
# WILLEXECUTOR_FEE, WILLEXECUTOR_FEE_TOO_HIGH, TX_BUILD_FAILED,
|
||||
# WILLEXECUTOR_TX_ERROR.
|
||||
self.last_build_error = None
|
||||
_before = list(self.keys())
|
||||
Heirs._validate(self, persist=False)
|
||||
_removed = [k for k in _before if k not in self]
|
||||
if _removed:
|
||||
# The build skips invalid heirs, but they are only dropped in memory
|
||||
# (persist=False): the wallet still keeps them, so the user can fix
|
||||
# or remove them deliberately instead of losing them silently.
|
||||
_logger.warning(
|
||||
"buildTransactions: skipped %d invalid heir(s) (kept in wallet, "
|
||||
"not removed): %s",
|
||||
len(_removed),
|
||||
", ".join(_removed),
|
||||
)
|
||||
if len(self) <= 0:
|
||||
self.last_build_error = "NO_HEIRS"
|
||||
_logger.info("while building transactions there was no heirs")
|
||||
return
|
||||
balance = 0.0
|
||||
@@ -612,35 +648,43 @@ class Heirs(dict, Logger):
|
||||
self.decimal_point = bal_plugin.get_decimal_point()
|
||||
no_willexecutors = bal_plugin.NO_WILLEXECUTOR.get()
|
||||
for utxo in utxos:
|
||||
if utxo.value_sats() > 0 * tx_fees:
|
||||
if utxo.value_sats() > 0:
|
||||
balance += utxo.value_sats()
|
||||
len_utxo_set += 1
|
||||
available_utxos.append(utxo)
|
||||
if len_utxo_set == 0:
|
||||
self.last_build_error = "NO_UTXO"
|
||||
_logger.info("no usable utxos")
|
||||
return
|
||||
j = -2
|
||||
willexecutorsitems = list(willexecutors.items())
|
||||
willexecutorslen = len(willexecutorsitems)
|
||||
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:
|
||||
j += 1
|
||||
if j >= willexecutorslen:
|
||||
break
|
||||
elif 0 <= j:
|
||||
url, willexecutor = willexecutorsitems[j]
|
||||
if not Willexecutors.is_selected(willexecutor) or willexecutor["base_fee"] < wallet.dust_threshold():
|
||||
if not (Willexecutors.is_selected(willexecutor) and Willexecutors.is_valid(willexecutor, max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(), dust=wallet.dust_threshold())):
|
||||
continue
|
||||
else:
|
||||
willexecutor["url"] = url
|
||||
elif j == -1:
|
||||
if not no_willexecutors:
|
||||
continue
|
||||
url = willexecutor = False
|
||||
url = willexecutor = None
|
||||
else:
|
||||
break
|
||||
processed_willexecutors += 1
|
||||
fees = {}
|
||||
i = 0
|
||||
txs = {}
|
||||
while i < 10:
|
||||
txs = {}
|
||||
redo = False
|
||||
@@ -651,9 +695,15 @@ class Heirs(dict, Logger):
|
||||
# newbalance = balance
|
||||
try:
|
||||
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:
|
||||
self.last_build_error = "WILLEXECUTOR_FEE"
|
||||
i = 10
|
||||
continue
|
||||
except WillExecutorFeeTooHighException:
|
||||
self.last_build_error = "WILLEXECUTOR_FEE_TOO_HIGH"
|
||||
i = 10
|
||||
continue
|
||||
if locktimes:
|
||||
@@ -662,19 +712,33 @@ class Heirs(dict, Logger):
|
||||
locktimes, available_utxos[:], fees, wallet
|
||||
)
|
||||
if not txs:
|
||||
self.last_build_error = "TX_BUILD_FAILED"
|
||||
return {}
|
||||
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(
|
||||
f"build transactions: error preparing transactions: {e}"
|
||||
"build transactions: error preparing transactions "
|
||||
"for will-executor %s: %r",
|
||||
(willexecutor or {}).get("url", "(none)"),
|
||||
e,
|
||||
)
|
||||
try:
|
||||
if "w!ll3x3c" in e.heirname:
|
||||
Willexecutors.is_selected(
|
||||
e.heirname[len("w!ll3x3c") :], False
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
raise e
|
||||
break
|
||||
total_fees = 0
|
||||
total_fees_real = 0
|
||||
total_in = 0
|
||||
@@ -698,12 +762,26 @@ class Heirs(dict, Logger):
|
||||
if i >= 10:
|
||||
break
|
||||
else:
|
||||
self.last_build_error = "NO_FUTURE_DATE"
|
||||
_logger.info(
|
||||
f"no locktimes for willexecutor {willexecutor} skipped"
|
||||
)
|
||||
break
|
||||
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
|
||||
|
||||
def get_transactions(
|
||||
@@ -776,7 +854,7 @@ class Heirs(dict, Logger):
|
||||
# support email-style addresses, per the OA standard
|
||||
url = url.replace("@", ".")
|
||||
try:
|
||||
records, validated = dnssec.query(url, dns.rdatatype.TXT)
|
||||
records, validated = _query_txt_records(url)
|
||||
except DNSException as e:
|
||||
_logger.info(f"Error resolving openalias: {repr(e)}")
|
||||
return None
|
||||
@@ -785,60 +863,78 @@ class Heirs(dict, Logger):
|
||||
string = to_string(record.strings[0], "utf8")
|
||||
if string.startswith("oa1:" + prefix):
|
||||
address = cls.find_regex(string, r"recipient_address=([A-Za-z0-9]+)")
|
||||
if not address:
|
||||
continue
|
||||
name = cls.find_regex(string, r"recipient_name=([^;]+)")
|
||||
if not name:
|
||||
name = address
|
||||
if not address:
|
||||
continue
|
||||
return address, name, validated
|
||||
|
||||
@staticmethod
|
||||
def find_regex(haystack, needle):
|
||||
def find_regex(haystack, needle) -> Optional[str]:
|
||||
regex = re.compile(needle)
|
||||
try:
|
||||
return regex.search(haystack).groups()[0]
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
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):
|
||||
raise NotAnAddress(f"not an address,{address}")
|
||||
return address
|
||||
|
||||
@staticmethod
|
||||
def validate_amount(amount):
|
||||
try:
|
||||
famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount)
|
||||
if famount <= 0.00000001:
|
||||
raise AmountNotValid(f"amount have to be positive {famount} < 0")
|
||||
except Exception as e:
|
||||
raise AmountNotValid(f"amount not properly formatted, {e}")
|
||||
raise AmountNotValid(f"amount not properly formatted, {e}") from e
|
||||
return amount
|
||||
|
||||
@staticmethod
|
||||
def validate_locktime(locktime, timestamp_to_check=False):
|
||||
try:
|
||||
if timestamp_to_check:
|
||||
if Util.parse_locktime_string(locktime, None) < timestamp_to_check:
|
||||
raise HeirExpiredException()
|
||||
except Exception as e:
|
||||
raise LocktimeNotValid(f"locktime string not properly formatted, {e}")
|
||||
raise LocktimeNotValid(f"locktime string not properly formatted, {e}") from e
|
||||
return locktime
|
||||
|
||||
@staticmethod
|
||||
def validate_heir(k, v, timestamp_to_check=False):
|
||||
address = Heirs.validate_address(v[HEIR_ADDRESS])
|
||||
amount = Heirs.validate_amount(v[HEIR_AMOUNT])
|
||||
if is_op_return_address(v[HEIR_ADDRESS]):
|
||||
amount = "0"
|
||||
else:
|
||||
amount = Heirs.validate_amount(v[HEIR_AMOUNT])
|
||||
locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check)
|
||||
return (address, amount, locktime)
|
||||
|
||||
def _validate(data, timestamp_to_check=False):
|
||||
@staticmethod
|
||||
def _validate(data, timestamp_to_check=False, persist=True):
|
||||
|
||||
for k, v in list(data.items()):
|
||||
if k == "heirs":
|
||||
return Heirs._validate(v, timestamp_to_check)
|
||||
return Heirs._validate(v, timestamp_to_check, persist)
|
||||
try:
|
||||
Heirs.validate_heir(k, v, timestamp_to_check)
|
||||
except Exception as e:
|
||||
_logger.info(f"exception heir removed {e}")
|
||||
data.pop(k)
|
||||
if persist:
|
||||
data.pop(k)
|
||||
else:
|
||||
# Drop the invalid heir in memory only, so the overridden
|
||||
# Heirs.pop (which calls save()) is not triggered: a build
|
||||
# must not silently delete heirs from the wallet.
|
||||
dict.pop(data, k)
|
||||
return data
|
||||
|
||||
|
||||
@@ -874,6 +970,19 @@ class WillExecutorFeeException(Exception):
|
||||
return "WillExecutorFeeException: {} fee:{}".format(
|
||||
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):
|
||||
def __init__(self,balance, dust_threshold, fees):
|
||||
self.balance=balance
|
||||
|
||||
193
bal/core/input_rules.py
Normal file
193
bal/core/input_rules.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
bal.core.input_rules
|
||||
====================
|
||||
|
||||
Pure, GUI-free rules for the plugin's text-input widgets: locktime bounds and
|
||||
acceptance, the RAW locktime sanitisation ("30d"/"1y"), and the percentage-or-
|
||||
amount field normalisation.
|
||||
|
||||
These used to live inside the Qt widget classes in ``bal.gui.qt.widgets``.
|
||||
Keeping them here makes them testable without Qt and lets any GUI front-end
|
||||
reuse the exact same parsing rules.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Optional, Tuple, Union
|
||||
|
||||
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
|
||||
|
||||
__all__ = [
|
||||
"NLOCKTIME_BLOCKHEIGHT_MAX",
|
||||
"NLOCKTIME_MAX",
|
||||
"NLOCKTIME_MIN",
|
||||
"LockTimeEditor",
|
||||
"normalize_locktime_raw_text",
|
||||
"normalize_perc_amount_text",
|
||||
"parse_perc_amount",
|
||||
"replace_dy_suffixes",
|
||||
]
|
||||
|
||||
|
||||
class LockTimeEditor:
|
||||
"""Acceptance bounds shared by the RAW and Date locktime editors.
|
||||
|
||||
A Qt widget mixin historically; the pure parts (bounds and acceptance test)
|
||||
live here so they can be tested and reused without Qt. Widget subclasses
|
||||
override ``min_allowed_value``/``max_allowed_value`` to tighten the bounds.
|
||||
"""
|
||||
|
||||
min_allowed_value = NLOCKTIME_MIN
|
||||
max_allowed_value = NLOCKTIME_MAX
|
||||
alarm = None
|
||||
|
||||
def get_value(self) -> Optional[int]:
|
||||
raise NotImplementedError()
|
||||
|
||||
def set_value(self, x: Any, force=True) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def is_acceptable_locktime(cls, x: Any) -> bool:
|
||||
"""True when ``x`` (string or int) is within the allowed locktime bounds.
|
||||
|
||||
An empty/falsy value is accepted (the field is not yet filled in).
|
||||
"""
|
||||
if not x: # e.g. empty string
|
||||
return True
|
||||
try:
|
||||
x = int(x)
|
||||
except Exception as _e:
|
||||
return False
|
||||
return cls.min_allowed_value <= x <= cls.max_allowed_value
|
||||
|
||||
@staticmethod
|
||||
def get_max_allowed_timestamp() -> int:
|
||||
"""Highest locktime timestamp accepted on this platform.
|
||||
|
||||
On 32-bit ``time_t`` Windows builds ``datetime.fromtimestamp`` overflows
|
||||
past 2038, so the ceiling is clamped to INT32_MAX (see #6170).
|
||||
"""
|
||||
ts = NLOCKTIME_MAX
|
||||
# Test if this value is within the valid timestamp limits (which is
|
||||
# platform-dependent). see #6170
|
||||
try:
|
||||
datetime.fromtimestamp(ts)
|
||||
except (OSError, OverflowError):
|
||||
ts = 2**31 - 1 # INT32_MAX
|
||||
datetime.fromtimestamp(ts) # test if raises
|
||||
return ts
|
||||
|
||||
|
||||
def replace_dy_suffixes(text: str) -> str:
|
||||
"""Strip the relative-time suffixes (d/y) from ``text``.
|
||||
|
||||
Only days ("d") and years ("y") are supported. The block-height suffix
|
||||
("b") was removed (A1): locktimes are always timestamps now.
|
||||
"""
|
||||
return str(text).replace("d", "").replace("y", "")
|
||||
|
||||
|
||||
def _checkbdy(s: str, pos: int, appendix: str) -> Tuple[int, str]:
|
||||
"""Keep a ``d``/``y`` suffix typed right after an existing suffix.
|
||||
|
||||
When the character just before ``pos`` equals ``appendix``, the text is
|
||||
re-normalised so only one suffix remains.
|
||||
"""
|
||||
try:
|
||||
charpos = pos - 1
|
||||
charpos = max(0, charpos)
|
||||
charpos = min(len(s) - 1, charpos)
|
||||
if appendix == s[charpos]:
|
||||
s = replace_dy_suffixes(s) + appendix
|
||||
pos = charpos
|
||||
except Exception:
|
||||
pass
|
||||
return pos, s
|
||||
|
||||
|
||||
def normalize_locktime_raw_text(
|
||||
text: str, pos: int
|
||||
) -> Tuple[str, bool, bool, int]:
|
||||
"""Sanitise the RAW locktime field text.
|
||||
|
||||
Only digits plus the day ("d") and year ("y") suffixes are kept; the block
|
||||
suffix ("b") is removed (A1). Exactly one ``d``/``y`` suffix survives.
|
||||
|
||||
Args:
|
||||
text: the raw field text.
|
||||
pos: the cursor position within ``text``.
|
||||
|
||||
Returns:
|
||||
``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised
|
||||
text and ``new_pos`` the adjusted cursor position.
|
||||
"""
|
||||
text = text.strip()
|
||||
chars = "0123456789dy"
|
||||
pos = len("".join([i for i in text[:pos] if i in chars]))
|
||||
s = "".join([i for i in text if i in chars])
|
||||
isdays = False
|
||||
isyears = False
|
||||
|
||||
pos, s = _checkbdy(s, pos, "d")
|
||||
pos, s = _checkbdy(s, pos, "y")
|
||||
|
||||
if "d" in s:
|
||||
isdays = True
|
||||
if "y" in s:
|
||||
isyears = True
|
||||
|
||||
if isdays:
|
||||
s = replace_dy_suffixes(s) + "d"
|
||||
if isyears:
|
||||
s = replace_dy_suffixes(s) + "y"
|
||||
return s, isdays, isyears, pos
|
||||
|
||||
|
||||
def normalize_perc_amount_text(text: str, decimal_point: str) -> Tuple[str, bool]:
|
||||
"""Sanitise the amount-or-percentage field text.
|
||||
|
||||
Keeps digits, ``%`` and the decimal point; a trailing ``%`` marks the value
|
||||
as a percentage (``is_perc``). At most 8 decimal digits after the point.
|
||||
|
||||
Args:
|
||||
text: the raw field text.
|
||||
decimal_point: the decimal separator character (``electrum`` uses
|
||||
``DECIMAL_POINT``, locale dependent).
|
||||
|
||||
Returns:
|
||||
``(clean, is_perc)``.
|
||||
"""
|
||||
text = text.strip()
|
||||
chars = "0123456789%"
|
||||
chars += decimal_point
|
||||
|
||||
s = "".join([i for i in text if i in chars])
|
||||
|
||||
if "%" in s:
|
||||
is_perc = True
|
||||
s = s.replace("%", "")
|
||||
else:
|
||||
is_perc = False
|
||||
|
||||
if decimal_point in s:
|
||||
p = s.find(decimal_point)
|
||||
s = s.replace(decimal_point, "")
|
||||
s = s[:p] + decimal_point + s[p : p + 8]
|
||||
if is_perc:
|
||||
s += "%"
|
||||
|
||||
return s, is_perc
|
||||
|
||||
|
||||
def parse_perc_amount(text: str, decimal_point: str) -> Union[None, Decimal, int]:
|
||||
"""Parse an amount-or-percentage field text into a numeric value.
|
||||
|
||||
Returns ``None`` when the text cannot be parsed.
|
||||
"""
|
||||
try:
|
||||
text = text.replace(decimal_point, ".")
|
||||
text = text.replace("%", "")
|
||||
return Decimal(text)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -21,18 +21,60 @@ serialised together with the wallet file.
|
||||
This module performs **no** GUI work and imports nothing from PyQt / electrum.gui.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from electrum import constants, json_db
|
||||
from electrum.logging import get_logger
|
||||
from electrum.plugin import BasePlugin
|
||||
from electrum.transaction import tx_from_any
|
||||
from electrum.util import classproperty
|
||||
|
||||
_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
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -67,7 +109,9 @@ def get_will(x):
|
||||
|
||||
try:
|
||||
# Electrum >= 4.8.0
|
||||
from electrum.stored_dict import register_name as _electrum_register_name
|
||||
from electrum.stored_dict import (
|
||||
register_name as _electrum_register_name, # pyright: ignore[reportMissingImports]
|
||||
)
|
||||
|
||||
def _register_will_dict(name, method, _type=None):
|
||||
"""Register a plugin dict in the wallet DB (Electrum >= 4.8.0 API)."""
|
||||
@@ -77,7 +121,7 @@ except ImportError:
|
||||
# Electrum <= 4.7.2
|
||||
def _register_will_dict(name, method, _type=None):
|
||||
"""Register a plugin dict in the wallet DB (Electrum <= 4.7.2 API)."""
|
||||
json_db.register_dict(name, method, _type)
|
||||
json_db.register_dict(name, method, _type) # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
|
||||
_register_will_dict("heirs", tuple)
|
||||
@@ -120,9 +164,6 @@ class BalPlugin(BasePlugin):
|
||||
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.
|
||||
default_app = {
|
||||
"Linux": "xdg-open",
|
||||
@@ -131,25 +172,21 @@ class BalPlugin(BasePlugin):
|
||||
}
|
||||
|
||||
# Human-readable chain name ("bitcoin", "testnet", "regtest", ...).
|
||||
chainname = (
|
||||
constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin"
|
||||
)
|
||||
# Must be a classproperty (not a plain class attribute) because the class
|
||||
# is defined before constants.net is set to the correct network — a plain
|
||||
# attribute would capture "bitcoin" and never update.
|
||||
@classproperty
|
||||
def chainname(cls):
|
||||
return constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin"
|
||||
|
||||
# Default geometry hint for some dialogs (kept from the original code).
|
||||
SIZE = (159, 97)
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
"""Return the plugin version, read once from the ``VERSION`` file."""
|
||||
if not self._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
|
||||
"""Plugin version, read from ``bal/manifest.json`` (single source of
|
||||
truth). See :func:`get_version`."""
|
||||
return get_version()
|
||||
|
||||
def __init__(self, parent, config, name):
|
||||
self.logger = get_logger(__name__)
|
||||
@@ -197,6 +234,21 @@ class BalPlugin(BasePlugin):
|
||||
self.PREVIEW = BalConfig(config, "bal_preview", 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
|
||||
# querying the will-executor servers, automatically sign the will
|
||||
# transactions and broadcast them to their will-executors, without the
|
||||
@@ -205,12 +257,37 @@ class BalPlugin(BasePlugin):
|
||||
# (handled by BalWindow.get_wallet_password). Default ON.
|
||||
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
|
||||
# check-alive date fields are editable everywhere (toolbar / Heirs tab),
|
||||
# not only inside the "Build your will" wizard. Default OFF, so the dates
|
||||
# stay display-only outside the wizard unless the user opts in.
|
||||
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
|
||||
# exported .ics calendar should contain. Each reminder becomes its own
|
||||
# VEVENT (its own date in the calendar). The dates are spread uniformly
|
||||
@@ -228,6 +305,9 @@ class BalPlugin(BasePlugin):
|
||||
# follows what is saved in that wallet (the default only applies when no
|
||||
# value has been stored yet, i.e. new wallets).
|
||||
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_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)
|
||||
self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True)
|
||||
@@ -257,7 +337,10 @@ class BalPlugin(BasePlugin):
|
||||
config, "bal_event_summary", "BAL -Will execution of $wallet_name"
|
||||
)
|
||||
|
||||
# Default will-executor servers, keyed by network.
|
||||
# Default will-executor servers, keyed by network. These addresses are
|
||||
# the ones currently reported by each server's <chain>/info endpoint and
|
||||
# are refreshed again on ping; testnet/testnet4 must NOT be regtest
|
||||
# (bcrt1...) addresses, which are invalid on those networks.
|
||||
self.WILLEXECUTORS = BalConfig(
|
||||
config,
|
||||
"bal_willexecutors",
|
||||
@@ -276,7 +359,7 @@ class BalPlugin(BasePlugin):
|
||||
"base_fee": 100000,
|
||||
"status": "New",
|
||||
"info": "Bitcoin After Life Will Executor",
|
||||
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
||||
"address": "tb1qp5tmrvtm6dmz23mzkf55n5d53xh39wt0gwpp5m",
|
||||
"selected": True,
|
||||
}
|
||||
},
|
||||
@@ -285,7 +368,7 @@ class BalPlugin(BasePlugin):
|
||||
"base_fee": 100000,
|
||||
"status": "New",
|
||||
"info": "Bitcoin After Life Will Executor",
|
||||
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
||||
"address": "tb1qfj5ewmczg8ck2z0eeff6uysdrxd4qdy2ltrx5a",
|
||||
"selected": True,
|
||||
}
|
||||
},
|
||||
@@ -356,7 +439,7 @@ class BalPlugin(BasePlugin):
|
||||
"""Fill in any missing will-setting with its default value."""
|
||||
defaults = BalPlugin.default_will_settings()
|
||||
if not will_settings:
|
||||
will_settings = []
|
||||
will_settings = {}
|
||||
if int(will_settings.get("baltx_fees", 0)) < 1:
|
||||
will_settings["baltx_fees"] = defaults['baltx_fees']
|
||||
if not will_settings.get("threshold"):
|
||||
@@ -378,7 +461,7 @@ class BalPlugin(BasePlugin):
|
||||
@staticmethod
|
||||
def default_will_settings():
|
||||
"""Default will settings: a fee rate plus absolute threshold/locktime."""
|
||||
will_settings = {"baltx_fees": 20}
|
||||
will_settings: dict[str, float] = {"baltx_fees": 20}
|
||||
will_settings.update(BalPlugin.default_will_settings_absolute())
|
||||
return will_settings
|
||||
|
||||
@@ -386,8 +469,8 @@ class BalPlugin(BasePlugin):
|
||||
def default_will_settings_absolute():
|
||||
"""Convert the default relative dates into absolute timestamps (from today)."""
|
||||
relative_dates = BalPlugin.default_will_settings_relative()
|
||||
today = date.today()
|
||||
dt = datetime(today.year, today.month, today.day, 0, 0, 0)
|
||||
today = datetime.now(tz=timezone.utc).date()
|
||||
dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc)
|
||||
threshold = (
|
||||
dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days())
|
||||
).timestamp()
|
||||
@@ -411,10 +494,12 @@ class BalTimestamp:
|
||||
* an integer -> an absolute UNIX timestamp (``unit is None``)
|
||||
"""
|
||||
|
||||
value = None
|
||||
unit = None
|
||||
value: int
|
||||
unit: str | None
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = 1
|
||||
self.unit = None
|
||||
str_value = str(value)
|
||||
if str_value and str_value[-1].lower() in ("y", "d"):
|
||||
self.value = int(str_value[:-1])
|
||||
@@ -443,14 +528,14 @@ class BalTimestamp:
|
||||
We clamp out-of-range timestamps to INT32_MAX, mirroring Electrum's own
|
||||
``get_max_allowed_timestamp`` workaround (see Electrum issue #6170).
|
||||
"""
|
||||
INT32_MAX = 2 ** 31 - 1
|
||||
int32_max = 2 ** 31 - 1
|
||||
try:
|
||||
return datetime.fromtimestamp(ts)
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc)
|
||||
except (OSError, OverflowError, ValueError):
|
||||
try:
|
||||
return datetime.fromtimestamp(min(int(ts), INT32_MAX))
|
||||
return datetime.fromtimestamp(min(int(ts), int32_max), tz=timezone.utc)
|
||||
except (OSError, OverflowError, ValueError):
|
||||
return datetime.fromtimestamp(INT32_MAX)
|
||||
return datetime.fromtimestamp(int32_max, tz=timezone.utc)
|
||||
|
||||
def to_date(self, from_date=None, reverse=False):
|
||||
"""Resolve to a ``datetime``.
|
||||
@@ -463,7 +548,7 @@ class BalTimestamp:
|
||||
return self._safe_fromtimestamp(self.value)
|
||||
else:
|
||||
if from_date is None:
|
||||
from_date = datetime.now()
|
||||
from_date = datetime.now(tz=timezone.utc)
|
||||
if isinstance(from_date, (int, float)):
|
||||
from_date = self._safe_fromtimestamp(from_date)
|
||||
reverse = 1 if not reverse else -1
|
||||
|
||||
304
bal/core/qrtransfer.py
Normal file
304
bal/core/qrtransfer.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
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
|
||||
228
bal/core/reminders.py
Normal file
228
bal/core/reminders.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
bal.core.reminders
|
||||
==================
|
||||
|
||||
Pure, GUI-free logic for the dead-man's-switch calendar reminders: choosing the
|
||||
reminder offsets (BASIC vs ADVANCED modes) and rendering them as an RFC-5545
|
||||
iCalendar (.ics) document.
|
||||
|
||||
Everything in this module is stdlib-only, so it can be imported and tested
|
||||
without Electrum or Qt (e.g. in the lint venv).
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def compute_reminder_offsets(days, count):
|
||||
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
|
||||
|
||||
Group D / D1. The reminders are spread uniformly across the check-alive
|
||||
period and always fall *before* the delivery deadline, i.e. every returned
|
||||
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
|
||||
|
||||
Rules:
|
||||
* ``count`` is the requested number of reminders (the settings dialog
|
||||
caps it at 5, default 3).
|
||||
* at most ONE reminder per available day: the effective number is
|
||||
``min(count, days)``;
|
||||
* with ``days`` available days, offsets are chosen as evenly spaced
|
||||
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
|
||||
= the first day of the period), de-duplicated and returned sorted
|
||||
descending (earliest reminder first).
|
||||
|
||||
Args:
|
||||
days: number of whole days between check-alive and the deadline.
|
||||
count: requested number of reminders.
|
||||
|
||||
Returns:
|
||||
A list of integer day-offsets (each ``>= 1``), e.g. ``[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,10 +18,14 @@ original implementation.
|
||||
"""
|
||||
|
||||
import bisect
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
||||
from electrum.logging import get_logger
|
||||
from electrum.transaction import PartialTxOutput
|
||||
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
# Bitcoin consensus rule: an nLockTime value strictly below this threshold is
|
||||
# interpreted as a *block height*, otherwise it is interpreted as a *UNIX
|
||||
# timestamp*.
|
||||
@@ -34,6 +38,41 @@ from electrum.transaction import PartialTxOutput
|
||||
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:
|
||||
"""Namespace of static helpers (kept as a class to preserve the original
|
||||
``Util.method(...)`` call sites used throughout the plugin)."""
|
||||
@@ -102,7 +141,7 @@ class Util:
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
now = datetime.now()
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
if locktime[-1] == "y":
|
||||
locktime = str(int(locktime[:-1]) * 365) + "d"
|
||||
if locktime[-1] == "d":
|
||||
@@ -130,6 +169,72 @@ class Util:
|
||||
+ 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
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -373,9 +478,9 @@ class Util:
|
||||
# On Windows datetime.fromtimestamp raises OverflowError past 2038
|
||||
# (e.g. NLOCKTIME_MAX); clamp to INT32_MAX (Electrum issue #6170).
|
||||
try:
|
||||
dt = datetime.fromtimestamp(locktime)
|
||||
dt = datetime.fromtimestamp(locktime, tz=timezone.utc)
|
||||
except (OverflowError, OSError, ValueError):
|
||||
dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1))
|
||||
dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1), tz=timezone.utc)
|
||||
dt -= timedelta(seconds=seconds)
|
||||
out = dt.timestamp()
|
||||
|
||||
@@ -383,34 +488,6 @@ class Util:
|
||||
out = 1
|
||||
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
|
||||
def get_lowest_locktimes(locktimes):
|
||||
"""Split a list of locktimes into (sorted_timestamps, sorted_blocks)."""
|
||||
@@ -425,32 +502,6 @@ class Util:
|
||||
|
||||
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
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -493,6 +544,106 @@ class Util:
|
||||
return True
|
||||
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
|
||||
def cmp_output(outputa, outputb):
|
||||
"""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.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
||||
from electrum.i18n import _
|
||||
from electrum.logging import Logger, get_logger
|
||||
from electrum.transaction import (
|
||||
@@ -40,10 +40,12 @@ from electrum.transaction import (
|
||||
tx_from_any,
|
||||
)
|
||||
from electrum.util import (
|
||||
UnrelatedTransactionException,
|
||||
bfh,
|
||||
)
|
||||
|
||||
from .util import Util
|
||||
from .heirs import WillExecutorFeeTooHighException
|
||||
from .util import Util, copy_structure
|
||||
from .willexecutors import Willexecutors
|
||||
|
||||
MIN_LOCKTIME = 1
|
||||
@@ -72,11 +74,6 @@ class Will:
|
||||
if not will[child[0]].father:
|
||||
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
|
||||
def only_valid(will):
|
||||
for k, v in will.items():
|
||||
@@ -105,15 +102,6 @@ class Will:
|
||||
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
|
||||
def get_tx_from_any(x):
|
||||
try:
|
||||
@@ -155,7 +143,7 @@ class Will:
|
||||
willitems = {}
|
||||
for wid in will:
|
||||
Will.add_info_from_will(will, wid, wallet)
|
||||
willitems[wid] = WillItem(will[wid])
|
||||
willitems[wid] = WillItem(will[wid], wallet=wallet)
|
||||
will = willitems
|
||||
errors = {}
|
||||
for wid in will:
|
||||
@@ -177,7 +165,7 @@ class Will:
|
||||
outputs = will[wid].tx.outputs()
|
||||
ow = will[wid]
|
||||
ow.normalize_locktime(others_input)
|
||||
will[wid] = WillItem(ow.to_dict())
|
||||
will[wid] = ow.copy()
|
||||
|
||||
for i in range(0, len(outputs)):
|
||||
Will.change_input(
|
||||
@@ -219,13 +207,8 @@ class Will:
|
||||
if ow.we["url"] == nw.we["url"]:
|
||||
if int(ow.we["base_fee"]) > int(nw.we["base_fee"]):
|
||||
return anticipate
|
||||
else:
|
||||
if int(ow.tx_fees) != int(nw.tx_fees):
|
||||
return anticipate
|
||||
else:
|
||||
ow.tx.locktime
|
||||
else:
|
||||
ow.tx.locktime
|
||||
elif int(ow.tx_fees) != int(nw.tx_fees):
|
||||
return anticipate
|
||||
else:
|
||||
if nw.we == ow.we:
|
||||
if not Util.cmp_heirs_by_values(ow.heirs, nw.heirs, [0, 3]):
|
||||
@@ -446,19 +429,25 @@ class Will:
|
||||
for wid in will:
|
||||
wtx = will[wid].tx
|
||||
found = False
|
||||
inp = None
|
||||
for inp in wtx.inputs():
|
||||
if inp.prevout.txid.hex() in will:
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
if not found and inp is not None:
|
||||
out[inp.prevout.to_str()] = inp
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def invalidate_will(will, wallet, fees_per_byte):
|
||||
def invalidate_will(will, wallet, fees_per_byte, history_label=None,
|
||||
will_locktime=None):
|
||||
_logger.debug("invalidate tx in will module")
|
||||
will_only_valid = Will.only_valid_list(will)
|
||||
inputs = Will.get_all_inputs(will_only_valid)
|
||||
utxos = wallet.get_utxos()
|
||||
if history_label is not None and will_locktime is not None:
|
||||
utxos = Util.get_available_utxos(wallet, history_label, will_locktime)
|
||||
else:
|
||||
utxos = wallet.get_utxos()
|
||||
filtered_inputs = []
|
||||
prevout_to_spend = []
|
||||
current_height = Util.get_current_height(wallet.network)
|
||||
@@ -472,11 +461,13 @@ class Will:
|
||||
utxo_to_spend = []
|
||||
for utxo in utxos:
|
||||
if utxo.is_coinbase_output() and utxo.block_height < current_height+100:
|
||||
_logger.debug("is not mature coinbase output")
|
||||
continue
|
||||
utxo_str = utxo.prevout.to_str()
|
||||
if utxo_str in prevout_to_spend:
|
||||
balance += inputs[utxo_str][0][2].value_sats()
|
||||
balance += utxo.value_sats()
|
||||
utxo_to_spend.append(utxo)
|
||||
_logger.debug("utxo to spend: {}".format(utxo_to_spend))
|
||||
if len(utxo_to_spend) > 0:
|
||||
change_addresses = wallet.get_change_addresses_for_new_transaction()
|
||||
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
|
||||
@@ -508,9 +499,10 @@ class Will:
|
||||
|
||||
@staticmethod
|
||||
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"):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def search_rai(all_inputs, all_utxos, will, wallet):
|
||||
@@ -534,10 +526,26 @@ class Will:
|
||||
wi.set_status("INVALIDATED", True)
|
||||
|
||||
else:
|
||||
if wallet.db.get_transaction(wi._id):
|
||||
wi.set_status("CONFIRMED", True)
|
||||
else:
|
||||
# The funding outpoint is not part of the will tree:
|
||||
# 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)
|
||||
|
||||
for child in wi.search(all_inputs):
|
||||
if child.tx.locktime < wi.tx.locktime:
|
||||
@@ -575,14 +583,22 @@ class Will:
|
||||
for inp in w.tx.inputs():
|
||||
inp_str = Util.utxo_to_str(inp)
|
||||
if inp_str not in utxos_list:
|
||||
if wallet:
|
||||
height = Will.check_tx_height(w.tx, wallet)
|
||||
if height < 0:
|
||||
if not wallet or not getattr(wallet, "adb", None):
|
||||
continue
|
||||
height = Will.check_tx_height(w.tx, wallet)
|
||||
if height < 0:
|
||||
# The will tx itself is not on-chain. A missing
|
||||
# funding UTXO is only a real problem when a
|
||||
# broadcast transaction actually spends it; a
|
||||
# wallet-local (history) copy of the same will tx
|
||||
# marks the funding spent locally and must not
|
||||
# invalidate the will.
|
||||
if Will._funding_really_spent(wallet, inp_str):
|
||||
Will.set_invalidate(wid, willtree)
|
||||
elif height == 0:
|
||||
w.set_status("MEMPOOL", True)
|
||||
else:
|
||||
w.set_status("CONFIRMED", True)
|
||||
elif height == 0:
|
||||
w.set_status("MEMPOOL", True)
|
||||
else:
|
||||
w.set_status("CONFIRMED", True)
|
||||
|
||||
# def reflect_to_children(treeitem):
|
||||
# if not treeitem.get_status("VALID"):
|
||||
@@ -598,7 +614,8 @@ class Will:
|
||||
# Will.reflect_to_children(wc)
|
||||
|
||||
@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 = (
|
||||
heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True)
|
||||
)
|
||||
@@ -614,13 +631,92 @@ class Will:
|
||||
raise PercAmountException(f"Perc amount({perc_amount}) =! 100%")
|
||||
|
||||
for url, wex in willexecutors.items():
|
||||
if Willexecutors.is_selected(wex):
|
||||
if Willexecutors.is_selected(wex) and Willexecutors.is_valid(wex, max_fee=max_fee, dust=dust):
|
||||
if max_fee is not None and int(wex["base_fee"]) > max_fee:
|
||||
raise WillExecutorFeeTooHighException(wex, max_fee)
|
||||
temp_balance = wallet_balance - int(wex["base_fee"])
|
||||
if fixed_amount >= temp_balance:
|
||||
raise FixedAmountException(
|
||||
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
|
||||
def check_will(will, all_utxos, wallet, timestamp_to_check):
|
||||
"""Validate a will against the current wallet state.
|
||||
@@ -636,6 +732,7 @@ class Will:
|
||||
timestamp_to_check: The reference UNIX timestamp (usually "now")
|
||||
used to decide whether any transaction has expired.
|
||||
"""
|
||||
Will._absorb_history_signatures(will, wallet)
|
||||
Will.add_willtree(will)
|
||||
utxos_list = Will.utxos_strs(all_utxos)
|
||||
|
||||
@@ -649,6 +746,228 @@ class Will:
|
||||
|
||||
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
|
||||
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)
|
||||
@@ -777,7 +1096,7 @@ class Will:
|
||||
timestamp_to_check: Reference UNIX timestamp (usually "now").
|
||||
"""
|
||||
_logger.info("check if some transaction is expired")
|
||||
for prevout_str, wid in all_inputs_min_locktime.items():
|
||||
for _inputs, wid in all_inputs_min_locktime.items():
|
||||
for w in wid:
|
||||
if w[1].get_status("VALID"):
|
||||
locktime = int(wid[0][1].tx.locktime)
|
||||
@@ -854,8 +1173,6 @@ class Will:
|
||||
if heir := heirs.get(wheir, None):
|
||||
|
||||
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
|
||||
# actually frozen inside the already-signed Bitcoin
|
||||
# transaction (w.tx.locktime), NOT against their[2].
|
||||
@@ -866,6 +1183,16 @@ class Will:
|
||||
# undetected. w.tx.locktime is immutable once signed
|
||||
# and is exactly what the will-executors hold.
|
||||
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:
|
||||
# Unchanged: this heir is still coherent.
|
||||
count = heirs_found.get(wheir, 0)
|
||||
@@ -929,15 +1256,15 @@ class Will:
|
||||
|
||||
if Util.parse_locktime_string(heirs[h][2]) >= check_date:
|
||||
count_heirs += 1
|
||||
if h not in heirs_found:
|
||||
_logger.debug(f"heir: {h} not found")
|
||||
raise HeirNotFoundException(h)
|
||||
if h not in heirs_found:
|
||||
_logger.debug(f"heir: {h} not found")
|
||||
raise HeirNotFoundException(h)
|
||||
if not count_heirs:
|
||||
raise NoHeirsException("there are not valid heirs")
|
||||
if self_willexecutor and no_willexecutor == 0:
|
||||
raise NoWillExecutorNotPresent("Backup tx")
|
||||
for url, we in willexecutors.items():
|
||||
if Willexecutors.is_selected(we):
|
||||
if Willexecutors.is_selected(we) and Willexecutors.is_valid(we):
|
||||
if url not in willexecutors_found:
|
||||
_logger.debug(f"will-executor: {url} not fount")
|
||||
raise WillExecutorNotPresent(url)
|
||||
@@ -974,6 +1301,7 @@ class WillItem(Logger):
|
||||
"MEMPOOL": ["Mempool", False],
|
||||
"PUSH_FAIL": ["Push failed", False],
|
||||
"PUSHED": ["Pushed", False],
|
||||
"PARTIALLY_SIGNED": ["Partially Signed", False],
|
||||
"REPLACED": ["Replaced", False],
|
||||
"RESTORED": ["Restored", False],
|
||||
"UPDATED": ["Updated", False],
|
||||
@@ -1032,51 +1360,85 @@ class WillItem(Logger):
|
||||
self.STATUS["PUSHED"][1] = True
|
||||
self.STATUS["PUSH_FAIL"][1] = False
|
||||
|
||||
if status in ["COMPLETE"]:
|
||||
self.STATUS["PARTIALLY_SIGNED"][1] = False
|
||||
|
||||
return value
|
||||
|
||||
def get_status(self, status):
|
||||
return self.STATUS[status][1]
|
||||
|
||||
def __init__(self, w, _id=None, wallet=None):
|
||||
if isinstance(
|
||||
w,
|
||||
WillItem,
|
||||
):
|
||||
self.__dict__ = w.__dict__.copy()
|
||||
else:
|
||||
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 isinstance(w, WillItem):
|
||||
# Copy a WillItem WITHOUT deepcopy. Serialize it to its plain-dict
|
||||
# form and deserialize from there: the tx is re-parsed into a fresh
|
||||
# object, STATUS is rebuilt from the clones below and heirs /
|
||||
# will-executors are cloned recursively, so the copy shares no
|
||||
# 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:
|
||||
self._id = self.tx.txid()
|
||||
else:
|
||||
self._id = _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:
|
||||
self._id = _id
|
||||
|
||||
if not self._id:
|
||||
self.status += "ERROR!!!"
|
||||
self.valid = False
|
||||
if not self._id:
|
||||
self.status += "ERROR!!!"
|
||||
self.valid = False
|
||||
|
||||
if 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):
|
||||
out = {
|
||||
"_id": self._id,
|
||||
@@ -1088,6 +1450,10 @@ class WillItem(Logger):
|
||||
"time": self.time,
|
||||
"change": self.change,
|
||||
"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:
|
||||
try:
|
||||
|
||||
@@ -17,13 +17,16 @@ interaction is handled by the Qt layer.
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
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.logging import get_logger
|
||||
from electrum.network import Network
|
||||
|
||||
from .plugin_base import BalPlugin
|
||||
from .plugin_base import BalPlugin, get_version
|
||||
|
||||
# Per-request timeout (seconds) for interactive operations (ping / info /
|
||||
# list download). These fail fast (no retries) so a dead server does not
|
||||
@@ -109,8 +112,6 @@ def is_tor_active():
|
||||
return False
|
||||
|
||||
|
||||
chainname = BalPlugin.chainname
|
||||
|
||||
|
||||
class Willexecutors:
|
||||
|
||||
@@ -143,19 +144,19 @@ class Willexecutors:
|
||||
|
||||
@staticmethod
|
||||
def save(bal_plugin, willexecutors):
|
||||
_logger.debug(f"save {willexecutors},{chainname}")
|
||||
_logger.debug(f"save {willexecutors},{BalPlugin.chainname}")
|
||||
aw = bal_plugin.WILLEXECUTORS.get()
|
||||
aw[chainname] = willexecutors
|
||||
aw[BalPlugin.chainname] = willexecutors
|
||||
bal_plugin.WILLEXECUTORS.set(aw)
|
||||
_logger.debug(f"saved: {aw}")
|
||||
# bal_plugin.WILLEXECUTORS.set(willexecutors)
|
||||
|
||||
@staticmethod
|
||||
def get_willexecutors(
|
||||
bal_plugin, update=False, bal_window=False, force=False, task=True
|
||||
bal_plugin, update=False, bal_window: Any = None, force=False, task=True
|
||||
):
|
||||
willexecutors = bal_plugin.WILLEXECUTORS.get()
|
||||
willexecutors = willexecutors.get(chainname, {})
|
||||
willexecutors = willexecutors.get(BalPlugin.chainname, {})
|
||||
to_del = []
|
||||
for w in willexecutors:
|
||||
if not isinstance(willexecutors[w], dict):
|
||||
@@ -169,7 +170,7 @@ class Willexecutors:
|
||||
)
|
||||
)
|
||||
del willexecutors[w]
|
||||
bal = bal_plugin.WILLEXECUTORS.default.get(chainname, {})
|
||||
bal = bal_plugin.WILLEXECUTORS.default.get(BalPlugin.chainname, {})
|
||||
for bal_url, bal_executor in bal.items():
|
||||
if bal_url not in willexecutors:
|
||||
_logger.debug(f"force add {bal_url} willexecutor")
|
||||
@@ -214,6 +215,20 @@ class Willexecutors:
|
||||
willexecutor["selected"] = 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
|
||||
def get_willexecutor_transactions(will, force=False):
|
||||
willexecutors = {}
|
||||
@@ -272,55 +287,48 @@ class Willexecutors:
|
||||
raise Exception("You are offline.")
|
||||
_logger.debug(f"<-- {method} {url} {data}")
|
||||
headers = {}
|
||||
headers["user-agent"] = f"BalPlugin v:{BalPlugin.__version__}"
|
||||
headers["user-agent"] = f"BalPlugin v:{get_version()}"
|
||||
headers["Content-Type"] = "text/plain"
|
||||
if not handle_response:
|
||||
handle_response = Willexecutors.handle_response
|
||||
try:
|
||||
if method == "get":
|
||||
response = Network.send_http_on_proxy(
|
||||
method,
|
||||
url,
|
||||
params=data,
|
||||
headers=headers,
|
||||
on_finish=handle_response,
|
||||
timeout=timeout,
|
||||
)
|
||||
elif method == "post":
|
||||
response = Network.send_http_on_proxy(
|
||||
method,
|
||||
url,
|
||||
body=data,
|
||||
headers=headers,
|
||||
on_finish=handle_response,
|
||||
timeout=timeout,
|
||||
)
|
||||
else:
|
||||
raise Exception(f"unexpected {method=!r}")
|
||||
except TimeoutError:
|
||||
if count_reply < max_retries:
|
||||
_logger.debug(
|
||||
f"timeout({count_reply}) error: retry in {retry_sleep} sec..."
|
||||
)
|
||||
if retry_sleep:
|
||||
time.sleep(retry_sleep)
|
||||
return Willexecutors.send_request(
|
||||
method,
|
||||
url,
|
||||
data,
|
||||
timeout=timeout,
|
||||
handle_response=handle_response,
|
||||
count_reply=count_reply + 1,
|
||||
max_retries=max_retries,
|
||||
retry_sleep=retry_sleep,
|
||||
)
|
||||
else:
|
||||
_logger.debug(f"Too many timeouts: {count_reply}")
|
||||
except Exception as e:
|
||||
raise e
|
||||
else:
|
||||
_logger.debug(f"--> {response}")
|
||||
return response
|
||||
attempts = max_retries + 1
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
if method == "get":
|
||||
response = Network.send_http_on_proxy(
|
||||
method,
|
||||
url,
|
||||
params=data,
|
||||
headers=headers,
|
||||
on_finish=handle_response,
|
||||
timeout=timeout,
|
||||
)
|
||||
elif method == "post":
|
||||
response = Network.send_http_on_proxy(
|
||||
method,
|
||||
url,
|
||||
body=data,
|
||||
headers=headers,
|
||||
on_finish=handle_response,
|
||||
timeout=timeout,
|
||||
)
|
||||
else:
|
||||
raise Exception(f"unexpected {method=!r}")
|
||||
_logger.debug(f"--> {response}")
|
||||
return response
|
||||
except TimeoutError:
|
||||
if attempt < max_retries:
|
||||
_logger.debug(
|
||||
f"timeout({attempt}) error: "
|
||||
f"retry in {retry_sleep} sec..."
|
||||
)
|
||||
if retry_sleep:
|
||||
time.sleep(retry_sleep)
|
||||
else:
|
||||
_logger.debug(f"Too many timeouts: {attempt}")
|
||||
except Exception as e:
|
||||
raise e
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_we_url_from_response(resp):
|
||||
@@ -331,15 +339,11 @@ class Willexecutors:
|
||||
|
||||
@staticmethod
|
||||
async def handle_response(resp: ClientResponse):
|
||||
resp.raise_for_status()
|
||||
r = await resp.text()
|
||||
try:
|
||||
|
||||
r = json.loads(r)
|
||||
# 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}")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return r
|
||||
|
||||
@@ -362,23 +366,25 @@ class Willexecutors:
|
||||
_logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}")
|
||||
if w := Willexecutors.send_request(
|
||||
"post",
|
||||
willexecutor["url"] + "/" + chainname + "/pushtxs",
|
||||
willexecutor["url"] + "/" + BalPlugin.chainname + "/pushtxs",
|
||||
data=willexecutor["txs"].encode("ascii"),
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
retry_sleep=retry_sleep,
|
||||
):
|
||||
willexecutor["broadcast_status"] = _("Success")
|
||||
_logger.debug(f"pushed: {w}")
|
||||
if w != "thx":
|
||||
_logger.debug(f"error: {w}")
|
||||
raise Exception(w)
|
||||
willexecutor["broadcast_status"] = _("Success")
|
||||
else:
|
||||
raise Exception("empty reply from:{willexecutor['url']}")
|
||||
raise Exception(
|
||||
f"empty reply from:{willexecutor['url']}"
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.debug(f"error:{e}")
|
||||
if str(e) == "already present":
|
||||
raise Willexecutors.AlreadyPresentException()
|
||||
raise Willexecutors.AlreadyPresentException() from None
|
||||
out = False
|
||||
willexecutor["broadcast_status"] = _("Failed")
|
||||
|
||||
@@ -400,15 +406,38 @@ class Willexecutors:
|
||||
# single short timeout instead of retrying 10x with sleeps, which
|
||||
# used to freeze the UI for minutes per unreachable server.
|
||||
w = Willexecutors.send_request(
|
||||
"get", url + "/" + chainname + "/info",
|
||||
"get", url + "/" + BalPlugin.chainname + "/info",
|
||||
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
|
||||
)
|
||||
if isinstance(w, dict):
|
||||
willexecutor["url"] = url
|
||||
willexecutor["status"] = 200
|
||||
willexecutor["base_fee"] = w["base_fee"]
|
||||
willexecutor["address"] = w["address"]
|
||||
willexecutor["info"] = w["info"]
|
||||
address = w.get("address")
|
||||
if not isinstance(address, str) or not bitcoin.is_address(
|
||||
address, net=constants.net
|
||||
):
|
||||
_logger.warning(
|
||||
f"invalid address from {url}: {address!r}"
|
||||
)
|
||||
willexecutor["status"] = "KO"
|
||||
else:
|
||||
base_fee = w.get("base_fee")
|
||||
try:
|
||||
base_fee = int(base_fee or 0)
|
||||
if base_fee < 0:
|
||||
raise ValueError("negative fee")
|
||||
if base_fee > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN:
|
||||
raise ValueError("fee exceeds total coin supply")
|
||||
except (TypeError, ValueError) as e:
|
||||
_logger.warning(
|
||||
f"invalid base_fee from {url}: "
|
||||
f"{w.get('base_fee')!r} ({e})"
|
||||
)
|
||||
willexecutor["status"] = "KO"
|
||||
else:
|
||||
willexecutor["url"] = url
|
||||
willexecutor["status"] = 200
|
||||
willexecutor["base_fee"] = base_fee
|
||||
willexecutor["address"] = address
|
||||
willexecutor["info"] = w.get("info", "")
|
||||
else:
|
||||
# No dict reply (timeout / empty) -> mark as unreachable.
|
||||
willexecutor["status"] = "KO"
|
||||
@@ -451,8 +480,7 @@ class Willexecutors:
|
||||
Returns:
|
||||
The same ``willexecutors`` mapping, updated in place.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor, wait
|
||||
from concurrent.futures import FIRST_COMPLETED
|
||||
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||
|
||||
items = list(willexecutors.items())
|
||||
if not items:
|
||||
@@ -532,8 +560,7 @@ class Willexecutors:
|
||||
Returns ``{url: (ok, exception_or_None)}`` for the servers that
|
||||
answered in time (timed-out servers are reported via ``on_timeout``).
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor, wait
|
||||
from concurrent.futures import FIRST_COMPLETED
|
||||
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||
|
||||
targets = [(url, we) for url, we in willexecutors.items() if "txs" in we]
|
||||
results = {}
|
||||
@@ -650,8 +677,7 @@ class Willexecutors:
|
||||
Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers
|
||||
that answered in time.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor, wait
|
||||
from concurrent.futures import FIRST_COMPLETED
|
||||
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||
|
||||
targets = [(wid, url) for wid, url in items if url]
|
||||
results = {}
|
||||
@@ -742,7 +768,14 @@ class Willexecutors:
|
||||
else:
|
||||
willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko"))
|
||||
willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False)
|
||||
willexecutor["address"]=old_willexecutor.get("address",willexecutor.get("address",""))
|
||||
address = old_willexecutor.get("address", willexecutor.get("address", ""))
|
||||
if address and not bitcoin.is_address(address, net=constants.net):
|
||||
_logger.warning(
|
||||
f"invalid address {address!r} for executor {url}, "
|
||||
f"falling back to empty"
|
||||
)
|
||||
address = ""
|
||||
willexecutor["address"] = address
|
||||
willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code"))
|
||||
|
||||
|
||||
@@ -753,16 +786,25 @@ class Willexecutors:
|
||||
welist_server = welist_server if welist_server[-1] == '/' else welist_server+'/'
|
||||
willexecutors = Willexecutors.send_request(
|
||||
"get",
|
||||
f"{welist_server}data/{chainname}?page=0&limit=100",
|
||||
f"{welist_server}data/{BalPlugin.chainname}?page=0&limit=100",
|
||||
)
|
||||
# del willexecutors["status"]
|
||||
if not isinstance(willexecutors, dict):
|
||||
_logger.warning(
|
||||
f"unexpected download_list response type: "
|
||||
f"{type(willexecutors).__name__}"
|
||||
)
|
||||
return {}
|
||||
for w in willexecutors:
|
||||
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[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
|
||||
|
||||
except Exception as e:
|
||||
@@ -794,6 +836,12 @@ class Willexecutors:
|
||||
"post", url + "/searchtx", data=txid.encode("ascii"),
|
||||
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
|
||||
except Exception as e:
|
||||
_logger.error(f"error contacting {url} for checking txs {e}")
|
||||
|
||||
@@ -7,12 +7,21 @@ iCalendar (.ics) generation and "open with default calendar app" helper.
|
||||
When a will is built, the plugin can create a calendar event reminding the user
|
||||
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.
|
||||
|
||||
The pure RFC-5545 logic (offsets, escaping, folding, the unified .ics builder,
|
||||
``write_temp_ics``) lives in :mod:`bal.core.reminders`; this module keeps only
|
||||
the Qt button and the OS/subprocess glue.
|
||||
"""
|
||||
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from electrum.gui.qt.util import getSaveFileName
|
||||
from PyQt6.QtGui import QAction
|
||||
from PyQt6.QtWidgets import QToolButton
|
||||
from PyQt6.QtWidgets import QInputDialog, QMenu, QToolButton
|
||||
|
||||
from ...core.reminders import write_temp_ics
|
||||
from .common import _, _logger
|
||||
|
||||
|
||||
class BalCalendarButton(QToolButton):
|
||||
@@ -56,7 +65,7 @@ class BalCalendarButton(QToolButton):
|
||||
try:
|
||||
content = self._ics_provider()
|
||||
if content:
|
||||
self._calendar_temp_path = BalCalendar.write_temp_ics(content)
|
||||
self._calendar_temp_path = write_temp_ics(content)
|
||||
else:
|
||||
self._calendar_temp_path = None
|
||||
self._bal_window.show_warning(
|
||||
@@ -79,7 +88,8 @@ class BalCalendarButton(QToolButton):
|
||||
path = self._ensure_ics()
|
||||
if not path:
|
||||
return
|
||||
import shlex, subprocess
|
||||
import shlex
|
||||
import subprocess
|
||||
if self._bal_window.bal_plugin.is_basic_mode():
|
||||
app = self._bal_window.bal_plugin.CALENDAR_APP.default
|
||||
else:
|
||||
@@ -147,13 +157,6 @@ class BalCalendarButton(QToolButton):
|
||||
|
||||
|
||||
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
|
||||
def open_with_default_app(calendar_app, path):
|
||||
_logger.debug("opening calendar app")
|
||||
@@ -182,52 +185,3 @@ class BalCalendar:
|
||||
if os.path.isdir(desktop):
|
||||
return desktop
|
||||
return home
|
||||
|
||||
|
||||
@staticmethod
|
||||
def format_time(time):
|
||||
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
#return time.astimezone(timezone.utc).strftime("%Y%m%d")
|
||||
|
||||
@staticmethod
|
||||
def ical_escape(text: str) -> str:
|
||||
# escape per RFC5545: backslash, ; , newlines
|
||||
text = text.encode("utf-8")
|
||||
text = (
|
||||
text.replace(b"\\", b"\\\\")
|
||||
.replace(b";", b"\\;")
|
||||
.replace(b",", b"\\,")
|
||||
)
|
||||
out =""
|
||||
temp=text.split(b"\r\n")
|
||||
for s in temp:
|
||||
encoded= s
|
||||
cut =0
|
||||
while len(encoded) >75:
|
||||
cut+=5
|
||||
encoded=f"{s[:len(s)-cut]}"
|
||||
if encoded[-1]==b"\\" and encoded[-2]!=b"\\\\":
|
||||
cut += 1
|
||||
encoded=f"{s[:len(s)-cut]}"
|
||||
encoded=f"{encoded}...\r\n".encode("utf-8")
|
||||
if cut>0:
|
||||
out+=str(f"{s[:len(s)-cut].decode()}...\r\n")
|
||||
else:
|
||||
out+=str(f"{s.decode()}\r\n")
|
||||
|
||||
return out[:-2]
|
||||
|
||||
@staticmethod
|
||||
def fold_ical_line(line: str, limit: int = 75) -> str:
|
||||
# ritorna linee separate da CRLF e folding con spazio iniziale sulle righe successive
|
||||
encoded = line.encode("utf-8")
|
||||
parts = []
|
||||
while len(encoded) > limit:
|
||||
# taglia senza spezzare byte UTF-8
|
||||
cut = limit
|
||||
while (encoded[cut] & 0xC0) == 0x80: # byte di continuazione UTF-8
|
||||
cut -= 1
|
||||
parts.append(encoded[:cut].decode("utf-8"))
|
||||
encoded = encoded[cut:]
|
||||
parts.append(encoded.decode("utf-8"))
|
||||
return "\r\n ".join(parts)
|
||||
|
||||
@@ -12,10 +12,9 @@ hosts a few GUI helpers that do not deserve a module of their own:
|
||||
* :func:`add_widget` - add a labelled widget (plus optional help) to a grid.
|
||||
* :func:`log_error` - format an exception traceback for a dialog.
|
||||
* :func:`export_meta_gui` - export plugin metadata to a JSON file.
|
||||
* :class:`CheckAliveError`- raised when the "check alive" date is in the past.
|
||||
(:class:`CheckAliveError` now lives in ``bal.core.checkalive``.)
|
||||
"""
|
||||
|
||||
import copy
|
||||
import enum
|
||||
import os
|
||||
import subprocess
|
||||
@@ -27,62 +26,143 @@ from decimal import Decimal
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Mapping, Optional, Union
|
||||
|
||||
from electrum.bitcoin import (NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX,
|
||||
NLOCKTIME_MIN)
|
||||
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
|
||||
from electrum.gui.common_qt.util import draw_qr
|
||||
from electrum.gui.qt.amountedit import BTCAmountEdit
|
||||
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
|
||||
from electrum.gui.qt.my_treeview import MyTreeView
|
||||
from electrum.gui.qt.password_dialog import PasswordDialog
|
||||
from electrum.gui.qt.transaction_dialog import TxDialog
|
||||
from electrum.gui.qt.util import (Buttons, CancelButton, ColorScheme,
|
||||
EnterButton, HelpButton, MessageBoxMixin,
|
||||
OkButton, TaskThread, WindowModalDialog,
|
||||
char_width_in_lineedit, getSaveFileName,
|
||||
import_meta_gui, read_QIcon_from_bytes,
|
||||
read_QPixmap_from_bytes, webopen)
|
||||
from electrum.gui.qt.util import (
|
||||
Buttons,
|
||||
CancelButton,
|
||||
ColorScheme,
|
||||
EnterButton,
|
||||
HelpButton,
|
||||
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.logging import get_logger
|
||||
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError
|
||||
from electrum.payment_identifier import PaymentIdentifier
|
||||
from electrum.plugin import hook
|
||||
from electrum.transaction import SerializationError, Transaction, tx_from_any
|
||||
from electrum.util import (DECIMAL_POINT, FileExportFailed, UserCancelled,
|
||||
decimal_point_to_base_unit_name, read_json_file,
|
||||
write_json_file)
|
||||
from PyQt6.QtCore import (QDateTime, QModelIndex, QPersistentModelIndex, QSize,
|
||||
Qt, QTimer, pyqtSignal)
|
||||
from PyQt6.QtGui import (QColor, QPainter, QPalette, QStandardItem,
|
||||
QStandardItemModel)
|
||||
from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox,
|
||||
QComboBox, QDateTimeEdit, QGridLayout, QHBoxLayout,
|
||||
QInputDialog, QLabel, QLineEdit, QTextEdit, QMenu,
|
||||
QMenuBar, QPushButton, QScrollArea, QSizePolicy,
|
||||
QSpinBox, QStackedWidget, QStyle, QStyleOptionFrame,
|
||||
QVBoxLayout, QWidget, QDialog)
|
||||
from electrum.util import (
|
||||
DECIMAL_POINT,
|
||||
FileExportFailed,
|
||||
FileImportFailed,
|
||||
UserCancelled,
|
||||
decimal_point_to_base_unit_name,
|
||||
read_json_file,
|
||||
write_json_file,
|
||||
)
|
||||
from PyQt6.QtCore import (
|
||||
QDateTime,
|
||||
QModelIndex,
|
||||
QPersistentModelIndex,
|
||||
QSize,
|
||||
Qt,
|
||||
QTimer,
|
||||
pyqtSignal,
|
||||
)
|
||||
from PyQt6.QtGui import QColor, QPainter, QPalette, QStandardItem, QStandardItemModel
|
||||
from PyQt6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QAbstractSpinBox,
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDateTimeEdit,
|
||||
QDialog,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMenu,
|
||||
QMenuBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QStackedWidget,
|
||||
QStyle,
|
||||
QStyleOptionFrame,
|
||||
QTextEdit,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...core.heirs import (
|
||||
HEIR_DUST_AMOUNT,
|
||||
HEIR_REAL_AMOUNT,
|
||||
OP_RETURN_PREFIX,
|
||||
BalanceTooLowException,
|
||||
HeirAmountIsDustException,
|
||||
Heirs,
|
||||
WillExecutorFeeTooHighException,
|
||||
get_op_return_hex,
|
||||
is_op_return_address,
|
||||
validate_op_return_hex,
|
||||
)
|
||||
|
||||
# --- Core (GUI-free) logic layer ---
|
||||
from ...core.plugin_base import BalPlugin, BalTimestamp
|
||||
from ...core.heirs import (HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT,
|
||||
HeirAmountIsDustException, Heirs)
|
||||
from ...core.util import Util
|
||||
from ...core.will import (AmountException, HeirChangeException,
|
||||
HeirNotFoundException, NoHeirsException,
|
||||
NotCompleteWillException, NoWillExecutorNotPresent,
|
||||
TxFeesChangedException, Will,
|
||||
WillexecutorChangeException, WillExecutorNotPresent,
|
||||
WillExpiredException, WillItem, WillPostponedException)
|
||||
from ...core.willexecutors import Willexecutors
|
||||
from ...core.willexecutors import is_onion_url, is_tor_active # noqa: F401
|
||||
from ...core.util import Util, copy_structure
|
||||
from ...core.will import (
|
||||
AmountException,
|
||||
HeirChangeException,
|
||||
HeirNotFoundException,
|
||||
NoHeirsException,
|
||||
NotCompleteWillException,
|
||||
NoWillExecutorNotPresent,
|
||||
TxFeesChangedException,
|
||||
Will,
|
||||
WillexecutorChangeException,
|
||||
WillExecutorNotPresent,
|
||||
WillExpiredException,
|
||||
WillItem,
|
||||
WillPostponedException,
|
||||
)
|
||||
from ...core.willexecutors import ( # noqa: F401
|
||||
Willexecutors,
|
||||
is_onion_url,
|
||||
is_tor_active,
|
||||
)
|
||||
|
||||
# --- Presentation helpers ---
|
||||
from .theme import server_status_text, server_status_tooltip, status_color
|
||||
from .window_utils import (bring_to_front, show_modal, show_on_top,
|
||||
stop_thread, top_level_of)
|
||||
from .theme import (
|
||||
server_status_text,
|
||||
server_status_tooltip,
|
||||
signature_suffix,
|
||||
status_color,
|
||||
)
|
||||
from .window_utils import (
|
||||
bring_to_front,
|
||||
show_modal,
|
||||
show_on_top,
|
||||
stop_thread,
|
||||
top_level_of,
|
||||
)
|
||||
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
|
||||
class shown_cv:
|
||||
class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal naming)
|
||||
_type = bool
|
||||
|
||||
def __init__(self, value):
|
||||
@@ -105,18 +185,6 @@ def add_widget(grid, label, widget, row, help_):
|
||||
|
||||
|
||||
|
||||
class CheckAliveError(Exception):
|
||||
def __init__(self, timestamp_to_check):
|
||||
self.timestamp_to_check = timestamp_to_check
|
||||
|
||||
def __str__(self):
|
||||
return "Check alive expired please update it: {}".format(
|
||||
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def log_error(exec_info, window=None):
|
||||
"""Log an error and optionally show it.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,12 +14,111 @@ construction) for all business actions, so the heavy logic stays in ``window``
|
||||
and ``dialogs``.
|
||||
"""
|
||||
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from .widgets import BalCheckBox, PercAmountEdit, WillSettingsWidget
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
from PyQt6.QtWidgets import QStyledItemDelegate, QLineEdit as _QLineEdit
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PyQt6.QtWidgets import QLineEdit as _QLineEdit
|
||||
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
|
||||
|
||||
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 .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):
|
||||
@@ -84,7 +183,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
||||
self.bal_window.new_heir_dialog(edit_key)
|
||||
|
||||
def on_edited(self, idx, edit_key, *, text):
|
||||
original = prior_name = self.bal_window.heirs.get(edit_key)
|
||||
prior_name = self.bal_window.heirs.get(edit_key)
|
||||
if not prior_name:
|
||||
return
|
||||
col = idx.column()
|
||||
@@ -105,12 +204,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
||||
try:
|
||||
self.bal_window.set_heir(prior_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.bal_window.set_heir((edit_key,) + original)
|
||||
except Exception:
|
||||
self.update()
|
||||
self.update()
|
||||
|
||||
def delete_heirs(self, selected_keys):
|
||||
self.bal_window.delete_heirs(selected_keys)
|
||||
@@ -157,7 +251,17 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
||||
heir = self.bal_window.heirs[key]
|
||||
labels = [""] * len(self.Columns)
|
||||
labels[self.Columns.NAME] = key
|
||||
labels[self.Columns.ADDRESS] = heir[0]
|
||||
if is_op_return_address(heir[0]):
|
||||
data_hex = heir[0][len(OP_RETURN_PREFIX):]
|
||||
try:
|
||||
decoded = bytes.fromhex(data_hex).decode("utf-8", errors="replace")
|
||||
if len(decoded) > 40:
|
||||
decoded = decoded[:40] + "\u2026"
|
||||
labels[self.Columns.ADDRESS] = decoded
|
||||
except Exception:
|
||||
labels[self.Columns.ADDRESS] = "OP_RETURN"
|
||||
else:
|
||||
labels[self.Columns.ADDRESS] = heir[0]
|
||||
labels[self.Columns.AMOUNT] = Util.decode_amount(
|
||||
heir[1], self.decimal_point
|
||||
)
|
||||
@@ -184,7 +288,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
||||
set_current = QPersistentModelIndex(idx)
|
||||
try:
|
||||
self.will_settings_widget.on_locktime_change()
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
pass
|
||||
self.set_current_idx(set_current)
|
||||
# FIXME refresh loses sort order; so set "default" here:
|
||||
@@ -204,15 +308,15 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
||||
menu.addAction(_("Import"), self.bal_window.import_heirs)
|
||||
menu.addAction(_("Export"), lambda: self.bal_window.export_heirs())
|
||||
|
||||
newHeirButton = QPushButton(_("New Heir"))
|
||||
newHeirButton.clicked.connect(self.bal_window.new_heir_dialog)
|
||||
new_heir_button = QPushButton(_("New Heir"))
|
||||
new_heir_button.clicked.connect(self.bal_window.new_heir_dialog)
|
||||
|
||||
widget = QWidget(self)
|
||||
layout = QHBoxLayout(widget)
|
||||
self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
|
||||
|
||||
layout.addWidget(self.will_settings_widget)
|
||||
layout.addWidget(newHeirButton)
|
||||
layout.addWidget(new_heir_button)
|
||||
|
||||
toolbar.insertWidget(2, widget)
|
||||
|
||||
@@ -271,7 +375,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
self.setModel(QStandardItemModel(self))
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.setSortingEnabled(True)
|
||||
@@ -297,36 +401,64 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
selected_keys.append(sel_key)
|
||||
if selected_keys and idx.isValid():
|
||||
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(
|
||||
_("details").format(column_title),
|
||||
lambda: self.show_transaction(selected_keys),
|
||||
).setEnabled(len(selected_keys) < 2)
|
||||
).setEnabled(len(selected_keys) == 1)
|
||||
menu.addAction(
|
||||
_("sign").format(column_title),
|
||||
lambda: self.sign_transactions(selected_keys),
|
||||
).setEnabled(
|
||||
any(_can_sign(self.will.get(k)) for k in selected_keys)
|
||||
)
|
||||
menu.addAction(
|
||||
_("broadcast").format(column_title),
|
||||
lambda: self.broadcast_transactions(selected_keys),
|
||||
).setEnabled(
|
||||
any(_can_broadcast(self.will.get(k)) for k in selected_keys)
|
||||
)
|
||||
menu.addAction(
|
||||
_("check ").format(column_title),
|
||||
lambda: self.check_transactions(selected_keys),
|
||||
)
|
||||
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
|
||||
try:
|
||||
self.importaction = self.menu.addAction(
|
||||
_("Import"), self.import_will
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
menu.addSeparator()
|
||||
menu.addAction(
|
||||
_("copy id").format(column_title),
|
||||
lambda: self.copy_txids(selected_keys),
|
||||
)
|
||||
menu.addAction(
|
||||
_("copy").format(column_title),
|
||||
lambda: self.copy_tx_hexes(selected_keys),
|
||||
)
|
||||
menu.addAction(
|
||||
_("merge from txn").format(column_title),
|
||||
lambda: self.merge_from_txn(),
|
||||
)
|
||||
|
||||
menu.addSeparator()
|
||||
menu.addAction(
|
||||
_("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))
|
||||
|
||||
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):
|
||||
for key in selected_keys:
|
||||
keys = [k for k in selected_keys if self.is_deletable(k)]
|
||||
for key in keys:
|
||||
del self.will[key]
|
||||
try:
|
||||
del self.bal_window.willitems[key]
|
||||
@@ -338,6 +470,75 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
pass
|
||||
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):
|
||||
wout = {}
|
||||
for k in selected_keys:
|
||||
@@ -385,8 +586,8 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
if bal_tx.we:
|
||||
we = bal_tx.we["url"]
|
||||
labels[self.Columns.WILLEXECUTOR] = we
|
||||
status = bal_tx.status
|
||||
if len(bal_tx.status) > 53:
|
||||
status = bal_tx.status + signature_suffix(bal_tx)
|
||||
if len(status) > 53:
|
||||
status = "...{}".format(status[-50:])
|
||||
labels[self.Columns.STATUS] = status
|
||||
# Dedicated, always-readable label describing whether the inheritance
|
||||
@@ -462,9 +663,12 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
menu.addAction(_("Prepare"), self.build_transactions)
|
||||
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
|
||||
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)
|
||||
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
|
||||
self.importaction = menu.addAction(_("Import"), self.import_will)
|
||||
menu.addAction(_("Import"), self.import_will)
|
||||
menu.addAction(_("Merge"), self.merge_will)
|
||||
menu.addAction(_("Broadcast"), self.broadcast)
|
||||
menu.addAction(_("Check"), self.check)
|
||||
menu.addAction(_("Invalidate"), self.invalidate_will)
|
||||
@@ -529,15 +733,17 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
if will:
|
||||
self.update_will(will)
|
||||
|
||||
def export_json_file(self, path):
|
||||
write_json_file(path, self.will)
|
||||
|
||||
def export_will(self):
|
||||
self.bal_window.export_will()
|
||||
self.update()
|
||||
self.bal_window.export_will_dialog()
|
||||
|
||||
def import_will(self):
|
||||
self.bal_window.import_will()
|
||||
self.bal_window.import_will_dialog()
|
||||
|
||||
def import_will_into_details(self):
|
||||
self.bal_window.import_will_into_details()
|
||||
|
||||
def merge_will(self):
|
||||
self.bal_window.merge_will_ui()
|
||||
|
||||
def ask_password_and_sign_transactions(self):
|
||||
self.bal_window.ask_password_and_sign_transactions(callback=self.update)
|
||||
@@ -756,6 +962,7 @@ class WillExecutorListWidget(MyTreeView):
|
||||
idx = self.indexAt(position)
|
||||
column = idx.column() or self.Columns.URL
|
||||
selected_keys = []
|
||||
sel_key = None
|
||||
for s_idx in self.selected_in_column(self.Columns.URL):
|
||||
item = self.model().itemFromIndex(s_idx)
|
||||
# Use the FULL url stored in the key role, NOT item.data(0): the
|
||||
@@ -771,6 +978,15 @@ class WillExecutorListWidget(MyTreeView):
|
||||
# self.model().itemFromIndex(s_idx).text()
|
||||
# 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]):
|
||||
menu.addAction(
|
||||
_("deselect").format(column_title),
|
||||
@@ -929,6 +1145,17 @@ class WillExecutorListWidget(MyTreeView):
|
||||
pass
|
||||
else:
|
||||
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.URL].setEditable(True)
|
||||
items[self.Columns.ADDRESS].setEditable(True)
|
||||
@@ -1008,13 +1235,44 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
||||
b.clicked.connect(self.import_file)
|
||||
buttonbox.addWidget(b)
|
||||
|
||||
b = QPushButton(_("Export"))
|
||||
b.clicked.connect(self.export_file)
|
||||
buttonbox.addWidget(b)
|
||||
def _menu_button(label):
|
||||
btn = QToolButton()
|
||||
btn.setText(_(label))
|
||||
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||
buttonbox.addWidget(btn)
|
||||
return btn
|
||||
|
||||
b = QPushButton(_("Ping All"))
|
||||
b.clicked.connect(self.update_willexecutors)
|
||||
buttonbox.addWidget(b)
|
||||
export_btn = _menu_button("Export")
|
||||
export_menu = QMenu(export_btn)
|
||||
export_menu.addAction(_("Export all"), lambda: self.export_file())
|
||||
export_menu.addAction(
|
||||
_("Export selected"), lambda: self.export_file(subset="selected")
|
||||
)
|
||||
export_menu.addAction(
|
||||
_("Export only valid"), lambda: self.export_file(subset="valid")
|
||||
)
|
||||
export_btn.setMenu(export_menu)
|
||||
|
||||
ping_btn = _menu_button("Ping All")
|
||||
ping_menu = QMenu(ping_btn)
|
||||
ping_menu.addAction(_("Ping all"), lambda: self.update_willexecutors())
|
||||
ping_menu.addAction(
|
||||
_("Ping selected"), lambda: self.ping_selected_willexecutors()
|
||||
)
|
||||
ping_btn.setMenu(ping_menu)
|
||||
|
||||
select_btn = _menu_button("Select All")
|
||||
select_menu = QMenu(select_btn)
|
||||
select_menu.addAction(_("Select all"), lambda: self.set_select_all(True))
|
||||
select_menu.addAction(
|
||||
_("Select only valid"), lambda: self.set_select_all(True, only_valid=True)
|
||||
)
|
||||
select_menu.addAction(_("Deselect all"), lambda: self.set_select_all(False))
|
||||
select_menu.addAction(
|
||||
_("Deselect only invalid"),
|
||||
lambda: self.set_select_all(False, only_valid=True),
|
||||
)
|
||||
select_btn.setMenu(select_menu)
|
||||
|
||||
vbox.addLayout(buttonbox)
|
||||
# self.will_executor_list_widget.update()
|
||||
@@ -1135,6 +1393,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
||||
add_another_btn.clicked.connect(add_another)
|
||||
else:
|
||||
self._add_another = False
|
||||
add_another_btn = None
|
||||
|
||||
row = 0
|
||||
grid.addWidget(QLabel(_("URL")), row, 0)
|
||||
@@ -1227,13 +1486,68 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
||||
|
||||
self.bal_window.download_list(self.bal_window.willexecutors, on_success)
|
||||
|
||||
def export_file(self, path):
|
||||
def export_file(self, subset=None):
|
||||
data = self.export_data(subset)
|
||||
if subset and not data:
|
||||
self.show_message(_("No will-executor matches the selected filter"))
|
||||
return
|
||||
export_meta_gui(
|
||||
self.bal_window.window, "willexecutors.json", self.export_json_file
|
||||
self.bal_window.window,
|
||||
"willexecutors.json",
|
||||
partial(self.export_json_file, subset=subset),
|
||||
)
|
||||
|
||||
def export_json_file(self, path):
|
||||
write_json_file(path, self.willexecutors_list)
|
||||
def export_data(self, subset=None):
|
||||
data = self.willexecutors_list
|
||||
if subset == "selected":
|
||||
data = {
|
||||
url: we
|
||||
for url, we in data.items()
|
||||
if Willexecutors.is_selected(we)
|
||||
}
|
||||
elif subset == "valid":
|
||||
valid = self._validity()
|
||||
data = {
|
||||
url: we for url, we in data.items() if valid.get(url, False)
|
||||
}
|
||||
return data
|
||||
|
||||
def export_json_file(self, path, subset=None):
|
||||
write_json_file(path, self.export_data(subset))
|
||||
|
||||
def _validity(self):
|
||||
max_fee = self.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
|
||||
dust = self.bal_window.window.wallet.dust_threshold()
|
||||
return {
|
||||
url: Willexecutors.is_valid(we, max_fee=max_fee, dust=dust)
|
||||
for url, we in self.willexecutors_list.items()
|
||||
}
|
||||
|
||||
def _selected_willexecutors(self):
|
||||
return {
|
||||
url: we
|
||||
for url, we in self.willexecutors_list.items()
|
||||
if Willexecutors.is_selected(we)
|
||||
}
|
||||
|
||||
def set_select_all(self, select, only_valid=False):
|
||||
"""Apply a bulk selection across all will-executors.
|
||||
|
||||
``select=True`` selects all (or only the valid ones when
|
||||
``only_valid=True``, deselecting the invalid ones); ``select=False``
|
||||
deselects all (or only the invalid ones when ``only_valid=True``,
|
||||
leaving the valid ones selected).
|
||||
"""
|
||||
valid = self._validity() if only_valid else None
|
||||
_apply_select_all(self.willexecutors_list, select, valid)
|
||||
self.save_willexecutors()
|
||||
|
||||
def ping_selected_willexecutors(self):
|
||||
wes = self._selected_willexecutors()
|
||||
if not wes:
|
||||
self.show_message(_("No will-executor is selected"))
|
||||
return
|
||||
self.update_willexecutors(wes)
|
||||
|
||||
def import_file(self):
|
||||
import_meta_gui(
|
||||
|
||||
@@ -15,14 +15,40 @@ and cached in ``self.bal_windows``.
|
||||
"""
|
||||
|
||||
from electrum.gui.qt.main_window import StatusBarButton
|
||||
from electrum.plugin import hook
|
||||
from electrum.util import EventListener, event_listener
|
||||
from PyQt6.QtWidgets import QLayout
|
||||
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from .common import read_QIcon_from_bytes
|
||||
from ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size
|
||||
from .common import (
|
||||
BalPlugin,
|
||||
Buttons,
|
||||
EnterButton,
|
||||
HelpButton,
|
||||
PasswordDialog,
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QTimer,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
UserCancelled,
|
||||
Willexecutors,
|
||||
_,
|
||||
_logger,
|
||||
add_widget,
|
||||
partial,
|
||||
read_QIcon_from_bytes,
|
||||
read_QPixmap_from_bytes,
|
||||
show_modal,
|
||||
webopen,
|
||||
)
|
||||
from .dialogs import BalDialog
|
||||
from .widgets import BalCheckBox, BalLineEdit, BalSpinBox, BalTextEdit
|
||||
from .window import BalWindow
|
||||
from .dialogs import BalDialog
|
||||
from PyQt6.QtWidgets import QLayout
|
||||
|
||||
|
||||
def _window_key(window):
|
||||
@@ -37,7 +63,7 @@ def _window_key(window):
|
||||
return id(window)
|
||||
|
||||
|
||||
class Plugin(BalPlugin):
|
||||
class Plugin(BalPlugin, EventListener):
|
||||
def __init__(self, parent, config, name):
|
||||
_logger.info("INIT BALPLUGIN")
|
||||
BalPlugin.__init__(self, parent, config, name)
|
||||
@@ -46,6 +72,10 @@ class Plugin(BalPlugin):
|
||||
# remove a stale button before creating a fresh one when a wallet is
|
||||
# switched / Electrum is restarted, so the icon is never duplicated.
|
||||
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
|
||||
def init_qt(self, gui_object):
|
||||
@@ -88,9 +118,11 @@ class Plugin(BalPlugin):
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
from electrum.gui.qt.plugins_dialog import PluginsDialog
|
||||
from electrum.gui.qt.plugins_dialog import (
|
||||
PluginsDialog as plugins_dialog, # noqa: N813
|
||||
)
|
||||
except Exception:
|
||||
PluginsDialog = None
|
||||
plugins_dialog = None
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return []
|
||||
@@ -106,7 +138,7 @@ class Plugin(BalPlugin):
|
||||
for w in app.topLevelWidgets():
|
||||
try:
|
||||
is_match = False
|
||||
if PluginsDialog is not None and isinstance(w, PluginsDialog):
|
||||
if plugins_dialog is not None and isinstance(w, plugins_dialog):
|
||||
is_match = True
|
||||
elif type(w).__name__ == "PluginsDialog":
|
||||
is_match = True
|
||||
@@ -142,17 +174,17 @@ class Plugin(BalPlugin):
|
||||
each is guarded independently.
|
||||
"""
|
||||
try:
|
||||
from PyQt6.QtWidgets import QDialog
|
||||
from PyQt6.QtWidgets import QDialog as qdialog # noqa: N813
|
||||
except Exception:
|
||||
QDialog = None
|
||||
qdialog = None
|
||||
# 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:
|
||||
d.reject()
|
||||
except Exception as e:
|
||||
_logger.debug("reject() failed: {}".format(e))
|
||||
try:
|
||||
d.done(QDialog.DialogCode.Rejected)
|
||||
d.done(qdialog.DialogCode.Rejected)
|
||||
except Exception as e:
|
||||
_logger.debug("done() failed: {}".format(e))
|
||||
# 2) close(): covers non-QDialog top-levels and is a harmless extra.
|
||||
@@ -172,9 +204,9 @@ class Plugin(BalPlugin):
|
||||
it and closes it themselves (it must not linger in the background).
|
||||
"""
|
||||
try:
|
||||
from PyQt6.QtCore import QTimer
|
||||
from PyQt6.QtCore import QTimer as qtimer # noqa: N813
|
||||
except Exception:
|
||||
QTimer = None
|
||||
qtimer = None
|
||||
# Schedule of retry delays (ms) measured from each call.
|
||||
retry_delays = [400, 800, 1500]
|
||||
dialogs = Plugin._find_plugins_manager_dialogs()
|
||||
@@ -190,8 +222,8 @@ class Plugin(BalPlugin):
|
||||
if not still_open:
|
||||
_logger.info("plugins dialog closed successfully")
|
||||
return
|
||||
if attempt < len(retry_delays) and QTimer is not None:
|
||||
QTimer.singleShot(
|
||||
if attempt < len(retry_delays) and qtimer is not None:
|
||||
qtimer.singleShot(
|
||||
retry_delays[attempt],
|
||||
lambda: Plugin._handle_plugins_manager_dialog(attempt + 1),
|
||||
)
|
||||
@@ -323,6 +355,44 @@ class Plugin(BalPlugin):
|
||||
except Exception as 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
|
||||
def init_keystore(self):
|
||||
_logger.debug("init keystore")
|
||||
@@ -436,13 +506,46 @@ class Plugin(BalPlugin):
|
||||
# persisted NUM_REMINDERS config (default 3), with a range of 1..5.
|
||||
heir_num_reminders = BalSpinBox(self.NUM_REMINDERS, minimum=1, maximum=5)
|
||||
|
||||
# "No will-executor TX" checkbox. Bound to the persisted NO_WILLEXECUTOR
|
||||
# config (default ON, see plugin_base.py), the SAME config used by the
|
||||
# checkbox inside the "Build your will" wizard's will-executor download
|
||||
# window, so the two stay in sync automatically. When enabled the plugin
|
||||
# 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)
|
||||
# Max willexecutor fee spin box. Maximum fee (in satoshi) allowed for
|
||||
# a single will-executor. If a will-executor charges more, the will
|
||||
# will not be built. Default 500,000 satoshi (0.005 BTC).
|
||||
heir_max_willexecutor_fee = BalSpinBox(
|
||||
self.MAX_WILLEXECUTOR_FEE, minimum=0, maximum=10000000
|
||||
)
|
||||
|
||||
# "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
|
||||
# (not a free-text field) bound to the USER_TYPE config:
|
||||
@@ -498,8 +601,10 @@ class Plugin(BalPlugin):
|
||||
lbl_event_description, edit_event_description, help_event_description,
|
||||
lbl_calendar_app, edit_calendar_app, help_calendar_app,
|
||||
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_auto_sign):
|
||||
reset_btn_11, reset_btn_12, reset_btn_auto_sign):
|
||||
w.setVisible(not basic)
|
||||
# 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
|
||||
@@ -525,6 +630,20 @@ class Plugin(BalPlugin):
|
||||
edit_calendar_app = BalLineEdit(self.CALENDAR_APP)
|
||||
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):
|
||||
"""Return a small ``↺`` button that resets a single setting."""
|
||||
btn = QPushButton("\u21ba")
|
||||
@@ -544,6 +663,10 @@ class Plugin(BalPlugin):
|
||||
widget.setCurrentIndex(
|
||||
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)
|
||||
return btn
|
||||
|
||||
@@ -614,28 +737,21 @@ class Plugin(BalPlugin):
|
||||
),
|
||||
)
|
||||
grid.addWidget(_make_reset_btn(self.EDITABLE_DATES, heir_editable_dates, "check"), 3, 3)
|
||||
# "Add transaction without will-executor" setting (formerly labelled
|
||||
# "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.
|
||||
# Max willexecutor fee: maximum fee (in satoshi) allowed for a single
|
||||
# will-executor. Visible to all users (BASIC and ADVANCED).
|
||||
add_widget(
|
||||
grid,
|
||||
"Add transaction without willexecutor",
|
||||
heir_no_willexecutor,
|
||||
"Max Will-Executor Fee (satoshi)",
|
||||
heir_max_willexecutor_fee,
|
||||
4,
|
||||
(
|
||||
"Create a will that does not require a Will-executor; it can be "
|
||||
"saved, for example, on a USB stick, and a copy can be given to "
|
||||
"the heirs."
|
||||
"Maximum fee (in satoshi) allowed to be paid to a single "
|
||||
"will-executor. If a will-executor charges more than this, "
|
||||
"the will will not be built.\n"
|
||||
"Default: 500,000 satoshi (0.005 BTC)."
|
||||
),
|
||||
)
|
||||
grid.addWidget(_make_reset_btn(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"), 4, 3)
|
||||
grid.addWidget(_make_reset_btn(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"), 4, 3)
|
||||
# User Type selector placed BEFORE the advanced-only settings so the
|
||||
# user chooses basic/advanced first, then sees the relevant options.
|
||||
add_widget(
|
||||
@@ -720,6 +836,37 @@ class Plugin(BalPlugin):
|
||||
reset_btn_10 = _make_reset_btn(self.CALENDAR_APP, edit_calendar_app, "line")
|
||||
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
|
||||
# correct initial visibility inline (via _hide_if_basic) BEFORE being
|
||||
# added to the grid. The old code did the opposite - it added them
|
||||
@@ -727,15 +874,76 @@ class Plugin(BalPlugin):
|
||||
# the Windows relayout flicker. Do NOT reintroduce a post-hoc
|
||||
# setVisible() loop here.
|
||||
|
||||
grid.addWidget(heir_repush, 11, 0)
|
||||
grid.addWidget(heir_repush, 13, 0)
|
||||
grid.addWidget(
|
||||
HelpButton(
|
||||
"Broadcast all transactions to willexecutors including those already pushed"
|
||||
),
|
||||
11,
|
||||
13,
|
||||
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 #
|
||||
# their factory defaults. It only resets the settings exposed by THIS #
|
||||
@@ -760,11 +968,16 @@ class Plugin(BalPlugin):
|
||||
(self.AUTO_SIGN, heir_auto_sign, "check"),
|
||||
(self.EDITABLE_DATES, heir_editable_dates, "check"),
|
||||
(self.NUM_REMINDERS, heir_num_reminders, "spin"),
|
||||
(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"),
|
||||
(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"),
|
||||
(self.EVENT_SUMMARY, edit_event_summary, "line"),
|
||||
(self.EVENT_DESCRIPTION, edit_event_description, "text"),
|
||||
(self.WELIST_SERVER, edit_welist_server, "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:
|
||||
# Persist the default value back into the Electrum config.
|
||||
@@ -785,6 +998,14 @@ class Plugin(BalPlugin):
|
||||
widget.setCurrentIndex(
|
||||
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
|
||||
# editable-dates state is not in this list, but hide filters are)
|
||||
# reflects the reset values.
|
||||
|
||||
@@ -54,12 +54,31 @@ def status_color(will_item) -> str:
|
||||
return "#e83845" # red - failed to push to will-executor
|
||||
elif will_item.get_status("PUSHED"):
|
||||
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"):
|
||||
return "#2bc8ed" # blue - signed
|
||||
else:
|
||||
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:
|
||||
"""Return a short, human-readable label describing the state of a will
|
||||
item on the will-executor servers (the online inheritance backup).
|
||||
|
||||
@@ -18,89 +18,67 @@ Contents:
|
||||
* WillWidget - single will-tx box
|
||||
"""
|
||||
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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 .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,
|
||||
)
|
||||
|
||||
|
||||
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]
|
||||
if TYPE_CHECKING:
|
||||
from .window import BalWindow
|
||||
|
||||
|
||||
class ClickableLabel(QLabel):
|
||||
@@ -214,39 +192,12 @@ class BalTxFeesWidget(QWidget):
|
||||
|
||||
|
||||
|
||||
class _LockTimeEditor:
|
||||
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
|
||||
class _LockTimeEditor(LockTimeEditor):
|
||||
"""Qt-side locktime editor base.
|
||||
|
||||
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):
|
||||
@@ -567,51 +518,16 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
|
||||
self.isyears = False
|
||||
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):
|
||||
# Only digits plus the day ("d") and year ("y") suffixes are accepted.
|
||||
# The block-height suffix ("b") was removed (A1): locktimes are always
|
||||
# UNIX timestamps now, so block-relative input is no longer allowed.
|
||||
# The sanitisation itself lives in bal.core.input_rules.
|
||||
text = self.text().strip()
|
||||
chars = "0123456789dy"
|
||||
pos = self.cursorPosition()
|
||||
pos = len("".join([i for i in text[:pos] if i in chars]))
|
||||
s = "".join([i for i in text if i in chars])
|
||||
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"
|
||||
s, isdays, isyears, pos = normalize_locktime_raw_text(text, pos)
|
||||
self.isdays = isdays
|
||||
self.isyears = isyears
|
||||
self.blockSignals(True)
|
||||
self.setText(s)
|
||||
self.blockSignals(False)
|
||||
@@ -690,15 +606,20 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
|
||||
return
|
||||
try:
|
||||
x = int(x)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
x = QDateTime.currentDateTime().timestamp()
|
||||
finally:
|
||||
# Use the overflow-safe converter: on Windows datetime.fromtimestamp
|
||||
# raises OverflowError for timestamps past 2038 (e.g. NLOCKTIME_MAX).
|
||||
_dt = BalTimestamp._safe_fromtimestamp(x)
|
||||
#if self.alarm != dt:
|
||||
self.setDateTime(_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))
|
||||
|
||||
|
||||
|
||||
@@ -1068,11 +989,12 @@ class WillSettingsWidget(QWidget):
|
||||
sees several distinct appointments in their calendar.
|
||||
|
||||
The number of events is read from the NUM_REMINDERS setting (default 3,
|
||||
capped at 5 by the settings dialog). Their dates are computed with
|
||||
``compute_reminder_offsets``: the offsets are spread uniformly across the
|
||||
check-alive period and the LAST event always falls one day before the
|
||||
delivery deadline (locktime). If the period is shorter than the
|
||||
requested number of reminders, at most one event per day is produced.
|
||||
capped at 5 by the settings dialog). Their dates are computed by
|
||||
``bal.core.reminders.build_ics_reminders`` (offsets spread uniformly
|
||||
across the check-alive period; the LAST event always falls one day
|
||||
before the delivery deadline / locktime). If the period is shorter than
|
||||
the requested number of reminders, at most one event per day is
|
||||
produced.
|
||||
|
||||
Each event:
|
||||
* is placed on ``locktime - offset`` days (its own visible date);
|
||||
@@ -1087,103 +1009,23 @@ class WillSettingsWidget(QWidget):
|
||||
path the user picks in the save dialog (default name "BAL_will_event.ics"
|
||||
on the Desktop).
|
||||
"""
|
||||
now = BalCalendar.format_time(datetime.now())
|
||||
|
||||
# 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})"
|
||||
# The .ics content (offsets, escaping, folding, VEVENT layout) is built
|
||||
# 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)"
|
||||
)
|
||||
)
|
||||
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",
|
||||
])
|
||||
return
|
||||
|
||||
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
|
||||
# user picks below.
|
||||
self.temp_path = BalCalendar.write_temp_ics(ics_content)
|
||||
self.temp_path = write_temp_ics(ics_content)
|
||||
|
||||
# Group D / D1b: always ask the user WHERE to save the .ics file (the
|
||||
# plugin no longer tries to open it with a calendar app). The save
|
||||
@@ -1236,26 +1078,31 @@ class WillSettingsWidget(QWidget):
|
||||
def _ics_provider(self):
|
||||
"""Return the .ics content for the current locktime/threshold values.
|
||||
|
||||
Used by :class:`BalCalendarButton` as its content provider.
|
||||
Used by :class:`BalCalendarButton` as its content provider. The whole
|
||||
document (reminder offsets, escaping, folding, VEVENT layout) is built
|
||||
by the pure :func:`bal.core.reminders.build_ics_reminders`.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
try:
|
||||
locktime = self.widgets["locktime"].alarm
|
||||
|
||||
if self.bal_window.bal_plugin.is_basic_mode():
|
||||
days_to_deadline = (locktime - datetime.now()).days
|
||||
offsets = basic_reminder_offsets(days_to_deadline)
|
||||
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
|
||||
if basic_mode:
|
||||
# BASIC mode: use factory defaults (the hidden settings are
|
||||
# ignored) and the fixed 30/10/1 offsets.
|
||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
||||
threshold = None
|
||||
num_reminders = 3
|
||||
else:
|
||||
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
|
||||
days = (locktime - threshold).days
|
||||
try:
|
||||
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
|
||||
num_reminders = int(
|
||||
self.bal_window.bal_plugin.NUM_REMINDERS.get()
|
||||
)
|
||||
except Exception:
|
||||
count = 3
|
||||
offsets = compute_reminder_offsets(days, count)
|
||||
|
||||
now = BalCalendar.format_time(datetime.now())
|
||||
num_reminders = 3
|
||||
|
||||
heirs_details = "\r\n".join(
|
||||
f" {heir} - {self.bal_window.heirs[heir][0]}, "
|
||||
@@ -1263,50 +1110,17 @@ class WillSettingsWidget(QWidget):
|
||||
for heir in self.bal_window.heirs
|
||||
)
|
||||
|
||||
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)
|
||||
return build_ics_reminders(
|
||||
locktime=locktime,
|
||||
basic_mode=basic_mode,
|
||||
description=raw_description,
|
||||
summary=raw_summary,
|
||||
wallet_name=str(self.bal_window.wallet),
|
||||
heirs_details=heirs_details,
|
||||
version=self.bal_window.bal_plugin.version,
|
||||
num_reminders=num_reminders,
|
||||
threshold=threshold,
|
||||
)
|
||||
summary_base = (
|
||||
f"{raw_summary}"
|
||||
.replace("$wallet_name", str(self.bal_window.wallet))
|
||||
)
|
||||
|
||||
lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//Bitcoin After Life//Electrum Plugin/"
|
||||
f"{BalPlugin.__version__}",
|
||||
]
|
||||
|
||||
total = len(offsets)
|
||||
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:
|
||||
_logger.error(f"failed to generate .ics: {e}")
|
||||
return None
|
||||
@@ -1349,40 +1163,20 @@ class PercAmountEdit(BTCAmountEdit):
|
||||
super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
|
||||
|
||||
def numbify(self):
|
||||
# The text sanitisation lives in bal.core.input_rules.
|
||||
text = self.text().strip()
|
||||
if text == "!":
|
||||
self.shortcut.emit()
|
||||
return
|
||||
pos = self.cursorPosition()
|
||||
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 += "%"
|
||||
s, self.is_perc = normalize_perc_amount_text(text, DECIMAL_POINT)
|
||||
|
||||
self.setText(s)
|
||||
self.setModified(self.hasFocus())
|
||||
self.setCursorPosition(pos)
|
||||
|
||||
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
|
||||
try:
|
||||
text = text.replace(DECIMAL_POINT, ".")
|
||||
text = text.replace("%", "")
|
||||
return (Decimal)(text)
|
||||
except Exception:
|
||||
return None
|
||||
return parse_perc_amount(text, DECIMAL_POINT)
|
||||
|
||||
def _get_text_from_amount(self, amount):
|
||||
out = super()._get_text_from_amount(amount)
|
||||
@@ -1395,15 +1189,15 @@ class PercAmountEdit(BTCAmountEdit):
|
||||
if self.base_unit:
|
||||
panel = QStyleOptionFrame()
|
||||
self.initStyleOption(panel)
|
||||
textRect = self.style().subElementRect(
|
||||
text_rect = self.style().subElementRect(
|
||||
QStyle.SubElement.SE_LineEditContents, panel, self
|
||||
)
|
||||
textRect.adjust(2, 0, -10, 0)
|
||||
text_rect.adjust(2, 0, -10, 0)
|
||||
painter = QPainter(self)
|
||||
painter.setPen(ColorScheme.GRAY.as_color())
|
||||
if len(self.text()) == 0:
|
||||
painter.drawText(
|
||||
textRect,
|
||||
text_rect,
|
||||
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
|
||||
self.base_unit() + " or perc value",
|
||||
)
|
||||
@@ -1482,11 +1276,11 @@ class BalSpinBox(QSpinBox):
|
||||
|
||||
|
||||
class WillWidget(QWidget):
|
||||
def __init__(self, father=None, parent=None):
|
||||
def __init__(self, father=None, parent=None, will=None):
|
||||
super().__init__()
|
||||
vlayout = QVBoxLayout()
|
||||
self.setLayout(vlayout)
|
||||
self.will = parent.bal_window.willitems
|
||||
self.will = will if will is not None else parent.bal_window.willitems
|
||||
self._bal_parent = parent
|
||||
for w in self.will:
|
||||
if (
|
||||
@@ -1513,7 +1307,10 @@ class WillWidget(QWidget):
|
||||
willpushbutton = QPushButton(w)
|
||||
|
||||
willpushbutton.clicked.connect(
|
||||
partial(self._bal_parent.bal_window.show_transaction, txid=w)
|
||||
partial(
|
||||
self._bal_parent.bal_window.show_transaction,
|
||||
tx=self.will[w].tx,
|
||||
)
|
||||
)
|
||||
detaillayout.addWidget(willpushbutton)
|
||||
locktime = str(BalTimestamp(self.will[w].tx.locktime))
|
||||
@@ -1535,17 +1332,33 @@ class WillWidget(QWidget):
|
||||
fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3)
|
||||
fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)"
|
||||
detaillayout.addWidget(qlabel("Transaction fees:", fees_str))
|
||||
detaillayout.addWidget(qlabel("Status:", self.will[w].status))
|
||||
detaillayout.addWidget(
|
||||
qlabel("Status:", self.will[w].status + signature_suffix(self.will[w]))
|
||||
)
|
||||
detaillayout.addWidget(QLabel(""))
|
||||
detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
|
||||
for heir in self.will[w].heirs:
|
||||
if 'w!ll3x3c"' not in heir:
|
||||
decoded_amount = Util.decode_amount(
|
||||
self.will[w].heirs[heir][3], self._bal_parent.decimal_point
|
||||
)
|
||||
for heir_name in self.will[w].heirs:
|
||||
if 'w!ll3x3c"' in heir_name:
|
||||
continue
|
||||
h = self.will[w].heirs[heir_name]
|
||||
decoded_amount = Util.decode_amount(
|
||||
h[3], self._bal_parent.decimal_point
|
||||
)
|
||||
if is_op_return_address(h[0]):
|
||||
data_hex = get_op_return_hex(h[0]) or ""
|
||||
try:
|
||||
decoded = bytes.fromhex(data_hex).decode(
|
||||
"utf-8", errors="replace"
|
||||
)
|
||||
except Exception:
|
||||
decoded = h[0]
|
||||
detaillayout.addWidget(qlabel(heir_name, "OP_RETURN: " + decoded))
|
||||
else:
|
||||
detaillayout.addWidget(
|
||||
qlabel(
|
||||
heir, f"{decoded_amount} {self._bal_parent.base_unit_name}"
|
||||
heir_name,
|
||||
f"{decoded_amount} {self._bal_parent.base_unit_name} "
|
||||
f"[{h[0]}]",
|
||||
)
|
||||
)
|
||||
if self.will[w].we:
|
||||
@@ -1561,6 +1374,10 @@ class WillWidget(QWidget):
|
||||
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()
|
||||
pal = QPalette()
|
||||
pal.setColor(
|
||||
@@ -1570,6 +1387,6 @@ class WillWidget(QWidget):
|
||||
detailw.setPalette(pal)
|
||||
|
||||
hlayout.addWidget(detailw)
|
||||
hlayout.addWidget(WillWidget(w, parent=parent))
|
||||
hlayout.addWidget(WillWidget(w, parent=parent, will=self.will))
|
||||
|
||||
|
||||
|
||||
1199
bal/gui/qt/window.py
1199
bal/gui/qt/window.py
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"name": "bal",
|
||||
"fullname": "Bitcoin After Life",
|
||||
"version": "0.5.18",
|
||||
"version": "0.7.0",
|
||||
"description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.",
|
||||
"author": "Svatantrya",
|
||||
"licence": "MIT",
|
||||
"available_for": ["qt"],
|
||||
"available_for": [
|
||||
"qt",
|
||||
"cmdline"
|
||||
],
|
||||
"icon": "icons/bal32x32.png"
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,12 @@
|
||||
|
||||
Documentation for the **BAL** open‑source Electrum plugin for Bitcoin digital
|
||||
inheritance. Everything here is plain Markdown + images (and optional styled
|
||||
HTML), so it renders directly on GitHub and via GitHub Pages — **no PDF needed**.
|
||||
HTML), so it renders directly on any forge (Gitea/GitHub) and in any browser —
|
||||
**no PDF needed**.
|
||||
|
||||
## Contents
|
||||
|
||||
| Document | Markdown (GitHub) | Styled HTML |
|
||||
| Document | Markdown | Styled HTML |
|
||||
|---|---|---|
|
||||
| **User Manual (revB)** — full plugin manual with screenshots | [`manual/README.md`](./manual/README.md) | [`manual/manual.html`](./manual/manual.html) |
|
||||
| **Inheritance Options Guide** — every change (date earlier/later, add/remove heir, change %, fees, executors) + decision flow chart + transaction states & server effects | [`inheritance-options.md`](./inheritance-options.md) | [`inheritance-options.html`](./inheritance-options.html) |
|
||||
@@ -23,9 +24,8 @@ HTML), so it renders directly on GitHub and via GitHub Pages — **no PDF needed
|
||||
|
||||
## Viewing the HTML versions
|
||||
|
||||
- On GitHub Pages: enable Pages for this repository (Settings → Pages → deploy
|
||||
from branch, folder `/docs`), then open
|
||||
`https://<owner>.github.io/<repo>/manual/manual.html`.
|
||||
- Online: serve the `docs/` folder as static files (e.g. Pages on Gitea or
|
||||
GitHub) and open `manual/manual.html`.
|
||||
- Offline: download the `docs/` folder and open the `.html` files in any browser
|
||||
(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).
|
||||
|
||||
@@ -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>
|
||||
</ol>
|
||||
|
||||
<footer>This document reflects BAL plugin v0.4.7. Behaviour is derived directly from
|
||||
<footer>This document reflects BAL plugin v0.7.0. Behaviour is derived directly from
|
||||
<code>core/will.py</code>, <code>core/heirs.py</code> and <code>gui/qt/window.py</code>.</footer>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ important ones:
|
||||
|---|---|---|
|
||||
| `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** |
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -65,6 +66,7 @@ Flag transitions enforced by `set_status` (the safety rules baked in the code):
|
||||
- Setting `CONFIRMED` / `MEMPOOL` → clears `INVALIDATED`.
|
||||
- Setting `PUSHED` → clears `PUSH_FAIL` **and** `CHECK_FAIL`.
|
||||
- Setting `CHECKED` → implies `PUSHED` (and clears `PUSH_FAIL`).
|
||||
- Setting `COMPLETE` → clears `PARTIALLY_SIGNED`.
|
||||
|
||||
### How states map to row colour in the list
|
||||
|
||||
@@ -83,7 +85,8 @@ wins:
|
||||
| 7 | `CHECKED` | green | `#8afa6c` |
|
||||
| 8 | `PUSH_FAIL` | red | `#e83845` |
|
||||
| 9 | `PUSHED` | teal | `#73f3c8` |
|
||||
| 10 | `COMPLETE` (signed, **not** yet pushed) | blue | `#2bc8ed` |
|
||||
| 10 | `PARTIALLY_SIGNED` | amber | `#ffb347` |
|
||||
| 11 | `COMPLETE` (signed, **not** yet pushed) | blue | `#2bc8ed` |
|
||||
| — | none of the above (e.g. plain `VALID`, prepared) | default white | `#ffffff` |
|
||||
|
||||
> **Note (v0.3.3 fix):** a will that is *signed but not yet broadcast*
|
||||
@@ -354,5 +357,5 @@ that limit.
|
||||
|
||||
---
|
||||
|
||||
*This document reflects BAL plugin v0.4.7. Behaviour is derived directly from
|
||||
`core/will.py`, `core/heirs.py` and `gui/qt/window.py`.*
|
||||
*This document reflects the current BAL plugin (v0.7.0). Behaviour is derived
|
||||
directly from `core/will.py`, `core/heirs.py` and `gui/qt/window.py`.*
|
||||
|
||||
@@ -76,6 +76,22 @@ inheritance cases.
|
||||
*Figure 2 — the parameters on the HEIRS tab: (1) Delivery Time, (2) Check Alive,
|
||||
(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)
|
||||
|
||||
Indicates the date on which the inheritance of your wallet on the blockchain
|
||||
@@ -94,6 +110,11 @@ If you choose **Raw**, you can insert various options based on a suffix:
|
||||
|
||||
*(i.e. check whether you are still alive, and then postpone the inheritance.)*
|
||||
|
||||
> **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
|
||||
the time by which the inheritance will **not** be changed by postponing it.
|
||||
|
||||
@@ -223,6 +244,9 @@ plugin will notify you that you need to update the inheritance.
|
||||
|
||||
## RAW settings
|
||||
|
||||
> **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
|
||||
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
|
||||
@@ -239,7 +263,8 @@ with a tolerance of a few hours.
|
||||
If you want a quick test run, enter an upcoming legacy date/time (e.g. 18 hours
|
||||
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
|
||||
previous month.
|
||||
previous month. *(The Check Alive only exists in **ADVANCED** mode; in
|
||||
**BASIC** this is not needed.)*
|
||||
|
||||
---
|
||||
|
||||
@@ -335,7 +360,7 @@ inheritance:
|
||||
|
||||
## Will‑Executor service list
|
||||
|
||||
This window opens from the Electrum menu, **Tools → Will‑executor**, and shows
|
||||
This window opens from the Electrum menu, **Tools → Will‑Executors**, and shows
|
||||
the official list of will‑executor servers.
|
||||
|
||||
If you want to make changes — such as adding an additional will‑executor server —
|
||||
@@ -409,14 +434,16 @@ transactions can have in the WILL tab, on each will‑executor that is online.
|
||||
| # | Status | Meaning | Colour | HEX |
|
||||
|---|--------|---------|--------|-----|
|
||||
| 1 | **New** | TX new inheritance | White (transparent) | `#FFFFFF` |
|
||||
| 2 | **Signed** | TX inheritance signed into the wallet | Azure | `#2BC8ED` |
|
||||
| 3 | **Pushed** | TX sent to will‑executor | Azure‑green | `#73F3C8` |
|
||||
| 4 | **Checked** | TX actually present in the will‑executor | Bright green | `#8AFA6C` |
|
||||
| 5 | **Confirmed** | TX confirmed in the blockchain | Gray | `#BFBFBF` |
|
||||
| 6 | **Pending** | TX awaiting confirmation on blockchain | Yellow | `#FFCE30` |
|
||||
| 7 | **Failed** | Communication failure with will‑executor | Red | `#E83845` |
|
||||
| 8 | **Invalidated** | UTXO input is no longer available | Orange | `#F87838` |
|
||||
| 9 | **Replaced** | A backdated‑locktime transaction spends the same input | Violet | `#FF97E9` |
|
||||
| 2 | **Partially signed** | TX has some, but not all, of the required signatures | Amber | `#FFB347` |
|
||||
| 3 | **Signed** | TX inheritance signed into the wallet | Azure | `#2BC8ED` |
|
||||
| 4 | **Pushed** | TX sent to will‑executor | Azure‑green | `#73F3C8` |
|
||||
| 5 | **Checked** | TX actually present in the will‑executor | Bright green | `#8AFA6C` |
|
||||
| 6 | **Confirmed** | TX confirmed in the blockchain | Gray | `#BFBFBF` |
|
||||
| 7 | **Pending** | TX awaiting confirmation on blockchain | Yellow | `#FFCE30` |
|
||||
| 8 | **Failed** | Communication failure with will‑executor | Red | `#E83845` |
|
||||
| 9 | **Invalidated** | UTXO input is no longer available | Orange | `#F87838` |
|
||||
| 10 | **Replaced** | A backdated‑locktime transaction spends the same input | Violet | `#FF97E9` |
|
||||
| 11 | **Updated** | TX re‑issued keeping the same locktime and heirs | Light violet | `#B266B2` |
|
||||
|
||||
---
|
||||
|
||||
@@ -448,6 +475,27 @@ transactions can have in the WILL tab, on each will‑executor that is online.
|
||||
> **NB:** When you close Electrum, the plugin automatically proceeds to execute
|
||||
> **Prepare → Sign → Broadcast** (if they have not already been completed) to
|
||||
> ensure the inheritance is correctly executed.
|
||||
>
|
||||
> Optionally, the **Rebuild on close** setting (available in **Tools → Plugins →
|
||||
> BAL**, default OFF) skips the full wizard and runs a one-shot rebuild/sign/push
|
||||
> flow when Electrum closes.
|
||||
|
||||
---
|
||||
|
||||
## Auto-rebuild on new transactions
|
||||
|
||||
> **NB:** this feature requires the **Auto-rebuild** setting to be enabled
|
||||
> (available in **Tools → Plugins → BAL**, default OFF).
|
||||
|
||||
When the **Auto-rebuild** setting is enabled, the plugin automatically rebuilds
|
||||
the will when new transactions are detected in the wallet (e.g. incoming
|
||||
payments). The delivery date is anticipated by one day so the new will orphans
|
||||
the old one on-chain without requiring a manual invalidation. An on-chain
|
||||
invalidation is only needed when the anticipated locktime crosses the **Check
|
||||
Alive** threshold (ADVANCED mode only).
|
||||
|
||||
This is useful for wallets that receive funds regularly: the inheritance stays
|
||||
up-to-date without manual intervention.
|
||||
|
||||
---
|
||||
|
||||
@@ -500,6 +548,32 @@ value.
|
||||
|
||||
---
|
||||
|
||||
## Command-line / headless usage
|
||||
|
||||
BAL can also be used without the Qt GUI, via Electrum's daemon mode. This is
|
||||
useful for scripting, automation, or running on a headless server.
|
||||
|
||||
**Prerequisites:** an Electrum daemon (`electrum daemon -d`) and a loaded wallet
|
||||
(`electrum load_wallet`).
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
electrum daemon -d
|
||||
electrum load_wallet
|
||||
electrum bal_heirs_list
|
||||
electrum bal_will_prepare
|
||||
electrum bal_will_sign --password '...'
|
||||
electrum bal_will_broadcast
|
||||
electrum stop
|
||||
```
|
||||
|
||||
All GUI operations (prepare, sign, broadcast, check, rebuild) are available as
|
||||
`bal_*` commands. See the full command table in the
|
||||
[README](../../README.md#command-line--headless-usage).
|
||||
|
||||
---
|
||||
|
||||
About installing a will‑executor server or collaboration, send your request to:
|
||||
**info@bitcoin-after.life**
|
||||
|
||||
|
||||
285
make-release.sh
Executable file
285
make-release.sh
Executable file
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env bash
|
||||
# make-release.sh — Create a Gitea release for bal-electrum-plugin
|
||||
#
|
||||
# Usage:
|
||||
# ./make-release.sh # read version from bal/manifest.json
|
||||
# ./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
|
||||
#
|
||||
# Requires: git, gpg, curl, python3, sha256sum
|
||||
# Optional: ruff (lint skipped if not installed)
|
||||
# Credentials: ~/.git-credentials or GITEA_USER / GITEA_TOKEN env vars
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────
|
||||
die() { echo "Error: $*" >&2; exit 1; }
|
||||
info() { echo ""; echo "── $* ──"; }
|
||||
|
||||
# ── 0. Resolve version ──────────────────────────────────────────────
|
||||
MANIFEST="bal/manifest.json"
|
||||
[ -f "$MANIFEST" ] || die "manifest not found: $MANIFEST"
|
||||
|
||||
# read current version from manifest
|
||||
CURRENT_VER=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['version'])")
|
||||
[ -n "$CURRENT_VER" ] || die "cannot read version from $MANIFEST"
|
||||
|
||||
ARG="${1:-}"
|
||||
if [ -n "$ARG" ]; then
|
||||
# normalise: accept "v0.6.2" or "0.6.2"
|
||||
NEW_VER="${ARG#v}"
|
||||
TAG="v${NEW_VER}"
|
||||
else
|
||||
TAG="v${CURRENT_VER}"
|
||||
NEW_VER=""
|
||||
fi
|
||||
|
||||
echo "=== Release ${TAG} ==="
|
||||
echo "Current manifest version: ${CURRENT_VER}"
|
||||
[ -n "$NEW_VER" ] && echo "New version (will bump): ${NEW_VER}"
|
||||
|
||||
# ── 1. Bump version in manifest (if arg provided) ──────────────────
|
||||
if [ -n "$NEW_VER" ] && [ "$NEW_VER" != "$CURRENT_VER" ]; then
|
||||
info "[1/10] Bumping version to ${NEW_VER} in ${MANIFEST}"
|
||||
python3 -c "
|
||||
import json
|
||||
f = open('${MANIFEST}')
|
||||
d = json.load(f); f.close()
|
||||
d['version'] = '${NEW_VER}'
|
||||
json.dump(d, open('${MANIFEST}', 'w'), indent=4, ensure_ascii=False)
|
||||
print(json.dumps(d, indent=4, ensure_ascii=False))
|
||||
"
|
||||
git add "$MANIFEST"
|
||||
else
|
||||
info "[1/10] Version already ${CURRENT_VER}, no bump needed"
|
||||
fi
|
||||
|
||||
# ── 2. Clean caches ─────────────────────────────────────────────────
|
||||
info "[2/10] Cleaning __pycache__ and .pyc"
|
||||
find bal -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
|
||||
find bal -name "*.pyc" -delete 2>/dev/null || true
|
||||
find bal -name "*.pyo" -delete 2>/dev/null || true
|
||||
|
||||
# ── 3. Run tests ────────────────────────────────────────────────────
|
||||
info "[3/10] Running test suite"
|
||||
if QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
|
||||
tests/test_core_*.py \
|
||||
tests/test_anticipate_past_locktime.py tests/test_anticipate_manual_locktime.py \
|
||||
tests/test_group_b_auto_sign.py tests/test_group_c_settings.py \
|
||||
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
|
||||
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
|
||||
tests/test_group_h_v048.py \
|
||||
-q 2>&1; then
|
||||
echo "All tests passed."
|
||||
else
|
||||
die "Tests failed — aborting release."
|
||||
fi
|
||||
|
||||
# ── 4. Lint (optional) ─────────────────────────────────────────────
|
||||
info "[4/10] Lint with ruff"
|
||||
if command -v ruff &>/dev/null; then
|
||||
RUFF_ERRORS=$(ruff check bal/ 2>&1 \
|
||||
| grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" \
|
||||
| grep -vE "F401|F403|F405|F841" || true)
|
||||
if [ -n "$RUFF_ERRORS" ]; then
|
||||
echo "New ruff errors:"
|
||||
echo "$RUFF_ERRORS"
|
||||
die "Lint errors found — fix before releasing."
|
||||
else
|
||||
echo "Lint clean (ignoring known pre-existing warnings)."
|
||||
fi
|
||||
else
|
||||
echo "ruff not installed — skipping lint."
|
||||
fi
|
||||
|
||||
# ── 5. Build ZIP via build_zip.py ───────────────────────────────────
|
||||
info "[5/10] Building ZIP"
|
||||
ZIP_NAME="bal_${TAG}.zip"
|
||||
python3 build_zip.py "$ZIP_NAME"
|
||||
|
||||
# ── 6. GPG sign (armor + binary) + export public key ───────────────
|
||||
info "[6/10] Signing with GPG"
|
||||
GPG_KEY="A847D004DB91610711CA6A0DFE756706E833E0D1"
|
||||
gpg --default-key "$GPG_KEY" --batch --yes --armor --detach-sign "$ZIP_NAME"
|
||||
gpg --default-key "$GPG_KEY" --batch --yes --detach-sign "$ZIP_NAME"
|
||||
ASC_FILE="${ZIP_NAME}.asc"
|
||||
SIG_FILE="${ZIP_NAME}.sig"
|
||||
PGP_FILE="svatantrya.asc"
|
||||
gpg --armor --export "$GPG_KEY" > "$PGP_FILE"
|
||||
echo " Signed: $ASC_FILE"
|
||||
echo " Signed: $SIG_FILE"
|
||||
echo " Public key: $PGP_FILE"
|
||||
|
||||
# ── 7. SHA-256 checksum ────────────────────────────────────────────
|
||||
info "[7/10] Computing SHA-256"
|
||||
SHA256_HASH=$(sha256sum "$ZIP_NAME" | cut -d' ' -f1)
|
||||
echo "${SHA256_HASH} ${ZIP_NAME}" > "${ZIP_NAME}.sha256"
|
||||
echo " SHA-256: ${SHA256_HASH}"
|
||||
|
||||
# ── 8. Pause for Electrum test ─────────────────────────────────────
|
||||
info "[8/10] Test in Electrum (ZIP-FIRST policy)"
|
||||
echo ""
|
||||
echo " ZIP ready: $(pwd)/${ZIP_NAME}"
|
||||
echo ""
|
||||
echo " Install it in Electrum (Tools -> Plugins -> Install from file)."
|
||||
echo " IMPORTANT: fully restart Electrum (not just reload the plugin)."
|
||||
echo ""
|
||||
read -r -p " Does the plugin work correctly in Electrum? [y/N] " CONFIRM
|
||||
case "$CONFIRM" in
|
||||
[yY][eE][sS]|[yY]) echo " Confirmed." ;;
|
||||
*) die "Aborted by user." ;;
|
||||
esac
|
||||
|
||||
# ── 9. Git tag + push ──────────────────────────────────────────────
|
||||
info "[9/10] Creating and pushing tag ${TAG}"
|
||||
ORIGIN_URL="$(git remote get-url origin 2>/dev/null || true)"
|
||||
[ -n "$ORIGIN_URL" ] || die "no git remote 'origin' found"
|
||||
|
||||
GITEA_HOST="$(echo "$ORIGIN_URL" | sed -n 's|https://\([^/]*\)/.*|\1|p')"
|
||||
[ -n "$GITEA_HOST" ] || die "cannot parse Gitea host from origin URL"
|
||||
|
||||
TARGET_REPO="bitcoinafterlife/bal-electrum-plugin"
|
||||
|
||||
# credentials
|
||||
GITEA_USER="${GITEA_USER:-}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
if [ -z "$GITEA_USER" ] && [ -z "$GITEA_TOKEN" ]; then
|
||||
if [ -f ~/.git-credentials ]; then
|
||||
CREDS_LINE="$(grep "${GITEA_HOST}" ~/.git-credentials | head -n1)"
|
||||
if [ -n "$CREDS_LINE" ]; then
|
||||
CREDS="$(echo "$CREDS_LINE" | sed -n 's|https://\([^@]*\)@.*|\1|p')"
|
||||
GITEA_USER="$(echo "$CREDS" | cut -d: -f1)"
|
||||
GITEA_PASS="$(echo "$CREDS" | cut -d: -f2-)"
|
||||
GITEA_TOKEN="$GITEA_PASS"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
[ -n "$GITEA_TOKEN" ] || die "GITEA_TOKEN not set and no credentials in ~/.git-credentials"
|
||||
|
||||
API="https://$GITEA_HOST/gitea/api/v1"
|
||||
|
||||
# create annotated tag
|
||||
git tag -d "$TAG" 2>/dev/null || true
|
||||
git tag -a "$TAG" -m "$TAG" HEAD
|
||||
|
||||
# push tag
|
||||
REMOTE_NAME="gitea-target"
|
||||
REMOTE_URL="https://$GITEA_USER:$GITEA_TOKEN@$GITEA_HOST/gitea/$TARGET_REPO.git"
|
||||
git remote rm "$REMOTE_NAME" 2>/dev/null || true
|
||||
git remote add "$REMOTE_NAME" "$REMOTE_URL"
|
||||
echo " Pushing tag ${TAG} to ${TARGET_REPO}..."
|
||||
git push "$REMOTE_NAME" "$TAG" --force
|
||||
|
||||
# ── 10. Create release + upload assets ──────────────────────────────
|
||||
info "[10/10] Creating Gitea release"
|
||||
|
||||
RELEASE_BODY=$(python3 -c "
|
||||
import json
|
||||
|
||||
sha256 = '${SHA256_HASH}'
|
||||
zip_name = '${ZIP_NAME}'
|
||||
asc_name = '${ASC_FILE}'
|
||||
sig_name = '${SIG_FILE}'
|
||||
pgp_file = '${PGP_FILE}'
|
||||
tag = '${TAG}'
|
||||
|
||||
body = f'''Release {tag}
|
||||
|
||||
## SHA-256 Checksum
|
||||
|
||||
\`\`\`
|
||||
{sha256} {zip_name}
|
||||
\`\`\`
|
||||
|
||||
### Verify SHA-256
|
||||
|
||||
\`\`\`bash
|
||||
sha256sum -c {zip_name}.sha256
|
||||
\`\`\`
|
||||
|
||||
## Download
|
||||
|
||||
- \`{zip_name}\` - Plugin BAL {tag}
|
||||
- \`{asc_name}\` - GPG signature (armor)
|
||||
- \`{sig_name}\` - GPG signature (binary)
|
||||
- \`{pgp_file}\` - Signing public key ([also available online](https://bitcoin-after.life/svatantrya.asc))
|
||||
|
||||
## GPG Verification
|
||||
|
||||
### Import the signing key
|
||||
|
||||
\`\`\`bash
|
||||
gpg --fetch-key https://bitcoin-after.life/svatantrya.asc
|
||||
\`\`\`
|
||||
|
||||
Or download \`{pgp_file}\` from the assets above:
|
||||
|
||||
\`\`\`bash
|
||||
gpg --import {pgp_file}
|
||||
\`\`\`
|
||||
|
||||
### Verify the signature (armor)
|
||||
|
||||
\`\`\`bash
|
||||
gpg --verify {asc_name} {zip_name}
|
||||
\`\`\`
|
||||
|
||||
### Verify the signature (binary)
|
||||
|
||||
\`\`\`bash
|
||||
gpg --verify {sig_name} {zip_name}
|
||||
\`\`\`
|
||||
|
||||
Expected output:
|
||||
\`\`\`
|
||||
gpg: Good signature from "Svātantrya <svatantrya@bitcoin-after.life>"
|
||||
\`\`\`
|
||||
|
||||
Fingerprint: \`A847D004DB91610711CA6A0DFE756706E833E0D1\`
|
||||
Public key: https://bitcoin-after.life/svatantrya.asc'''
|
||||
|
||||
print(json.dumps({'body': body}, ensure_ascii=False))
|
||||
")
|
||||
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token $GITEA_TOKEN" \
|
||||
-X POST "${API}/repos/${TARGET_REPO}/releases" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":${RELEASE_BODY},\"draft\":false,\"prerelease\":false}")
|
||||
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
|
||||
if [ "$HTTP_CODE" != "201" ]; then
|
||||
echo "Error creating release: HTTP $HTTP_CODE"
|
||||
echo "$BODY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_ID=$(echo "$BODY" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
|
||||
HTML_URL=$(echo "$BODY" | python3 -c "import json,sys; print(json.load(sys.stdin)['html_url'])")
|
||||
echo " Release created: $HTML_URL (ID: $RELEASE_ID)"
|
||||
|
||||
# upload assets
|
||||
for FILE in "$ZIP_NAME" "$ASC_FILE" "$SIG_FILE" "${ZIP_NAME}.sha256" "$PGP_FILE"; do
|
||||
BASENAME=$(basename "$FILE")
|
||||
echo " Uploading $BASENAME ..."
|
||||
UPLOAD=$(curl -s -w "\n%{http_code}" -H "Authorization: token $GITEA_TOKEN" \
|
||||
-X POST "${API}/repos/${TARGET_REPO}/releases/${RELEASE_ID}/assets" \
|
||||
-F "attachment=@${FILE}" -F "name=${BASENAME}")
|
||||
UPLOAD_CODE=$(echo "$UPLOAD" | tail -1)
|
||||
if [ "$UPLOAD_CODE" == "201" ]; then
|
||||
echo " OK ($BASENAME)"
|
||||
else
|
||||
echo " FAILED ($BASENAME) - HTTP $UPLOAD_CODE"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
echo "Release: $HTML_URL"
|
||||
echo "Assets:"
|
||||
echo " ${ZIP_NAME}"
|
||||
echo " ${ASC_FILE}"
|
||||
echo " ${SIG_FILE}"
|
||||
echo " ${ZIP_NAME}.sha256"
|
||||
echo " ${PGP_FILE}"
|
||||
echo "SHA-256: ${SHA256_HASH}"
|
||||
21
pyproject.toml
Normal file
21
pyproject.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select =["E", "W", "F", "I", "N", "B"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.lint.pep8-naming]
|
||||
classmethod-decorators = ["classmethod", "classproperty"] # electrum.util.classproperty uses cls
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"bal/gui/qt/common.py" = ["F401"] # re-exports consumed via explicit imports
|
||||
"bal/gui/qt/dialogs.py" = ["N802"] # Qt overrides: closeEvent/hideEvent/getText
|
||||
"bal/gui/qt/lists.py" = ["N802"] # Qt overrides: createEditor/setEditorData/setModelData
|
||||
"bal/gui/qt/widgets.py" = ["N802", "N815"] # Qt overrides + Qt signal attrs (valueChanged, ...)
|
||||
"bal/gui/qt/window.py" = ["N802"] # getMsg
|
||||
"bal/core/heirs.py" = ["N818", "N802"] # public exception names + buildTransactions API
|
||||
"bal/core/will.py" = ["N818"] # public exception names
|
||||
"bal/core/willexecutors.py" = ["N818"] # public exception names
|
||||
"tests/*.py" = ["N802", "E402"] # deliberate UPPER_CASE helpers + sys.path-before-import
|
||||
30
svatantrya.asc
Normal file
30
svatantrya.asc
Normal file
@@ -0,0 +1,30 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQENBGfgPmMBCAC4VXQn/ofBGPn/Wr9dF4tM/4uYNcWLvvz+/+TQsCi/bv4GG6jf
|
||||
6Ttlg4TDwqF3JlZ1YfPImcdWKxr9is4fyq12OEZvz12LoFEJG8+0NdJrCoT2sm2f
|
||||
yGmWKgZqRzH9LVBtIOOQIrXF3PdE0X77trWnSFrK/qAv9dszYiVOk9IBwUVI/3Wp
|
||||
PN5EV7zqbCjYvzD0Hxl2sFzZKqsZCsiy70PJtaJKvKISd8RVTNuIiwZj0gu6hCSa
|
||||
ZnBr5SLLr56YO4xaTzYNYh7XIEaQXZTHugEJbwygfZajnJ8gC91wWB3BsxVeHDdm
|
||||
uDy1VGkAs65qvRn9ml5udmnEIPoEsS95HblpABEBAAG0K1N2xIF0YW50cnlhIDxz
|
||||
dmF0YW50cnlhQGJpdGNvaW4tYWZ0ZXIubGlmZT6JAU4EEwEKADgWIQSoR9AE25Fh
|
||||
BxHKag3+dWcG6DPg0QUCZ+A+YwIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAK
|
||||
CRD+dWcG6DPg0coSCACbu3/tqMTwWTqRzXedl6VTGng+qeYfA5NYUaRgZeQYcVWM
|
||||
sUi4dTAthBUxU3axfcu3V/Vkonn/Hrghdjh94lfpNsgdBNi3c2elI1rHT3Yobkj+
|
||||
ZsMEj91VlqV81uPFzfq8a/Pp7RIDhy1FJbIunmjnpD3GeJ7vVt76OOcyjV5hkGR0
|
||||
YJ4JX9O1OOC6wqgR2HVCvXTw/3JhNbj4TS8wr7GGsVWwiotAwZw506vspQRBqeYB
|
||||
T5Wo2lpEQtagWzIHtgy4A2iAoLQ45E0T1lkr+mZa3V7sucS6W/UXI7HTvqC7wbku
|
||||
jef6Hxwzzw83TWqPkd4wywuHsDZ3+DTcIDaqP/ROuQENBGfgPmMBCAC69Y2n2Ogi
|
||||
T7i4Pm4J0cQxLaqwvox3GWSRuBG0QlhsBr0ER5j5fRRDH85P/WyTcvs4/9mIZsSl
|
||||
JyQH/Lfetr/76pFCyc2zhKxxS1miG3RWOuM7BOKbRjjiieBa6XAiyWStKp2ij8a/
|
||||
kpqqgulLe1Tiq2SRPA8etqHGd7oR02fbEvzmsgiVqFOz3/tozp2jdC7zCKnp+XFZ
|
||||
xMKqhIMgfZAxRmVl/qImH944ffcJU6M+qjEL3ENXpuDXpMSWI/indlbK06+R/UPA
|
||||
hOxCOUSRPTeHzhQrJYUgH6Q6Q/cijpTQHVQFFqLXRKGgK7oE1QhmiNGNBeCVF7DP
|
||||
hpcWrnUkY/xFABEBAAGJATYEGAEKACAWIQSoR9AE25FhBxHKag3+dWcG6DPg0QUC
|
||||
Z+A+YwIbDAAKCRD+dWcG6DPg0ToJB/4t2V4FMqd2q00Sd+HmttZoAWNuklui8wO4
|
||||
nrjfh3Rt0ZBYYk+egZXzPx8lr42Ec8T4h24oJPovMlDu1xN9seQDbVaYC1ICVsnp
|
||||
6/yfh+elYT5egaAxm9oP9+lQHBB/qZNKrfAssMuVQOrVh5E+XxSz+KG28dQnCYUT
|
||||
L0k5PCO1f4Jz4XZd5AunVbMQ4J1JawUDoEb/w3Mn9ALDMsdAcOYC6pGhFtV88cqu
|
||||
IO/ekQV+M8LpRwyh+CiPzgqtN3Z09wHLXFUJYBixXrYbXxAbSqe0PhqAhEKApk2c
|
||||
4vVkSTAi+bNpkt0QgJ194iTyK20jVw3/roq7sUtDD4FrUoQb7llP
|
||||
=6EE4
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
18
tests/conftest.py
Normal file
18
tests/conftest.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Shared pytest fixtures.
|
||||
|
||||
Guards every test against cross-file network pollution: several karen7
|
||||
regtest modules historically flipped ``electrum.constants.net`` to regtest at
|
||||
import time, which broke unrelated offline tests (e.g. the CLI controller
|
||||
suite) run in the same pytest process.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from electrum import constants
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_network():
|
||||
"""Snapshot ``constants.net`` before each test and restore it after."""
|
||||
prev = constants.net
|
||||
yield
|
||||
constants.net = prev
|
||||
@@ -33,7 +33,8 @@ def _active_source_without_strings(module) -> str:
|
||||
if isinstance(node.value, str) and hasattr(node, "end_lineno"):
|
||||
self.spans.append((node.lineno, node.end_lineno))
|
||||
self.generic_visit(node)
|
||||
s = _S(); s.visit(tree)
|
||||
s = _S()
|
||||
s.visit(tree)
|
||||
drop = set()
|
||||
for a, b in s.spans:
|
||||
drop.update(range(a, b + 1))
|
||||
@@ -45,12 +46,13 @@ def _active_source_without_strings(module) -> str:
|
||||
|
||||
def main(pkg: str) -> int:
|
||||
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")
|
||||
|
||||
# top_level_of: returns the top-level container of a child widget
|
||||
w = QWidget(); child = QWidget(w)
|
||||
w = QWidget()
|
||||
child = QWidget(w)
|
||||
assert wu.top_level_of(child) is w
|
||||
assert wu.top_level_of(None) is None
|
||||
print("[OK] top_level_of")
|
||||
|
||||
11949
tests/karen7
11949
tests/karen7
File diff suppressed because one or more lines are too long
@@ -31,7 +31,7 @@ N = 8 # number of servers
|
||||
|
||||
def main():
|
||||
we_mod = importlib.import_module(f"{PKG}.core.willexecutors")
|
||||
W = we_mod.Willexecutors
|
||||
we_cls = we_mod.Willexecutors
|
||||
|
||||
# ---- 1) ping_servers_parallel: time ~= slowest, not sum ----
|
||||
def slow_get_info(url, we, **kwargs):
|
||||
@@ -43,8 +43,8 @@ def main():
|
||||
we["status"] = 200
|
||||
return we
|
||||
|
||||
orig_get_info = W.get_info_task
|
||||
W.get_info_task = staticmethod(slow_get_info)
|
||||
orig_get_info = we_cls.get_info_task
|
||||
we_cls.get_info_task = staticmethod(slow_get_info)
|
||||
try:
|
||||
wes = {}
|
||||
for i in range(N):
|
||||
@@ -57,7 +57,7 @@ def main():
|
||||
seen.append((url, ok))
|
||||
|
||||
start = time.time()
|
||||
W.ping_servers_parallel(wes, on_each=on_each, max_workers=N)
|
||||
we_cls.ping_servers_parallel(wes, on_each=on_each, max_workers=N)
|
||||
elapsed = time.time() - start
|
||||
|
||||
# Sequential would take ~ N * SLOW. Parallel must be far less.
|
||||
@@ -81,15 +81,15 @@ def main():
|
||||
assert we["status"] == "KO", (url, we)
|
||||
print("[OK] ping results written back into the willexecutors mapping")
|
||||
finally:
|
||||
W.get_info_task = orig_get_info
|
||||
we_cls.get_info_task = orig_get_info
|
||||
|
||||
# ---- 2) push_transactions_parallel: time ~= slowest, not sum ----
|
||||
def slow_push(we, **kwargs):
|
||||
time.sleep(SLOW)
|
||||
return "fail" not in we["url"]
|
||||
|
||||
orig_push = W.push_transactions_to_willexecutor
|
||||
W.push_transactions_to_willexecutor = staticmethod(slow_push)
|
||||
orig_push = we_cls.push_transactions_to_willexecutor
|
||||
we_cls.push_transactions_to_willexecutor = staticmethod(slow_push)
|
||||
try:
|
||||
wes = {}
|
||||
for i in range(N):
|
||||
@@ -106,7 +106,7 @@ def main():
|
||||
pushed.append((url, ok))
|
||||
|
||||
start = time.time()
|
||||
results = W.push_transactions_parallel(wes, on_each=on_each_push,
|
||||
results = we_cls.push_transactions_parallel(wes, on_each=on_each_push,
|
||||
max_workers=N)
|
||||
elapsed = time.time() - start
|
||||
|
||||
@@ -117,11 +117,11 @@ def main():
|
||||
f"(sequential would be ~{sequential:.2f}s)")
|
||||
|
||||
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)
|
||||
print("[OK] push results correct for every server")
|
||||
finally:
|
||||
W.push_transactions_to_willexecutor = orig_push
|
||||
we_cls.push_transactions_to_willexecutor = orig_push
|
||||
|
||||
# ---- 2b) global deadline: a hung server must not block past `deadline` ----
|
||||
def hanging_push(we, **kwargs):
|
||||
@@ -129,8 +129,8 @@ def main():
|
||||
time.sleep(10)
|
||||
return True
|
||||
|
||||
orig_push2 = W.push_transactions_to_willexecutor
|
||||
W.push_transactions_to_willexecutor = staticmethod(hanging_push)
|
||||
orig_push2 = we_cls.push_transactions_to_willexecutor
|
||||
we_cls.push_transactions_to_willexecutor = staticmethod(hanging_push)
|
||||
try:
|
||||
wes = {
|
||||
"https://fast.example": {
|
||||
@@ -146,7 +146,7 @@ def main():
|
||||
return True
|
||||
time.sleep(10)
|
||||
return True
|
||||
W.push_transactions_to_willexecutor = staticmethod(fast_or_hang)
|
||||
we_cls.push_transactions_to_willexecutor = staticmethod(fast_or_hang)
|
||||
|
||||
timed_out = []
|
||||
|
||||
@@ -154,7 +154,7 @@ def main():
|
||||
timed_out.append(url)
|
||||
|
||||
start = time.time()
|
||||
W.push_transactions_parallel(
|
||||
we_cls.push_transactions_parallel(
|
||||
wes, max_workers=2, deadline=1.0, on_timeout=on_timeout
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
@@ -163,7 +163,7 @@ def main():
|
||||
print(f"[OK] global deadline enforced: returned in {elapsed:.1f}s, "
|
||||
f"hung server reported via on_timeout")
|
||||
finally:
|
||||
W.push_transactions_to_willexecutor = orig_push2
|
||||
we_cls.push_transactions_to_willexecutor = orig_push2
|
||||
|
||||
# ---- 2c) on_tick is fired periodically from the CALLING thread ----
|
||||
# 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
|
||||
return True
|
||||
|
||||
orig_push3 = W.push_transactions_to_willexecutor
|
||||
W.push_transactions_to_willexecutor = staticmethod(slow_push2)
|
||||
orig_push3 = we_cls.push_transactions_to_willexecutor
|
||||
we_cls.push_transactions_to_willexecutor = staticmethod(slow_push2)
|
||||
try:
|
||||
wes = {
|
||||
"https://tick.example": {
|
||||
@@ -191,7 +191,7 @@ def main():
|
||||
ticks.append(time.time())
|
||||
tick_threads.add(threading.current_thread())
|
||||
|
||||
W.push_transactions_parallel(
|
||||
we_cls.push_transactions_parallel(
|
||||
wes, max_workers=1, on_tick=on_tick, tick_interval=0.5
|
||||
)
|
||||
# ~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")
|
||||
finally:
|
||||
W.push_transactions_to_willexecutor = orig_push3
|
||||
we_cls.push_transactions_to_willexecutor = orig_push3
|
||||
|
||||
# ---- 2d) check_transactions_parallel: parallel + deadline + on_tick ----
|
||||
# Pressing "Check" verifies each will-executor still holds its tx. This used
|
||||
@@ -214,8 +214,8 @@ def main():
|
||||
time.sleep(SLOW)
|
||||
return {"tx": "ok"} if "good" in url else None
|
||||
|
||||
orig_check = W.check_transaction
|
||||
W.check_transaction = staticmethod(slow_check)
|
||||
orig_check = we_cls.check_transaction
|
||||
we_cls.check_transaction = staticmethod(slow_check)
|
||||
try:
|
||||
targets = []
|
||||
for i in range(N):
|
||||
@@ -228,7 +228,7 @@ def main():
|
||||
checked.append((wid, res))
|
||||
|
||||
start = time.time()
|
||||
results = W.check_transactions_parallel(
|
||||
results = we_cls.check_transactions_parallel(
|
||||
targets, on_each=on_each_check, max_workers=N
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
@@ -239,7 +239,7 @@ def main():
|
||||
print(f"[OK] check parallel: {elapsed:.2f}s for {N} servers "
|
||||
f"(sequential would be ~{sequential:.2f}s)")
|
||||
finally:
|
||||
W.check_transaction = orig_check
|
||||
we_cls.check_transaction = orig_check
|
||||
|
||||
# 2d-bis) global deadline + on_tick from the calling thread
|
||||
def hanging_check(txid, url, **kwargs):
|
||||
@@ -248,8 +248,8 @@ def main():
|
||||
time.sleep(10)
|
||||
return {"tx": "ok"}
|
||||
|
||||
orig_check2 = W.check_transaction
|
||||
W.check_transaction = staticmethod(hanging_check)
|
||||
orig_check2 = we_cls.check_transaction
|
||||
we_cls.check_transaction = staticmethod(hanging_check)
|
||||
try:
|
||||
targets = [
|
||||
("idf", "https://fast.example"),
|
||||
@@ -268,7 +268,7 @@ def main():
|
||||
tick_threads.add(threading.current_thread())
|
||||
|
||||
start = time.time()
|
||||
W.check_transactions_parallel(
|
||||
we_cls.check_transactions_parallel(
|
||||
targets, max_workers=2, deadline=2.0,
|
||||
on_timeout=on_timeout_check, on_tick=on_tick_check,
|
||||
tick_interval=0.5,
|
||||
@@ -282,7 +282,7 @@ def main():
|
||||
print(f"[OK] check global deadline enforced ({elapsed:.1f}s), on_tick "
|
||||
f"fired {len(ticks)}x from the calling thread")
|
||||
finally:
|
||||
W.check_transaction = orig_check2
|
||||
we_cls.check_transaction = orig_check2
|
||||
|
||||
# ---- 3) the wizard's loop_push must use the parallel helper ----
|
||||
# The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push.
|
||||
|
||||
@@ -17,8 +17,8 @@ import sys
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
# Same colors as BalBuildWillDialog
|
||||
COLOR_WARNING = "#cfa808"
|
||||
|
||||
@@ -17,10 +17,15 @@ import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PyQt6.QtWidgets import ( # noqa: E402
|
||||
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
)
|
||||
from PyQt6.QtCore import Qt # noqa: E402
|
||||
from PyQt6.QtWidgets import ( # noqa: E402
|
||||
QApplication,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
COLOR_OK = "#05ad05"
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ import sys
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
COLOR_ERROR = "#ff0000"
|
||||
COLOR_OK = "#05ad05"
|
||||
|
||||
@@ -20,12 +20,17 @@ import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PyQt6.QtWidgets import ( # noqa: E402
|
||||
QApplication, QWidget, QHBoxLayout, QPushButton, QComboBox, QLineEdit,
|
||||
QLabel,
|
||||
)
|
||||
from PyQt6.QtCore import QSize # noqa: E402
|
||||
from PyQt6.QtGui import QIcon, QPixmap # noqa: E402
|
||||
from PyQt6.QtCore import QSize, Qt # noqa: E402
|
||||
from PyQt6.QtWidgets import ( # noqa: E402
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
ICON_PATH = os.path.join(os.path.dirname(__file__), "..", "bal", "icons",
|
||||
"wizard.png")
|
||||
|
||||
@@ -27,12 +27,19 @@ import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PyQt6.QtWidgets import ( # noqa: E402
|
||||
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QComboBox,
|
||||
QLineEdit, QSpinBox, QLabel,
|
||||
)
|
||||
from PyQt6.QtGui import QFontMetrics # noqa: E402
|
||||
from PyQt6.QtCore import Qt # noqa: E402
|
||||
from PyQt6.QtGui import QFontMetrics # noqa: E402
|
||||
from PyQt6.QtWidgets import ( # noqa: E402
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
def _char_w():
|
||||
|
||||
792
tests/samanta7
Normal file
792
tests/samanta7
Normal file
File diff suppressed because one or more lines are too long
@@ -21,18 +21,21 @@ Run:
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import copy
|
||||
import sys
|
||||
|
||||
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 (
|
||||
WillItem, Will,
|
||||
NotCompleteWillException, HeirNotFoundException, NoHeirsException,
|
||||
TxFeesChangedException, WillExpiredException,
|
||||
HeirNotFoundException,
|
||||
NoHeirsException,
|
||||
NotCompleteWillException,
|
||||
TxFeesChangedException,
|
||||
Will,
|
||||
WillExpiredException,
|
||||
WillItem,
|
||||
)
|
||||
from bal.core.util import Util
|
||||
|
||||
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0.
|
||||
_VALID_TX_HEX = (
|
||||
@@ -55,7 +58,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
||||
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
|
||||
d = {
|
||||
"tx": _VALID_TX_HEX,
|
||||
"heirs": copy.deepcopy(heirs),
|
||||
"heirs": copy_structure(heirs),
|
||||
"willexecutor": None,
|
||||
"status": "",
|
||||
"description": "",
|
||||
@@ -64,7 +67,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
||||
"baltx_fees": TX_FEES,
|
||||
}
|
||||
item = WillItem(d, _id="willid_1")
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
# Force the locktime frozen "inside" the signed tx.
|
||||
item.tx.locktime = tx_locktime
|
||||
if status_complete:
|
||||
@@ -115,7 +118,7 @@ def main():
|
||||
# Scenario 0: nothing changed -> should be coherent.
|
||||
heirs = {"alice": ["addr_alice", 5000, same_lt]}
|
||||
_run("0. nothing changed",
|
||||
will_heirs=heirs, current_heirs=copy.deepcopy(heirs),
|
||||
will_heirs=heirs, current_heirs=copy_structure(heirs),
|
||||
tx_locktime=base_lt, check_date=0)
|
||||
|
||||
# Scenario 1: delivery date moved forward (postpone), will NOT yet signed.
|
||||
|
||||
@@ -26,30 +26,28 @@ def main():
|
||||
from PyQt6.QtWidgets import QApplication # noqa
|
||||
_app = QApplication.instance() or QApplication([])
|
||||
|
||||
results = {}
|
||||
|
||||
# 1) Core modules import (these must be GUI-free).
|
||||
bal = imp_core("bal", "core.plugin_base")
|
||||
util = imp_core("util", "core.util")
|
||||
heirs = imp_core("heirs", "core.heirs")
|
||||
will = imp_core("will", "core.will")
|
||||
we = imp_core("willexecutors", "core.willexecutors")
|
||||
_we = imp_core("willexecutors", "core.willexecutors")
|
||||
|
||||
# 2) GUI module imports.
|
||||
qt = imp_gui()
|
||||
|
||||
# 3) Behaviour checks (pure logic, must be identical across versions).
|
||||
BalTimestamp = bal.BalTimestamp
|
||||
assert BalTimestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
|
||||
assert BalTimestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
|
||||
assert str(BalTimestamp("7d")) == "7d", "BalTimestamp str"
|
||||
bal_timestamp = bal.BalTimestamp
|
||||
assert bal_timestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
|
||||
assert bal_timestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
|
||||
assert str(bal_timestamp("7d")) == "7d", "BalTimestamp str"
|
||||
|
||||
Util = util.Util
|
||||
assert Util.is_perc("50%") is True
|
||||
assert Util.is_perc("100") is False
|
||||
assert Util.text_to_hex("BAL") == "42414c"
|
||||
assert Util.hex_to_text("42414c") == "BAL"
|
||||
assert Util.int_locktime(days=1) == 86400
|
||||
util_cls = util.Util
|
||||
assert util_cls.is_perc("50%") is True
|
||||
assert util_cls.is_perc("100") is False
|
||||
assert util_cls.text_to_hex("BAL") == "42414c"
|
||||
assert util_cls.hex_to_text("42414c") == "BAL"
|
||||
assert util_cls.int_locktime(days=1) == 86400
|
||||
|
||||
# heirs constants must keep the same column layout (very delicate!)
|
||||
assert heirs.HEIR_ADDRESS == 0
|
||||
|
||||
@@ -27,19 +27,19 @@ Run:
|
||||
tests/test_anticipate_manual_locktime.py -q
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import copy
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
import pytest # noqa: E402
|
||||
import pytest # noqa: E402 # pyright: ignore[reportMissingImports]
|
||||
|
||||
from bal.core.util import copy_structure # noqa: E402
|
||||
from bal.core.will import ( # noqa: E402
|
||||
WillItem,
|
||||
Will,
|
||||
NotCompleteWillException,
|
||||
Will,
|
||||
WillExpiredException,
|
||||
WillItem,
|
||||
)
|
||||
|
||||
# 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 = {
|
||||
"tx": _VALID_TX_HEX,
|
||||
"heirs": copy.deepcopy(heirs),
|
||||
"heirs": copy_structure(heirs),
|
||||
"willexecutor": None,
|
||||
"status": "",
|
||||
"description": "",
|
||||
@@ -79,7 +79,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
||||
"baltx_fees": TX_FEES,
|
||||
}
|
||||
item = WillItem(d, _id="willid_1")
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
item.tx.locktime = tx_locktime
|
||||
if status_complete:
|
||||
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
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.util import Util, LOCKTIME_THRESHOLD
|
||||
from bal.core.util import LOCKTIME_THRESHOLD, Util
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
570
tests/test_auto_rebuild_on_new_tx.py
Normal file
570
tests/test_auto_rebuild_on_new_tx.py
Normal file
@@ -0,0 +1,570 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the "Rebuild automatically on new transactions" (AUTO_REBUILD)
|
||||
feature.
|
||||
|
||||
Covers:
|
||||
|
||||
* the persisted ``bal_auto_rebuild`` configuration key exists and defaults
|
||||
to OFF (False), and can be enabled and read back;
|
||||
* the event wiring: ``Plugin._wallet_activity`` schedules the rebuild only
|
||||
for the matching wallet and only when the setting is enabled;
|
||||
* ``BalWindow.schedule_auto_rebuild`` debounces through ``QTimer`` and the
|
||||
re-entrancy / cooldown guards;
|
||||
* ``BalWindow.maybe_auto_rebuild`` reproduces the wizard's close-time flow:
|
||||
- no-op when the will is still valid;
|
||||
- rebuild + sign + push when a new UTXO invalidates the will (no on-chain
|
||||
invalidation, the rebuilt tx is anticipated to mine before the old);
|
||||
- on-chain invalidation when the check-alive threshold is already in the
|
||||
past (CheckAliveError);
|
||||
- on-chain invalidation when the will is already expired;
|
||||
- on-chain invalidation when the anticipated locktime would fall before
|
||||
the check-alive threshold (and no sign/push in that case).
|
||||
|
||||
Run:
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_auto_rebuild_on_new_tx.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest.mock as mock
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from electrum import bitcoin, crypto # noqa: E402
|
||||
from electrum.descriptor import parse_descriptor # noqa: E402
|
||||
from electrum.transaction import ( # noqa: E402
|
||||
PartialTxInput,
|
||||
PartialTxOutput,
|
||||
TxOutpoint,
|
||||
)
|
||||
from electrum.util import bfh # noqa: E402
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import bal.gui.qt.window as window_mod # noqa: E402
|
||||
from bal.core.heirs import Heirs # noqa: E402
|
||||
from bal.core.plugin_base import BalConfig, BalPlugin # noqa: E402
|
||||
from bal.core.util import Util # noqa: E402
|
||||
from bal.core.will import Will # noqa: E402
|
||||
from bal.core.willexecutors import Willexecutors # noqa: E402
|
||||
from bal.gui.qt.plugin import Plugin # noqa: E402
|
||||
from bal.gui.qt.window import BalWindow # noqa: E402
|
||||
|
||||
CONFIG_KEY = "bal_auto_rebuild"
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
PRIVKEY = bytes(range(32))
|
||||
PUBKEY = crypto.privkey_to_pubkey(PRIVKEY)
|
||||
ADDRESS = bitcoin.public_key_to_p2wpkh(PUBKEY)
|
||||
SCRIPT = bitcoin.address_to_script(ADDRESS)
|
||||
FUNDING_SATOSHIS = 500000
|
||||
|
||||
|
||||
def make_funding_input(prevout_hex="11" * 32):
|
||||
"""Return a fake wallet UTXO spendable by the will."""
|
||||
utxo = PartialTxInput(prevout=TxOutpoint(bfh(prevout_hex), 0))
|
||||
utxo.witness_utxo = PartialTxOutput.from_address_and_value(
|
||||
ADDRESS, FUNDING_SATOSHIS
|
||||
)
|
||||
utxo._trusted_value_sats = FUNDING_SATOSHIS
|
||||
utxo._TxInput__scriptpubkey = SCRIPT
|
||||
utxo._TxInput__address = ADDRESS
|
||||
return utxo
|
||||
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self):
|
||||
self._data = {}
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._data.get(key, default)
|
||||
|
||||
def put(self, key, value):
|
||||
self._data[key] = value
|
||||
|
||||
def get_transaction(self, txid):
|
||||
return None
|
||||
|
||||
def commit(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakeWallet:
|
||||
def __init__(self, utxos):
|
||||
self.db = FakeDB()
|
||||
self.adb = None
|
||||
self.network = None
|
||||
self._utxos = list(utxos)
|
||||
self._dust = 546
|
||||
self._change_addresses = [ADDRESS]
|
||||
self.labels = {}
|
||||
self.save_db_calls = 0
|
||||
|
||||
def save_db(self):
|
||||
self.save_db_calls += 1
|
||||
|
||||
def dust_threshold(self):
|
||||
return self._dust
|
||||
|
||||
def has_keystore_encryption(self):
|
||||
return False
|
||||
|
||||
def set_label(self, txid, label):
|
||||
self.labels[txid] = label
|
||||
|
||||
def get_all_labels(self):
|
||||
return dict(self.labels)
|
||||
|
||||
def get_label_for_txid(self, txid):
|
||||
return self.labels.get(txid, "")
|
||||
|
||||
def get_utxos(self):
|
||||
return list(self._utxos)
|
||||
|
||||
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
|
||||
return self._change_addresses
|
||||
|
||||
def add_input_info(self, txin, only_der_suffix=False):
|
||||
pass
|
||||
|
||||
def add_output_info(self, txout, only_der_suffix=False):
|
||||
pass
|
||||
|
||||
def get_tx_info(self, tx):
|
||||
class _TxInfo:
|
||||
def __init__(self):
|
||||
class _MinedStatus:
|
||||
def height(self):
|
||||
return 0
|
||||
|
||||
self.tx_mined_status = _MinedStatus()
|
||||
|
||||
return _TxInfo()
|
||||
|
||||
def get_transaction(self, txid):
|
||||
return None
|
||||
|
||||
def sign_transaction(self, tx, password=None, ignore_warnings=True):
|
||||
descriptor = parse_descriptor(f"wpkh({PUBKEY.hex()})")
|
||||
for txin in tx.inputs():
|
||||
if txin.script_descriptor is None:
|
||||
txin.script_descriptor = descriptor
|
||||
if txin.value_sats() is None:
|
||||
txin._trusted_value_sats = FUNDING_SATOSHIS
|
||||
tx.sign({PUBKEY: PRIVKEY})
|
||||
|
||||
|
||||
class FakeConfig:
|
||||
def __init__(self):
|
||||
self._data = {}
|
||||
self._tmpdir = tempfile.mkdtemp(prefix="bal-test-")
|
||||
|
||||
def electrum_path(self):
|
||||
return self._tmpdir
|
||||
|
||||
def user_dir(self):
|
||||
return self._tmpdir
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._data.get(key, default)
|
||||
|
||||
def set_key(self, key, value, save=True):
|
||||
self._data[key] = value
|
||||
|
||||
|
||||
class FakeWindow:
|
||||
def __init__(self, wallet):
|
||||
self.wallet = wallet
|
||||
self.messages = []
|
||||
self.warnings = []
|
||||
self.errors = []
|
||||
|
||||
def get_decimal_point(self):
|
||||
return 0
|
||||
|
||||
def show_message(self, text):
|
||||
self.messages.append(str(text))
|
||||
|
||||
def show_warning(self, text, parent=None, title=None):
|
||||
self.warnings.append(str(text))
|
||||
|
||||
def show_error(self, text):
|
||||
self.errors.append(str(text))
|
||||
|
||||
def show_critical(self, text):
|
||||
self.errors.append(str(text))
|
||||
|
||||
def update_status(self):
|
||||
pass
|
||||
|
||||
|
||||
def make_controller(utxos=None):
|
||||
"""Build a fully-wired BalWindow without constructing the Qt tabs."""
|
||||
utxos = [make_funding_input()] if utxos is None else utxos
|
||||
config = FakeConfig()
|
||||
wallet = FakeWallet(utxos)
|
||||
window = FakeWindow(wallet)
|
||||
|
||||
plugin = BalPlugin(None, config, "bal")
|
||||
plugin.get_window_title = lambda title: str(title)
|
||||
plugin.get_decimal_point = window.get_decimal_point
|
||||
plugin.NO_WILLEXECUTOR.set(True)
|
||||
plugin.AUTO_REBUILD.set(True)
|
||||
|
||||
ctl = BalWindow.__new__(BalWindow)
|
||||
ctl.bal_plugin = plugin
|
||||
ctl.window = window
|
||||
ctl.wallet = wallet
|
||||
ctl.will = {}
|
||||
ctl.willitems = {}
|
||||
ctl.willexecutors = {}
|
||||
ctl.will_settings = plugin.WILL_SETTINGS.get()
|
||||
Util.fix_will_settings_tx_fees(ctl.will_settings)
|
||||
ctl.heirs = Heirs(wallet)
|
||||
ctl.heirs["alice"] = [ADDRESS, "100000", "1y"]
|
||||
ctl.heirs["bob"] = [ADDRESS, "100%", "1y"]
|
||||
ctl.no_willexecutor = True
|
||||
ctl.disable_plugin = False
|
||||
ctl.ok = True
|
||||
ctl.update_all = lambda: None
|
||||
ctl._schedule_history_refresh = lambda: None
|
||||
ctl._auto_rebuild_running = False
|
||||
ctl._auto_rebuild_cooldown_until = 0.0
|
||||
return ctl
|
||||
|
||||
|
||||
def _no_willexecutors():
|
||||
"""Force an empty will-executor list (offline tests)."""
|
||||
return mock.patch.object(
|
||||
Willexecutors,
|
||||
"get_willexecutors",
|
||||
return_value={},
|
||||
)
|
||||
|
||||
|
||||
def _single(controller):
|
||||
"""Return (txid, WillItem) for the controller's single will item."""
|
||||
assert len(controller.willitems) == 1, controller.willitems
|
||||
return next(iter(controller.willitems.items()))
|
||||
|
||||
|
||||
def _item_spending(controller, *prevout_hexes):
|
||||
"""Return the will item whose tx spends exactly the given prevouts."""
|
||||
wanted = sorted(h for h in prevout_hexes)
|
||||
items = [
|
||||
item
|
||||
for item in controller.willitems.values()
|
||||
if sorted(i.prevout.txid.hex() for i in item.tx.inputs()) == wanted
|
||||
]
|
||||
assert len(items) == 1, controller.willitems
|
||||
return items[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Config key
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_auto_rebuild_config_defaults_off():
|
||||
"""bal_auto_rebuild must default to OFF (False) when not yet stored."""
|
||||
cfg = FakeConfig()
|
||||
rebuild = BalConfig(cfg, CONFIG_KEY, False)
|
||||
assert rebuild.get() is False
|
||||
|
||||
|
||||
def test_auto_rebuild_config_can_be_enabled():
|
||||
"""Once enabled and persisted, bal_auto_rebuild reads back True."""
|
||||
cfg = FakeConfig()
|
||||
rebuild = BalConfig(cfg, CONFIG_KEY, False)
|
||||
rebuild.set(True)
|
||||
assert rebuild.get() is True
|
||||
assert BalConfig(cfg, CONFIG_KEY, False).get() is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Event wiring (Plugin._wallet_activity)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_wallet_activity_schedules_only_matching_wallet():
|
||||
plugin = Plugin.__new__(Plugin)
|
||||
plugin.AUTO_REBUILD = BalConfig(FakeConfig(), CONFIG_KEY, True)
|
||||
wallet_a = FakeWallet([make_funding_input()])
|
||||
wallet_b = FakeWallet([make_funding_input()])
|
||||
|
||||
scheduled = []
|
||||
|
||||
class _Win:
|
||||
wallet = wallet_a
|
||||
ok = True
|
||||
disable_plugin = False
|
||||
|
||||
def schedule_auto_rebuild(self):
|
||||
scheduled.append(self)
|
||||
|
||||
win = _Win()
|
||||
plugin.bal_windows = {"a": win}
|
||||
|
||||
plugin._wallet_activity(wallet_b)
|
||||
assert scheduled == [], "a different wallet must not schedule a rebuild"
|
||||
|
||||
plugin._wallet_activity(wallet_a)
|
||||
assert scheduled == [win], "the matching wallet must schedule a rebuild"
|
||||
|
||||
|
||||
def test_wallet_activity_skips_when_disabled():
|
||||
plugin = Plugin.__new__(Plugin)
|
||||
plugin.AUTO_REBUILD = BalConfig(FakeConfig(), CONFIG_KEY, False)
|
||||
wallet_obj = FakeWallet([make_funding_input()])
|
||||
|
||||
scheduled = []
|
||||
|
||||
class _Win:
|
||||
wallet = wallet_obj
|
||||
ok = True
|
||||
disable_plugin = False
|
||||
|
||||
def schedule_auto_rebuild(self):
|
||||
scheduled.append(self)
|
||||
|
||||
plugin.bal_windows = {"a": _Win()}
|
||||
|
||||
plugin._wallet_activity(wallet_obj)
|
||||
assert scheduled == [], "AUTO_REBUILD off must not schedule anything"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Scheduling / guards
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_schedule_auto_rebuild_debounces():
|
||||
ctl = make_controller()
|
||||
with mock.patch.object(window_mod.QTimer, "singleShot") as single_shot:
|
||||
ctl.schedule_auto_rebuild()
|
||||
single_shot.assert_called_once_with(
|
||||
ctl._AUTO_REBUILD_DEBOUNCE_MS, ctl._run_auto_rebuild
|
||||
)
|
||||
|
||||
|
||||
def test_auto_rebuild_guards():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
assert ctl._auto_rebuild_allowed() is True
|
||||
# Re-entrancy guard.
|
||||
ctl._auto_rebuild_running = True
|
||||
assert ctl._auto_rebuild_allowed() is False
|
||||
ctl._auto_rebuild_running = False
|
||||
# Cooldown guard.
|
||||
ctl._auto_rebuild_cooldown_until = time.time() + 100
|
||||
assert ctl._auto_rebuild_allowed() is False
|
||||
ctl._auto_rebuild_cooldown_until = 0.0
|
||||
assert ctl._auto_rebuild_allowed() is True
|
||||
# Disabled / inactive guards.
|
||||
ctl.disable_plugin = True
|
||||
assert ctl._auto_rebuild_allowed() is False
|
||||
ctl.disable_plugin = False
|
||||
ctl.ok = False
|
||||
assert ctl._auto_rebuild_allowed() is False
|
||||
|
||||
|
||||
def test_run_auto_rebuild_spawns_worker_when_allowed():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
|
||||
started = []
|
||||
|
||||
class FakeThread:
|
||||
def __init__(self, target, daemon=None):
|
||||
self.target = target
|
||||
|
||||
def start(self):
|
||||
started.append(self.target)
|
||||
|
||||
with mock.patch.object(window_mod.threading, "Thread", FakeThread):
|
||||
ctl._run_auto_rebuild()
|
||||
assert len(started) == 1, "the worker thread must be spawned"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# maybe_auto_rebuild behaviour
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_auto_rebuild_noop_when_disabled():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
ctl.bal_plugin.AUTO_REBUILD.set(False)
|
||||
txid_before, _ = _single(ctl)
|
||||
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is False
|
||||
txid_after, _ = _single(ctl)
|
||||
assert txid_after == txid_before, "disabled flow must not touch the will"
|
||||
|
||||
|
||||
def test_auto_rebuild_noop_without_will():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
assert not ctl.willitems
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_auto_rebuild_noop_when_will_valid():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
txid_before, _ = _single(ctl)
|
||||
|
||||
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||
ctl, "_auto_sign_save_push"
|
||||
) as sign:
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is False
|
||||
txid_after, _ = _single(ctl)
|
||||
assert txid_after == txid_before, "a valid will must not be rebuilt"
|
||||
inv.assert_not_called()
|
||||
sign.assert_not_called()
|
||||
|
||||
|
||||
def test_auto_rebuild_rebuilds_and_pushes_on_new_utxo():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
# A relative delivery recipe keeps the will coherent after the rebuild
|
||||
# anticipates the locktime by one day (an absolute recipe would read the
|
||||
# anticipated tx as a postpone, see check_willexecutors_and_heirs).
|
||||
ctl.will_settings["locktime"] = "1y"
|
||||
ctl.prepare_will()
|
||||
old_txid, old_item = _single(ctl)
|
||||
old_locktime = int(old_item.tx.locktime)
|
||||
|
||||
# An incoming payment adds a second UTXO -> the will no longer covers
|
||||
# the whole wallet (NotCompleteWillException).
|
||||
ctl.wallet._utxos.append(make_funding_input("22" * 32))
|
||||
|
||||
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||
ctl, "push_transactions_to_willexecutors"
|
||||
) as push, mock.patch.object(ctl, "_save_will_to_history") as history:
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is True, "a stale will must be rebuilt"
|
||||
assert inv.call_count == 0, "a plain rebuild must not invalidate on-chain"
|
||||
push.assert_called_once()
|
||||
history.assert_called_once()
|
||||
|
||||
# The rebuilt will now spends BOTH wallet UTXOs (BAL keeps the previous
|
||||
# single-input transaction alongside it in the will).
|
||||
new_item = _item_spending(ctl, "11" * 32, "22" * 32)
|
||||
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||
# The new locktime must be at most the old one, so the new tx can be mined
|
||||
# before the previous will.
|
||||
assert int(new_item.tx.locktime) <= old_locktime
|
||||
assert new_item.get_status("COMPLETE"), "passwordless rebuild must sign"
|
||||
assert new_item.get_status("VALID")
|
||||
|
||||
# The rebuilt will is still valid now: no further work.
|
||||
assert ctl.check_will() is True
|
||||
|
||||
|
||||
def test_auto_rebuild_invalidates_when_threshold_passed():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
# ADVANCED mode with a check-alive threshold already in the past.
|
||||
ctl.bal_plugin.USER_TYPE.set("advanced")
|
||||
ctl.will_settings["threshold"] = int(time.time()) - 3600
|
||||
|
||||
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||
ctl, "_auto_sign_save_push"
|
||||
) as sign:
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is True
|
||||
inv.assert_called_once()
|
||||
sign.assert_not_called()
|
||||
|
||||
|
||||
def test_auto_rebuild_invalidates_when_locktime_expired():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
txid, item = _single(ctl)
|
||||
# Move the frozen delivery date into the past: "too late to
|
||||
# anticipate" -> the old will must be invalidated on-chain.
|
||||
item.tx.locktime = int(time.time()) - 2 * 86400
|
||||
|
||||
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||
ctl, "_auto_sign_save_push"
|
||||
) as sign:
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is True
|
||||
inv.assert_called_once()
|
||||
sign.assert_not_called()
|
||||
|
||||
|
||||
def test_auto_rebuild_invalidates_when_anticipation_crosses_threshold():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
now = time.time()
|
||||
delivery = int(now + 3 * 86400)
|
||||
ctl.will_settings["locktime"] = delivery
|
||||
# ADVANCED mode: the check-alive threshold sits 12h before delivery, so
|
||||
# an anticipated (delivery - 1 day) locktime falls BEFORE it.
|
||||
ctl.bal_plugin.USER_TYPE.set("advanced")
|
||||
ctl.will_settings["threshold"] = delivery - 12 * 3600
|
||||
|
||||
ctl.prepare_will()
|
||||
old_txid, _ = _single(ctl)
|
||||
ctl.wallet._utxos.append(make_funding_input("22" * 32))
|
||||
|
||||
# The rebuild itself anticipates the delivery date by one day ONLY when
|
||||
# the rebuilt transactions keep the same real amounts (Will.check_anticipate,
|
||||
# same coins + same heirs). Real amounts are re-computed against the
|
||||
# wallet balance, so a new UTXO normally changes them and the rebuilt
|
||||
# will keeps the old locktime. Force the anticipating branch here to
|
||||
# exercise the "anticipated locktime crosses the threshold" handling.
|
||||
with mock.patch.object(
|
||||
Will, "check_anticipate", return_value=delivery - 86400
|
||||
):
|
||||
with mock.patch.object(
|
||||
ctl, "_auto_invalidate_will"
|
||||
) as inv, mock.patch.object(ctl, "_auto_sign_save_push") as sign:
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is True
|
||||
inv.assert_called_once(), (
|
||||
"an anticipated locktime below the threshold must invalidate on-chain"
|
||||
)
|
||||
sign.assert_not_called(), (
|
||||
"after an invalidation the rebuilt will must NOT be signed/pushed "
|
||||
"(the wizard stops and waits for the invalidation to confirm)"
|
||||
)
|
||||
new_item = _item_spending(ctl, "11" * 32, "22" * 32)
|
||||
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||
assert int(new_item.tx.locktime) == delivery - 86400, (
|
||||
"the rebuilt locktime must be anticipated by one day"
|
||||
)
|
||||
|
||||
|
||||
def _run_all():
|
||||
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
|
||||
for fn in tests:
|
||||
print(f"{fn.__name__} ... ", end="", flush=True)
|
||||
fn()
|
||||
print("OK")
|
||||
print(f"\n{len(tests)} tests passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication.instance() or QApplication([])
|
||||
_run_all()
|
||||
339
tests/test_cli_autorebuild.py
Normal file
339
tests/test_cli_autorebuild.py
Normal file
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the headless auto-rebuild flow (``bal_will_autorebuild``).
|
||||
|
||||
The CLI equivalent of the GUI AUTO_REBUILD feature:
|
||||
``BalController.auto_rebuild`` runs the wizard's close-time flow in a single
|
||||
call. Everything is exercised offline against a fake signing wallet (the same
|
||||
fixtures the GUI tests use), so no wallet, network or Qt is needed.
|
||||
|
||||
Covers:
|
||||
|
||||
* no-op when the will is still valid (``valid``);
|
||||
* rebuild + sign + push when a new UTXO invalidates the will (``rebuilt``,
|
||||
no on-chain invalidation: the rebuilt tx is anticipated to mine before
|
||||
the old one);
|
||||
* ``needs_signing`` when the wallet is encrypted;
|
||||
* on-chain invalidation when the will is already expired (``expired``);
|
||||
* on-chain invalidation when the anticipated locktime crosses the check-alive
|
||||
threshold (``anticipation_crossed``) - and no sign/push in that case.
|
||||
|
||||
The ``no_heirs`` and ``threshold_passed`` paths live in
|
||||
``test_cli_controller_offline.py``.
|
||||
|
||||
Run:
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
python3 tests/test_cli_autorebuild.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest.mock as mock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from electrum import bitcoin, crypto
|
||||
from electrum.descriptor import parse_descriptor
|
||||
from electrum.simple_config import SimpleConfig
|
||||
from electrum.transaction import PartialTxInput, PartialTxOutput, TxOutpoint
|
||||
from electrum.util import bfh
|
||||
|
||||
from bal.cli.controller import BalController
|
||||
from bal.core.will import Will
|
||||
from bal.core.willexecutors import Willexecutors
|
||||
|
||||
PRIVKEY = bytes(range(32))
|
||||
PUBKEY = crypto.privkey_to_pubkey(PRIVKEY)
|
||||
ADDRESS = bitcoin.public_key_to_p2wpkh(PUBKEY)
|
||||
SCRIPT = bitcoin.address_to_script(ADDRESS)
|
||||
FUNDING_SATOSHIS = 500000
|
||||
|
||||
|
||||
def make_funding_input(prevout_hex="11" * 32):
|
||||
"""Return a fake wallet UTXO spendable by the will."""
|
||||
utxo = PartialTxInput(prevout=TxOutpoint(bfh(prevout_hex), 0))
|
||||
utxo.witness_utxo = PartialTxOutput.from_address_and_value(
|
||||
ADDRESS, FUNDING_SATOSHIS
|
||||
)
|
||||
utxo._trusted_value_sats = FUNDING_SATOSHIS
|
||||
utxo._TxInput__scriptpubkey = SCRIPT
|
||||
utxo._TxInput__address = ADDRESS
|
||||
return utxo
|
||||
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self):
|
||||
self._data = {}
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._data.get(key, default)
|
||||
|
||||
def put(self, key, value):
|
||||
self._data[key] = value
|
||||
|
||||
def get_dict(self, key):
|
||||
return self._data.setdefault(key, {})
|
||||
|
||||
def get_transaction(self, txid):
|
||||
return None
|
||||
|
||||
def add_transaction(self, tx, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class FakeWallet:
|
||||
def __init__(self, utxos, encrypted=False):
|
||||
self.db = FakeDB()
|
||||
self.adb = None
|
||||
self.network = None
|
||||
self._utxos = list(utxos)
|
||||
self._dust = 546
|
||||
self._change_addresses = [ADDRESS]
|
||||
self._encrypted = encrypted
|
||||
self.labels = {}
|
||||
|
||||
def save_db(self):
|
||||
pass
|
||||
|
||||
def dust_threshold(self):
|
||||
return self._dust
|
||||
|
||||
def has_keystore_encryption(self):
|
||||
return self._encrypted
|
||||
|
||||
def set_label(self, txid, label):
|
||||
self.labels[txid] = label
|
||||
|
||||
def get_utxos(self):
|
||||
return list(self._utxos)
|
||||
|
||||
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
|
||||
return self._change_addresses
|
||||
|
||||
def add_input_info(self, txin, only_der_suffix=False):
|
||||
pass
|
||||
|
||||
def add_output_info(self, txout, only_der_suffix=False):
|
||||
pass
|
||||
|
||||
def get_tx_info(self, tx):
|
||||
class _TxInfo:
|
||||
def __init__(self):
|
||||
class _MinedStatus:
|
||||
def height(self):
|
||||
return 0
|
||||
|
||||
self.tx_mined_status = _MinedStatus()
|
||||
|
||||
return _TxInfo()
|
||||
|
||||
def get_transaction(self, txid):
|
||||
return None
|
||||
|
||||
def sign_transaction(self, tx, password=None, ignore_warnings=True):
|
||||
descriptor = parse_descriptor(f"wpkh({PUBKEY.hex()})")
|
||||
for txin in tx.inputs():
|
||||
if txin.script_descriptor is None:
|
||||
txin.script_descriptor = descriptor
|
||||
if txin.value_sats() is None:
|
||||
txin._trusted_value_sats = FUNDING_SATOSHIS
|
||||
tx.sign({PUBKEY: PRIVKEY})
|
||||
|
||||
|
||||
class _Plugin:
|
||||
"""Real ``bal.cli.plugin.Plugin`` with an isolated config directory."""
|
||||
|
||||
def __init__(self):
|
||||
self.tmpdir = tempfile.mkdtemp(prefix="bal_cli_autorebuild_")
|
||||
from bal.cli.plugin import Plugin as RealPlugin
|
||||
|
||||
self.config = SimpleConfig(
|
||||
{"electrum_path": self.tmpdir},
|
||||
read_user_config_function=lambda path: {},
|
||||
)
|
||||
self.plugin = RealPlugin(None, self.config, "bal")
|
||||
|
||||
def __enter__(self):
|
||||
return self.plugin
|
||||
|
||||
def __exit__(self, *exc):
|
||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def _no_willexecutors():
|
||||
"""Force an empty will-executor list (offline tests)."""
|
||||
return mock.patch.object(
|
||||
Willexecutors,
|
||||
"get_willexecutors",
|
||||
return_value={},
|
||||
)
|
||||
|
||||
|
||||
def _make_controller(plugin, wallet):
|
||||
c = BalController(plugin, wallet)
|
||||
c.will_settings["locktime"] = "1y"
|
||||
c.heirs_add("alice", ADDRESS, "100000")
|
||||
c.heirs_add("bob", ADDRESS, "100%")
|
||||
return c
|
||||
|
||||
|
||||
def _single(controller):
|
||||
"""Return (txid, WillItem) for the controller's single will item."""
|
||||
assert len(controller.willitems) == 1, controller.willitems
|
||||
return next(iter(controller.willitems.items()))
|
||||
|
||||
|
||||
def _item_spending(controller, *prevout_hexes):
|
||||
"""Return the will item whose tx spends exactly the given prevouts."""
|
||||
wanted = sorted(h for h in prevout_hexes)
|
||||
items = [
|
||||
item
|
||||
for item in controller.willitems.values()
|
||||
if sorted(i.prevout.txid.hex() for i in item.tx.inputs()) == wanted
|
||||
]
|
||||
assert len(items) == 1, controller.willitems
|
||||
return items[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# auto_rebuild behaviour
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_auto_rebuild_noop_when_will_valid():
|
||||
with _no_willexecutors():
|
||||
with _Plugin() as plugin:
|
||||
plugin.NO_WILLEXECUTOR.set(True)
|
||||
wallet = FakeWallet([make_funding_input()])
|
||||
c = _make_controller(plugin, wallet)
|
||||
c.prepare_will()
|
||||
txid_before, _ = _single(c)
|
||||
|
||||
result = c.auto_rebuild()
|
||||
|
||||
assert result["result"] == "valid", result
|
||||
assert next(iter(c.willitems)) == txid_before, (
|
||||
"a valid will must not be rebuilt"
|
||||
)
|
||||
|
||||
|
||||
def test_auto_rebuild_rebuilds_and_pushes_on_new_utxo():
|
||||
with _no_willexecutors():
|
||||
with _Plugin() as plugin:
|
||||
plugin.NO_WILLEXECUTOR.set(True)
|
||||
wallet = FakeWallet([make_funding_input()])
|
||||
c = _make_controller(plugin, wallet)
|
||||
c.prepare_will()
|
||||
old_txid, old_item = _single(c)
|
||||
old_locktime = int(old_item.tx.locktime)
|
||||
|
||||
# An incoming payment adds a second UTXO -> the will no longer
|
||||
# covers the whole wallet (NotCompleteWillException).
|
||||
wallet._utxos.append(make_funding_input("22" * 32))
|
||||
|
||||
result = c.auto_rebuild()
|
||||
|
||||
assert result["result"] == "rebuilt", result
|
||||
assert result["push"] == {}
|
||||
|
||||
new_item = _item_spending(c, "11" * 32, "22" * 32)
|
||||
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||
assert int(new_item.tx.locktime) <= old_locktime, (
|
||||
"the rebuilt tx must be anticipatable before the old will"
|
||||
)
|
||||
assert new_item.get_status("COMPLETE"), "passwordless rebuild must sign"
|
||||
assert new_item.get_status("VALID")
|
||||
|
||||
# The rebuilt will is still valid now: no further work.
|
||||
assert c.check_will() is True
|
||||
|
||||
|
||||
def test_auto_rebuild_encrypted_wallet_requires_manual_signing():
|
||||
with _no_willexecutors():
|
||||
with _Plugin() as plugin:
|
||||
plugin.NO_WILLEXECUTOR.set(True)
|
||||
wallet = FakeWallet([make_funding_input()], encrypted=True)
|
||||
c = _make_controller(plugin, wallet)
|
||||
c.prepare_will()
|
||||
wallet._utxos.append(make_funding_input("22" * 32))
|
||||
|
||||
result = c.auto_rebuild()
|
||||
|
||||
assert result["result"] == "needs_signing", result
|
||||
assert result["will"]["count"] == 2
|
||||
assert not any(w.get_status("COMPLETE") for w in c.willitems.values()), (
|
||||
"an encrypted wallet must never be signed without the password"
|
||||
)
|
||||
|
||||
|
||||
def test_auto_rebuild_invalidates_when_locktime_expired():
|
||||
with _no_willexecutors():
|
||||
with _Plugin() as plugin:
|
||||
plugin.NO_WILLEXECUTOR.set(True)
|
||||
wallet = FakeWallet([make_funding_input()])
|
||||
c = _make_controller(plugin, wallet)
|
||||
c.prepare_will()
|
||||
_, item = _single(c)
|
||||
# Move the frozen delivery date into the past: "too late to
|
||||
# anticipate" -> the old will must be invalidated on-chain.
|
||||
item.tx.locktime = int(time.time()) - 2 * 86400
|
||||
|
||||
result = c.auto_rebuild()
|
||||
|
||||
assert result["result"] == "invalidated", result
|
||||
assert result["reason"] == "expired"
|
||||
assert result["invalidation_tx"]["txid"] is not None
|
||||
assert result["invalidation_tx"]["tx"]
|
||||
assert not any(w.get_status("COMPLETE") for w in c.willitems.values())
|
||||
|
||||
|
||||
def test_auto_rebuild_invalidates_when_anticipation_crosses_threshold():
|
||||
with _no_willexecutors():
|
||||
with _Plugin() as plugin:
|
||||
now = time.time()
|
||||
delivery = int(now + 3 * 86400)
|
||||
# ADVANCED mode: the check-alive threshold sits 12h before delivery,
|
||||
# so an anticipated (delivery - 1 day) locktime falls BEFORE it.
|
||||
plugin.USER_TYPE.set("advanced")
|
||||
wallet = FakeWallet([make_funding_input()])
|
||||
plugin.NO_WILLEXECUTOR.set(True)
|
||||
c = _make_controller(plugin, wallet)
|
||||
c.will_settings["locktime"] = delivery
|
||||
c.will_settings["threshold"] = delivery - 12 * 3600
|
||||
c.prepare_will()
|
||||
old_txid, _ = _single(c)
|
||||
wallet._utxos.append(make_funding_input("22" * 32))
|
||||
|
||||
# Force the anticipating branch (see the GUI test for the rationale:
|
||||
# with a new UTXO the real amounts change, so the natural rebuild
|
||||
# keeps the old locktime).
|
||||
with mock.patch.object(
|
||||
Will, "check_anticipate", return_value=delivery - 86400
|
||||
):
|
||||
result = c.auto_rebuild()
|
||||
|
||||
assert result["result"] == "invalidated", result
|
||||
assert result["reason"] == "anticipation_crossed"
|
||||
assert not any(w.get_status("COMPLETE") for w in c.willitems.values()), (
|
||||
"after an invalidation the rebuilt will must NOT be signed/pushed "
|
||||
"(the wizard stops and waits for the invalidation to confirm)"
|
||||
)
|
||||
new_item = _item_spending(c, "11" * 32, "22" * 32)
|
||||
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||
assert int(new_item.tx.locktime) == delivery - 86400, (
|
||||
"the rebuilt locktime must be anticipated by one day"
|
||||
)
|
||||
|
||||
|
||||
def _run_all():
|
||||
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
|
||||
for fn in tests:
|
||||
print(f"{fn.__name__} ... ", end="", flush=True)
|
||||
fn()
|
||||
print("OK")
|
||||
print(f"\n{len(tests)} tests passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_run_all()
|
||||
150
tests/test_cli_commands_registered.py
Normal file
150
tests/test_cli_commands_registered.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Test: BAL plugin CLI commands are registered with Electrum.
|
||||
|
||||
Verifies that importing the plugin through Electrum's own plugin loader
|
||||
(``Plugins(config, cmd_only=True)``, the exact code path ``run_electrum`` uses
|
||||
to pre-parse the command line) registers every ``bal_*`` command with
|
||||
``electrum.commands`` (``known_commands`` + the ``Commands`` class).
|
||||
|
||||
It also asserts the basic contract enforced by ``plugin_command``: each command
|
||||
is a coroutine and carries the expected flags (all ``bal_*`` commands require a
|
||||
daemon/network, i.e. the ``'n'`` flag; the wallet-bound ones the ``'w'`` flag;
|
||||
signing also ``'p'``).
|
||||
|
||||
Run:
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
python3 tests/test_cli_commands_registered.py
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import tempfile
|
||||
|
||||
from electrum import commands as electrum_commands
|
||||
from electrum.plugin import Plugins
|
||||
from electrum.simple_config import SimpleConfig
|
||||
|
||||
# The full command table lives in PLAN_CMDLINE_PLUGIN.md section 6; new commands
|
||||
# added in later phases must be appended here so the registration test keeps
|
||||
# proving the whole list is wired up.
|
||||
EXPECTED_COMMANDS = {
|
||||
# Settings (no wallet required)
|
||||
"bal_settings_list": {
|
||||
"requires_network": True,
|
||||
"requires_wallet": False,
|
||||
"requires_password": False,
|
||||
},
|
||||
"bal_settings_get": {
|
||||
"requires_network": True,
|
||||
"requires_wallet": False,
|
||||
"requires_password": False,
|
||||
},
|
||||
"bal_settings_set": {
|
||||
"requires_network": True,
|
||||
"requires_wallet": False,
|
||||
"requires_password": False,
|
||||
},
|
||||
"bal_settings_reset": {
|
||||
"requires_network": True,
|
||||
"requires_wallet": False,
|
||||
"requires_password": False,
|
||||
},
|
||||
# Heirs
|
||||
"bal_heirs_list": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_heirs_show": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_heirs_add": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_heirs_update": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_heirs_delete": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_heirs_import": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_heirs_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
# Will-Executors
|
||||
"bal_willexecutors_list": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_show": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_add": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_update": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_select": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_delete": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_ping": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_download": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_import": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
# Will
|
||||
"bal_will_status": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_will_check": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_will_prepare": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_will_autorebuild": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_will_sign": {"requires_network": True, "requires_wallet": True, "requires_password": True},
|
||||
"bal_will_broadcast": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_will_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_will_import_merge": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_will_invalidate": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_will_check_executor": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
}
|
||||
|
||||
|
||||
def _isolated_config(**overrides):
|
||||
"""A throwaway SimpleConfig that never touches the real Electrum config.
|
||||
|
||||
A fresh ``electrum_path`` temp dir keeps every write isolated, so running
|
||||
the tests cannot pollute the user's config files. The bal plugin is
|
||||
enabled because ``Plugins(cmd_only=True)`` skips any plugin that is not
|
||||
explicitly enabled (electrum.plugin.Plugins.find_directory_plugins).
|
||||
"""
|
||||
opts = {"electrum_path": tempfile.mkdtemp(prefix="bal_test_")}
|
||||
opts.update(overrides)
|
||||
cfg = SimpleConfig(opts)
|
||||
cfg.enable_plugin("bal")
|
||||
return cfg
|
||||
|
||||
|
||||
def test_commands_registered():
|
||||
cfg = _isolated_config()
|
||||
Plugins(cfg, cmd_only=True)
|
||||
for name, flags in EXPECTED_COMMANDS.items():
|
||||
assert name in electrum_commands.known_commands, f"{name} not registered"
|
||||
cmd = electrum_commands.known_commands[name]
|
||||
assert cmd.name == name
|
||||
assert cmd.requires_network is flags["requires_network"]
|
||||
assert cmd.requires_wallet is flags["requires_wallet"]
|
||||
assert cmd.requires_password is flags["requires_password"]
|
||||
|
||||
|
||||
def test_commands_are_coroutines():
|
||||
cfg = _isolated_config()
|
||||
Plugins(cfg, cmd_only=True)
|
||||
for name in EXPECTED_COMMANDS:
|
||||
func = getattr(electrum_commands.Commands, name, None)
|
||||
assert func is not None, f"{name} missing from Commands"
|
||||
assert inspect.iscoroutinefunction(func), f"{name} is not a coroutine"
|
||||
|
||||
|
||||
def test_no_duplicate_registration():
|
||||
"""Loading the plugin twice must not raise "Command name bal_... already
|
||||
exists" (the guard in bal/__init__._register_cli_commands)."""
|
||||
cfg = _isolated_config()
|
||||
plugins = Plugins(cfg, cmd_only=True)
|
||||
plugins.maybe_load_plugin_init_method("bal") # already imported -> no-op
|
||||
for name in EXPECTED_COMMANDS:
|
||||
assert name in electrum_commands.known_commands
|
||||
|
||||
|
||||
def test_command_docstrings_document_all_args():
|
||||
"""Every parameter/option must carry an ``arg:TYPE:NAME:DESC`` line (the
|
||||
CLI parser prints "undocumented argument ..." otherwise)."""
|
||||
cfg = _isolated_config()
|
||||
Plugins(cfg, cmd_only=True)
|
||||
for name in EXPECTED_COMMANDS:
|
||||
cmd = electrum_commands.known_commands[name]
|
||||
for varname in list(cmd.params) + list(cmd.options):
|
||||
if varname in ("wallet", "wallet_path", "plugin", "password"):
|
||||
continue
|
||||
assert varname in cmd.arg_descriptions, (
|
||||
f"{name}: undocumented argument {varname}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All CLI registration tests passed")
|
||||
288
tests/test_cli_controller_offline.py
Normal file
288
tests/test_cli_controller_offline.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
Offline tests for the headless ``bal.cli.controller.BalController``.
|
||||
|
||||
These run without a wallet, a network or Qt: the controller is exercised
|
||||
against a ``FakeWallet`` plus a real ``bal.cli.plugin.Plugin`` backed by an
|
||||
isolated in-memory ``SimpleConfig``. Only the flows that never touch the
|
||||
network (settings/heirs/willexecutors CRUD, status snapshots, error mapping)
|
||||
are covered here; build/sign/push flows need a live wallet and network and are
|
||||
exercised by the group tests instead.
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
python3 tests/test_cli_controller_offline.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest.mock as mock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from electrum.simple_config import SimpleConfig
|
||||
from electrum.util import UserFacingException
|
||||
|
||||
from bal.cli.controller import BalController
|
||||
from bal.core.heirs import Heirs
|
||||
from bal.core.util import Util
|
||||
|
||||
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
|
||||
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self):
|
||||
self._data = {}
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._data.get(key, default)
|
||||
|
||||
def put(self, key, value):
|
||||
self._data[key] = value
|
||||
|
||||
def get_dict(self, key):
|
||||
return self._data.setdefault(key, {})
|
||||
|
||||
def get_transaction(self, txid):
|
||||
return None
|
||||
|
||||
def add_transaction(self, tx, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class FakeWallet:
|
||||
def __init__(self):
|
||||
self.db = FakeDB()
|
||||
self.network = None
|
||||
self.adb = None
|
||||
self._dust = 500
|
||||
|
||||
def save_db(self):
|
||||
pass
|
||||
|
||||
def dust_threshold(self):
|
||||
return self._dust
|
||||
|
||||
def has_keystore_encryption(self):
|
||||
return False
|
||||
|
||||
def set_label(self, txid, text):
|
||||
pass
|
||||
|
||||
def get_utxos(self):
|
||||
return []
|
||||
|
||||
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
|
||||
return [VALID_ADDRESS]
|
||||
|
||||
|
||||
class Plugin:
|
||||
"""Real ``bal.cli.plugin.Plugin`` with an isolated config directory."""
|
||||
|
||||
def __init__(self):
|
||||
self.tmpdir = tempfile.mkdtemp(prefix="bal_cli_test_")
|
||||
from bal.cli.plugin import Plugin as RealPlugin
|
||||
|
||||
self.config = SimpleConfig(
|
||||
{"electrum_path": self.tmpdir},
|
||||
read_user_config_function=lambda path: {},
|
||||
)
|
||||
self.plugin = RealPlugin(None, self.config, "bal")
|
||||
|
||||
def __enter__(self):
|
||||
return self.plugin
|
||||
|
||||
def __exit__(self, *exc):
|
||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def _make_controller(plugin):
|
||||
return BalController(plugin, FakeWallet())
|
||||
|
||||
|
||||
def test_controller_init_empty():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
assert c.willitems == {}
|
||||
assert c.will == {}
|
||||
assert c.heirs == {}
|
||||
assert isinstance(c.will_settings, dict)
|
||||
assert "baltx_fees" in c.will_settings
|
||||
# Fresh config: no stored will-executors. On mainnet the default
|
||||
# WILLEXECUTORS table is keyed by "mainnet" while chainname is
|
||||
# "bitcoin", so nothing is injected either.
|
||||
assert c.willexecutors == {}
|
||||
assert c.no_willexecutor is False
|
||||
|
||||
|
||||
def test_settings_roundtrip():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
listing = c.settings_list()
|
||||
assert "BAL_TX_FEES" in listing or "TX_FEES" in listing
|
||||
tx_key = "BAL_TX_FEES" if "BAL_TX_FEES" in listing else "TX_FEES"
|
||||
assert c.settings_get(tx_key)["value"] == 100
|
||||
|
||||
c.settings_set("bal_tx_fees", "150")
|
||||
assert c.settings_get("bal_tx_fees")["value"] == 150
|
||||
assert c.settings_get("TX_FEES")["value"] == 150
|
||||
|
||||
c.settings_set("bal_no_willexecutor", "true")
|
||||
assert c.settings_get("bal_no_willexecutor")["value"] is True
|
||||
|
||||
c.settings_reset("bal_tx_fees")
|
||||
assert c.settings_get("bal_tx_fees")["value"] == 100
|
||||
|
||||
|
||||
def test_settings_unknown_key():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
try:
|
||||
c.settings_get("bal_does_not_exist")
|
||||
raise AssertionError("expected UserFacingException")
|
||||
except UserFacingException as e:
|
||||
assert "Unknown BAL setting" in str(e)
|
||||
|
||||
|
||||
def test_heirs_crud():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
c.heirs_add("alice", VALID_ADDRESS, "100000")
|
||||
assert c.heirs["alice"][0] == VALID_ADDRESS
|
||||
assert c.heirs["alice"][1] == "100000"
|
||||
|
||||
c.heirs_update("alice", amount="200000")
|
||||
assert c.heirs["alice"][1] == "200000"
|
||||
assert c.heirs_show("alice")["value"][1] == "200000"
|
||||
|
||||
assert "alice" in c.heirs_list()
|
||||
c.heirs_delete(["alice"])
|
||||
assert "alice" not in c.heirs_list()
|
||||
|
||||
|
||||
def test_heirs_add_op_return():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
c.heirs_add("note", "OP_RETURN:6a0242414c", "100000")
|
||||
assert c.heirs["note"][1] == "0"
|
||||
|
||||
|
||||
def test_willexecutors_crud():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
assert c.willexecutors == {}
|
||||
|
||||
new_url = "https://executor.example.invalid"
|
||||
c.willexecutors_add(new_url, address="", base_fee=250)
|
||||
assert c.willexecutors_show(new_url)["willexecutor"]["base_fee"] == 250
|
||||
assert c.willexecutors_show(new_url)["willexecutor"]["selected"] is False
|
||||
|
||||
c.willexecutors_update(new_url, base_fee="300", info="Example executor")
|
||||
assert c.willexecutors_show(new_url)["willexecutor"]["base_fee"] == 300
|
||||
|
||||
c.willexecutors_select([new_url], select=True)
|
||||
assert c.willexecutors_show(new_url)["willexecutor"]["selected"] is True
|
||||
|
||||
renamed = "https://executor2.example.invalid"
|
||||
c.willexecutors_update(new_url, rename_to=renamed)
|
||||
assert renamed in c.willexecutors
|
||||
assert new_url not in c.willexecutors
|
||||
|
||||
assert c.willexecutors_delete([renamed]) == {"deleted": [renamed]}
|
||||
assert renamed not in c.willexecutors
|
||||
|
||||
|
||||
def test_will_status_empty():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
status = c.will_status()
|
||||
assert status["count"] == 0
|
||||
assert status["items"] == []
|
||||
|
||||
|
||||
def test_will_check_no_heirs_raises():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
try:
|
||||
c.will_check()
|
||||
raise AssertionError("expected UserFacingException")
|
||||
except UserFacingException as e:
|
||||
assert "heir" in str(e).lower()
|
||||
|
||||
|
||||
def test_auto_rebuild_no_heirs():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
assert c.auto_rebuild() == {"result": "no_heirs"}
|
||||
|
||||
|
||||
def test_auto_rebuild_threshold_passed_invalidates():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
c.heirs_add("alice", VALID_ADDRESS, "100000")
|
||||
plugin.USER_TYPE.set("advanced")
|
||||
c.will_settings["threshold"] = int(time.time()) - 3600
|
||||
result = c.auto_rebuild()
|
||||
assert result["result"] == "invalidated"
|
||||
assert result["reason"] == "threshold_passed"
|
||||
assert result["invalidation_tx"] == {"txid": None, "tx": None}
|
||||
|
||||
|
||||
def test_build_will_reanchors_date_to_check_to_new_locktime():
|
||||
"""CLI mirror of the GUI regression: ``build_will`` must re-anchor
|
||||
``date_to_check`` to the CURRENT heirs' earliest delivery before building,
|
||||
so an anticipated (shortened) rebuild is not blocked by the old built-will
|
||||
anchor (which would yield NO_FUTURE_DATE in ``get_transactions``).
|
||||
"""
|
||||
with Plugin() as plugin:
|
||||
plugin.USER_TYPE.set("advanced")
|
||||
plugin.NO_WILLEXECUTOR.set(True)
|
||||
plugin.ENABLE_MULTIVERSE.set(True)
|
||||
plugin.WILL_SETTINGS.set({"threshold": "150d", "locktime": "2y", "baltx_fees": 20})
|
||||
c = _make_controller(plugin)
|
||||
c.no_willexecutor = True
|
||||
c.heirs["alice"] = [VALID_ADDRESS, "100%", "1y"]
|
||||
|
||||
# Simulate an old built will frozen at 2y: reload keeps its (stale)
|
||||
# anchor, which would reject the anticipated "1y" delivery.
|
||||
c.init_class_variables()
|
||||
stale_anchor = Util.parse_locktime_string("2y") - 150 * 86400
|
||||
c.date_to_check = stale_anchor
|
||||
assert Util.parse_locktime_string("1y") < c.date_to_check
|
||||
|
||||
with mock.patch.object(Heirs, "get_transactions", return_value={}) as gt:
|
||||
result = c.build_will()
|
||||
|
||||
assert result == {}
|
||||
# build_will re-anchored date_to_check to the new 1y delivery...
|
||||
expected = Util.parse_locktime_string("1y") - 150 * 86400
|
||||
assert abs(c.date_to_check - expected) < 3600
|
||||
# ...and used THAT anchor as the build filter, not the stale 2y one.
|
||||
assert gt.call_args.args[-1] == c.date_to_check
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# runner
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def main():
|
||||
failures = 0
|
||||
for name, fn in sorted(globals().items()):
|
||||
if not name.startswith("test_") or not callable(fn):
|
||||
continue
|
||||
print(f" {name}")
|
||||
try:
|
||||
fn()
|
||||
except Exception as e:
|
||||
failures += 1
|
||||
print(f" [FAIL] {name}: {e!r}")
|
||||
if failures:
|
||||
print(f"[FAIL] {failures} test(s) failed")
|
||||
sys.exit(1)
|
||||
print("[OK] All offline controller tests passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
482
tests/test_core_animated_qr.py
Normal file
482
tests/test_core_animated_qr.py
Normal file
@@ -0,0 +1,482 @@
|
||||
"""
|
||||
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")
|
||||
249
tests/test_core_checkalive.py
Normal file
249
tests/test_core_checkalive.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
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,22 +9,34 @@ Run:
|
||||
python3 tests/test_core_heirs.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.heirs import (
|
||||
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
|
||||
HEIR_DUST_AMOUNT, TRANSACTION_LABEL,
|
||||
create_op_return_script,
|
||||
HEIR_ADDRESS,
|
||||
HEIR_AMOUNT,
|
||||
HEIR_DUST_AMOUNT,
|
||||
HEIR_LOCKTIME,
|
||||
HEIR_REAL_AMOUNT,
|
||||
OP_RETURN_PREFIX,
|
||||
TRANSACTION_LABEL,
|
||||
AliasNotFoundException,
|
||||
NotAnAddress, AmountNotValid, LocktimeNotValid,
|
||||
HeirExpiredException, HeirAmountIsDustException,
|
||||
NoHeirsException, WillExecutorFeeException,
|
||||
AmountNotValid,
|
||||
BalanceTooLowException,
|
||||
HeirAmountIsDustException,
|
||||
Heirs,
|
||||
LocktimeNotValid,
|
||||
NoHeirsException,
|
||||
NotAnAddress,
|
||||
WillExecutorFeeException,
|
||||
create_op_return_script,
|
||||
get_op_return_hex,
|
||||
is_op_return_address,
|
||||
validate_op_return_hex,
|
||||
)
|
||||
|
||||
from bal.core.util import Util
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Constants
|
||||
@@ -68,7 +80,7 @@ def test_op_return_empty():
|
||||
def test_op_return_too_big():
|
||||
try:
|
||||
create_op_return_script("ab" * 81) # 81 bytes > max 80
|
||||
assert False, "expected ValueError"
|
||||
raise AssertionError("expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
@@ -156,6 +168,32 @@ def test_heirs_amount_to_float():
|
||||
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)
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -177,13 +215,13 @@ def test_validate_amount():
|
||||
# Invalid
|
||||
try:
|
||||
Heirs.validate_amount("0.000000001")
|
||||
assert False, "expected AmountNotValid"
|
||||
raise AssertionError("expected AmountNotValid")
|
||||
except AmountNotValid:
|
||||
pass
|
||||
|
||||
try:
|
||||
Heirs.validate_amount("-1")
|
||||
assert False, "expected AmountNotValid"
|
||||
raise AssertionError("expected AmountNotValid")
|
||||
except AmountNotValid:
|
||||
pass
|
||||
|
||||
@@ -207,7 +245,7 @@ def test_validate_locktime_expired():
|
||||
past = int(time.time()) - 86400 # yesterday
|
||||
try:
|
||||
Heirs.validate_locktime(str(past), timestamp_to_check=past + 1)
|
||||
assert False, "expected LocktimeNotValid"
|
||||
raise AssertionError("expected LocktimeNotValid")
|
||||
except LocktimeNotValid:
|
||||
pass
|
||||
|
||||
@@ -258,9 +296,99 @@ def test_validate_removes_invalid():
|
||||
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__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print(f"[OK] All heirs tests passed")
|
||||
print("[OK] All heirs tests passed")
|
||||
|
||||
@@ -8,19 +8,20 @@ Run:
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.heirs import (
|
||||
Heirs, create_op_return_script, reduce_outputs,
|
||||
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
|
||||
HEIR_AMOUNT,
|
||||
Heirs,
|
||||
create_op_return_script,
|
||||
reduce_outputs,
|
||||
)
|
||||
from bal.core.willexecutors import Willexecutors
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Heirs db-dependent methods
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -139,6 +140,7 @@ def test_prepare_lists_mixed_dust_continues():
|
||||
"dust": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", "1%", "30d"],
|
||||
})
|
||||
raised = False
|
||||
result = None
|
||||
try:
|
||||
result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
|
||||
except HeirAmountIsDustException:
|
||||
@@ -165,6 +167,7 @@ def test_prepare_lists_multi_locktime_continues():
|
||||
"late_valid": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 5000, "60d"],
|
||||
})
|
||||
raised = False
|
||||
result = None
|
||||
try:
|
||||
result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
|
||||
except HeirAmountIsDustException:
|
||||
@@ -188,7 +191,7 @@ def test_validate_address_invalid():
|
||||
from bal.core.heirs import NotAnAddress
|
||||
try:
|
||||
Heirs.validate_address("bad")
|
||||
assert False, "should have raised"
|
||||
raise AssertionError("should have raised")
|
||||
except NotAnAddress:
|
||||
pass
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user