Compare commits
14 Commits
feature/cm
...
feature/i1
| Author | SHA1 | Date | |
|---|---|---|---|
| 70196fc3cd | |||
| 9c654bf2bf | |||
| e8db76338d | |||
|
4a33116b66
|
|||
|
fb88d7540c
|
|||
| 9c4697c923 | |||
| 42f05d3c4f | |||
| 3a9ee5adb9 | |||
| 2c9f6bdd9d | |||
| 4dfb3fc41c | |||
| 1ae1617172 | |||
| 125bf09b9a | |||
| b5752a42f0 | |||
| ce659048ca |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -33,3 +33,7 @@ tmp*
|
||||
|
||||
# Release artifacts
|
||||
bal_v*.zip.*
|
||||
tests/karen7
|
||||
|
||||
# Compiled translations: build_zip.py rebuilds them from the .po sources
|
||||
*.mo
|
||||
|
||||
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).
|
||||
59
AGENTS.md
59
AGENTS.md
@@ -3,6 +3,9 @@
|
||||
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.
|
||||
@@ -20,8 +23,9 @@ The plugin's `bal/` directory is symlinked into
|
||||
|
||||
## Test & verify
|
||||
|
||||
Tests are **standalone scripts**, not pytest. Each `tests/test_*.py` file runs
|
||||
its `test_*` functions from `if __name__ == "__main__"`. Run a file directly:
|
||||
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"
|
||||
@@ -29,6 +33,13 @@ 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
|
||||
@@ -43,7 +54,8 @@ QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need of
|
||||
- **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).
|
||||
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
|
||||
@@ -53,15 +65,50 @@ QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need of
|
||||
## Architecture
|
||||
|
||||
- `bal/core/` = GUI-free logic (`heirs.py`, `will.py`, `willexecutors.py`,
|
||||
`plugin_base.py`, `util.py`). Must never import Qt.
|
||||
`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.
|
||||
`plugin.py` is the Electrum `@hooks` entry. `qt.py` is a zipimport shim.
|
||||
`common.py` uses `import *` intentionally (ruff suppresses F403/F405 here);
|
||||
`bal/gui/qt/*.py` all import from it.
|
||||
- `bal/cli/` = headless command-line layer (no Qt). `plugin.py` is the daemon
|
||||
entry point, `commands.py` registers `bal_*` commands with Electrum.
|
||||
- `bal/wallet_util/` = wallet helper utilities for Qt and core.
|
||||
- `bal/qt.py` and `bal/cmdline.py` are thin shims that Electrum discovers
|
||||
via `manifest.json`; they import the real `Plugin` class via `importlib`.
|
||||
- `bal/manifest.json` = version source of truth (Electrum reads it; also read by
|
||||
`make-release.sh`).
|
||||
- Compatibility constraint: must support Electrum **4.7.2 and 4.8.0**; the DB
|
||||
registration API differs between them (`json_db.register_dict` vs
|
||||
`stored_dict.register_name`).
|
||||
|
||||
## Translations (i18n)
|
||||
|
||||
How it works: `bal/i18n.py` (Electrum's catalog first, then BAL's, then
|
||||
English). Plan and decisions: `PLAN_I18N.md`.
|
||||
|
||||
- Sources in git: `babel.cfg`, `bal/locale/bal.pot` (template) and
|
||||
`bal/locale/<lang>/LC_MESSAGES/bal.po` (one per language). The compiled
|
||||
`.mo` files are NOT in git: `build_zip.py` builds them into the zip.
|
||||
- Writing texts: `_("... {}").format(x)`, never f-strings or `%` inside
|
||||
`_()` (Ruff INT); `N_()` for class attributes/constants, translated with
|
||||
`_()` when shown. Never translate stored or compared text (wallet labels,
|
||||
`HISTORY_LABEL`, the status history).
|
||||
- After changing texts, refresh the catalogs (then translate the new
|
||||
entries, e.g. with Poedit):
|
||||
```bash
|
||||
pybabel extract -F babel.cfg --no-wrap -o bal/locale/bal.pot .
|
||||
pybabel update -i bal/locale/bal.pot -d bal/locale -D bal --no-wrap
|
||||
```
|
||||
- New language: `pybabel init -i bal/locale/bal.pot -d bal/locale -D bal -l <lang>`
|
||||
(use Electrum's language code, e.g. `de_DE`).
|
||||
- Dev install (symlink, not the zip): compile once to see the translations,
|
||||
`pybabel compile -d bal/locale -D bal`.
|
||||
- `tests/test_translations.py` checks every catalog ({} fields, `$tokens`,
|
||||
no addresses/links/e-mails added by a translation) and prints a summary
|
||||
when run standalone.
|
||||
|
||||
## Build / release
|
||||
|
||||
```bash
|
||||
@@ -69,6 +116,8 @@ python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha25
|
||||
./make-release.sh [v0.x.y] # bump manifest version, tag, sign, push Gitea release
|
||||
```
|
||||
|
||||
- `build_zip.py` needs **Babel** (`pip install babel`): it compiles the
|
||||
translation catalogs into the zip and stops if Babel is missing.
|
||||
- `make-release.sh` requires gpg and Gitea credentials (`~/.git-credentials`
|
||||
or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the
|
||||
version there, never invent a new source of truth.
|
||||
|
||||
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
|
||||
```
|
||||
880
CHANGELOG.md
880
CHANGELOG.md
@@ -2568,3 +2568,883 @@ of a session, without requiring the normal wizard flow to have run first.
|
||||
`test_merge_will_validity_error_logs_without_crashing`.
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## 49. OP_RETURN support for heirs
|
||||
|
||||
**Date:** 2026-07-30
|
||||
|
||||
**Goal:** allow heirs to produce an OP_RETURN output instead of a regular BTC
|
||||
payment. An address prefixed with `OP_RETURN:` carries hex data (max 80 bytes);
|
||||
such heirs always have amount 0 and are excluded from the normal amount
|
||||
calculations (percentage normalization, dust checks, leftover redistribution).
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/heirs.py`
|
||||
- `validate_heir`: new `OP_RETURN:<hex>` address validation (rejects non-hex,
|
||||
> 80 bytes). OP_RETURN heirs are stored with amount `"0"` and carry the raw
|
||||
hex data in the address field.
|
||||
- `prepare_lists`: OP_RETURN heirs are skipped during amount calculation
|
||||
(`normalize_perc`, dust checks). They are kept in the will but produce a
|
||||
zero-value output.
|
||||
- `buildTransactions`: when building the transaction, OP_RETURN heirs emit a
|
||||
`OP_RETURN <hex>` scriptPubKey output with value 0, matching Bitcoin's
|
||||
OP_RETURN output standard.
|
||||
- `get_transactions`: OP_RETURN heirs are grouped with their locktime peers
|
||||
but excluded from fee/dust arithmetic.
|
||||
|
||||
- `bal/gui/qt/window.py`
|
||||
- Heir dialog / wizard: the address field accepts `OP_RETURN:` prefix and
|
||||
shows a decoded-text message field when an OP_RETURN address is entered.
|
||||
- `build_will` / `_build_success_report`: OP_RETURN heirs display the
|
||||
decoded message text in the build report instead of a BTC address.
|
||||
|
||||
- `bal/gui/qt/lists.py`
|
||||
- Heir list: OP_RETURN heirs display the decoded message in the address
|
||||
column and show "0" for the amount.
|
||||
|
||||
- `bal/gui/qt/common.py`
|
||||
- Re-export the new `validate_op_return_hex` helper for the GUI layer.
|
||||
|
||||
- `tests/test_core_heirs.py`
|
||||
- 8 new tests: OP_RETURN validation (valid hex, too long, non-hex), OP_RETURN
|
||||
heirs excluded from amount calculations, OP_RETURN output shape in built
|
||||
transactions, mixed OP_RETURN + regular heirs.
|
||||
|
||||
**Verification:**
|
||||
- `ruff check` on changed production files: no new errors.
|
||||
- Full test suite: 307 passed.
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## 50. Core extraction: GUI-free logic into bal/core/ (reminders, checkalive, input_rules); RLock pickle fix
|
||||
|
||||
**Date:** 2026-08-05
|
||||
|
||||
**Goal:** extract GUI-free business logic that was previously embedded in Qt
|
||||
widgets (`bal/gui/qt/widgets.py`) into standalone `bal/core/` modules, making
|
||||
them independently testable without Qt. Also fix a critical `copy.deepcopy(tx)`
|
||||
crash on Electrum 4.8 (`RLock` cannot be pickled) and improve wallet-DB
|
||||
persistence robustness.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/checkalive.py` (new)
|
||||
- `resolve_date_to_check`: computes the effective check-alive timestamp from
|
||||
will-settings and user type (BASIC uses `now()`; ADVANCED uses the stored
|
||||
threshold). Extracted from `window.py init_class_variables`.
|
||||
- `check_alive_expired`: pure-logic test for whether the check-alive has
|
||||
passed. Previously duplicated inline in `window.py`.
|
||||
|
||||
- `bal/core/reminders.py` (new)
|
||||
- `compute_reminder_offsets`, `basic_reminder_offsets`, `BALCalendar.write_ics`,
|
||||
`BALCalendar._ics_provider`: all calendar/reminder logic extracted from
|
||||
`widgets.py`. Generates iCal (.ics) files with separate VEVENT entries per
|
||||
reminder date. No Qt dependency.
|
||||
|
||||
- `bal/core/input_rules.py` (new)
|
||||
- `LockTimeRawEdit`, `LockTimeDateEdit`, `BalTimeEditWidget`,
|
||||
`ThresholdTimeWidget`: GUI-free data models for locktime/threshold
|
||||
validation and the Raw/Date selector logic. The Qt widgets in
|
||||
`widgets.py` now thin-wrap these helpers.
|
||||
|
||||
- `bal/core/heirs.py`
|
||||
- Fixed `copy.deepcopy(tx)` failure on Electrum 4.8: the `Transaction`
|
||||
object contains a `_thread.RLock` that cannot be pickled. Will-item
|
||||
persistence now re-parses the transaction from its hex serialization
|
||||
instead of deep-copying.
|
||||
- Invalid heirs (sentinel values from failed builds) are now kept in the
|
||||
wallet DB instead of being silently dropped, so the user can see and
|
||||
correct them.
|
||||
|
||||
- `bal/core/plugin_base.py`
|
||||
- Minor adjustments to support the extracted modules.
|
||||
|
||||
- `bal/gui/qt/widgets.py`
|
||||
- Major slim-down: business logic delegates to `bal/core/checkalive.py`,
|
||||
`bal/core/reminders.py`, and `bal/core/input_rules.py`. Only Qt widget
|
||||
creation and layout remain.
|
||||
|
||||
- `bal/gui/qt/calendar.py`
|
||||
- Adapted to use `bal/core/reminders.py` for .ics generation.
|
||||
|
||||
- `bal/gui/qt/window.py`
|
||||
- `init_class_variables` now calls `resolve_date_to_check` from
|
||||
`bal/core/checkalive.py` instead of computing inline.
|
||||
|
||||
- `bal/gui/qt/common.py`
|
||||
- Updated re-exports for the new core modules.
|
||||
|
||||
- Tests reorganized: core-only tests moved to `tests/test_core_checkalive.py`,
|
||||
`tests/test_core_reminders.py`, `tests/test_core_input_rules.py` (run
|
||||
without Qt).
|
||||
|
||||
- `tests/karen7`: fixture file compressed/updated for the new test structure.
|
||||
|
||||
**Verification:**
|
||||
- `ruff check` on changed files: no new errors.
|
||||
- Full test suite: 388 passed (significant increase due to new core test modules).
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## 51. "Rebuild will on wallet close" setting (`REBUILD_ON_CLOSE`)
|
||||
|
||||
**Date:** 2026-08-14
|
||||
|
||||
**Goal:** add a new plugin setting that lets the plugin rebuild the will
|
||||
automatically when Electrum closes, skipping the full Build wizard. When
|
||||
enabled, closing Electrum triggers a one-shot prepare/inheritance flow
|
||||
(check, rebuild if needed, sign, broadcast) without showing the
|
||||
`BalBuildWillDialog`.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/plugin_base.py`
|
||||
- New persisted config `REBUILD_ON_CLOSE = BalConfig(config,
|
||||
"bal_rebuild_on_close", False)` (default OFF), with explanatory comment.
|
||||
|
||||
- `bal/gui/qt/plugin.py`
|
||||
- New "Rebuild on close" checkbox in the settings dialog, bound to
|
||||
`REBUILD_ON_CLOSE`, with a tooltip explaining the behaviour. Added to the
|
||||
"Reset to Default Setting" list.
|
||||
|
||||
- `bal/gui/qt/window.py`
|
||||
- `on_close`: when `REBUILD_ON_CLOSE` is enabled, the close flow runs
|
||||
the auto-rebuild path (check + rebuild + sign + push) instead of the
|
||||
full wizard dialog. The legacy wizard-on-close path is kept when the
|
||||
setting is OFF.
|
||||
|
||||
- `tests/test_rebuild_on_close_setting.py` (new)
|
||||
- 8 tests: default OFF, toggle/persist, close triggers rebuild when ON,
|
||||
close skips rebuild when OFF, reset restores default.
|
||||
|
||||
**Verification:**
|
||||
- `ruff check` on changed files: no new errors.
|
||||
- Full test suite: 396 passed.
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## 52. Headless CLI layer (`bal/cli/`, `bal/cmdline.py`, `bal_*` daemon commands)
|
||||
|
||||
**Date:** 2026-08-14
|
||||
|
||||
**Goal:** expose the full BAL inheritance cycle via Electrum's command-line
|
||||
interface (daemon mode), without the Qt GUI. This enables scripting,
|
||||
automation, and headless server usage.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/cmdline.py` (new)
|
||||
- Zip-import shim for Electrum's `gui_name='cmdline'` plugin loader.
|
||||
- Follows the same `importlib` pattern as `qt.py` but never imports Qt.
|
||||
- Re-exports `Plugin` from `bal.cli.plugin`.
|
||||
|
||||
- `bal/cli/__init__.py` (new)
|
||||
- Registers the `bal_*` commands with Electrum on import.
|
||||
|
||||
- `bal/cli/plugin.py` (new)
|
||||
- `Plugin(BalPlugin)` -- minimal entry point for the daemon (no Qt hooks,
|
||||
no `bal_windows`).
|
||||
|
||||
- `bal/cli/commands.py` (new)
|
||||
- 30 `@plugin_command` async functions registered as `bal_*` commands:
|
||||
settings (list/get/set/reset), heirs (list/show/add/update/delete/import/
|
||||
export), will-executors (list/show/add/update/select/delete/ping/download/
|
||||
import/export), will (status/check/prepare/sign/broadcast/export/
|
||||
import_merge/invalidate/check_executor).
|
||||
- Each command is a thin transport layer: validates args, delegates to
|
||||
`BalController`, returns JSON-serializable results.
|
||||
|
||||
- `bal/cli/controller.py` (new, ~1100 lines)
|
||||
- `BalController` -- headless replica of `BalWindow`. Reads/writes wallet DB,
|
||||
config, and will-executors without any Qt dependency.
|
||||
- Methods mirror `BalWindow` flows: `load_willitems`, `save_willitems`,
|
||||
`init_class_variables`, `build_inheritance_transaction`, `sign_transactions`,
|
||||
`push_transactions_to_willexecutors`, `check_transactions`, `export_json_file`,
|
||||
`merge_will_from_file`, `invalidate_will`.
|
||||
- Domain exceptions (`WillExpiredException`, `HeirNotFoundException`, etc.)
|
||||
are converted to `UserFacingException` with clear text.
|
||||
|
||||
- `bal/manifest.json`
|
||||
- `"available_for"` updated from `["qt"]` to `["qt", "cmdline"]`.
|
||||
|
||||
- `bal/__init__.py`
|
||||
- Added `from .cli import commands` to register `bal_*` commands on import
|
||||
(both CLI pre-parse and GUI startup).
|
||||
|
||||
- `tests/test_cli_commands_registered.py` (new)
|
||||
- 4 tests: all `bal_*` commands registered, all are coroutines, no duplicate
|
||||
registration, all args documented.
|
||||
|
||||
- `tests/test_cli_controller_offline.py` (new)
|
||||
- Offline CRUD tests for heirs, will-executors, settings, and will
|
||||
import/export merge via `BalController` (no network).
|
||||
|
||||
**Verification:**
|
||||
- `ruff check` on new files: clean.
|
||||
- Full test suite: 427 passed.
|
||||
- `tests/test_cli_commands_registered.py`: all 4 tests pass.
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## 53. Auto-rebuild on new transactions (`AUTO_REBUILD`)
|
||||
|
||||
**Date:** 2026-08-16
|
||||
|
||||
**Goal:** when new transactions are detected in the wallet (e.g. incoming
|
||||
payments), automatically rebuild the will so the inheritance covers the
|
||||
new UTXOs. The delivery date is anticipated by one day to orphan the old
|
||||
will on-chain; an on-chain invalidation is only needed when the anticipated
|
||||
locktime crosses the Check Alive threshold.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/plugin_base.py`
|
||||
- New persisted config `AUTO_REBUILD = BalConfig(config,
|
||||
"bal_auto_rebuild", False)` (default OFF), with explanatory comment.
|
||||
|
||||
- `bal/gui/qt/plugin.py`
|
||||
- New "Auto-rebuild" checkbox in the settings dialog, bound to
|
||||
`AUTO_REBUILD`, with a tooltip. Added to the "Reset to Default Setting"
|
||||
list.
|
||||
|
||||
- `bal/gui/qt/window.py`
|
||||
- New `_auto_rebuild_on_new_tx()` method: triggered when Electrum detects
|
||||
a new transaction in the wallet. Runs the full prepare flow: check
|
||||
coherence, rebuild with the anticipated locktime (delivery date minus 1
|
||||
day), persist, sign (if passwordless), push to will-executors.
|
||||
- When the anticipated locktime crosses the Check Alive threshold, returns
|
||||
an invalidation transaction instead of auto-completing.
|
||||
- Connected to Electrum's `new_transaction` signal.
|
||||
|
||||
- `tests/test_auto_rebuild_on_new_tx.py` (new)
|
||||
- 16 tests: AUTO_REBUILD default OFF, toggle/persist, rebuild triggers on
|
||||
new tx, locktime anticipation by 1 day, threshold crossing returns
|
||||
invalidation, passwordless wallet signs automatically, encrypted wallet
|
||||
returns invalidation tx for manual signing.
|
||||
|
||||
**Verification:**
|
||||
- `ruff check` on changed files: no new errors.
|
||||
- Full test suite: 432 passed.
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## 54. CLI `bal_will_autorebuild` command
|
||||
|
||||
**Date:** 2026-08-16
|
||||
|
||||
**Goal:** expose the auto-rebuild flow (entry #53) as a headless CLI command,
|
||||
so scripts and daemons can trigger the one-shot check/rebuild/sign/push
|
||||
cycle without the Qt GUI.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/cli/commands.py`
|
||||
- New `bal_will_autorebuild` async command (flag `nw`): runs the full
|
||||
auto-rebuild flow via `BalController.auto_rebuild()`. Returns a JSON
|
||||
object with `result` (`valid`, `no_heirs`, `invalidated`, `nothing`,
|
||||
`needs_signing`, `rebuilt`) and, when applicable, the invalidation
|
||||
transaction.
|
||||
|
||||
- `bal/cli/controller.py`
|
||||
- New `auto_rebuild()` method: headless replica of the GUI auto-rebuild
|
||||
flow. Checks coherence, rebuilds with anticipated locktime, handles
|
||||
threshold-crossing (returns invalidation tx), signs passwordless wallets
|
||||
automatically, pushes to will-executors.
|
||||
|
||||
- `tests/test_cli_autorebuild.py` (new)
|
||||
- 10 tests: auto-rebuild returns `valid` when will is coherent, `rebuilt`
|
||||
when rebuilt, `invalidated` with invalidation tx when threshold crossed,
|
||||
`needs_signing` for encrypted wallets, `no_heirs` when heirs are missing.
|
||||
|
||||
- `tests/test_cli_commands_registered.py`
|
||||
- Updated `EXPECTED_COMMANDS` to include `bal_will_autorebuild`.
|
||||
|
||||
- `tests/test_cli_controller_offline.py`
|
||||
- Extended with auto-rebuild flow tests.
|
||||
|
||||
**Verification:**
|
||||
- `ruff check` on changed files: no new errors.
|
||||
- Full test suite: 438 passed.
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## 55. Remove "Add transaction without willexecutor" from settings dialog
|
||||
|
||||
**Date:** 2026-08-17
|
||||
|
||||
**Goal (owner request):** the "Add transaction without willexecutor" checkbox
|
||||
was already available in the Will-Executor tab; showing it redundantly in the
|
||||
settings dialog created confusion. Remove it from the settings dialog and
|
||||
renumber the grid rows.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/gui/qt/plugin.py`
|
||||
- Removed the "Add transaction without willexecutor" checkbox
|
||||
(`NO_WILLEXECUTOR`) from the settings dialog grid. The setting is still
|
||||
functional (available from the Will-Executor tab and the wizard); only
|
||||
the settings-dialog exposure was removed.
|
||||
- Grid rows 5--16 renumbered to 4--15 to close the gap left by the removal.
|
||||
- Removed the corresponding reset-button widget for `NO_WILLEXECUTOR` from
|
||||
the "Reset to Default Setting" list.
|
||||
|
||||
**Verification:**
|
||||
- `ruff check` on changed file: no new errors.
|
||||
- Full test suite: 438 passed (unchanged).
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
## 56. QR / audio will transfer (chunked multi-QR export/import + review-and-sign wizard)
|
||||
|
||||
**Date:** 2026-08-27
|
||||
|
||||
**Goal (owner request):** export a will to another device via QR codes (with
|
||||
an optional audio channel on top), and import it there with a per-transaction
|
||||
review-and-sign flow. QR codes are chunked because a full will usually exceeds
|
||||
a single code's capacity.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/qrtransfer.py` (new, GUI-free): the wire format and chunk
|
||||
scheduler — `BALQR{version}|{total}|{index}|{flags}|{payload}` frames,
|
||||
`encode_transfer` / `decode_transfer`, `split_frames` / `parse_frame` /
|
||||
`assemble`, optional `Z` (zlib+base64) compression at export, four chunk
|
||||
presets (150/400/900/1800 bytes/frame, EC level M), plus
|
||||
`preset_index_for_chunk_size` and the `QrTransferError` exception family.
|
||||
- `tests/test_core_qr_transfer.py` (new): round-trips (plain/compressed),
|
||||
boundaries (exact-fit, size>payload, `|` in payload), min-size guard, bad
|
||||
magic/version/numbers/flags, multi-frame reassembly, header consistency.
|
||||
- `bal/core/plugin_base.py`: new `QR_CHUNK_SIZE` config (default 150).
|
||||
- `bal/gui/qt/plugin.py`: settings-dialog row 16 "QR Code Size" combo
|
||||
(4 presets) + reset button, visible in BASIC and ADVANCED.
|
||||
- `bal/gui/qt/dialogs.py`:
|
||||
- `BalQrImage`: QR widget with MEDIUM error correction (Electrum's
|
||||
`QRCodeWidget` is EC-L), reusing `draw_qr`.
|
||||
- `WillQrExportDialog`: walks the frames (Prev/Next, "i of N" progress),
|
||||
export filters **All / Valid / Valid-NC**, live chunk-preset selector,
|
||||
**Auto slideshow** (toggle button + "QR codes per second" spinbox, stops on
|
||||
the last frame and on filter/chunk changes), optional audio-send button.
|
||||
- `WillQrImportDialog`: camera scan (Electrum `scan_qrcode_from_camera`,
|
||||
one code at a time), manual paste fallback, slot grid (1..N) with
|
||||
green=stored, total-mismatch reset, optional audio-receive that mirrors
|
||||
the audio_modem `_recv` sink with a callback instead of `setText`.
|
||||
- `WillTxReviewSignDialog`: per-transaction review (outputs via
|
||||
`get_ui_address_str`, total outputs, fee) with Sign / Skip / Cancel and a
|
||||
single wallet password; final page offers "Save signed file…" and
|
||||
"Show signed QR…". Runs on the imported local copy only.
|
||||
- `bal/gui/qt/window.py`: `export_will_via_qr`, `import_will_via_qr`,
|
||||
`get_audio_modem_plugin`, `_audio_send_payload`; `sign_transactions`
|
||||
refactored into a byte-equivalent batch loop plus the reusable
|
||||
`_prepare_and_sign_tx(…, txid, password)` single-transaction helper.
|
||||
- `bal/gui/qt/lists.py`: will-list menu gains **Export → QR Codes** and
|
||||
**Import via QR**.
|
||||
- `tests/test_gui_qr_transfer.py` (new): export build/navigation/chunk
|
||||
change, filters (Valid, Valid-NC, empty-revert), import frame flow,
|
||||
assembly+decode, total-mismatch reset, manual entry.
|
||||
- `PLAN_QR_TRANSFER.md`: the full spec (wire format, settings, export,
|
||||
import, wizard checklist P0–P6, findings log).
|
||||
- Docs: `README.md` QR-transfer section; `QML_PLAN.md` updated (Phase 2
|
||||
`BalQrTransferModel`, Phase 3 dedicated QML export/import pages, R6
|
||||
mitigation rewritten, deferred-chunks note removed).
|
||||
|
||||
**Audio channel caveats:**
|
||||
- The audio send/receive buttons only appear when Electrum's `audio_modem`
|
||||
plugin is enabled *and* `amodem` + PortAudio are installed (not present in
|
||||
the current dev runtime — verified F22). On that channel the transport
|
||||
zlib-compresses internally, so no BAL framing/`Z` flag is used.
|
||||
- `WaitingDialog` requires a real `QWidget` parent and the plugin's `_recv`
|
||||
hard-wires `parent.setText`, so receive uses a local mirror with a
|
||||
callback sink.
|
||||
|
||||
**Verification:**
|
||||
- `python3 tests/test_core_qr_transfer.py`: all pass.
|
||||
- `QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py`: all pass.
|
||||
- Pytest batch `tests/test_core_*.py tests/test_gui_*.py`: 374 passed (only
|
||||
the two pre-existing `test_bt_to_date_*` datetime compare failures remain).
|
||||
- `QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py
|
||||
electrum.plugins.bal`: passed (clean import under real Electrum).
|
||||
- `ruff`: no new violations on changed files.
|
||||
|
||||
**Audio-environment notes (dev box, discovered while testing):**
|
||||
- `amodem` 1.16.0 is old and uses `np.ndarray.tostring()`, removed in numpy 2.x;
|
||||
the runtime venv (numpy 2.4.6) needs the one-line patch
|
||||
`tostring()` → `tobytes()` in
|
||||
`electrum/env/lib/python3.11/site-packages/amodem/common.py` (done locally,
|
||||
not in the repo). Any machine with numpy>=2 and this amodem version needs
|
||||
the same patch (or numpy<2).
|
||||
- Electrum's `audio_modem` plugin hardcodes `libportaudio.so` (unversioned).
|
||||
Debian/Ubuntu only ship `libportaudio.so.2`, so the load fails silently
|
||||
inside the plugin's `_send` `WaitingDialog` (no `on_error` → no sound, no
|
||||
message). Fix on the dev box:
|
||||
`sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /usr/lib/x86_64-linux-gnu/libportaudio.so`
|
||||
(created by the `libportaudio-dev` package; a `LD_LIBRARY_PATH` stub works
|
||||
without root). The audio buttons stay hidden unless the plugin is enabled
|
||||
and available.
|
||||
- Verified on the dev box (no physical mic required) via a full
|
||||
send→sink→monitor→recv round-trip: set the default PulseAudio source to
|
||||
`<sink>.monitor` at receive time; payload returned byte-identical.
|
||||
|
||||
**Follow-up fixes (same session, reported during audio testing):**
|
||||
- `WillItem.__init__` now defaults `status` to `""` instead of `None`. A
|
||||
`WillItem` built from a bare `{"tx": ...}` (QR/audio import, clipboard
|
||||
merge) crashed in `set_status` with
|
||||
`unsupported operand type(s) for +=: 'NoneType' and 'str'` during the
|
||||
validity pass / `IMPORTED` marking.
|
||||
- `BalWindow.invalidate_will` guards a missing `date_to_check` (first-action
|
||||
case) like `merge_will` already did, fixing
|
||||
`AttributeError: 'BalWindow' object has no attribute 'date_to_check'` when
|
||||
invalidating before the periodic check initialized it.
|
||||
- Regression test `test_imported_item_status_not_none` added to
|
||||
`tests/test_gui_qr_transfer.py`; QR GUI suite 12/12, batch 377 passed.
|
||||
---
|
||||
|
||||
## 57. Name the real cause of a failed build instead of guessing
|
||||
|
||||
**Date:** 2026-09-04
|
||||
|
||||
**Goal (owner request):** the "Building Will" report was hard to read and often
|
||||
misleading.
|
||||
|
||||
1. The long "could not build the will" block was printed entirely in amber
|
||||
(`COLOR_WARNING`), which the owner reported as barely legible.
|
||||
2. Whenever the build produced nothing, the dialog printed a FIXED list of
|
||||
three "possible reasons" (low balance / dust shares / check-alive later than
|
||||
the delivery date) regardless of what had actually happened. In a case
|
||||
reproduced from the owner's log all three were false, and the real cause
|
||||
(no delivery date left to build) was not even in the list.
|
||||
3. The "Checking your will" row had the same problem: the single sentence
|
||||
"Found CHANGES to the DATE or the HEIRS" was shown for five different
|
||||
situations, including one where it is plainly wrong - funds received, where
|
||||
neither the date nor the heirs changed.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/heirs.py`
|
||||
- `Heirs.__init__` / `buildTransactions`: new `last_build_error` attribute
|
||||
recording WHY a build produced no transaction. Reset at the start of every
|
||||
build, and set at each path that previously returned empty with no
|
||||
explanation at all: `NO_HEIRS`, `NO_UTXO`, `NO_WILLEXECUTOR_USABLE`,
|
||||
`NO_FUTURE_DATE`, `WILLEXECUTOR_FEE`, `WILLEXECUTOR_FEE_TOO_HIGH`,
|
||||
`TX_BUILD_FAILED`, `WILLEXECUTOR_TX_ERROR`.
|
||||
- Added a `processed_willexecutors` counter so that "the loop skipped every
|
||||
will-executor" - which returned silently, with no log line whatsoever - is
|
||||
told apart from "we tried and the build failed".
|
||||
- Fixed a latent crash in the `prepare_transactions` exception handler. It
|
||||
read `e.heirname` in order to auto-deselect the offending will-executor,
|
||||
but NOTHING in the plugin sets that attribute any more (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. The handler now
|
||||
records `WILLEXECUTOR_TX_ERROR`, logs the actual exception together with
|
||||
the will-executor it happened on, and moves on to the next one - which is
|
||||
what the original code was clearly trying to do.
|
||||
|
||||
- `bal/gui/qt/dialogs.py`
|
||||
- New `msg_alert()`: an amber warning sign (U+26A0, written as a numeric HTML
|
||||
entity so the source stays ASCII) followed by text in the theme's default
|
||||
colour. Colour is what ATTRACTS attention, not what is read, so it is kept
|
||||
on the sign alone; the message body stays readable and still works under
|
||||
the dark theme, where a hard-coded black would disappear.
|
||||
- New `_build_failure_message()`: maps `last_build_error` to ONE specific
|
||||
sentence. When the code is missing or unrecognised it SAYS the cause could
|
||||
not be determined and lists what to check, instead of asserting three
|
||||
guesses as if they were the only possibilities.
|
||||
- New `_check_failure_message()`: replaces the single "Found CHANGES to the
|
||||
DATE or the HEIRS" line with seven precise messages, reusing the detail the
|
||||
exceptions already carry (heir name, will-executor URL, old and new fee
|
||||
rate). The two plain `NotCompleteWillException` cases are told apart
|
||||
STRUCTURALLY (raised with no argument vs. with one), not by matching
|
||||
message text, which would be fragile. No new exception classes were added
|
||||
(owner request).
|
||||
- Added a dedicated `except BalanceTooLowException` handler. The exception
|
||||
already carried the balance, the fees and the dust threshold, but was
|
||||
falling through to the generic handler, which printed the raw technical
|
||||
string in red and re-raised. It now shows the real figures.
|
||||
- "Checking variables" row: `No Heirs` now uses `msg_alert()`. The
|
||||
"Check Alive Threshold Passed" message deliberately STAYS red
|
||||
(`COLOR_ERROR`) because it is the more urgent situation (owner request).
|
||||
|
||||
- `bal/gui/qt/common.py`
|
||||
- Re-export `BalanceTooLowException` from `core.heirs` so the Qt layer can
|
||||
catch it.
|
||||
|
||||
**Verification:**
|
||||
- `py_compile` clean on all 44 files of the package.
|
||||
- The real `msg_alert`, `_build_failure_message` and `_check_failure_message`
|
||||
were extracted from the source via AST and executed against every reason code
|
||||
and every exception type, with the exception hierarchy rebuilt from
|
||||
`will.py`: 9 build cases and 8 check cases all produce the intended text.
|
||||
- NOT RUN: the official test suite. The machine used for this task (Windows)
|
||||
has no importable `electrum` module, so `tests/` could not be executed.
|
||||
- Manually tested by the owner in Electrum 4.8.1: `NO_FUTURE_DATE`,
|
||||
`WILLEXECUTOR_FEE` and `No Heirs` were all confirmed on screen.
|
||||
|
||||
## 57. Remove all `copy.deepcopy` (ad-hoc copy helpers; `WillItem` copies serialize/deserialize)
|
||||
|
||||
**Date:** 2026-08-28
|
||||
|
||||
**Goal (owner request):** eliminate every `copy.deepcopy` from the codebase
|
||||
and replace it with ad-hoc copy methods; `WillItem` copies must be produced by
|
||||
serializing and deserializing the item rather than by deep-copying live
|
||||
runtime objects (which can hold a `threading.RLock` and cannot be pickled).
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/util.py`: new `copy_structure(value, _path="copy")` — the single
|
||||
JSON-safe, deepcopy-free recursive cloner (dict / list / tuple cloned
|
||||
structurally, JSON scalars kept as-is, any accidental runtime object coerced
|
||||
to `str` + logged). It replaces the old `heirs._json_safe` implementation.
|
||||
- `bal/core/heirs.py`: `_json_safe` is now a thin backward-compatible alias of
|
||||
`bal.core.util.copy_structure`; `Heirs.save` behaviour is unchanged.
|
||||
- `bal/core/will.py`:
|
||||
- `WillItem.__init__` on a `WillItem` argument no longer does
|
||||
`self.__dict__ = w.__dict__.copy()` + `copy.deepcopy`; instead it
|
||||
serializes (`to_dict()`) and deserializes: the tx is re-parsed into a fresh
|
||||
object, `STATUS` is rebuilt from a clone, and heirs / will-executors are
|
||||
cloned recursively, so the copy shares no mutable state with the source.
|
||||
- New `WillItem.copy(wallet=None)` (serialize/deserialize round trip; re-adds
|
||||
wallet tx info when a wallet is passed) and the static
|
||||
`WillItem.copy_status_table(table)` used for the `STATUS` tables.
|
||||
- `to_dict()` now also emits `Father` / `Children` so the round trip is
|
||||
faithful.
|
||||
- `normalize_will` routes copies through the constructor / `copy()`.
|
||||
- `bal/gui/qt/window.py` and `bal/cli/controller.py`: the Build-will flow now
|
||||
uses `copy_structure(...)` instead of `copy.deepcopy(...)` for heirs and
|
||||
will-executors.
|
||||
- Dropped now-unused `import copy` (`will.py`, `controller.py`, `qt/common.py`,
|
||||
`qt/window.py`).
|
||||
- Tests updated to the same helpers: STATUS tables via
|
||||
`WillItem.copy_status_table`, heirs / built dicts via `copy_structure`
|
||||
(`test_core_will.py`, `test_core_will_invalidate.py`,
|
||||
`test_heir_relative_anchor.py`, `test_anticipate_manual_locktime.py`,
|
||||
`test_no_willexecutor_karen7.py`, `test_reproduce_none_type.py`,
|
||||
`test_group_e_mock_karen7.py`, `test_group_e_karen7_invalidate.py`,
|
||||
`sim_update_flows.py`).
|
||||
|
||||
**Verification:**
|
||||
- Full offline batch `tests/test_core_*.py tests/test_gui_*.py`: 377 passed,
|
||||
only the two pre-existing `test_bt_to_date_*` datetime compare failures
|
||||
remain (identical to HEAD — no regression; `test_heir_relative_anchor`
|
||||
isolated-file failure is pre-existing test-pollution at HEAD too).
|
||||
- Ad-hoc semantics check: `copy()`/ctor copy share no mutable state with the
|
||||
source (mutating source heirs/STATUS does not leak into the copy and vice
|
||||
versa), `copy_status_table` returns fresh lists, `normalize_will` runs.
|
||||
- `ruff` on all touched files: no new violations (4 findings, all pre-existing
|
||||
at HEAD).
|
||||
- `tests/smoke_test.py electrum.plugins.bal`: passed.
|
||||
- `python3 build_zip.py`: 45 files, 343591 bytes, sha256 `aa8f8154…`;
|
||||
`tests/external_zip_test.py bal-electrum-plugin.zip`: passed (Plugin class
|
||||
loads via the zipimport shim).
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## Next. Animated-QR interop (BC-UR v1/v2, BBQR)
|
||||
|
||||
**Date:** 2026-09-08
|
||||
|
||||
**Goal:** Let BAL export/import a will not only as its own BAL QR frame format
|
||||
but also as BC-UR v1 (`ur:bytes`, BC32 + SHA-256), BC-UR v2 (`ur:bytes`, CBOR
|
||||
bytewords-minimal fountain codes) and BBQR (Coinkite `B$…`) animated-QR
|
||||
sequences, so transfers interoperate with Blockchain Commons / Coldcard-style
|
||||
tools and BitKit. Codecs must be stdlib-only and the export must keep BAL QR
|
||||
as the default.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/animated_qr.py` (new): stdlib-only codec module.
|
||||
- BC32 (bech32_bis checksum, XOR `0x3FFFFFFF`) encode/decode matching the
|
||||
BCR-2020-004/005 reference vectors.
|
||||
- bytewords-minimal encode/decode (BCR-2020-012) with CRC-32 rejection;
|
||||
the word list was transcribed verbatim from the reference C++.
|
||||
- BC-UR v2: CBOR part writer/reader, CRC-32, `choose_fragments`
|
||||
(xoshiro256** + alias + ary-threshold sampler) and XOR-based fountain
|
||||
mixing/solving; emits a redundant mixed wave for loss tolerance.
|
||||
- BC-UR v1: multipart with SHA-256 digest and single-part digest-less
|
||||
frames; `1of1` handling.
|
||||
- BBQR: base32 (encoding `2`), hex (uppercase, `H`) and zlib (lowercase,
|
||||
`Z`, automatic compression fallback) frames; out-of-order reconstruction.
|
||||
- One `AnimatedQrSession` + `detect_format` + `parse_for_detection` for
|
||||
auto-detecting the incoming format and keying the GUI debounce.
|
||||
- Safety caps: `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`,
|
||||
zlib-bomb guard, `TransferConflictError`/`SessionLimitError`.
|
||||
- `bal/gui/qt/dialogs.py`:
|
||||
- Export page (`BalQrExportWidget`) gained a **Format** selector
|
||||
(BAL QR default, BC-UR v1, BC-UR v2, BBQR) reusing the QR-size presets,
|
||||
with per-format intro/format-hint text.
|
||||
- Import page (`BalQrImportWidget`) now routes every frame through
|
||||
`parse_for_detection` + `AnimatedQrSession.add_part`, auto-detecting the
|
||||
format and resetting when the transfer's session key changes; the
|
||||
review/sign step resolves the session and decodes parts uniformly.
|
||||
- `qr_import_accept_frame` generalised to
|
||||
`(state, fmt, session_key, frame_total, index, payload, stable_reads=2)`.
|
||||
- `tests/test_core_animated_qr.py` (new, 32 tests): BC32 spec vectors,
|
||||
bytewords round-trip/CRC, C++ reference-frame decode+re-encode parity
|
||||
(single-part 12B, seq_len=2, seq_len=7), fountain solve with missing pure
|
||||
part, out-of-order/duplicate handling, single/multipart UR v1, BBQR
|
||||
Z/2/H round-trips, runt last part, zlib-bomb guard, detection positive/
|
||||
negative.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- `tests/test_core_animated_qr.py`: 32/32 pass.
|
||||
- `tests/test_gui_qr_transfer.py` (now 36 tests) + `test_gui_export_dialogs.py`: pass.
|
||||
- `ruff` clean on `animated_qr.py`, `dialogs.py` and both test files;
|
||||
`pyright` 0 errors on the touched modules.
|
||||
- `tests/smoke_test.py electrum.plugins.bal`, `python3 build_zip.py` and
|
||||
`external_zip_test.py` all pass.
|
||||
- Full regression: 462 passed; only pre-existing failures remain
|
||||
(`test_bt_to_date_*`, will-invalidate fee, unrelated `sign_transactions`
|
||||
stub test).
|
||||
|
||||
**Notes / caveats:**
|
||||
|
||||
- A real bug was found & fixed during this work: `_ur2_part_cost` used
|
||||
`2 * body_len` but `bytewords_minimal_encode` appends a 4-byte CRC, so every
|
||||
UR v2 frame was undercounted by 8 characters and could overflow the QR
|
||||
budget for large transfers.
|
||||
- UR v1 multipart emits the digest-carrying `1of1/<digest>/<frag>` form for a
|
||||
single part (both headered and headerless single parts are accepted on
|
||||
import); this keeps deterministic digest verification.
|
||||
- Imported payloads are UTF-8 text; the codec sessions do not decode raw
|
||||
binary transfer blobs.
|
||||
|
||||
**Outcome:** DONE (uncommitted).
|
||||
|
||||
---
|
||||
|
||||
## Animated-QR bugfix: QVideoSink signal wiring + will-export JSON crash
|
||||
|
||||
**Date:** 2026-09-08
|
||||
|
||||
**Goal:** Fix two runtime crashes found by manual testing of the QR paths.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/gui/qt/dialogs.py`:
|
||||
- `_start_scan`/`_stop_scan` used `QVideoSink.videoFrame.connect/.disconnect`,
|
||||
but on PyQt6 `videoFrame` is the frame **getter method**, not a signal —
|
||||
this raised ``AttributeError: 'builtin_function_or_method' object has no
|
||||
attribute 'connect'`` on camera scan. Switched to the `videoFrameChanged`
|
||||
signal (same wiring Electrum's `QrReaderVideoSurface` uses).
|
||||
- `_stop_scan` now tolerates `AttributeError` when disconnecting the sink
|
||||
and guards the `errorOccurred` disconnect too, so a mid-init failure can
|
||||
never cascade into a second uncaught exception.
|
||||
- `_whole_will_json` (whole-will QR export) serialized ``WillItem.to_dict()``
|
||||
with plain `json.dumps`, crashing with ``TypeError: Object of type
|
||||
Transaction is not JSON serializable`` (the ``tx`` field holds a real
|
||||
``Transaction``). Now uses Electrum's `MyEncoder`, matching `write_json_file`.
|
||||
- `tests/test_gui_qr_transfer.py`: new `test_import_start_stop_scan_signal_wiring`
|
||||
drives the real `QVideoSink` life-cycle with a mocked camera and fails if
|
||||
the signal name regresses to `videoFrame`.
|
||||
- `tests/test_gui_export_dialogs.py`: new
|
||||
`test_qr_whole_will_json_serializes_transaction` covers the JSON export.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- `pytest tests/test_gui_qr_transfer.py tests/test_gui_export_dialogs.py -q`: pass.
|
||||
- Full offline batch `tests/test_core_*.py tests/test_gui_*.py`: 444 passed.
|
||||
- Regression test flips correctly (fails when reverted to the buggy call).
|
||||
- `ruff` clean on touched files; `tests/smoke_test.py`, `build_zip.py`,
|
||||
`external_zip_test.py` all pass.
|
||||
|
||||
**Outcome:** DONE (uncommitted).
|
||||
|
||||
---
|
||||
|
||||
## Balanced-QR wire format v2: compact header + best-of compression
|
||||
|
||||
**Date:** 2026-09-13
|
||||
|
||||
**Goal:** Shrink the native BAL QR wire format to its minimum. The old
|
||||
pipe-separated header (`BALQR1|total|index|flags|`) wasted 12-14 characters on
|
||||
direction marker, separators and decimal count fields, and the export always
|
||||
sent uncompressed hex text. New exports should fit a will in the fewest,
|
||||
densest frames possible.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/qrtransfer.py`:
|
||||
- New wire format v2: `BAL1<TTT><iii><F><payload>` — fixed 11-char header,
|
||||
no separators. `BAL1` magic, 3-digit **base36** zero-padded totals/index
|
||||
(values `00A`-`ZZZ`, cap 46655 frames), single flag char.
|
||||
- Flags: `0` = plain payload, `Z` = zlib+base64 compressed payload (the importer
|
||||
already decompressed `Z`; the exporter now produces it).
|
||||
- `encode_transfer_best(tx_strings)` returns the shorter of plain vs
|
||||
compressed; the export widget uses it as the default for BAL QR.
|
||||
- `parse_frame` is dual: old `BALQR1|total|index|flags|payload` frames still
|
||||
import unchanged (backwards-compatible receive).
|
||||
- Frame-count overflow (a transfer needing > 46655 frames) raises
|
||||
`QrTransferError` at encode time instead of emitting corrupt headers.
|
||||
- `bal/gui/qt/dialogs.py` (`BalQrExportWidget`): BAL QR export now encodes via
|
||||
`encode_transfer_best`, so plain *or* compressed frames are emitted per
|
||||
transfer; import is untouched (already format-agnostic and flag-driven).
|
||||
- `bal/core/animated_qr.py`: `detect_format` accepts `BAL1` in addition to the
|
||||
legacy `BALQR` prefix; wire-format docstring updated.
|
||||
- `bal/gui/qt/widgets.py` (`WillWidget`): the will detail view now shows each
|
||||
heir's address (or the decoded UTF-8 text of an `OP_RETURN:` heir) and a
|
||||
dedicated Address row for the will-executor.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- `tests/test_core_qr_transfer.py` (new v2 tests: header structure, field width,
|
||||
`Z` flag round-trip, best-of selection, malformed `BAL1` frames, 46655 cap and
|
||||
exact boundary): all pass; legacy `BALQR1` parse tests unchanged and green.
|
||||
- `tests/test_core_animated_qr.py`: `BAL1` detection + `parse_for_detection`;
|
||||
`tests/test_gui_qr_transfer.py`: format-combo + chunk-navigation updated for
|
||||
the compact export.
|
||||
- `pytest tests/test_core_*.py tests/test_import_will_details.py -q`: 345 passed.
|
||||
- `ruff` clean on all touched files except the pre-existing `dialogs.py` I001
|
||||
(present on HEAD); `pyright` 0 errors on the codec modules.
|
||||
- Android Chaquopy bundle re-synced (`sync_codecs.py` + `verify_chain.py`).
|
||||
|
||||
**Notes / caveats:**
|
||||
|
||||
- **Compatibility break (forward):** the new default export (compressed
|
||||
`BAL1…`) is NOT readable by older BAL versions — nor by the previously
|
||||
released Android APK — until those are updated to accept `BAL1`. Imports of
|
||||
legacy `BALQR1…` exports keep working on this version. Existing audio
|
||||
transfers are unaffected (they keep explicit `compress=False`, and the audio
|
||||
format was never flag-driven on receive).
|
||||
- A typical multi-tx will now ships as a single dense frame instead of two
|
||||
sparse ones: header overhead dropped from 12-14 chars to a constant 11, and
|
||||
the base36 count fields are 3 chars regardless of how many frames exist.
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
## 58. i18n phase 1: BAL translation layer (Electrum first, then BAL)
|
||||
|
||||
**Date:** 2026-09-26 (branch `feature/i18n`, see `PLAN_I18N.md`)
|
||||
|
||||
**Goal (owner request):** make the whole plugin translatable, starting with
|
||||
Italian, so that BAL follows the language chosen in Electrum. This phase adds
|
||||
the mechanism only: nothing visible changes yet.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- New `bal/i18n.py`, BAL's own gettext layer (domain `bal`, catalogs in
|
||||
`bal/locale/<lang>/LC_MESSAGES/bal.mo`, read with `plugin.read_file()` so it
|
||||
works from the zip and from a development checkout):
|
||||
- `_()` looks a text up in Electrum's catalog first, then in BAL's, then
|
||||
returns the English source (owner's decision D7: BAL reads like Electrum,
|
||||
and Electrum's translations would take over if BAL ever became an internal
|
||||
plugin). BAL translations whose `{}` fields differ from the source are
|
||||
rejected with the same rules as Electrum's.
|
||||
- `N_()` marks texts defined at import time, translated when shown.
|
||||
- `init_from_config()` repeats Electrum's own language choice (Preferences >
|
||||
Language, else the supported system language, else English) once, in the
|
||||
Qt `Plugin.__init__`. A missing or broken catalog never stops the plugin.
|
||||
- `common.py`, `theme.py`, `core/will.py`, `core/willexecutors.py` now import
|
||||
`_` from `bal.i18n`. The CLI keeps Electrum's translator and stays English,
|
||||
like Electrum's own CLI.
|
||||
|
||||
**Verification:** new `tests/test_i18n.py`; results in entry 59.
|
||||
|
||||
**Outcome:** DONE (delivered in one test ZIP together with entry 59).
|
||||
|
||||
## 59. i18n phase 2: translatable texts and language-neutral stored data
|
||||
|
||||
**Date:** 2026-09-26 (branch `feature/i18n`, see `PLAN_I18N.md`)
|
||||
|
||||
**Goal:** make every user-visible text of the Qt GUI extractable and
|
||||
translatable, without changing the English UI, and make sure no stored data
|
||||
depends on the GUI language.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- Texts built with f-strings or `.format()` inside `_()`, or glued together
|
||||
from pieces, are now whole sentences with `{}` fields (Ruff INT clean).
|
||||
- Class-level texts (list headers, wizard titles/messages, help texts,
|
||||
download error messages, status labels) are marked with `N_()` and
|
||||
translated when shown: class bodies run before the catalog is loaded.
|
||||
- Helpers no longer translate their argument (`add_widget`, `get_window_title`,
|
||||
`msg_set_status`, the will-detail `qlabel`): callers pass translated text.
|
||||
This also stops heir names and URLs from going through the translator.
|
||||
- Newly translatable: the settings help texts, the wizard download options,
|
||||
the build report rows, the Ping/Select menu buttons, the QR size presets
|
||||
(marked in `common.QR_PRESET_LABELS`: `core/qrtransfer.py` stays free of
|
||||
Electrum imports because the Android reader ships a copy of it), and the
|
||||
"(reminder i/n)" suffix of the calendar events (`build_ics_reminders` gained
|
||||
`reminder_suffix`, English by default for the CLI).
|
||||
- **Stored status history:** `WillItem.set_status` now always writes the
|
||||
English label; the new `format_status_history()` translates it for display
|
||||
(lists and will detail) and keeps unknown tokens as they are, so histories
|
||||
saved by older versions (e.g. `New.Firmato.Pushed`) still display fine.
|
||||
- **Calendar defaults:** `EVENT_SUMMARY` / `EVENT_DESCRIPTION` defaults follow
|
||||
the GUI language (`BalConfig(..., translatable=True)`), while the English
|
||||
original is what gets stored; a text written by the user is kept as it is.
|
||||
- **Not translated on purpose:** `HISTORY_LABEL` (used by
|
||||
`Util._label_matches_history` to recognise BAL's own local transactions),
|
||||
the "BAL Invalidate/Inheritance transaction" wallet labels,
|
||||
`heirs.TRANSACTION_LABEL` and the will-executor "info" fallback stored in the
|
||||
config.
|
||||
- Two small visible fixes: the will detail showed "Transaction fees::" and
|
||||
"Status::" with a double colon.
|
||||
- `pyproject.toml`: Ruff now also checks the `INT` (gettext) rules.
|
||||
|
||||
**Verification (Electrum 4.8.2, Python 3.14, offscreen):**
|
||||
|
||||
- `tests/test_i18n.py`: 20 passed (lookup order, rejected `{}` fields,
|
||||
missing/broken catalogs, language fallback, English status storage and its
|
||||
display translation, translatable config defaults, reminder suffix, QR
|
||||
preset labels).
|
||||
- Release-gate batch (as `make-release.sh`): 403 passed, 1 failed; the other
|
||||
offline test files: 213 passed. Identical to the baseline taken before the
|
||||
change; the only failures are pre-existing (`test_e5` and
|
||||
`test_reproduce_none_type` need the local `tests/karen7` wallet,
|
||||
`test_import_start_stop_scan_signal_wiring` fails on `main` too,
|
||||
`test_gui_export_dialogs.py` hangs on `main` too).
|
||||
- `smoke_test.py`, `build_zip.py` and `external_zip_test.py`: OK.
|
||||
- Ruff: INT clean; no new errors (the only one left is the pre-existing
|
||||
`dialogs.py` I001).
|
||||
- Babel extraction (GUI + core): 424 strings (about 3,000 words), was 323.
|
||||
|
||||
**Outcome:** DONE, pending the owner's test of the ZIP.
|
||||
|
||||
## 60. i18n phase 3: Italian catalog
|
||||
|
||||
**Date:** 2026-09-26 (branch `feature/i18n`, see `PLAN_I18N.md`)
|
||||
|
||||
**Goal (owner request):** with Electrum set to Italian, BAL shows in Italian.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `babel.cfg` (extraction mapping, CLI and wallet_util excluded),
|
||||
`bal/locale/bal.pot` (template, 424 texts) and the Italian catalog
|
||||
`bal/locale/it_IT/LC_MESSAGES/bal.po`: 43 entries copied from Electrum's
|
||||
own it_IT catalog (same English text), the others drafted following the
|
||||
owner's glossary (D1). Draft to be reviewed by the owner (D2).
|
||||
- `build_zip.py` compiles every `bal.po` into `bal.mo` inside the zip
|
||||
(Babel required, the build stops without it); `.po`/`.pot` are not
|
||||
shipped and `*.mo` is ignored by git (D4).
|
||||
- New `tests/test_translations.py`: every catalog compiles; `{}` fields and
|
||||
`$tokens` match the English text; no Bitcoin address, e-mail, URL or long
|
||||
letters-and-digits word that the English text does not have (patterns from
|
||||
Electrum's `electrum-locale/update.py`).
|
||||
- `AGENTS.md`: how to update the catalogs, add a language, compile for the
|
||||
development install.
|
||||
|
||||
**Verification (Electrum 4.8.2, offscreen):** `tests/test_translations.py`
|
||||
OK (it_IT: 424 translated, 0 untranslated, 0 fuzzy); all offline tests 640
|
||||
passed, only the pre-existing failures of entry 59; the built zip contains
|
||||
only `bal/locale/it_IT/LC_MESSAGES/bal.mo` for the translations and
|
||||
`external_zip_test.py` loads it; Ruff: no new errors.
|
||||
|
||||
**Outcome:** DONE, pending the owner's review of the Italian texts and test
|
||||
of the ZIP.
|
||||
|
||||
@@ -23,6 +23,25 @@ is updated to mark them as supported.
|
||||
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
|
||||
|
||||
104
HANDOFF.md
104
HANDOFF.md
@@ -58,6 +58,7 @@ bal/ <- the plugin package (this is what ships in the ZI
|
||||
__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 <- get_version() reads the version from manifest.json (zip-safe)
|
||||
heirs.py <- HEIRS + transaction building (prepare_lists,
|
||||
@@ -67,6 +68,14 @@ bal/ <- the plugin package (this is what ships in the ZI
|
||||
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.
|
||||
@@ -80,7 +89,6 @@ bal/ <- the plugin package (this is what ships in the ZI
|
||||
wallet_util/ <- standalone wallet-inspection helpers, no Qt
|
||||
tests/ <- standalone test scripts (see Section 3).
|
||||
docs/ <- user manual + inheritance-options guide (.md sources).
|
||||
bal_cli.py <- headless CLI (heirs/will build/sign/push/check), no Qt.
|
||||
build_zip.py <- builds the shippable ZIP (36 files).
|
||||
CHANGELOG.md <- numbered task log (English).
|
||||
.agent_memory_tasks.md <- terse internal memory notes per task batch.
|
||||
@@ -103,7 +111,8 @@ Two separate venvs — using the wrong one is the #1 mistake:
|
||||
- **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.
|
||||
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:
|
||||
@@ -295,8 +304,8 @@ See Section 5 for details.
|
||||
update `GITEA_TOKEN` env var or `~/.git-credentials`, then retry.
|
||||
- Older PR history (pre-`main` direct workflow): **#13** (v0.4.7), **#14**
|
||||
(docs/DUST section + translation), **#15** (v0.4.8), **#4** (v0.6.1 —
|
||||
manifest.json version). All merged into `main`.
|
||||
- Releases: latest is **v0.6.1**; v0.6.0 and v0.5.18 before it; the older
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -346,6 +355,11 @@ See Section 5 for details.
|
||||
- **#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
|
||||
@@ -379,3 +393,85 @@ See Section 5 for details.
|
||||
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.
|
||||
453
PLAN_I18N.md
Normal file
453
PLAN_I18N.md
Normal file
@@ -0,0 +1,453 @@
|
||||
# PLAN_I18N — Italian translation of the BAL Electrum plugin (i18n)
|
||||
|
||||
> **Status:** DRAFT. Every phase still needs the owner's explicit "OK" before any code is written (HANDOFF.md, rule R4).
|
||||
> **Prepared:** 2026-09-25, from a read-only analysis (no code was changed). Owner's decisions recorded on 2026-09-26 (section 6).
|
||||
> **Code base analysed:** `bal-electrum-plugin` `main` @ `4a33116` (2026-09-19, manifest `0.7.0`) and Electrum `4.8.0`.
|
||||
> **Line numbers** refer to that commit. After merging a newer `main`, find the code by content.
|
||||
|
||||
## 0. Read this first
|
||||
|
||||
1. Read `HANDOFF.md` (rules R1–R4, ZIP-FIRST, CREDIT-SAVING) and `AGENTS.md` (venvs, tests). They still apply.
|
||||
2. **One exception to HANDOFF.md.** Section 5 ("Git / delivery workflow") and section 7, step 7, say: work directly on `main`, push to `origin/main`, run `make-release.sh`. **That does NOT apply to this task.** See section 3 (dedicated branch).
|
||||
3. The owner's decisions (D1–D7) are recorded in section 6. For anything not covered there, ask the owner (rule R3).
|
||||
|
||||
## 1. Goal and scope
|
||||
|
||||
**Goal:** when Electrum is set to Italian, the whole BAL interface appears in Italian. The mechanism must allow adding more languages later just by adding one `.po` file.
|
||||
|
||||
**In scope**
|
||||
- The Qt GUI of the plugin (`bal/gui/qt/`) and the GUI-free strings it displays (`bal/core/`).
|
||||
- One language for now: Italian (`it_IT`).
|
||||
|
||||
**Out of scope** (owner's decisions, or by design)
|
||||
- **BAL Easy Heirs**: excluded by the owner.
|
||||
- **CLI output** (`bal/cli/`): stays English, exactly like Electrum (`run_electrum`: "the CLI is intentionally always non-localized").
|
||||
- `bal/wallet_util/` (standalone helper scripts), the Android reader (`android/`, which uses Android resources), the manual, the website and the docs.
|
||||
- Other languages: later. The infrastructure will support them.
|
||||
|
||||
## 2. Background (verified)
|
||||
|
||||
### 2.1 How Electrum 4.8.0 does it
|
||||
|
||||
- **gettext with one domain, `electrum`.** Catalogs live in `electrum/locale/locale/<lang>/LC_MESSAGES/electrum.mo` (`electrum/i18n.py`, `LOCALE_DIR`). Every string goes through `electrum.i18n._()`.
|
||||
- **The language is set once, at startup, for the GUI only** (`run_electrum` L433–451):
|
||||
- it uses `config.LOCALIZATION_LANGUAGE` (config key `language`);
|
||||
- if that is empty, it uses `electrum.gui.default_lang.get_default_language(gui_name)`, i.e. `QLocale.system().name()`, **but only if** that value is in `electrum.i18n.languages`; otherwise `en_UK`;
|
||||
- `en_*` means no translation (the English source strings are shown);
|
||||
- changing the language requires a restart.
|
||||
- **Strings evaluated too early stay English.** Anything evaluated before `set_language()` (for example at import time) is not translated.
|
||||
- **Safety check inside `_()`.** The decorator `_ensure_translation_keeps_format_string_syntax_similar` rejects a translation whose `{}` fields differ from the source. `_("")` returns `""`. There is an optional `context=` argument (pgettext).
|
||||
- **Coding rules** (comments in `i18n.py`): no f-strings inside `_()`, no `%` formatting; write `_("… {}").format(x)`. There are no plural forms (`ngettext` is not used anywhere).
|
||||
- **Pipeline:**
|
||||
1. `contrib/locale/push_locale.py` runs xgettext over `electrum/**/*.py`. This includes *internal* plugins.
|
||||
2. The strings go to Crowdin, where volunteers translate them.
|
||||
3. The `.po` files are stored in the `electrum-locale` repo.
|
||||
4. `contrib/locale/build_locale.sh` compiles them with msgfmt into `.mo` files and writes `stats.json`.
|
||||
- **Translation security:**
|
||||
- `electrum-locale/update.py` refuses any translation whose `msgstr` contains a Bitcoin address, an e-mail address, a URL, or a long word mixing letters and digits;
|
||||
- `llm_proofreader/` checks every translation PR for vandalism in CI.
|
||||
- **External plugins:**
|
||||
- they are always ZIP files, imported with `zipimport` as `electrum_external_plugins.<name>`;
|
||||
- `BasePlugin.read_file(filename) -> bytes` reads a file from inside the ZIP, or from the internal plugin folder;
|
||||
- **Electrum provides nothing for translating external plugins.** Their strings are not in `electrum.mo`.
|
||||
- `i18n.py`, `gui/default_lang.py` and `read_file()` are identical in 4.7.2, 4.8.0 and 4.8.2.
|
||||
- **Swiss locales** (`it_CH`, `de_CH`, `fr_CH`) are not in Electrum's list, so Electrum starts in English until the user picks a language. BAL must simply follow Electrum and not detect the language itself. Otherwise the UI would be half in one language and half in another.
|
||||
|
||||
### 2.2 BAL today (`main` @ `4a33116`)
|
||||
|
||||
- Source strings are in English. Most are wrapped in `_`, imported from `electrum.i18n` in these places:
|
||||
- `gui/qt/common.py:55`, which re-exports it to every `gui/qt` module;
|
||||
- `gui/qt/theme.py:90,108`;
|
||||
- `core/will.py:32` and `core/willexecutors.py:25`;
|
||||
- `cli/controller.py:29`.
|
||||
- The package has 483 `_()` calls in 11 files. xgettext finds 365 distinct strings (≈ 2,230 words).
|
||||
- **With Electrum set to Italian, only 39 of the 365 strings (≈ 11 %) appear in Italian.** They are the generic words that Electrum already translates (Cancel, Save, Address…). Everything specific to BAL stays in English.
|
||||
- **Issues to fix before translating** (exact locations are in the Appendix):
|
||||
- 14 f-strings or `.format()` calls inside `_()`: these strings can never be translated (Ruff INT001/INT002);
|
||||
- 15 `_()` calls with a non-literal argument: extraction tools cannot find the text;
|
||||
- 14 concatenations around `_()`: the word order breaks in other languages;
|
||||
- 18 `_()` calls evaluated at import time (in class bodies): they would stay English (see 4.2);
|
||||
- 2 × `_("")`: plain gettext returns the catalog header instead of an empty string;
|
||||
- 12 settings help texts (`HelpButton("…")`) and the help texts of 5 `add_widget()` calls are never translated (≈ 500 words in total);
|
||||
- other text shown to the user without `_()`.
|
||||
- **Some stored data depends on the UI language.**
|
||||
- `WillItem.set_status()` (`core/will.py:1344`) appends the *translated* status label to `WillItem.status`.
|
||||
- That field is saved via `to_dict()` in the wallet DB (`wallet.db.get_dict("will")`, `gui/qt/window.py:232`), in exported files and in QR transfers.
|
||||
- Real example from `tests/samanta7`: `"New.Firmato.Pushed.Checked.Replaced"`. Electrum's Italian catalog already translates *Signed*, *Error* and *Expired*, so the saved history mixes languages today.
|
||||
- **Default texts in `core/plugin_base.py` exist only in English:**
|
||||
- `HISTORY_LABEL` (around L246, token `{willexecutor}`);
|
||||
- `EVENT_DESCRIPTION` and `EVENT_SUMMARY` (around L331–337, tokens `$wallet_name` and `$heirs_complete`).
|
||||
|
||||
## 3. Branch and workflow for this task
|
||||
|
||||
The owner decided, on Truman's suggestion, to do this work on a **separate branch on Gitea**, not on `main`.
|
||||
|
||||
- **Branch:** `feature/i18n` (proposed name, see D6), created from `main`.
|
||||
- **Never** commit or push to `main` during this task.
|
||||
- **Never** run `make-release.sh` from the branch: it tags and publishes a Gitea release. Build test ZIPs with `python3 build_zip.py` only.
|
||||
- **At the start of every phase:**
|
||||
1. merge `origin/main` into `feature/i18n`;
|
||||
2. resolve any conflicts;
|
||||
3. run the tests again.
|
||||
- **Where conflicts are likely:**
|
||||
- `gui/qt/dialogs.py`, `window.py`, `lists.py`, `plugin.py`, `widgets.py` and `core/will.py`: all were changed on `main` on 2026-09-04, 09-14 and 09-19, and commit `fb88d75` alone changed ≈ 1,800 lines of `dialogs.py`;
|
||||
- `CHANGELOG.md`: both sides append at the end, so keep both entries and renumber.
|
||||
- **ZIP-FIRST for every phase:**
|
||||
1. build the ZIP;
|
||||
2. the owner (and Truman) test it;
|
||||
3. only after the owner's explicit OK, commit on `feature/i18n` and push to `origin/feature/i18n` (who does this: see D3).
|
||||
- Add one numbered `CHANGELOG.md` entry per phase, in English.
|
||||
- Run ruff and the tests before every ZIP (HANDOFF.md). The known pre-existing test failures are listed in HANDOFF.md, section 7, step 3.
|
||||
- **At the end:** open a Pull Request `feature/i18n` → `main` on Gitea (there is a precedent: `feature/bal-qr-transfer`). Release from `main` only after the merge.
|
||||
|
||||
## 4. Design (decided)
|
||||
|
||||
**Chosen:** a gettext catalog owned by BAL, domain `bal`, shipped inside the plugin ZIP, that follows Electrum's language and is consulted **after** Electrum's own catalog (D7).
|
||||
|
||||
**Rejected:**
|
||||
- keep using `electrum.i18n._`: 89 % of the text stays English;
|
||||
- Python dictionaries or JSON files: a home-made format with no tooling;
|
||||
- Qt Linguist (`.ts` / `.qm`): Electrum desktop does not use it, and `core/` has no Qt.
|
||||
|
||||
### 4.1 New module `bal/i18n.py`
|
||||
|
||||
| Function | Behaviour |
|
||||
|---|---|
|
||||
| `_(msg)` | 1. `""` returns `""`. 2. **Electrum first (D7):** if `electrum.i18n._(msg)` differs from `msg`, return it (Electrum has already applied its own `{}` check). 3. Otherwise, if a BAL catalog is loaded and translates `msg`, apply the `{}` safety check and return the translation. The check uses the same logic as Electrum's decorator: **copy it** with an attribution comment (MIT); do not import Electrum's private function. 4. Otherwise return `msg` (English). The CLI and English behave exactly as in Electrum, because no BAL catalog is loaded there. |
|
||||
| `N_(msg)` | Returns `msg` unchanged. It marks strings defined in tables, constants and class attributes, which are translated later with `_()` when displayed. Babel extracts `N_` by default. |
|
||||
| `set_language(plugin, lang)` | `None`, `""` or `en_*`: no catalog. Otherwise read `locale/<lang>/LC_MESSAGES/bal.mo` with `plugin.read_file()`, falling back to `locale/<lang[:2]>/…`, and build `gettext.GNUTranslations(io.BytesIO(data))`. If the catalog is missing or broken, log at info level and continue in English. Never raise. |
|
||||
| `init_from_config(plugin, config)` | `lang = config.LOCALIZATION_LANGUAGE`. If it is empty, use `electrum.gui.default_lang.get_default_language(gui_name="qt")`, guarding against `ImportError` as `run_electrum` does. Then call `set_language()`. |
|
||||
|
||||
- **Why Electrum first (D7):** BAL follows Electrum's wording wherever Electrum has it. If Electrum ever ships BAL as an internal plugin, Electrum's own translations (Crowdin) would be used automatically and the BAL catalog would only fill the gaps.
|
||||
- **Trade-off of D7:** for a text that Electrum already translates, the BAL catalog cannot override Electrum's wording, not even with the D1 glossary. If one such case reads badly in BAL, handle that single case with the owner.
|
||||
- A text that Electrum "translates" into the identical English text counts as not found and falls through to the BAL catalog. This is harmless: the BAL catalog is prefilled with Electrum's translations (3.3).
|
||||
- **Do not use `gettext.translation(localedir=…)`.** When pointed inside a ZIP it silently returns `NullTranslations` (verified).
|
||||
- `plugin.read_file()` is the same API BAL already uses for its icons. It works both for the ZIP install and for the dev symlink install (`AGENTS.md`).
|
||||
- The state is module-level and set once. Changing the language requires restarting Electrum, as in Electrum itself.
|
||||
|
||||
### 4.2 Where the language is set
|
||||
|
||||
- In `bal/gui/qt/plugin.py`, `Plugin.__init__` (L67): call `init_from_config(self, config)` first.
|
||||
- The CLI entry point (`bal/cli/plugin.py`) does not call it, so the CLI stays English.
|
||||
- **Consequence:** `gui/qt/plugin.py` imports `dialogs`, `widgets` and `window` at module level (L49–51), and those import `lists`. So every class body is evaluated *before* `Plugin.__init__` runs, and any `_()` evaluated at import time would stay English. The cases in Appendix D must be converted in Phase 2.
|
||||
|
||||
### 4.3 Files and tools
|
||||
|
||||
```
|
||||
babel.cfg # extraction mapping (repo root)
|
||||
bal/locale/bal.pot # template, generated
|
||||
bal/locale/it_IT/LC_MESSAGES/bal.po # Italian translations (source of truth)
|
||||
bal/locale/it_IT/LC_MESSAGES/bal.mo # compiled (see D4)
|
||||
```
|
||||
|
||||
The tool is **Babel** (`pip install babel`). It is pure Python and works on Windows without GNU gettext. These commands were tested on this code base:
|
||||
|
||||
```ini
|
||||
# babel.cfg
|
||||
[ignore: bal/cli/**]
|
||||
[ignore: bal/wallet_util/**]
|
||||
[python: bal/**.py]
|
||||
```
|
||||
|
||||
```bash
|
||||
mkdir -p bal/locale # extract does not create it
|
||||
pybabel extract -F babel.cfg --no-wrap -o bal/locale/bal.pot .
|
||||
pybabel init -i bal/locale/bal.pot -d bal/locale -D bal -l it_IT # once
|
||||
pybabel update -i bal/locale/bal.pot -d bal/locale -D bal --no-wrap # after each extract
|
||||
pybabel compile -d bal/locale -D bal --statistics
|
||||
```
|
||||
|
||||
- Today this extracts 323 strings (≈ 1,900 words) from the GUI and core.
|
||||
- After Phase 2, expect about 360 strings (≈ 2,500 words).
|
||||
- Poedit (free) can be used to translate or review the `.po` file by hand.
|
||||
|
||||
### 4.4 Translation safety (as in Electrum)
|
||||
|
||||
- Add a check script (for example `scripts/check_translations.py`) and run it before every ZIP. It must check that:
|
||||
- every `.po` file compiles;
|
||||
- the `{}` fields are identical to the source (the same rule as the runtime check);
|
||||
- the `$tokens` are identical (`$wallet_name`, `$heirs_complete`, …): Electrum does not check these;
|
||||
- no `msgstr` contains a Bitcoin address, an e-mail address, a URL or a long letter-and-digit word (use the regexes from `electrum-locale/update.py`), unless the same text is in the `msgid`.
|
||||
|
||||
It must also print the counts of translated, untranslated and fuzzy strings.
|
||||
- Never put addresses or URLs inside translatable strings; pass them with `{}`.
|
||||
- The owner reviews every Italian string, especially the warnings about invalidation, locktime, fees and signing.
|
||||
|
||||
## 5. Phases
|
||||
|
||||
Each phase follows HANDOFF.md: DISCOVER → PLAN (owner's "OK") → EXECUTE → VERIFY → ZIP → test by the owner and Truman → commit on `feature/i18n`.
|
||||
Phases 1 and 2 change nothing visible, so they may be delivered as a single ZIP/test cycle (see D5).
|
||||
|
||||
### Phase 1 — Infrastructure (no visible change)
|
||||
|
||||
- NEW `bal/i18n.py` (section 4.1), with docstrings explaining *why* (rule R2).
|
||||
- `bal/gui/qt/common.py:55`: replace `from electrum.i18n import _` with an import of `_` and `N_` from `bal.i18n`. Use a relative import, zip-safe like the existing `from ...core…` imports.
|
||||
- `bal/gui/qt/theme.py:90,108`: change the local imports to `bal.i18n`.
|
||||
- `bal/core/will.py:32` and `bal/core/willexecutors.py:25`: import from `bal.i18n`.
|
||||
- `bal/cli/controller.py:29`: **unchanged** (the CLI stays English by design).
|
||||
- `bal/gui/qt/plugin.py`, `Plugin.__init__`: call `init_from_config(self, config)`.
|
||||
- NEW `tests/test_i18n.py`, covering:
|
||||
- the empty string;
|
||||
- Electrum's translation wins; the BAL catalog is used only when Electrum has none (D7);
|
||||
- rejection of a `{}` mismatch;
|
||||
- `en_UK` means no catalog;
|
||||
- a missing `.mo` does not crash;
|
||||
- a catalog loaded from bytes (use a small test catalog).
|
||||
- **Verify:**
|
||||
- the existing tests (baseline as in HANDOFF.md, section 7);
|
||||
- `tests/smoke_test.py`;
|
||||
- `build_zip.py` followed by `tests/external_zip_test.py`;
|
||||
- manually on Windows 11 with Electrum 4.8.0, once in Italian and once in English: the UI must look exactly as before.
|
||||
|
||||
### Phase 2 — String clean-up and language-neutral stored data (no visible change)
|
||||
|
||||
- **2.0** Merge `origin/main` first.
|
||||
- **2.1** Appendix A: turn `_(f"…")` and `_("…".format(x))` into `_("… {}").format(x)`. Include `cli/controller.py`, for lint consistency only.
|
||||
- **2.2** Appendix C: turn each concatenation into one string with `{}`.
|
||||
- Keep HTML outside the strings where possible, e.g. `"<b>{}</b>".format(_("Support:"))`.
|
||||
- Fix the malformed `_("<b>Willexecutor:</b:")` (`widgets.py:1366`).
|
||||
- **2.3** Appendix B: mark the literal with `N_()` where it is defined, or translate it at the call site.
|
||||
- The helpers `add_widget()` (`common.py:180`), `get_window_title()` (`plugin.py:1085`) and `qlabel()` (`widgets.py:1319`) need ONE rule: callers pass text that is already translated, and the helper does not call `_()` again.
|
||||
- Today `window.py:290` translates twice.
|
||||
- **2.4** Appendix D: replace class-level `_()` with `N_()` and translate when the text is displayed.
|
||||
- In `lists.py`, `update_headers(self.__class__.headers)` (L248, 647, 1094) must receive a translated copy.
|
||||
- For the wizard `title` / `message` (`dialogs.py` L380, 419–420, 527–528, 547–548), use `_(self.title)` and `_(self.message)` at L325 and L327.
|
||||
- **2.5** Appendix E: replace `_("")` with `""`.
|
||||
- **2.6** Appendix F and G: make the settings help texts and labels in `plugin.py` translatable.
|
||||
- **2.7** Appendix H, plus a full sweep for any other text shown to the user.
|
||||
- **2.8** **Stored status** (`core/will.py:1344`):
|
||||
- store the **English** label (mark the `STATUS_DEFAULT` labels with `N_()`); no `_()` at write time;
|
||||
- add a helper `format_status_history(status) -> str` that splits on `.`, translates the tokens that are known English labels (with an optional `NOT ` prefix), and leaves unknown or legacy tokens unchanged (`New`, `Firmato`, …);
|
||||
- use it wherever the status is shown (`lists.py:589`, `widgets.py:1336`);
|
||||
- this stays backward compatible: existing wallets keep displaying correctly, and older BAL versions still read plain English;
|
||||
- test it with the real legacy strings in `tests/samanta7`.
|
||||
- **2.9** **Default texts** (`core/plugin_base.py`: `HISTORY_LABEL`, `EVENT_DESCRIPTION`, `EVENT_SUMMARY`):
|
||||
- mark the English defaults with `N_()`;
|
||||
- when the stored value is missing **or equal to the English default**, use the translated default at the moment the text is used (calendar export, history label);
|
||||
- the tokens must survive translation.
|
||||
- **Changed during Phase 2 (safety):** `HISTORY_LABEL` stays English and is *not* translatable. `Util._label_matches_history()` uses it to recognise BAL's own local transactions (stale-history cleanup, spendable UTXOs), so a label that changed with the GUI language would no longer match the transactions saved before. Only `EVENT_SUMMARY` and `EVENT_DESCRIPTION` follow the language (`BalConfig(..., translatable=True)`). The same rule applies to every stored or compared text (wallet labels, `heirs.TRANSACTION_LABEL`, the status history).
|
||||
- **2.10** `pyproject.toml`: add `"INT"` to `[tool.ruff.lint] select`.
|
||||
- Ruff's hint suggests `%` formatting: **do not follow it**. Use `.format()`, which is Electrum's rule.
|
||||
- `make-release.sh` treats Ruff errors as blocking, so INT must end up clean.
|
||||
- **Verify:**
|
||||
- Ruff INT is clean;
|
||||
- the extraction count is about 360 (compare with 4.3);
|
||||
- the tests pass, plus new tests for 2.8 and 2.9;
|
||||
- manually in English: no visible text changed.
|
||||
|
||||
### Phase 3 — Italian catalog (visible change)
|
||||
|
||||
- **3.0** Merge `origin/main` first.
|
||||
- **3.1** Add `babel.cfg` and the commands from 4.3, documented in `README.md` or `AGENTS.md`.
|
||||
- **3.2** Generate `bal/locale/bal.pot` and create `it_IT/LC_MESSAGES/bal.po`.
|
||||
- **3.3** Draft the translation using the glossary (D1); the owner reviews it (D2). Where Electrum's `it_IT` catalog already translates the exact same English text, copy Electrum's translation, so the two UIs match.
|
||||
- **3.4** `build_zip.py`:
|
||||
- compile `.po` into `.mo`, according to D4;
|
||||
- put only the `.mo` files in the ZIP (no `.po` / `.pot`);
|
||||
- run the check script (4.4) before zipping.
|
||||
- **Verify:**
|
||||
- Electrum in Italian shows BAL in Italian;
|
||||
- Electrum in English shows BAL in English;
|
||||
- missing entries fall back to English;
|
||||
- `external_zip_test.py` passes;
|
||||
- it works on Windows 11 with Electrum 4.8.0 (and 4.7.2);
|
||||
- manual walkthrough of: wizard, settings, heirs list, will list and detail, will-executors, check / sign / broadcast, invalidate, export / import, QR transfer, calendar export.
|
||||
|
||||
### Phase 4 — Final test and merge
|
||||
|
||||
- The owner and Truman test the final branch ZIP.
|
||||
- Merge `origin/main` into the branch one last time and test again.
|
||||
- Open the Pull Request `feature/i18n` → `main` on Gitea. After the merge, follow the normal release flow from `main`.
|
||||
|
||||
## 6. Decisions
|
||||
|
||||
Recorded on 2026-09-26 from the owner's answers.
|
||||
|
||||
- **D1 — Italian glossary.** Rule: follow Electrum's `it_IT` wording wherever Electrum already has the term or the sentence. Grammatical variants (nouns, past participles, plurals, capitals in headers and buttons) are adapted in the draft and checked by the owner during the review (D2).
|
||||
|
||||
| English | Italian |
|
||||
|---|---|
|
||||
| will | testamento |
|
||||
| heir / heirs | erede / eredi |
|
||||
| will-executor | Will-Executor (kept in English) |
|
||||
| check-alive | verifica se sei vivo |
|
||||
| invalidate | invalida il piano |
|
||||
| invalidation (noun) | invalidazione del piano |
|
||||
| anticipate / postpone | anticipa / posticipa |
|
||||
| locktime | locktime (kept in English; owner, 2026-09-26) |
|
||||
| fee | commissione (as Electrum) |
|
||||
| broadcast | trasmetti il piano |
|
||||
| sign / signed | firma / firmato (as Electrum) |
|
||||
| wallet | portafogli (as Electrum) |
|
||||
|
||||
- **D2 — Who translates.** The developer drafts `it_IT/bal.po`; the owner reviews and corrects it (for example in Poedit). For later languages: Weblate (supports Gitea and `.po`) or Crowdin, as Electrum does.
|
||||
- **D3 — Branch and commits.** The developer creates `feature/i18n`, and commits and pushes on it, each time only after the owner's explicit OK (ZIP-FIRST).
|
||||
- **D4 — `.mo` files.** `build_zip.py` compiles `.po` into `.mo` at build time with Babel; `.mo` files stay out of git (add `*.mo` to `.gitignore`). If Babel is missing, the build stops with a clear error, so an English-only ZIP is never shipped by mistake. Reasons: the `.po` is the only source (no stale `.mo`), every change to a translation is readable in review, and it is how Electrum works.
|
||||
- **D5 — Cycles.** Phases 1 and 2 are delivered in one ZIP/test cycle. Phase 1 is not merged into `main` early.
|
||||
- **D6 — Branch name:** `feature/i18n`.
|
||||
- **D7 — Lookup order.** Electrum's catalog first, then BAL's, then English (see 4.1). Proposed by the owner so that BAL and Electrum read as one, also if Electrum ever integrates BAL.
|
||||
|
||||
## 7. Reminders for the developer
|
||||
|
||||
- Talk to the owner in **Italian**, with simple words and no jargon (the owner is not a programmer). Code, comments, docstrings, UI source strings, docs and commits are in **English**.
|
||||
- UI source strings stay in English; Italian lives only in the `.po` file.
|
||||
- Never invent. When in doubt, stop and ask.
|
||||
- Before writing any code, present the phase PLAN and wait for "OK".
|
||||
- ZIP-FIRST; run ruff and the tests before every ZIP; one CHANGELOG entry per phase; keep reports brief (CREDIT-SAVING).
|
||||
- Wallet data compatibility comes first: apart from 2.8 (status labels), do not change what is stored.
|
||||
|
||||
## Appendix — Inventory at `main` @ `4a33116`
|
||||
|
||||
Generated by a static scan (AST, Ruff `INT` and xgettext). The line numbers will move after merging a newer `main`: re-run the scan or search by content. These lists cover the known cases; Phase 2 still needs a full sweep.
|
||||
|
||||
#### A. f-string / `.format()` inside `_()` — Ruff INT001/INT002 (14)
|
||||
|
||||
| Location | Rule |
|
||||
|---|---|
|
||||
| `bal/cli/controller.py:92` | INT001 |
|
||||
| `bal/cli/controller.py:98` | INT001 |
|
||||
| `bal/cli/controller.py:104` | INT001 |
|
||||
| `bal/gui/qt/common.py:225` | INT002 |
|
||||
| `bal/gui/qt/common.py:240` | INT002 |
|
||||
| `bal/gui/qt/dialogs.py:1226` | INT001 |
|
||||
| `bal/gui/qt/dialogs.py:2491` | INT001 |
|
||||
| `bal/gui/qt/window.py:831` | INT001 |
|
||||
| `bal/gui/qt/window.py:836` | INT001 |
|
||||
| `bal/gui/qt/window.py:1090` | INT001 |
|
||||
| `bal/gui/qt/window.py:1091` | INT001 |
|
||||
| `bal/gui/qt/window.py:2228` | INT001 |
|
||||
| `bal/gui/qt/window.py:2236` | INT001 |
|
||||
| `bal/gui/qt/window.py:2246` | INT001 |
|
||||
|
||||
#### B. `_()` with a non-literal argument (not extractable) (15)
|
||||
|
||||
| Location | Code |
|
||||
|---|---|
|
||||
| `bal/core/will.py:1344` | `_(self.STATUS[status][0])` |
|
||||
| `bal/gui/qt/common.py:181` | `_(label)` |
|
||||
| `bal/gui/qt/dialogs.py:327` | `_(self.message)` |
|
||||
| `bal/gui/qt/dialogs.py:2288` | `_(msg)` |
|
||||
| `bal/gui/qt/dialogs.py:2291` | `_(msg)` |
|
||||
| `bal/gui/qt/lists.py:1240` | `_(label)` |
|
||||
| `bal/gui/qt/plugin.py:1086` | `_(title)` |
|
||||
| `bal/gui/qt/widgets.py:284` | `_(self.tooltip_text)` |
|
||||
| `bal/gui/qt/widgets.py:1320` | `_(str(title))` |
|
||||
| `bal/gui/qt/window.py:290` | `_(title)` |
|
||||
| `bal/gui/qt/window.py:942` | `_(message)` |
|
||||
| `bal/gui/qt/window.py:2217` | `_(self.DOWNLOAD_FAILED_TOR_MESSAGE)` |
|
||||
| `bal/gui/qt/window.py:2219` | `_(self.DOWNLOAD_FAILED_MESSAGE)` |
|
||||
| `bal/gui/qt/window.py:2253` | `_(self.DOWNLOAD_FAILED_TOR_MESSAGE)` |
|
||||
| `bal/gui/qt/window.py:2255` | `_(self.DOWNLOAD_FAILED_MESSAGE)` |
|
||||
|
||||
#### C. Concatenations around `_()` (14)
|
||||
|
||||
| Location | Expression |
|
||||
|---|---|
|
||||
| `bal/core/will.py:1344` | `'.' + (('NOT ' if not value else '') + _(self.STATUS[status][0]))` |
|
||||
| `bal/gui/qt/dialogs.py:1010` | `_('Wallet balance is too low: {} satoshi available, but the miner and…` |
|
||||
| `bal/gui/qt/dialogs.py:2206` | `messages[reason] + '\n\n' + _('Skipped')` |
|
||||
| `bal/gui/qt/dialogs.py:2209` | `_("Could not build the will, and the exact cause could not be determi…` |
|
||||
| `bal/gui/qt/dialogs.py:2419` | `_('Expiration date: ') + str(BalTimestamp(self.threshold))` |
|
||||
| `bal/gui/qt/dialogs.py:2425` | `_('Valid Txs:') + str(len(Will.only_valid_list(self.will)))` |
|
||||
| `bal/gui/qt/dialogs.py:2427` | `_('Total Txs:') + str(len(self.will))` |
|
||||
| `bal/gui/qt/lists.py:679` | `' ' + _('Build Your Will')` |
|
||||
| `bal/gui/qt/plugin.py:301` | `'Bal ' + _('Bitcoin After Life')` |
|
||||
| `bal/gui/qt/plugin.py:1034` | `'<b>' + _('Support:') + '</b>'` |
|
||||
| `bal/gui/qt/plugin.py:1086` | `_('BAL - ') + _(title)` |
|
||||
| `bal/gui/qt/widgets.py:1320` | `'<b>' + _(str(title)) + f':</b>\t{str(value)}'` |
|
||||
| `bal/gui/qt/window.py:1022` | `_('Electrum was unable to deserialize the transaction:') + '\n' + str…` |
|
||||
| `bal/gui/qt/window.py:1091` | `msg + _(f'signing: {tosign}')` |
|
||||
|
||||
#### D. `_()` evaluated at import time (class body) (18)
|
||||
|
||||
| Location | Scope | Call |
|
||||
|---|---|---|
|
||||
| `bal/gui/qt/dialogs.py:419` | class body | `_('Bitcoin After Life Will-Executors')` |
|
||||
| `bal/gui/qt/dialogs.py:420` | class body | `_('Choose willexecutors download method')` |
|
||||
| `bal/gui/qt/dialogs.py:528` | class body | `_('Configure and select your willexecutors')` |
|
||||
| `bal/gui/qt/dialogs.py:548` | class body | `_('')` |
|
||||
| `bal/gui/qt/lists.py:131` | class body | `_('Name')` |
|
||||
| `bal/gui/qt/lists.py:132` | class body | `_('Address')` |
|
||||
| `bal/gui/qt/lists.py:133` | class body | `_('Amount')` |
|
||||
| `bal/gui/qt/lists.py:340` | class body | `_('Locktime')` |
|
||||
| `bal/gui/qt/lists.py:341` | class body | `_('Txid')` |
|
||||
| `bal/gui/qt/lists.py:342` | class body | `_('Will-Executor')` |
|
||||
| `bal/gui/qt/lists.py:343` | class body | `_('Status')` |
|
||||
| `bal/gui/qt/lists.py:344` | class body | `_('Server')` |
|
||||
| `bal/gui/qt/lists.py:907` | class body | `_('')` |
|
||||
| `bal/gui/qt/lists.py:908` | class body | `_('Url')` |
|
||||
| `bal/gui/qt/lists.py:909` | class body | `_('S')` |
|
||||
| `bal/gui/qt/lists.py:910` | class body | `_('Base fee')` |
|
||||
| `bal/gui/qt/lists.py:911` | class body | `_('Info')` |
|
||||
| `bal/gui/qt/lists.py:912` | class body | `_('Default Address')` |
|
||||
|
||||
#### E. `_("")` (2)
|
||||
|
||||
| Location | Call |
|
||||
|---|---|
|
||||
| `bal/gui/qt/dialogs.py:548` | `_('')` |
|
||||
| `bal/gui/qt/lists.py:907` | `_('')` |
|
||||
|
||||
#### F. Settings help texts never passed to `_()` (12)
|
||||
|
||||
| Location | Widget | Text |
|
||||
|---|---|---|
|
||||
| `bal/gui/qt/plugin.py:715` | HelpButton | When checking, automatically sign and broadcast the will transactions… |
|
||||
| `bal/gui/qt/plugin.py:778` | HelpButton | `How many reminder alarms the exported calendar (.ics) event contains.…` |
|
||||
| `bal/gui/qt/plugin.py:789` | HelpButton | `Default message to be used in event summary\nVariables:\n $wallet_na…` |
|
||||
| `bal/gui/qt/plugin.py:803` | HelpButton | `Default message to be used in event description\nVariables:\n $walle…` |
|
||||
| `bal/gui/qt/plugin.py:818` | HelpButton | URL of the server that provides the will-executor list. Only availab… |
|
||||
| `bal/gui/qt/plugin.py:829` | HelpButton | `Command used to open .ics calendar files.\nLeave empty to use the sys…` |
|
||||
| `bal/gui/qt/plugin.py:844` | HelpButton | `After each check, save the valid will transactions into the wallet's …` |
|
||||
| `bal/gui/qt/plugin.py:858` | HelpButton | `Label applied to the will transactions saved into the wallet's local …` |
|
||||
| `bal/gui/qt/plugin.py:880` | HelpButton | Broadcast all transactions to willexecutors including those already p… |
|
||||
| `bal/gui/qt/plugin.py:891` | HelpButton | `Run the 'Build your will' wizard every time the wallet is closed or E…` |
|
||||
| `bal/gui/qt/plugin.py:909` | HelpButton | When a new transaction arrives for the wallet, automatically rebuild … |
|
||||
| `bal/gui/qt/plugin.py:933` | HelpButton | `Payload size of a single QR code when exporting a will via QR.\n\nLar…` |
|
||||
|
||||
#### G. `add_widget()` calls (label translated at runtime, help text never) (5)
|
||||
|
||||
| Location | Label | Help text |
|
||||
|---|---|---|
|
||||
| `bal/gui/qt/plugin.py:697` | Hide Replaced | Hide replaced transactions from will detail and l… |
|
||||
| `bal/gui/qt/plugin.py:705` | Hide Invalidated | Hide invalidated transactions from will detail an… |
|
||||
| `bal/gui/qt/plugin.py:727` | Panel editable Date and Fee | When enabled, the delivery-time and check-alive d… |
|
||||
| `bal/gui/qt/plugin.py:742` | `Max Will-Executor Fee (satoshi)` | `Maximum fee (in satoshi) allowed to be paid to a …` |
|
||||
| `bal/gui/qt/plugin.py:757` | User Type | `Choose how much detail the plugin shows.\n\nBASIC…` |
|
||||
|
||||
#### H. Other user-visible text not (correctly) translatable — known cases, not exhaustive
|
||||
|
||||
| Location | Code | Note |
|
||||
|---|---|---|
|
||||
| `bal/gui/qt/dialogs.py:380` | `title = "Bitcoin After Life Heirs"` | Wizard title, shown untranslated at `dialogs.py:325` |
|
||||
| `bal/gui/qt/dialogs.py:527` | `title = "Bitcoin After Life Will-Executors"` | Same |
|
||||
| `bal/gui/qt/dialogs.py:547` | `title = "Bitcoin After Life Will Settings"` | Same |
|
||||
| `bal/gui/qt/lists.py:1472` | `setText("New Will Executor")` | Not wrapped |
|
||||
| `bal/gui/qt/plugin.py:673` | `QPushButton("Rebroadcast transactions")` | Not wrapped |
|
||||
| `bal/gui/qt/widgets.py:1323` | `qlabel("Locktime", …)` | Translated inside `qlabel()` but not extractable |
|
||||
| `bal/gui/qt/widgets.py:1324` | `qlabel("Creation Time", …)` | Same |
|
||||
| `bal/gui/qt/widgets.py:1334` | `qlabel("Transaction fees:", …)` | Same; `qlabel()` adds another `:` |
|
||||
| `bal/gui/qt/widgets.py:1336` | `qlabel("Status:", …)` | Same; also shows the stored status (see 2.8) |
|
||||
| `bal/gui/qt/widgets.py:1339` | `QLabel("<b>Heirs:</b>")` | Not wrapped |
|
||||
| `bal/gui/qt/widgets.py:1366` | `_("<b>Willexecutor:</b:")` | Malformed HTML inside the string |
|
||||
| `bal/gui/qt/window.py:1613` | `msg = "Broadcasting Transactions to Will-Executors:\n"` | Not wrapped |
|
||||
|
||||
Not to translate: `widgets.py:122` (icon glyph), `plugin.py:1020` (link to bitcoin-after.life).
|
||||
|
||||
#### Numbers (`main` @ `4a33116`)
|
||||
|
||||
| Measure | Value |
|
||||
|---|---|
|
||||
| `_()` calls | 483 in 11 files |
|
||||
| Distinct strings, whole package (xgettext) | 365 (≈ 2,230 words) |
|
||||
| …of which Electrum `it_IT` already translates | 39 (≈ 11 %) |
|
||||
| Strings used only by the CLI | 36 (≈ 315 words), out of scope |
|
||||
| Babel extraction today, GUI + core | 323 (≈ 1,900 words) |
|
||||
| Expected after Phase 2 | ≈ 360 (≈ 2,500 words) |
|
||||
|
||||
## Sources
|
||||
|
||||
- Electrum 4.8.0: `electrum/i18n.py`, `run_electrum` (L433–451), `electrum/gui/default_lang.py`, `electrum/plugin.py` (`maybe_load_plugin_init_method`, `read_file`), `contrib/locale/` — https://github.com/spesmilo/electrum/tree/4.8.0
|
||||
- `electrum-locale` at the 4.8.0 submodule commit `3d7594c`: `update.py`, `llm_proofreader/` — https://github.com/spesmilo/electrum-locale
|
||||
- Gitea "Update branch" button on pull requests — https://github.com/go-gitea/gitea/pull/9784
|
||||
- Weblate: Gitea and gettext PO support — https://docs.weblate.org/en/latest/vcs.html
|
||||
499
PLAN_QR_TRANSFER.md
Normal file
499
PLAN_QR_TRANSFER.md
Normal file
@@ -0,0 +1,499 @@
|
||||
# PLAN — Will transfer via QR codes / audio modem (Qt now, QML planned)
|
||||
|
||||
> Goal: let the user move an inheritance ("will") between devices over two
|
||||
> air-gap channels:
|
||||
>
|
||||
> 1. **QR codes** (primary): export the **valid** inheritance transactions as
|
||||
> a sequence of QR codes, and import them back on another machine with the
|
||||
> camera;
|
||||
> 2. **Audio modem** (secondary, when Electrum's `audio_modem` plugin is
|
||||
> enabled): send/receive the same payload through the PC speaker +
|
||||
> microphone.
|
||||
>
|
||||
> Both channels converge on the same review-and-sign flow afterwards.
|
||||
>
|
||||
> Status: APPROVED by owner (2026-08-25). No code written yet — this document
|
||||
> is the implementation contract. Work top-down through §9 Checklist.
|
||||
>
|
||||
> Chat language: Italian; this document is in English (global rule R1).
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- **Desktop PyQt6 GUI** (primary, implemented by this plan):
|
||||
- New plugin setting: QR chunk size, offered as **4 standard presets**.
|
||||
- *"Export via QR"* action: serializes the **valid** will transactions,
|
||||
optionally compresses, splits the resulting string into fixed-size frames,
|
||||
shows one QR at a time with prev/next navigation, live re-chunking and
|
||||
progress (`i di N`).
|
||||
- *"Import via QR"* action: camera capture dialog with a slot grid
|
||||
(1..N); the user selects which shot he is about to capture, scans, the
|
||||
frame lands in its slot; when 1..N are filled the payload is assembled,
|
||||
parsed into `WillItem`s, validity-checked locally.
|
||||
- **Post-capture flow (owner decision D6, amended)**: after capture
|
||||
completes there is NO read-only preview. Instead a review-and-sign wizard
|
||||
walks through every transaction one at a time showing **outputs
|
||||
(address + amount), total outputs, total fees**, signs it (wallet
|
||||
password asked once), and at the end **proposes exporting the signed
|
||||
transactions** (to file and/or back via QR).
|
||||
- **Audio-modem channel** (owner decision D7): when the Electrum
|
||||
`audio_modem` plugin is enabled and available, the export dialog gains a
|
||||
*"Send via Audio Modem…"* button and the import dialog a *"Receive via
|
||||
Audio Modem…"* button, reusing the same transfer string (no QR framing).
|
||||
- **QML**: document-only update to `QML_PLAN.md` adding dedicated view specs
|
||||
(owner decision D1). No QML code in this feature.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Implementing the QML frontend (gated behind `QML_PLAN.md` Phases 0–2).
|
||||
- Merging imported wills into the live wallet state (existing Merge flows
|
||||
stay unchanged).
|
||||
- Broadcast of the reviewed transactions (user exports them; broadcasting
|
||||
remains an explicit action elsewhere).
|
||||
- CLI/cmdline parity for QR transfer.
|
||||
|
||||
---
|
||||
|
||||
## 2. Owner decisions (locked)
|
||||
|
||||
| # | Decision |
|
||||
|---|----------|
|
||||
| D1 | QML part = update `QML_PLAN.md` document only; implementation later. |
|
||||
| D2 | Frames carry a small **compact** ASCII header (`BAL1<total><index><flag>`, fixed 11 chars, base36 count fields) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. Legacy `BALQR1\|N\|i\|flags\|` export removed; legacy import kept. |
|
||||
| D3 | Payload = **serialized transaction strings only** (`str(wi.tx)`), NOT the JSON will dump. Loses statuses/metadata on purpose; import rebuilds items like `merge_single_transaction` does. |
|
||||
| D4 | Compression (zlib+base64 over the whole payload) exported as **best-of**: `encode_transfer_best` ships compressed when it is shorter, plain otherwise; flag `0` = plain, `Z` = compressed. No user-facing checkbox. |
|
||||
| D5 | 4 standard size presets: **~150 / ~400 / ~900 / ~1800 bytes** of payload per QR (low-res cams → high-res cams). Error-correction level fixed **M**. Stored as plugin config default; selectable again inside the export dialog. |
|
||||
| D6 | After capture completes: **review + sign each tx one at a time** (show outputs, total outputs, total fees), then **propose export of the signed txs** (file and/or QR). Supersedes the earlier "WillDetailDialog preview" answer. |
|
||||
| D7 | Add an **audio-modem transfer path** gated on Electrum's `audio_modem` plugin being enabled and available (`amodem` importable). Same payload semantics as QR (transfer string of serialized txs), but NO BAL frame chunking — `amodem` handles transport framing internally. Buttons simply hidden when the plugin is absent/unavailable (info message pointing at `pip install amodem` when enabled-but-broken); graceful degradation, never a hard dependency. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Verified facts (research done on the local checkouts)
|
||||
|
||||
All verified by reading source; references are `file:line`.
|
||||
|
||||
| # | Fact | Where |
|
||||
|---|------|-------|
|
||||
| F1 | Export today: `BalWindow.export_will()` writes `{wid: WillItem.to_dict()}` JSON; subsets All/Valid/Valid-NC built in `WillList` | `bal/gui/qt/window.py:1605-1621`, `lists.py:666-669, 743-767` |
|
||||
| F2 | Batch signer: `BalWindow.sign_transactions(password, will, txids)` loops valid txs, resolves input values from change prevouts (`txin._trusted_value_sats` …), calls `wallet.sign_transaction`, updates `COMPLETE` + signature counts | `window.py:1017-1086` |
|
||||
| F3 | External-will signing already supported (`will=` param, nothing saved to live wallet/history) | `window.py:1443-1484` |
|
||||
| F4 | Single-tx import precedent: `merge_single_transaction` wraps `WillItem({"tx": str(tx)}, _id=tx.txid(), wallet=...)` | `window.py:1715-1724` |
|
||||
| F5 | Local validity recomputation recipe (no network): `add_willtree` → `Util.get_available_utxos` → `check_invalidated` → `search_rai` → `check_signatures` | `window.py:1678-1701` |
|
||||
| F6 | Plugin config accessor pattern `BalConfig`; keys declared in ctor | `bal/core/plugin_base.py:130-154, 211-264` |
|
||||
| F7 | Plugin settings dialog: grid rows 0..12, `add_widget(grid,label,widget,row,help)` + `_make_reset_btn(cfgvar,widget,kind)`; ADVANCED-only rows wrapped with `_hide_if_basic(...)` | `bal/gui/qt/plugin.py:442-850` (rows at 677-850) |
|
||||
| F8 | `BalDialog(parent, bal_plugin, title=None, icon=...)` base class anchors to top-level window | `bal/gui/qt/dialogs.py:92-129` |
|
||||
| F9 | Fee display precedent: `fee = tx.input_value() - tx.output_value()`, fee rate = `fee / tx.estimated_size()` | `bal/gui/qt/widgets.py:1319-1328` |
|
||||
| F10 | Input-value resolution helper exists: `Will.add_info_from_will(will, wid, wallet)` sets trusted input values from sibling will change outputs | `bal/core/will.py:118-136` |
|
||||
| F11 | `str(tx)` = `tx.serialize()`: raw hex for complete txs; `PartialTransaction.serialize()` → base64 PSBT. Both accepted by `tx_from_any` (= `Will.get_tx_from_any`). This is exactly how BAL persists/reloads txs today | `electrum/transaction.py:907, 2539`; `will.py:106-113`; `window.py:1041-1044` |
|
||||
| F12 | `QRCodeWidget` exists but **hardcodes `ERROR_CORRECT_L`** → cannot satisfy D5/M; must render our own `qrcode` instance | `electrum/gui/qt/qrcodewidget.py:35-37` |
|
||||
| F13 | Camera scanning one-shot API with OS-permission handling: `scan_qrcode_from_camera(*, parent, config, callback(success: bool, error: str, data: Optional[str]))`; on Linux uses zbar CLI backend | `electrum/gui/qt/qrreader/__init__.py:47-64` |
|
||||
| F14 | QR painting without PIL: `draw_qr(qr, paint_device, ...)` from `electrum.gui.common_qt.util` (what `QRCodeWidget.paintEvent` uses) | `electrum/gui/qt/qrcodewidget.py:63-72` |
|
||||
| F15 | QR capacity sanity (byte mode, EC **M**): v40-M ≈ 2331 B ≥ 1800 ✓; v10-M ≈ 213 B ≥ 150 ✓; the `qrcode` lib auto-picks the version | `qrcode` lib |
|
||||
| F16 | `build_zip.py` walks the tree with `os.walk` → new `.py` files ship automatically | `build_zip.py:39-47` |
|
||||
| F17 | QML fork already has `QRImage.qml`, `QRScan.qml`, `ScanDialog.qml`; ScanDialog carries upstream comment "currently not used on android … qt6 camera support stops crashing" | `electrum/gui/qml/components/ScanDialog.qml:8-9` |
|
||||
| F18 | `QML_PLAN.md` currently defers chunked multi-QR streams (Phase 3 note + risk R6) — this feature supersedes that deferral | `QML_PLAN.md:228-231, 304` |
|
||||
| F19 | House test conventions: `def test_*` + `if __name__ == "__main__"` + `sys.path.insert(0, ..pardir)`; run standalone or via pytest | `tests/test_core_heirs.py:1-24` |
|
||||
| F20 | Fork ships an `audio_modem` plugin. `_send(parent, blob)` zlib-compresses an **ASCII** blob, plays it via speaker through `amodem` inside a `WaitingDialog`; bit-rate selectable in the plugin's own settings (default = `amodem.config.slowest()`) | `electrum/plugins/audio_modem/qt.py:96-110` |
|
||||
| F21 | `_recv(parent)` records from mic and delivers the decompressed ASCII text by calling `parent.setText(blob)` — the only integration contract is "an object with `setText(str)`"; there is no callback API | `audio_modem/qt.py:112-127` |
|
||||
| F22 | Plugin lookup for enabled plugins: `window.plugins.get(name)` → instance or `None` (`Plugins.get`, electrum/plugin.py:575-576); availability check is the plugin's own `is_available()` (imports `amodem`). **`amodem` is NOT installed in the runtime env today** → optional dependency (pip `amodem` + libportaudio); feature must degrade gracefully | `electrum/plugin.py:575`, runtime-env check |
|
||||
| F23 | `amodem.main.send/recv` stream the whole blob with their own framing/training → BAL must NOT apply QR frame chunking on this channel; and since `_send` compresses internally, BAL sends the **plain** transfer string to avoid double compression | consequence of F20/F21 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Wire format specification
|
||||
|
||||
### 4.1 Transfer string
|
||||
|
||||
```
|
||||
transfer_string = "\n".join( tx_str(tx) for tx in valid_txs_sorted_by_txid )
|
||||
```
|
||||
|
||||
- `tx_str(tx)` = `str(tx)` (F11): hex for complete txs, base64-PSBT for
|
||||
partially-signed ones. Neither alphabet contains `\n` or `|`, so both are
|
||||
safe delimiters.
|
||||
- Ordering: ascending `tx.txid()` → deterministic output for identical input.
|
||||
- If compression enabled (D4):
|
||||
`transfer_string = base64_ascii( zlib_compress( transfer_string ) )`.
|
||||
|
||||
### 4.2 Frame layout (one frame = content of ONE QR code)
|
||||
|
||||
Wire format v2 (compact, current export):
|
||||
|
||||
```
|
||||
BAL1<TTT><iii><F><payload>
|
||||
```
|
||||
|
||||
- Magic+version literal `BAL1` (reject anything else with a clear message).
|
||||
- `<TTT>` = `<iii>` — **base36** zero-padded 3-char strings (`000`…`ZZZ`),
|
||||
representing total N and index i, `1 ≤ i ≤ N ≤ 46655`. Fixed width means a
|
||||
3-digit count field costs the same for a 1-frame or a 46655-frame transfer.
|
||||
- `<F>`: single flag char — `0` ⇒ plain, `Z` ⇒ zlib+base64 compressed.
|
||||
- `<payload>`: the i-th slice of `transfer_string`, exactly
|
||||
`chunk_size` bytes each (last slice may be shorter). No separators: both
|
||||
base36 count fields are fixed-width, so the header is unambiguously 11
|
||||
chars and the payload starts at offset 11.
|
||||
- Header overhead is a constant **11 bytes** → effective payload =
|
||||
`chunk_size − 11`; the chunker slices the transfer string so that
|
||||
**header+payload ≤ preset size**.
|
||||
|
||||
Legacy frames `BALQR1|<total>|<index>|<flags>|<payload>` (variable-width
|
||||
decimal header, pipe-separated) are still **imported** by `parse_frame`
|
||||
(`_parse_v1`), so old exports keep working; the exporter emits v2 only.
|
||||
That is the one deliberate compatibility break: **v2 frames are not readable
|
||||
by builds older than this change.**
|
||||
|
||||
### 4.3 Size presets (D5)
|
||||
|
||||
| Preset label | Payload budget (bytes/frame) | Typical QR version @EC-M |
|
||||
|--------------|------------------------------|--------------------------|
|
||||
| Small (low-res cameras) | 150 | ~v10 |
|
||||
| Medium | 400 | ~v15 |
|
||||
| Large | 900 | ~v22 |
|
||||
| XL (high-res cameras) | 1800 | ~v40 |
|
||||
|
||||
EC level fixed **M** for scan reliability (D5). Presets live in
|
||||
`bal/core/qrtransfer.py::CHUNK_PRESETS` so core tests can cover them.
|
||||
|
||||
### 4.4 Audio-modem channel (D7, F20-F23)
|
||||
|
||||
- Payload = the **plain** `transfer_string` of §4.1 — no BAL frames
|
||||
(`split_frames`/`parse_frame` are QR-only), no BAL compression (the plugin
|
||||
compresses internally; double compression wastes airtime).
|
||||
- The existing core functions `encode_transfer(tx_strings,
|
||||
compress=False)` + `decode_transfer(text, compressed=False)` are reused
|
||||
unchanged; only the transport differs.
|
||||
- Bit-rate is owned by the audio_modem plugin's settings dialog — BAL adds
|
||||
no setting of its own.
|
||||
|
||||
---
|
||||
|
||||
## 5. New core module — `bal/core/qrtransfer.py`
|
||||
|
||||
GUI-free (never imports Qt — house rule). Public API:
|
||||
|
||||
```python
|
||||
MAGIC = "BALQR"
|
||||
VERSION = 1
|
||||
FLAG_COMPRESSED = "Z"
|
||||
CHUNK_PRESETS = [(label_en, budget_bytes), ...] # §4.3 table
|
||||
|
||||
def encode_transfer(tx_strings: list[str], compress: bool = False) -> str
|
||||
"""Join -> optional zlib+base64 -> return transfer_string."""
|
||||
|
||||
def split_frames(transfer_string: str, chunk_size: int) -> list[str]
|
||||
"""Slice into frames 'BAL1<TTT><iii><F>payload' (v2) — header+payload <=
|
||||
chunk_size. Raises QrTransferError over the 46655-frame base36 cap, or
|
||||
ValueError if chunk_size < MIN_CHUNK_SIZE."""
|
||||
|
||||
def encode_transfer_best(tx_strings: list[str]) -> tuple[str, bool]
|
||||
"""-> (transfer_string, compressed); ships the shorter of plain vs
|
||||
zlib+base64 so the export emits the densest frames."""
|
||||
|
||||
def parse_frame(frame: str) -> tuple[int, int, bool, str]
|
||||
"""-> (total, index, compressed, payload); accepts v2 'BAL1…' and legacy
|
||||
'BALQR1|…' (wrapped as _parse_v1/_parse_v2); ValueError on bad magic/
|
||||
version/arity/non-numeric fields."""
|
||||
|
||||
def assemble(frames: dict[int, str]) -> str
|
||||
"""Validate indices form exactly range(1..max_total) (taken from any
|
||||
frame header), concatenate payloads in order, decode flags ->
|
||||
transfer_string. Raises MissingFramesError(indexes) / InconsistentTotalError."""
|
||||
|
||||
def decode_transfer(transfer_string: str, compressed: bool) -> list[str]
|
||||
"""Inverse of encode_transfer -> list of tx strings."""
|
||||
```
|
||||
|
||||
Plus exceptions `QrTransferError(ValueError)`, `MissingFramesError`,
|
||||
`InconsistentTotalError`. All docstrings/comments English; ruff-clean
|
||||
(line-length 88, E501 ignored).
|
||||
|
||||
---
|
||||
|
||||
## 6. Settings (Qt)
|
||||
|
||||
1. `bal/core/plugin_base.py`: after `REBUILD_ON_CLOSE` (~line 264) add
|
||||
|
||||
```python
|
||||
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
|
||||
```
|
||||
|
||||
2. `bal/gui/qt/plugin.py::settings_dialog` (rows end at 12, ~line 844):
|
||||
append row **13** — visible in BASIC and ADVANCED (do NOT wrap with
|
||||
`_hide_if_basic`):
|
||||
|
||||
- Label: `"QR Code Size"`
|
||||
- `QComboBox` fed from `CHUNK_PRESETS`; item text e.g.
|
||||
`"Small — ~150 bytes/QR (low-res cameras)"`; `currentIndexChanged`
|
||||
→ `self.QR_CHUNK_SIZE.set(budget_bytes)`; initial index from
|
||||
`QR_CHUNK_SIZE.get()` (fallback to nearest preset if the stored value
|
||||
was customized).
|
||||
- `HelpButton` text: explains trade-off (small QR = more shots but easier
|
||||
to scan with poor cameras; large QR = fewer shots, needs good camera)
|
||||
and that the size can also be changed inside the export dialog.
|
||||
- Reset button via existing `_make_reset_btn(self.QR_CHUNK_SIZE, combo, ...)`
|
||||
pattern (plugin.py:684).
|
||||
|
||||
---
|
||||
|
||||
## 7. Qt export flow
|
||||
|
||||
### 7.1 Entry point
|
||||
|
||||
`bal/gui/qt/lists.py::WillList.create_toolbar` (menu block lines 666-670):
|
||||
|
||||
```python
|
||||
export_menu.addAction(_("Via QR…"), self.export_will_valid_qr)
|
||||
```
|
||||
|
||||
New `WillList.export_will_valid_qr()` mirrors `export_will_valid`
|
||||
(lists.py:743-754): builds `{wid: wi}` subset of `VALID` items, empty →
|
||||
`show_message(_("No valid will item to export"))`, else
|
||||
`self.bal_window.export_will_via_qr(will=subset)`.
|
||||
|
||||
### 7.2 `BalWindow.export_will_via_qr(will=None)` (new, `window.py` near
|
||||
`export_will`)
|
||||
|
||||
- Collect `tx_strings = [str(wi.tx) for wid, wi in sorted-by-txid ...]`
|
||||
(F11).
|
||||
- Mark exported items `EXPORTED` (parity with `export_json_file`,
|
||||
window.py:1607-1609) — only when `will` came from the live list.
|
||||
- Open `WillQrExportDialog(self, tx_strings)`.
|
||||
|
||||
Shared helper used by both dialogs:
|
||||
|
||||
```python
|
||||
def get_audio_modem_plugin(self): # on BalWindow
|
||||
"""Return the loaded audio_modem plugin if enabled AND available
|
||||
(amodem importable), else None. Never raises."""
|
||||
p = self.window.plugins.get("audio_modem") # F22
|
||||
return p if p is not None and p.is_available() else None
|
||||
```
|
||||
|
||||
### 7.3 `WillQrExportDialog(BalDialog)` (new class in `dialogs.py`)
|
||||
|
||||
Layout:
|
||||
|
||||
```
|
||||
[Size ▾ Small/Medium/Large/XL] ← compressed best-of automatically (no checkbox)
|
||||
[ QR image ] ← BalQrImage (see below)
|
||||
«i di N» [◀ Prev] [Next ▶]
|
||||
[Save current QR as PNG…] [Send via Audio Modem…] [Close]
|
||||
```
|
||||
|
||||
Behaviour:
|
||||
|
||||
- On any control change: rebuild `split_frames(encode_transfer_best(...))`,
|
||||
reset index to frame 1, refresh counter (owner requirement: "cambiare la
|
||||
risoluzione").
|
||||
- `BalQrImage(QWidget)` ≈ trimmed copy of `QRCodeWidget`
|
||||
(`electrum/gui/qt/qrcodewidget.py:21-72`) but constructing
|
||||
`qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2)` and painting
|
||||
via `electrum.gui.common_qt.util.draw_qr` (F12/F14). ~30 lines.
|
||||
- Prev/Next wrap or disable at ends (disable chosen: clearer).
|
||||
- PNG export optional convenience via existing
|
||||
`getSaveFileName` + `QWidget.grab()` (same trick as
|
||||
`qrcodewidget.py:110`).
|
||||
- **Send via Audio Modem…** (D7): shown only when
|
||||
`bal_window.get_audio_modem_plugin()` returns a usable instance (below);
|
||||
otherwise hidden. Handler: re-encode the payload **plain**
|
||||
(`encode_transfer(tx_strings, compress=False)`) and call the plugin's
|
||||
`_send(parent=self, blob=transfer_string)` — its own WaitingDialog owns
|
||||
progress/cancellation (F20). Tooltip when hidden is unnecessary; instead,
|
||||
if the plugin is enabled but `is_available()` is False, show an info
|
||||
message pointing to `pip install amodem` + portaudio (F22).
|
||||
|
||||
---
|
||||
|
||||
## 8. Qt import flow + review/sign wizard
|
||||
|
||||
### 8.1 Entry point
|
||||
|
||||
`lists.py` toolbar menu, next to Import/Merge (lines 670-671):
|
||||
|
||||
```python
|
||||
menu.addAction(_("Import via QR…"), lambda: self.bal_window.import_will_via_qr())
|
||||
```
|
||||
|
||||
`BalWindow.import_will_via_qr()` opens `WillQrImportDialog(self)`.
|
||||
|
||||
### 8.2 `WillQrImportDialog(BalDialog)`
|
||||
|
||||
State: `self.frames: dict[int, str]`, `self.total: int | None`,
|
||||
`self.target_index: int | None`.
|
||||
|
||||
Layout:
|
||||
|
||||
```
|
||||
«Captured k of N» [Scan ▶] [Reset]
|
||||
[slot grid: push-buttons 1..N; states: empty / filled ✓ / selected-target]
|
||||
hint line («Select a slot, then scan» / «Scan the first QR»)
|
||||
[Receive via Audio Modem…] [Review & Sign ▶] [Close]
|
||||
```
|
||||
|
||||
Behaviour:
|
||||
|
||||
- **Scan** → `scan_qrcode_from_camera(parent=self,
|
||||
config=self.bal_window.window.config, callback=self._on_scan)`
|
||||
(F13). One-shot per press; dialog stays open between shots (simplest,
|
||||
matches Electrum UX; no continuous mode).
|
||||
- `_on_scan(success, error, data)`:
|
||||
- failure → `show_error(error)` (covers missing zbar/camera too);
|
||||
- `parse_frame` errors → `show_warning(_("Not a BAL will QR"))`;
|
||||
- first valid frame adopts `total` and materializes the slot grid;
|
||||
- frame whose `total` ≠ adopted total → warn + offer Reset (user may have
|
||||
restarted the export with another size);
|
||||
- valid → `frames[index] = payload`; auto-advance `target_index` to the
|
||||
lowest missing index; refresh grid + counter.
|
||||
- Clicking an empty slot sets `target_index` (owner requirement: manual
|
||||
shot selection); a filled slot click asks to overwrite.
|
||||
- **Receive via Audio Modem…** (D7): shown only when
|
||||
`bal_window.get_audio_modem_plugin()` returns a usable instance. Handler:
|
||||
build a tiny adapter object exposing `setText(str)` that stores the text
|
||||
and invokes the shared post-receive continuation, then call
|
||||
`plugin._recv(parent=self, ...)`-style flow (F21 contract). On success the
|
||||
received string is treated as the **whole payload**: skip frames/slots
|
||||
entirely → `decode_transfer(text, compressed=False)` → continue at §8.2's
|
||||
item-building step (WillItem construction + validity pass + wizard).
|
||||
Errors from the modem surface through the plugin's own dialog; empty
|
||||
result (user cancelled) is silently ignored.
|
||||
- **Review & Sign** enabled only when `set(frames) == set(range(1, N+1))`:
|
||||
runs `assemble` + `decode_transfer` → `list[str]`; any `QrTransferError`
|
||||
surfaces as `show_error` and keeps the dialog open.
|
||||
- Build items exactly like `merge_single_transaction` (F4):
|
||||
`WillItem({"tx": s}, wallet=self.wallet)` per string; failures per-string
|
||||
are collected and reported at the end (bad string ≠ fatal for the rest).
|
||||
- Local validity pass (F5 recipe) on the resulting dict; items failing
|
||||
`VALID` are dropped and listed in a warning. Set
|
||||
`wi.set_status("IMPORTED", True)` on survivors (mirrors
|
||||
`import_will_into_details`, window.py:1753-1754).
|
||||
- Then `close()` and start the wizard (§8.3) with the valid subset. Empty
|
||||
result → stop with a message.
|
||||
|
||||
### 8.3 `WillTxReviewSignDialog(BalDialog)` — post-capture wizard (D6)
|
||||
|
||||
Constructed with `(bal_window, willitems: dict[str, WillItem])` — the
|
||||
imported subset lives **outside** the live wallet state (external mode,
|
||||
F3).
|
||||
|
||||
Flow:
|
||||
|
||||
1. **Password once**: `password = bal_window.get_wallet_password()`
|
||||
(window.py:1088-1100). Returns `False` on cancel → abort wizard; `None`
|
||||
means unencrypted wallet → proceed without password.
|
||||
2. **Per-transaction page** (one `QStackedWidget` step per tx, ordered by
|
||||
txid like export):
|
||||
|
||||
```
|
||||
Tx 2 of 5 — a1b2…c3d1 (short txid)
|
||||
Locktime: 2033-04-05 Status: unsigned (0/1 sigs)
|
||||
┌ outputs ─────────────────────────────────┐
|
||||
│ bc1q…heir1 0,042 BTC │
|
||||
│ bc1q…willexec fee 0,00012 BTC │
|
||||
│ bc1q…change 0,00988 BTC │
|
||||
└───────────────────────────────────────────┘
|
||||
Total outputs: 0,052 BTC Fees: 420 sat (1.2 sat/vB)
|
||||
[Sign & Next ▶] [Skip] [Cancel all]
|
||||
```
|
||||
- Outputs from `tx.outputs()` (address via `TxOutput.get_ui_address_str()`
|
||||
style helpers already imported in the qt layer; value via
|
||||
`bal_window.window.format_amount`).
|
||||
- Totals: `output_value()` sum; fees via `input_value() - output_value()`
|
||||
after resolving inputs with `Will.add_info_from_will(will, wid, wallet)`
|
||||
(F10); `-1`/unknown handled like widgets.py:1319-1324 (F9).
|
||||
3. **Sign & Next** → sign this single tx through a **refactored helper**
|
||||
extracted from the loop body of `sign_transactions`
|
||||
(window.py:1037-1083 → `_sign_single_tx(tx, willitems, password)` kept
|
||||
byte-equivalent; batch method calls the helper per iteration so existing
|
||||
behaviour/tests are unaffected). Update `COMPLETE`/sig-counts exactly as
|
||||
today; then advance.
|
||||
4. **Skip** leaves the tx untouched and advances. **Cancel all** stops; the
|
||||
already-signed txs remain in the wizard's local dict (still exportable —
|
||||
confirmation dialog warns about skipped ones).
|
||||
5. **Summary page**: `signed X of Y`, skipped/failed lists, then:
|
||||
|
||||
```
|
||||
[Save signed file…] [Show QR…] [Close]
|
||||
```
|
||||
- *Save file* = existing JSON path: `export_meta_gui(window,
|
||||
"will.json", writer)` writing `{wid: wi.to_dict()}` of the signed
|
||||
subset (same serializer as `export_json_file`, window.py:1605).
|
||||
- *Show QR* = `WillQrExportDialog` over `[str(wi.tx)]` of the signed
|
||||
subset (the online machine can scan them straight into Merge).
|
||||
- Nothing touches `self.willitems`/history (external-mode rule, F3).
|
||||
|
||||
---
|
||||
|
||||
## 9. Checklist (execution order — tick here when resuming work)
|
||||
|
||||
- [x] **P0** `bal/core/qrtransfer.py` + unit tests `tests/test_core_qr_transfer.py`
|
||||
(cases: round-trip plain/compressed; boundaries: len%size==0, size>len,
|
||||
min-size guard; bad magic/version; missing middle frame; duplicate
|
||||
overwrite; inconsistent totals; multi-PSBT mixes; presets sanity vs
|
||||
qrcode capacities F15). Run:
|
||||
`QT_QPA_PLATFORM=offscreen python3 tests/test_core_qr_transfer.py`
|
||||
- [x] **P1** Settings: `QR_CHUNK_SIZE` config var + settings-dialog row 16
|
||||
(ø16) + reset kind (§6). Verify in `QT_QPA_PLATFORM=offscreen` GUI run.
|
||||
- [x] **P2** Export: `BalWindow.export_will_via_qr`, `get_audio_modem_plugin`
|
||||
helper, `WillList` menu action, `WillQrExportDialog` + `BalQrImage`,
|
||||
audio-modem send button (§7).
|
||||
- [x] **P3** Import: `import_will_via_qr`, `WillQrImportDialog` (§8.2),
|
||||
incl. camera error paths, audio-modem receive button (local mirror of
|
||||
`_recv`, `setText` sink replaced by a callback), plain-payload fast
|
||||
path into the wizard.
|
||||
- [x] **P4** Wizard: `_prepare_and_sign_tx` refactor + `WillTxReviewSignDialog`
|
||||
(§8.3). Regression-gate: full batch sign still green
|
||||
(`tests/test_core_*.py` offline batch; `tests/test_gui_*.py` batch
|
||||
including new `tests/test_gui_qr_transfer.py`).
|
||||
- [x] **P5** Docs & QML plan sync: update `QML_PLAN.md` — Phase 2 models +=
|
||||
`BalQrTransferModel` (thin QObject over `bal.core.qrtransfer`),
|
||||
Phase 3 += dedicated views `BalQrExportPage.qml` /
|
||||
`BalQrImportPage.qml` (slot grid + `QRScan` reuse), delete the
|
||||
"chunked streams deferred" note, rewrite R6 mitigation, add Android
|
||||
caveat quoting F17 with file/paste fallback; README/HANDOFF sections;
|
||||
CHANGELOG numbered entry 56 at END (house rule).
|
||||
- [x] **P6** Release hygiene: `python3 build_zip.py` +
|
||||
`QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py
|
||||
electrum.plugins.bal` + external-zip test; ruff (repo venv
|
||||
`venv/bin/ruff`) no NEW violations; pyright false-positive policy per
|
||||
AGENTS.md. Version bump only via `make-release.sh` (owner-driven).
|
||||
Docs: document audio-modem as OPTIONAL channel — requires the
|
||||
Electrum `audio_modem` plugin enabled plus `pip install amodem`
|
||||
and libportaudio (not installed in the dev runtime env today, F22);
|
||||
manual test matrix gains an audiomodem round-trip row (two machines,
|
||||
default slowest bitrate) marked optional/skippable when hardware
|
||||
unavailable.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| High frame counts annoy users (e.g. 40+ QR at 150 B) | Presets span 150→1800; compress option; counter always visible |
|
||||
| Big QR versions fail on cheap cameras | EC=M fixed; Small preset targets low-res cams (D5 rationale) |
|
||||
| User rescans old export with different total | `InconsistentTotalError` → clear warning + Reset (§8.2) |
|
||||
| `_sign_single_tx` refactor regresses batch signing | Byte-equivalent extraction; batch callers unchanged; offline core tests gate P4 |
|
||||
| Imported txs reference UTXOs the importing wallet doesn't know | Validity pass drops them with an explicit report instead of silently merging garbage |
|
||||
| zbar/camera unavailable (esp. Windows/macOS packaging) | `scan_qrcode_from_camera` error path → suggest file export/import fallback |
|
||||
| `amodem`/portaudio not installed (current dev env state, F22) or audio_modem plugin disabled | Buttons simply hidden; QR/file remain the primary channels; P6 documents the optional dependency |
|
||||
| Audio transfer fails mid-way (noise, wrong volume) | Plugin's WaitingDialog surfaces the error; user retries — nothing to clean up on BAL side (single atomic blob, no slot state touched) |
|
||||
| Very slow airtime at default slowest bitrate | Bitrate is selectable in the audio_modem plugin's own settings (F20); BAL adds no knob; tooltip in export dialog hints at large payloads |
|
||||
| Qt6 camera instability on Android (future QML work) | Recorded as caveat in QML_PLAN update (P5), file/paste stays the primary mobile fallback |
|
||||
|
||||
---
|
||||
|
||||
## 11. Findings log (append-only)
|
||||
|
||||
- 2026-08-25: plan drafted after code exploration; owner answered D1-D6
|
||||
(D6 amended live from "preview dialog" to "review+sign wizard").
|
||||
- Verified F12 (QRCodeWidget hardcodes EC-L) and F11 (str(tx) round-trip
|
||||
guarantees) — both shaped §§4/7.
|
||||
- 2026-08-25: owner requested an audio-modem transfer path → researched
|
||||
`electrum/plugins/audio_modem/qt.py`, added D7 + F20-F23, §4.4, buttons
|
||||
in §§7.3/8.2, checklist/risk updates. Key constraint found: `_recv`'s
|
||||
only contract is `parent.setText(blob)` (F21) → thin adapter object; and
|
||||
BAL must not chunk/compress on this channel (F23). `amodem` is NOT in
|
||||
the runtime env yet — feature is strictly optional.
|
||||
83
README.md
83
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,6 +41,7 @@ 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 README.md
|
||||
build_zip.py builds a clean, zipimport-friendly distribution zip
|
||||
@@ -77,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
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
5
babel.cfg
Normal file
5
babel.cfg
Normal file
@@ -0,0 +1,5 @@
|
||||
# Babel extraction mapping for the BAL gettext catalog (see bal/i18n.py).
|
||||
# The CLI and the wallet_util helpers stay English, like Electrum's CLI.
|
||||
[ignore: bal/cli/**]
|
||||
[ignore: bal/wallet_util/**]
|
||||
[python: bal/**.py]
|
||||
@@ -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
|
||||
@@ -40,3 +47,85 @@ available and adapts, so both releases keep working.
|
||||
# (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
@@ -10,7 +10,7 @@ Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the
|
||||
postpone/invalidate flow; the decision that it *should* be raised lives here.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .plugin_base import BalTimestamp
|
||||
@@ -24,7 +24,7 @@ class CheckAliveError(Exception):
|
||||
|
||||
def __str__(self):
|
||||
return "Check alive expired please update it: {}".format(
|
||||
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
|
||||
datetime.fromtimestamp(self.timestamp_to_check, tz=timezone.utc).isoformat()
|
||||
)
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ def resolve_date_to_check(
|
||||
The reference timestamp (float, UNIX seconds).
|
||||
"""
|
||||
if is_basic_mode:
|
||||
return (now if now is not None else datetime.now().timestamp())
|
||||
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":
|
||||
@@ -96,6 +96,54 @@ def resolve_date_to_check(
|
||||
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:
|
||||
@@ -107,5 +155,5 @@ def check_alive_expired(
|
||||
"""
|
||||
if is_basic_mode:
|
||||
return False
|
||||
current = now if now is not None else datetime.now().timestamp()
|
||||
current = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
|
||||
return date_to_check < current
|
||||
|
||||
@@ -59,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:
|
||||
@@ -321,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):
|
||||
@@ -363,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)
|
||||
@@ -630,6 +608,20 @@ class Heirs(dict, Logger):
|
||||
def buildTransactions(
|
||||
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
|
||||
):
|
||||
# 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]
|
||||
@@ -644,6 +636,7 @@ class Heirs(dict, Logger):
|
||||
", ".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
|
||||
@@ -660,12 +653,18 @@ class Heirs(dict, Logger):
|
||||
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:
|
||||
@@ -682,6 +681,7 @@ class Heirs(dict, Logger):
|
||||
url = willexecutor = None
|
||||
else:
|
||||
break
|
||||
processed_willexecutors += 1
|
||||
fees = {}
|
||||
i = 0
|
||||
txs = {}
|
||||
@@ -699,9 +699,11 @@ class Heirs(dict, Logger):
|
||||
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:
|
||||
@@ -710,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}"
|
||||
)
|
||||
try:
|
||||
if "w!ll3x3c" in e.heirname:
|
||||
Willexecutors.is_selected(
|
||||
e.heirname[len("w!ll3x3c") :], False
|
||||
"build transactions: error preparing transactions "
|
||||
"for will-executor %s: %r",
|
||||
(willexecutor or {}).get("url", "(none)"),
|
||||
e,
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
raise
|
||||
total_fees = 0
|
||||
total_fees_real = 0
|
||||
total_in = 0
|
||||
@@ -746,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(
|
||||
|
||||
@@ -24,12 +24,15 @@ This module performs **no** GUI work and imports nothing from PyQt / electrum.gu
|
||||
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
|
||||
|
||||
from ..i18n import N_, _
|
||||
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
@@ -108,7 +111,9 @@ def get_will(x):
|
||||
|
||||
try:
|
||||
# Electrum >= 4.8.0
|
||||
from electrum.stored_dict import register_name as _electrum_register_name # pyright: ignore[reportMissingImports]
|
||||
from electrum.stored_dict import (
|
||||
register_name as _electrum_register_name, # pyright: ignore[reportMissingImports]
|
||||
)
|
||||
|
||||
def _register_will_dict(name, method, _type=None):
|
||||
"""Register a plugin dict in the wallet DB (Electrum >= 4.8.0 API)."""
|
||||
@@ -131,12 +136,20 @@ class BalConfig:
|
||||
|
||||
Wraps ``config.get`` / ``config.set_key`` and supplies a default value
|
||||
when the key is missing.
|
||||
|
||||
``translatable=True`` is for a default *text* (marked with ``N_()``) that
|
||||
should follow the GUI language. The English default is what gets stored,
|
||||
and :meth:`get` translates it when it is read, so the stored value never
|
||||
depends on the GUI language. A text the user wrote is returned unchanged.
|
||||
Only for texts that are shown: a text that is also used to recognise
|
||||
stored data (e.g. ``HISTORY_LABEL``) must stay non-translatable.
|
||||
"""
|
||||
|
||||
def __init__(self, config, name, default):
|
||||
def __init__(self, config, name, default, translatable=False):
|
||||
self.config = config
|
||||
self.name = name
|
||||
self.default = default
|
||||
self.translatable = translatable
|
||||
|
||||
def get(self, default=None):
|
||||
"""Return the stored value, falling back to ``default`` then ``self.default``."""
|
||||
@@ -146,10 +159,20 @@ class BalConfig:
|
||||
v = default
|
||||
else:
|
||||
v = self.default
|
||||
if self.translatable and v == self.default:
|
||||
return _(v)
|
||||
return v
|
||||
|
||||
def localized_default(self):
|
||||
"""Return the default value, translated if the setting is translatable."""
|
||||
return _(self.default) if self.translatable else self.default
|
||||
|
||||
def set(self, value, save=True):
|
||||
"""Persist ``value`` for this key."""
|
||||
if self.translatable and value == self.localized_default():
|
||||
# The translated default is stored as its English original, so
|
||||
# it keeps following the GUI language (see get()).
|
||||
value = self.default
|
||||
self.config.set_key(self.name, value, save=save)
|
||||
|
||||
|
||||
@@ -169,9 +192,12 @@ 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)
|
||||
@@ -237,6 +263,10 @@ class BalPlugin(BasePlugin):
|
||||
# the wallet's local history. May contain the "{willexecutor}" token,
|
||||
# which is replaced with the will-executor URL of each will item at
|
||||
# save time.
|
||||
# Deliberately NOT translatable: Util._label_matches_history() uses
|
||||
# this text to recognise BAL's own local transactions (stale-history
|
||||
# cleanup, spendable UTXOs). A label that changed with the GUI
|
||||
# language would no longer match the transactions saved before.
|
||||
self.HISTORY_LABEL = BalConfig(
|
||||
config,
|
||||
"bal_history_label",
|
||||
@@ -251,12 +281,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
|
||||
@@ -297,13 +352,20 @@ class BalPlugin(BasePlugin):
|
||||
self.WELIST_SERVER = BalConfig(
|
||||
config, "bal_welist_server", "https://welist.bitcoin-after.life/"
|
||||
)
|
||||
# Calendar (.ics) texts: only shown, never used to recognise data, so
|
||||
# their defaults follow the GUI language (translatable=True). The
|
||||
# $tokens must survive translation.
|
||||
self.EVENT_DESCRIPTION = BalConfig(
|
||||
config,
|
||||
"bal_event_description",
|
||||
"BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete",
|
||||
N_("BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete"),
|
||||
translatable=True,
|
||||
)
|
||||
self.EVENT_SUMMARY = BalConfig(
|
||||
config, "bal_event_summary", "BAL -Will execution of $wallet_name"
|
||||
config,
|
||||
"bal_event_summary",
|
||||
N_("BAL -Will execution of $wallet_name"),
|
||||
translatable=True,
|
||||
)
|
||||
|
||||
# Default will-executor servers, keyed by network. These addresses are
|
||||
@@ -438,8 +500,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()
|
||||
@@ -499,12 +561,12 @@ class BalTimestamp:
|
||||
"""
|
||||
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``.
|
||||
@@ -517,7 +579,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
|
||||
@@ -38,7 +38,7 @@ def compute_reminder_offsets(days, count):
|
||||
count: requested number of reminders.
|
||||
|
||||
Returns:
|
||||
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
|
||||
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).
|
||||
@@ -147,6 +147,7 @@ def build_ics_reminders(
|
||||
num_reminders: int = 3,
|
||||
now: Optional[datetime] = None,
|
||||
threshold: Optional[datetime] = None,
|
||||
reminder_suffix: str = "(reminder {idx}/{total})",
|
||||
) -> Optional[str]:
|
||||
"""Build the ``.ics`` content with one VEVENT per reminder date.
|
||||
|
||||
@@ -169,6 +170,10 @@ def build_ics_reminders(
|
||||
num_reminders: requested reminder count (ADVANCED mode only).
|
||||
now: "today" reference; defaults to ``datetime.now()``.
|
||||
threshold: check-alive date (ADVANCED mode only; required there).
|
||||
reminder_suffix: text appended to each event summary, with the
|
||||
``{idx}`` and ``{total}`` fields. This module stays free of
|
||||
Electrum imports, so the GUI passes it already translated; the
|
||||
English default keeps the CLI output unchanged.
|
||||
|
||||
Returns:
|
||||
The ``.ics`` content string, or ``None`` when no reminder falls in the
|
||||
@@ -210,7 +215,9 @@ def build_ics_reminders(
|
||||
# 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})")
|
||||
event_summary = ical_escape(
|
||||
"{} {}".format(summary_base, reminder_suffix.format(idx=idx, total=total))
|
||||
)
|
||||
lines.extend([
|
||||
"BEGIN:VEVENT",
|
||||
# Offset in the UID keeps each event unique (no merging).
|
||||
|
||||
102
bal/core/util.py
102
bal/core/util.py
@@ -18,11 +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*.
|
||||
@@ -35,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)."""
|
||||
@@ -103,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":
|
||||
@@ -189,7 +227,7 @@ class Util:
|
||||
# moment, so fall back to the legacy forward-from-now resolution.
|
||||
return Util.parse_locktime_string(current)
|
||||
try:
|
||||
base = datetime.fromtimestamp(int(tx_locktime)).replace(
|
||||
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)
|
||||
@@ -440,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()
|
||||
|
||||
@@ -450,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 _, 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)."""
|
||||
@@ -492,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 _, willitem in will.items():
|
||||
utxos += willitem["tx"].inputs()
|
||||
|
||||
return utxos
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# UTXO helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
197
bal/core/will.py
197
bal/core/will.py
@@ -26,10 +26,9 @@ The status flags themselves (the source of truth) stay here; only the mapping
|
||||
"status -> colour" now lives in the GUI layer. No behaviour changed.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from electrum.i18n import _
|
||||
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
||||
from electrum.logging import Logger, get_logger
|
||||
from electrum.transaction import (
|
||||
PartialTransaction,
|
||||
@@ -44,8 +43,9 @@ from electrum.util import (
|
||||
bfh,
|
||||
)
|
||||
|
||||
from ..i18n import N_, _
|
||||
from .heirs import WillExecutorFeeTooHighException
|
||||
from .util import Util
|
||||
from .util import Util, copy_structure
|
||||
from .willexecutors import Willexecutors
|
||||
|
||||
MIN_LOCKTIME = 1
|
||||
@@ -74,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():
|
||||
@@ -107,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:
|
||||
@@ -157,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:
|
||||
@@ -179,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(
|
||||
@@ -479,7 +465,7 @@ class Will:
|
||||
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:
|
||||
@@ -516,6 +502,7 @@ class Will:
|
||||
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):
|
||||
@@ -861,6 +848,48 @@ class Will:
|
||||
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``.
|
||||
@@ -1244,6 +1273,34 @@ class Will:
|
||||
|
||||
|
||||
|
||||
def format_status_history(status: str) -> str:
|
||||
"""Return the saved status history of a will item in the GUI language.
|
||||
|
||||
``WillItem.status`` is a dot-separated history such as
|
||||
``".Signed.Pushed.NOT Valid"``. It is stored in English (see
|
||||
``WillItem.set_status``) and translated only here, for display. A token
|
||||
that is not a known English label is shown unchanged: this keeps working
|
||||
the histories written by older versions, which stored the label already
|
||||
translated (for example ``"Firmato"``).
|
||||
"""
|
||||
labels = {label for label, _flag in WillItem.STATUS_DEFAULT.values()}
|
||||
labels.update(WillItem.LEGACY_STATUS_LABELS)
|
||||
|
||||
def translate(token):
|
||||
# "ERROR!!!" is glued to the end of the history when an item has no
|
||||
# id (see WillItem.__init__): keep it, translate what comes before.
|
||||
suffix = ""
|
||||
if token.endswith("ERROR!!!"):
|
||||
token, suffix = token[: -len("ERROR!!!")], "ERROR!!!"
|
||||
if token in labels:
|
||||
token = _(token)
|
||||
elif token.startswith("NOT ") and token[len("NOT "):] in labels:
|
||||
token = _("NOT {}").format(_(token[len("NOT "):]))
|
||||
return token + suffix
|
||||
|
||||
return ".".join(translate(token) for token in status.split("."))
|
||||
|
||||
|
||||
class WillItem(Logger):
|
||||
# Default status flags for an inheritance transaction.
|
||||
# Each entry maps an internal status key to [human-readable label, default
|
||||
@@ -1257,28 +1314,37 @@ class WillItem(Logger):
|
||||
# * "UPDATED" was added: the transaction was spendable AND valid, and a new
|
||||
# transaction replaces it while keeping the SAME locktime and SAME heirs.
|
||||
# UPDATED keeps the VALID flag (see set_status).
|
||||
#
|
||||
# The labels are marked with N_() but stay English here: they are written
|
||||
# into the saved status history (see set_status) and translated only when
|
||||
# shown, by format_status_history().
|
||||
STATUS_DEFAULT = {
|
||||
"ANTICIPATED": ["Anticipated", False],
|
||||
"BROADCASTED": ["Broadcasted", False],
|
||||
"CHECKED": ["Checked", False],
|
||||
"CHECK_FAIL": ["Check Failed", False],
|
||||
"COMPLETE": ["Signed", False],
|
||||
"CONFIRMED": ["Confirmed", False],
|
||||
"ERROR": ["Error", False],
|
||||
"EXPIRED": ["Expired", False],
|
||||
"EXPORTED": ["Exported", False],
|
||||
"IMPORTED": ["Imported", False],
|
||||
"INVALIDATED": ["Invalidated", False],
|
||||
"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],
|
||||
"VALID": ["Valid", True],
|
||||
"ANTICIPATED": [N_("Anticipated"), False],
|
||||
"BROADCASTED": [N_("Broadcasted"), False],
|
||||
"CHECKED": [N_("Checked"), False],
|
||||
"CHECK_FAIL": [N_("Check Failed"), False],
|
||||
"COMPLETE": [N_("Signed"), False],
|
||||
"CONFIRMED": [N_("Confirmed"), False],
|
||||
"ERROR": [N_("Error"), False],
|
||||
"EXPIRED": [N_("Expired"), False],
|
||||
"EXPORTED": [N_("Exported"), False],
|
||||
"IMPORTED": [N_("Imported"), False],
|
||||
"INVALIDATED": [N_("Invalidated"), False],
|
||||
"MEMPOOL": [N_("Mempool"), False],
|
||||
"PUSH_FAIL": [N_("Push failed"), False],
|
||||
"PUSHED": [N_("Pushed"), False],
|
||||
"PARTIALLY_SIGNED": [N_("Partially Signed"), False],
|
||||
"REPLACED": [N_("Replaced"), False],
|
||||
"RESTORED": [N_("Restored"), False],
|
||||
"UPDATED": [N_("Updated"), False],
|
||||
"VALID": [N_("Valid"), True],
|
||||
}
|
||||
|
||||
# Labels found in histories saved by older versions but no longer
|
||||
# written: "New" opened every history (e.g. tests/samanta7).
|
||||
# format_status_history() still translates them.
|
||||
LEGACY_STATUS_LABELS = (N_("New"),)
|
||||
|
||||
def set_status(self, status, value=True):
|
||||
"""Set a status flag and apply the related side effects.
|
||||
|
||||
@@ -1312,7 +1378,11 @@ class WillItem(Logger):
|
||||
if self.STATUS[status][1] == bool(value):
|
||||
return None
|
||||
|
||||
self.status += "." + (("NOT " if not value else "") + _(self.STATUS[status][0]))
|
||||
# The history is saved in the wallet, in exported files and in QR
|
||||
# transfers, so it is always written in English: the saved data must
|
||||
# not depend on the GUI language (older versions wrote the translated
|
||||
# label). format_status_history() translates it when it is shown.
|
||||
self.status += "." + (("NOT " if not value else "") + self.STATUS[status][0])
|
||||
self.STATUS[status][1] = bool(value)
|
||||
if value:
|
||||
# NOTE: ANTICIPATED and UPDATED are intentionally NOT in this list,
|
||||
@@ -1340,16 +1410,24 @@ class WillItem(Logger):
|
||||
return self.STATUS[status][1]
|
||||
|
||||
def __init__(self, w, _id=None, wallet=None):
|
||||
if isinstance(
|
||||
w,
|
||||
WillItem,
|
||||
):
|
||||
self.__dict__ = w.__dict__.copy()
|
||||
else:
|
||||
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:
|
||||
_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", 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)
|
||||
@@ -1358,7 +1436,7 @@ class WillItem(Logger):
|
||||
self.sigs_have = int(w.get("sigs_have", 0))
|
||||
self.father = w.get("Father", None)
|
||||
self.children = w.get("Children", None)
|
||||
self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
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
|
||||
@@ -1381,6 +1459,27 @@ class WillItem(Logger):
|
||||
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,
|
||||
@@ -1394,6 +1493,8 @@ class WillItem(Logger):
|
||||
"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:
|
||||
|
||||
@@ -22,10 +22,10 @@ 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 ..i18n import _
|
||||
from .plugin_base import BalPlugin, get_version
|
||||
|
||||
# Per-request timeout (seconds) for interactive operations (ping / info /
|
||||
@@ -112,8 +112,6 @@ def is_tor_active():
|
||||
return False
|
||||
|
||||
|
||||
chainname = BalPlugin.chainname
|
||||
|
||||
|
||||
class Willexecutors:
|
||||
|
||||
@@ -146,9 +144,9 @@ 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)
|
||||
@@ -158,7 +156,7 @@ class Willexecutors:
|
||||
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):
|
||||
@@ -172,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")
|
||||
@@ -368,7 +366,7 @@ 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,
|
||||
@@ -408,7 +406,7 @@ 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):
|
||||
@@ -788,7 +786,7 @@ 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",
|
||||
)
|
||||
if not isinstance(willexecutors, dict):
|
||||
_logger.warning(
|
||||
|
||||
@@ -13,12 +13,15 @@ The pure RFC-5545 logic (offsets, escaping, folding, the unified .ics builder,
|
||||
the Qt button and the OS/subprocess glue.
|
||||
"""
|
||||
|
||||
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 *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from .common import _, _logger
|
||||
|
||||
|
||||
class BalCalendarButton(QToolButton):
|
||||
|
||||
@@ -15,7 +15,6 @@ hosts a few GUI helpers that do not deserve a module of their own:
|
||||
(:class:`CheckAliveError` now lives in ``bal.core.checkalive``.)
|
||||
"""
|
||||
|
||||
import copy
|
||||
import enum
|
||||
import os
|
||||
import subprocess
|
||||
@@ -28,6 +27,7 @@ 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.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
|
||||
@@ -42,6 +42,7 @@ from electrum.gui.qt.util import (
|
||||
MessageBoxMixin,
|
||||
OkButton,
|
||||
TaskThread,
|
||||
WaitingDialog,
|
||||
WindowModalDialog,
|
||||
char_width_in_lineedit,
|
||||
getOpenFileName,
|
||||
@@ -51,7 +52,6 @@ from electrum.gui.qt.util import (
|
||||
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
|
||||
@@ -80,6 +80,7 @@ from PyQt6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QAbstractSpinBox,
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDateTimeEdit,
|
||||
@@ -92,6 +93,7 @@ from PyQt6.QtWidgets import (
|
||||
QMenu,
|
||||
QMenuBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
@@ -108,6 +110,7 @@ from ...core.heirs import (
|
||||
HEIR_DUST_AMOUNT,
|
||||
HEIR_REAL_AMOUNT,
|
||||
OP_RETURN_PREFIX,
|
||||
BalanceTooLowException,
|
||||
HeirAmountIsDustException,
|
||||
Heirs,
|
||||
WillExecutorFeeTooHighException,
|
||||
@@ -118,7 +121,7 @@ from ...core.heirs import (
|
||||
|
||||
# --- Core (GUI-free) logic layer ---
|
||||
from ...core.plugin_base import BalPlugin, BalTimestamp
|
||||
from ...core.util import Util
|
||||
from ...core.util import Util, copy_structure
|
||||
from ...core.will import (
|
||||
AmountException,
|
||||
HeirChangeException,
|
||||
@@ -133,6 +136,7 @@ from ...core.will import (
|
||||
WillExpiredException,
|
||||
WillItem,
|
||||
WillPostponedException,
|
||||
format_status_history,
|
||||
)
|
||||
from ...core.willexecutors import ( # noqa: F401
|
||||
Willexecutors,
|
||||
@@ -140,6 +144,9 @@ from ...core.willexecutors import ( # noqa: F401
|
||||
is_tor_active,
|
||||
)
|
||||
|
||||
# BAL's translator: Electrum's catalog first, then BAL's (see bal/i18n.py).
|
||||
from ...i18n import N_, _
|
||||
|
||||
# --- Presentation helpers ---
|
||||
from .theme import (
|
||||
server_status_text,
|
||||
@@ -157,6 +164,17 @@ from .window_utils import (
|
||||
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
# Labels of bal.core.qrtransfer.CHUNK_PRESETS, marked for translation here
|
||||
# because qrtransfer.py must stay free of Electrum imports (the Android reader
|
||||
# ships a copy of it). The combos show them with _(label);
|
||||
# tests/test_i18n.py checks that this list matches CHUNK_PRESETS.
|
||||
QR_PRESET_LABELS = (
|
||||
N_("Small - ~150 bytes/QR (low-res cameras)"),
|
||||
N_("Medium - ~400 bytes/QR"),
|
||||
N_("Large - ~900 bytes/QR"),
|
||||
N_("XL - ~1800 bytes/QR (high-res cameras)"),
|
||||
)
|
||||
|
||||
|
||||
class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal naming)
|
||||
_type = bool
|
||||
@@ -174,13 +192,30 @@ class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal namin
|
||||
|
||||
|
||||
def add_widget(grid, label, widget, row, help_):
|
||||
grid.addWidget(QLabel(_(label)), row, 0)
|
||||
"""Add a ``label | widget | help button`` row to ``grid``.
|
||||
|
||||
``label`` and ``help_`` must already be translated by the caller: a
|
||||
variable passed to _() cannot be extracted into the catalog.
|
||||
"""
|
||||
grid.addWidget(QLabel(label), row, 0)
|
||||
grid.addWidget(widget, row, 1)
|
||||
grid.addWidget(HelpButton(help_), row, 2)
|
||||
|
||||
|
||||
|
||||
|
||||
def translated_headers(headers):
|
||||
"""Return a translated copy of a list's ``headers`` class attribute.
|
||||
|
||||
The column headers are class attributes marked with N_(): a class body
|
||||
runs at import time, before the catalog is loaded, so they are
|
||||
translated here, each time the headers are (re)built.
|
||||
"""
|
||||
return {column: _(text) for column, text in headers.items()}
|
||||
|
||||
|
||||
|
||||
|
||||
def log_error(exec_info, window=None):
|
||||
"""Log an error and optionally show it.
|
||||
|
||||
@@ -218,7 +253,7 @@ def export_meta_gui(electrum_window, title, exporter):
|
||||
filter_ = "All files (*)"
|
||||
filename = getSaveFileName(
|
||||
parent=electrum_window,
|
||||
title=_("Select file to save your {}".format(title)),
|
||||
title=_("Select file to save your {}").format(title),
|
||||
filename="BALplugin_{}_{}_{}".format(
|
||||
BalPlugin.chainname, str(electrum_window.wallet), title
|
||||
),
|
||||
@@ -233,7 +268,7 @@ def export_meta_gui(electrum_window, title, exporter):
|
||||
electrum_window.show_critical(str(e))
|
||||
else:
|
||||
electrum_window.show_message(
|
||||
_("Your {0} were exported to '{1}'".format(title, str(filename)))
|
||||
_("Your {0} were exported to '{1}'").format(title, str(filename))
|
||||
)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,8 +19,62 @@ from typing import TYPE_CHECKING
|
||||
from PyQt6.QtWidgets import QLineEdit as _QLineEdit
|
||||
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
|
||||
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from .common import (
|
||||
N_,
|
||||
OP_RETURN_PREFIX,
|
||||
BalTimestamp,
|
||||
Buttons,
|
||||
CancelButton,
|
||||
HelpButton,
|
||||
MessageBoxMixin,
|
||||
MyTreeView,
|
||||
OkButton,
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QColor,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMenu,
|
||||
QModelIndex,
|
||||
QPersistentModelIndex,
|
||||
QPushButton,
|
||||
QSize,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QStandardItem,
|
||||
QStandardItemModel,
|
||||
Qt,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
TaskThread,
|
||||
Util,
|
||||
Will,
|
||||
Willexecutors,
|
||||
WillItem,
|
||||
_,
|
||||
_logger,
|
||||
char_width_in_lineedit,
|
||||
datetime,
|
||||
enum,
|
||||
export_meta_gui,
|
||||
format_status_history,
|
||||
getOpenFileName,
|
||||
import_meta_gui,
|
||||
is_op_return_address,
|
||||
partial,
|
||||
read_json_file,
|
||||
read_QIcon_from_bytes,
|
||||
server_status_text,
|
||||
server_status_tooltip,
|
||||
signature_suffix,
|
||||
status_color,
|
||||
translated_headers,
|
||||
tx_from_any,
|
||||
write_json_file,
|
||||
)
|
||||
from .dialogs import BalBuildWillDialog, BalDialog
|
||||
from .widgets import BalCheckBox, WillSettingsWidget
|
||||
|
||||
@@ -77,9 +131,9 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
||||
AMOUNT = enum.auto()
|
||||
|
||||
headers = {
|
||||
Columns.NAME: _("Name"),
|
||||
Columns.ADDRESS: _("Address"),
|
||||
Columns.AMOUNT: _("Amount"),
|
||||
Columns.NAME: N_("Name"),
|
||||
Columns.ADDRESS: N_("Address"),
|
||||
Columns.AMOUNT: N_("Amount"),
|
||||
}
|
||||
filter_columns = [Columns.NAME, Columns.ADDRESS]
|
||||
|
||||
@@ -194,7 +248,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
||||
col=self.Columns.NAME, role=self.ROLE_HEIR_KEY
|
||||
)
|
||||
self.model().clear()
|
||||
self.update_headers(self.__class__.headers)
|
||||
self.update_headers(translated_headers(self.__class__.headers))
|
||||
set_current = None
|
||||
for key in sorted(self.bal_window.heirs.keys()):
|
||||
heir = self.bal_window.heirs[key]
|
||||
@@ -286,11 +340,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
SERVER = enum.auto()
|
||||
|
||||
headers = {
|
||||
Columns.LOCKTIME: _("Locktime"),
|
||||
Columns.TXID: _("Txid"),
|
||||
Columns.WILLEXECUTOR: _("Will-Executor"),
|
||||
Columns.STATUS: _("Status"),
|
||||
Columns.SERVER: _("Server"),
|
||||
Columns.LOCKTIME: N_("Locktime"),
|
||||
Columns.TXID: N_("Txid"),
|
||||
Columns.WILLEXECUTOR: N_("Will-Executor"),
|
||||
Columns.STATUS: N_("Status"),
|
||||
Columns.SERVER: N_("Server"),
|
||||
}
|
||||
|
||||
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000
|
||||
@@ -535,7 +589,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
if bal_tx.we:
|
||||
we = bal_tx.we["url"]
|
||||
labels[self.Columns.WILLEXECUTOR] = we
|
||||
status = bal_tx.status + signature_suffix(bal_tx)
|
||||
status = format_status_history(bal_tx.status) + signature_suffix(bal_tx)
|
||||
if len(status) > 53:
|
||||
status = "...{}".format(status[-50:])
|
||||
labels[self.Columns.STATUS] = status
|
||||
@@ -593,7 +647,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
col=self.Columns.TXID, role=self.ROLE_HEIR_KEY
|
||||
)
|
||||
self.model().clear()
|
||||
self.update_headers(self.__class__.headers)
|
||||
self.update_headers(translated_headers(self.__class__.headers))
|
||||
|
||||
set_current = None
|
||||
for txid, bal_tx in self.will.items():
|
||||
@@ -612,11 +666,11 @@ 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_menu = menu.addMenu(_("Export"))
|
||||
export_menu.addAction(_("All"), self.export_will)
|
||||
export_menu.addAction(_("Valid"), self.export_will_valid)
|
||||
export_menu.addAction(_("Valid NC"), self.export_will_valid_incomplete)
|
||||
menu.addAction(_("Import"), self.import_will_into_details)
|
||||
# Export/Import open a single window that offers all transports
|
||||
# (file / QR / audio). The Choose Filter / transport settings live
|
||||
# inside that window.
|
||||
menu.addAction(_("Export"), self.export_will)
|
||||
menu.addAction(_("Import"), self.import_will)
|
||||
menu.addAction(_("Merge"), self.merge_will)
|
||||
menu.addAction(_("Broadcast"), self.broadcast)
|
||||
menu.addAction(_("Check"), self.check)
|
||||
@@ -625,7 +679,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
# The Wizard is the main entry point to create an inheritance, so make
|
||||
# it stand out: show a bold label next to a slightly larger icon (the
|
||||
# plain icon-only button was too easy to overlook).
|
||||
wizard = QPushButton(" " + _("Build Your Will"))
|
||||
wizard = QPushButton(" {}".format(_("Build Your Will")))
|
||||
wizard.setIcon(
|
||||
read_QIcon_from_bytes(
|
||||
self.bal_window.bal_plugin.read_file("icons/wizard.png")
|
||||
@@ -682,38 +736,11 @@ 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 export_will_valid(self):
|
||||
"""Export only the will items that are valid."""
|
||||
subset = {
|
||||
wid: wi
|
||||
for wid, wi in self.will.items()
|
||||
if wi.get_status("VALID")
|
||||
}
|
||||
if not subset:
|
||||
self.show_message(_("No valid will item to export"))
|
||||
return
|
||||
self.bal_window.export_will(will=subset)
|
||||
self.update()
|
||||
|
||||
def export_will_valid_incomplete(self):
|
||||
"""Export only the will items that are valid but not yet fully signed (V-NC)."""
|
||||
subset = {
|
||||
wid: wi
|
||||
for wid, wi in self.will.items()
|
||||
if wi.get_status("VALID") and not wi.get_status("COMPLETE")
|
||||
}
|
||||
if not subset:
|
||||
self.show_message(_("No valid, incomplete will item to export"))
|
||||
return
|
||||
self.bal_window.export_will(will=subset)
|
||||
self.update()
|
||||
def import_will(self):
|
||||
self.bal_window.import_will_dialog()
|
||||
|
||||
def import_will_into_details(self):
|
||||
self.bal_window.import_will_into_details()
|
||||
@@ -880,12 +907,12 @@ class WillExecutorListWidget(MyTreeView):
|
||||
ADDRESS = enum.auto()
|
||||
|
||||
headers = {
|
||||
Columns.SELECTED: _(""),
|
||||
Columns.URL: _("Url"),
|
||||
Columns.STATUS: _("S"),
|
||||
Columns.BASE_FEE: _("Base fee"),
|
||||
Columns.INFO: _("Info"),
|
||||
Columns.ADDRESS: _("Default Address"),
|
||||
Columns.SELECTED: "",
|
||||
Columns.URL: N_("Url"),
|
||||
Columns.STATUS: N_("S"),
|
||||
Columns.BASE_FEE: N_("Base fee"),
|
||||
Columns.INFO: N_("Info"),
|
||||
Columns.ADDRESS: N_("Default Address"),
|
||||
}
|
||||
|
||||
filter_columns = [Columns.URL]
|
||||
@@ -1067,7 +1094,7 @@ class WillExecutorListWidget(MyTreeView):
|
||||
col=self.Columns.URL, role=self.ROLE_HEIR_KEY
|
||||
)
|
||||
self.model().clear()
|
||||
self.update_headers(self.__class__.headers)
|
||||
self.update_headers(translated_headers(self.__class__.headers))
|
||||
|
||||
set_current = None
|
||||
|
||||
@@ -1212,13 +1239,14 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
||||
buttonbox.addWidget(b)
|
||||
|
||||
def _menu_button(label):
|
||||
# ``label`` must already be translated by the caller.
|
||||
btn = QToolButton()
|
||||
btn.setText(_(label))
|
||||
btn.setText(label)
|
||||
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||
buttonbox.addWidget(btn)
|
||||
return btn
|
||||
|
||||
export_btn = _menu_button("Export")
|
||||
export_btn = _menu_button(_("Export"))
|
||||
export_menu = QMenu(export_btn)
|
||||
export_menu.addAction(_("Export all"), lambda: self.export_file())
|
||||
export_menu.addAction(
|
||||
@@ -1229,7 +1257,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
||||
)
|
||||
export_btn.setMenu(export_menu)
|
||||
|
||||
ping_btn = _menu_button("Ping All")
|
||||
ping_btn = _menu_button(_("Ping All"))
|
||||
ping_menu = QMenu(ping_btn)
|
||||
ping_menu.addAction(_("Ping all"), lambda: self.update_willexecutors())
|
||||
ping_menu.addAction(
|
||||
@@ -1237,7 +1265,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
||||
)
|
||||
ping_btn.setMenu(ping_menu)
|
||||
|
||||
select_btn = _menu_button("Select All")
|
||||
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(
|
||||
@@ -1271,7 +1299,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
||||
|
||||
url_edit = QLineEdit()
|
||||
url_edit.setFixedWidth(32 * char_width_in_lineedit())
|
||||
info_edit = QLineEdit("New Will Executor")
|
||||
info_edit = QLineEdit(_("New Will Executor"))
|
||||
info_edit.setFixedWidth(32 * char_width_in_lineedit())
|
||||
base_fee_spin = QSpinBox()
|
||||
base_fee_spin.setRange(0, 1000000)
|
||||
@@ -1285,7 +1313,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
||||
|
||||
if executor:
|
||||
url_edit.setText(edit_key)
|
||||
info_edit.setText(str(executor.get("info", "New Will Executor")))
|
||||
info_edit.setText(str(executor.get("info", _("New Will Executor"))))
|
||||
base_fee_spin.setValue(int(executor.get("base_fee", 0)))
|
||||
address_edit.setText(str(executor.get("address", "")))
|
||||
|
||||
@@ -1445,7 +1473,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
||||
break
|
||||
self._add_another = False
|
||||
url_edit.clear()
|
||||
info_edit.setText("New Will Executor")
|
||||
info_edit.setText(_("New Will Executor"))
|
||||
base_fee_spin.setValue(0)
|
||||
address_edit.clear()
|
||||
|
||||
|
||||
@@ -15,13 +15,37 @@ 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 ( # underscore names are not re-exported by "import *"
|
||||
from ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size
|
||||
from ...i18n import init_from_config
|
||||
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
|
||||
@@ -40,15 +64,24 @@ 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)
|
||||
# Load BAL's catalog for the language Electrum's GUI is using, before
|
||||
# any window or dialog builds its texts. It must come after
|
||||
# BalPlugin.__init__: read_file() needs the plugin's parent and name.
|
||||
# The CLI plugin does not call this, so the CLI stays in English.
|
||||
init_from_config(self, config)
|
||||
self.bal_windows = {}
|
||||
# Status-bar buttons, keyed by id(sb.window()). Tracking them lets us
|
||||
# remove a stale button before creating a fresh one when a wallet is
|
||||
# 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):
|
||||
@@ -271,7 +304,7 @@ class Plugin(BalPlugin):
|
||||
pass
|
||||
b = StatusBarButton(
|
||||
read_QIcon_from_bytes(self.read_file("icons/bal32x32.png")),
|
||||
"Bal " + _("Bitcoin After Life"),
|
||||
"Bal {}".format(_("Bitcoin After Life")),
|
||||
lambda: self.settings_dialog(sb.window()),
|
||||
sb.height(),
|
||||
)
|
||||
@@ -328,6 +361,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")
|
||||
@@ -377,7 +448,7 @@ class Plugin(BalPlugin):
|
||||
|
||||
def settings_dialog(self, window=None, wallet=None):
|
||||
|
||||
d = BalDialog(window, self, self.get_window_title("Settings"))
|
||||
d = BalDialog(window, self, self.get_window_title(_("Settings")))
|
||||
d.setMinimumSize(100, 200)
|
||||
qicon = read_QPixmap_from_bytes(self.read_file("icons/bal16x16.png"))
|
||||
lbl_logo = QLabel()
|
||||
@@ -448,13 +519,40 @@ class Plugin(BalPlugin):
|
||||
self.MAX_WILLEXECUTOR_FEE, minimum=0, maximum=10000000
|
||||
)
|
||||
|
||||
# "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)
|
||||
# "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()
|
||||
# Labels marked for translation in common.QR_PRESET_LABELS.
|
||||
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:
|
||||
@@ -565,17 +663,21 @@ class Plugin(BalPlugin):
|
||||
elif kind == "spin":
|
||||
widget.setValue(int(cfg.default))
|
||||
elif kind == "line":
|
||||
widget.setText(cfg.default)
|
||||
widget.setText(cfg.localized_default())
|
||||
elif kind == "text":
|
||||
widget.setPlainText(cfg.default)
|
||||
widget.setPlainText(cfg.localized_default())
|
||||
elif kind == "user_type":
|
||||
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
|
||||
|
||||
heir_repush = QPushButton("Rebroadcast transactions")
|
||||
heir_repush = QPushButton(_("Rebroadcast transactions"))
|
||||
heir_repush.clicked.connect(partial(self.broadcast_transactions, True))
|
||||
bal_mode = QComboBox()
|
||||
options = ["Easy", "Advanced", "Experimental"]
|
||||
@@ -601,27 +703,27 @@ class Plugin(BalPlugin):
|
||||
# advanced-only settings, so the user picks basic/advanced first.
|
||||
add_widget(
|
||||
grid,
|
||||
"Hide Replaced",
|
||||
_("Hide Replaced"),
|
||||
heir_hide_replaced,
|
||||
0,
|
||||
"Hide replaced transactions from will detail and list",
|
||||
_("Hide replaced transactions from will detail and list"),
|
||||
)
|
||||
grid.addWidget(_make_reset_btn(self.HIDE_REPLACED, heir_hide_replaced, "check"), 0, 3)
|
||||
add_widget(
|
||||
grid,
|
||||
"Hide Invalidated",
|
||||
_("Hide Invalidated"),
|
||||
heir_hide_invalidated,
|
||||
1,
|
||||
"Hide invalidated transactions from will detail and list",
|
||||
_("Hide invalidated transactions from will detail and list"),
|
||||
)
|
||||
grid.addWidget(_make_reset_btn(self.HIDE_INVALIDATED, heir_hide_invalidated, "check"), 1, 3)
|
||||
lbl_auto_sign = QLabel(_("Auto-sign on Check"))
|
||||
help_auto_sign = HelpButton(
|
||||
help_auto_sign = HelpButton(_(
|
||||
"When checking, automatically sign and broadcast the will "
|
||||
"transactions to their will-executors.\n"
|
||||
"The wallet password is requested only if the wallet is "
|
||||
"encrypted."
|
||||
)
|
||||
))
|
||||
grid.addWidget(_hide_if_basic(lbl_auto_sign), 2, 0)
|
||||
grid.addWidget(_hide_if_basic(heir_auto_sign), 2, 1)
|
||||
grid.addWidget(_hide_if_basic(help_auto_sign), 2, 2)
|
||||
@@ -631,10 +733,10 @@ class Plugin(BalPlugin):
|
||||
grid.addWidget(_hide_if_basic(reset_btn_auto_sign), 2, 3)
|
||||
add_widget(
|
||||
grid,
|
||||
"Panel editable Date and Fee",
|
||||
_("Panel editable Date and Fee"),
|
||||
heir_editable_dates,
|
||||
3,
|
||||
(
|
||||
_(
|
||||
"When enabled, the delivery-time and check-alive date fields "
|
||||
"can be edited everywhere (toolbar / Heirs tab), not only in "
|
||||
"the will-building wizard.\n"
|
||||
@@ -642,51 +744,29 @@ 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.
|
||||
add_widget(
|
||||
grid,
|
||||
"Add transaction without willexecutor",
|
||||
heir_no_willexecutor,
|
||||
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."
|
||||
),
|
||||
)
|
||||
grid.addWidget(_make_reset_btn(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"), 4, 3)
|
||||
# Max willexecutor fee: maximum fee (in satoshi) allowed for a single
|
||||
# will-executor. Visible to all users (BASIC and ADVANCED).
|
||||
add_widget(
|
||||
grid,
|
||||
"Max Will-Executor Fee (satoshi)",
|
||||
_("Max Will-Executor Fee (satoshi)"),
|
||||
heir_max_willexecutor_fee,
|
||||
5,
|
||||
(
|
||||
4,
|
||||
_(
|
||||
"Maximum fee (in satoshi) allowed to be paid to a single "
|
||||
"will-executor. If a will-executor charges more than this, "
|
||||
"the will will not be built.\n"
|
||||
"Default: 500,000 satoshi (0.005 BTC)."
|
||||
),
|
||||
)
|
||||
grid.addWidget(_make_reset_btn(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"), 5, 3)
|
||||
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(
|
||||
grid,
|
||||
"User Type",
|
||||
_("User Type"),
|
||||
user_type_combo,
|
||||
6,
|
||||
(
|
||||
5,
|
||||
_(
|
||||
"Choose how much detail the plugin shows.\n\n"
|
||||
"BASIC: simplified interface, safe configuration for most "
|
||||
"users.\n\n"
|
||||
@@ -696,103 +776,103 @@ class Plugin(BalPlugin):
|
||||
"editable."
|
||||
),
|
||||
)
|
||||
grid.addWidget(_make_reset_btn(self.USER_TYPE, user_type_combo, "user_type"), 6, 3)
|
||||
grid.addWidget(_make_reset_btn(self.USER_TYPE, user_type_combo, "user_type"), 5, 3)
|
||||
# Number of reminders, event summary and event description are visible
|
||||
# only in ADVANCED mode. In BASIC mode the factory defaults are always
|
||||
# used and these settings are hidden.
|
||||
lbl_num_reminders = QLabel(_("Number of reminders"))
|
||||
help_num_reminders = HelpButton(
|
||||
help_num_reminders = HelpButton(_(
|
||||
"How many reminder alarms the exported calendar (.ics) event "
|
||||
"contains. Range: 1 to 5 (default 3). Only used in ADVANCED mode."
|
||||
)
|
||||
grid.addWidget(_hide_if_basic(lbl_num_reminders), 7, 0)
|
||||
grid.addWidget(_hide_if_basic(heir_num_reminders), 7, 1)
|
||||
grid.addWidget(_hide_if_basic(help_num_reminders), 7, 2)
|
||||
))
|
||||
grid.addWidget(_hide_if_basic(lbl_num_reminders), 6, 0)
|
||||
grid.addWidget(_hide_if_basic(heir_num_reminders), 6, 1)
|
||||
grid.addWidget(_hide_if_basic(help_num_reminders), 6, 2)
|
||||
reset_btn_6 = _make_reset_btn(self.NUM_REMINDERS, heir_num_reminders, "spin")
|
||||
grid.addWidget(_hide_if_basic(reset_btn_6), 7, 3)
|
||||
grid.addWidget(_hide_if_basic(reset_btn_6), 6, 3)
|
||||
|
||||
lbl_event_summary = QLabel(_("Event summary"))
|
||||
help_event_summary = HelpButton(
|
||||
help_event_summary = HelpButton(_(
|
||||
"Default message to be used in event summary\n"
|
||||
"Variables:\n"
|
||||
" $wallet_name: name of wallet\n"
|
||||
" $heirs_complete: list of heirs name,address,amount\n"
|
||||
"Only used in ADVANCED mode."
|
||||
)
|
||||
grid.addWidget(_hide_if_basic(lbl_event_summary), 8, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_event_summary), 8, 1)
|
||||
grid.addWidget(_hide_if_basic(help_event_summary), 8, 2)
|
||||
))
|
||||
grid.addWidget(_hide_if_basic(lbl_event_summary), 7, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_event_summary), 7, 1)
|
||||
grid.addWidget(_hide_if_basic(help_event_summary), 7, 2)
|
||||
reset_btn_7 = _make_reset_btn(self.EVENT_SUMMARY, edit_event_summary, "line")
|
||||
grid.addWidget(_hide_if_basic(reset_btn_7), 8, 3)
|
||||
grid.addWidget(_hide_if_basic(reset_btn_7), 7, 3)
|
||||
|
||||
lbl_event_description = QLabel(_("Event description"))
|
||||
help_event_description = HelpButton(
|
||||
help_event_description = HelpButton(_(
|
||||
"Default message to be used in event description\n"
|
||||
"Variables:\n"
|
||||
" $wallet_name: name of wallet\n"
|
||||
" $heirs_complete: list of heirs name,address,amount\n"
|
||||
"Only used in ADVANCED mode."
|
||||
)
|
||||
grid.addWidget(_hide_if_basic(lbl_event_description), 9, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_event_description), 9, 1)
|
||||
grid.addWidget(_hide_if_basic(help_event_description), 9, 2)
|
||||
))
|
||||
grid.addWidget(_hide_if_basic(lbl_event_description), 8, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_event_description), 8, 1)
|
||||
grid.addWidget(_hide_if_basic(help_event_description), 8, 2)
|
||||
reset_btn_8 = _make_reset_btn(self.EVENT_DESCRIPTION, edit_event_description, "text")
|
||||
grid.addWidget(_hide_if_basic(reset_btn_8), 9, 3)
|
||||
grid.addWidget(_hide_if_basic(reset_btn_8), 8, 3)
|
||||
# Welist server URL: shown only in ADVANCED mode. In BASIC mode the
|
||||
# factory default is always used and the setting is hidden.
|
||||
lbl_welist_server = QLabel(_("Welist Server URL"))
|
||||
help_welist_server = HelpButton(
|
||||
help_welist_server = HelpButton(_(
|
||||
"URL of the server that provides the will-executor list. "
|
||||
"Only available in ADVANCED mode."
|
||||
)
|
||||
grid.addWidget(_hide_if_basic(lbl_welist_server), 10, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_welist_server), 10, 1)
|
||||
grid.addWidget(_hide_if_basic(help_welist_server), 10, 2)
|
||||
))
|
||||
grid.addWidget(_hide_if_basic(lbl_welist_server), 9, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_welist_server), 9, 1)
|
||||
grid.addWidget(_hide_if_basic(help_welist_server), 9, 2)
|
||||
reset_btn_9 = _make_reset_btn(self.WELIST_SERVER, edit_welist_server, "line")
|
||||
grid.addWidget(_hide_if_basic(reset_btn_9), 10, 3)
|
||||
grid.addWidget(_hide_if_basic(reset_btn_9), 9, 3)
|
||||
|
||||
lbl_calendar_app = QLabel(_("Calendar app command"))
|
||||
help_calendar_app = HelpButton(
|
||||
help_calendar_app = HelpButton(_(
|
||||
"Command used to open .ics calendar files.\n"
|
||||
"Leave empty to use the system default (xdg-open/open/start).\n"
|
||||
"Only used in ADVANCED mode."
|
||||
)
|
||||
grid.addWidget(_hide_if_basic(lbl_calendar_app), 11, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_calendar_app), 11, 1)
|
||||
grid.addWidget(_hide_if_basic(help_calendar_app), 11, 2)
|
||||
))
|
||||
grid.addWidget(_hide_if_basic(lbl_calendar_app), 10, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_calendar_app), 10, 1)
|
||||
grid.addWidget(_hide_if_basic(help_calendar_app), 10, 2)
|
||||
reset_btn_10 = _make_reset_btn(self.CALENDAR_APP, edit_calendar_app, "line")
|
||||
grid.addWidget(_hide_if_basic(reset_btn_10), 11, 3)
|
||||
grid.addWidget(_hide_if_basic(reset_btn_10), 10, 3)
|
||||
|
||||
# Save-in-history toggle and history label: advanced-only rows. The
|
||||
# label field is disabled while the checkbox is off (see
|
||||
# on_save_history_change above).
|
||||
lbl_save_history = QLabel(_("Save inheritance transactions in history"))
|
||||
help_save_history = HelpButton(
|
||||
help_save_history = HelpButton(_(
|
||||
"After each check, save the valid will transactions into the "
|
||||
"wallet's local history (the History tab), each with a label.\n"
|
||||
"The label may contain the variable:\n"
|
||||
" {willexecutor}: replaced with the will-executor URL of the item\n"
|
||||
"Only used in ADVANCED mode."
|
||||
)
|
||||
grid.addWidget(_hide_if_basic(lbl_save_history), 12, 0)
|
||||
grid.addWidget(_hide_if_basic(heir_save_history), 12, 1)
|
||||
grid.addWidget(_hide_if_basic(help_save_history), 12, 2)
|
||||
))
|
||||
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), 12, 3)
|
||||
grid.addWidget(_hide_if_basic(reset_btn_11), 11, 3)
|
||||
|
||||
lbl_history_label = QLabel(_("History label"))
|
||||
help_history_label = HelpButton(
|
||||
help_history_label = HelpButton(_(
|
||||
"Label applied to the will transactions saved into the wallet's "
|
||||
"local history.\n"
|
||||
"Variables:\n"
|
||||
" {willexecutor}: replaced with the will-executor URL of the item\n"
|
||||
"Only used in ADVANCED mode."
|
||||
)
|
||||
grid.addWidget(_hide_if_basic(lbl_history_label), 13, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_history_label), 13, 1)
|
||||
grid.addWidget(_hide_if_basic(help_history_label), 13, 2)
|
||||
))
|
||||
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), 13, 3)
|
||||
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
|
||||
@@ -801,15 +881,76 @@ class Plugin(BalPlugin):
|
||||
# the Windows relayout flicker. Do NOT reintroduce a post-hoc
|
||||
# setVisible() loop here.
|
||||
|
||||
grid.addWidget(heir_repush, 14, 0)
|
||||
grid.addWidget(heir_repush, 13, 0)
|
||||
grid.addWidget(
|
||||
HelpButton(
|
||||
"Broadcast all transactions to willexecutors including those already pushed"
|
||||
_("Broadcast all transactions to willexecutors including those already pushed")
|
||||
),
|
||||
14,
|
||||
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 #
|
||||
@@ -834,7 +975,6 @@ 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"),
|
||||
@@ -842,6 +982,9 @@ class Plugin(BalPlugin):
|
||||
(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.
|
||||
@@ -854,14 +997,18 @@ class Plugin(BalPlugin):
|
||||
elif kind == "spin":
|
||||
widget.setValue(int(cfg.default))
|
||||
elif kind == "line":
|
||||
widget.setText(cfg.default)
|
||||
widget.setText(cfg.localized_default())
|
||||
elif kind == "text":
|
||||
widget.setPlainText(cfg.default)
|
||||
widget.setPlainText(cfg.localized_default())
|
||||
elif kind == "user_type":
|
||||
# Default is "basic" -> combo index 0; "advanced" -> index 1.
|
||||
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.
|
||||
@@ -891,7 +1038,7 @@ class Plugin(BalPlugin):
|
||||
bottom_row = QHBoxLayout()
|
||||
bottom_row.addWidget(btn_reset)
|
||||
bottom_row.addStretch(1)
|
||||
bottom_row.addWidget(QLabel("<b>" + _("Support:") + "</b>"))
|
||||
bottom_row.addWidget(QLabel("<b>{}</b>".format(_("Support:"))))
|
||||
bottom_row.addWidget(lbl_support)
|
||||
|
||||
# Outer layout: warning (top) -> settings grid -> bottom button row.
|
||||
@@ -943,6 +1090,11 @@ class Plugin(BalPlugin):
|
||||
)
|
||||
|
||||
def get_window_title(self, title):
|
||||
return _("BAL - ") + _(title)
|
||||
"""Return the window title "BAL - <title>".
|
||||
|
||||
``title`` must already be translated by the caller: a variable passed
|
||||
to _() cannot be extracted into the catalog.
|
||||
"""
|
||||
return _("BAL - {}").format(title)
|
||||
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ def server_status_text(will_item) -> str:
|
||||
the user always knows whether each inheritance transaction is actually
|
||||
stored on the will-executor servers, regardless of the row colour.
|
||||
"""
|
||||
from electrum.i18n import _
|
||||
from ...i18n import _
|
||||
|
||||
if will_item.get_status("CHECK_FAIL") and not will_item.get_status("CHECKED"):
|
||||
return _("Not on server")
|
||||
@@ -105,7 +105,7 @@ def server_status_text(will_item) -> str:
|
||||
def server_status_tooltip(will_item) -> str:
|
||||
"""Return a detailed tooltip for the "Server" column, including the
|
||||
will-executor URL (if any) and the current server state."""
|
||||
from electrum.i18n import _
|
||||
from ...i18n import _
|
||||
|
||||
url = None
|
||||
we = getattr(will_item, "we", None)
|
||||
|
||||
@@ -20,6 +20,7 @@ Contents:
|
||||
|
||||
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,
|
||||
@@ -28,8 +29,55 @@ from ...core.input_rules import (
|
||||
)
|
||||
from ...core.reminders import build_ics_reminders, write_temp_ics
|
||||
from .calendar import BalCalendar, BalCalendarButton
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from .common import (
|
||||
DECIMAL_POINT,
|
||||
N_,
|
||||
NLOCKTIME_BLOCKHEIGHT_MAX,
|
||||
NLOCKTIME_MAX,
|
||||
Any,
|
||||
BalTimestamp,
|
||||
BTCAmountEdit,
|
||||
ColorScheme,
|
||||
Decimal,
|
||||
HelpButton,
|
||||
Optional,
|
||||
QAbstractSpinBox,
|
||||
QCheckBox,
|
||||
QColor,
|
||||
QComboBox,
|
||||
QDateTime,
|
||||
QDateTimeEdit,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPainter,
|
||||
QPalette,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QStyle,
|
||||
QStyleOptionFrame,
|
||||
Qt,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
Union,
|
||||
Util,
|
||||
Will,
|
||||
_,
|
||||
_logger,
|
||||
char_width_in_lineedit,
|
||||
datetime,
|
||||
format_status_history,
|
||||
getSaveFileName,
|
||||
log_error,
|
||||
os,
|
||||
partial,
|
||||
pyqtSignal,
|
||||
read_QIcon_from_bytes,
|
||||
signature_suffix,
|
||||
status_color,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .window import BalWindow
|
||||
@@ -161,10 +209,10 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
||||
current_index = None
|
||||
default_value = None
|
||||
|
||||
help_text = (
|
||||
help_text = N_(
|
||||
"if you choose Raw, you can insert various options based on suffix:\n"
|
||||
+ " - d: number of days after current day(ex: 1d means tomorrow)\n"
|
||||
+ " - y: number of years after currrent day(ex: 1y means one year from today)\n"
|
||||
" - d: number of days after current day(ex: 1d means tomorrow)\n"
|
||||
" - y: number of years after currrent day(ex: 1y means one year from today)\n"
|
||||
)
|
||||
label_text = None
|
||||
tooltip_text = None
|
||||
@@ -230,7 +278,9 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
||||
]
|
||||
)
|
||||
#hbox.addWidget(QLabel(self.label_text))
|
||||
help_button=HelpButton(self.help_text)
|
||||
# help_text and tooltip_text are class attributes marked with N_():
|
||||
# translate them here, when shown.
|
||||
help_button=HelpButton(_(self.help_text))
|
||||
help_button.setText(self.label_text)
|
||||
# Show a short label (e.g. "Delivery time" / "Check Alive") when the
|
||||
# user hovers the icon, so the emoji button is self-explanatory.
|
||||
@@ -566,15 +616,20 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
|
||||
# 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))
|
||||
|
||||
|
||||
|
||||
class ThresholdTimeWidget(BalTimeEditWidget):
|
||||
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
|
||||
help_text = (
|
||||
help_text = N_(
|
||||
"<b>CHECK ALIVE</b><br><br>"
|
||||
"In DATA mode:<br>"
|
||||
"set the date for the \u201ccheck alive\u201d parameter.<br>"
|
||||
@@ -593,7 +648,7 @@ class ThresholdTimeWidget(BalTimeEditWidget):
|
||||
)
|
||||
label_text = "🚨"
|
||||
#label_text = "Check Alive"
|
||||
tooltip_text = "Check Alive"
|
||||
tooltip_text = N_("Check Alive")
|
||||
base_field = "threshold"
|
||||
|
||||
def __init__(self, bal_window, parent, init_value=None):
|
||||
@@ -608,7 +663,7 @@ class ThresholdTimeWidget(BalTimeEditWidget):
|
||||
|
||||
class LockTimeWidget(BalTimeEditWidget):
|
||||
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
|
||||
help_text = (
|
||||
help_text = N_(
|
||||
"<b>DELIVERY TIME</b><br><br>"
|
||||
"Set Locktime for transactions.<br>"
|
||||
"Any time is needed transaction will be anticipated by 1day<br><br>"
|
||||
@@ -626,7 +681,7 @@ class LockTimeWidget(BalTimeEditWidget):
|
||||
#label_text = "Locktime"
|
||||
# Hover tooltip for the delivery-time icon; mirrors the style of the fee
|
||||
# icon tooltip ("..., click for more information") so the two are consistent.
|
||||
tooltip_text = "Delivery Time, click for more information"
|
||||
tooltip_text = N_("Delivery Time, click for more information")
|
||||
base_field = "locktime"
|
||||
|
||||
def __init__(self, bal_window, parent, init_value=None):
|
||||
@@ -1037,9 +1092,12 @@ class WillSettingsWidget(QWidget):
|
||||
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
|
||||
# ignored) and the fixed 30/10/1 offsets. The defaults are in
|
||||
# the GUI language (see BalConfig).
|
||||
raw_description = (
|
||||
self.bal_window.bal_plugin.EVENT_DESCRIPTION.localized_default()
|
||||
)
|
||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.localized_default()
|
||||
threshold = None
|
||||
num_reminders = 3
|
||||
else:
|
||||
@@ -1069,6 +1127,7 @@ class WillSettingsWidget(QWidget):
|
||||
version=self.bal_window.bal_plugin.version,
|
||||
num_reminders=num_reminders,
|
||||
threshold=threshold,
|
||||
reminder_suffix=_("(reminder {idx}/{total})"),
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(f"failed to generate .ics: {e}")
|
||||
@@ -1148,7 +1207,7 @@ class PercAmountEdit(BTCAmountEdit):
|
||||
painter.drawText(
|
||||
text_rect,
|
||||
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
|
||||
self.base_unit() + " or perc value",
|
||||
_("{} or perc value").format(self.base_unit()),
|
||||
)
|
||||
|
||||
|
||||
@@ -1266,11 +1325,12 @@ class WillWidget(QWidget):
|
||||
creation = str(BalTimestamp(self.will[w].time))
|
||||
|
||||
def qlabel(title, value):
|
||||
label = "<b>" + _(str(title)) + f":</b>\t{str(value)}"
|
||||
return QLabel(label)
|
||||
# ``title`` is shown as it is: callers pass a translated
|
||||
# label, or data (an heir name, a will-executor URL).
|
||||
return QLabel("<b>{}:</b>\t{}".format(title, value))
|
||||
|
||||
detaillayout.addWidget(qlabel("Locktime", locktime))
|
||||
detaillayout.addWidget(qlabel("Creation Time", creation))
|
||||
detaillayout.addWidget(qlabel(_("Locktime"), locktime))
|
||||
detaillayout.addWidget(qlabel(_("Creation Time"), creation))
|
||||
try:
|
||||
total_fees = (
|
||||
self.will[w].tx.input_value() - self.will[w].tx.output_value()
|
||||
@@ -1280,25 +1340,46 @@ class WillWidget(QWidget):
|
||||
decoded_fees = total_fees
|
||||
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 + 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
|
||||
)
|
||||
detaillayout.addWidget(qlabel(_("Transaction fees"), fees_str))
|
||||
# The saved status history is English: translate it for display.
|
||||
detaillayout.addWidget(
|
||||
qlabel(
|
||||
heir, f"{decoded_amount} {self._bal_parent.base_unit_name}"
|
||||
_("Status"),
|
||||
format_status_history(self.will[w].status)
|
||||
+ signature_suffix(self.will[w]),
|
||||
)
|
||||
)
|
||||
detaillayout.addWidget(QLabel(""))
|
||||
detaillayout.addWidget(QLabel("<b>{}</b>".format(_("Heirs:"))))
|
||||
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_name,
|
||||
f"{decoded_amount} {self._bal_parent.base_unit_name} "
|
||||
f"[{h[0]}]",
|
||||
)
|
||||
)
|
||||
if self.will[w].we:
|
||||
detaillayout.addWidget(QLabel(""))
|
||||
detaillayout.addWidget(QLabel(_("<b>Willexecutor:</b:")))
|
||||
detaillayout.addWidget(
|
||||
QLabel("<b>{}</b>".format(_("Willexecutor:")))
|
||||
)
|
||||
decoded_amount = Util.decode_amount(
|
||||
self.will[w].we["base_fee"], self._bal_parent.decimal_point
|
||||
)
|
||||
@@ -1309,6 +1390,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(
|
||||
|
||||
@@ -23,9 +23,67 @@ from ...core.checkalive import (
|
||||
CheckAliveError,
|
||||
check_alive_expired,
|
||||
resolve_date_to_check,
|
||||
resolve_guard_threshold,
|
||||
)
|
||||
from .common import (
|
||||
N_,
|
||||
OP_RETURN_PREFIX,
|
||||
AmountException,
|
||||
BalPlugin,
|
||||
Buttons,
|
||||
CancelButton,
|
||||
ElectrumWindow,
|
||||
FileImportFailed,
|
||||
HeirChangeException,
|
||||
HeirNotFoundException,
|
||||
Heirs,
|
||||
HelpButton,
|
||||
Mapping,
|
||||
Network,
|
||||
NoHeirsException,
|
||||
NotCompleteWillException,
|
||||
NoWillExecutorNotPresent,
|
||||
OkButton,
|
||||
Optional,
|
||||
PaymentIdentifier,
|
||||
QGridLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QTimer,
|
||||
QVBoxLayout,
|
||||
SerializationError,
|
||||
Transaction,
|
||||
TxDialog,
|
||||
TxFeesChangedException,
|
||||
Util,
|
||||
Will,
|
||||
WillexecutorChangeException,
|
||||
WillExecutorFeeTooHighException,
|
||||
WillExecutorNotPresent,
|
||||
Willexecutors,
|
||||
WillExpiredException,
|
||||
WillItem,
|
||||
WillPostponedException,
|
||||
_,
|
||||
_logger,
|
||||
char_width_in_lineedit,
|
||||
copy_structure,
|
||||
export_meta_gui,
|
||||
import_meta_gui,
|
||||
is_onion_url,
|
||||
is_op_return_address,
|
||||
is_tor_active,
|
||||
log_error,
|
||||
partial,
|
||||
read_json_file,
|
||||
read_QIcon_from_bytes,
|
||||
show_on_top,
|
||||
shown_cv,
|
||||
time,
|
||||
tx_from_any,
|
||||
write_json_file,
|
||||
)
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from .dialogs import (
|
||||
BalBuildWillDialog,
|
||||
BalDialog,
|
||||
@@ -33,12 +91,23 @@ from .dialogs import (
|
||||
BalWizardDialog,
|
||||
WillDetailDialog,
|
||||
WillExecutorDialog,
|
||||
WillExportDialog,
|
||||
WillImportDialog,
|
||||
_complete_import,
|
||||
decode_will_payload,
|
||||
)
|
||||
from .lists import HeirListWidget, PreviewList
|
||||
from .widgets import LockTimeWidget, PercAmountEdit
|
||||
|
||||
|
||||
class BalWindow:
|
||||
# Automatic rebuild-on-new-transaction flow (AUTO_REBUILD setting):
|
||||
# the debounce window collapses bursts of wallet events into one run, and
|
||||
# the cooldown prevents the flow from re-triggering right after a rebuild
|
||||
# (the freshly persisted txs can themselves fire wallet events).
|
||||
_AUTO_REBUILD_DEBOUNCE_MS = 5000
|
||||
_AUTO_REBUILD_COOLDOWN = 10.0
|
||||
|
||||
def __init__(self, bal_plugin: "BalPlugin", window: "ElectrumWindow"):
|
||||
self.bal_plugin = bal_plugin
|
||||
self.window = window
|
||||
@@ -56,6 +125,9 @@ class BalWindow:
|
||||
# ``init_menubar_tools`` twice would add the Heirs/Will tabs and the
|
||||
# menu actions twice, producing the garbled/condensed menu entry.
|
||||
self._menubar_initialized = False
|
||||
# Auto-rebuild flow state: re-entrancy guard and cooldown deadline.
|
||||
self._auto_rebuild_running = False
|
||||
self._auto_rebuild_cooldown_until = 0.0
|
||||
self.bal_plugin.get_decimal_point = self.window.get_decimal_point
|
||||
|
||||
if self.window.wallet:
|
||||
@@ -211,12 +283,12 @@ class BalWindow:
|
||||
|
||||
def new_heir_dialog(self, heir_key=None):
|
||||
heir = self.heirs.get(heir_key)
|
||||
title = "New heir"
|
||||
title = _("New heir")
|
||||
if heir:
|
||||
title = f"Edit: {heir_key}"
|
||||
title = _("Edit: {}").format(heir_key)
|
||||
|
||||
d = BalDialog(
|
||||
self.window, self.bal_plugin, self.bal_plugin.get_window_title(_(title))
|
||||
self.window, self.bal_plugin, self.bal_plugin.get_window_title(title)
|
||||
)
|
||||
|
||||
vbox = QVBoxLayout(d)
|
||||
@@ -396,6 +468,31 @@ class BalWindow:
|
||||
|
||||
def build_will(self, ignore_duplicate=True, keep_original=True):
|
||||
_logger.debug("building will...")
|
||||
# Drop stale wallet-LOCAL will placeholders saved by previous prepares
|
||||
# so their coins are available to this build (see remove_stale...).
|
||||
Will.remove_stale_wallet_history(
|
||||
self.window.wallet, self.bal_plugin.HISTORY_LABEL.get()
|
||||
)
|
||||
# A (re)build may have anticipated the delivery (shorter heir recipes)
|
||||
# while ``date_to_check`` is still anchored to the OLD built will. Using
|
||||
# that stale anchor as the build filter would block every future
|
||||
# delivery ("NO_FUTURE_DATE"). Recompute ``date_to_check`` for the will
|
||||
# that is being built: its locktime is the earliest future delivery
|
||||
# among the CURRENT heirs. The checks of the EXISTING will keep their
|
||||
# anchored ``date_to_check`` (set in init_class_variables).
|
||||
_new_locktime = min(
|
||||
(
|
||||
Util.parse_locktime_string(h[2])
|
||||
for h in self.heirs.values()
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
if _new_locktime:
|
||||
self.date_to_check = resolve_date_to_check(
|
||||
self.bal_plugin.is_basic_mode(),
|
||||
self.will_settings,
|
||||
built_locktime=_new_locktime,
|
||||
)
|
||||
will = {}
|
||||
# willtodelete = []
|
||||
# willtoappend = {}
|
||||
@@ -450,11 +547,11 @@ class BalWindow:
|
||||
tx["my_locktime"] = txs[txid].my_locktime
|
||||
tx["heirsvalue"] = txs[txid].heirsvalue
|
||||
tx["description"] = txs[txid].description
|
||||
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
|
||||
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
|
||||
tx["status"] = _("New")
|
||||
tx["baltx_fees"] = txs[txid].tx_fees
|
||||
tx["time"] = creation_time
|
||||
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
|
||||
tx["heirs"] = copy_structure(txs[txid].heirs)
|
||||
tx["txchildren"] = []
|
||||
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
||||
self.update_will(will)
|
||||
@@ -675,6 +772,27 @@ class BalWindow:
|
||||
|
||||
raise e
|
||||
|
||||
def is_locktime_below_threshold(self) -> bool:
|
||||
"""True when the stored settings make the delivery earlier than the
|
||||
Check Alive threshold (the "locktime is lower than threshold" guard).
|
||||
|
||||
Compares the delivery against the settings-derived threshold on the
|
||||
SAME reference frame (see ``resolve_guard_threshold``), never against
|
||||
the built-will-anchored ``date_to_check``: anchoring the guard to an
|
||||
old, longer built will would wrongly fire right after the delivery was
|
||||
shortened. The anchored reference still governs the validity and
|
||||
expiry checks, which is where ``date_to_check`` belongs.
|
||||
In BASIC mode there is no threshold, so the locktime is checked against
|
||||
``date_to_check`` (= now) exactly as before.
|
||||
"""
|
||||
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
||||
threshold_ts = resolve_guard_threshold(
|
||||
self.bal_plugin.is_basic_mode(), self.will_settings
|
||||
)
|
||||
if threshold_ts is not None:
|
||||
return locktime < threshold_ts
|
||||
return self.date_to_check is not None and locktime < self.date_to_check
|
||||
|
||||
def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True):
|
||||
try:
|
||||
_logger.info(
|
||||
@@ -687,6 +805,11 @@ class BalWindow:
|
||||
if not self.heirs:
|
||||
_logger.warning("not heirs {}".format(self.heirs))
|
||||
return
|
||||
# Free the coins locked by stale wallet-LOCAL will placeholders
|
||||
# BEFORE the amount/UTXO checks below (Step 1) see them.
|
||||
Will.remove_stale_wallet_history(
|
||||
self.window.wallet, self.bal_plugin.HISTORY_LABEL.get()
|
||||
)
|
||||
try:
|
||||
self.init_class_variables()
|
||||
Will.check_amounts(
|
||||
@@ -706,12 +829,12 @@ class BalWindow:
|
||||
except AmountException as e:
|
||||
self.show_warning(
|
||||
_(
|
||||
f"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{e}"
|
||||
)
|
||||
"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{}"
|
||||
).format(e)
|
||||
)
|
||||
except WillExecutorFeeTooHighException as e:
|
||||
self.show_error(
|
||||
_(f"Will-executor fee too high: {e}")
|
||||
_("Will-executor fee too high: {}").format(e)
|
||||
)
|
||||
return
|
||||
except CheckAliveError:
|
||||
@@ -721,8 +844,7 @@ class BalWindow:
|
||||
)
|
||||
)
|
||||
return
|
||||
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
||||
if locktime < self.date_to_check:
|
||||
if self.is_locktime_below_threshold():
|
||||
self.show_error(_("locktime is lower than threshold"))
|
||||
return
|
||||
if not self.no_willexecutor:
|
||||
@@ -798,27 +920,27 @@ class BalWindow:
|
||||
_logger.info("{}:{}".format(type(e), e))
|
||||
message = False
|
||||
if isinstance(e, HeirChangeException):
|
||||
message = "Heirs changed:"
|
||||
message = _("Heirs changed:")
|
||||
elif isinstance(e, WillExecutorNotPresent):
|
||||
message = "Will-Executor not present:"
|
||||
message = _("Will-Executor not present:")
|
||||
elif isinstance(e, WillexecutorChangeException):
|
||||
message = "Will-Executor changed"
|
||||
message = _("Will-Executor changed")
|
||||
elif isinstance(e, TxFeesChangedException):
|
||||
message = "Txfees are changed"
|
||||
message = _("Txfees are changed")
|
||||
elif isinstance(e, HeirNotFoundException):
|
||||
# Task #01b: replace the misleading "Heir not found" text.
|
||||
# This branch is most often hit because the delivery date
|
||||
# was anticipated, not because an heir is missing, so we use
|
||||
# a clear message that covers both the DATE and the HEIRS
|
||||
# cases (kept consistent with dialogs.py / the CHECK window).
|
||||
message = (
|
||||
message = _(
|
||||
"Found CHANGES to the DATE or the HEIRS,\n"
|
||||
"a NEW WILL must be prepared."
|
||||
)
|
||||
|
||||
if message:
|
||||
self.show_message(
|
||||
f"{_(message)}:\n {e}\n{_('will have to be built')}"
|
||||
"{}:\n {}\n{}".format(message, e, _("will have to be built"))
|
||||
)
|
||||
|
||||
_logger.info("build will")
|
||||
@@ -838,7 +960,7 @@ class BalWindow:
|
||||
self.invalidate_will()
|
||||
except NotCompleteWillException as e:
|
||||
self.show_error(
|
||||
"Error:{}\n {}".format(
|
||||
_("Error:{}\n {}").format(
|
||||
str(e),
|
||||
_("Please, check your heirs, locktime and threshold!"),
|
||||
)
|
||||
@@ -898,7 +1020,9 @@ class BalWindow:
|
||||
except SerializationError as e:
|
||||
_logger.error("unable to deserialize the transaction")
|
||||
parent.show_critical(
|
||||
_("Electrum was unable to deserialize the transaction:") + "\n" + str(e)
|
||||
"{}\n{}".format(
|
||||
_("Electrum was unable to deserialize the transaction:"), e
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Electrum's own TxDialog: keep it in front of the main window.
|
||||
@@ -915,6 +1039,13 @@ class BalWindow:
|
||||
return self.show_transaction_real(tx, parent=parent)
|
||||
|
||||
def invalidate_will(self, will=None):
|
||||
# The reference timestamp is normally set by init_class_variables();
|
||||
# fall back to "now" so a first-action invalidation always has it.
|
||||
if not hasattr(self, "date_to_check") or self.date_to_check is None:
|
||||
self.date_to_check = resolve_date_to_check(
|
||||
self.bal_plugin.is_basic_mode(), self.will_settings
|
||||
)
|
||||
|
||||
def on_success(result):
|
||||
if result:
|
||||
self.show_message(
|
||||
@@ -959,8 +1090,8 @@ class BalWindow:
|
||||
def get_message():
|
||||
msg = ""
|
||||
if signed:
|
||||
msg = _(f"signed: {signed}\n")
|
||||
return msg + _(f"signing: {tosign}")
|
||||
msg = _("signed: {}").format(signed) + "\n"
|
||||
return msg + _("signing: {}").format(tosign)
|
||||
|
||||
if txids is not None:
|
||||
targets = [
|
||||
@@ -971,13 +1102,10 @@ class BalWindow:
|
||||
targets = Will.only_valid(willitems)
|
||||
for txid in targets:
|
||||
wi = willitems[txid]
|
||||
# Do NOT deepcopy: the stored tx carries wallet-derived objects
|
||||
# (utxo / script_descriptor) that hold a threading.RLock, and
|
||||
# copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse
|
||||
# from the serialized form instead, which is exactly how the will
|
||||
# is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any).
|
||||
tx = Will.get_tx_from_any(str(wi.tx))
|
||||
if wi.get_status("COMPLETE"):
|
||||
# Already signed and complete: keep as-is (the single-tx
|
||||
# helper short-circuits without touching the wallet).
|
||||
tx, _ = self._prepare_and_sign_tx(willitems, txid, password)
|
||||
txs[txid] = tx
|
||||
continue
|
||||
tosign = txid
|
||||
@@ -985,6 +1113,32 @@ class BalWindow:
|
||||
self.waiting_dialog.update(get_message())
|
||||
except Exception:
|
||||
pass
|
||||
tx, _signed = self._prepare_and_sign_tx(willitems, txid, password)
|
||||
signed = tosign
|
||||
txs[txid] = tx
|
||||
except Exception:
|
||||
return None
|
||||
return txs
|
||||
|
||||
def _prepare_and_sign_tx(self, willitems, txid, password):
|
||||
"""Prepare one will transaction and sign it.
|
||||
|
||||
Shared by the batch signer (:meth:`sign_transactions`) and the
|
||||
per-transaction review wizard of the QR import flow
|
||||
(:class:`WillTxReviewSignDialog`).
|
||||
|
||||
Returns ``(tx, newly_signed)``: ``newly_signed`` is False when the
|
||||
transaction was already COMPLETE (nothing was signed).
|
||||
"""
|
||||
wi = willitems[txid]
|
||||
# Do NOT deepcopy: the stored tx carries wallet-derived objects
|
||||
# (utxo / script_descriptor) that hold a threading.RLock, and
|
||||
# copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse
|
||||
# from the serialized form instead, which is exactly how the will
|
||||
# is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any).
|
||||
tx = Will.get_tx_from_any(str(wi.tx))
|
||||
if wi.get_status("COMPLETE"):
|
||||
return tx, False
|
||||
for txin in tx.inputs():
|
||||
prevout = txin.prevout.to_json()
|
||||
if prevout[0] in willitems:
|
||||
@@ -998,12 +1152,10 @@ class BalWindow:
|
||||
txin._TxInput__address = change.address
|
||||
txin._TxInput__scriptpubkey = change.scriptpubkey
|
||||
txin._TxInput__value_sats = change.value
|
||||
txin._trusted_value_sats = change.value
|
||||
|
||||
self.wallet.sign_transaction(tx, password, ignore_warnings=True)
|
||||
signed = tosign
|
||||
# is_complete = False
|
||||
if tx.is_complete():
|
||||
# is_complete = True
|
||||
wi.set_status("COMPLETE", True)
|
||||
# Refresh the per-item signature counts from the freshly signed
|
||||
# partial tx: at this point the signatures are still present
|
||||
@@ -1015,10 +1167,7 @@ class BalWindow:
|
||||
wi.sigs_required = int(required)
|
||||
except Exception as e:
|
||||
_logger.debug(f"signature_count after signing failed: {e}")
|
||||
txs[txid] = tx
|
||||
except Exception:
|
||||
return None
|
||||
return txs
|
||||
return tx, True
|
||||
|
||||
def get_wallet_password(self, message=None, parent=None):
|
||||
parent = self.window if not parent else parent
|
||||
@@ -1034,6 +1183,297 @@ class BalWindow:
|
||||
password = self.get_wallet_password(message)
|
||||
return password
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Automatic rebuild on new transactions (AUTO_REBUILD)
|
||||
#
|
||||
# When the AUTO_REBUILD setting is enabled, wallet activity (a new
|
||||
# transaction / a sync update) schedules the headless rebuild flow below,
|
||||
# which reproduces EXACTLY what the "Build your will" wizard does at wallet
|
||||
# close (task_phase1 / task_phase2):
|
||||
#
|
||||
# * the delivery date of the rebuilt transactions is anticipated by one
|
||||
# day (Will.search_anticipate -> check_anticipate) so the new will
|
||||
# mines BEFORE the previous one and orphans it WITHOUT an on-chain
|
||||
# invalidation transaction;
|
||||
# * an on-chain invalidation transaction is built ONLY when the
|
||||
# anticipated locktime would fall before the check-alive threshold
|
||||
# (post-build check_will -> WillExpiredException), or when the
|
||||
# threshold is already in the past (CheckAliveError) - the same two
|
||||
# conditions that trigger invalidation in the wizard.
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def schedule_auto_rebuild(self, delay_ms=None):
|
||||
"""Debounced entry point for the auto-rebuild flow.
|
||||
|
||||
Called by ``Plugin._wallet_activity`` (on the asyncio callback thread)
|
||||
whenever a transaction/update is seen for this wallet. The actual
|
||||
rebuild is deferred through ``QTimer`` (thread-safe to schedule, runs
|
||||
on the GUI thread) so a burst of events collapses into a single run.
|
||||
"""
|
||||
try:
|
||||
delay = delay_ms if delay_ms is not None else self._AUTO_REBUILD_DEBOUNCE_MS
|
||||
QTimer.singleShot(delay, self._run_auto_rebuild)
|
||||
except Exception as e:
|
||||
_logger.debug("schedule_auto_rebuild failed: {}".format(e))
|
||||
|
||||
def _run_auto_rebuild(self):
|
||||
"""GUI-thread guard before launching the auto-rebuild worker.
|
||||
|
||||
Checks the cheap guards that must be evaluated on the GUI thread and,
|
||||
when allowed, runs the headless flow in a background thread so the
|
||||
interface is not frozen (signing/pushing can take a while).
|
||||
"""
|
||||
if not self._auto_rebuild_allowed():
|
||||
return
|
||||
self._auto_rebuild_running = True
|
||||
threading.Thread(target=self._auto_rebuild_worker, daemon=True).start()
|
||||
|
||||
def _auto_rebuild_worker(self):
|
||||
try:
|
||||
self._auto_rebuild_flow()
|
||||
except Exception as e:
|
||||
_logger.error("auto rebuild worker failed: {}".format(e))
|
||||
finally:
|
||||
self._auto_rebuild_running = False
|
||||
QTimer.singleShot(0, self._after_auto_rebuild)
|
||||
|
||||
def _auto_rebuild_allowed(self):
|
||||
"""Cheap guards evaluated before running the auto-rebuild flow."""
|
||||
if self.disable_plugin or not self.ok:
|
||||
return False
|
||||
if not self.bal_plugin.AUTO_REBUILD.get():
|
||||
return False
|
||||
if not self.willitems:
|
||||
return False
|
||||
if self._auto_rebuild_running:
|
||||
return False
|
||||
if time.time() < self._auto_rebuild_cooldown_until:
|
||||
return False
|
||||
return True
|
||||
|
||||
def maybe_auto_rebuild(self):
|
||||
"""Run the headless auto-rebuild flow synchronously on this thread.
|
||||
|
||||
This is the testable entry point (and what the background worker
|
||||
runs): it reproduces the wizard's close-time flow and returns True when
|
||||
it rebuilt/invalidated the will, False when there was nothing to do.
|
||||
"""
|
||||
if not self._auto_rebuild_allowed():
|
||||
return False
|
||||
self._auto_rebuild_running = True
|
||||
try:
|
||||
result = self._auto_rebuild_flow()
|
||||
finally:
|
||||
self._auto_rebuild_running = False
|
||||
QTimer.singleShot(0, self._after_auto_rebuild)
|
||||
return result
|
||||
|
||||
def _after_auto_rebuild(self):
|
||||
"""Refresh the will tabs after an auto-rebuild (GUI thread)."""
|
||||
try:
|
||||
self.update_all()
|
||||
except Exception as e:
|
||||
_logger.debug("_after_auto_rebuild update_all failed: {}".format(e))
|
||||
try:
|
||||
if hasattr(self, "will_list_widget"):
|
||||
self.will_list_widget.update()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _auto_rebuild_flow(self):
|
||||
"""Core headless rebuild flow (mirrors the wizard's close flow).
|
||||
|
||||
Returns True when the will was rebuilt or invalidated, False when there
|
||||
was nothing to do. Runs on the caller's thread.
|
||||
"""
|
||||
try:
|
||||
self._auto_rebuild_cooldown_until = (
|
||||
time.time() + self._AUTO_REBUILD_COOLDOWN
|
||||
)
|
||||
_logger.info("auto rebuild: checking will after wallet activity")
|
||||
|
||||
# 1) Recompute date_to_check / willexecutors exactly like
|
||||
# init_class_variables does at the start of the wizard's phase 1.
|
||||
# A Check Alive threshold already in the past (ADVANCED mode)
|
||||
# means the old will must be invalidated on-chain.
|
||||
try:
|
||||
self.init_class_variables()
|
||||
except CheckAliveError:
|
||||
_logger.info("auto rebuild: check-alive threshold passed -> invalidate")
|
||||
self._auto_invalidate_will()
|
||||
return True
|
||||
except NoHeirsException:
|
||||
_logger.info("auto rebuild: no heirs, nothing to rebuild")
|
||||
return False
|
||||
|
||||
# 2) Check the current will against the freshly computed reference
|
||||
# date. A still-valid will needs no rebuild.
|
||||
try:
|
||||
self.check_will()
|
||||
_logger.debug("auto rebuild: will is still valid, nothing to do")
|
||||
return False
|
||||
except (WillExpiredException, WillPostponedException) as e:
|
||||
# Expired ("too late to anticipate") or a postpone on a
|
||||
# signed/sent will: the old coins must be invalidated on-chain
|
||||
# first.
|
||||
_logger.info(
|
||||
"auto rebuild: {} -> invalidate".format(type(e).__name__)
|
||||
)
|
||||
self._auto_invalidate_will()
|
||||
return True
|
||||
except NoHeirsException:
|
||||
return False
|
||||
except NotCompleteWillException:
|
||||
# The will no longer covers the wallet's current UTXOs / heirs
|
||||
# / date: rebuild it. The rebuild automatically anticipates
|
||||
# the delivery date by one day when the same coins/heirs are
|
||||
# involved (Will.search_anticipate), so the new transactions
|
||||
# mine before the previous ones.
|
||||
pass
|
||||
|
||||
# 3) Rebuild.
|
||||
try:
|
||||
txs = self.build_will()
|
||||
except Exception as e:
|
||||
_logger.error("auto rebuild: build_will failed: {}".format(e))
|
||||
return False
|
||||
if not txs:
|
||||
_logger.info("auto rebuild: nothing was built")
|
||||
return False
|
||||
|
||||
# 4) Re-validate the freshly built will (mirrors task_phase1 after
|
||||
# build_will). If the anticipated locktime now falls before the
|
||||
# check-alive threshold, the previous will must be invalidated
|
||||
# on-chain before the new one is used - and we STOP, exactly like
|
||||
# the wizard ("invalidate_classic"): signing/pushing the new will
|
||||
# while the invalidation is not confirmed would race it for the
|
||||
# same inputs. The next wallet event / manual Check continues
|
||||
# once the invalidation confirms.
|
||||
try:
|
||||
self.check_will()
|
||||
except (WillExpiredException, WillPostponedException) as e:
|
||||
_logger.info(
|
||||
"auto rebuild: anticipated locktime crossed threshold "
|
||||
"({}) -> invalidate old will".format(type(e).__name__)
|
||||
)
|
||||
self._auto_invalidate_will()
|
||||
return True
|
||||
except NoHeirsException:
|
||||
return False
|
||||
except NotCompleteWillException:
|
||||
# The freshly rebuilt transactions simply need signing.
|
||||
pass
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
"auto rebuild: post-build check failed: {}".format(e)
|
||||
)
|
||||
return False
|
||||
|
||||
# 5) Sign (passwordless wallets only, headlessly), persist and push
|
||||
# the rebuilt transactions to their will-executors: pushing the
|
||||
# earlier-locktime transactions is what makes them orphan the
|
||||
# previous ones.
|
||||
self._auto_sign_save_push()
|
||||
return True
|
||||
finally:
|
||||
# Always apply the cooldown so a burst of events (or the wallet
|
||||
# events fired by our own persistence) cannot loop forever.
|
||||
self._auto_rebuild_cooldown_until = (
|
||||
time.time() + self._AUTO_REBUILD_COOLDOWN
|
||||
)
|
||||
|
||||
def _auto_invalidate_will(self, will=None):
|
||||
"""Build, sign and broadcast the on-chain invalidation tx, headlessly.
|
||||
|
||||
Reuses the exact recipe of the wizard's ``loop_broadcast_invalidating``
|
||||
(label set before broadcast, tx info pulled from wallet/network,
|
||||
broadcast timeout 120s) without any dialog. An encrypted wallet cannot
|
||||
sign headlessly, so we stop with a logged warning and leave the
|
||||
invalidation to the user's manual flow.
|
||||
"""
|
||||
willitems = will if will is not None else self.willitems
|
||||
try:
|
||||
tx = Will.invalidate_will(
|
||||
willitems,
|
||||
self.wallet,
|
||||
self.will_settings.get("baltx_fees", 1),
|
||||
history_label=self.bal_plugin.HISTORY_LABEL.get(),
|
||||
will_locktime=Will.get_min_locktime(
|
||||
willitems,
|
||||
default_value=getattr(self, "date_to_check", None),
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error("auto invalidate: could not build tx: {}".format(e))
|
||||
return None
|
||||
if not tx:
|
||||
_logger.info("auto invalidate: no transactions to invalidate")
|
||||
return None
|
||||
try:
|
||||
if self.wallet.has_keystore_encryption():
|
||||
_logger.warning(
|
||||
"auto invalidate: wallet is encrypted; signing the "
|
||||
"invalidation requires the password -> invalidate manually"
|
||||
)
|
||||
return None
|
||||
network = getattr(self.wallet, "network", None)
|
||||
if network is None:
|
||||
_logger.error("auto invalidate: no network, cannot broadcast")
|
||||
return None
|
||||
tx = self.wallet.sign_transaction(tx, None, ignore_warnings=True)
|
||||
if not tx or not tx.is_complete():
|
||||
raise Exception("invalidation tx not complete")
|
||||
tx.add_info_from_wallet(self.wallet)
|
||||
network.run_from_another_thread(tx.add_info_from_network(network))
|
||||
txid = tx.txid()
|
||||
if txid:
|
||||
# Label BEFORE broadcasting so the History tab shows it the
|
||||
# moment the tx appears (matches the wizard behaviour).
|
||||
self.wallet.set_label(txid, "BAL Invalidate transaction")
|
||||
network.run_from_another_thread(
|
||||
network.broadcast_transaction(tx, timeout=120), timeout=120
|
||||
)
|
||||
_logger.info("auto invalidate: broadcast invalidation {}".format(txid))
|
||||
return tx
|
||||
except Exception as e:
|
||||
_logger.error("auto invalidate failed: {}".format(e))
|
||||
return None
|
||||
|
||||
def _auto_sign_save_push(self):
|
||||
"""Headless sign + persist + push of the rebuilt will.
|
||||
|
||||
Mirrors the wizard's phase 2 (sign_transactions -> save_willitems ->
|
||||
push_transactions_to_willexecutors) without dialogs. Encrypted
|
||||
wallets cannot be signed headlessly, so the rebuilt transactions are
|
||||
left unsigned ("New") for the user to sign manually.
|
||||
"""
|
||||
try:
|
||||
if self.wallet.has_keystore_encryption():
|
||||
_logger.warning(
|
||||
"auto rebuild: wallet is encrypted; rebuilt will left "
|
||||
"unsigned (sign manually)"
|
||||
)
|
||||
else:
|
||||
txs = self.sign_transactions(None)
|
||||
if txs:
|
||||
for txid, tx in txs.items():
|
||||
# Store the signed tx back, like
|
||||
# ask_password_and_sign_transactions.on_success does
|
||||
# (re-parse instead of deepcopy: the signed tx may carry
|
||||
# wallet-derived input info holding a threading.RLock).
|
||||
self.willitems[txid].tx = Will.get_tx_from_any(str(tx))
|
||||
except Exception as e:
|
||||
_logger.error("auto rebuild: signing failed: {}".format(e))
|
||||
try:
|
||||
self.save_willitems()
|
||||
except Exception as e:
|
||||
_logger.error("auto rebuild: save_willitems failed: {}".format(e))
|
||||
self._save_will_to_history()
|
||||
try:
|
||||
self.push_transactions_to_willexecutors()
|
||||
except Exception as e:
|
||||
_logger.error("auto rebuild: push failed: {}".format(e))
|
||||
|
||||
def on_close(self):
|
||||
# Wallet is closing: run the closing "build will" task and tear down
|
||||
# the plugin's tabs/menu. Each step is isolated so that one failure
|
||||
@@ -1044,7 +1484,10 @@ class BalWindow:
|
||||
return
|
||||
|
||||
# 1) Business logic: build/save the will on close (unchanged behaviour).
|
||||
# REBUILD_ON_CLOSE gates the "Build your will" wizard only: the will is
|
||||
# still persisted so a manual Build/Check from the session is not lost.
|
||||
try:
|
||||
if self.bal_plugin.REBUILD_ON_CLOSE.get():
|
||||
close_window = BalBuildWillDialog(self)
|
||||
close_window.build_will_task()
|
||||
self.save_willitems()
|
||||
@@ -1170,7 +1613,7 @@ class BalWindow:
|
||||
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
|
||||
|
||||
def getMsg(willexecutors):
|
||||
msg = "Broadcasting Transactions to Will-Executors:\n"
|
||||
msg = _("Broadcasting Transactions to Will-Executors:") + "\n"
|
||||
for url in willexecutors:
|
||||
msg += f"{url}:\t{willexecutors[url]['broadcast_status']}\n"
|
||||
return msg
|
||||
@@ -1224,8 +1667,8 @@ class BalWindow:
|
||||
if self.waiting_dialog._stopping:
|
||||
return
|
||||
self.waiting_dialog.update(
|
||||
"checking {} - {} : {}".format(
|
||||
willitems[wid].we["url"], wid, "Waiting"
|
||||
_("checking {} - {} : {}").format(
|
||||
willitems[wid].we["url"], wid, _("Waiting")
|
||||
)
|
||||
)
|
||||
w = willitems[wid]
|
||||
@@ -1233,7 +1676,7 @@ class BalWindow:
|
||||
Willexecutors.check_transaction(wid, w.we["url"])
|
||||
)
|
||||
self.waiting_dialog.update(
|
||||
"checked {} - {} : {}".format(
|
||||
_("checked {} - {} : {}").format(
|
||||
willitems[wid].we["url"],
|
||||
wid,
|
||||
willitems[wid].get_status("CHECKED"),
|
||||
@@ -1252,6 +1695,19 @@ class BalWindow:
|
||||
else:
|
||||
write_json_file(path, {wid: wi.to_dict() for wid, wi in will.items()})
|
||||
|
||||
def export_tx_file(self, path, will=None):
|
||||
"""Export only the serialized transactions of the given will items.
|
||||
|
||||
Writes a plain text file with every transaction (or PSBT) serialized
|
||||
on a single line, separated by a comma (``tx1,tx2,tx3``). The raw hex
|
||||
and PSBT base64 alphabets never contain a comma, so the separator is
|
||||
unambiguous. When ``will`` is omitted the live will items are used.
|
||||
"""
|
||||
willitems = will if will is not None else self.willitems
|
||||
serialized = ",".join(str(wi.tx) for wid, wi in willitems.items())
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(serialized)
|
||||
|
||||
def export_will(self, will=None):
|
||||
try:
|
||||
export_meta_gui(
|
||||
@@ -1261,6 +1717,73 @@ class BalWindow:
|
||||
self.show_error(str(e))
|
||||
raise e
|
||||
|
||||
def export_will_dialog(self, will=None, initial_mode: Optional[str] = None):
|
||||
"""Open the unified export window (File / QR / Audio).
|
||||
|
||||
The window lets the user pick an All / Valid / Valid NC filter in
|
||||
the top row and choose one of the three transports, each with its
|
||||
contextual settings (file format for File, QR-code size and autoplay
|
||||
for QR, KB/sec for Audio). ``will`` defaults to the live will items;
|
||||
``initial_mode`` opens the window directly on the given transport.
|
||||
"""
|
||||
try:
|
||||
willitems = will if will is not None else self.willitems
|
||||
d = WillExportDialog(
|
||||
self,
|
||||
will=willitems,
|
||||
bal_plugin=self.bal_plugin,
|
||||
initial_mode=initial_mode or "file",
|
||||
)
|
||||
show_on_top(d)
|
||||
except Exception as e:
|
||||
self.show_error(str(e))
|
||||
raise e
|
||||
|
||||
def get_audio_modem_plugin(self):
|
||||
"""Return Electrum's ``audio_modem`` plugin instance, or None.
|
||||
|
||||
The plugin is only usable when Electrum exposes it (the ``Plugins``
|
||||
manager knows the name) and its optional runtime dependency
|
||||
``amodem`` is installed (:meth:`is_available`). Every other case
|
||||
returns None so callers can simply hide the audio buttons.
|
||||
"""
|
||||
try:
|
||||
p = self.window.gui_object.plugins.get("audio_modem")
|
||||
except Exception:
|
||||
return None
|
||||
if not p or not getattr(p, "is_available", lambda: False)():
|
||||
return None
|
||||
return p
|
||||
|
||||
def _audio_send_payload(self, payload):
|
||||
"""Send a transfer payload through the audio_modem plugin.
|
||||
|
||||
Wraps the plugin's own ``_send`` with a proper parent widget. The
|
||||
audio channel zlib-compresses internally, so the payload is passed
|
||||
uncompressed (no BAL ``Z`` flag needed on that transport).
|
||||
"""
|
||||
plugin = self.get_audio_modem_plugin()
|
||||
if plugin is None:
|
||||
self.show_error(_("Audio MODEM plugin is not available."))
|
||||
return
|
||||
plugin._send(parent=self.window, blob=payload)
|
||||
|
||||
def set_audio_modem_bitrate(self, kbps):
|
||||
"""Set the ``audio_modem`` plugin transfer speed to ``kbps`` KB/sec.
|
||||
|
||||
Both the send and the receive paths read ``modem_config``, so the
|
||||
sender and the receiver must be configured with the same speed. Raises
|
||||
when the plugin (or its ``amodem`` dependency) is unavailable.
|
||||
"""
|
||||
plugin = self.get_audio_modem_plugin()
|
||||
if plugin is None:
|
||||
raise Exception(_("Audio MODEM plugin is not available."))
|
||||
try:
|
||||
import amodem.config
|
||||
except Exception as e:
|
||||
raise Exception(str(e)) from e
|
||||
plugin.modem_config = amodem.config.bitrates[int(kbps)]
|
||||
|
||||
def merge_will(self, imported):
|
||||
"""Merge imported will items into the live will.
|
||||
|
||||
@@ -1384,16 +1907,34 @@ class BalWindow:
|
||||
|
||||
def on_file(path):
|
||||
try:
|
||||
willitems = self._load_will_file(path)
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
except Exception as e:
|
||||
self.show_error(_("Invalid will file: {}").format(e))
|
||||
return
|
||||
# Attach wallet/input info so the imported txs can be signed and
|
||||
# broadcast (mirrors what merge_will_from_file does).
|
||||
kind, data = decode_will_payload(text)
|
||||
try:
|
||||
if kind == "will":
|
||||
willitems = self._load_will_payload(data)
|
||||
# Attach wallet/input info so the imported txs can be
|
||||
# signed and broadcast (mirrors merge_will_from_file).
|
||||
Will.normalize_will(willitems, self.wallet)
|
||||
for wi in willitems.values():
|
||||
wi.set_status("IMPORTED", True)
|
||||
imported.update(willitems)
|
||||
else:
|
||||
# Serialized transactions: route through the shared import
|
||||
# tail (validity pass + review/sign wizard).
|
||||
_complete_import(
|
||||
self,
|
||||
self.bal_plugin,
|
||||
text,
|
||||
show_error=self.show_error,
|
||||
show_warning=self.show_warning,
|
||||
close=lambda: None,
|
||||
)
|
||||
except Exception as e:
|
||||
self.show_error(_("Invalid will file: {}").format(e))
|
||||
|
||||
def on_success():
|
||||
if not imported:
|
||||
@@ -1403,6 +1944,18 @@ class BalWindow:
|
||||
|
||||
import_meta_gui(self.window, _("will"), on_file, on_success)
|
||||
|
||||
def import_will_dialog(self):
|
||||
"""Open the unified import window (File / QR / Audio).
|
||||
|
||||
The window offers three transports: File opens the read-only
|
||||
:class:`WillDetailDialog` preview; QR and Audio capture the
|
||||
transfer and send it through the per-transaction review wizard
|
||||
(:class:`WillTxReviewSignDialog`). Every flow works on fresh
|
||||
:class:`WillItem` objects and never touches the live will.
|
||||
"""
|
||||
d = WillImportDialog(self, bal_plugin=self.bal_plugin)
|
||||
show_on_top(d)
|
||||
|
||||
def _load_will_file(self, path):
|
||||
data = read_json_file(path)
|
||||
willitems = {}
|
||||
@@ -1411,6 +1964,15 @@ class BalWindow:
|
||||
willitems[k] = WillItem(data[k], _id=k)
|
||||
return willitems
|
||||
|
||||
def _load_will_payload(self, data):
|
||||
"""Build WillItems from decoded whole-will JSON data."""
|
||||
willitems = {}
|
||||
for k, v in data.items():
|
||||
d = dict(v)
|
||||
d["tx"] = tx_from_any(d["tx"])
|
||||
willitems[k] = WillItem(d, _id=k)
|
||||
return willitems
|
||||
|
||||
def check_transactions_task(self, will):
|
||||
start = time.time()
|
||||
# Servers are now contacted in parallel (see
|
||||
@@ -1583,7 +2145,7 @@ class BalWindow:
|
||||
|
||||
# Simple, user-facing message shown when the download fails for any reason
|
||||
# (the technical cause is in the Electrum log).
|
||||
DOWNLOAD_FAILED_MESSAGE = (
|
||||
DOWNLOAD_FAILED_MESSAGE = N_(
|
||||
"Could not download the will-executors list.\n\n"
|
||||
"This is usually caused by your internet connection or a firewall, "
|
||||
"not by the plugin. Please check your connection (a VPN often helps) "
|
||||
@@ -1593,7 +2155,7 @@ class BalWindow:
|
||||
# Shown when the download fails while Electrum is connected through Tor:
|
||||
# the most common cause is a slow Tor connection, so guide the user
|
||||
# accordingly instead of the generic message above.
|
||||
DOWNLOAD_FAILED_TOR_MESSAGE = (
|
||||
DOWNLOAD_FAILED_TOR_MESSAGE = N_(
|
||||
"Could not download the will-executors list over Tor.\n\n"
|
||||
"Electrum is connected through Tor and the connection is taking too "
|
||||
"long. Your Tor connection may be slow. Please try again, or use a VPN "
|
||||
@@ -1666,27 +2228,27 @@ class BalWindow:
|
||||
if err.url and err.reason == "empty response":
|
||||
# Server reached but returned no data for this chain.
|
||||
self.show_warning(_(
|
||||
f"No active will-executor servers found for the "
|
||||
f"{err.chain} network.\n\n"
|
||||
f"The welist server at {err.url} responded but "
|
||||
f"returned no will-executors for this chain."
|
||||
))
|
||||
"No active will-executor servers found for the "
|
||||
"{chain} network.\n\n"
|
||||
"The welist server at {url} responded but "
|
||||
"returned no will-executors for this chain."
|
||||
).format(chain=err.chain, url=err.url))
|
||||
elif err.url:
|
||||
# Advanced mode with a non-empty error (network issue).
|
||||
self.show_warning(_(
|
||||
f"Could not reach the configured welist server.\n\n"
|
||||
f"Server: {err.url}\n"
|
||||
f"Error: {err.reason}\n\n"
|
||||
f"Please verify the welist server URL in the plugin "
|
||||
f"settings."
|
||||
))
|
||||
"Could not reach the configured welist server.\n\n"
|
||||
"Server: {url}\n"
|
||||
"Error: {reason}\n\n"
|
||||
"Please verify the welist server URL in the plugin "
|
||||
"settings."
|
||||
).format(url=err.url, reason=err.reason))
|
||||
else:
|
||||
# Basic mode: the server responded but has no data for this
|
||||
# chain.
|
||||
self.show_warning(_(
|
||||
f"No active will-executor found for the "
|
||||
f"{err.chain} network."
|
||||
))
|
||||
"No active will-executor found for the "
|
||||
"{chain} network."
|
||||
).format(chain=err.chain))
|
||||
else:
|
||||
# Tor-aware: a generic failure/timeout while on Tor is most
|
||||
# likely a slow Tor connection.
|
||||
|
||||
198
bal/i18n.py
Normal file
198
bal/i18n.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
bal.i18n
|
||||
========
|
||||
|
||||
Translation layer of the BAL plugin (gettext domain ``bal``).
|
||||
|
||||
Why BAL has its own catalog
|
||||
---------------------------
|
||||
Electrum translates its interface with gettext (domain ``electrum``). Its
|
||||
translators only see Electrum's own source code, so the texts of an external
|
||||
(zip) plugin such as BAL are not in Electrum's catalog, and Electrum offers no
|
||||
way to translate external plugins. BAL therefore ships its own compiled
|
||||
catalogs inside the plugin: ``locale/<lang>/LC_MESSAGES/bal.mo``.
|
||||
|
||||
Lookup order
|
||||
------------
|
||||
:func:`_` returns the first translation found in:
|
||||
|
||||
1. **Electrum's catalog.** When Electrum already translates the exact same
|
||||
English text, its translation wins. BAL then reads like Electrum and
|
||||
follows Electrum's own translation fixes; if Electrum ever ships BAL as an
|
||||
internal plugin, Electrum's translations take over with no change here
|
||||
(owner's decision D7 in ``PLAN_I18N.md``).
|
||||
2. **BAL's catalog**, for the texts that only BAL has.
|
||||
3. **The English source text.**
|
||||
|
||||
Language
|
||||
--------
|
||||
Electrum picks the GUI language once, at start-up (``run_electrum``), and a
|
||||
change needs a restart. :func:`init_from_config` repeats Electrum's choice
|
||||
once, when the Qt plugin is created. BAL never detects the language by
|
||||
itself: otherwise part of a window could be in one language and part in
|
||||
another. The CLI never calls it, so the CLI stays in English, like Electrum's.
|
||||
|
||||
Rules for translatable texts
|
||||
----------------------------
|
||||
* Wrap literal English text only: ``_("Sign the will")``.
|
||||
* Put the variable parts in ``{}`` and fill them *after* translating:
|
||||
``_("{} heirs").format(count)``. Never build the text with an f-string or
|
||||
``%`` inside ``_()``: it would change at run time and never match a catalog
|
||||
entry (Ruff rules INT001-INT003 catch this).
|
||||
* Texts evaluated at import time (class attributes, tables, constants) are
|
||||
marked with :func:`N_` and passed to ``_()`` when they are displayed. Every
|
||||
class body runs before :func:`init_from_config`, so a ``_()`` there would
|
||||
always return English.
|
||||
* Never translate text that is stored or compared (wallet labels, the saved
|
||||
status history, config values used to recognise BAL transactions): stored
|
||||
data must not depend on the UI language.
|
||||
"""
|
||||
|
||||
import gettext
|
||||
import io
|
||||
import string
|
||||
from typing import Optional
|
||||
|
||||
from electrum.i18n import _ as _electrum_gettext
|
||||
from electrum.logging import get_logger
|
||||
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
#: gettext domain of the BAL catalogs.
|
||||
DOMAIN = "bal"
|
||||
|
||||
# BAL catalog of the current language, or None (English, CLI, or no usable
|
||||
# catalog). Set by set_language().
|
||||
_catalog: Optional[gettext.GNUTranslations] = None
|
||||
|
||||
# Source texts whose BAL translation was rejected: the warning is logged once,
|
||||
# not at every repaint of the widget that shows the text.
|
||||
_rejected: set = set()
|
||||
|
||||
_formatter = string.Formatter()
|
||||
|
||||
|
||||
def keeps_format_fields(msg: str, translation: str) -> bool:
|
||||
"""Tell whether ``translation`` keeps the ``{}`` fields of ``msg``.
|
||||
|
||||
A translation that adds, drops or renames a replacement field would make
|
||||
the ``.format()`` call that follows raise, or put a value in the wrong
|
||||
place, so such a translation must not be used.
|
||||
|
||||
The rules are copied from Electrum's
|
||||
``_ensure_translation_keeps_format_string_syntax_similar``
|
||||
(``electrum/i18n.py``, MIT licence, Copyright (C) The Electrum developers)
|
||||
rather than importing that private function, which may change without
|
||||
notice. Keep them identical, so a BAL translation is accepted or rejected
|
||||
exactly like an Electrum one. The only addition: a malformed source text
|
||||
returns False here instead of raising.
|
||||
"""
|
||||
try:
|
||||
# Tuples of (literal_text, field_name, format_spec, conversion).
|
||||
parsed1 = list(_formatter.parse(msg))
|
||||
parsed2 = list(_formatter.parse(translation))
|
||||
except ValueError: # malformed format string
|
||||
return False
|
||||
if len(parsed1) != len(parsed2):
|
||||
return False
|
||||
# The set of field names must not change (re-ordering them is allowed).
|
||||
return {t[1] for t in parsed1} == {t[1] for t in parsed2}
|
||||
|
||||
|
||||
def _(msg: str) -> str:
|
||||
"""Return ``msg`` translated into the GUI language.
|
||||
|
||||
See the module docstring for the lookup order. ``_("")`` returns ``""``:
|
||||
for gettext the empty text is the key of the catalog header.
|
||||
"""
|
||||
if msg == "":
|
||||
return ""
|
||||
translation = _electrum_gettext(msg)
|
||||
if translation != msg or _catalog is None:
|
||||
# Electrum's translation wins (Electrum has already checked its {}
|
||||
# fields); without a BAL catalog there is nothing else to look up.
|
||||
return translation
|
||||
translation = _catalog.gettext(msg)
|
||||
if translation == msg or keeps_format_fields(msg, translation):
|
||||
return translation
|
||||
if msg not in _rejected:
|
||||
_rejected.add(msg)
|
||||
_logger.warning(
|
||||
f"rejected BAL translation, replacement fields differ: "
|
||||
f"{msg!r} -> {translation!r}"
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
def N_(msg: str) -> str: # noqa: N802 (the conventional gettext marker name)
|
||||
"""Mark ``msg`` as translatable and return it unchanged.
|
||||
|
||||
Used where a text is defined at import time (class attributes, tables,
|
||||
constants). Extraction tools (Babel, xgettext) collect ``N_("...")`` like
|
||||
``_("...")``; the code passes the value to ``_()`` when it shows it.
|
||||
"""
|
||||
return msg
|
||||
|
||||
|
||||
def set_language(plugin, lang: Optional[str]) -> None:
|
||||
"""Load the BAL catalog of ``lang`` (for example ``"it_IT"``), or none.
|
||||
|
||||
``None``, ``""`` and English (``"en_*"``) load no catalog, so the English
|
||||
source texts are shown, as Electrum does for ``en_*``.
|
||||
|
||||
The catalog is read with ``plugin.read_file()``, the Electrum API BAL uses
|
||||
for its icons: it works both from the installed zip and from a development
|
||||
checkout. ``gettext.translation(localedir=...)`` is not used because,
|
||||
pointed inside a zip, it silently finds nothing.
|
||||
|
||||
If ``locale/<lang>/`` has no catalog, the plain language code is tried
|
||||
(``locale/it/`` for ``it_IT``). A missing or unreadable catalog is logged
|
||||
and BAL stays in English: a translation problem must never stop the
|
||||
plugin.
|
||||
"""
|
||||
global _catalog
|
||||
_catalog = None
|
||||
_rejected.clear()
|
||||
if not isinstance(lang, str) or not lang or lang.startswith("en_"):
|
||||
return
|
||||
codes = [lang]
|
||||
if "_" in lang:
|
||||
codes.append(lang.split("_", 1)[0])
|
||||
for code in codes:
|
||||
path = "locale/{}/LC_MESSAGES/{}.mo".format(code, DOMAIN)
|
||||
try:
|
||||
data = plugin.read_file(path)
|
||||
except Exception:
|
||||
# Not shipped for this code (a zip raises KeyError, a development
|
||||
# folder raises OSError): try the next code.
|
||||
continue
|
||||
try:
|
||||
_catalog = gettext.GNUTranslations(io.BytesIO(data))
|
||||
except Exception as e:
|
||||
_logger.info(f"unreadable BAL catalog {path}, using English: {e!r}")
|
||||
return
|
||||
_logger.info(f"BAL catalog loaded: {path}")
|
||||
return
|
||||
_logger.info(f"no BAL catalog for {lang!r}, using English")
|
||||
|
||||
|
||||
def init_from_config(plugin, config) -> None:
|
||||
"""Load the BAL catalog of the language Electrum's Qt GUI is using.
|
||||
|
||||
Repeats Electrum's own choice in ``run_electrum``: the ``language``
|
||||
setting (Preferences > Language) or, when it is empty, the system language
|
||||
if Electrum supports it, else English. Called once, from the Qt plugin's
|
||||
``__init__``. Never raises.
|
||||
"""
|
||||
try:
|
||||
lang = config.LOCALIZATION_LANGUAGE
|
||||
if not lang:
|
||||
from electrum.gui.default_lang import get_default_language
|
||||
|
||||
lang = get_default_language(gui_name="qt")
|
||||
except Exception as e:
|
||||
# Electrum also falls back to English when it cannot read the
|
||||
# system language.
|
||||
_logger.info(f"could not read the GUI language, using English: {e!r}")
|
||||
lang = None
|
||||
set_language(plugin, lang)
|
||||
1918
bal/locale/bal.pot
Normal file
1918
bal/locale/bal.pot
Normal file
File diff suppressed because it is too large
Load Diff
2082
bal/locale/it_IT/LC_MESSAGES/bal.po
Normal file
2082
bal/locale/it_IT/LC_MESSAGES/bal.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "bal",
|
||||
"fullname": "Bitcoin After Life",
|
||||
"version": "0.6.1",
|
||||
"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"
|
||||
"qt",
|
||||
"cmdline"
|
||||
],
|
||||
"icon": "icons/bal32x32.png"
|
||||
}
|
||||
53
build_zip.py
53
build_zip.py
@@ -9,7 +9,12 @@ Electrum loads external plugins from a ``.zip`` using Python's ``zipimport``.
|
||||
* uses standard DEFLATE compression (well supported by ``zipimport``);
|
||||
* emits entries in a deterministic, sorted order so the archive is
|
||||
reproducible (stable SHA-256);
|
||||
* skips ``__pycache__`` directories and compiled ``*.pyc``/``*.pyo`` files.
|
||||
* skips ``__pycache__`` directories and compiled ``*.pyc``/``*.pyo`` files;
|
||||
* compiles each translation catalog ``bal/locale/<lang>/LC_MESSAGES/bal.po``
|
||||
into ``bal.mo`` inside the archive (the ``.mo`` files are not kept in
|
||||
git, and the ``.po``/``.pot`` sources are not shipped). This needs Babel
|
||||
(``pip install babel``); without it the build stops, so a release can
|
||||
never ship without its translations by mistake.
|
||||
|
||||
The archive keeps the top-level ``bal/`` directory so that the package is
|
||||
importable as ``bal`` (and Electrum derives ``dirname='bal'`` from the path of
|
||||
@@ -23,14 +28,36 @@ Prints the resulting size and SHA-256 so the download can be integrity-checked.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
SRC_ROOT = "bal"
|
||||
DEFAULT_OUT = "bal-electrum-plugin.zip"
|
||||
|
||||
|
||||
def compile_catalog(po_path: str) -> bytes:
|
||||
"""Return the compiled ``.mo`` bytes of the gettext catalog ``po_path``.
|
||||
|
||||
Fuzzy (unreviewed) entries are left out, as ``msgfmt`` does.
|
||||
"""
|
||||
try:
|
||||
from babel.messages.mofile import write_mo
|
||||
from babel.messages.pofile import read_po
|
||||
except ImportError:
|
||||
raise SystemExit(
|
||||
"ERROR: Babel is needed to compile the translations "
|
||||
"(pip install babel)"
|
||||
) from None
|
||||
with open(po_path, "rb") as f:
|
||||
catalog = read_po(f)
|
||||
buf = io.BytesIO()
|
||||
write_mo(buf, catalog, use_fuzzy=False)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def build(out_path: str) -> None:
|
||||
if os.path.exists(out_path):
|
||||
os.remove(out_path)
|
||||
@@ -40,17 +67,34 @@ def build(out_path: str) -> None:
|
||||
# prune cache dirs in place so os.walk does not descend into them
|
||||
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
|
||||
for fn in filenames:
|
||||
if fn.endswith((".pyc", ".pyo")):
|
||||
# .mo files are rebuilt from the .po sources below; the .po/.pot
|
||||
# sources themselves are not shipped.
|
||||
if fn.endswith((".pyc", ".pyo", ".po", ".pot", ".mo")):
|
||||
continue
|
||||
files.append(os.path.join(dirpath, fn))
|
||||
catalogs = {} # archive path of the .mo -> source .po
|
||||
for dirpath, _dirnames, filenames in os.walk(os.path.join(SRC_ROOT, "locale")):
|
||||
for fn in filenames:
|
||||
if fn.endswith(".po"):
|
||||
po = os.path.join(dirpath, fn)
|
||||
catalogs[po[: -len(".po")] + ".mo"] = po
|
||||
files.sort()
|
||||
|
||||
with zipfile.ZipFile(
|
||||
out_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
|
||||
) as z:
|
||||
for f in files:
|
||||
entries = [(f, None) for f in files] + [
|
||||
(mo, po) for mo, po in catalogs.items()
|
||||
]
|
||||
for f, po in sorted(entries):
|
||||
arc = f.replace(os.sep, "/") # forward slashes inside the archive
|
||||
if po is None:
|
||||
z.write(f, arc)
|
||||
else:
|
||||
# Same timestamp rule as z.write(): the source file's mtime.
|
||||
info = zipfile.ZipInfo(arc, time.localtime(os.path.getmtime(po))[:6])
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
z.writestr(info, compile_catalog(po))
|
||||
|
||||
# Integrity + summary
|
||||
with zipfile.ZipFile(out_path) as z:
|
||||
@@ -63,7 +107,8 @@ def build(out_path: str) -> None:
|
||||
|
||||
data = open(out_path, "rb").read()
|
||||
print(f"built : {out_path}")
|
||||
print(f"files : {len(files)}")
|
||||
print(f"files : {len(files) + len(catalogs)}")
|
||||
print(f"langs : {', '.join(sorted(os.path.basename(os.path.dirname(os.path.dirname(m))) for m in catalogs)) or '-'}")
|
||||
print(f"size : {len(data)} bytes")
|
||||
print(f"sha256: {hashlib.sha256(data).hexdigest()}")
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -357,5 +357,5 @@ that limit.
|
||||
|
||||
---
|
||||
|
||||
*This document reflects the current BAL plugin (v0.6.1). Behaviour is derived
|
||||
*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`.*
|
||||
|
||||
@@ -475,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -527,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**
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ line-length = 88
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select =["E", "W", "F", "I", "N", "B"]
|
||||
select =["E", "W", "F", "I", "N", "B", "INT"] # INT: gettext rules (see bal/i18n.py)
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.lint.pep8-naming]
|
||||
classmethod-decorators = ["classmethod", "classproperty"] # electrum.util.classproperty uses cls
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"bal/gui/qt/*.py" = ["F403", "F405"] # intentional `from .common import *` hub
|
||||
"bal/gui/qt/common.py" = ["F401"] # re-exports consumed via `import *`
|
||||
"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, ...)
|
||||
|
||||
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
|
||||
11986
tests/karen7
11986
tests/karen7
File diff suppressed because one or more lines are too long
@@ -21,12 +21,12 @@ Run:
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
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 (
|
||||
HeirNotFoundException,
|
||||
NoHeirsException,
|
||||
@@ -58,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": "",
|
||||
@@ -67,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:
|
||||
@@ -118,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.
|
||||
|
||||
@@ -27,7 +27,6 @@ Run:
|
||||
tests/test_anticipate_manual_locktime.py -q
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -35,6 +34,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
import pytest # noqa: E402 # pyright: ignore[reportMissingImports]
|
||||
|
||||
from bal.core.util import copy_structure # noqa: E402
|
||||
from bal.core.will import ( # noqa: E402
|
||||
NotCompleteWillException,
|
||||
Will,
|
||||
@@ -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)
|
||||
|
||||
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")
|
||||
@@ -18,6 +18,7 @@ from bal.core.checkalive import ( # noqa: E402 (path insert above)
|
||||
CheckAliveError,
|
||||
check_alive_expired,
|
||||
resolve_date_to_check,
|
||||
resolve_guard_threshold,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -61,14 +62,14 @@ def test_advanced_mode_uses_threshold_absolute():
|
||||
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
|
||||
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)
|
||||
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
|
||||
@@ -91,7 +92,7 @@ def test_advanced_mode_relative_threshold_anchored_to_locktime():
|
||||
def test_advanced_mode_relative_threshold_with_relative_locktime():
|
||||
"""A relative locktime is resolved against 'now' first, then the relative
|
||||
threshold counts N days back from it (matches the settings widget)."""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from bal.core.plugin_base import BalTimestamp
|
||||
|
||||
@@ -100,7 +101,7 @@ def test_advanced_mode_relative_threshold_with_relative_locktime():
|
||||
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))
|
||||
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
|
||||
@@ -117,7 +118,7 @@ def test_advanced_mode_relative_locktime_anchored_to_built_tx():
|
||||
"""A RELATIVE stored locktime is anchored to the built will's frozen
|
||||
delivery date (built_locktime), not to "now": an unchanged will must not
|
||||
read as expired as the clock advances (the karen7 daily-invalidate bug)."""
|
||||
frozen = 1817438400 # frozen tx locktime (2027-08-05), built 2026-08-05
|
||||
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.
|
||||
@@ -136,18 +137,88 @@ def test_advanced_mode_relative_locktime_anchored_to_built_tx():
|
||||
def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
|
||||
"""Without a built will there is no anchor: keeps the legacy now-based
|
||||
resolution (a moving target, used only before the first build)."""
|
||||
from datetime import datetime
|
||||
from 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))
|
||||
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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -36,6 +36,7 @@ from bal.core.heirs import (
|
||||
is_op_return_address,
|
||||
validate_op_return_hex,
|
||||
)
|
||||
from bal.core.util import Util
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Constants
|
||||
@@ -167,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)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -14,7 +14,7 @@ import time
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from bal.core.plugin_base import BalConfig, BalPlugin, BalTimestamp
|
||||
|
||||
@@ -74,7 +74,7 @@ def test_bt_to_date_absolute():
|
||||
|
||||
|
||||
def test_bt_to_date_relative():
|
||||
now = datetime.now()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# relative days from now
|
||||
bt = BalTimestamp("7d")
|
||||
@@ -86,8 +86,8 @@ def test_bt_to_date_relative():
|
||||
d_rev = bt.to_date(reverse=True)
|
||||
assert d_rev < now
|
||||
|
||||
# from explicit datetime
|
||||
base = datetime(2025, 6, 1, 12, 0, 0)
|
||||
# from explicit datetime (UTC, so the naive-timestamp roundtrip below is stable)
|
||||
base = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
d = bt.to_date(from_date=base)
|
||||
expected = (base + timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
assert d == expected
|
||||
@@ -101,7 +101,7 @@ def test_bt_to_date_relative():
|
||||
def test_bt_to_date_years():
|
||||
bt = BalTimestamp("1y")
|
||||
d = bt.to_date()
|
||||
assert d > datetime.now()
|
||||
assert d > datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def test_bt_to_date_overflow():
|
||||
|
||||
439
tests/test_core_qr_transfer.py
Normal file
439
tests/test_core_qr_transfer.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
Tests for ``bal.core.qrtransfer``.
|
||||
|
||||
Covers the BALQR frame encoding used for will transfer via QR codes /
|
||||
audio modem: encoding, framing, reassembly, malformed input and the preset
|
||||
list (optionally cross-checked against the ``qrcode`` library's EC-M
|
||||
capacity when it is installed).
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
python3 tests/test_core_qr_transfer.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.qrtransfer import (
|
||||
CHUNK_PRESETS,
|
||||
MIN_CHUNK_SIZE,
|
||||
InconsistentTotalError,
|
||||
MissingFramesError,
|
||||
QrTransferError,
|
||||
assemble,
|
||||
decode_transfer,
|
||||
encode_transfer,
|
||||
parse_frame,
|
||||
preset_index_for_chunk_size,
|
||||
split_frames,
|
||||
)
|
||||
|
||||
|
||||
def _frames(tx_strings, chunk_size, compress=False):
|
||||
"""Split a payload and return (payload, total, {index: payload})."""
|
||||
payload = encode_transfer(tx_strings, compress=compress)
|
||||
parsed = {}
|
||||
total = None
|
||||
for frame in split_frames(payload, chunk_size, compressed=compress):
|
||||
t, index, _compressed, p = parse_frame(frame)
|
||||
if total is not None:
|
||||
assert total == t
|
||||
total = t
|
||||
parsed[index] = p
|
||||
assert total is not None
|
||||
return payload, total, parsed
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Round trips
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_encode_decode_plain():
|
||||
tx_strings = ["00" * 32, "aa" * 40, "ff" * 50]
|
||||
payload = encode_transfer(tx_strings, compress=False)
|
||||
assert decode_transfer(payload, compressed=False) == tx_strings
|
||||
|
||||
|
||||
def test_encode_decode_compressed():
|
||||
tx_strings = ["00" * 32, "aa" * 40, "ff" * 50]
|
||||
payload = encode_transfer(tx_strings, compress=True)
|
||||
assert decode_transfer(payload, compressed=True) == tx_strings
|
||||
|
||||
|
||||
def test_empty_list_roundtrip():
|
||||
assert decode_transfer(encode_transfer([]), compressed=False) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Framing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_single_frame():
|
||||
tx_strings = ["11" * 10]
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 150)
|
||||
assert len(frames) == 1
|
||||
total, index, compressed, p = parse_frame(frames[0])
|
||||
assert (total, index, compressed) == (1, 1, False)
|
||||
assert p == payload
|
||||
|
||||
|
||||
def test_multiple_frames_reassemble():
|
||||
tx_strings = ["ab" * 100, "cd" * 100] # 600 chars -> multiple frames
|
||||
_payload, total, parsed = _frames(tx_strings, CHUNK_PRESETS[0][1])
|
||||
assert total > 1
|
||||
decoded = decode_transfer(assemble(parsed, total), compressed=False)
|
||||
assert decoded == tx_strings
|
||||
|
||||
|
||||
def test_size_greater_than_payload():
|
||||
tx_strings = ["12" * 5]
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 1800)
|
||||
assert len(frames) == 1
|
||||
_t, _i, _c, p = parse_frame(frames[0])
|
||||
assert p == payload
|
||||
|
||||
|
||||
def test_exact_single_frame_boundary():
|
||||
# A 139-byte payload exactly fills the 150-byte preset budget (the 11-char
|
||||
# compact header plus payload), so the encoded frame is exactly 150.
|
||||
tx_strings = ["a" * 139]
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 150)
|
||||
assert len(frames) == 1
|
||||
assert len(frames[0]) == 150
|
||||
_t, _i, _c, p = parse_frame(frames[0])
|
||||
assert p == payload
|
||||
|
||||
|
||||
def test_frames_fit_chunk_size():
|
||||
tx_strings = ["".join("{:02x}".format(i) * 2) for i in range(300)]
|
||||
payload = encode_transfer(tx_strings)
|
||||
for _label, size in CHUNK_PRESETS:
|
||||
for frame in split_frames(payload, size):
|
||||
assert len(frame) <= size, (size, len(frame))
|
||||
|
||||
|
||||
def test_single_tx_larger_than_chunk():
|
||||
# A huge serialized tx must be split over several frames and reassemble
|
||||
# exactly (positional slicing is safe for hex/base64 text).
|
||||
tx_strings = ["7b" * 1000] # 2000 chars
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 150)
|
||||
assert len(frames) > 1
|
||||
parsed = {parse_frame(f)[1]: parse_frame(f)[3] for f in frames}
|
||||
total = parse_frame(frames[0])[0]
|
||||
assert assemble(parsed, total) == payload
|
||||
|
||||
|
||||
def test_compressed_frames_carry_flag():
|
||||
tx_strings = ["ab" * 40]
|
||||
frames = split_frames(encode_transfer(tx_strings, compress=True), 150, compressed=True)
|
||||
for frame in frames:
|
||||
_t, _i, compressed, _p = parse_frame(frame)
|
||||
assert compressed is True
|
||||
# Plain frames do not.
|
||||
frames_plain = split_frames(encode_transfer(tx_strings), 150)
|
||||
_t, _i, compressed, _p = parse_frame(frames_plain[0])
|
||||
assert compressed is False
|
||||
|
||||
|
||||
def test_compressed_roundtrip_through_frames():
|
||||
tx_strings = ["ab" * 50, "cd" * 50, "12" * 60]
|
||||
payload = encode_transfer(tx_strings, compress=True)
|
||||
parsed = {}
|
||||
total = None
|
||||
for frame in split_frames(payload, 400, compressed=True):
|
||||
t, index, _c, p = parse_frame(frame)
|
||||
total = t
|
||||
parsed[index] = p
|
||||
assert total is not None
|
||||
decoded = decode_transfer(assemble(parsed, total), compressed=True)
|
||||
assert decoded == tx_strings
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Compact v2 wire format ("BAL1")
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_v2_frame_header_structure():
|
||||
frames = split_frames(encode_transfer(["11" * 10]), 150)
|
||||
assert len(frames) == 1
|
||||
frame = frames[0]
|
||||
assert frame.startswith("BAL1")
|
||||
# Fixed 11-char header: magic + 3-char total + 3-char index + 1 flag.
|
||||
assert len(frame) > 11
|
||||
magic, total_s, index_s, flag, payload = (
|
||||
frame[:4],
|
||||
frame[4:7],
|
||||
frame[7:10],
|
||||
frame[10],
|
||||
frame[11:],
|
||||
)
|
||||
assert magic == "BAL1"
|
||||
assert total_s == "001"
|
||||
assert index_s == "001"
|
||||
assert flag == "0"
|
||||
assert payload == "11" * 10
|
||||
total, index, compressed, p = parse_frame(frame)
|
||||
assert (total, index, compressed) == (1, 1, False)
|
||||
assert p == payload
|
||||
|
||||
|
||||
def test_v2_compressed_flag_is_z():
|
||||
frames = split_frames(
|
||||
encode_transfer(["11" * 10], compress=True), 150, compressed=True
|
||||
)
|
||||
assert frames[0][10] == "Z"
|
||||
_t, _i, compressed, _p = parse_frame(frames[0])
|
||||
assert compressed is True
|
||||
|
||||
|
||||
def test_v2_header_fixed_width_high_counts():
|
||||
# A long transfer needs multi-digit counts; the v2 header stays exactly
|
||||
# 11 chars no matter how many frames (3-char base36 zero-padded counts).
|
||||
tx_strings = ["ab" * 300] # 600 chars -> several frames at 150
|
||||
frames = split_frames(encode_transfer(tx_strings), 150)
|
||||
assert len(frames) > 1
|
||||
for frame in frames:
|
||||
# magic(4) + total(3) + index(3) + flag(1) = 11 chars, then payload.
|
||||
assert len(frame) - len(frame[11:]) == 11
|
||||
|
||||
|
||||
def test_v2_max_frame_count():
|
||||
# A transfer needing more than 46655 frames must be rejected (3-char
|
||||
# base36 count fields cannot represent larger totals).
|
||||
from bal.core.qrtransfer import _MAX_TOTAL
|
||||
|
||||
oversized = "A" * (_MAX_TOTAL * (150 - 11) + 1)
|
||||
try:
|
||||
split_frames(oversized, 150)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError above the frame cap")
|
||||
|
||||
|
||||
def test_v2_boundary_at_max_count():
|
||||
from bal.core.qrtransfer import _MAX_TOTAL
|
||||
|
||||
# Exactly at the cap: must still produce (bounded) frames with 3-char
|
||||
# counts "VVV" (46655) for the highest serialised part.
|
||||
payload = "B" * (_MAX_TOTAL * (150 - 11))
|
||||
frames = split_frames(payload, 150)
|
||||
assert len(frames) == _MAX_TOTAL
|
||||
total, index, _c, _p = parse_frame(frames[-1])
|
||||
assert total == _MAX_TOTAL
|
||||
assert index == _MAX_TOTAL
|
||||
assert frames[-1][:10] == "BAL1" + "ZZZ" + "ZZZ"
|
||||
|
||||
|
||||
def test_encode_transfer_best():
|
||||
from bal.core.qrtransfer import encode_transfer_best
|
||||
|
||||
# Redundant JSON-ish text compresses -> compressed (and longer source
|
||||
# must round-trip unchanged).
|
||||
txs = ['{"a": "%s"}' % ("x" * 300), '{"b": "%s"}' % ("y" * 300)]
|
||||
transfer, compressed = encode_transfer_best(txs)
|
||||
assert compressed is True
|
||||
assert decode_transfer(transfer, compressed) == txs
|
||||
|
||||
# Already-compact input stays plain (never larger than the source).
|
||||
txs_small = ["ab", "cd"]
|
||||
transfer, compressed = encode_transfer_best(txs_small)
|
||||
assert compressed is False
|
||||
assert decode_transfer(transfer, compressed) == txs_small
|
||||
|
||||
|
||||
def test_v2_malformed_frames():
|
||||
bad = (
|
||||
"BAL1", # header only, no fields
|
||||
"BAL1" + "001", # truncated
|
||||
"BAL1" + "G-1" + "001" + "Z" + "p", # non-base36 total
|
||||
"BAL1" + "001" + "G-1" + "Z" + "p", # non-base36 index
|
||||
"BAL1" + "000" + "001" + "Z" + "p", # total 0
|
||||
"BAL1" + "001" + "000" + "Z" + "p", # index 0
|
||||
"BAL1" + "001" + "002" + "Z" + "p", # index beyond total
|
||||
"BAL1" + "001" + "001" + "Q" + "p", # unknown flag
|
||||
)
|
||||
for frame in bad:
|
||||
try:
|
||||
parse_frame(frame)
|
||||
except QrTransferError:
|
||||
continue
|
||||
raise AssertionError("expected QrTransferError for: {!r}".format(frame))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Malformed input
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_parse_bad_magic_and_version():
|
||||
for frame in (
|
||||
"BALQR|1|1||a", # missing version
|
||||
"BALQR2|1|1||a", # unknown version
|
||||
"XXXXX1|1|1||a", # unknown magic
|
||||
):
|
||||
try:
|
||||
parse_frame(frame)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for: {}".format(frame))
|
||||
|
||||
|
||||
def test_parse_bad_arity():
|
||||
for frame in ("BALQR1", "BALQR1|1|1|"):
|
||||
try:
|
||||
parse_frame(frame)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for: {}".format(frame))
|
||||
|
||||
|
||||
def test_parse_pipe_in_payload_is_folded():
|
||||
# maxsplit keeps the tail (including any inner '|') in the payload part.
|
||||
frame = "BALQR1|1|1||a|b|c"
|
||||
total, index, compressed, payload = parse_frame(frame)
|
||||
assert (total, index, compressed) == (1, 1, False)
|
||||
assert payload == "a|b|c"
|
||||
|
||||
|
||||
def test_parse_bad_numbers():
|
||||
for frame in (
|
||||
"BALQR1|x|1||a",
|
||||
"BALQR1|1|y||a",
|
||||
"BALQR1|0|1||a",
|
||||
"BALQR1|1|0||a",
|
||||
"BALQR1|1|2||a", # index beyond total
|
||||
"BALQR1|-1|1||a",
|
||||
):
|
||||
try:
|
||||
parse_frame(frame)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for: {}".format(frame))
|
||||
|
||||
|
||||
def test_parse_bad_flags():
|
||||
try:
|
||||
parse_frame("BALQR1|1|1|Q|payload")
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for unknown flags")
|
||||
|
||||
|
||||
def test_assemble_missing_frames():
|
||||
try:
|
||||
assemble({1: "a", 3: "c"}, total=3)
|
||||
except MissingFramesError as e:
|
||||
assert e.missing == [2]
|
||||
else:
|
||||
raise AssertionError("expected MissingFramesError")
|
||||
|
||||
|
||||
def test_assemble_index_beyond_total():
|
||||
try:
|
||||
assemble({1: "a", 2: "b"}, total=1)
|
||||
except InconsistentTotalError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected InconsistentTotalError")
|
||||
|
||||
|
||||
def test_assemble_order_and_total_validation():
|
||||
assert assemble({1: "a", 2: "b"}, total=2) == "ab"
|
||||
try:
|
||||
assemble({}, total=0)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Constants / presets
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_preset_count_and_order():
|
||||
assert len(CHUNK_PRESETS) == 4
|
||||
budgets = [budget for _label, budget in CHUNK_PRESETS]
|
||||
assert budgets == sorted(budgets)
|
||||
|
||||
|
||||
def test_preset_index_for_chunk_size():
|
||||
for index, (_label, budget) in enumerate(CHUNK_PRESETS):
|
||||
assert preset_index_for_chunk_size(budget) == index
|
||||
assert preset_index_for_chunk_size(150) == 0
|
||||
assert preset_index_for_chunk_size(1800) == 3
|
||||
|
||||
|
||||
def test_min_chunk_size_guard():
|
||||
try:
|
||||
split_frames("x" * 10, MIN_CHUNK_SIZE - 1)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for tiny chunk size")
|
||||
|
||||
|
||||
def test_split_frame_headers_consistent():
|
||||
tx_strings = ["ab" * 80]
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 150)
|
||||
totals = {parse_frame(frame)[0] for frame in frames}
|
||||
assert len(totals) == 1
|
||||
assert totals.pop() == len(frames)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Optional: cross-check presets against the qrcode library (EC level M)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_presets_fit_qrcode_ec_m():
|
||||
"""Every preset budget must render inside a QR at EC level M."""
|
||||
try:
|
||||
import qrcode
|
||||
from qrcode.constants import ERROR_CORRECT_M
|
||||
except ImportError:
|
||||
print("qrcode not installed - skipping capacity check")
|
||||
return
|
||||
for _label, size in CHUNK_PRESETS:
|
||||
# Worst-case frame: header with the largest plausible total/index plus
|
||||
# a full payload of the preset budget.
|
||||
frame = "BALQR1|9999|9999|Z|" + "a" * (size - 14)
|
||||
qr = qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2)
|
||||
qr.add_data(frame)
|
||||
qr.get_matrix() # raises DataOverflowError if it does not fit
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
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")
|
||||
@@ -97,7 +97,7 @@ def test_relative_days():
|
||||
|
||||
def test_resolve_locktime_against_tx_absolute():
|
||||
"""An absolute current date is returned unchanged (compared vs the tx)."""
|
||||
frozen = 1817438400
|
||||
frozen = 1817424000
|
||||
assert Util.resolve_locktime_against_tx(str(frozen), "1y", frozen) == frozen
|
||||
assert Util.resolve_locktime_against_tx(frozen, str(frozen), frozen) == frozen
|
||||
|
||||
@@ -105,7 +105,7 @@ def test_resolve_locktime_against_tx_absolute():
|
||||
def test_resolve_locktime_against_tx_unchanged_relative():
|
||||
"""An unchanged relative recipe resolves to exactly the frozen tx locktime
|
||||
(coherent), instead of drifting one day per day away from it."""
|
||||
frozen = 1817438400 # 2027-08-05, i.e. a tx built 2026-08-05 with "1y"
|
||||
frozen = 1817424000 # 2027-08-05 00:00 UTC, i.e. a tx built 2026-08-05 with "1y"
|
||||
resolved = Util.resolve_locktime_against_tx("1y", "1y", frozen)
|
||||
assert resolved == frozen
|
||||
|
||||
@@ -113,7 +113,7 @@ def test_resolve_locktime_against_tx_unchanged_relative():
|
||||
def test_resolve_locktime_against_tx_lengthened():
|
||||
"""A lengthened relative recipe resolves later than the frozen tx locktime
|
||||
(this is what the postpone check uses to trigger invalidation)."""
|
||||
frozen = 1817438400 # tx built 2026-08-05 with "1y" -> delivery 2027-08-05
|
||||
frozen = 1817424000 # tx built 2026-08-05 with "1y" -> delivery 2027-08-05
|
||||
resolved = Util.resolve_locktime_against_tx("2y", "1y", frozen)
|
||||
assert resolved == frozen + 365 * 86400
|
||||
|
||||
@@ -121,7 +121,7 @@ def test_resolve_locktime_against_tx_lengthened():
|
||||
def test_resolve_locktime_against_tx_shortened():
|
||||
"""A shortened relative recipe resolves earlier than the frozen tx locktime
|
||||
(this is what the anticipate/rebuild path uses)."""
|
||||
frozen = 1817438400
|
||||
frozen = 1817424000
|
||||
resolved = Util.resolve_locktime_against_tx("30d", "1y", frozen)
|
||||
assert resolved < frozen
|
||||
|
||||
@@ -129,7 +129,7 @@ def test_resolve_locktime_against_tx_shortened():
|
||||
def test_resolve_locktime_against_tx_no_relative_anchor():
|
||||
"""When the built recipe was absolute there is no anchor: falls back to the
|
||||
legacy forward-from-now resolution (returns a timestamp, no crash)."""
|
||||
frozen = 1817438400
|
||||
frozen = 1817424000
|
||||
result = Util.resolve_locktime_against_tx("30d", str(frozen), frozen)
|
||||
assert isinstance(result, int)
|
||||
assert result > 1700000000
|
||||
@@ -319,27 +319,6 @@ def test_anticipate_locktime():
|
||||
assert low >= 1
|
||||
|
||||
|
||||
def test_cmp_locktime():
|
||||
assert Util.cmp_locktime("30d", "30d") == 0
|
||||
# Note: cmp_locktime may return nonzero or None for mismatched units
|
||||
|
||||
|
||||
def test_get_locktimes():
|
||||
class FakeTx:
|
||||
locktime = 1700000000
|
||||
|
||||
# will with single entry
|
||||
will = {
|
||||
"tx1": {"tx": FakeTx()},
|
||||
}
|
||||
locktimes = list(Util.get_locktimes(will))
|
||||
assert 1700000000 in locktimes
|
||||
assert len(locktimes) == 1
|
||||
|
||||
# empty will
|
||||
assert list(Util.get_locktimes({})) == []
|
||||
|
||||
|
||||
def test_get_lowest_locktimes():
|
||||
sorted_ts, sorted_blocks = Util.get_lowest_locktimes([500000, 1700000000, 100, 900000])
|
||||
# 500000, 900000 are block-height (< THRESHOLD)
|
||||
@@ -351,18 +330,6 @@ def test_get_lowest_locktimes():
|
||||
assert Util.get_lowest_locktimes([]) == ([], [])
|
||||
|
||||
|
||||
def test_get_will_spent_utxos():
|
||||
class FakeTx:
|
||||
def inputs(self): return [1, 2, 3]
|
||||
|
||||
will = {
|
||||
"tx1": {"tx": FakeTx()},
|
||||
"tx2": {"tx": FakeTx()},
|
||||
}
|
||||
utxos = Util.get_will_spent_utxos(will)
|
||||
assert len(utxos) == 6 # 3 inputs * 2 txs
|
||||
|
||||
|
||||
def test_utxo_to_str():
|
||||
class FakeUtxo:
|
||||
def to_str(self): return "txid:0"
|
||||
@@ -518,10 +485,7 @@ if __name__ == "__main__":
|
||||
test_get_value_amount()
|
||||
test_chk_locktime()
|
||||
test_anticipate_locktime()
|
||||
test_cmp_locktime()
|
||||
test_get_locktimes()
|
||||
test_get_lowest_locktimes()
|
||||
test_get_will_spent_utxos()
|
||||
test_utxo_to_str()
|
||||
test_cmp_utxo()
|
||||
test_in_utxo()
|
||||
|
||||
@@ -8,13 +8,19 @@ Run:
|
||||
python3 tests/test_core_will.py
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.will import Will, WillItem
|
||||
from bal.core.checkalive import resolve_date_to_check
|
||||
from bal.core.util import copy_structure
|
||||
from bal.core.will import (
|
||||
HeirNotFoundException,
|
||||
NoHeirsException,
|
||||
Will,
|
||||
WillItem,
|
||||
)
|
||||
|
||||
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
|
||||
_VALID_TX_HEX = (
|
||||
@@ -48,7 +54,7 @@ def _make_willitem_blank():
|
||||
"""Create a fresh WillItem from scratch."""
|
||||
item = WillItem(_make_minimal_willitem_dict())
|
||||
# Reset STATUS to clean defaults
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
return item
|
||||
|
||||
|
||||
@@ -126,22 +132,6 @@ def test_willitem_str_repr():
|
||||
# Will static methods
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_will_get_sorted_will():
|
||||
# Use a simple dict structure that will[key]["tx"].locktime works
|
||||
class FakeTx:
|
||||
def __init__(self, locktime):
|
||||
self.locktime = locktime
|
||||
|
||||
will = {
|
||||
"b": {"tx": FakeTx(200)},
|
||||
"a": {"tx": FakeTx(100)},
|
||||
}
|
||||
sorted_will = Will.get_sorted_will(will)
|
||||
assert len(sorted_will) == 2
|
||||
assert sorted_will[0][1]["tx"].locktime == 100
|
||||
assert sorted_will[1][1]["tx"].locktime == 200
|
||||
|
||||
|
||||
def test_will_only_valid():
|
||||
item1 = _make_willitem_blank()
|
||||
item2 = _make_willitem_blank()
|
||||
@@ -167,8 +157,8 @@ def test_will_only_valid_list():
|
||||
def _make_will_with_heirs(heirs, tx_locktime):
|
||||
"""Build a single-item will whose stored heirs == ``heirs`` and whose
|
||||
frozen tx.locktime == ``tx_locktime`` (what the will-executors hold)."""
|
||||
item = WillItem(_make_minimal_willitem_dict(heirs=copy.deepcopy(heirs)))
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
item = WillItem(_make_minimal_willitem_dict(heirs=copy_structure(heirs)))
|
||||
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
item.tx.locktime = tx_locktime
|
||||
return {"willid_1": item}
|
||||
|
||||
@@ -179,7 +169,7 @@ def test_check_heirs_unchanged_is_coherent():
|
||||
heirs = {"alice": ["addr_alice", 5000, str(lt)]}
|
||||
will = _make_will_with_heirs(heirs, lt)
|
||||
result = Will.check_willexecutors_and_heirs(
|
||||
will, copy.deepcopy(heirs), {}, False, 0, 100
|
||||
will, copy_structure(heirs), {}, False, 0, 100
|
||||
)
|
||||
assert result is True
|
||||
|
||||
@@ -226,6 +216,59 @@ def test_check_heir_added_triggers_rebuild():
|
||||
assert raised, "adding an heir must raise HeirNotFoundException"
|
||||
|
||||
|
||||
def test_shortened_relative_recipe_on_signed_rebuilds_not_noheirs():
|
||||
"""Regression (karen7): heirs shortened "2y"->"1y" on a signed will whose
|
||||
ADVANCED check window is anchored to the frozen built delivery must trigger
|
||||
a plain rebuild (HeirNotFoundException), NOT "No Heirs".
|
||||
|
||||
Earlier the count gate resolved each current relative recipe from *now*
|
||||
while ``check_date`` was anchored to the (longer) frozen built locktime, so
|
||||
every heir fell below the window and was silently excluded -> NoHeirs even
|
||||
though the will simply needs rebuilding on the new, shorter schedule."""
|
||||
lt = 2_100_000_000 # a far-future frozen delivery (a "2y" build)
|
||||
will_heirs = {"alice": ["addr_alice", 5000, "2y"]}
|
||||
current_heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
||||
will = _make_will_with_heirs(will_heirs, lt)
|
||||
will["willid_1"].set_status("COMPLETE", True)
|
||||
check_date = resolve_date_to_check(
|
||||
False, {"locktime": "2y", "threshold": "150d"}, built_locktime=lt
|
||||
)
|
||||
assert check_date < lt # the anchored window really precedes the delivery
|
||||
raised = None
|
||||
try:
|
||||
Will.check_willexecutors_and_heirs(
|
||||
will, copy_structure(current_heirs), {}, False, check_date, 100
|
||||
)
|
||||
except HeirNotFoundException:
|
||||
raised = "rebuild"
|
||||
except NoHeirsException:
|
||||
raised = "noheirs"
|
||||
assert raised == "rebuild", (
|
||||
f"shortened recipe on a signed will must rebuild, got {raised!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_all_heirs_past_check_date_still_noheirs():
|
||||
"""The "no valid heirs" gate is preserved: when every heir is coherent with
|
||||
the built will but its delivery lies before ``check_date``, the check still
|
||||
reports NoHeirsException (there is literally nothing future to inherit)."""
|
||||
lt = 1_900_000_000
|
||||
will_heirs = {"alice": ["addr_alice", 5000, str(lt)]}
|
||||
will = _make_will_with_heirs(will_heirs, lt)
|
||||
raised = None
|
||||
try:
|
||||
Will.check_willexecutors_and_heirs(
|
||||
will, copy_structure(will_heirs), {}, False, lt + 86400, 100
|
||||
)
|
||||
except HeirNotFoundException:
|
||||
raised = "rebuild"
|
||||
except NoHeirsException:
|
||||
raised = "noheirs"
|
||||
assert raised == "noheirs", (
|
||||
f"a fully delivered will must report NoHeirs, got {raised!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_needs_server_check():
|
||||
"""Check button selection logic: only a VALID, PUSHED will with a
|
||||
will-executor that is not yet CHECKED must be queried on the server.
|
||||
|
||||
@@ -450,6 +450,12 @@ class FakeADB:
|
||||
|
||||
def remove_transaction(self, txid):
|
||||
self.removed.append(txid)
|
||||
# Simulate the real adb: dropping a stored tx frees the outputs it spent.
|
||||
for utxos in self.outputs.values():
|
||||
for utxo in utxos.values():
|
||||
if getattr(utxo, "spent_txid", None) == txid:
|
||||
utxo.spent_txid = None
|
||||
utxo.spent_height = None
|
||||
|
||||
def get_spender(self, outpoint):
|
||||
txid = self.spenders.get(outpoint)
|
||||
@@ -837,6 +843,73 @@ def test_get_available_utxos_none_locktime_is_raw_view():
|
||||
assert Util.get_available_utxos(None, _HISTORY_TEMPLATE, 1000) == []
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Will.remove_stale_wallet_history (pre-build history purge)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_remove_stale_wallet_history_frees_equal_locktime_spend():
|
||||
# The stale placeholders (saved by a previous prepare) have the SAME
|
||||
# locktime as the will being rebuilt, so get_available_utxos does NOT
|
||||
# restore their coins (see test_...does_not_restore_not_later_locktime).
|
||||
# The pre-build purge deletes them and the coins become available again.
|
||||
wallet, utxo = _wallet_with_local_spend(locktime=1000)
|
||||
spender = "ab" * 32
|
||||
assert Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000) == []
|
||||
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
|
||||
assert removed == [spender]
|
||||
assert wallet.adb.removed == [spender]
|
||||
assert spender not in wallet.labels
|
||||
assert [
|
||||
u.prevout.to_str()
|
||||
for u in Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
|
||||
] == [utxo.prevout.to_str()]
|
||||
|
||||
|
||||
def test_remove_stale_wallet_history_keeps_confirmed_spender():
|
||||
# A broadcast (confirmed) BAL-labelled tx is never purged.
|
||||
addr = "bcrt1qexample"
|
||||
spender = "ab" * 32
|
||||
utxo = _make_utxo(spent_txid=spender, spent_height=100)
|
||||
wallet = FakeWallet(
|
||||
stored_txs={spender: _make_multisig_ptx(0, locktime=2000)},
|
||||
heights={spender: 100},
|
||||
outputs={addr: {utxo.prevout.to_str(): utxo}},
|
||||
addresses=[addr],
|
||||
)
|
||||
wallet.labels[spender] = _HISTORY_LABEL
|
||||
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
|
||||
assert removed == []
|
||||
assert wallet.adb.removed == []
|
||||
assert wallet.labels[spender] == _HISTORY_LABEL
|
||||
|
||||
|
||||
def test_remove_stale_wallet_history_keeps_unlabeled_local_spender():
|
||||
# Wallet-local BAL-status tx without a matching history label stays.
|
||||
wallet, _ = _wallet_with_local_spend(locktime=1000)
|
||||
spender = "ab" * 32
|
||||
wallet.labels[spender] = "some other label"
|
||||
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
|
||||
assert removed == []
|
||||
assert wallet.adb.removed == []
|
||||
assert wallet.labels[spender] == "some other label"
|
||||
|
||||
|
||||
def test_remove_stale_wallet_history_noop_without_wallet_or_adb():
|
||||
assert Will.remove_stale_wallet_history(None, _HISTORY_TEMPLATE) == []
|
||||
wallet = FakeWallet()
|
||||
wallet.adb = None
|
||||
assert Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) == []
|
||||
|
||||
|
||||
def test_remove_stale_wallet_history_never_raises():
|
||||
adb = MagicMock()
|
||||
adb.get_tx_height.side_effect = RuntimeError("boom")
|
||||
wallet = MagicMock()
|
||||
wallet.adb = adb
|
||||
wallet.get_all_labels.return_value = {"ab" * 32: _HISTORY_LABEL}
|
||||
assert Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) == []
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -17,7 +17,6 @@ Run:
|
||||
python3 -m pytest tests/test_core_will_invalidate.py -q
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -75,9 +74,12 @@ def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None):
|
||||
"change": "",
|
||||
"baltx_fees": 100,
|
||||
})
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
# Set the input value so the balance calculation works.
|
||||
item.tx.inputs()[0]._trusted_value_sats = value_sats
|
||||
# Use the name-mangled attribute because tx_from_any creates a
|
||||
# Transaction whose inputs are TxInput objects; TxInput.value_sats()
|
||||
# reads __value_sats, not _trusted_value_sats.
|
||||
item.tx.inputs()[0]._TxInput__value_sats = value_sats
|
||||
if not valid:
|
||||
item.set_status("INVALIDATED", True)
|
||||
return item
|
||||
@@ -267,7 +269,7 @@ class TestInvalidateWill:
|
||||
"""
|
||||
item = _make_willitem(value_sats=100)
|
||||
will = {"willtxid1": item}
|
||||
wallet = _mock_wallet([_make_utxo()])
|
||||
wallet = _mock_wallet([_make_utxo(value_sats=100)])
|
||||
|
||||
result, mock_from_io, _ = _run_invalidate(will, wallet, fees_per_byte=100)
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ Run:
|
||||
python3 -m pytest tests/test_group_e_karen7_invalidate.py -q
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -46,6 +45,7 @@ from electrum.transaction import (
|
||||
from electrum.util import bfh
|
||||
|
||||
from bal.core.heirs import Heirs
|
||||
from bal.core.util import copy_structure
|
||||
from bal.core.will import Will, WillItem
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -219,7 +219,7 @@ def _txs_to_will(txs, heirs_data):
|
||||
for txid, tx in txs.items():
|
||||
item_dict = {
|
||||
"tx": tx,
|
||||
"heirs": copy.deepcopy(heirs_data),
|
||||
"heirs": copy_structure(heirs_data),
|
||||
"willexecutor": None,
|
||||
"status": "",
|
||||
"description": "",
|
||||
@@ -281,10 +281,10 @@ class TestKaren7BuildAndInvalidate:
|
||||
)
|
||||
|
||||
def test_built_tx_has_karen7_heirs(self):
|
||||
"""The built will contains karen7's four heirs."""
|
||||
assert len(self.heirs_model) == 4
|
||||
"""The built will contains karen7's six heirs."""
|
||||
assert len(self.heirs_model) == 6
|
||||
assert list(self.heirs_model.keys()) == [
|
||||
"aaaa", "lucia", "mario", "mario2"
|
||||
"aaaa", "lucia", "mario", "mario2", "op_return", "op_return2"
|
||||
]
|
||||
|
||||
def test_will_items_are_valid(self):
|
||||
|
||||
@@ -22,7 +22,6 @@ Run:
|
||||
python3 -m pytest tests/test_group_e_mock_karen7.py -q
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -43,6 +42,7 @@ from bal.core.reminders import (
|
||||
ical_escape,
|
||||
write_temp_ics,
|
||||
)
|
||||
from bal.core.util import copy_structure
|
||||
from bal.core.will import HeirNotFoundException, Will, WillItem
|
||||
from bal.core.willexecutors import Willexecutors
|
||||
|
||||
@@ -136,7 +136,7 @@ def _make_willitem(**overrides):
|
||||
}
|
||||
d.update(overrides)
|
||||
item = WillItem(d)
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
return item
|
||||
|
||||
|
||||
@@ -343,7 +343,7 @@ def test_e2_heir_change_triggers_rebuild():
|
||||
item = WillItem(
|
||||
{
|
||||
"tx": _VALID_TX_HEX,
|
||||
"heirs": copy.deepcopy(will_heirs),
|
||||
"heirs": copy_structure(will_heirs),
|
||||
"willexecutor": None,
|
||||
"status": "",
|
||||
"description": "",
|
||||
@@ -352,7 +352,7 @@ def test_e2_heir_change_triggers_rebuild():
|
||||
"baltx_fees": 100,
|
||||
}
|
||||
)
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
item.tx.locktime = lt
|
||||
will = {"willid_1": item}
|
||||
|
||||
@@ -570,8 +570,8 @@ def test_e5_build_with_real_wallet_heirs_and_utxos():
|
||||
h = Heirs.__new__(Heirs)
|
||||
h.update(heirs_data)
|
||||
|
||||
assert len(h) == 4, f"expected 4 heirs, got {len(h)}"
|
||||
assert list(h.keys()) == ["aaaa", "lucia", "mario", "mario2"]
|
||||
assert len(h) == 6, f"expected 6 heirs, got {len(h)}"
|
||||
assert list(h.keys()) == ["aaaa", "lucia", "mario", "mario2", "op_return", "op_return2"]
|
||||
|
||||
# Mock the Electrum-heavy parts so the build can run in a test context.
|
||||
wallet = MagicMock()
|
||||
|
||||
456
tests/test_gui_export_dialogs.py
Normal file
456
tests/test_gui_export_dialogs.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
Tests for the filter-based unified export/import dialogs and the BalWindow
|
||||
transport helpers (``bal.gui.qt.dialogs``, ``bal.gui.qt.window``).
|
||||
|
||||
Covers the shared export filters (All / Valid / Valid NC), the unified
|
||||
``WillExportDialog`` file page (whole item vs tx-only content, empty-filter
|
||||
abort), the audio export/import pages (KB/sec wiring, missing plugin guard,
|
||||
receive flow) and the comma-separated tx-only file writer. The audio pages
|
||||
run against a stub plugin so no sound hardware is exercised.
|
||||
|
||||
Run:
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_export_dialogs.py
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import zlib
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||
|
||||
import bal.gui.qt.dialogs as dialogs
|
||||
from bal.core.qrtransfer import CHUNK_PRESETS
|
||||
|
||||
_app = QApplication.instance() or QApplication(sys.argv)
|
||||
|
||||
# A valid 1x1 transparent PNG, good enough for BalDialog's window icon.
|
||||
_PNG_BYTES = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
|
||||
# version 2); used for real-WillItem serialization tests.
|
||||
_VALID_TX_HEX = (
|
||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||
"42146f11ef8414ae929feaafc388ac00000000"
|
||||
)
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def get(self):
|
||||
return self._value
|
||||
|
||||
|
||||
class FakePlugin:
|
||||
QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||
|
||||
def read_file(self, path):
|
||||
return _PNG_BYTES
|
||||
|
||||
|
||||
class FakeWindow(QMainWindow):
|
||||
config = {}
|
||||
|
||||
def format_amount(self, amount):
|
||||
return "{:.8f}".format(amount)
|
||||
|
||||
def format_amount_and_units(self, amount):
|
||||
return "{:.8f} sat".format(amount)
|
||||
|
||||
|
||||
class FakeAudioPlugin:
|
||||
"""Duck-typed ``audio_modem`` plugin for the audio pages."""
|
||||
|
||||
def __init__(self):
|
||||
self.modem_config = None
|
||||
|
||||
def is_available(self):
|
||||
return True
|
||||
|
||||
|
||||
class _FakeModemConfig:
|
||||
def __init__(self, kbps):
|
||||
self.modem_bps = kbps * 1000
|
||||
|
||||
|
||||
class FakeBalWindow:
|
||||
"""Duck-typed stand-in for BalWindow (dialog layer only)."""
|
||||
|
||||
def __init__(self, audio_plugin=None):
|
||||
self.window = FakeWindow()
|
||||
self.bal_plugin = FakePlugin()
|
||||
self.willitems = {}
|
||||
self.audio_plugin = audio_plugin
|
||||
self.bitrate_set = None
|
||||
self.audio_payloads = []
|
||||
|
||||
def get_audio_modem_plugin(self):
|
||||
return self.audio_plugin
|
||||
|
||||
def set_audio_modem_bitrate(self, kbps):
|
||||
self.bitrate_set = kbps
|
||||
if self.audio_plugin is not None:
|
||||
self.audio_plugin.modem_config = _FakeModemConfig(kbps)
|
||||
|
||||
def _audio_send_payload(self, payload):
|
||||
self.audio_payloads.append(payload)
|
||||
|
||||
def export_json_file(self, path, will=None):
|
||||
items = will if will is not None else self.willitems
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump({wid: wi.to_dict() for wid, wi in items.items()}, f)
|
||||
|
||||
def export_tx_file(self, path, will=None):
|
||||
items = will if will is not None else self.willitems
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(",".join(str(wi.tx) for _, wi in items.items()))
|
||||
|
||||
|
||||
class StubTx:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def txid(self):
|
||||
return "{:064x}".format(hash(self.payload) & 0xFFFFFFFFFFFFFFFF)
|
||||
|
||||
def __str__(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class StubWillItem:
|
||||
def __init__(self, payload, statuses=None):
|
||||
self.tx = StubTx(payload)
|
||||
self.statuses = statuses or {}
|
||||
|
||||
def get_status(self, name):
|
||||
return self.statuses.get(name, False)
|
||||
|
||||
def to_dict(self):
|
||||
return {"tx": str(self.tx)}
|
||||
|
||||
|
||||
def _make_willitems(n=3, payload_len=60, statuses=None):
|
||||
return {
|
||||
"item{}".format(i): StubWillItem(
|
||||
"T{}".format(i) * payload_len, statuses=statuses
|
||||
)
|
||||
for i in range(n)
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Shared export filters
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_export_filter_options():
|
||||
opts = dialogs.export_filter_options()
|
||||
assert [label for label, _fn in opts] == ["All", "Valid", "Valid NC"]
|
||||
complete = StubWillItem("C*", statuses={"VALID": True, "COMPLETE": True})
|
||||
valid = StubWillItem("V*", statuses={"VALID": True})
|
||||
plain = StubWillItem("P*")
|
||||
assert opts[0][1](complete) and opts[0][1](plain)
|
||||
assert opts[1][1](complete) and opts[1][1](valid) and not opts[1][1](plain)
|
||||
assert not opts[2][1](complete)
|
||||
assert opts[2][1](valid) and not opts[2][1](plain)
|
||||
|
||||
|
||||
def test_filter_willitems_by_index():
|
||||
items = {
|
||||
"a": StubWillItem("A*", statuses={"VALID": True, "COMPLETE": True}),
|
||||
"b": StubWillItem("B*", statuses={"VALID": True}),
|
||||
"c": StubWillItem("C*"),
|
||||
}
|
||||
opts = dialogs.export_filter_options()
|
||||
assert set(dialogs.filter_willitems(items, opts, 0)) == {"a", "b", "c"}
|
||||
assert set(dialogs.filter_willitems(items, opts, 1)) == {"a", "b"}
|
||||
assert dialogs.filter_willitems(items, opts, 2) == {"b": items["b"]}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillExportDialog file page
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_file_export_selects_by_filter():
|
||||
bw = FakeBalWindow()
|
||||
items = _make_willitems(3)
|
||||
items["item0"].statuses = {"VALID": True, "COMPLETE": True}
|
||||
items["item1"].statuses = {"VALID": True}
|
||||
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||
assert set(d._selected_items()) == set(items)
|
||||
|
||||
d._on_filter_change(1)
|
||||
assert set(d._selected_items()) == {"item0", "item1"}
|
||||
d._on_filter_change(2)
|
||||
assert list(d._selected_items()) == ["item1"]
|
||||
d.close()
|
||||
|
||||
|
||||
def test_file_export_run_tx_only(tmpdir):
|
||||
bw = FakeBalWindow()
|
||||
items = _make_willitems(3)
|
||||
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||
d.content_check.setChecked(False)
|
||||
captured = {}
|
||||
|
||||
def fake_gui(window, title, exporter):
|
||||
captured["title"] = title
|
||||
captured["exporter"] = exporter
|
||||
|
||||
with patch.object(dialogs, "export_meta_gui", side_effect=fake_gui):
|
||||
d._export_file()
|
||||
assert captured["title"] == "will_tx"
|
||||
out = tmpdir.join("will_tx.txt").strpath
|
||||
captured["exporter"](out)
|
||||
expected = ",".join(str(wi.tx) for _, wi in items.items())
|
||||
assert open(out, encoding="utf-8").read() == expected
|
||||
d.close()
|
||||
|
||||
|
||||
def test_file_export_run_willitem(tmpdir):
|
||||
bw = FakeBalWindow()
|
||||
items = _make_willitems(2)
|
||||
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||
assert d.content_check.isChecked()
|
||||
captured = {}
|
||||
|
||||
def fake_gui(window, title, exporter):
|
||||
captured["title"] = title
|
||||
captured["exporter"] = exporter
|
||||
|
||||
with patch.object(dialogs, "export_meta_gui", side_effect=fake_gui):
|
||||
d._export_file()
|
||||
assert captured["title"] == "will"
|
||||
out = tmpdir.join("will.json").strpath
|
||||
captured["exporter"](out)
|
||||
data = json.load(open(out, encoding="utf-8"))
|
||||
assert set(data) == set(items)
|
||||
assert data["item0"]["tx"] == str(items["item0"].tx)
|
||||
d.close()
|
||||
|
||||
|
||||
def test_file_export_empty_under_filter_aborts():
|
||||
bw = FakeBalWindow()
|
||||
items = {
|
||||
"a": StubWillItem("A*", statuses={"VALID": True, "COMPLETE": True})
|
||||
}
|
||||
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||
messages = []
|
||||
d.show_message = lambda msg: messages.append(msg) # type: ignore[assignment]
|
||||
d._filter_index = 2 # force an empty "Valid NC" selection
|
||||
with patch.object(dialogs, "export_meta_gui") as gui:
|
||||
d._export_file()
|
||||
assert not gui.called
|
||||
assert messages
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# BalWindow transport helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_bal_window_export_tx_file(tmpdir):
|
||||
import bal.gui.qt.window as window
|
||||
|
||||
items = _make_willitems(3)
|
||||
bw = object.__new__(window.BalWindow)
|
||||
bw.willitems = items
|
||||
out = tmpdir.join("will_tx.txt").strpath
|
||||
bw.export_tx_file(out)
|
||||
expected = ",".join(str(wi.tx) for _, wi in items.items())
|
||||
assert open(out, encoding="utf-8").read() == expected
|
||||
|
||||
|
||||
def test_bal_window_set_audio_modem_bitrate():
|
||||
try:
|
||||
import amodem.config
|
||||
except ImportError:
|
||||
return
|
||||
import bal.gui.qt.window as window
|
||||
|
||||
class P:
|
||||
def __init__(self):
|
||||
self.modem_config = None
|
||||
|
||||
probe = P()
|
||||
bw = object.__new__(window.BalWindow)
|
||||
bw.get_audio_modem_plugin = lambda: probe
|
||||
bw.set_audio_modem_bitrate(1)
|
||||
assert probe.modem_config is amodem.config.bitrates[1]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillExportDialog audio page
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_audio_export_page_send():
|
||||
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||
items = _make_willitems(3, payload_len=30)
|
||||
bw.willitems = items
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert d.audio_send_btn.text() == dialogs._("Send")
|
||||
assert d.audio_send_btn.isEnabled()
|
||||
assert d.kbps_combo.count() > 0
|
||||
|
||||
kbps = int(d.kbps_combo.currentText())
|
||||
d._send_audio()
|
||||
assert bw.bitrate_set == kbps
|
||||
# Whole-will default: the audio payload is a single JSON document.
|
||||
assert len(bw.audio_payloads) == 1
|
||||
data = json.loads(bw.audio_payloads[0])
|
||||
assert set(data) == set(items)
|
||||
d.close()
|
||||
|
||||
|
||||
def test_audio_export_page_send_tx_only():
|
||||
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||
items = _make_willitems(3, payload_len=30)
|
||||
bw.willitems = items
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
d.content_check.setChecked(False)
|
||||
|
||||
kbps = int(d.kbps_combo.currentText())
|
||||
d._send_audio()
|
||||
assert bw.bitrate_set == kbps
|
||||
expected = "\n".join(dialogs.serialize_tx_list(items))
|
||||
assert bw.audio_payloads == [expected]
|
||||
d.close()
|
||||
|
||||
|
||||
def test_audio_export_page_plugin_missing():
|
||||
# The window stays usable: only the audio option is disabled.
|
||||
bw = FakeBalWindow(audio_plugin=None)
|
||||
bw.willitems = _make_willitems(2)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert not d.audio_send_btn.isEnabled()
|
||||
assert "not available" in d.audio_warn_label.text()
|
||||
assert len(d.qr_page.frames) >= 1 # QR still usable
|
||||
assert d.file_export_btn.isEnabled()
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillImportDialog audio page
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_audio_import_page_build():
|
||||
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert d.kbps_combo.count() > 0
|
||||
assert d.receive_btn.text() == dialogs._("Receive by audio…")
|
||||
assert d.receive_btn.isEnabled()
|
||||
d.close()
|
||||
|
||||
|
||||
def test_audio_import_page_plugin_missing():
|
||||
bw = FakeBalWindow(audio_plugin=None)
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert not d.receive_btn.isEnabled()
|
||||
assert "not available" in d.audio_warn_label.text()
|
||||
assert d.qr_page is not None # QR import still usable
|
||||
d.close()
|
||||
|
||||
|
||||
def test_audio_import_receive_wiring():
|
||||
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
captured = {}
|
||||
|
||||
class FakeWaitingDialog:
|
||||
def __init__(self, parent, msg, task, on_success=None, on_error=None):
|
||||
captured["msg"] = msg
|
||||
captured["task"] = task
|
||||
captured["success"] = on_success
|
||||
captured["error"] = on_error
|
||||
|
||||
imported = []
|
||||
|
||||
def fake_complete(bal_window, bal_plugin, payload, **kwargs):
|
||||
imported.append(payload)
|
||||
|
||||
blob = zlib.compress(b"A" * 40 + b"\n" + b"B" * 40)
|
||||
with patch.object(dialogs, "WaitingDialog", FakeWaitingDialog), patch.object(
|
||||
dialogs, "_complete_import", side_effect=fake_complete
|
||||
):
|
||||
d._audio_receive()
|
||||
kbps = int(d.kbps_combo.currentText())
|
||||
assert bw.bitrate_set == kbps
|
||||
assert captured["task"] is not None
|
||||
captured["success"](blob)
|
||||
# Payload is the raw decompressed text; autodetect handles the splitting.
|
||||
assert imported == ["A" * 40 + "\n" + "B" * 40]
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Whole-will JSON payload serializes a real Transaction (MyEncoder)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_qr_whole_will_json_serializes_transaction():
|
||||
"""Regression: _whole_will_json must not raise
|
||||
"Object of type Transaction is not JSON serializable".
|
||||
|
||||
Real WillItems keep a ``Transaction`` object in ``tx``; the whole-will
|
||||
QR payload (default content scope) must serialize it via MyEncoder the
|
||||
same way write_json_file does.
|
||||
"""
|
||||
from bal.core.will import WillItem
|
||||
|
||||
item = WillItem({
|
||||
"tx": _VALID_TX_HEX,
|
||||
"heirs": {},
|
||||
"willexecutor": None,
|
||||
"status": "",
|
||||
"description": "",
|
||||
"time": 0,
|
||||
"change": "",
|
||||
"baltx_fees": 100,
|
||||
})
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillExportDialog(
|
||||
bw, will={"imp0": item}, bal_plugin=bw.bal_plugin, initial_mode="qr"
|
||||
)
|
||||
j = d._whole_will_json()
|
||||
data = json.loads(j)
|
||||
assert data["imp0"]["tx"] == _VALID_TX_HEX
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
if __name__ == "__main__":
|
||||
import inspect
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
class _Path:
|
||||
def __init__(self, d, name):
|
||||
self.strpath = os.path.join(d, name)
|
||||
|
||||
class _Tmp:
|
||||
def __init__(self):
|
||||
self._d = tempfile.mkdtemp()
|
||||
|
||||
def join(self, name):
|
||||
return _Path(self._d, name)
|
||||
|
||||
tmp = _Tmp()
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
fn = globals()[name]
|
||||
fn(tmp) if inspect.signature(fn).parameters else fn()
|
||||
print(" [OK] {}".format(name))
|
||||
print("[OK] All export dialog GUI tests passed")
|
||||
@@ -179,6 +179,7 @@ def test_rebuild_path_schedules_full_refresh():
|
||||
win.date_to_check = 1_800_000_000
|
||||
win.will_settings = {"baltx_fees": 1, "locktime": "1 month"}
|
||||
win.bal_plugin = _CfgBag(
|
||||
is_basic_mode=lambda: False,
|
||||
MAX_WILLEXECUTOR_FEE=_Cfg(1),
|
||||
SAVE_HISTORY=_Cfg(True),
|
||||
HISTORY_LABEL=_Cfg("LBL"),
|
||||
@@ -204,6 +205,46 @@ def test_rebuild_path_schedules_full_refresh():
|
||||
schedule_mock.assert_called_once_with()
|
||||
|
||||
|
||||
def test_rebuild_purges_stale_wallet_history_before_building():
|
||||
# The rebuild path must drop stale wallet-LOCAL will placeholders (saved by
|
||||
# an earlier prepare) so their coins are available to the new build.
|
||||
win = object.__new__(BalWindow)
|
||||
win.disable_plugin = False
|
||||
win.heirs = {"h": object()}
|
||||
win.willexecutors = {}
|
||||
win.no_willexecutor = True
|
||||
win.willitems = {}
|
||||
win.will = {}
|
||||
win.date_to_check = 1_800_000_000
|
||||
win.will_settings = {"baltx_fees": 1, "locktime": "1 month"}
|
||||
win.bal_plugin = _CfgBag(
|
||||
is_basic_mode=lambda: False,
|
||||
MAX_WILLEXECUTOR_FEE=_Cfg(1),
|
||||
SAVE_HISTORY=_Cfg(True),
|
||||
HISTORY_LABEL=_Cfg("LBL"),
|
||||
)
|
||||
win.window = _FakeWindow()
|
||||
win.window.wallet = _Wallet()
|
||||
with (
|
||||
patch.object(Util, "get_available_utxos", return_value=[]),
|
||||
patch.object(Util, "parse_locktime_string", return_value=1_800_000_001),
|
||||
patch.object(Will, "get_min_locktime", return_value=0),
|
||||
patch.object(Will, "check_amounts"),
|
||||
patch.object(BalWindow, "init_class_variables"),
|
||||
patch.object(BalWindow, "build_will"),
|
||||
patch.object(
|
||||
BalWindow,
|
||||
"check_will",
|
||||
side_effect=[NotCompleteWillException(), None],
|
||||
),
|
||||
patch.object(BalWindow, "update_all"),
|
||||
patch.object(BalWindow, "_schedule_history_refresh"),
|
||||
patch.object(Will, "remove_stale_wallet_history") as purge_mock,
|
||||
):
|
||||
BalWindow.build_inheritance_transaction(win)
|
||||
purge_mock.assert_called_once_with(win.window.wallet, "LBL")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
760
tests/test_gui_qr_transfer.py
Normal file
760
tests/test_gui_qr_transfer.py
Normal file
@@ -0,0 +1,760 @@
|
||||
"""
|
||||
Tests for the QR / audio will-transfer dialogs (``bal.gui.qt.dialogs``).
|
||||
|
||||
Covers the unified ``WillExportDialog`` (transport radios, stacked pages,
|
||||
QR build/navigation, chunk-size / autoplay, filter revert) and the unified
|
||||
``WillImportDialog`` (QR frame capture, slot grid, complete-review enabling,
|
||||
total mismatch reset, frame assembly -> decode). The wizard and the
|
||||
camera/audio paths need a live wallet/hardware and are exercised only
|
||||
through the shared frame-assembly path here.
|
||||
|
||||
Run:
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||
|
||||
from electrum.transaction import Transaction
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||
|
||||
import bal.gui.qt.dialogs as dialogs
|
||||
from bal.core.qrtransfer import CHUNK_PRESETS, encode_transfer, split_frames
|
||||
from bal.core.will import WillItem
|
||||
|
||||
_app = QApplication.instance() or QApplication(sys.argv)
|
||||
|
||||
# A valid 1x1 transparent PNG, good enough for BalDialog's window icon.
|
||||
_PNG_BYTES = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output),
|
||||
# reused for the WillItem status regression test.
|
||||
_VALID_TX_HEX = (
|
||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||
"42146f11ef8414ae929feaafc388ac00000000"
|
||||
)
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def get(self):
|
||||
return self._value
|
||||
|
||||
|
||||
class FakePlugin:
|
||||
# Smallest QR preset: long transfers produce several frames.
|
||||
QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||
|
||||
def read_file(self, path):
|
||||
return _PNG_BYTES
|
||||
|
||||
|
||||
class FakeWindow(QMainWindow):
|
||||
config = {}
|
||||
|
||||
def format_amount(self, amount):
|
||||
return "{:.8f}".format(amount)
|
||||
|
||||
def format_amount_and_units(self, amount):
|
||||
return "{:.8f} sat".format(amount)
|
||||
|
||||
|
||||
class FakeBalWindow:
|
||||
"""Duck-typed stand-in for BalWindow (dialog layer only)."""
|
||||
|
||||
def __init__(self):
|
||||
self.window = FakeWindow()
|
||||
self.bal_plugin = FakePlugin()
|
||||
self.willitems = {}
|
||||
|
||||
def get_audio_modem_plugin(self):
|
||||
return None
|
||||
|
||||
|
||||
class StubTx:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def txid(self):
|
||||
return "{:064x}".format(hash(self.payload) & 0xFFFFFFFFFFFFFFFF)
|
||||
|
||||
def __str__(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class StubWillItem:
|
||||
def __init__(self, payload, statuses=None):
|
||||
self.tx = StubTx(payload)
|
||||
self.statuses = statuses or {}
|
||||
|
||||
def get_status(self, name):
|
||||
return self.statuses.get(name, False)
|
||||
|
||||
def to_dict(self):
|
||||
return {"tx": str(self.tx), "status": self.statuses}
|
||||
|
||||
|
||||
def _make_willitems(n=3, payload_len=120, payloads=None):
|
||||
if payloads is None:
|
||||
payloads = ["T{}".format(i) * payload_len for i in range(n)]
|
||||
return {
|
||||
"item{}".format(i): StubWillItem(p)
|
||||
for i, p in enumerate(payloads)
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillExportDialog (QR transport via d.qr_page)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_export_dialog_builds():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems()
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert d.transport_qr.isChecked()
|
||||
assert page.tx_strings
|
||||
assert page.frames
|
||||
assert len(page.frames) >= 1
|
||||
# Frame 1 is shown.
|
||||
assert page.qr_view.text == page.frames[0]
|
||||
assert "1" in page.progress_label.text()
|
||||
d.close()
|
||||
|
||||
|
||||
def test_export_dialog_unified_transports():
|
||||
# One window hosts the three transports as stacked, radio-selected pages.
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems()
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert d.stacked.count() == 3
|
||||
assert d.transport_file.isChecked()
|
||||
assert d.stacked.currentWidget() is d.file_page
|
||||
d._on_mode_clicked(d.MODE_QR)
|
||||
assert d.stacked.currentWidget() is d.qr_page
|
||||
d._on_mode_clicked(d.MODE_AUDIO)
|
||||
assert d.stacked.currentWidget() is d.audio_page
|
||||
d.close()
|
||||
|
||||
|
||||
def test_import_dialog_qr_page_has_no_audio():
|
||||
# Audio lives on the import dialog's own audio page, never in the QR page.
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
assert not hasattr(page, "_audio_receive")
|
||||
texts = [b.text() for b in page.findChildren(dialogs.QPushButton)]
|
||||
assert not any("Audio" in t for t in texts)
|
||||
assert d.receive_btn is not None
|
||||
d.close()
|
||||
|
||||
|
||||
def test_export_dialog_empty_close():
|
||||
# An empty will shows a modal message and no widgets are built; stub the
|
||||
# message out for the test.
|
||||
orig = dialogs.MessageBoxMixin.show_message
|
||||
dialogs.MessageBoxMixin.show_message = lambda self, msg, icon=None: None
|
||||
try:
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert not d.isVisible()
|
||||
assert not hasattr(d, "qr_page")
|
||||
d.close()
|
||||
finally:
|
||||
dialogs.MessageBoxMixin.show_message = orig
|
||||
|
||||
|
||||
def test_imported_item_status_not_none():
|
||||
# Regression: a WillItem built from a bare {"tx": hex} had a None status,
|
||||
# so set_status (e.g. IMPORTED / INVALIDATED from the import validity
|
||||
# pass) crashed with "unsupported operand type(s) for +=: 'NoneType' and
|
||||
# 'str'".
|
||||
with patch.object(Transaction, "add_info_from_wallet"):
|
||||
wi = WillItem({"tx": _VALID_TX_HEX}, wallet=None)
|
||||
assert wi.status == ""
|
||||
assert wi.set_status("IMPORTED", True) is True
|
||||
assert wi.set_status("INVALIDATED", True) is True
|
||||
assert "Imported" in wi.status and "Invalidated" in wi.status
|
||||
|
||||
|
||||
def test_export_auto_scroll():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert page.fps_spin is not None
|
||||
assert not page.auto_timer.isActive()
|
||||
|
||||
page.fps_spin.setValue(2)
|
||||
page._toggle_auto()
|
||||
assert page.auto_timer.isActive()
|
||||
assert page.auto_btn.text() == dialogs._("Stop")
|
||||
page._auto_step()
|
||||
assert page.index == 1
|
||||
page._toggle_auto()
|
||||
assert not page.auto_timer.isActive()
|
||||
assert page.auto_btn.text() == dialogs._("Auto")
|
||||
|
||||
# Advancing past the last frame stops the slideshow automatically.
|
||||
page._toggle_auto()
|
||||
page.index = len(page.frames) - 1
|
||||
page._auto_step()
|
||||
assert not page.auto_timer.isActive()
|
||||
d.close()
|
||||
|
||||
|
||||
def test_export_auto_scroll_loop():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert page.loop_check is not None
|
||||
|
||||
# Loop on: reaching the last code wraps back to the first and keeps going.
|
||||
page.loop_check.setChecked(True)
|
||||
page._toggle_auto()
|
||||
assert page.auto_timer.isActive()
|
||||
page.index = len(page.frames) - 1
|
||||
page._auto_step()
|
||||
assert page.index == 0
|
||||
assert page.auto_timer.isActive()
|
||||
page._toggle_auto()
|
||||
|
||||
# Loop off: reaching the last code stops the slideshow.
|
||||
page.loop_check.setChecked(False)
|
||||
page._toggle_auto()
|
||||
page.index = len(page.frames) - 1
|
||||
page._auto_step()
|
||||
assert not page.auto_timer.isActive()
|
||||
d.close()
|
||||
|
||||
|
||||
def test_export_filter_valid_and_valid_nc():
|
||||
bw = FakeBalWindow()
|
||||
a = StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True})
|
||||
b = StubWillItem("B" * 120, statuses={"VALID": True})
|
||||
c = StubWillItem("C" * 120)
|
||||
bw.willitems = {"a": a, "b": b, "c": c}
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert d.filter_combo.count() == 3
|
||||
# Whole-will default: a single JSON document carrying all selected items.
|
||||
assert d.content_check.isChecked()
|
||||
assert len(page.tx_strings) == 1
|
||||
data = json.loads(page.tx_strings[0])
|
||||
assert set(data) == {"a", "b", "c"}
|
||||
|
||||
# Switch to tx-only content, then exercise the filters.
|
||||
d.content_check.setChecked(False)
|
||||
assert len(page.tx_strings) == 3
|
||||
|
||||
# "Valid" filter -> only the valid items (a, b).
|
||||
d._on_filter_change(1)
|
||||
assert sorted(page.tx_strings) == ["A" * 120, "B" * 120]
|
||||
|
||||
# "Valid NC" filter -> only the valid, not-complete item (b).
|
||||
d._on_filter_change(2)
|
||||
assert sorted(page.tx_strings) == ["B" * 120]
|
||||
assert page.qr_view.text == page.frames[0]
|
||||
d.close()
|
||||
|
||||
|
||||
def test_export_filter_empty_reverts():
|
||||
bw = FakeBalWindow()
|
||||
# Only a COMPLETE valid item: "Valid NC" selects nothing -> revert.
|
||||
bw.willitems = {
|
||||
"a": StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True})
|
||||
}
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
messages = []
|
||||
d.show_message = lambda msg: messages.append(msg) # type: ignore[assignment]
|
||||
d._on_filter_change(2) # "Valid NC" -> empty subset
|
||||
assert messages
|
||||
assert d._filter_index == 0 # reverted to "All"
|
||||
assert d.filter_combo.currentIndex() == 0
|
||||
assert len(d.qr_page.tx_strings) == 1
|
||||
d.close()
|
||||
|
||||
|
||||
def test_export_navigation_and_chunk_change():
|
||||
# Low-redundancy serialized transactions resist deflate, so even the
|
||||
# compressed best-of transfer still needs several frames at the default
|
||||
# chunk and navigation across frames is exercised.
|
||||
def noisy(pad):
|
||||
return "".join("{:02x}".format((pad * 31 + j * 101 + j * j) % 256) for j in range(200))
|
||||
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(
|
||||
n=6, payload_len=400,
|
||||
payloads=["{}0{}".format(noisy(i), "T" * 50) for i in range(6)],
|
||||
)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
first_count = len(page.frames)
|
||||
assert first_count > 1 # long transfer, small default chunk
|
||||
|
||||
assert not page.prev_btn.isEnabled()
|
||||
page._next()
|
||||
assert page.index == 1
|
||||
assert page.qr_view.text == page.frames[1]
|
||||
assert page.prev_btn.isEnabled()
|
||||
page._prev()
|
||||
assert page.index == 0
|
||||
assert page.qr_view.text == page.frames[0]
|
||||
|
||||
# Switch to the largest preset: fewer, bigger frames.
|
||||
page._on_chunk_change(len(dialogs.CHUNK_PRESETS) - 1)
|
||||
assert len(page.frames) < first_count
|
||||
assert page.index == 0
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillImportDialog (QR transport via d.qr_page)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_import_frame_flow():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
assert not page.review_btn.isEnabled()
|
||||
assert not page.slot_area.isVisible()
|
||||
|
||||
transfer = encode_transfer(["A" * 120, "B" * 120, "C" * 120])
|
||||
frames = split_frames(transfer, 150)
|
||||
assert len(frames) > 1
|
||||
|
||||
for frame in frames:
|
||||
page._add_frame(frame)
|
||||
assert page.total == len(frames)
|
||||
assert page.review_btn.isEnabled()
|
||||
# isVisible() needs a shown parent; assert the widget is not hidden instead.
|
||||
assert not page.slot_area.isHidden()
|
||||
assert len(page.slot_widgets) == page.total
|
||||
assert "All" in page.status_label.text()
|
||||
# Duplicate capture is harmless.
|
||||
page._add_frame(frames[0])
|
||||
assert len(page.frames) == page.total
|
||||
d.close()
|
||||
|
||||
|
||||
def test_import_assembles_and_decodes():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
captured = {}
|
||||
|
||||
def fake_finish(payload):
|
||||
captured["payload"] = payload
|
||||
|
||||
page._finish_import = fake_finish
|
||||
transfer = encode_transfer(["P" * 130, "Q" * 130])
|
||||
for frame in split_frames(transfer, 150):
|
||||
page._add_frame(frame)
|
||||
page._review_and_sign()
|
||||
# The payload is rejoined into an opaque string for autodetect.
|
||||
assert captured["payload"].split("\n") == ["P" * 130, "Q" * 130]
|
||||
d.close()
|
||||
|
||||
|
||||
def test_decode_will_payload_autodetect():
|
||||
# Whole-will JSON is recognized as a will document.
|
||||
payload = json.dumps({"item1": {"tx": "AAAA", "status": {"VALID": True}}})
|
||||
kind, data = dialogs.decode_will_payload(payload)
|
||||
assert kind == "will"
|
||||
assert data["item1"]["tx"] == "AAAA"
|
||||
|
||||
# A singleton dict whose value is not an item dict falls back to txs.
|
||||
kind, data = dialogs.decode_will_payload('{"foo": 1}')
|
||||
assert kind == "txs"
|
||||
|
||||
# Comma and/or newline separated transactions.
|
||||
kind, data = dialogs.decode_will_payload("AAAA,BBBB\nCCCC")
|
||||
assert kind == "txs"
|
||||
assert data == ["AAAA", "BBBB", "CCCC"]
|
||||
|
||||
# A single transaction with no separators.
|
||||
kind, data = dialogs.decode_will_payload("HEXHEX")
|
||||
assert kind == "txs"
|
||||
assert data == ["HEXHEX"]
|
||||
|
||||
|
||||
def test_whole_will_qr_roundtrip():
|
||||
# "Whole will" produces a single JSON document that survives a full
|
||||
# QR encode -> frame capture -> assemble -> decode cycle.
|
||||
bw = FakeBalWindow()
|
||||
items = _make_willitems(2)
|
||||
bw.willitems = items
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert d.content_check.isChecked()
|
||||
assert len(page.tx_strings) == 1
|
||||
|
||||
# Rebuild the transfer from the dialog's own strings, as the importer does.
|
||||
transfer = encode_transfer(page.tx_strings)
|
||||
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
import_page = impl.qr_page
|
||||
for frame in split_frames(transfer, dialogs.CHUNK_PRESETS[0][1]):
|
||||
import_page._add_frame(frame)
|
||||
assert import_page.review_btn.isEnabled()
|
||||
caught = {}
|
||||
|
||||
def fake_finish(payload):
|
||||
caught["payload"] = payload
|
||||
|
||||
import_page._finish_import = fake_finish
|
||||
import_page._review_and_sign()
|
||||
kind, data = dialogs.decode_will_payload(caught["payload"])
|
||||
assert kind == "will"
|
||||
assert set(data) == {"item0", "item1"}
|
||||
d.close()
|
||||
impl.close()
|
||||
|
||||
|
||||
def test_import_total_mismatch_resets():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
warnings = []
|
||||
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
|
||||
|
||||
frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150)
|
||||
for frame in frames_a:
|
||||
page._add_frame(frame)
|
||||
assert page.total == len(frames_a)
|
||||
|
||||
# A frame with a different total wipes the import; the first frame of
|
||||
# the new transfer must be scanned afresh.
|
||||
page._add_frame(frames_b[0])
|
||||
assert warnings
|
||||
assert page.total == 0
|
||||
assert not page.frames
|
||||
assert not page.review_btn.isEnabled()
|
||||
d.close()
|
||||
|
||||
|
||||
def test_import_manual_entry():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
frames = split_frames(encode_transfer(["M" * 120]), 150)
|
||||
page.manual_edit.setText(frames[0])
|
||||
page._add_from_manual()
|
||||
assert page.manual_edit.text() == ""
|
||||
assert page.total == 1
|
||||
assert page.review_btn.isEnabled()
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Continuous camera scan (change/detection debounce + auto-finish)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _fresh_debounce():
|
||||
return {
|
||||
"last_index": None,
|
||||
"last_payload": None,
|
||||
"pending_index": None,
|
||||
"pending_payload": None,
|
||||
"pending_count": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_qr_import_debounce_pending_then_accept():
|
||||
s = _fresh_debounce()
|
||||
# First sighting of a new identity: pending, not yet stored.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "PAYLOAD1") == "pending"
|
||||
assert s["pending_count"] == 1
|
||||
assert s["last_index"] is None
|
||||
# A second stable read of the same identity: accepted.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "PAYLOAD1") == "accept"
|
||||
assert s["last_index"] == 1
|
||||
assert s["last_payload"] == "PAYLOAD1"
|
||||
assert s["pending_count"] == 0
|
||||
|
||||
|
||||
def test_qr_import_debounce_re_reading_last_is_ignored():
|
||||
s = _fresh_debounce()
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1")
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") # accepted
|
||||
# The exporter is still showing frame 1: must be ignored, not accepted.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") == "ignore"
|
||||
assert s["last_index"] == 1
|
||||
assert s["pending_count"] == 0
|
||||
|
||||
|
||||
def test_qr_import_debounce_transition_pending_resets():
|
||||
s = _fresh_debounce()
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1")
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") # accept frame 1
|
||||
# A new identity interrupts the pending accumulation.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "accept"
|
||||
# Same-index duplicate with different payload is treated as new identity.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "ignore"
|
||||
|
||||
|
||||
def test_qr_import_debounce_total_mismatch_resets():
|
||||
s = _fresh_debounce()
|
||||
# In-range frame is accepted even though its declared total is ignored.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:3", 3, 2, "P2") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:3", 3, 2, "P2") == "accept"
|
||||
# A frame that belongs to a different transfer.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:4", 4, 2, "P2B") == "reset"
|
||||
# Once the policy is rebased on the new transfer, frames resume normally
|
||||
# (the widget clears the debounce while wiping the import).
|
||||
s["key"] = None
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:4", 4, 2, "P2B") == "pending"
|
||||
|
||||
|
||||
def test_qr_import_handle_scanned_text_autofinish():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
reviewed = []
|
||||
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||
|
||||
frames = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
assert len(frames) > 1
|
||||
|
||||
# The camera session is running and every frame needs two stable reads.
|
||||
page._scanning = True
|
||||
for frame in frames:
|
||||
for _rep in range(2):
|
||||
page._handle_scanned_text(frame)
|
||||
assert page.total == len(frames)
|
||||
assert len(page.frames) == len(frames)
|
||||
assert page.review_btn.isEnabled()
|
||||
|
||||
# With all frames stored, the loop auto-finishes exactly once.
|
||||
_app.processEvents()
|
||||
assert reviewed == [True]
|
||||
# The camera loop was stopped before handing over to the review step.
|
||||
assert not page._scanning
|
||||
assert not page._scan_timer.isActive()
|
||||
d.close()
|
||||
|
||||
|
||||
def test_qr_import_handle_scanned_text_manual_does_not_autofinish():
|
||||
# Without a camera session running, extra frames never auto-proceed.
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
reviewed = []
|
||||
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||
|
||||
frames = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
assert len(frames) > 1
|
||||
assert not page._scanning
|
||||
for frame in frames:
|
||||
for _rep in range(2):
|
||||
page._handle_scanned_text(frame)
|
||||
assert len(page.frames) == len(frames)
|
||||
_app.processEvents()
|
||||
assert reviewed == []
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Animated-QR formats (BC-UR v1/v2, BBQR) via the export/import pages
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_export_format_combo_switches_codecs():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert page.format == "balqr"
|
||||
assert page.frames[0].startswith("BAL1")
|
||||
assert page.format_combo.count() == 4
|
||||
|
||||
page._on_format_change(1) # BC-UR v1
|
||||
assert page.format == "ur1"
|
||||
assert page.frames[0].startswith("ur:bytes/")
|
||||
assert page.index == 0
|
||||
assert page.qr_view.text == page.frames[0]
|
||||
|
||||
page._on_format_change(2) # BC-UR v2
|
||||
assert page.format == "ur2"
|
||||
assert page.frames[0].startswith("ur:bytes/")
|
||||
|
||||
page._on_format_change(3) # BBQR
|
||||
assert page.format == "bbqr"
|
||||
assert page.frames[0].startswith("B$")
|
||||
d.close()
|
||||
|
||||
|
||||
def test_export_animated_format_frames_fit_budget():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
for index in range(1, 4):
|
||||
page._on_format_change(index)
|
||||
for frame in page.frames:
|
||||
assert len(frame) <= dialogs.CHUNK_PRESETS[0][1]
|
||||
d.close()
|
||||
|
||||
|
||||
def _import_roundtrip_fmt(fmt_index):
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(2)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
page._on_format_change(fmt_index)
|
||||
frames = list(page.frames)
|
||||
assert frames
|
||||
d.close()
|
||||
|
||||
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
import_page = impl.qr_page
|
||||
caught = {}
|
||||
import_page._finish_import = lambda payload: caught.__setitem__("payload", payload)
|
||||
for frame in frames:
|
||||
import_page._add_frame(frame)
|
||||
assert import_page.review_btn.isEnabled()
|
||||
import_page._review_and_sign()
|
||||
kind, data = dialogs.decode_will_payload(caught["payload"])
|
||||
assert kind == "will"
|
||||
assert set(data) == {"item0", "item1"}
|
||||
impl.close()
|
||||
|
||||
|
||||
def test_import_ur1_roundtrip():
|
||||
_import_roundtrip_fmt(1)
|
||||
|
||||
|
||||
def test_import_ur2_roundtrip():
|
||||
_import_roundtrip_fmt(2)
|
||||
|
||||
|
||||
def test_import_bbqr_roundtrip():
|
||||
_import_roundtrip_fmt(3)
|
||||
|
||||
|
||||
def test_import_animated_scan_debounce_autofinish():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(2)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
page._on_format_change(2) # BC-UR v2 fountain
|
||||
frames = list(page.frames)
|
||||
d.close()
|
||||
|
||||
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
import_page = impl.qr_page
|
||||
reviewed = []
|
||||
import_page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||
import_page._scanning = True
|
||||
for frame in frames:
|
||||
for _rep in range(2):
|
||||
import_page._handle_scanned_text(frame)
|
||||
assert import_page.review_btn.isEnabled()
|
||||
# The fountain transfer's part count is ``len(frames) // 2`` (pure + one
|
||||
# redundant mixed wave).
|
||||
assert import_page.total == len(frames) // 2
|
||||
assert len(import_page.frames) >= import_page.total
|
||||
assert import_page.review_btn.isEnabled()
|
||||
_app.processEvents()
|
||||
assert reviewed == [True]
|
||||
assert not import_page._scanning
|
||||
impl.close()
|
||||
|
||||
|
||||
def test_import_garbage_scan_is_ignored():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
for garbage in ("hello world", "12345", "B$ZZ", ""):
|
||||
page._handle_scanned_text(garbage)
|
||||
assert not page.frames
|
||||
assert page.total == 0
|
||||
assert not page.review_btn.isEnabled()
|
||||
d.close()
|
||||
|
||||
|
||||
def test_import_different_animated_transfer_resets():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
warnings = []
|
||||
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
|
||||
|
||||
frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150)
|
||||
for frame in frames_a:
|
||||
page._add_frame(frame)
|
||||
assert page.total == len(frames_a)
|
||||
assert not warnings
|
||||
|
||||
page._add_frame(frames_b[0])
|
||||
assert warnings
|
||||
assert page.total == 0
|
||||
assert not page.frames
|
||||
assert not page.review_btn.isEnabled()
|
||||
d.close()
|
||||
|
||||
|
||||
def test_import_start_stop_scan_signal_wiring():
|
||||
"""Regression: _start_scan/_stop_scan must use the QVideoSink signal
|
||||
videoFrameChanged, not the videoFrame frame getter.
|
||||
|
||||
On PyQt6, ``QVideoSink.videoFrame`` is a method (the frame getter), so
|
||||
``.videoFrame.connect(...)`` raises AttributeError. This test drives the
|
||||
real sink life-cycle with a mocked camera and asserts the scan session
|
||||
starts/ends cleanly with no error.
|
||||
"""
|
||||
from PyQt6.QtMultimedia import QCamera, QMediaCaptureSession, QMediaDevices
|
||||
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
errors = []
|
||||
page.show_error = lambda msg: errors.append(msg) # type: ignore[assignment]
|
||||
|
||||
fake_device = MagicMock()
|
||||
fake_device.isNull.return_value = False
|
||||
|
||||
with (
|
||||
patch.object(QMediaDevices, "defaultVideoInput", return_value=fake_device),
|
||||
# Mock camera + capture session; the QVideoSink stays real so the
|
||||
# videoFrameChanged connect/disconnect wiring is exercised for real.
|
||||
patch.object(QCamera, "__new__", return_value=MagicMock()),
|
||||
patch.object(QMediaCaptureSession, "__new__", return_value=MagicMock()),
|
||||
):
|
||||
page._start_scan()
|
||||
assert page._scanning is True
|
||||
assert not errors
|
||||
|
||||
page._stop_scan()
|
||||
assert page._scanning is False
|
||||
assert page._camera is None
|
||||
assert page._video_sink is None
|
||||
assert not errors
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All QR transfer GUI tests passed")
|
||||
@@ -183,6 +183,22 @@ def test_locktime_raw_edit_get_set_value():
|
||||
assert "d" in val
|
||||
|
||||
|
||||
def test_locktime_date_edit_get_set_value_roundtrip():
|
||||
"""set_value(x) must roundtrip to get_value() == x (same timezone).
|
||||
|
||||
Guards a timezone regression that made the Date editor return the stored
|
||||
wall clock re-read as local time, i.e. ``x + utc_offset``. That broke the
|
||||
set_value/get_value roundtrip and kept the valueEdited ->
|
||||
update_setting_widgets -> set_value signal cycle alive forever, ending in a
|
||||
RecursionError when opening the "Build your will" wizard (Next button).
|
||||
"""
|
||||
from bal.gui.qt.widgets import LockTimeDateEdit
|
||||
edit = LockTimeDateEdit()
|
||||
for ts in (1700000000, 1750000000, 2147483647):
|
||||
edit.set_value(ts)
|
||||
assert edit.get_value() == ts
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# PercAmountEdit
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user