Compare commits
13 Commits
feature/ba
...
fix/i18n-r
| Author | SHA1 | Date | |
|---|---|---|---|
| a81443ff67 | |||
|
827a1d1e54
|
|||
|
d6fd20ad9c
|
|||
|
5ec2b79d03
|
|||
|
47b120f526
|
|||
|
1b601609f0
|
|||
|
b1f78700d1
|
|||
|
beab22ce23
|
|||
| 70196fc3cd | |||
| 9c654bf2bf | |||
| e8db76338d | |||
|
4a33116b66
|
|||
|
fb88d7540c
|
7
.gitignore
vendored
7
.gitignore
vendored
@@ -33,3 +33,10 @@ tmp*
|
|||||||
|
|
||||||
# Release artifacts
|
# Release artifacts
|
||||||
bal_v*.zip.*
|
bal_v*.zip.*
|
||||||
|
tests/karen7
|
||||||
|
|
||||||
|
# Compiled translations: build_zip.py rebuilds them from the .po sources
|
||||||
|
*.mo
|
||||||
|
|
||||||
|
# Android companion app: kept locally only, not part of the plugin repo
|
||||||
|
android/
|
||||||
|
|||||||
231
.opencode/plans/plan_android_reader.md
Normal file
231
.opencode/plans/plan_android_reader.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# PLAN_ANDROID_READER.md — BAL Reader: an Android QR will reader
|
||||||
|
|
||||||
|
**Date:** 2026-09-09 · **Status:** proposed · **Ties into:** BAL plugin QR export (BALQR / BC-UR v1 / BC-UR v2 / BBQR)
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Ship a minimal single-activity Android app that reads any QR will exported by the BAL
|
||||||
|
Electrum plugin, decodes all four supported wire formats, and lets the user view, copy,
|
||||||
|
share, or save the recovered will data. The app is a **reader only** — it never signs or
|
||||||
|
broadcasts.
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- Continuous camera capture (CameraX + ML Kit on-device barcode scanning, QR-only).
|
||||||
|
- Auto-detection of the four formats (`detect_format`: `"balqr" / "ur1" / "ur2" / "bbqr"`).
|
||||||
|
- Order-independent frame assembly: duplicates ignored, out-of-order accepted, UR v2
|
||||||
|
XOR-fountain redundancy exploited, session-level reset on transfer switch.
|
||||||
|
- Whole-will JSON **and** tx-hex-list payloads, both decoded to a readable view.
|
||||||
|
- Copy raw transfer text / Share / Save via Storage Access Framework.
|
||||||
|
- Verified decode chain runnable on the dev machine (no Android needed).
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- Signing, broadcasting, password handling, wallet integration.
|
||||||
|
- Export/authoring QR codes *from* the phone.
|
||||||
|
- Audio-modem transport (unchanged, plugin-only).
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
### 3.1 Why Chaquopy (reuse the tested Python codecs)
|
||||||
|
|
||||||
|
`bal/core/animated_qr.py` (1178 lines) and `bal/core/qrtransfer.py` (212 lines) are
|
||||||
|
**pure stdlib** (`base64`, `hashlib`, `zlib`), GUI-free, Python-3.8-clean (verified: no
|
||||||
|
walrus/match/`X|Y`/PEP-585 generics), and `bal/core/__init__.py` is an empty docstring.
|
||||||
|
Chaquopy bundles CPython into the APK, so **the exact, battle-tested codec modules run
|
||||||
|
unchanged on Android** with zero port risk. The Kotlin side is only camera glue + UI.
|
||||||
|
|
||||||
|
### 3.2 Decode chain (mirror of the plugin's import tail)
|
||||||
|
|
||||||
|
The plugin's `_review_and_sign` (dialogs.py:3792) does exactly:
|
||||||
|
|
||||||
|
1. `session.resolve()` -> `(transfer_text, compressed: bool)`
|
||||||
|
(UR v1/v2/BBQR for the first pair; `(text, flag)` for BAL QR).
|
||||||
|
2. `decode_transfer(transfer_text, compressed)` -> list of parts (tx hexes, or the single
|
||||||
|
whole-will JSON).
|
||||||
|
3. `"\n".join(parts)` -> opaque payload.
|
||||||
|
4. `decode_will_payload(payload)` -> `("will", dict)` **or** `("txs", [strings])`.
|
||||||
|
|
||||||
|
The app reuses all of it verbatim through the `AnimatedQrSession` facade (`add_part`
|
||||||
|
auto-detects per frame, dedups, tracks `received/total/done`).
|
||||||
|
|
||||||
|
### 3.3 Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
CameraX ImageAnalysis -> ML Kit BarcodeScanning (QR) -> raw string
|
||||||
|
-> BalDecoder.add(text) [Chaquopy -> AnimatedQrSession.add_part]
|
||||||
|
-> status: format chip + received/total, duplicate-tolerant
|
||||||
|
-> when done: auto-stop -> BalDecoder.finish() (4-step decode)
|
||||||
|
-> ResultActivity: JSON view OR tx-list view + Copy/Share/Save
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Repository layout (new `android/` subfolder)
|
||||||
|
|
||||||
|
```
|
||||||
|
android/
|
||||||
|
README.md # build (Android Studio), usage, codec-sync rule, MIT note
|
||||||
|
settings.gradle.kts
|
||||||
|
build.gradle.kts # root: plugins (AGP 8.10, Kotlin 2.0.21, Chaquopy 17, apply false)
|
||||||
|
gradle.properties
|
||||||
|
gradlew, gradlew.bat
|
||||||
|
gradle/wrapper/ # gradle-wrapper.properties + gradle-wrapper.jar (fetched; see §7)
|
||||||
|
scripts/
|
||||||
|
sync_codecs.py # copy bal/core/{__init__,animated_qr,qrtransfer}.py -> app python dir; import-check
|
||||||
|
test_chain/
|
||||||
|
verify_chain.py # full decode-chain simulation, runs on dev machine
|
||||||
|
app/
|
||||||
|
build.gradle.kts # com.android.application + com.chaquo.python; CameraX/ML Kit deps
|
||||||
|
src/main/
|
||||||
|
AndroidManifest.xml # CAMERA permission
|
||||||
|
java/life/after/bitcoin/
|
||||||
|
MainActivity.kt # permission + PreviewView + ImageAnalysis + status header
|
||||||
|
BalDecoder.kt # Chaquopy bridge (§5.1)
|
||||||
|
ResultActivity.kt # viewer + copy/share/save (§5.3)
|
||||||
|
res/layout/activity_main.xml, activity_result.xml
|
||||||
|
res/values/strings.xml
|
||||||
|
python/bal/core/ # SYNCED COPIES (committed, deterministic; regenerate with script)
|
||||||
|
__init__.py
|
||||||
|
animated_qr.py
|
||||||
|
qrtransfer.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Component specifications
|
||||||
|
|
||||||
|
### 5.1 `BalDecoder` (Kotlin, Chaquopy bridge)
|
||||||
|
|
||||||
|
- Lazy init: `Python.start(AndroidPlatform(context))`, `getModule("bal.core.animated_qr")`.
|
||||||
|
- `fun add(text: String): String` -> `session.add_part(text)`; surfaces `"ok"`/`"dup"`;
|
||||||
|
maps `AnimatedQrError` subclasses to a result the UI can ignore (garbage frames) vs
|
||||||
|
reset (transfer switch -> `TransferConflictError` -> tell user to rescan).
|
||||||
|
- `val format: String?` (`"balqr"/"ur1"/"ur2"/"bbqr"`), `val received: Int`,
|
||||||
|
`val total: Int`, `val done: Boolean` (auto-converted by Chaquopy).
|
||||||
|
- `fun finish(): DecodedResult` - the 4-step chain; returns
|
||||||
|
`data class DecodedResult(kind: "will"|"txs", data: Map<String,Any>|List<String>, rawTransfer: String)`.
|
||||||
|
- `fun reset()` -> new `AnimatedQrSession` (new scan).
|
||||||
|
|
||||||
|
### 5.2 `MainActivity` (camera + scan loop)
|
||||||
|
|
||||||
|
- Launches CameraX via `ProcessCameraProvider`; `PreviewView` fills screen; camera
|
||||||
|
permission via `ActivityResultContracts.RequestPermission`.
|
||||||
|
- `ImageAnalysis` `STRATEGY_KEEP_ONLY_LATEST`; analyzer throttled (~100 ms) calls ML Kit
|
||||||
|
`BarcodeScanning` with `Barcode.FORMAT_QR_CODE`.
|
||||||
|
- Thread-safe feed to `BalDecoder` (analyzer runs on a background executor); UI status
|
||||||
|
via `runOnUiThread`.
|
||||||
|
- Header row: format chip + `received/total`; on `done` -> stop analyzer -> launch
|
||||||
|
`ResultActivity` (results as Parcelable); "New scan" restarts.
|
||||||
|
- Garbage / incomplete frames silently ignored (same policy as the plugin); a
|
||||||
|
`TransferConflictError` mid-scan resets the session and signals the user to rescan.
|
||||||
|
|
||||||
|
### 5.3 `ResultActivity` (viewer)
|
||||||
|
|
||||||
|
- `kind == "will"`: whole-will JSON - each item's `tx` hex shown truncated with full view
|
||||||
|
on demand.
|
||||||
|
- `kind == "txs"`: list of tx hexes.
|
||||||
|
- Action bar: **Copy** (raw transfer text to clipboard), **Share** (ACTION_SEND
|
||||||
|
text/plain), **Save** (SAF `ACTION_CREATE_DOCUMENT` -> `will.json` / `will_tx.txt`).
|
||||||
|
- "Scan another" button -> finish -> back to camera.
|
||||||
|
|
||||||
|
### 5.4 `scripts/sync_codecs.py`
|
||||||
|
|
||||||
|
- Copies the 3 files from `bal/core/` -> `app/src/main/python/bal/core/` (idempotent).
|
||||||
|
- Post-copy check (dev machine): import `bal.core.animated_qr`, run one UR v2 frame +
|
||||||
|
decode cycle to prove the copy imports standalone.
|
||||||
|
- Documented as the rule after any codec change (README).
|
||||||
|
|
||||||
|
### 5.5 `test_chain/verify_chain.py`
|
||||||
|
|
||||||
|
Simulates the exact APK runtime path on the dev machine (no Android). Generates frames
|
||||||
|
precisely as the export page does, then feeds `AnimatedQrSession.add_part` in
|
||||||
|
scrambled/duplicated/dropped order and asserts correct results for:
|
||||||
|
|
||||||
|
- BAL QR multi-frame, plain **and** compressed (`Z` flag): `split_frames(encode_transfer(...))`.
|
||||||
|
- UR v1 single-part (`ur1_frames` headerless) and `1ofN` multipart.
|
||||||
|
- UR v2 single-part and fountain multipart with >=1 frame dropped and >=1 duplicated
|
||||||
|
(exercises the XOR solve).
|
||||||
|
- BBQR `Z`/`H`/`2`: `bbqr_frames(..., encoding=...)`, with the `Z` auto-decompress branch.
|
||||||
|
- payload kinds: whole-will JSON **and** tx-hex list.
|
||||||
|
- transfer-switch: feed a frame of a different transfer mid-session -> expect conflict, as
|
||||||
|
the UI will.
|
||||||
|
|
||||||
|
Runs under the runtime venv:
|
||||||
|
`source .../electrum/env/bin/activate && QT_QPA_PLATFORM=offscreen python3 android/test_chain/verify_chain.py`
|
||||||
|
|
||||||
|
## 6. Wire-format reference (bundled codecs)
|
||||||
|
|
||||||
|
- **BAL QR**: `BALQR1|<total>|<index>|<flags>|<payload>`, flags `""` or `Z` (zlib+base64).
|
||||||
|
Concatenate payloads 1..total -> transfer string -> `decode_transfer` splits on `\n`.
|
||||||
|
- **BC-UR v1**: multipart `ur:bytes/<seq>of<seq_len>/<sha256-bc32-digest>/<bc32-frag>`;
|
||||||
|
single-part `ur:bytes/<bc32>` (digest-less). BC32 = bech32_bis (XOR `0x3FFFFFFF`)
|
||||||
|
5-bit alphabet.
|
||||||
|
- **BC-UR v2**: multipart `ur:bytes/<seq>-of-<seq_len>/<bytewords-minimal-part>` +
|
||||||
|
single-part headerless; part body = CBOR `[seq, seq_len, msg_len, crc32, data]` +
|
||||||
|
per-part CRC-32, bytewords-minimal; fountain via `choose_fragments` (xoshiro256** +
|
||||||
|
alias/threshold), mixed by XOR - decoder solves from any sufficient subset.
|
||||||
|
- **BBQR**: `B$<encoding><type><base36 total><base36 index><payload>`; encodings `H`
|
||||||
|
(upper hex), `2` (base32nal), `Z` (deflate `wbits=-10` -> base32).
|
||||||
|
|
||||||
|
## 7. Toolchain & versions
|
||||||
|
|
||||||
|
| Item | Version | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Chaquopy | 17.0.0 | Python 3.10-3.14, AGP 7.3-9.2, minSdk 24 |
|
||||||
|
| AGP | 8.10.0 | in Chaquopy 17 range |
|
||||||
|
| Gradle wrapper | 8.14 | required by AGP 8.10 |
|
||||||
|
| Kotlin | 2.0.21 | |
|
||||||
|
| compile/target SDK | 35 / min 24 | |
|
||||||
|
| JDK | 17 (machine has OpenJDK 17) | |
|
||||||
|
| CameraX | 1.3.4 | `camera-core`, `camera-camera2`, `camera-lifecycle`, `camera-view` |
|
||||||
|
| ML Kit barcode | 17.3.0 | on-device, no API key |
|
||||||
|
| Python (codec) | 3.12 (Chaquopy) | codecs verified 3.8-clean |
|
||||||
|
|
||||||
|
Dev machine: Android Studio + JDK 17 present; SDK platforms/build-tools/gradle absent ->
|
||||||
|
the **first real APK build happens in Android Studio with network**. `gradle-wrapper.jar`
|
||||||
|
is fetched via `curl` (canonical location) so `./gradlew` works; if network is blocked,
|
||||||
|
README documents Android Studio regenerating it.
|
||||||
|
|
||||||
|
## 8. Build & run outline (for README)
|
||||||
|
|
||||||
|
1. Open `android/` in Android Studio (or `./gradlew assembleDebug`).
|
||||||
|
2. Allow Gradle to fetch wrapper/deps (network).
|
||||||
|
3. Install on device; grant camera permission.
|
||||||
|
4. In the plugin: export dialog -> pick format (start with BAL QR default; also test
|
||||||
|
UR v1/UR v2/BBQR) -> show the animated QR on screen.
|
||||||
|
5. Point camera at screen; watch `received/total`; decoded result appears ->
|
||||||
|
Copy/Share/Save.
|
||||||
|
|
||||||
|
## 9. Verification
|
||||||
|
|
||||||
|
**On this machine (no Android needed)**
|
||||||
|
|
||||||
|
1. `python3 android/scripts/sync_codecs.py` -> copy + import/roundtrip sanity.
|
||||||
|
2. `QT_QPA_PLATFORM=offscreen python3 android/test_chain/verify_chain.py` -> all
|
||||||
|
formats/payload kinds/conflict cases pass.
|
||||||
|
3. `ruff check android/scripts/sync_codecs.py android/test_chain/verify_chain.py` -> clean.
|
||||||
|
4. `pytest tests/test_core_*.py tests/test_gui_*.py` -> still **445 passed**
|
||||||
|
(payload code untouched; only new files).
|
||||||
|
5. `python3 build_zip.py` unaffected (no change under `bal/`).
|
||||||
|
|
||||||
|
**On-device (manual, user)**
|
||||||
|
|
||||||
|
- Walk the 4 formats against the plugin's export page, single- and multi-frame
|
||||||
|
(animated), on a real phone.
|
||||||
|
- Confirm format chip, progress, auto-finish, Copy/Share/Save.
|
||||||
|
|
||||||
|
**Boundary** - no APK is produced by this machine's environment; the artifact is a
|
||||||
|
complete, independently buildable source tree + verified codec path.
|
||||||
|
|
||||||
|
## 10. Risks & notes
|
||||||
|
|
||||||
|
- First Gradle sync needs network (deps + wrapper). Pinned versions are conservative;
|
||||||
|
knobs documented.
|
||||||
|
- Bundled codec copies must be regenerated after any `bal/core/animated_qr.py` /
|
||||||
|
`qrtransfer.py` change - handled by `sync_codecs.py` + README note (copies are
|
||||||
|
committed for deterministic builds).
|
||||||
|
- Animated QR reading depends on the camera catching enough distinct frames (ML Kit
|
||||||
|
analyzer keeps scanning; the session dedups and accepts out-of-order). Slow phone
|
||||||
|
screens / glare may raise time-to-complete - expected, same as the plugin.
|
||||||
|
- App name/package (`life.after.bitcoin`, label "BAL Reader") are placeholders - trivial
|
||||||
|
to change.
|
||||||
|
- MIT: bundled codec files inherit the plugin's MIT license (noted in README).
|
||||||
34
AGENTS.md
34
AGENTS.md
@@ -3,6 +3,9 @@
|
|||||||
BAL — Bitcoin After Life, an Electrum plugin (inheritance / dead-man's-switch).
|
BAL — Bitcoin After Life, an Electrum plugin (inheritance / dead-man's-switch).
|
||||||
Source-of-truth docs: `README.md`, `HANDOFF.md`, `COMPATIBILITY.md`.
|
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)
|
## Environments (critical)
|
||||||
|
|
||||||
Two separate venvs; using the wrong one is the #1 mistake.
|
Two separate venvs; using the wrong one is the #1 mistake.
|
||||||
@@ -80,6 +83,32 @@ QT_QPA_PLATFORM=offscreen python3 -m pytest tests/test_core_*.py -q
|
|||||||
registration API differs between them (`json_db.register_dict` vs
|
registration API differs between them (`json_db.register_dict` vs
|
||||||
`stored_dict.register_name`).
|
`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
|
## Build / release
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -87,6 +116,11 @@ 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
|
./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. The i18n
|
||||||
|
tests need it too. Install it in the **runtime** env used for releases
|
||||||
|
(`source "$BAL_HOME/electrum/env/bin/activate" && pip install babel`);
|
||||||
|
`make-release.sh` checks it before running anything.
|
||||||
- `make-release.sh` requires gpg and Gitea credentials (`~/.git-credentials`
|
- `make-release.sh` requires gpg and Gitea credentials (`~/.git-credentials`
|
||||||
or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the
|
or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the
|
||||||
version there, never invent a new source of truth.
|
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
|
||||||
|
```
|
||||||
619
CHANGELOG.md
619
CHANGELOG.md
@@ -2909,6 +2909,108 @@ renumber the grid rows.
|
|||||||
|
|
||||||
**Outcome:** DONE.
|
**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
|
## 57. Name the real cause of a failed build instead of guessing
|
||||||
@@ -2992,4 +3094,521 @@ misleading.
|
|||||||
- Manually tested by the owner in Electrum 4.8.1: `NO_FUTURE_DATE`,
|
- Manually tested by the owner in Electrum 4.8.1: `NO_FUTURE_DATE`,
|
||||||
`WILLEXECUTOR_FEE` and `No Heirs` were all confirmed on screen.
|
`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.
|
**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.
|
||||||
|
## 61. Signed relative Check Alive (negative = before delivery, positive = after creation)
|
||||||
|
|
||||||
|
**Date:** 2026-10-01
|
||||||
|
|
||||||
|
**Goal:** Change the meaning of a RELATIVE Check Alive value so that its SIGN
|
||||||
|
selects the anchor date:
|
||||||
|
|
||||||
|
- NEGATIVE (`-30d` / `-1y`): counted BACKWARDS from the delivery date
|
||||||
|
(`delivery + N days`; `-30d` = 30 days BEFORE the delivery). This preserves
|
||||||
|
the historical meaning of a relative threshold.
|
||||||
|
- POSITIVE (`30d` / `1y`): counted FORWARD from the inheritance creation date
|
||||||
|
(`min(WillItem.time)` over the VALID will items, frozen in the wallet DB).
|
||||||
|
- When the anchor is unknown (no built will yet, or imported items without a
|
||||||
|
`time`), the reference falls back to *now*.
|
||||||
|
|
||||||
|
No migration: existing stored positive values switch to the new meaning. The
|
||||||
|
Check Alive must always be strictly AFTER today and strictly BEFORE the
|
||||||
|
delivery; when it is not, the plugin raises an explicit error (see below).
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
|
||||||
|
- `bal/core/checkalive.py` (centralised, GUI-free):
|
||||||
|
- new `resolve_delivery_date(will_settings, now, built_locktime)` — the single
|
||||||
|
source of the delivery reference (absolute locktime as-is; relative locktime
|
||||||
|
resolved forward from *now* unless a built will freezes it).
|
||||||
|
- new `resolve_relative_check_alive_date(threshold_raw, delivery_date,
|
||||||
|
creation_date, now)` — the SINGLE calculation rule (sign selects the anchor;
|
||||||
|
`BalTimestamp.to_date` is used forward on both anchors, no more
|
||||||
|
`reverse=True`).
|
||||||
|
- new `is_check_alive_date_valid(date_to_check, delivery_date, now)` and
|
||||||
|
`ensure_check_alive_valid(is_basic_mode, date_to_check, delivery_date, now)`
|
||||||
|
— the SINGLE validity gate: `now < date_to_check < delivery`. BASIC mode is
|
||||||
|
a no-op (Check Alive is inert).
|
||||||
|
- `resolve_date_to_check` / `resolve_guard_threshold`: gain a `creation_date`
|
||||||
|
parameter and delegate to the shared rule; `resolve_guard_threshold` still
|
||||||
|
resolves the delivery from the stored settings alone (no built anchor).
|
||||||
|
- `check_alive_expired` removed (superseded by `ensure_check_alive_valid`).
|
||||||
|
- new `CheckAliveAfterDeliveryError` (a SIBLING of `CheckAliveError`, not a
|
||||||
|
subclass): raised when the Check Alive is on/after the delivery. It must not
|
||||||
|
reuse the `except CheckAliveError` handlers, which invalidate the will
|
||||||
|
on-chain; this is a configuration error that only aborts the action.
|
||||||
|
`CheckAliveError` gained an optional `reason`.
|
||||||
|
- `bal/core/will.py`: new `Will.get_creation_time(will)` — the oldest
|
||||||
|
`WillItem.time` among VALID items (`None` when unknown).
|
||||||
|
- `bal/core/input_rules.py`: `normalize_locktime_raw_text(..., allow_negative)`
|
||||||
|
keeps a single leading `-` (Check Alive field only).
|
||||||
|
- `bal/gui/qt/widgets.py`: the Check Alive RAW editor accepts `-`
|
||||||
|
(`ThresholdTimeWidget.allow_negative_raw`); `on_locktime_change` uses the
|
||||||
|
shared resolver for `real_threshold`; updated help text.
|
||||||
|
- `bal/gui/qt/window.py`, `bal/gui/qt/dialogs.py`, `bal/cli/controller.py`: pass
|
||||||
|
`creation_date=Will.get_creation_time(...)`, call
|
||||||
|
`ensure_check_alive_valid`, and handle `CheckAliveAfterDeliveryError` by
|
||||||
|
aborting (never invalidating). The wizard .ics provider and the CLI guard use
|
||||||
|
the shared resolver too.
|
||||||
|
- `bal/core/plugin_base.py`: the default relative Check Alive is now `"-30d"`
|
||||||
|
(30 days BEFORE the 1-year delivery), preserving the historical default; the
|
||||||
|
absolute default threshold follows the same sign rule.
|
||||||
|
|
||||||
|
**Tests:**
|
||||||
|
|
||||||
|
- `tests/test_core_checkalive.py` rewritten (both signs, delivery/creation
|
||||||
|
resolution, validity gate, new exceptions, `Will.get_creation_time`).
|
||||||
|
- Updated `tests/test_group_i_basic_checkalive.py`, `tests/test_core_will.py`,
|
||||||
|
`tests/test_gui_will_flows.py`, `tests/test_cli_controller_offline.py`,
|
||||||
|
`tests/test_core_plugin_base.py`, `tests/test_core_input_rules.py`.
|
||||||
|
|
||||||
|
**Outcome:** DONE.
|
||||||
|
|
||||||
|
## 62. Remove BALQR and BC-UR v1/v2 — keep only BBQR
|
||||||
|
|
||||||
|
**Date:** 2026-10-03
|
||||||
|
|
||||||
|
**Goal:** Drop the BALQR, BC-UR v1 and BC-UR v2 QR wire formats and keep only
|
||||||
|
**BBQR** for will transfer.
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
|
||||||
|
- `bal/core/animated_qr.py`: removed the BC32, bytewords-minimal, CRC-32,
|
||||||
|
CBOR, xoshiro256/fountain codecs and the BALQR / UR v1 / UR v2 receive
|
||||||
|
sessions; the module now implements only the BBQR codec plus the
|
||||||
|
`AnimatedQrSession` facade and `detect_format` / `parse_for_detection`
|
||||||
|
(BBQR-only).
|
||||||
|
- `bal/core/qrtransfer.py`: removed the BALQR framing helpers (`split_frames`,
|
||||||
|
`parse_frame`, `assemble`, `encode_transfer_best`, the frame errors);
|
||||||
|
kept the transfer-string codec (`encode_transfer` / `decode_transfer`) and
|
||||||
|
the QR-size presets.
|
||||||
|
- `bal/gui/qt/dialogs.py`: the export widget no longer shows a Format selector
|
||||||
|
and always emits BBQR; the import widget treats BBQR indexes as 0-based.
|
||||||
|
- Removed the "QR Code Size" setting from the plugin settings dialog; the
|
||||||
|
QR-size selector now lives only in the export dialog, and its chosen value
|
||||||
|
is remembered (persisted to `QR_CHUNK_SIZE`) for the next export.
|
||||||
|
- Android reader: `bal/core/*` copies re-synced, `bal/encoder.py` and the
|
||||||
|
Kotlin bridges (`BalDecoder`, `QrEncoder`, `QrSendActivity`, `MainActivity`,
|
||||||
|
`strings.xml`) reduced to BBQR; `tools/emitter.py` and
|
||||||
|
`test_chain/verify_chain.py` updated.
|
||||||
|
- Tests: `test_core_animated_qr.py`, `test_core_qr_transfer.py` and
|
||||||
|
`test_gui_qr_transfer.py` rewritten for BBQR only.
|
||||||
|
- Docs: README, COMPATIBILITY, HANDOFF and android/README updated; translation
|
||||||
|
catalogs refreshed (removed format strings marked obsolete).
|
||||||
|
|
||||||
|
**Outcome:** DONE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 63. i18n review fixes (Truman's review of the translation PR)
|
||||||
|
|
||||||
|
**Date:** 2026-10-07
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
|
||||||
|
- `make-release.sh`
|
||||||
|
- Step 3 now also runs `tests/test_i18n.py`, `tests/test_translations.py`,
|
||||||
|
`tests/test_cli_*.py` and `tests/test_import_will_details.py` (the i18n
|
||||||
|
tests were never run by the release gate; the CLI and will-import tests
|
||||||
|
were not either).
|
||||||
|
- Checks up front that Babel is importable by `python3` and stops with
|
||||||
|
"pip install babel" otherwise: the i18n tests (step 3) and `build_zip.py`
|
||||||
|
(step 5) both need it, so a release no longer stops half-way.
|
||||||
|
- `AGENTS.md`: Babel must be installed in the runtime env used for releases.
|
||||||
|
- `pyproject.toml`: `[tool.ruff.lint.isort] known-first-party = ["bal"]`.
|
||||||
|
WHY: the reported I001 in `tests/test_translations.py` is not reproducible
|
||||||
|
from the repository root (ruff 0.15.11 and 0.6.9: clean), but it appears as
|
||||||
|
soon as ruff classifies `bal` as third-party (then 20 files report I001).
|
||||||
|
Removing the blank line would break the check where `bal` is first-party;
|
||||||
|
declaring it makes the result the same on every machine.
|
||||||
|
- `bal/core/willexecutors.py` - `get_willexecutor_transactions` returns COPIES
|
||||||
|
of the executor records. It used to write `txs`, `txsids` and the
|
||||||
|
translated `broadcast_status` into the will item's own `we` dict, which is
|
||||||
|
saved in the wallet (stored data depended on the UI language).
|
||||||
|
- `bal/core/will.py` - `WillItem` drops those transient fields when loading a
|
||||||
|
will saved by an older version, and maps a translated leading "New"
|
||||||
|
("Nuovo", "Nuevo" - saved by i18n test builds) back to "New".
|
||||||
|
- `bal/gui/qt/window.py`, `bal/cli/controller.py`: the status history of a new
|
||||||
|
will item is saved as the literal "New" (was `_("New")`).
|
||||||
|
- `tests/test_core_status_language_neutral.py` (new, 7 tests; 5 fail on
|
||||||
|
`main`): English "New" stored, translated "New" mapped back, the mapping
|
||||||
|
covers every catalog's translation of "New", the push helper does not
|
||||||
|
modify the will item, old transient fields dropped on load.
|
||||||
|
|
||||||
|
**Scope:** this entry contains ONLY the i18n review fixes. The other fixes
|
||||||
|
prepared in the same session (invalidation fee, Check `KeyError`, cascade,
|
||||||
|
deselected executor) are not part of it and will be proposed separately.
|
||||||
|
|
||||||
|
**Verification (on `main` 827a1d1):**
|
||||||
|
|
||||||
|
- New test file `tests/test_core_status_language_neutral.py`: 5 of 7 fail on
|
||||||
|
`main`, 7 of 7 pass with this change.
|
||||||
|
- Extended release test list (as above) plus the new test file: 446 passed,
|
||||||
|
1 failed (`main`: 439 passed, same failure). The failure is pre-existing:
|
||||||
|
`test_group_e_mock_karen7::test_e5` needs `tests/karen7`, a wallet file that
|
||||||
|
is not in the repository.
|
||||||
|
- Whole `tests/` directory: 609 passed, 26 failed, 3 collection errors
|
||||||
|
(`main`: 602 passed, and the SAME 26 failures and 3 errors, test by test).
|
||||||
|
All pre-existing and unrelated: 9 need `tests/karen7`; 21 in
|
||||||
|
`test_auto_rebuild_on_new_tx.py`, `test_cli_autorebuild.py` and
|
||||||
|
`test_cli_controller_offline.py` fail only when the whole directory runs
|
||||||
|
together (they pass when each file runs alone); 3 audio tests need an audio
|
||||||
|
device. `test_gui_export_dialogs.py::test_audio_import_receive_wiring` hangs
|
||||||
|
forever, also on `main`, so it was deselected.
|
||||||
|
- `ruff check bal/ tests/`: the same 3 pre-existing I001 as on `main`
|
||||||
|
(`dialogs.py`, `test_core_animated_qr.py`, `test_core_qr_transfer.py`),
|
||||||
|
none new.
|
||||||
|
|
||||||
|
**Outcome:** DONE (pending Truman's review).
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ is updated to mark them as supported.
|
|||||||
See [`README.md`](README.md) for supported Electrum versions (currently 4.7.2
|
See [`README.md`](README.md) for supported Electrum versions (currently 4.7.2
|
||||||
and 4.8.0).
|
and 4.8.0).
|
||||||
|
|
||||||
|
## QR wire-format compatibility
|
||||||
|
|
||||||
|
BAL exports/imports wills as QR codes using **BBQR**:
|
||||||
|
|
||||||
|
| Format | Wire appearance | Interop target |
|
||||||
|
|-----------|----------------------------|------------------------------------------------------|
|
||||||
|
| BBQR | `B$<enc><type><N><n>…` | Coinkite BitKit / Coldcard's BBQR animated-QR mode |
|
||||||
|
|
||||||
|
Import auto-detects the format of each scanned code; out-of-order and duplicate
|
||||||
|
BBQR frames are handled. Interop has not yet been cross-verified against
|
||||||
|
third-party libraries (`bbqr`, Coldcard firmwares).
|
||||||
|
|
||||||
## Reporting compatibility issues
|
## Reporting compatibility issues
|
||||||
|
|
||||||
If you find a compatibility problem not listed here, please open an issue on
|
If you find a compatibility problem not listed here, please open an issue on
|
||||||
|
|||||||
67
HANDOFF.md
67
HANDOFF.md
@@ -68,7 +68,8 @@ bal/ <- the plugin package (this is what ships in the ZI
|
|||||||
willexecutors.py <- remote will-executor services handling (is_selected / is_valid,
|
willexecutors.py <- remote will-executor services handling (is_selected / is_valid,
|
||||||
parallel push/check).
|
parallel push/check).
|
||||||
util.py <- locktime parsing/most helpers (timestamps only).
|
util.py <- locktime parsing/most helpers (timestamps only).
|
||||||
checkalive.py <- resolve_date_to_check, check_alive_expired (GUI-free).
|
checkalive.py <- resolve_date_to_check, resolve_relative_check_alive_date,
|
||||||
|
ensure_check_alive_valid (GUI-free; signed relative rule).
|
||||||
reminders.py <- compute_reminder_offsets, BALCalendar .ics generation (GUI-free).
|
reminders.py <- compute_reminder_offsets, BALCalendar .ics generation (GUI-free).
|
||||||
input_rules.py <- locktime/threshold data models, Raw/Date selector logic (GUI-free).
|
input_rules.py <- locktime/threshold data models, Raw/Date selector logic (GUI-free).
|
||||||
cli/ <- headless command-line layer (no Qt)
|
cli/ <- headless command-line layer (no Qt)
|
||||||
@@ -111,7 +112,8 @@ Two separate venvs — using the wrong one is the #1 mistake:
|
|||||||
- **Lint venv** (repo-local `venv/`): ruff, black, flake8 only. It cannot
|
- **Lint venv** (repo-local `venv/`): ruff, black, flake8 only. It cannot
|
||||||
import `electrum` or `PyQt6`. Do NOT use it to run tests.
|
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
|
**Tests are standalone scripts (not pytest):** each `tests/test_*.py` runs its
|
||||||
`test_*` functions from `if __name__ == "__main__"`. Run a file directly:
|
`test_*` functions from `if __name__ == "__main__"`. Run a file directly:
|
||||||
@@ -392,3 +394,64 @@ See Section 5 for details.
|
|||||||
push to `origin/main`, then run `./make-release.sh` to create the Gitea
|
push to `origin/main`, then run `./make-release.sh` to create the Gitea
|
||||||
**Release** with the ZIP + signatures attached (it becomes the owner's
|
**Release** with the ZIP + signatures attached (it becomes the owner's
|
||||||
"Latest" download). Always give the owner the Release URL.
|
"Latest" download). Always give the owner the Release URL.
|
||||||
|
|
||||||
|
### In progress: QR / audio will transfer (branch `feature/bal-qr-transfer`)
|
||||||
|
|
||||||
|
- The full QR-transfer feature (P0–P6) is implemented, tested and committed on
|
||||||
|
`feature/bal-qr-transfer` (commits `d288b55`, `ce3e36d`, pushed to
|
||||||
|
`origin`). PR creation URL:
|
||||||
|
`https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin/pulls/new/feature/bal-qr-transfer`
|
||||||
|
- Included: core scheduler (`bal/core/qrtransfer.py`), `QR_CHUNK_SIZE`
|
||||||
|
setting (4 export presets), export/import dialogs + review/sign wizard +
|
||||||
|
lists/window wiring, export filters, auto slideshow with per-second rate +
|
||||||
|
loop option, audio send/receive buttons, and the crash fixes
|
||||||
|
(`status` default, `invalidate_will` guard). Docs: README, CHANGELOG entry
|
||||||
|
56, QML_PLAN, `AUDIO_MODEM_DEBIAN.md`.
|
||||||
|
- Follow-up refactor (CHANGELOG entry 57): all `copy.deepcopy` removed —
|
||||||
|
`copy_structure()` in `bal/core/util.py`, `WillItem.copy()` / ctor
|
||||||
|
serialize/deserialize, `copy_status_table()`. Working tree clean after the
|
||||||
|
branch's three commits.
|
||||||
|
- Verification: batch 377 passed / 2 pre-existing `test_bt_to_date_*`
|
||||||
|
failures; ruff no new violations; smoke + `build_zip.py` +
|
||||||
|
external-zip OK; pyright clean. The isolated
|
||||||
|
`test_heir_relative_anchor.py::test_karen7_frozen_delivery_not_expired`
|
||||||
|
failure is pre-existing test pollution (fails identically on clean HEAD,
|
||||||
|
passes inside the full batch) — not caused by entry 57.
|
||||||
|
- Remaining: manual on-device walkthrough of the QR path (and, if wanted,
|
||||||
|
the audio path — buttons only appear when the `audio_modem` plugin +
|
||||||
|
`amodem` are installed; see the prerequisites below).
|
||||||
|
|
||||||
|
### Animated-QR interop: BBQR only
|
||||||
|
|
||||||
|
- `bal/core/animated_qr.py` implements a stdlib-only codec for **BBQR**
|
||||||
|
(Coinkite `B$…` base32/hex/zlib frames), plus one shared
|
||||||
|
`AnimatedQrSession` with `detect_format` auto-detection and
|
||||||
|
`parse_for_detection` frame identity for the GUI debounce. The previously
|
||||||
|
supported **BAL QR**, **BC-UR v1** and **BC-UR v2** wire formats were
|
||||||
|
removed; only BBQR remains.
|
||||||
|
- **Sessions:** BBQR full-frame decode in any order for Z/2/H encodings,
|
||||||
|
duplicate drops.
|
||||||
|
- **Safety:** `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`,
|
||||||
|
`TransferConflictError` on a frame from a different transfer,
|
||||||
|
`SessionLimitError`, BBQR zlib-bomb guard, UTF-8 payloads only.
|
||||||
|
- **GUI:** `BalQrExportWidget` emits BBQR frames using the QR-size presets;
|
||||||
|
the importer routes every frame through `detect_format` +
|
||||||
|
`AnimatedQrSession.add_part` with the shared
|
||||||
|
`qr_import_accept_frame(state, fmt, session_key, frame_total, index,
|
||||||
|
payload, stable_reads=2)` debounce (reset on session-key change).
|
||||||
|
`_review_and_sign` resolves the session to the transfer text and decodes
|
||||||
|
parts uniformly.
|
||||||
|
- **Verification:** `tests/test_core_animated_qr.py` (BBQR round-trips,
|
||||||
|
encoding fallback, zlib-bomb guard, detection) and the extended
|
||||||
|
`tests/test_gui_qr_transfer.py` pass.
|
||||||
|
|
||||||
|
**Dev-box audio prerequisites (audio_modem channel):**
|
||||||
|
See `AUDIO_MODEM_DEBIAN.md` — the full Debian setup + verification, with the
|
||||||
|
two pitfalls (unversioned `libportaudio.so`, numpy>=2 `tostring` removal):
|
||||||
|
- `sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /usr/lib/x86_64-linux-gnu/libportaudio.so`
|
||||||
|
(the unversioned name the plugin loads; Debian ships only `.so.2`).
|
||||||
|
- numpy>=2 patch in `electrum/env/.../amodem/common.py`: `tostring()` →
|
||||||
|
`tobytes()` (already applied locally). Both are runtime-env fixes, not repo
|
||||||
|
changes; see CHANGELOG entry 56.
|
||||||
|
- To loop-test on one machine without speakers/mic: during receive,
|
||||||
|
`pactl set-default-source <sink>.monitor` (restore after).
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Ho verificato sul codice reale (`electrum/commands.py`, `electrum/plugin.py`,
|
|||||||
- `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`.
|
- `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`.
|
- `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.
|
- `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).
|
- `checkalive.py` — `resolve_date_to_check`, `resolve_relative_check_alive_date`, `ensure_check_alive_valid` (riferimento temporale unico per ogni check; relativo negativo = prima della delivery, positivo = dopo la creazione).
|
||||||
- `util.py` — `Util` (locktime, quantità, confronto tx/heirs, `get_available_utxos`, `fix_will_settings_tx_fees`).
|
- `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):
|
- `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_will` (riga 151), `load_willitems`/`save_willitems` (120/129),
|
||||||
@@ -173,7 +173,7 @@ Metodi principali (replicano le funzioni Qt, senza dialoghi):
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `load_willitems()` | 120 | Costruisce i `WillItem` dal dict `will` del wallet DB. |
|
| `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()`. |
|
| `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`. |
|
| `init_class_variables()` | 618 | `date_to_check`, `no_willexecutor`, `willexecutors`, check `ensure_check_alive_valid`. |
|
||||||
| `check_will()` | 473 | `Will.is_will_valid(...)`; le eccezioni di dominio vengono propagate al comando. |
|
| `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}`. |
|
| `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`. |
|
| `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`. |
|
||||||
|
|||||||
453
PLAN_I18N.md
Normal file
453
PLAN_I18N.md
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
# PLAN_I18N — Italian translation of the BAL Electrum plugin (i18n)
|
||||||
|
|
||||||
|
> **Status:** DRAFT. Every phase still needs the owner's explicit "OK" before any code is written (HANDOFF.md, rule R4).
|
||||||
|
> **Prepared:** 2026-09-25, from a read-only analysis (no code was changed). Owner's decisions recorded on 2026-09-26 (section 6).
|
||||||
|
> **Code base analysed:** `bal-electrum-plugin` `main` @ `4a33116` (2026-09-19, manifest `0.7.0`) and Electrum `4.8.0`.
|
||||||
|
> **Line numbers** refer to that commit. After merging a newer `main`, find the code by content.
|
||||||
|
|
||||||
|
## 0. Read this first
|
||||||
|
|
||||||
|
1. Read `HANDOFF.md` (rules R1–R4, ZIP-FIRST, CREDIT-SAVING) and `AGENTS.md` (venvs, tests). They still apply.
|
||||||
|
2. **One exception to HANDOFF.md.** Section 5 ("Git / delivery workflow") and section 7, step 7, say: work directly on `main`, push to `origin/main`, run `make-release.sh`. **That does NOT apply to this task.** See section 3 (dedicated branch).
|
||||||
|
3. The owner's decisions (D1–D7) are recorded in section 6. For anything not covered there, ask the owner (rule R3).
|
||||||
|
|
||||||
|
## 1. Goal and scope
|
||||||
|
|
||||||
|
**Goal:** when Electrum is set to Italian, the whole BAL interface appears in Italian. The mechanism must allow adding more languages later just by adding one `.po` file.
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
- The Qt GUI of the plugin (`bal/gui/qt/`) and the GUI-free strings it displays (`bal/core/`).
|
||||||
|
- Languages: Italian (`it_IT`) and Spanish (`es_ES`).
|
||||||
|
|
||||||
|
**Out of scope** (owner's decisions, or by design)
|
||||||
|
- **BAL Easy Heirs**: excluded by the owner.
|
||||||
|
- **CLI output** (`bal/cli/`): stays English, exactly like Electrum (`run_electrum`: "the CLI is intentionally always non-localized").
|
||||||
|
- `bal/wallet_util/` (standalone helper scripts), the Android reader (`android/`, which uses Android resources), the manual, the website and the docs.
|
||||||
|
- Other languages: later. The infrastructure will support them.
|
||||||
|
|
||||||
|
## 2. Background (verified)
|
||||||
|
|
||||||
|
### 2.1 How Electrum 4.8.0 does it
|
||||||
|
|
||||||
|
- **gettext with one domain, `electrum`.** Catalogs live in `electrum/locale/locale/<lang>/LC_MESSAGES/electrum.mo` (`electrum/i18n.py`, `LOCALE_DIR`). Every string goes through `electrum.i18n._()`.
|
||||||
|
- **The language is set once, at startup, for the GUI only** (`run_electrum` L433–451):
|
||||||
|
- it uses `config.LOCALIZATION_LANGUAGE` (config key `language`);
|
||||||
|
- if that is empty, it uses `electrum.gui.default_lang.get_default_language(gui_name)`, i.e. `QLocale.system().name()`, **but only if** that value is in `electrum.i18n.languages`; otherwise `en_UK`;
|
||||||
|
- `en_*` means no translation (the English source strings are shown);
|
||||||
|
- changing the language requires a restart.
|
||||||
|
- **Strings evaluated too early stay English.** Anything evaluated before `set_language()` (for example at import time) is not translated.
|
||||||
|
- **Safety check inside `_()`.** The decorator `_ensure_translation_keeps_format_string_syntax_similar` rejects a translation whose `{}` fields differ from the source. `_("")` returns `""`. There is an optional `context=` argument (pgettext).
|
||||||
|
- **Coding rules** (comments in `i18n.py`): no f-strings inside `_()`, no `%` formatting; write `_("… {}").format(x)`. There are no plural forms (`ngettext` is not used anywhere).
|
||||||
|
- **Pipeline:**
|
||||||
|
1. `contrib/locale/push_locale.py` runs xgettext over `electrum/**/*.py`. This includes *internal* plugins.
|
||||||
|
2. The strings go to Crowdin, where volunteers translate them.
|
||||||
|
3. The `.po` files are stored in the `electrum-locale` repo.
|
||||||
|
4. `contrib/locale/build_locale.sh` compiles them with msgfmt into `.mo` files and writes `stats.json`.
|
||||||
|
- **Translation security:**
|
||||||
|
- `electrum-locale/update.py` refuses any translation whose `msgstr` contains a Bitcoin address, an e-mail address, a URL, or a long word mixing letters and digits;
|
||||||
|
- `llm_proofreader/` checks every translation PR for vandalism in CI.
|
||||||
|
- **External plugins:**
|
||||||
|
- they are always ZIP files, imported with `zipimport` as `electrum_external_plugins.<name>`;
|
||||||
|
- `BasePlugin.read_file(filename) -> bytes` reads a file from inside the ZIP, or from the internal plugin folder;
|
||||||
|
- **Electrum provides nothing for translating external plugins.** Their strings are not in `electrum.mo`.
|
||||||
|
- `i18n.py`, `gui/default_lang.py` and `read_file()` are identical in 4.7.2, 4.8.0 and 4.8.2.
|
||||||
|
- **Swiss locales** (`it_CH`, `de_CH`, `fr_CH`) are not in Electrum's list, so Electrum starts in English until the user picks a language. BAL must simply follow Electrum and not detect the language itself. Otherwise the UI would be half in one language and half in another.
|
||||||
|
|
||||||
|
### 2.2 BAL today (`main` @ `4a33116`)
|
||||||
|
|
||||||
|
- Source strings are in English. Most are wrapped in `_`, imported from `electrum.i18n` in these places:
|
||||||
|
- `gui/qt/common.py:55`, which re-exports it to every `gui/qt` module;
|
||||||
|
- `gui/qt/theme.py:90,108`;
|
||||||
|
- `core/will.py:32` and `core/willexecutors.py:25`;
|
||||||
|
- `cli/controller.py:29`.
|
||||||
|
- The package has 483 `_()` calls in 11 files. xgettext finds 365 distinct strings (≈ 2,230 words).
|
||||||
|
- **With Electrum set to Italian, only 39 of the 365 strings (≈ 11 %) appear in Italian.** They are the generic words that Electrum already translates (Cancel, Save, Address…). Everything specific to BAL stays in English.
|
||||||
|
- **Issues to fix before translating** (exact locations are in the Appendix):
|
||||||
|
- 14 f-strings or `.format()` calls inside `_()`: these strings can never be translated (Ruff INT001/INT002);
|
||||||
|
- 15 `_()` calls with a non-literal argument: extraction tools cannot find the text;
|
||||||
|
- 14 concatenations around `_()`: the word order breaks in other languages;
|
||||||
|
- 18 `_()` calls evaluated at import time (in class bodies): they would stay English (see 4.2);
|
||||||
|
- 2 × `_("")`: plain gettext returns the catalog header instead of an empty string;
|
||||||
|
- 12 settings help texts (`HelpButton("…")`) and the help texts of 5 `add_widget()` calls are never translated (≈ 500 words in total);
|
||||||
|
- other text shown to the user without `_()`.
|
||||||
|
- **Some stored data depends on the UI language.**
|
||||||
|
- `WillItem.set_status()` (`core/will.py:1344`) appends the *translated* status label to `WillItem.status`.
|
||||||
|
- That field is saved via `to_dict()` in the wallet DB (`wallet.db.get_dict("will")`, `gui/qt/window.py:232`), in exported files and in QR transfers.
|
||||||
|
- Real example from `tests/samanta7`: `"New.Firmato.Pushed.Checked.Replaced"`. Electrum's Italian catalog already translates *Signed*, *Error* and *Expired*, so the saved history mixes languages today.
|
||||||
|
- **Default texts in `core/plugin_base.py` exist only in English:**
|
||||||
|
- `HISTORY_LABEL` (around L246, token `{willexecutor}`);
|
||||||
|
- `EVENT_DESCRIPTION` and `EVENT_SUMMARY` (around L331–337, tokens `$wallet_name` and `$heirs_complete`).
|
||||||
|
|
||||||
|
## 3. Branch and workflow for this task
|
||||||
|
|
||||||
|
The owner decided, on Truman's suggestion, to do this work on a **separate branch on Gitea**, not on `main`.
|
||||||
|
|
||||||
|
- **Branch:** `feature/i18n` (proposed name, see D6), created from `main`.
|
||||||
|
- **Never** commit or push to `main` during this task.
|
||||||
|
- **Never** run `make-release.sh` from the branch: it tags and publishes a Gitea release. Build test ZIPs with `python3 build_zip.py` only.
|
||||||
|
- **At the start of every phase:**
|
||||||
|
1. merge `origin/main` into `feature/i18n`;
|
||||||
|
2. resolve any conflicts;
|
||||||
|
3. run the tests again.
|
||||||
|
- **Where conflicts are likely:**
|
||||||
|
- `gui/qt/dialogs.py`, `window.py`, `lists.py`, `plugin.py`, `widgets.py` and `core/will.py`: all were changed on `main` on 2026-09-04, 09-14 and 09-19, and commit `fb88d75` alone changed ≈ 1,800 lines of `dialogs.py`;
|
||||||
|
- `CHANGELOG.md`: both sides append at the end, so keep both entries and renumber.
|
||||||
|
- **ZIP-FIRST for every phase:**
|
||||||
|
1. build the ZIP;
|
||||||
|
2. the owner (and Truman) test it;
|
||||||
|
3. only after the owner's explicit OK, commit on `feature/i18n` and push to `origin/feature/i18n` (who does this: see D3).
|
||||||
|
- Add one numbered `CHANGELOG.md` entry per phase, in English.
|
||||||
|
- Run ruff and the tests before every ZIP (HANDOFF.md). The known pre-existing test failures are listed in HANDOFF.md, section 7, step 3.
|
||||||
|
- **At the end:** open a Pull Request `feature/i18n` → `main` on Gitea (there is a precedent: `feature/bal-qr-transfer`). Release from `main` only after the merge.
|
||||||
|
|
||||||
|
## 4. Design (decided)
|
||||||
|
|
||||||
|
**Chosen:** a gettext catalog owned by BAL, domain `bal`, shipped inside the plugin ZIP, that follows Electrum's language and is consulted **after** Electrum's own catalog (D7).
|
||||||
|
|
||||||
|
**Rejected:**
|
||||||
|
- keep using `electrum.i18n._`: 89 % of the text stays English;
|
||||||
|
- Python dictionaries or JSON files: a home-made format with no tooling;
|
||||||
|
- Qt Linguist (`.ts` / `.qm`): Electrum desktop does not use it, and `core/` has no Qt.
|
||||||
|
|
||||||
|
### 4.1 New module `bal/i18n.py`
|
||||||
|
|
||||||
|
| Function | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `_(msg)` | 1. `""` returns `""`. 2. **Electrum first (D7):** if `electrum.i18n._(msg)` differs from `msg`, return it (Electrum has already applied its own `{}` check). 3. Otherwise, if a BAL catalog is loaded and translates `msg`, apply the `{}` safety check and return the translation. The check uses the same logic as Electrum's decorator: **copy it** with an attribution comment (MIT); do not import Electrum's private function. 4. Otherwise return `msg` (English). The CLI and English behave exactly as in Electrum, because no BAL catalog is loaded there. |
|
||||||
|
| `N_(msg)` | Returns `msg` unchanged. It marks strings defined in tables, constants and class attributes, which are translated later with `_()` when displayed. Babel extracts `N_` by default. |
|
||||||
|
| `set_language(plugin, lang)` | `None`, `""` or `en_*`: no catalog. Otherwise read `locale/<lang>/LC_MESSAGES/bal.mo` with `plugin.read_file()`, falling back to `locale/<lang[:2]>/…`, and build `gettext.GNUTranslations(io.BytesIO(data))`. If the catalog is missing or broken, log at info level and continue in English. Never raise. |
|
||||||
|
| `init_from_config(plugin, config)` | `lang = config.LOCALIZATION_LANGUAGE`. If it is empty, use `electrum.gui.default_lang.get_default_language(gui_name="qt")`, guarding against `ImportError` as `run_electrum` does. Then call `set_language()`. |
|
||||||
|
|
||||||
|
- **Why Electrum first (D7):** BAL follows Electrum's wording wherever Electrum has it. If Electrum ever ships BAL as an internal plugin, Electrum's own translations (Crowdin) would be used automatically and the BAL catalog would only fill the gaps.
|
||||||
|
- **Trade-off of D7:** for a text that Electrum already translates, the BAL catalog cannot override Electrum's wording, not even with the D1 glossary. If one such case reads badly in BAL, handle that single case with the owner.
|
||||||
|
- A text that Electrum "translates" into the identical English text counts as not found and falls through to the BAL catalog. This is harmless: the BAL catalog is prefilled with Electrum's translations (3.3).
|
||||||
|
- **Do not use `gettext.translation(localedir=…)`.** When pointed inside a ZIP it silently returns `NullTranslations` (verified).
|
||||||
|
- `plugin.read_file()` is the same API BAL already uses for its icons. It works both for the ZIP install and for the dev symlink install (`AGENTS.md`).
|
||||||
|
- The state is module-level and set once. Changing the language requires restarting Electrum, as in Electrum itself.
|
||||||
|
|
||||||
|
### 4.2 Where the language is set
|
||||||
|
|
||||||
|
- In `bal/gui/qt/plugin.py`, `Plugin.__init__` (L67): call `init_from_config(self, config)` first.
|
||||||
|
- The CLI entry point (`bal/cli/plugin.py`) does not call it, so the CLI stays English.
|
||||||
|
- **Consequence:** `gui/qt/plugin.py` imports `dialogs`, `widgets` and `window` at module level (L49–51), and those import `lists`. So every class body is evaluated *before* `Plugin.__init__` runs, and any `_()` evaluated at import time would stay English. The cases in Appendix D must be converted in Phase 2.
|
||||||
|
|
||||||
|
### 4.3 Files and tools
|
||||||
|
|
||||||
|
```
|
||||||
|
babel.cfg # extraction mapping (repo root)
|
||||||
|
bal/locale/bal.pot # template, generated
|
||||||
|
bal/locale/it_IT/LC_MESSAGES/bal.po # Italian translations (source of truth)
|
||||||
|
bal/locale/it_IT/LC_MESSAGES/bal.mo # compiled (see D4)
|
||||||
|
```
|
||||||
|
|
||||||
|
The tool is **Babel** (`pip install babel`). It is pure Python and works on Windows without GNU gettext. These commands were tested on this code base:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# babel.cfg
|
||||||
|
[ignore: bal/cli/**]
|
||||||
|
[ignore: bal/wallet_util/**]
|
||||||
|
[python: bal/**.py]
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p bal/locale # extract does not create it
|
||||||
|
pybabel extract -F babel.cfg --no-wrap -o bal/locale/bal.pot .
|
||||||
|
pybabel init -i bal/locale/bal.pot -d bal/locale -D bal -l it_IT # once
|
||||||
|
pybabel update -i bal/locale/bal.pot -d bal/locale -D bal --no-wrap # after each extract
|
||||||
|
pybabel compile -d bal/locale -D bal --statistics
|
||||||
|
```
|
||||||
|
|
||||||
|
- Today this extracts 323 strings (≈ 1,900 words) from the GUI and core.
|
||||||
|
- After Phase 2, expect about 360 strings (≈ 2,500 words).
|
||||||
|
- Poedit (free) can be used to translate or review the `.po` file by hand.
|
||||||
|
|
||||||
|
### 4.4 Translation safety (as in Electrum)
|
||||||
|
|
||||||
|
- Add a check script (for example `scripts/check_translations.py`) and run it before every ZIP. It must check that:
|
||||||
|
- every `.po` file compiles;
|
||||||
|
- the `{}` fields are identical to the source (the same rule as the runtime check);
|
||||||
|
- the `$tokens` are identical (`$wallet_name`, `$heirs_complete`, …): Electrum does not check these;
|
||||||
|
- no `msgstr` contains a Bitcoin address, an e-mail address, a URL or a long letter-and-digit word (use the regexes from `electrum-locale/update.py`), unless the same text is in the `msgid`.
|
||||||
|
|
||||||
|
It must also print the counts of translated, untranslated and fuzzy strings.
|
||||||
|
- Never put addresses or URLs inside translatable strings; pass them with `{}`.
|
||||||
|
- The owner reviews every Italian string, especially the warnings about invalidation, locktime, fees and signing.
|
||||||
|
|
||||||
|
## 5. Phases
|
||||||
|
|
||||||
|
Each phase follows HANDOFF.md: DISCOVER → PLAN (owner's "OK") → EXECUTE → VERIFY → ZIP → test by the owner and Truman → commit on `feature/i18n`.
|
||||||
|
Phases 1 and 2 change nothing visible, so they may be delivered as a single ZIP/test cycle (see D5).
|
||||||
|
|
||||||
|
### Phase 1 — Infrastructure (no visible change)
|
||||||
|
|
||||||
|
- NEW `bal/i18n.py` (section 4.1), with docstrings explaining *why* (rule R2).
|
||||||
|
- `bal/gui/qt/common.py:55`: replace `from electrum.i18n import _` with an import of `_` and `N_` from `bal.i18n`. Use a relative import, zip-safe like the existing `from ...core…` imports.
|
||||||
|
- `bal/gui/qt/theme.py:90,108`: change the local imports to `bal.i18n`.
|
||||||
|
- `bal/core/will.py:32` and `bal/core/willexecutors.py:25`: import from `bal.i18n`.
|
||||||
|
- `bal/cli/controller.py:29`: **unchanged** (the CLI stays English by design).
|
||||||
|
- `bal/gui/qt/plugin.py`, `Plugin.__init__`: call `init_from_config(self, config)`.
|
||||||
|
- NEW `tests/test_i18n.py`, covering:
|
||||||
|
- the empty string;
|
||||||
|
- Electrum's translation wins; the BAL catalog is used only when Electrum has none (D7);
|
||||||
|
- rejection of a `{}` mismatch;
|
||||||
|
- `en_UK` means no catalog;
|
||||||
|
- a missing `.mo` does not crash;
|
||||||
|
- a catalog loaded from bytes (use a small test catalog).
|
||||||
|
- **Verify:**
|
||||||
|
- the existing tests (baseline as in HANDOFF.md, section 7);
|
||||||
|
- `tests/smoke_test.py`;
|
||||||
|
- `build_zip.py` followed by `tests/external_zip_test.py`;
|
||||||
|
- manually on Windows 11 with Electrum 4.8.0, once in Italian and once in English: the UI must look exactly as before.
|
||||||
|
|
||||||
|
### Phase 2 — String clean-up and language-neutral stored data (no visible change)
|
||||||
|
|
||||||
|
- **2.0** Merge `origin/main` first.
|
||||||
|
- **2.1** Appendix A: turn `_(f"…")` and `_("…".format(x))` into `_("… {}").format(x)`. Include `cli/controller.py`, for lint consistency only.
|
||||||
|
- **2.2** Appendix C: turn each concatenation into one string with `{}`.
|
||||||
|
- Keep HTML outside the strings where possible, e.g. `"<b>{}</b>".format(_("Support:"))`.
|
||||||
|
- Fix the malformed `_("<b>Willexecutor:</b:")` (`widgets.py:1366`).
|
||||||
|
- **2.3** Appendix B: mark the literal with `N_()` where it is defined, or translate it at the call site.
|
||||||
|
- The helpers `add_widget()` (`common.py:180`), `get_window_title()` (`plugin.py:1085`) and `qlabel()` (`widgets.py:1319`) need ONE rule: callers pass text that is already translated, and the helper does not call `_()` again.
|
||||||
|
- Today `window.py:290` translates twice.
|
||||||
|
- **2.4** Appendix D: replace class-level `_()` with `N_()` and translate when the text is displayed.
|
||||||
|
- In `lists.py`, `update_headers(self.__class__.headers)` (L248, 647, 1094) must receive a translated copy.
|
||||||
|
- For the wizard `title` / `message` (`dialogs.py` L380, 419–420, 527–528, 547–548), use `_(self.title)` and `_(self.message)` at L325 and L327.
|
||||||
|
- **2.5** Appendix E: replace `_("")` with `""`.
|
||||||
|
- **2.6** Appendix F and G: make the settings help texts and labels in `plugin.py` translatable.
|
||||||
|
- **2.7** Appendix H, plus a full sweep for any other text shown to the user.
|
||||||
|
- **2.8** **Stored status** (`core/will.py:1344`):
|
||||||
|
- store the **English** label (mark the `STATUS_DEFAULT` labels with `N_()`); no `_()` at write time;
|
||||||
|
- add a helper `format_status_history(status) -> str` that splits on `.`, translates the tokens that are known English labels (with an optional `NOT ` prefix), and leaves unknown or legacy tokens unchanged (`New`, `Firmato`, …);
|
||||||
|
- use it wherever the status is shown (`lists.py:589`, `widgets.py:1336`);
|
||||||
|
- this stays backward compatible: existing wallets keep displaying correctly, and older BAL versions still read plain English;
|
||||||
|
- test it with the real legacy strings in `tests/samanta7`.
|
||||||
|
- **2.9** **Default texts** (`core/plugin_base.py`: `HISTORY_LABEL`, `EVENT_DESCRIPTION`, `EVENT_SUMMARY`):
|
||||||
|
- mark the English defaults with `N_()`;
|
||||||
|
- when the stored value is missing **or equal to the English default**, use the translated default at the moment the text is used (calendar export, history label);
|
||||||
|
- the tokens must survive translation.
|
||||||
|
- **Changed during Phase 2 (safety):** `HISTORY_LABEL` stays English and is *not* translatable. `Util._label_matches_history()` uses it to recognise BAL's own local transactions (stale-history cleanup, spendable UTXOs), so a label that changed with the GUI language would no longer match the transactions saved before. Only `EVENT_SUMMARY` and `EVENT_DESCRIPTION` follow the language (`BalConfig(..., translatable=True)`). The same rule applies to every stored or compared text (wallet labels, `heirs.TRANSACTION_LABEL`, the status history).
|
||||||
|
- **2.10** `pyproject.toml`: add `"INT"` to `[tool.ruff.lint] select`.
|
||||||
|
- Ruff's hint suggests `%` formatting: **do not follow it**. Use `.format()`, which is Electrum's rule.
|
||||||
|
- `make-release.sh` treats Ruff errors as blocking, so INT must end up clean.
|
||||||
|
- **Verify:**
|
||||||
|
- Ruff INT is clean;
|
||||||
|
- the extraction count is about 360 (compare with 4.3);
|
||||||
|
- the tests pass, plus new tests for 2.8 and 2.9;
|
||||||
|
- manually in English: no visible text changed.
|
||||||
|
|
||||||
|
### Phase 3 — Italian catalog (visible change)
|
||||||
|
|
||||||
|
- **3.0** Merge `origin/main` first.
|
||||||
|
- **3.1** Add `babel.cfg` and the commands from 4.3, documented in `README.md` or `AGENTS.md`.
|
||||||
|
- **3.2** Generate `bal/locale/bal.pot` and create `it_IT/LC_MESSAGES/bal.po`.
|
||||||
|
- **3.3** Draft the translation using the glossary (D1); the owner reviews it (D2). Where Electrum's `it_IT` catalog already translates the exact same English text, copy Electrum's translation, so the two UIs match.
|
||||||
|
- **3.4** `build_zip.py`:
|
||||||
|
- compile `.po` into `.mo`, according to D4;
|
||||||
|
- put only the `.mo` files in the ZIP (no `.po` / `.pot`);
|
||||||
|
- run the check script (4.4) before zipping.
|
||||||
|
- **Verify:**
|
||||||
|
- Electrum in Italian shows BAL in Italian;
|
||||||
|
- Electrum in English shows BAL in English;
|
||||||
|
- missing entries fall back to English;
|
||||||
|
- `external_zip_test.py` passes;
|
||||||
|
- it works on Windows 11 with Electrum 4.8.0 (and 4.7.2);
|
||||||
|
- manual walkthrough of: wizard, settings, heirs list, will list and detail, will-executors, check / sign / broadcast, invalidate, export / import, QR transfer, calendar export.
|
||||||
|
|
||||||
|
### Phase 4 — Final test and merge
|
||||||
|
|
||||||
|
- The owner and Truman test the final branch ZIP.
|
||||||
|
- Merge `origin/main` into the branch one last time and test again.
|
||||||
|
- Open the Pull Request `feature/i18n` → `main` on Gitea. After the merge, follow the normal release flow from `main`.
|
||||||
|
|
||||||
|
## 6. Decisions
|
||||||
|
|
||||||
|
Recorded on 2026-09-26 from the owner's answers.
|
||||||
|
|
||||||
|
- **D1 — Italian glossary.** Rule: follow Electrum's `it_IT` wording wherever Electrum already has the term or the sentence. Grammatical variants (nouns, past participles, plurals, capitals in headers and buttons) are adapted in the draft and checked by the owner during the review (D2).
|
||||||
|
|
||||||
|
| English | Italian |
|
||||||
|
|---|---|
|
||||||
|
| will | testamento |
|
||||||
|
| heir / heirs | erede / eredi |
|
||||||
|
| will-executor | Will-Executor (kept in English) |
|
||||||
|
| check-alive | verifica se sei vivo |
|
||||||
|
| invalidate | invalida il piano |
|
||||||
|
| invalidation (noun) | invalidazione del piano |
|
||||||
|
| anticipate / postpone | anticipa / posticipa |
|
||||||
|
| locktime | locktime (kept in English; owner, 2026-09-26) |
|
||||||
|
| fee | commissione (as Electrum) |
|
||||||
|
| broadcast | trasmetti il piano |
|
||||||
|
| sign / signed | firma / firmato (as Electrum) |
|
||||||
|
| wallet | portafogli (as Electrum) |
|
||||||
|
|
||||||
|
- **D2 — Who translates.** The developer drafts `it_IT/bal.po`; the owner reviews and corrects it (for example in Poedit). For later languages: Weblate (supports Gitea and `.po`) or Crowdin, as Electrum does.
|
||||||
|
- **D3 — Branch and commits.** The developer creates `feature/i18n`, and commits and pushes on it, each time only after the owner's explicit OK (ZIP-FIRST).
|
||||||
|
- **D4 — `.mo` files.** `build_zip.py` compiles `.po` into `.mo` at build time with Babel; `.mo` files stay out of git (add `*.mo` to `.gitignore`). If Babel is missing, the build stops with a clear error, so an English-only ZIP is never shipped by mistake. Reasons: the `.po` is the only source (no stale `.mo`), every change to a translation is readable in review, and it is how Electrum works.
|
||||||
|
- **D5 — Cycles.** Phases 1 and 2 are delivered in one ZIP/test cycle. Phase 1 is not merged into `main` early.
|
||||||
|
- **D6 — Branch name:** `feature/i18n`.
|
||||||
|
- **D7 — Lookup order.** Electrum's catalog first, then BAL's, then English (see 4.1). Proposed by the owner so that BAL and Electrum read as one, also if Electrum ever integrates BAL.
|
||||||
|
|
||||||
|
## 7. Reminders for the developer
|
||||||
|
|
||||||
|
- Talk to the owner in **Italian**, with simple words and no jargon (the owner is not a programmer). Code, comments, docstrings, UI source strings, docs and commits are in **English**.
|
||||||
|
- UI source strings stay in English; Italian lives only in the `.po` file.
|
||||||
|
- Never invent. When in doubt, stop and ask.
|
||||||
|
- Before writing any code, present the phase PLAN and wait for "OK".
|
||||||
|
- ZIP-FIRST; run ruff and the tests before every ZIP; one CHANGELOG entry per phase; keep reports brief (CREDIT-SAVING).
|
||||||
|
- Wallet data compatibility comes first: apart from 2.8 (status labels), do not change what is stored.
|
||||||
|
|
||||||
|
## Appendix — Inventory at `main` @ `4a33116`
|
||||||
|
|
||||||
|
Generated by a static scan (AST, Ruff `INT` and xgettext). The line numbers will move after merging a newer `main`: re-run the scan or search by content. These lists cover the known cases; Phase 2 still needs a full sweep.
|
||||||
|
|
||||||
|
#### A. f-string / `.format()` inside `_()` — Ruff INT001/INT002 (14)
|
||||||
|
|
||||||
|
| Location | Rule |
|
||||||
|
|---|---|
|
||||||
|
| `bal/cli/controller.py:92` | INT001 |
|
||||||
|
| `bal/cli/controller.py:98` | INT001 |
|
||||||
|
| `bal/cli/controller.py:104` | INT001 |
|
||||||
|
| `bal/gui/qt/common.py:225` | INT002 |
|
||||||
|
| `bal/gui/qt/common.py:240` | INT002 |
|
||||||
|
| `bal/gui/qt/dialogs.py:1226` | INT001 |
|
||||||
|
| `bal/gui/qt/dialogs.py:2491` | INT001 |
|
||||||
|
| `bal/gui/qt/window.py:831` | INT001 |
|
||||||
|
| `bal/gui/qt/window.py:836` | INT001 |
|
||||||
|
| `bal/gui/qt/window.py:1090` | INT001 |
|
||||||
|
| `bal/gui/qt/window.py:1091` | INT001 |
|
||||||
|
| `bal/gui/qt/window.py:2228` | INT001 |
|
||||||
|
| `bal/gui/qt/window.py:2236` | INT001 |
|
||||||
|
| `bal/gui/qt/window.py:2246` | INT001 |
|
||||||
|
|
||||||
|
#### B. `_()` with a non-literal argument (not extractable) (15)
|
||||||
|
|
||||||
|
| Location | Code |
|
||||||
|
|---|---|
|
||||||
|
| `bal/core/will.py:1344` | `_(self.STATUS[status][0])` |
|
||||||
|
| `bal/gui/qt/common.py:181` | `_(label)` |
|
||||||
|
| `bal/gui/qt/dialogs.py:327` | `_(self.message)` |
|
||||||
|
| `bal/gui/qt/dialogs.py:2288` | `_(msg)` |
|
||||||
|
| `bal/gui/qt/dialogs.py:2291` | `_(msg)` |
|
||||||
|
| `bal/gui/qt/lists.py:1240` | `_(label)` |
|
||||||
|
| `bal/gui/qt/plugin.py:1086` | `_(title)` |
|
||||||
|
| `bal/gui/qt/widgets.py:284` | `_(self.tooltip_text)` |
|
||||||
|
| `bal/gui/qt/widgets.py:1320` | `_(str(title))` |
|
||||||
|
| `bal/gui/qt/window.py:290` | `_(title)` |
|
||||||
|
| `bal/gui/qt/window.py:942` | `_(message)` |
|
||||||
|
| `bal/gui/qt/window.py:2217` | `_(self.DOWNLOAD_FAILED_TOR_MESSAGE)` |
|
||||||
|
| `bal/gui/qt/window.py:2219` | `_(self.DOWNLOAD_FAILED_MESSAGE)` |
|
||||||
|
| `bal/gui/qt/window.py:2253` | `_(self.DOWNLOAD_FAILED_TOR_MESSAGE)` |
|
||||||
|
| `bal/gui/qt/window.py:2255` | `_(self.DOWNLOAD_FAILED_MESSAGE)` |
|
||||||
|
|
||||||
|
#### C. Concatenations around `_()` (14)
|
||||||
|
|
||||||
|
| Location | Expression |
|
||||||
|
|---|---|
|
||||||
|
| `bal/core/will.py:1344` | `'.' + (('NOT ' if not value else '') + _(self.STATUS[status][0]))` |
|
||||||
|
| `bal/gui/qt/dialogs.py:1010` | `_('Wallet balance is too low: {} satoshi available, but the miner and…` |
|
||||||
|
| `bal/gui/qt/dialogs.py:2206` | `messages[reason] + '\n\n' + _('Skipped')` |
|
||||||
|
| `bal/gui/qt/dialogs.py:2209` | `_("Could not build the will, and the exact cause could not be determi…` |
|
||||||
|
| `bal/gui/qt/dialogs.py:2419` | `_('Expiration date: ') + str(BalTimestamp(self.threshold))` |
|
||||||
|
| `bal/gui/qt/dialogs.py:2425` | `_('Valid Txs:') + str(len(Will.only_valid_list(self.will)))` |
|
||||||
|
| `bal/gui/qt/dialogs.py:2427` | `_('Total Txs:') + str(len(self.will))` |
|
||||||
|
| `bal/gui/qt/lists.py:679` | `' ' + _('Build Your Will')` |
|
||||||
|
| `bal/gui/qt/plugin.py:301` | `'Bal ' + _('Bitcoin After Life')` |
|
||||||
|
| `bal/gui/qt/plugin.py:1034` | `'<b>' + _('Support:') + '</b>'` |
|
||||||
|
| `bal/gui/qt/plugin.py:1086` | `_('BAL - ') + _(title)` |
|
||||||
|
| `bal/gui/qt/widgets.py:1320` | `'<b>' + _(str(title)) + f':</b>\t{str(value)}'` |
|
||||||
|
| `bal/gui/qt/window.py:1022` | `_('Electrum was unable to deserialize the transaction:') + '\n' + str…` |
|
||||||
|
| `bal/gui/qt/window.py:1091` | `msg + _(f'signing: {tosign}')` |
|
||||||
|
|
||||||
|
#### D. `_()` evaluated at import time (class body) (18)
|
||||||
|
|
||||||
|
| Location | Scope | Call |
|
||||||
|
|---|---|---|
|
||||||
|
| `bal/gui/qt/dialogs.py:419` | class body | `_('Bitcoin After Life Will-Executors')` |
|
||||||
|
| `bal/gui/qt/dialogs.py:420` | class body | `_('Choose willexecutors download method')` |
|
||||||
|
| `bal/gui/qt/dialogs.py:528` | class body | `_('Configure and select your willexecutors')` |
|
||||||
|
| `bal/gui/qt/dialogs.py:548` | class body | `_('')` |
|
||||||
|
| `bal/gui/qt/lists.py:131` | class body | `_('Name')` |
|
||||||
|
| `bal/gui/qt/lists.py:132` | class body | `_('Address')` |
|
||||||
|
| `bal/gui/qt/lists.py:133` | class body | `_('Amount')` |
|
||||||
|
| `bal/gui/qt/lists.py:340` | class body | `_('Locktime')` |
|
||||||
|
| `bal/gui/qt/lists.py:341` | class body | `_('Txid')` |
|
||||||
|
| `bal/gui/qt/lists.py:342` | class body | `_('Will-Executor')` |
|
||||||
|
| `bal/gui/qt/lists.py:343` | class body | `_('Status')` |
|
||||||
|
| `bal/gui/qt/lists.py:344` | class body | `_('Server')` |
|
||||||
|
| `bal/gui/qt/lists.py:907` | class body | `_('')` |
|
||||||
|
| `bal/gui/qt/lists.py:908` | class body | `_('Url')` |
|
||||||
|
| `bal/gui/qt/lists.py:909` | class body | `_('S')` |
|
||||||
|
| `bal/gui/qt/lists.py:910` | class body | `_('Base fee')` |
|
||||||
|
| `bal/gui/qt/lists.py:911` | class body | `_('Info')` |
|
||||||
|
| `bal/gui/qt/lists.py:912` | class body | `_('Default Address')` |
|
||||||
|
|
||||||
|
#### E. `_("")` (2)
|
||||||
|
|
||||||
|
| Location | Call |
|
||||||
|
|---|---|
|
||||||
|
| `bal/gui/qt/dialogs.py:548` | `_('')` |
|
||||||
|
| `bal/gui/qt/lists.py:907` | `_('')` |
|
||||||
|
|
||||||
|
#### F. Settings help texts never passed to `_()` (12)
|
||||||
|
|
||||||
|
| Location | Widget | Text |
|
||||||
|
|---|---|---|
|
||||||
|
| `bal/gui/qt/plugin.py:715` | HelpButton | When checking, automatically sign and broadcast the will transactions… |
|
||||||
|
| `bal/gui/qt/plugin.py:778` | HelpButton | `How many reminder alarms the exported calendar (.ics) event contains.…` |
|
||||||
|
| `bal/gui/qt/plugin.py:789` | HelpButton | `Default message to be used in event summary\nVariables:\n $wallet_na…` |
|
||||||
|
| `bal/gui/qt/plugin.py:803` | HelpButton | `Default message to be used in event description\nVariables:\n $walle…` |
|
||||||
|
| `bal/gui/qt/plugin.py:818` | HelpButton | URL of the server that provides the will-executor list. Only availab… |
|
||||||
|
| `bal/gui/qt/plugin.py:829` | HelpButton | `Command used to open .ics calendar files.\nLeave empty to use the sys…` |
|
||||||
|
| `bal/gui/qt/plugin.py:844` | HelpButton | `After each check, save the valid will transactions into the wallet's …` |
|
||||||
|
| `bal/gui/qt/plugin.py:858` | HelpButton | `Label applied to the will transactions saved into the wallet's local …` |
|
||||||
|
| `bal/gui/qt/plugin.py:880` | HelpButton | Broadcast all transactions to willexecutors including those already p… |
|
||||||
|
| `bal/gui/qt/plugin.py:891` | HelpButton | `Run the 'Build your will' wizard every time the wallet is closed or E…` |
|
||||||
|
| `bal/gui/qt/plugin.py:909` | HelpButton | When a new transaction arrives for the wallet, automatically rebuild … |
|
||||||
|
| `bal/gui/qt/plugin.py:933` | HelpButton | `Payload size of a single QR code when exporting a will via QR.\n\nLar…` |
|
||||||
|
|
||||||
|
#### G. `add_widget()` calls (label translated at runtime, help text never) (5)
|
||||||
|
|
||||||
|
| Location | Label | Help text |
|
||||||
|
|---|---|---|
|
||||||
|
| `bal/gui/qt/plugin.py:697` | Hide Replaced | Hide replaced transactions from will detail and l… |
|
||||||
|
| `bal/gui/qt/plugin.py:705` | Hide Invalidated | Hide invalidated transactions from will detail an… |
|
||||||
|
| `bal/gui/qt/plugin.py:727` | Panel editable Date and Fee | When enabled, the delivery-time and check-alive d… |
|
||||||
|
| `bal/gui/qt/plugin.py:742` | `Max Will-Executor Fee (satoshi)` | `Maximum fee (in satoshi) allowed to be paid to a …` |
|
||||||
|
| `bal/gui/qt/plugin.py:757` | User Type | `Choose how much detail the plugin shows.\n\nBASIC…` |
|
||||||
|
|
||||||
|
#### H. Other user-visible text not (correctly) translatable — known cases, not exhaustive
|
||||||
|
|
||||||
|
| Location | Code | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `bal/gui/qt/dialogs.py:380` | `title = "Bitcoin After Life Heirs"` | Wizard title, shown untranslated at `dialogs.py:325` |
|
||||||
|
| `bal/gui/qt/dialogs.py:527` | `title = "Bitcoin After Life Will-Executors"` | Same |
|
||||||
|
| `bal/gui/qt/dialogs.py:547` | `title = "Bitcoin After Life Will Settings"` | Same |
|
||||||
|
| `bal/gui/qt/lists.py:1472` | `setText("New Will Executor")` | Not wrapped |
|
||||||
|
| `bal/gui/qt/plugin.py:673` | `QPushButton("Rebroadcast transactions")` | Not wrapped |
|
||||||
|
| `bal/gui/qt/widgets.py:1323` | `qlabel("Locktime", …)` | Translated inside `qlabel()` but not extractable |
|
||||||
|
| `bal/gui/qt/widgets.py:1324` | `qlabel("Creation Time", …)` | Same |
|
||||||
|
| `bal/gui/qt/widgets.py:1334` | `qlabel("Transaction fees:", …)` | Same; `qlabel()` adds another `:` |
|
||||||
|
| `bal/gui/qt/widgets.py:1336` | `qlabel("Status:", …)` | Same; also shows the stored status (see 2.8) |
|
||||||
|
| `bal/gui/qt/widgets.py:1339` | `QLabel("<b>Heirs:</b>")` | Not wrapped |
|
||||||
|
| `bal/gui/qt/widgets.py:1366` | `_("<b>Willexecutor:</b:")` | Malformed HTML inside the string |
|
||||||
|
| `bal/gui/qt/window.py:1613` | `msg = "Broadcasting Transactions to Will-Executors:\n"` | Not wrapped |
|
||||||
|
|
||||||
|
Not to translate: `widgets.py:122` (icon glyph), `plugin.py:1020` (link to bitcoin-after.life).
|
||||||
|
|
||||||
|
#### Numbers (`main` @ `4a33116`)
|
||||||
|
|
||||||
|
| Measure | Value |
|
||||||
|
|---|---|
|
||||||
|
| `_()` calls | 483 in 11 files |
|
||||||
|
| Distinct strings, whole package (xgettext) | 365 (≈ 2,230 words) |
|
||||||
|
| …of which Electrum `it_IT` already translates | 39 (≈ 11 %) |
|
||||||
|
| Strings used only by the CLI | 36 (≈ 315 words), out of scope |
|
||||||
|
| Babel extraction today, GUI + core | 323 (≈ 1,900 words) |
|
||||||
|
| Expected after Phase 2 | ≈ 360 (≈ 2,500 words) |
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- Electrum 4.8.0: `electrum/i18n.py`, `run_electrum` (L433–451), `electrum/gui/default_lang.py`, `electrum/plugin.py` (`maybe_load_plugin_init_method`, `read_file`), `contrib/locale/` — https://github.com/spesmilo/electrum/tree/4.8.0
|
||||||
|
- `electrum-locale` at the 4.8.0 submodule commit `3d7594c`: `update.py`, `llm_proofreader/` — https://github.com/spesmilo/electrum-locale
|
||||||
|
- Gitea "Update branch" button on pull requests — https://github.com/go-gitea/gitea/pull/9784
|
||||||
|
- Weblate: Gitea and gettext PO support — https://docs.weblate.org/en/latest/vcs.html
|
||||||
499
PLAN_QR_TRANSFER.md
Normal file
499
PLAN_QR_TRANSFER.md
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
# PLAN — Will transfer via QR codes / audio modem (Qt now, QML planned)
|
||||||
|
|
||||||
|
> Goal: let the user move an inheritance ("will") between devices over two
|
||||||
|
> air-gap channels:
|
||||||
|
>
|
||||||
|
> 1. **QR codes** (primary): export the **valid** inheritance transactions as
|
||||||
|
> a sequence of QR codes, and import them back on another machine with the
|
||||||
|
> camera;
|
||||||
|
> 2. **Audio modem** (secondary, when Electrum's `audio_modem` plugin is
|
||||||
|
> enabled): send/receive the same payload through the PC speaker +
|
||||||
|
> microphone.
|
||||||
|
>
|
||||||
|
> Both channels converge on the same review-and-sign flow afterwards.
|
||||||
|
>
|
||||||
|
> Status: APPROVED by owner (2026-08-25). No code written yet — this document
|
||||||
|
> is the implementation contract. Work top-down through §9 Checklist.
|
||||||
|
>
|
||||||
|
> Chat language: Italian; this document is in English (global rule R1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- **Desktop PyQt6 GUI** (primary, implemented by this plan):
|
||||||
|
- New plugin setting: QR chunk size, offered as **4 standard presets**.
|
||||||
|
- *"Export via QR"* action: serializes the **valid** will transactions,
|
||||||
|
optionally compresses, splits the resulting string into fixed-size frames,
|
||||||
|
shows one QR at a time with prev/next navigation, live re-chunking and
|
||||||
|
progress (`i di N`).
|
||||||
|
- *"Import via QR"* action: camera capture dialog with a slot grid
|
||||||
|
(1..N); the user selects which shot he is about to capture, scans, the
|
||||||
|
frame lands in its slot; when 1..N are filled the payload is assembled,
|
||||||
|
parsed into `WillItem`s, validity-checked locally.
|
||||||
|
- **Post-capture flow (owner decision D6, amended)**: after capture
|
||||||
|
completes there is NO read-only preview. Instead a review-and-sign wizard
|
||||||
|
walks through every transaction one at a time showing **outputs
|
||||||
|
(address + amount), total outputs, total fees**, signs it (wallet
|
||||||
|
password asked once), and at the end **proposes exporting the signed
|
||||||
|
transactions** (to file and/or back via QR).
|
||||||
|
- **Audio-modem channel** (owner decision D7): when the Electrum
|
||||||
|
`audio_modem` plugin is enabled and available, the export dialog gains a
|
||||||
|
*"Send via Audio Modem…"* button and the import dialog a *"Receive via
|
||||||
|
Audio Modem…"* button, reusing the same transfer string (no QR framing).
|
||||||
|
- **QML**: document-only update to `QML_PLAN.md` adding dedicated view specs
|
||||||
|
(owner decision D1). No QML code in this feature.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Implementing the QML frontend (gated behind `QML_PLAN.md` Phases 0–2).
|
||||||
|
- Merging imported wills into the live wallet state (existing Merge flows
|
||||||
|
stay unchanged).
|
||||||
|
- Broadcast of the reviewed transactions (user exports them; broadcasting
|
||||||
|
remains an explicit action elsewhere).
|
||||||
|
- CLI/cmdline parity for QR transfer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Owner decisions (locked)
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|----------|
|
||||||
|
| D1 | QML part = update `QML_PLAN.md` document only; implementation later. |
|
||||||
|
| D2 | Frames carry a small **compact** ASCII header (`BAL1<total><index><flag>`, fixed 11 chars, base36 count fields) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. Legacy `BALQR1\|N\|i\|flags\|` export removed; legacy import kept. |
|
||||||
|
| D3 | Payload = **serialized transaction strings only** (`str(wi.tx)`), NOT the JSON will dump. Loses statuses/metadata on purpose; import rebuilds items like `merge_single_transaction` does. |
|
||||||
|
| D4 | Compression (zlib+base64 over the whole payload) exported as **best-of**: `encode_transfer_best` ships compressed when it is shorter, plain otherwise; flag `0` = plain, `Z` = compressed. No user-facing checkbox. |
|
||||||
|
| D5 | 4 standard size presets: **~150 / ~400 / ~900 / ~1800 bytes** of payload per QR (low-res cams → high-res cams). Error-correction level fixed **M**. Stored as plugin config default; selectable again inside the export dialog. |
|
||||||
|
| D6 | After capture completes: **review + sign each tx one at a time** (show outputs, total outputs, total fees), then **propose export of the signed txs** (file and/or QR). Supersedes the earlier "WillDetailDialog preview" answer. |
|
||||||
|
| D7 | Add an **audio-modem transfer path** gated on Electrum's `audio_modem` plugin being enabled and available (`amodem` importable). Same payload semantics as QR (transfer string of serialized txs), but NO BAL frame chunking — `amodem` handles transport framing internally. Buttons simply hidden when the plugin is absent/unavailable (info message pointing at `pip install amodem` when enabled-but-broken); graceful degradation, never a hard dependency. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Verified facts (research done on the local checkouts)
|
||||||
|
|
||||||
|
All verified by reading source; references are `file:line`.
|
||||||
|
|
||||||
|
| # | Fact | Where |
|
||||||
|
|---|------|-------|
|
||||||
|
| F1 | Export today: `BalWindow.export_will()` writes `{wid: WillItem.to_dict()}` JSON; subsets All/Valid/Valid-NC built in `WillList` | `bal/gui/qt/window.py:1605-1621`, `lists.py:666-669, 743-767` |
|
||||||
|
| F2 | Batch signer: `BalWindow.sign_transactions(password, will, txids)` loops valid txs, resolves input values from change prevouts (`txin._trusted_value_sats` …), calls `wallet.sign_transaction`, updates `COMPLETE` + signature counts | `window.py:1017-1086` |
|
||||||
|
| F3 | External-will signing already supported (`will=` param, nothing saved to live wallet/history) | `window.py:1443-1484` |
|
||||||
|
| F4 | Single-tx import precedent: `merge_single_transaction` wraps `WillItem({"tx": str(tx)}, _id=tx.txid(), wallet=...)` | `window.py:1715-1724` |
|
||||||
|
| F5 | Local validity recomputation recipe (no network): `add_willtree` → `Util.get_available_utxos` → `check_invalidated` → `search_rai` → `check_signatures` | `window.py:1678-1701` |
|
||||||
|
| F6 | Plugin config accessor pattern `BalConfig`; keys declared in ctor | `bal/core/plugin_base.py:130-154, 211-264` |
|
||||||
|
| F7 | Plugin settings dialog: grid rows 0..12, `add_widget(grid,label,widget,row,help)` + `_make_reset_btn(cfgvar,widget,kind)`; ADVANCED-only rows wrapped with `_hide_if_basic(...)` | `bal/gui/qt/plugin.py:442-850` (rows at 677-850) |
|
||||||
|
| F8 | `BalDialog(parent, bal_plugin, title=None, icon=...)` base class anchors to top-level window | `bal/gui/qt/dialogs.py:92-129` |
|
||||||
|
| F9 | Fee display precedent: `fee = tx.input_value() - tx.output_value()`, fee rate = `fee / tx.estimated_size()` | `bal/gui/qt/widgets.py:1319-1328` |
|
||||||
|
| F10 | Input-value resolution helper exists: `Will.add_info_from_will(will, wid, wallet)` sets trusted input values from sibling will change outputs | `bal/core/will.py:118-136` |
|
||||||
|
| F11 | `str(tx)` = `tx.serialize()`: raw hex for complete txs; `PartialTransaction.serialize()` → base64 PSBT. Both accepted by `tx_from_any` (= `Will.get_tx_from_any`). This is exactly how BAL persists/reloads txs today | `electrum/transaction.py:907, 2539`; `will.py:106-113`; `window.py:1041-1044` |
|
||||||
|
| F12 | `QRCodeWidget` exists but **hardcodes `ERROR_CORRECT_L`** → cannot satisfy D5/M; must render our own `qrcode` instance | `electrum/gui/qt/qrcodewidget.py:35-37` |
|
||||||
|
| F13 | Camera scanning one-shot API with OS-permission handling: `scan_qrcode_from_camera(*, parent, config, callback(success: bool, error: str, data: Optional[str]))`; on Linux uses zbar CLI backend | `electrum/gui/qt/qrreader/__init__.py:47-64` |
|
||||||
|
| F14 | QR painting without PIL: `draw_qr(qr, paint_device, ...)` from `electrum.gui.common_qt.util` (what `QRCodeWidget.paintEvent` uses) | `electrum/gui/qt/qrcodewidget.py:63-72` |
|
||||||
|
| F15 | QR capacity sanity (byte mode, EC **M**): v40-M ≈ 2331 B ≥ 1800 ✓; v10-M ≈ 213 B ≥ 150 ✓; the `qrcode` lib auto-picks the version | `qrcode` lib |
|
||||||
|
| F16 | `build_zip.py` walks the tree with `os.walk` → new `.py` files ship automatically | `build_zip.py:39-47` |
|
||||||
|
| F17 | QML fork already has `QRImage.qml`, `QRScan.qml`, `ScanDialog.qml`; ScanDialog carries upstream comment "currently not used on android … qt6 camera support stops crashing" | `electrum/gui/qml/components/ScanDialog.qml:8-9` |
|
||||||
|
| F18 | `QML_PLAN.md` currently defers chunked multi-QR streams (Phase 3 note + risk R6) — this feature supersedes that deferral | `QML_PLAN.md:228-231, 304` |
|
||||||
|
| F19 | House test conventions: `def test_*` + `if __name__ == "__main__"` + `sys.path.insert(0, ..pardir)`; run standalone or via pytest | `tests/test_core_heirs.py:1-24` |
|
||||||
|
| F20 | Fork ships an `audio_modem` plugin. `_send(parent, blob)` zlib-compresses an **ASCII** blob, plays it via speaker through `amodem` inside a `WaitingDialog`; bit-rate selectable in the plugin's own settings (default = `amodem.config.slowest()`) | `electrum/plugins/audio_modem/qt.py:96-110` |
|
||||||
|
| F21 | `_recv(parent)` records from mic and delivers the decompressed ASCII text by calling `parent.setText(blob)` — the only integration contract is "an object with `setText(str)`"; there is no callback API | `audio_modem/qt.py:112-127` |
|
||||||
|
| F22 | Plugin lookup for enabled plugins: `window.plugins.get(name)` → instance or `None` (`Plugins.get`, electrum/plugin.py:575-576); availability check is the plugin's own `is_available()` (imports `amodem`). **`amodem` is NOT installed in the runtime env today** → optional dependency (pip `amodem` + libportaudio); feature must degrade gracefully | `electrum/plugin.py:575`, runtime-env check |
|
||||||
|
| F23 | `amodem.main.send/recv` stream the whole blob with their own framing/training → BAL must NOT apply QR frame chunking on this channel; and since `_send` compresses internally, BAL sends the **plain** transfer string to avoid double compression | consequence of F20/F21 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Wire format specification
|
||||||
|
|
||||||
|
### 4.1 Transfer string
|
||||||
|
|
||||||
|
```
|
||||||
|
transfer_string = "\n".join( tx_str(tx) for tx in valid_txs_sorted_by_txid )
|
||||||
|
```
|
||||||
|
|
||||||
|
- `tx_str(tx)` = `str(tx)` (F11): hex for complete txs, base64-PSBT for
|
||||||
|
partially-signed ones. Neither alphabet contains `\n` or `|`, so both are
|
||||||
|
safe delimiters.
|
||||||
|
- Ordering: ascending `tx.txid()` → deterministic output for identical input.
|
||||||
|
- If compression enabled (D4):
|
||||||
|
`transfer_string = base64_ascii( zlib_compress( transfer_string ) )`.
|
||||||
|
|
||||||
|
### 4.2 Frame layout (one frame = content of ONE QR code)
|
||||||
|
|
||||||
|
Wire format v2 (compact, current export):
|
||||||
|
|
||||||
|
```
|
||||||
|
BAL1<TTT><iii><F><payload>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Magic+version literal `BAL1` (reject anything else with a clear message).
|
||||||
|
- `<TTT>` = `<iii>` — **base36** zero-padded 3-char strings (`000`…`ZZZ`),
|
||||||
|
representing total N and index i, `1 ≤ i ≤ N ≤ 46655`. Fixed width means a
|
||||||
|
3-digit count field costs the same for a 1-frame or a 46655-frame transfer.
|
||||||
|
- `<F>`: single flag char — `0` ⇒ plain, `Z` ⇒ zlib+base64 compressed.
|
||||||
|
- `<payload>`: the i-th slice of `transfer_string`, exactly
|
||||||
|
`chunk_size` bytes each (last slice may be shorter). No separators: both
|
||||||
|
base36 count fields are fixed-width, so the header is unambiguously 11
|
||||||
|
chars and the payload starts at offset 11.
|
||||||
|
- Header overhead is a constant **11 bytes** → effective payload =
|
||||||
|
`chunk_size − 11`; the chunker slices the transfer string so that
|
||||||
|
**header+payload ≤ preset size**.
|
||||||
|
|
||||||
|
Legacy frames `BALQR1|<total>|<index>|<flags>|<payload>` (variable-width
|
||||||
|
decimal header, pipe-separated) are still **imported** by `parse_frame`
|
||||||
|
(`_parse_v1`), so old exports keep working; the exporter emits v2 only.
|
||||||
|
That is the one deliberate compatibility break: **v2 frames are not readable
|
||||||
|
by builds older than this change.**
|
||||||
|
|
||||||
|
### 4.3 Size presets (D5)
|
||||||
|
|
||||||
|
| Preset label | Payload budget (bytes/frame) | Typical QR version @EC-M |
|
||||||
|
|--------------|------------------------------|--------------------------|
|
||||||
|
| Small (low-res cameras) | 150 | ~v10 |
|
||||||
|
| Medium | 400 | ~v15 |
|
||||||
|
| Large | 900 | ~v22 |
|
||||||
|
| XL (high-res cameras) | 1800 | ~v40 |
|
||||||
|
|
||||||
|
EC level fixed **M** for scan reliability (D5). Presets live in
|
||||||
|
`bal/core/qrtransfer.py::CHUNK_PRESETS` so core tests can cover them.
|
||||||
|
|
||||||
|
### 4.4 Audio-modem channel (D7, F20-F23)
|
||||||
|
|
||||||
|
- Payload = the **plain** `transfer_string` of §4.1 — no BAL frames
|
||||||
|
(`split_frames`/`parse_frame` are QR-only), no BAL compression (the plugin
|
||||||
|
compresses internally; double compression wastes airtime).
|
||||||
|
- The existing core functions `encode_transfer(tx_strings,
|
||||||
|
compress=False)` + `decode_transfer(text, compressed=False)` are reused
|
||||||
|
unchanged; only the transport differs.
|
||||||
|
- Bit-rate is owned by the audio_modem plugin's settings dialog — BAL adds
|
||||||
|
no setting of its own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. New core module — `bal/core/qrtransfer.py`
|
||||||
|
|
||||||
|
GUI-free (never imports Qt — house rule). Public API:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MAGIC = "BALQR"
|
||||||
|
VERSION = 1
|
||||||
|
FLAG_COMPRESSED = "Z"
|
||||||
|
CHUNK_PRESETS = [(label_en, budget_bytes), ...] # §4.3 table
|
||||||
|
|
||||||
|
def encode_transfer(tx_strings: list[str], compress: bool = False) -> str
|
||||||
|
"""Join -> optional zlib+base64 -> return transfer_string."""
|
||||||
|
|
||||||
|
def split_frames(transfer_string: str, chunk_size: int) -> list[str]
|
||||||
|
"""Slice into frames 'BAL1<TTT><iii><F>payload' (v2) — header+payload <=
|
||||||
|
chunk_size. Raises QrTransferError over the 46655-frame base36 cap, or
|
||||||
|
ValueError if chunk_size < MIN_CHUNK_SIZE."""
|
||||||
|
|
||||||
|
def encode_transfer_best(tx_strings: list[str]) -> tuple[str, bool]
|
||||||
|
"""-> (transfer_string, compressed); ships the shorter of plain vs
|
||||||
|
zlib+base64 so the export emits the densest frames."""
|
||||||
|
|
||||||
|
def parse_frame(frame: str) -> tuple[int, int, bool, str]
|
||||||
|
"""-> (total, index, compressed, payload); accepts v2 'BAL1…' and legacy
|
||||||
|
'BALQR1|…' (wrapped as _parse_v1/_parse_v2); ValueError on bad magic/
|
||||||
|
version/arity/non-numeric fields."""
|
||||||
|
|
||||||
|
def assemble(frames: dict[int, str]) -> str
|
||||||
|
"""Validate indices form exactly range(1..max_total) (taken from any
|
||||||
|
frame header), concatenate payloads in order, decode flags ->
|
||||||
|
transfer_string. Raises MissingFramesError(indexes) / InconsistentTotalError."""
|
||||||
|
|
||||||
|
def decode_transfer(transfer_string: str, compressed: bool) -> list[str]
|
||||||
|
"""Inverse of encode_transfer -> list of tx strings."""
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus exceptions `QrTransferError(ValueError)`, `MissingFramesError`,
|
||||||
|
`InconsistentTotalError`. All docstrings/comments English; ruff-clean
|
||||||
|
(line-length 88, E501 ignored).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Settings (Qt)
|
||||||
|
|
||||||
|
1. `bal/core/plugin_base.py`: after `REBUILD_ON_CLOSE` (~line 264) add
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. `bal/gui/qt/plugin.py::settings_dialog` (rows end at 12, ~line 844):
|
||||||
|
append row **13** — visible in BASIC and ADVANCED (do NOT wrap with
|
||||||
|
`_hide_if_basic`):
|
||||||
|
|
||||||
|
- Label: `"QR Code Size"`
|
||||||
|
- `QComboBox` fed from `CHUNK_PRESETS`; item text e.g.
|
||||||
|
`"Small — ~150 bytes/QR (low-res cameras)"`; `currentIndexChanged`
|
||||||
|
→ `self.QR_CHUNK_SIZE.set(budget_bytes)`; initial index from
|
||||||
|
`QR_CHUNK_SIZE.get()` (fallback to nearest preset if the stored value
|
||||||
|
was customized).
|
||||||
|
- `HelpButton` text: explains trade-off (small QR = more shots but easier
|
||||||
|
to scan with poor cameras; large QR = fewer shots, needs good camera)
|
||||||
|
and that the size can also be changed inside the export dialog.
|
||||||
|
- Reset button via existing `_make_reset_btn(self.QR_CHUNK_SIZE, combo, ...)`
|
||||||
|
pattern (plugin.py:684).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Qt export flow
|
||||||
|
|
||||||
|
### 7.1 Entry point
|
||||||
|
|
||||||
|
`bal/gui/qt/lists.py::WillList.create_toolbar` (menu block lines 666-670):
|
||||||
|
|
||||||
|
```python
|
||||||
|
export_menu.addAction(_("Via QR…"), self.export_will_valid_qr)
|
||||||
|
```
|
||||||
|
|
||||||
|
New `WillList.export_will_valid_qr()` mirrors `export_will_valid`
|
||||||
|
(lists.py:743-754): builds `{wid: wi}` subset of `VALID` items, empty →
|
||||||
|
`show_message(_("No valid will item to export"))`, else
|
||||||
|
`self.bal_window.export_will_via_qr(will=subset)`.
|
||||||
|
|
||||||
|
### 7.2 `BalWindow.export_will_via_qr(will=None)` (new, `window.py` near
|
||||||
|
`export_will`)
|
||||||
|
|
||||||
|
- Collect `tx_strings = [str(wi.tx) for wid, wi in sorted-by-txid ...]`
|
||||||
|
(F11).
|
||||||
|
- Mark exported items `EXPORTED` (parity with `export_json_file`,
|
||||||
|
window.py:1607-1609) — only when `will` came from the live list.
|
||||||
|
- Open `WillQrExportDialog(self, tx_strings)`.
|
||||||
|
|
||||||
|
Shared helper used by both dialogs:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_audio_modem_plugin(self): # on BalWindow
|
||||||
|
"""Return the loaded audio_modem plugin if enabled AND available
|
||||||
|
(amodem importable), else None. Never raises."""
|
||||||
|
p = self.window.plugins.get("audio_modem") # F22
|
||||||
|
return p if p is not None and p.is_available() else None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 `WillQrExportDialog(BalDialog)` (new class in `dialogs.py`)
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Size ▾ Small/Medium/Large/XL] ← compressed best-of automatically (no checkbox)
|
||||||
|
[ QR image ] ← BalQrImage (see below)
|
||||||
|
«i di N» [◀ Prev] [Next ▶]
|
||||||
|
[Save current QR as PNG…] [Send via Audio Modem…] [Close]
|
||||||
|
```
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
|
||||||
|
- On any control change: rebuild `split_frames(encode_transfer_best(...))`,
|
||||||
|
reset index to frame 1, refresh counter (owner requirement: "cambiare la
|
||||||
|
risoluzione").
|
||||||
|
- `BalQrImage(QWidget)` ≈ trimmed copy of `QRCodeWidget`
|
||||||
|
(`electrum/gui/qt/qrcodewidget.py:21-72`) but constructing
|
||||||
|
`qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2)` and painting
|
||||||
|
via `electrum.gui.common_qt.util.draw_qr` (F12/F14). ~30 lines.
|
||||||
|
- Prev/Next wrap or disable at ends (disable chosen: clearer).
|
||||||
|
- PNG export optional convenience via existing
|
||||||
|
`getSaveFileName` + `QWidget.grab()` (same trick as
|
||||||
|
`qrcodewidget.py:110`).
|
||||||
|
- **Send via Audio Modem…** (D7): shown only when
|
||||||
|
`bal_window.get_audio_modem_plugin()` returns a usable instance (below);
|
||||||
|
otherwise hidden. Handler: re-encode the payload **plain**
|
||||||
|
(`encode_transfer(tx_strings, compress=False)`) and call the plugin's
|
||||||
|
`_send(parent=self, blob=transfer_string)` — its own WaitingDialog owns
|
||||||
|
progress/cancellation (F20). Tooltip when hidden is unnecessary; instead,
|
||||||
|
if the plugin is enabled but `is_available()` is False, show an info
|
||||||
|
message pointing to `pip install amodem` + portaudio (F22).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Qt import flow + review/sign wizard
|
||||||
|
|
||||||
|
### 8.1 Entry point
|
||||||
|
|
||||||
|
`lists.py` toolbar menu, next to Import/Merge (lines 670-671):
|
||||||
|
|
||||||
|
```python
|
||||||
|
menu.addAction(_("Import via QR…"), lambda: self.bal_window.import_will_via_qr())
|
||||||
|
```
|
||||||
|
|
||||||
|
`BalWindow.import_will_via_qr()` opens `WillQrImportDialog(self)`.
|
||||||
|
|
||||||
|
### 8.2 `WillQrImportDialog(BalDialog)`
|
||||||
|
|
||||||
|
State: `self.frames: dict[int, str]`, `self.total: int | None`,
|
||||||
|
`self.target_index: int | None`.
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
«Captured k of N» [Scan ▶] [Reset]
|
||||||
|
[slot grid: push-buttons 1..N; states: empty / filled ✓ / selected-target]
|
||||||
|
hint line («Select a slot, then scan» / «Scan the first QR»)
|
||||||
|
[Receive via Audio Modem…] [Review & Sign ▶] [Close]
|
||||||
|
```
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
|
||||||
|
- **Scan** → `scan_qrcode_from_camera(parent=self,
|
||||||
|
config=self.bal_window.window.config, callback=self._on_scan)`
|
||||||
|
(F13). One-shot per press; dialog stays open between shots (simplest,
|
||||||
|
matches Electrum UX; no continuous mode).
|
||||||
|
- `_on_scan(success, error, data)`:
|
||||||
|
- failure → `show_error(error)` (covers missing zbar/camera too);
|
||||||
|
- `parse_frame` errors → `show_warning(_("Not a BAL will QR"))`;
|
||||||
|
- first valid frame adopts `total` and materializes the slot grid;
|
||||||
|
- frame whose `total` ≠ adopted total → warn + offer Reset (user may have
|
||||||
|
restarted the export with another size);
|
||||||
|
- valid → `frames[index] = payload`; auto-advance `target_index` to the
|
||||||
|
lowest missing index; refresh grid + counter.
|
||||||
|
- Clicking an empty slot sets `target_index` (owner requirement: manual
|
||||||
|
shot selection); a filled slot click asks to overwrite.
|
||||||
|
- **Receive via Audio Modem…** (D7): shown only when
|
||||||
|
`bal_window.get_audio_modem_plugin()` returns a usable instance. Handler:
|
||||||
|
build a tiny adapter object exposing `setText(str)` that stores the text
|
||||||
|
and invokes the shared post-receive continuation, then call
|
||||||
|
`plugin._recv(parent=self, ...)`-style flow (F21 contract). On success the
|
||||||
|
received string is treated as the **whole payload**: skip frames/slots
|
||||||
|
entirely → `decode_transfer(text, compressed=False)` → continue at §8.2's
|
||||||
|
item-building step (WillItem construction + validity pass + wizard).
|
||||||
|
Errors from the modem surface through the plugin's own dialog; empty
|
||||||
|
result (user cancelled) is silently ignored.
|
||||||
|
- **Review & Sign** enabled only when `set(frames) == set(range(1, N+1))`:
|
||||||
|
runs `assemble` + `decode_transfer` → `list[str]`; any `QrTransferError`
|
||||||
|
surfaces as `show_error` and keeps the dialog open.
|
||||||
|
- Build items exactly like `merge_single_transaction` (F4):
|
||||||
|
`WillItem({"tx": s}, wallet=self.wallet)` per string; failures per-string
|
||||||
|
are collected and reported at the end (bad string ≠ fatal for the rest).
|
||||||
|
- Local validity pass (F5 recipe) on the resulting dict; items failing
|
||||||
|
`VALID` are dropped and listed in a warning. Set
|
||||||
|
`wi.set_status("IMPORTED", True)` on survivors (mirrors
|
||||||
|
`import_will_into_details`, window.py:1753-1754).
|
||||||
|
- Then `close()` and start the wizard (§8.3) with the valid subset. Empty
|
||||||
|
result → stop with a message.
|
||||||
|
|
||||||
|
### 8.3 `WillTxReviewSignDialog(BalDialog)` — post-capture wizard (D6)
|
||||||
|
|
||||||
|
Constructed with `(bal_window, willitems: dict[str, WillItem])` — the
|
||||||
|
imported subset lives **outside** the live wallet state (external mode,
|
||||||
|
F3).
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
1. **Password once**: `password = bal_window.get_wallet_password()`
|
||||||
|
(window.py:1088-1100). Returns `False` on cancel → abort wizard; `None`
|
||||||
|
means unencrypted wallet → proceed without password.
|
||||||
|
2. **Per-transaction page** (one `QStackedWidget` step per tx, ordered by
|
||||||
|
txid like export):
|
||||||
|
|
||||||
|
```
|
||||||
|
Tx 2 of 5 — a1b2…c3d1 (short txid)
|
||||||
|
Locktime: 2033-04-05 Status: unsigned (0/1 sigs)
|
||||||
|
┌ outputs ─────────────────────────────────┐
|
||||||
|
│ bc1q…heir1 0,042 BTC │
|
||||||
|
│ bc1q…willexec fee 0,00012 BTC │
|
||||||
|
│ bc1q…change 0,00988 BTC │
|
||||||
|
└───────────────────────────────────────────┘
|
||||||
|
Total outputs: 0,052 BTC Fees: 420 sat (1.2 sat/vB)
|
||||||
|
[Sign & Next ▶] [Skip] [Cancel all]
|
||||||
|
```
|
||||||
|
- Outputs from `tx.outputs()` (address via `TxOutput.get_ui_address_str()`
|
||||||
|
style helpers already imported in the qt layer; value via
|
||||||
|
`bal_window.window.format_amount`).
|
||||||
|
- Totals: `output_value()` sum; fees via `input_value() - output_value()`
|
||||||
|
after resolving inputs with `Will.add_info_from_will(will, wid, wallet)`
|
||||||
|
(F10); `-1`/unknown handled like widgets.py:1319-1324 (F9).
|
||||||
|
3. **Sign & Next** → sign this single tx through a **refactored helper**
|
||||||
|
extracted from the loop body of `sign_transactions`
|
||||||
|
(window.py:1037-1083 → `_sign_single_tx(tx, willitems, password)` kept
|
||||||
|
byte-equivalent; batch method calls the helper per iteration so existing
|
||||||
|
behaviour/tests are unaffected). Update `COMPLETE`/sig-counts exactly as
|
||||||
|
today; then advance.
|
||||||
|
4. **Skip** leaves the tx untouched and advances. **Cancel all** stops; the
|
||||||
|
already-signed txs remain in the wizard's local dict (still exportable —
|
||||||
|
confirmation dialog warns about skipped ones).
|
||||||
|
5. **Summary page**: `signed X of Y`, skipped/failed lists, then:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Save signed file…] [Show QR…] [Close]
|
||||||
|
```
|
||||||
|
- *Save file* = existing JSON path: `export_meta_gui(window,
|
||||||
|
"will.json", writer)` writing `{wid: wi.to_dict()}` of the signed
|
||||||
|
subset (same serializer as `export_json_file`, window.py:1605).
|
||||||
|
- *Show QR* = `WillQrExportDialog` over `[str(wi.tx)]` of the signed
|
||||||
|
subset (the online machine can scan them straight into Merge).
|
||||||
|
- Nothing touches `self.willitems`/history (external-mode rule, F3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Checklist (execution order — tick here when resuming work)
|
||||||
|
|
||||||
|
- [x] **P0** `bal/core/qrtransfer.py` + unit tests `tests/test_core_qr_transfer.py`
|
||||||
|
(cases: round-trip plain/compressed; boundaries: len%size==0, size>len,
|
||||||
|
min-size guard; bad magic/version; missing middle frame; duplicate
|
||||||
|
overwrite; inconsistent totals; multi-PSBT mixes; presets sanity vs
|
||||||
|
qrcode capacities F15). Run:
|
||||||
|
`QT_QPA_PLATFORM=offscreen python3 tests/test_core_qr_transfer.py`
|
||||||
|
- [x] **P1** Settings: `QR_CHUNK_SIZE` config var + settings-dialog row 16
|
||||||
|
(ø16) + reset kind (§6). Verify in `QT_QPA_PLATFORM=offscreen` GUI run.
|
||||||
|
- [x] **P2** Export: `BalWindow.export_will_via_qr`, `get_audio_modem_plugin`
|
||||||
|
helper, `WillList` menu action, `WillQrExportDialog` + `BalQrImage`,
|
||||||
|
audio-modem send button (§7).
|
||||||
|
- [x] **P3** Import: `import_will_via_qr`, `WillQrImportDialog` (§8.2),
|
||||||
|
incl. camera error paths, audio-modem receive button (local mirror of
|
||||||
|
`_recv`, `setText` sink replaced by a callback), plain-payload fast
|
||||||
|
path into the wizard.
|
||||||
|
- [x] **P4** Wizard: `_prepare_and_sign_tx` refactor + `WillTxReviewSignDialog`
|
||||||
|
(§8.3). Regression-gate: full batch sign still green
|
||||||
|
(`tests/test_core_*.py` offline batch; `tests/test_gui_*.py` batch
|
||||||
|
including new `tests/test_gui_qr_transfer.py`).
|
||||||
|
- [x] **P5** Docs & QML plan sync: update `QML_PLAN.md` — Phase 2 models +=
|
||||||
|
`BalQrTransferModel` (thin QObject over `bal.core.qrtransfer`),
|
||||||
|
Phase 3 += dedicated views `BalQrExportPage.qml` /
|
||||||
|
`BalQrImportPage.qml` (slot grid + `QRScan` reuse), delete the
|
||||||
|
"chunked streams deferred" note, rewrite R6 mitigation, add Android
|
||||||
|
caveat quoting F17 with file/paste fallback; README/HANDOFF sections;
|
||||||
|
CHANGELOG numbered entry 56 at END (house rule).
|
||||||
|
- [x] **P6** Release hygiene: `python3 build_zip.py` +
|
||||||
|
`QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py
|
||||||
|
electrum.plugins.bal` + external-zip test; ruff (repo venv
|
||||||
|
`venv/bin/ruff`) no NEW violations; pyright false-positive policy per
|
||||||
|
AGENTS.md. Version bump only via `make-release.sh` (owner-driven).
|
||||||
|
Docs: document audio-modem as OPTIONAL channel — requires the
|
||||||
|
Electrum `audio_modem` plugin enabled plus `pip install amodem`
|
||||||
|
and libportaudio (not installed in the dev runtime env today, F22);
|
||||||
|
manual test matrix gains an audiomodem round-trip row (two machines,
|
||||||
|
default slowest bitrate) marked optional/skippable when hardware
|
||||||
|
unavailable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Risks & mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| High frame counts annoy users (e.g. 40+ QR at 150 B) | Presets span 150→1800; compress option; counter always visible |
|
||||||
|
| Big QR versions fail on cheap cameras | EC=M fixed; Small preset targets low-res cams (D5 rationale) |
|
||||||
|
| User rescans old export with different total | `InconsistentTotalError` → clear warning + Reset (§8.2) |
|
||||||
|
| `_sign_single_tx` refactor regresses batch signing | Byte-equivalent extraction; batch callers unchanged; offline core tests gate P4 |
|
||||||
|
| Imported txs reference UTXOs the importing wallet doesn't know | Validity pass drops them with an explicit report instead of silently merging garbage |
|
||||||
|
| zbar/camera unavailable (esp. Windows/macOS packaging) | `scan_qrcode_from_camera` error path → suggest file export/import fallback |
|
||||||
|
| `amodem`/portaudio not installed (current dev env state, F22) or audio_modem plugin disabled | Buttons simply hidden; QR/file remain the primary channels; P6 documents the optional dependency |
|
||||||
|
| Audio transfer fails mid-way (noise, wrong volume) | Plugin's WaitingDialog surfaces the error; user retries — nothing to clean up on BAL side (single atomic blob, no slot state touched) |
|
||||||
|
| Very slow airtime at default slowest bitrate | Bitrate is selectable in the audio_modem plugin's own settings (F20); BAL adds no knob; tooltip in export dialog hints at large payloads |
|
||||||
|
| Qt6 camera instability on Android (future QML work) | Recorded as caveat in QML_PLAN update (P5), file/paste stays the primary mobile fallback |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Findings log (append-only)
|
||||||
|
|
||||||
|
- 2026-08-25: plan drafted after code exploration; owner answered D1-D6
|
||||||
|
(D6 amended live from "preview dialog" to "review+sign wizard").
|
||||||
|
- Verified F12 (QRCodeWidget hardcodes EC-L) and F11 (str(tx) round-trip
|
||||||
|
guarantees) — both shaped §§4/7.
|
||||||
|
- 2026-08-25: owner requested an audio-modem transfer path → researched
|
||||||
|
`electrum/plugins/audio_modem/qt.py`, added D7 + F20-F23, §4.4, buttons
|
||||||
|
in §§7.3/8.2, checklist/risk updates. Key constraint found: `_recv`'s
|
||||||
|
only contract is `parent.setText(blob)` (F21) → thin adapter object; and
|
||||||
|
BAL must not chunk/compress on this channel (F23). `amodem` is NOT in
|
||||||
|
the runtime env yet — feature is strictly optional.
|
||||||
22
README.md
22
README.md
@@ -26,6 +26,8 @@ bal/ the installable Electrum plugin package
|
|||||||
│ ├── willexecutors.py
|
│ ├── willexecutors.py
|
||||||
│ ├── checkalive.py
|
│ ├── checkalive.py
|
||||||
│ ├── reminders.py
|
│ ├── reminders.py
|
||||||
|
│ ├── qrtransfer.py transfer-string codec / QR size presets
|
||||||
|
│ ├── animated_qr.py BBQR codec (stdlib-only)
|
||||||
│ └── input_rules.py
|
│ └── input_rules.py
|
||||||
├── cli/ headless command-line layer (no Qt)
|
├── cli/ headless command-line layer (no Qt)
|
||||||
│ ├── commands.py bal_* daemon commands (@plugin_command)
|
│ ├── commands.py bal_* daemon commands (@plugin_command)
|
||||||
@@ -87,6 +89,26 @@ Copy the `bal/` directory into your Electrum installation's
|
|||||||
`electrum/plugins/` directory, so that `electrum/plugins/bal/manifest.json`
|
`electrum/plugins/` directory, so that `electrum/plugins/bal/manifest.json`
|
||||||
exists, then enable it from **Tools → Plugins**.
|
exists, then enable it from **Tools → Plugins**.
|
||||||
|
|
||||||
|
## Transfer a will with QR codes (or audio)
|
||||||
|
|
||||||
|
From the will list (**Export → QR Codes**) a will can be exported as a
|
||||||
|
sequence of QR codes and imported on another device (**Import via QR**). The
|
||||||
|
export offers All / Valid / Valid-NC filters plus a QR size preset
|
||||||
|
(150–1800 bytes/frame) and ships the **BBQR** format; the import
|
||||||
|
flow reviews and sign each transaction
|
||||||
|
one at a time, then proposes exporting the signed transactions. When
|
||||||
|
Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` +
|
||||||
|
PortAudio) Send/Receive audio buttons complement the QR channel. See
|
||||||
|
[`PLAN_QR_TRANSFER.md`](PLAN_QR_TRANSFER.md) for the transfer wire-format spec.
|
||||||
|
|
||||||
|
### Animated-QR format (BBQR)
|
||||||
|
|
||||||
|
The export page emits **BBQR** (`B$…`, Coinkite, used by BitKit and Coldcard)
|
||||||
|
animated-QR sequences. The importer auto-detects each code it sees, so a BAL
|
||||||
|
export can be imported by any tool that understands BBQR. BBQR frames may
|
||||||
|
arrive in any order, and duplicates are ignored. The 32 MB message limit
|
||||||
|
(zlib-bomb guard) bounds untrusted scanner input.
|
||||||
|
|
||||||
## Command-line / headless usage
|
## Command-line / headless usage
|
||||||
|
|
||||||
BAL can be used without the Qt GUI via Electrum's daemon mode. The CLI layer
|
BAL can be used without the Qt GUI via Electrum's daemon mode. The CLI layer
|
||||||
|
|||||||
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]
|
||||||
@@ -22,7 +22,6 @@ This module is imported lazily (only when a ``bal_*`` command actually runs),
|
|||||||
so a missing wallet or a network-less daemon can still start Electrum.
|
so a missing wallet or a network-less daemon can still start Electrum.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -41,13 +40,16 @@ from ..core import checkalive
|
|||||||
from ..core import heirs as heirs_mod
|
from ..core import heirs as heirs_mod
|
||||||
from ..core import will as will_mod
|
from ..core import will as will_mod
|
||||||
from ..core.checkalive import (
|
from ..core.checkalive import (
|
||||||
|
CheckAliveAfterDeliveryError,
|
||||||
CheckAliveError,
|
CheckAliveError,
|
||||||
check_alive_expired,
|
ensure_check_alive_valid,
|
||||||
resolve_date_to_check,
|
resolve_date_to_check,
|
||||||
|
resolve_delivery_date,
|
||||||
|
resolve_guard_threshold,
|
||||||
)
|
)
|
||||||
from ..core.heirs import Heirs, is_op_return_address
|
from ..core.heirs import Heirs, is_op_return_address
|
||||||
from ..core.plugin_base import BalConfig, BalPlugin
|
from ..core.plugin_base import BalConfig, BalPlugin
|
||||||
from ..core.util import Util
|
from ..core.util import Util, copy_structure
|
||||||
from ..core.will import Will, WillItem
|
from ..core.will import Will, WillItem
|
||||||
from ..core.willexecutors import Willexecutors, is_onion_url, is_tor_active
|
from ..core.willexecutors import Willexecutors, is_onion_url, is_tor_active
|
||||||
|
|
||||||
@@ -91,21 +93,27 @@ def _user_facing(e):
|
|||||||
_(
|
_(
|
||||||
"In the inheritance process, the entire wallet will always be "
|
"In the inheritance process, the entire wallet will always be "
|
||||||
"fully emptied. Your settings require an adjustment of the "
|
"fully emptied. Your settings require an adjustment of the "
|
||||||
f"amounts: {e}"
|
"amounts: {}"
|
||||||
)
|
).format(e)
|
||||||
)
|
)
|
||||||
if isinstance(e, heirs_mod.WillExecutorFeeTooHighException):
|
if isinstance(e, heirs_mod.WillExecutorFeeTooHighException):
|
||||||
return UserFacingException(_(f"Will-executor fee too high: {e}"))
|
return UserFacingException(_("Will-executor fee too high: {}").format(e))
|
||||||
if isinstance(e, heirs_mod.BalanceTooLowException):
|
if isinstance(e, heirs_mod.BalanceTooLowException):
|
||||||
return UserFacingException(str(e))
|
return UserFacingException(str(e))
|
||||||
if isinstance(e, heirs_mod.HeirAmountIsDustException):
|
if isinstance(e, heirs_mod.HeirAmountIsDustException):
|
||||||
return UserFacingException(str(e))
|
return UserFacingException(str(e))
|
||||||
if isinstance(e, heirs_mod.NotAnAddress):
|
if isinstance(e, heirs_mod.NotAnAddress):
|
||||||
return UserFacingException(_(f"not an address, {e}"))
|
return UserFacingException(_("not an address, {}").format(e))
|
||||||
if isinstance(e, heirs_mod.AmountNotValid):
|
if isinstance(e, heirs_mod.AmountNotValid):
|
||||||
return UserFacingException(str(e))
|
return UserFacingException(str(e))
|
||||||
if isinstance(e, heirs_mod.LocktimeNotValid):
|
if isinstance(e, heirs_mod.LocktimeNotValid):
|
||||||
return UserFacingException(str(e))
|
return UserFacingException(str(e))
|
||||||
|
if isinstance(e, checkalive.CheckAliveAfterDeliveryError):
|
||||||
|
return UserFacingException(
|
||||||
|
_(
|
||||||
|
"Check Alive must be after today and before the delivery date, please update it"
|
||||||
|
)
|
||||||
|
)
|
||||||
if isinstance(e, checkalive.CheckAliveError):
|
if isinstance(e, checkalive.CheckAliveError):
|
||||||
return UserFacingException(
|
return UserFacingException(
|
||||||
_(
|
_(
|
||||||
@@ -272,13 +280,21 @@ class BalController:
|
|||||||
self.plugin.is_basic_mode(),
|
self.plugin.is_basic_mode(),
|
||||||
self.will_settings,
|
self.will_settings,
|
||||||
built_locktime=Will.get_min_locktime(self.willitems),
|
built_locktime=Will.get_min_locktime(self.willitems),
|
||||||
|
creation_date=Will.get_creation_time(self.willitems),
|
||||||
)
|
)
|
||||||
self.no_willexecutor = bool(self.plugin.NO_WILLEXECUTOR.get())
|
self.no_willexecutor = bool(self.plugin.NO_WILLEXECUTOR.get())
|
||||||
self.willexecutors = Willexecutors.get_willexecutors(
|
self.willexecutors = Willexecutors.get_willexecutors(
|
||||||
self.plugin, update=True, task=False
|
self.plugin, update=True, task=False
|
||||||
)
|
)
|
||||||
if check_alive_expired(self.plugin.is_basic_mode(), self.date_to_check):
|
delivery_date = resolve_delivery_date(
|
||||||
raise CheckAliveError(self.date_to_check)
|
self.will_settings,
|
||||||
|
built_locktime=Will.get_min_locktime(self.willitems),
|
||||||
|
)
|
||||||
|
ensure_check_alive_valid(
|
||||||
|
self.plugin.is_basic_mode(),
|
||||||
|
self.date_to_check,
|
||||||
|
delivery_date=delivery_date,
|
||||||
|
)
|
||||||
self._init_heirs_to_locktime(self.plugin.ENABLE_MULTIVERSE.get())
|
self._init_heirs_to_locktime(self.plugin.ENABLE_MULTIVERSE.get())
|
||||||
|
|
||||||
def _init_heirs_to_locktime(self, multiverse=False):
|
def _init_heirs_to_locktime(self, multiverse=False):
|
||||||
@@ -315,6 +331,28 @@ class BalController:
|
|||||||
executor.
|
executor.
|
||||||
"""
|
"""
|
||||||
will = {}
|
will = {}
|
||||||
|
# Drop stale wallet-LOCAL will placeholders (mirror of the GUI
|
||||||
|
# build_will) so their coins are available to this build.
|
||||||
|
Will.remove_stale_wallet_history(
|
||||||
|
self.wallet, self.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.
|
||||||
|
# Recompute it for the will being built (earliest future delivery among
|
||||||
|
# the CURRENT heirs), mirroring ``BalWindow.build_will``, so the
|
||||||
|
# anticipated dates pass the build filter.
|
||||||
|
_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.plugin.is_basic_mode(), self.will_settings,
|
||||||
|
built_locktime=_new_locktime,
|
||||||
|
)
|
||||||
self.willexecutors = Willexecutors.get_willexecutors(
|
self.willexecutors = Willexecutors.get_willexecutors(
|
||||||
self.plugin, update=False, task=False
|
self.plugin, update=False, task=False
|
||||||
)
|
)
|
||||||
@@ -346,11 +384,13 @@ class BalController:
|
|||||||
tx["my_locktime"] = txs[txid].my_locktime
|
tx["my_locktime"] = txs[txid].my_locktime
|
||||||
tx["heirsvalue"] = txs[txid].heirsvalue
|
tx["heirsvalue"] = txs[txid].heirsvalue
|
||||||
tx["description"] = txs[txid].description
|
tx["description"] = txs[txid].description
|
||||||
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
|
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
|
||||||
tx["status"] = _("New")
|
# Stored history: always English (translated only for display by
|
||||||
|
# format_status_history); never _() here.
|
||||||
|
tx["status"] = "New"
|
||||||
tx["baltx_fees"] = txs[txid].tx_fees
|
tx["baltx_fees"] = txs[txid].tx_fees
|
||||||
tx["time"] = creation_time
|
tx["time"] = creation_time
|
||||||
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
|
tx["heirs"] = copy_structure(txs[txid].heirs)
|
||||||
tx["txchildren"] = []
|
tx["txchildren"] = []
|
||||||
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
||||||
Will.update_will(self.willitems, will)
|
Will.update_will(self.willitems, will)
|
||||||
@@ -435,7 +475,15 @@ class BalController:
|
|||||||
raise _user_facing(e) from e
|
raise _user_facing(e) from e
|
||||||
|
|
||||||
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
||||||
if locktime < date_to_check:
|
threshold_ts = resolve_guard_threshold(
|
||||||
|
self.plugin.is_basic_mode(),
|
||||||
|
self.will_settings,
|
||||||
|
creation_date=Will.get_creation_time(self.willitems),
|
||||||
|
)
|
||||||
|
if threshold_ts is not None:
|
||||||
|
if locktime < threshold_ts:
|
||||||
|
raise UserFacingException(_("locktime is lower than threshold"))
|
||||||
|
elif locktime < date_to_check:
|
||||||
raise UserFacingException(_("locktime is lower than threshold"))
|
raise UserFacingException(_("locktime is lower than threshold"))
|
||||||
|
|
||||||
if not self.no_willexecutor:
|
if not self.no_willexecutor:
|
||||||
@@ -555,6 +603,11 @@ class BalController:
|
|||||||
except will_mod.NoHeirsException as e:
|
except will_mod.NoHeirsException as e:
|
||||||
_logger.info(f"auto rebuild: no heirs ({e})")
|
_logger.info(f"auto rebuild: no heirs ({e})")
|
||||||
return {"result": "no_heirs"}
|
return {"result": "no_heirs"}
|
||||||
|
except CheckAliveAfterDeliveryError:
|
||||||
|
_logger.info(
|
||||||
|
"auto rebuild: Check Alive not before the delivery, nothing to do"
|
||||||
|
)
|
||||||
|
return {"result": "check_alive_before_delivery"}
|
||||||
except CheckAliveError:
|
except CheckAliveError:
|
||||||
_logger.info("auto rebuild: Check Alive already in the past")
|
_logger.info("auto rebuild: Check Alive already in the past")
|
||||||
return self._auto_rebuild_invalidate("threshold_passed")
|
return self._auto_rebuild_invalidate("threshold_passed")
|
||||||
|
|||||||
296
bal/core/animated_qr.py
Normal file
296
bal/core/animated_qr.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
"""
|
||||||
|
bal.core.animated_qr
|
||||||
|
====================
|
||||||
|
|
||||||
|
GUI-free implementation of the BBQR animated-QR transfer format used to
|
||||||
|
move BAL will data between devices.
|
||||||
|
|
||||||
|
Supported wire format (self-describing and order-independent on receive):
|
||||||
|
|
||||||
|
* **BBQR** (Coinkite)::
|
||||||
|
B$<encoding><type><2 base36 total><2 base36 index><payload>
|
||||||
|
Equal-length text frames; the payload is uppercase hex, RFC-4648
|
||||||
|
base32, or raw-deflate (``wbits=-10``) zlib plus base32.
|
||||||
|
|
||||||
|
Everything is implemented from scratch on top of the Python standard library
|
||||||
|
only (``zlib``, ``base64``), so the shipped plugin zip stays a
|
||||||
|
self-contained bundle with no third-party dependencies (house rule).
|
||||||
|
|
||||||
|
This module never imports Qt or any Electrum GUI code (house rule).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import zlib
|
||||||
|
from typing import Dict, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Errors & safety caps
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class AnimatedQrError(ValueError):
|
||||||
|
"""Base error for all animated-QR codec failures."""
|
||||||
|
|
||||||
|
|
||||||
|
class FormatNotDetectedError(AnimatedQrError):
|
||||||
|
"""The scanned text does not look like any known animated-QR format."""
|
||||||
|
|
||||||
|
|
||||||
|
class TransferConflictError(AnimatedQrError):
|
||||||
|
"""An incoming frame belongs to a different transfer than the open one."""
|
||||||
|
|
||||||
|
|
||||||
|
class SessionLimitError(AnimatedQrError):
|
||||||
|
"""A receive session exceeded its safety caps."""
|
||||||
|
|
||||||
|
|
||||||
|
# Safety caps for untrusted scanner input.
|
||||||
|
_MAX_SESSION_PARTS = 20000
|
||||||
|
_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# BBQR (Coinkite)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
_BBQR_PREFIX = "B$"
|
||||||
|
|
||||||
|
|
||||||
|
def _bbqr_base36(n: int) -> str:
|
||||||
|
if not 0 <= n <= 1295:
|
||||||
|
raise AnimatedQrError("BBQR part count out of range")
|
||||||
|
|
||||||
|
def digit(x: int) -> str:
|
||||||
|
return chr(48 + x) if x < 10 else chr(65 + x - 10)
|
||||||
|
|
||||||
|
return digit(n // 36) + digit(n % 36)
|
||||||
|
|
||||||
|
|
||||||
|
def _bbqr_base32(data: bytes) -> str:
|
||||||
|
return base64.b32encode(data).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _bbqr_encode(raw: bytes, encoding: str) -> Tuple[str, str, int]:
|
||||||
|
"""Return ``(encoding, encoded_text, split_mod)`` honouring the reference."""
|
||||||
|
if encoding == "H":
|
||||||
|
return "H", raw.hex().upper(), 2
|
||||||
|
if encoding == "Z":
|
||||||
|
compressor = zlib.compressobj(wbits=-10)
|
||||||
|
compressed = compressor.compress(raw) + compressor.flush()
|
||||||
|
if len(compressed) < len(raw):
|
||||||
|
return "Z", _bbqr_base32(compressed), 8
|
||||||
|
encoding = "2"
|
||||||
|
if encoding != "2":
|
||||||
|
raise AnimatedQrError("unknown BBQR encoding")
|
||||||
|
return "2", _bbqr_base32(raw), 8
|
||||||
|
|
||||||
|
|
||||||
|
def bbqr_frames(payload: bytes, budget_chars: int, encoding: str = "Z", type_code: str = "B") -> List[str]:
|
||||||
|
"""Encode ``payload`` into BBQR frames (``B$<enc><type><N><n>…``)."""
|
||||||
|
if len(type_code) != 1 or not type_code.isalnum():
|
||||||
|
raise AnimatedQrError("bad BBQR type code")
|
||||||
|
encoding, encoded, split_mod = _bbqr_encode(payload, encoding)
|
||||||
|
chunk = budget_chars - 8
|
||||||
|
if chunk < split_mod:
|
||||||
|
raise AnimatedQrError("QR budget too small for a BBQR frame")
|
||||||
|
chunk -= chunk % split_mod
|
||||||
|
if chunk < 1:
|
||||||
|
raise AnimatedQrError("QR budget too small for a BBQR frame")
|
||||||
|
if len(payload) > _MAX_MESSAGE_BYTES:
|
||||||
|
raise AnimatedQrError("BBQR payload exceeds the size cap")
|
||||||
|
total = -(-len(encoded) // chunk)
|
||||||
|
if total > 1295:
|
||||||
|
raise AnimatedQrError("BBQR transfer demands too many parts")
|
||||||
|
header = _BBQR_PREFIX + encoding + type_code + _bbqr_base36(total)
|
||||||
|
frames: List[str] = []
|
||||||
|
pos = 0
|
||||||
|
for index in range(total):
|
||||||
|
frames.append(header + _bbqr_base36(index) + encoded[pos : pos + chunk])
|
||||||
|
pos += chunk
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
|
def bbqr_parse_part(frame_text: str) -> Tuple[str, str, int, int, str]:
|
||||||
|
"""Parse a BBQR frame into ``(encoding, type_code, total, index, payload)``."""
|
||||||
|
frame_text = frame_text.strip()
|
||||||
|
if len(frame_text) < 10 or not frame_text.startswith(_BBQR_PREFIX):
|
||||||
|
raise AnimatedQrError("not a BBQR frame")
|
||||||
|
encoding = frame_text[2]
|
||||||
|
type_code = frame_text[3]
|
||||||
|
if encoding not in ("H", "2", "Z"):
|
||||||
|
raise AnimatedQrError("unknown BBQR encoding")
|
||||||
|
try:
|
||||||
|
total = int(frame_text[4:6], 36)
|
||||||
|
index = int(frame_text[6:8], 36)
|
||||||
|
except ValueError:
|
||||||
|
raise AnimatedQrError("bad BBQR part numbers") from None
|
||||||
|
if total < 1 or not 0 <= index < total:
|
||||||
|
raise AnimatedQrError("bad BBQR part numbers")
|
||||||
|
if index >= _MAX_SESSION_PARTS:
|
||||||
|
raise AnimatedQrError("BBQR part number out of range")
|
||||||
|
return encoding, type_code, total, index, frame_text[8:]
|
||||||
|
|
||||||
|
|
||||||
|
def _bbqr_decode(encoded_parts: Sequence[str], encoding: str) -> bytes:
|
||||||
|
pieces: List[bytes] = []
|
||||||
|
for part in encoded_parts:
|
||||||
|
if encoding == "H":
|
||||||
|
try:
|
||||||
|
pieces.append(bytes.fromhex(part))
|
||||||
|
except ValueError:
|
||||||
|
raise AnimatedQrError("invalid BBQR hex payload") from None
|
||||||
|
continue
|
||||||
|
padding = (8 - (len(part) % 8)) % 8
|
||||||
|
try:
|
||||||
|
pieces.append(base64.b32decode(part + "=" * padding))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise AnimatedQrError("invalid BBQR base32 payload") from None
|
||||||
|
raw = b"".join(pieces)
|
||||||
|
if encoding == "Z":
|
||||||
|
try:
|
||||||
|
inflater = zlib.decompressobj(wbits=-10)
|
||||||
|
out = inflater.decompress(raw, _MAX_MESSAGE_BYTES + 1)
|
||||||
|
except zlib.error:
|
||||||
|
raise AnimatedQrError("invalid BBQR zlib payload") from None
|
||||||
|
if len(out) > _MAX_MESSAGE_BYTES or inflater.unconsumed_tail:
|
||||||
|
raise AnimatedQrError("BBQR payload exceeds the size cap")
|
||||||
|
return out
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Format detection & per-frame identity for the shared debounce
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
FORMAT_LABELS = {
|
||||||
|
"bbqr": "BBQR",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_name(fmt: str) -> str:
|
||||||
|
"""Human-readable name of a wire format for UI labels."""
|
||||||
|
return FORMAT_LABELS.get(fmt, fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_format(text: str) -> Optional[str]:
|
||||||
|
"""Return the wire format of a scanned string, or ``None``."""
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if text.startswith(_BBQR_PREFIX):
|
||||||
|
return "bbqr"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_for_detection(text: str) -> Tuple[str, str, int, int]:
|
||||||
|
"""Parse a frame and return ``(format, session_key, frame_total, index)``.
|
||||||
|
|
||||||
|
``session_key`` identifies the transfer the frame belongs to and drives
|
||||||
|
the shared reset/ignore/accept debounce. Raises
|
||||||
|
:class:`AnimatedQrError` when the text cannot be parsed.
|
||||||
|
"""
|
||||||
|
fmt = detect_format(text)
|
||||||
|
if fmt == "bbqr":
|
||||||
|
encoding, type_code, total, index, _payload = bbqr_parse_part(text)
|
||||||
|
return "bbqr", "bbqr:{}{}:{}".format(encoding, type_code, total), total, index
|
||||||
|
raise FormatNotDetectedError("Not a supported QR transfer format")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Receive session (order-independent assembly)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
class _BbqrSession:
|
||||||
|
def __init__(self):
|
||||||
|
self._total = 0
|
||||||
|
self._encoding = ""
|
||||||
|
self._type_code = ""
|
||||||
|
self._parts: Dict[int, str] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int:
|
||||||
|
return self._total
|
||||||
|
|
||||||
|
@property
|
||||||
|
def received(self) -> int:
|
||||||
|
return len(self._parts)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def done(self) -> bool:
|
||||||
|
return bool(self._total) and len(self._parts) >= self._total
|
||||||
|
|
||||||
|
def add(self, text: str) -> str:
|
||||||
|
encoding, type_code, total, index, payload = bbqr_parse_part(text)
|
||||||
|
if self._total:
|
||||||
|
if (encoding, type_code, total) != (self._encoding, self._type_code, self._total):
|
||||||
|
raise TransferConflictError("BBQR frame header changed")
|
||||||
|
else:
|
||||||
|
self._total = total
|
||||||
|
self._encoding = encoding
|
||||||
|
self._type_code = type_code
|
||||||
|
if index in self._parts:
|
||||||
|
return "dup"
|
||||||
|
self._parts[index] = payload
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
def resolve(self) -> Tuple[str, bool]:
|
||||||
|
ordered = [self._parts[i] for i in range(self._total)]
|
||||||
|
raw = _bbqr_decode(ordered, self._encoding)
|
||||||
|
return _transfer_text(raw), False
|
||||||
|
|
||||||
|
|
||||||
|
def _transfer_text(raw: bytes) -> str:
|
||||||
|
try:
|
||||||
|
return raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raise AnimatedQrError("decoded transfer is not valid UTF-8") from None
|
||||||
|
|
||||||
|
|
||||||
|
class AnimatedQrSession:
|
||||||
|
"""Facade over the per-format receive sessions used by the QR import page."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._inner: Optional[object] = None
|
||||||
|
self._fmt: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def format(self) -> Optional[str]:
|
||||||
|
return self._fmt
|
||||||
|
|
||||||
|
def add_part(self, text: str) -> str:
|
||||||
|
"""Feed one scanned frame; returns ``"ok"``/``"dup"``, raises on bad input."""
|
||||||
|
fmt = detect_format(text)
|
||||||
|
if fmt is None:
|
||||||
|
raise FormatNotDetectedError("Not a supported QR transfer format")
|
||||||
|
if self._inner is None:
|
||||||
|
self._fmt = fmt
|
||||||
|
self._inner = self._make(fmt)
|
||||||
|
elif fmt != self._fmt:
|
||||||
|
raise TransferConflictError(
|
||||||
|
"Switched QR format mid-import ({} -> {})".format(self.format, fmt)
|
||||||
|
)
|
||||||
|
return self._inner.add(text) # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make(fmt: str) -> object:
|
||||||
|
if fmt == "bbqr":
|
||||||
|
return _BbqrSession()
|
||||||
|
raise AssertionError("unknown animated-QR format {}".format(fmt))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int:
|
||||||
|
return self._inner.total if self._inner is not None else 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def received(self) -> int:
|
||||||
|
return self._inner.received if self._inner is not None else 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def done(self) -> bool:
|
||||||
|
return bool(self._inner is not None and self._inner.done)
|
||||||
|
|
||||||
|
def resolve(self) -> Tuple[str, bool]:
|
||||||
|
if self._inner is None:
|
||||||
|
raise AnimatedQrError("no transfer has been received")
|
||||||
|
return self._inner.resolve() # type: ignore[no-any-return]
|
||||||
@@ -6,8 +6,21 @@ The "Check Alive" policy: the single reference timestamp (``date_to_check``)
|
|||||||
against which every will-validity check is evaluated, and the BASIC/ADVANCED
|
against which every will-validity check is evaluated, and the BASIC/ADVANCED
|
||||||
mode rules that decide it.
|
mode rules that decide it.
|
||||||
|
|
||||||
Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the
|
A RELATIVE Check Alive value is anchored according to its SIGN:
|
||||||
postpone/invalidate flow; the decision that it *should* be raised lives here.
|
|
||||||
|
* NEGATIVE (``"-30d"`` / ``"-1y"``): counted BACKWARDS from the delivery date,
|
||||||
|
i.e. ``delivery + N days`` (``-30d`` = 30 days BEFORE the delivery). This is
|
||||||
|
the historical meaning of a relative threshold.
|
||||||
|
* POSITIVE (``"30d"`` / ``"1y"``): counted FORWARD from the inheritance
|
||||||
|
creation date (``min(WillItem.time)`` of the valid will items), i.e.
|
||||||
|
``creation + N days``. The creation date is frozen in the wallet DB, so the
|
||||||
|
reference never drifts with the clock.
|
||||||
|
* When the anchor is unknown (no built will yet, or imported items without a
|
||||||
|
``time``). The reference falls back to *now*.
|
||||||
|
|
||||||
|
Pure, GUI-free. The GUI raises :class:`CheckAliveError` /
|
||||||
|
:class:`CheckAliveAfterDeliveryError` to trigger the postpone/invalidate flow;
|
||||||
|
the decision that they *should* be raised lives here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -19,13 +32,114 @@ from .plugin_base import BalTimestamp
|
|||||||
class CheckAliveError(Exception):
|
class CheckAliveError(Exception):
|
||||||
"""Raised when the "check alive" date is in the past."""
|
"""Raised when the "check alive" date is in the past."""
|
||||||
|
|
||||||
def __init__(self, timestamp_to_check):
|
def __init__(self, timestamp_to_check, reason: str | None = None):
|
||||||
self.timestamp_to_check = timestamp_to_check
|
self.timestamp_to_check = timestamp_to_check
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "Check alive expired please update it: {}".format(
|
message = "Check alive expired please update it: {}".format(
|
||||||
datetime.fromtimestamp(self.timestamp_to_check, tz=timezone.utc).isoformat()
|
datetime.fromtimestamp(self.timestamp_to_check, tz=timezone.utc).isoformat()
|
||||||
)
|
)
|
||||||
|
if self.reason:
|
||||||
|
message += f" ({self.reason})"
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
class CheckAliveAfterDeliveryError(Exception):
|
||||||
|
"""Raised when the "check alive" date is not before the delivery date.
|
||||||
|
|
||||||
|
Deliberately NOT a subclass of :class:`CheckAliveError`: the existing
|
||||||
|
``except CheckAliveError`` handlers invalidate the inheritance on-chain,
|
||||||
|
while a Check Alive that lands on/after the delivery is a configuration
|
||||||
|
error that must only abort the current action.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, timestamp_to_check, delivery_timestamp=None):
|
||||||
|
self.timestamp_to_check = timestamp_to_check
|
||||||
|
self.delivery_timestamp = delivery_timestamp
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
check_date = datetime.fromtimestamp(
|
||||||
|
self.timestamp_to_check, tz=timezone.utc
|
||||||
|
).isoformat()
|
||||||
|
if self.delivery_timestamp is None:
|
||||||
|
return (
|
||||||
|
"Check alive date must be after today and before the delivery "
|
||||||
|
f"date: {check_date}"
|
||||||
|
)
|
||||||
|
delivery_date = datetime.fromtimestamp(
|
||||||
|
self.delivery_timestamp, tz=timezone.utc
|
||||||
|
).isoformat()
|
||||||
|
return (
|
||||||
|
"Check alive date must be after today and before the delivery "
|
||||||
|
f"date: {check_date} >= {delivery_date}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_delivery_date(
|
||||||
|
will_settings: Any,
|
||||||
|
now: float | None = None,
|
||||||
|
built_locktime: float | int | None = None,
|
||||||
|
) -> float | None:
|
||||||
|
"""Return the delivery date every Check Alive calculation is anchored to.
|
||||||
|
|
||||||
|
This is the single source of the delivery reference, shared by
|
||||||
|
:func:`resolve_date_to_check` and :func:`resolve_guard_threshold`:
|
||||||
|
|
||||||
|
* an ABSOLUTE stored locktime is used as-is;
|
||||||
|
* a RELATIVE stored locktime (``"2y"``) is resolved forward from *now*,
|
||||||
|
UNLESS a will has already been built: its frozen delivery date
|
||||||
|
(``built_locktime``, the locktime inside the signed tx) is then
|
||||||
|
authoritative, so an unchanged will never drifts as the clock advances.
|
||||||
|
|
||||||
|
Returns ``None`` when no locktime is stored.
|
||||||
|
"""
|
||||||
|
locktime_raw = will_settings.get("locktime")
|
||||||
|
if locktime_raw is None:
|
||||||
|
return None
|
||||||
|
if BalTimestamp(locktime_raw).unit is not None and built_locktime:
|
||||||
|
return BalTimestamp(int(built_locktime)).to_timestamp(now)
|
||||||
|
return BalTimestamp(locktime_raw).to_timestamp(now)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_relative_check_alive_date(
|
||||||
|
threshold_raw: Any,
|
||||||
|
delivery_date: float | None,
|
||||||
|
creation_date: float | None = None,
|
||||||
|
now: float | None = None,
|
||||||
|
) -> float:
|
||||||
|
"""Resolve a RELATIVE Check Alive value to an absolute timestamp.
|
||||||
|
|
||||||
|
The SIGN of the value selects the anchor (the single calculation rule):
|
||||||
|
|
||||||
|
* negative (``"-30d"``): ``delivery_date + N days`` (N < 0 -> BEFORE the
|
||||||
|
delivery);
|
||||||
|
* positive (``"30d"``): ``creation_date + N days`` (the inheritance
|
||||||
|
creation date);
|
||||||
|
* when the selected anchor is unknown, *now* is used as a fallback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
threshold_raw: the stored ``"<n>d"`` / ``"<n>y"`` value (may be
|
||||||
|
negative).
|
||||||
|
delivery_date: the resolved delivery timestamp (see
|
||||||
|
:func:`resolve_delivery_date`), or ``None``.
|
||||||
|
creation_date: the inheritance creation timestamp
|
||||||
|
(``Will.get_creation_time``), or ``None``.
|
||||||
|
now: overridable clock for tests.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The absolute reference timestamp (float, UNIX seconds).
|
||||||
|
"""
|
||||||
|
threshold = BalTimestamp(threshold_raw)
|
||||||
|
if threshold.unit is None:
|
||||||
|
return threshold.to_timestamp()
|
||||||
|
if threshold.value < 0:
|
||||||
|
anchor = delivery_date
|
||||||
|
else:
|
||||||
|
anchor = creation_date
|
||||||
|
if anchor is None:
|
||||||
|
anchor = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
|
||||||
|
return threshold.to_date(anchor).timestamp()
|
||||||
|
|
||||||
|
|
||||||
def resolve_date_to_check(
|
def resolve_date_to_check(
|
||||||
@@ -33,6 +147,7 @@ def resolve_date_to_check(
|
|||||||
will_settings: Any,
|
will_settings: Any,
|
||||||
now: float | None = None,
|
now: float | None = None,
|
||||||
built_locktime: float | int | None = None,
|
built_locktime: float | int | None = None,
|
||||||
|
creation_date: float | None = None,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Return the reference timestamp for every will-validity check.
|
"""Return the reference timestamp for every will-validity check.
|
||||||
|
|
||||||
@@ -41,22 +156,11 @@ def resolve_date_to_check(
|
|||||||
``check_amounts`` and the locktime-vs-threshold guard.
|
``check_amounts`` and the locktime-vs-threshold guard.
|
||||||
|
|
||||||
* BASIC mode: the Check Alive is hidden and NOT editable, so it must never
|
* BASIC mode: the Check Alive is hidden and NOT editable, so it must never
|
||||||
govern those checks. ``date_to_check`` is set to *now*: every check is
|
govern those checks. ``date_to_check`` is set to *now*.
|
||||||
evaluated against the current moment (the Check Alive effectively does not
|
* ADVANCED mode: an ABSOLUTE stored threshold is returned unchanged; a
|
||||||
exist) while the delivery locktime is still fully enforced.
|
RELATIVE one is resolved by :func:`resolve_relative_check_alive_date`
|
||||||
* ADVANCED mode: the user-controlled stored threshold is used as-is. An
|
(negative -> anchored to the delivery, positive -> anchored to the
|
||||||
ABSOLUTE threshold is returned unchanged; a RELATIVE one (``"30d"``/``"1y"``)
|
inheritance creation date).
|
||||||
means "N days BEFORE the delivery date" and is resolved against the stored
|
|
||||||
locktime (matching the date the settings widget displays), so it stays in
|
|
||||||
lockstep with the built transactions instead of drifting with the clock.
|
|
||||||
|
|
||||||
A RELATIVE stored locktime is resolved against the frozen delivery date of
|
|
||||||
the built will (``built_locktime``, the locktime inside the signed tx) when
|
|
||||||
one exists: the will's real delivery date is authoritative, and resolving
|
|
||||||
the relative locktime from *now* would drift ``date_to_check`` past the
|
|
||||||
frozen tx locktime so an unchanged will wrongly reads as expired (asking to
|
|
||||||
invalidate) every day. Without a built will the legacy forward-from-now
|
|
||||||
resolution is kept.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
is_basic_mode: ``True`` for the SIMPLE / BASIC user type.
|
is_basic_mode: ``True`` for the SIMPLE / BASIC user type.
|
||||||
@@ -65,6 +169,8 @@ def resolve_date_to_check(
|
|||||||
now: overridable clock for tests; defaults to ``datetime.now()``.
|
now: overridable clock for tests; defaults to ``datetime.now()``.
|
||||||
built_locktime: the absolute locktime frozen inside the built will's
|
built_locktime: the absolute locktime frozen inside the built will's
|
||||||
transactions (``None`` when there is no built will yet).
|
transactions (``None`` when there is no built will yet).
|
||||||
|
creation_date: the inheritance creation timestamp
|
||||||
|
(``Will.get_creation_time``); ``None`` falls back to *now*.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The reference timestamp (float, UNIX seconds).
|
The reference timestamp (float, UNIX seconds).
|
||||||
@@ -73,39 +179,113 @@ def resolve_date_to_check(
|
|||||||
return (now if now is not None else datetime.now(tz=timezone.utc).timestamp())
|
return (now if now is not None else datetime.now(tz=timezone.utc).timestamp())
|
||||||
|
|
||||||
threshold = BalTimestamp(will_settings["threshold"])
|
threshold = BalTimestamp(will_settings["threshold"])
|
||||||
# A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery":
|
if threshold.unit is None:
|
||||||
# the settings widget resolves it as real_threshold = locktime - N days.
|
return threshold.to_timestamp()
|
||||||
# Resolving it FORWARD from now (BalTimestamp.to_timestamp) turns
|
delivery_date = resolve_delivery_date(
|
||||||
# date_to_check into a moving target that disagrees with the fixed
|
will_settings, now=now, built_locktime=built_locktime
|
||||||
# locktime of the built transactions (and with the date shown in the UI),
|
)
|
||||||
# which can wrongly mark the will as expired/postponed. Resolve it
|
return resolve_relative_check_alive_date(
|
||||||
# against the delivery date instead.
|
will_settings["threshold"],
|
||||||
if threshold.unit is not None:
|
delivery_date,
|
||||||
locktime_raw = will_settings.get("locktime")
|
creation_date=creation_date,
|
||||||
if locktime_raw is None:
|
now=now,
|
||||||
# No delivery reference to anchor to: fall back to the legacy
|
)
|
||||||
# forward-from-now resolution.
|
|
||||||
return threshold.to_timestamp()
|
|
||||||
locktime_dt = BalTimestamp(locktime_raw).to_date(now)
|
|
||||||
# A RELATIVE stored locktime ("2y") is itself a moving target; when a
|
|
||||||
# will has already been built, its frozen delivery date (the tx
|
|
||||||
# locktime) is the authoritative anchor (see docstring).
|
|
||||||
if BalTimestamp(locktime_raw).unit is not None and built_locktime:
|
|
||||||
locktime_dt = BalTimestamp(int(built_locktime)).to_date(now)
|
|
||||||
return threshold.to_date(locktime_dt, reverse=True).timestamp()
|
|
||||||
return threshold.to_timestamp()
|
|
||||||
|
|
||||||
|
|
||||||
def check_alive_expired(
|
def resolve_guard_threshold(
|
||||||
is_basic_mode: bool, date_to_check: float, now: float | None = None
|
is_basic_mode: bool,
|
||||||
) -> bool:
|
will_settings: Any,
|
||||||
"""True when the Check Alive guard should fire (``CheckAliveError``).
|
now: float | None = None,
|
||||||
|
creation_date: float | None = None,
|
||||||
|
) -> float | None:
|
||||||
|
"""Resolve the "locktime is lower than threshold" guard's reference.
|
||||||
|
|
||||||
Only ADVANCED mode can be "expired": in BASIC mode the Check Alive is inert
|
The guard compares the stored settings on ONE reference frame: the
|
||||||
by construction, so a passed check-alive date must never force a postpone or
|
delivery (``locktime``, kept as at the call site) against this threshold.
|
||||||
rewrite of the will.
|
|
||||||
|
Unlike :func:`resolve_date_to_check` -- which may be *anchored* to the
|
||||||
|
built will's frozen tx locktime so that an unchanged will never reads as
|
||||||
|
expired -- this helper resolves the threshold from the **stored settings
|
||||||
|
alone** (no ``built_locktime``). Otherwise, when the stored relative
|
||||||
|
locktime is shorter than the frozen locktime of an old (still valid) built
|
||||||
|
will (e.g. the delivery was shortened from ``"2y"`` to ``"1y"``), the guard
|
||||||
|
would compare the fresh "1y" locktime against the old will's anchored
|
||||||
|
threshold and wrongly fire, even though locktime > threshold by the
|
||||||
|
settings themselves.
|
||||||
|
|
||||||
|
* BASIC mode: no threshold exists. Returns ``None`` and the caller falls
|
||||||
|
back to comparing the locktime against ``date_to_check`` (= now), so its
|
||||||
|
behaviour is unchanged.
|
||||||
|
* ADVANCED mode with an ABSOLUTE threshold: returns the stored threshold
|
||||||
|
as-is.
|
||||||
|
* ADVANCED mode with a RELATIVE threshold: resolved by
|
||||||
|
:func:`resolve_relative_check_alive_date` (negative -> delivery anchor,
|
||||||
|
positive -> inheritance creation date), using *now* for the delivery
|
||||||
|
reading and never a built tx.
|
||||||
|
|
||||||
|
Returns ``None`` when there is no threshold to enforce (BASIC mode or a
|
||||||
|
missing stored value).
|
||||||
"""
|
"""
|
||||||
if is_basic_mode:
|
if is_basic_mode:
|
||||||
|
return None
|
||||||
|
threshold_raw = will_settings.get("threshold")
|
||||||
|
if threshold_raw is None:
|
||||||
|
return None
|
||||||
|
threshold = BalTimestamp(threshold_raw)
|
||||||
|
if threshold.unit is None:
|
||||||
|
return threshold.to_timestamp()
|
||||||
|
delivery_date = resolve_delivery_date(will_settings, now=now, built_locktime=None)
|
||||||
|
return resolve_relative_check_alive_date(
|
||||||
|
threshold_raw,
|
||||||
|
delivery_date,
|
||||||
|
creation_date=creation_date,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_check_alive_date_valid(
|
||||||
|
date_to_check: float | None,
|
||||||
|
delivery_date: float | None,
|
||||||
|
now: float | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""True when the Check Alive date is inside ``(now, delivery_date)``.
|
||||||
|
|
||||||
|
The single validity rule for the Check Alive reference: it must be AFTER
|
||||||
|
today and BEFORE the delivery. A ``None`` ``delivery_date`` skips the
|
||||||
|
upper bound (no delivery is known yet).
|
||||||
|
"""
|
||||||
|
if date_to_check is None:
|
||||||
return False
|
return False
|
||||||
current = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
|
current = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
|
||||||
return date_to_check < current
|
if date_to_check < current:
|
||||||
|
return False
|
||||||
|
if delivery_date is not None and date_to_check >= delivery_date:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_check_alive_valid(
|
||||||
|
is_basic_mode: bool,
|
||||||
|
date_to_check: float | None,
|
||||||
|
delivery_date: float | None = None,
|
||||||
|
now: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Raise when the Check Alive date is not valid.
|
||||||
|
|
||||||
|
Only ADVANCED mode can be invalid: in BASIC mode the Check Alive is inert
|
||||||
|
by construction, so a passed check-alive date must never force a postpone
|
||||||
|
or rewrite of the will.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CheckAliveError: the date is in the past.
|
||||||
|
CheckAliveAfterDeliveryError: the date is on/after the delivery.
|
||||||
|
"""
|
||||||
|
if is_basic_mode:
|
||||||
|
return
|
||||||
|
if date_to_check is None:
|
||||||
|
return
|
||||||
|
current = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
|
||||||
|
if date_to_check < current:
|
||||||
|
raise CheckAliveError(date_to_check)
|
||||||
|
if delivery_date is not None and date_to_check >= delivery_date:
|
||||||
|
raise CheckAliveAfterDeliveryError(date_to_check, delivery_date)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ from electrum.util import (
|
|||||||
write_json_file,
|
write_json_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .util import Util
|
from .util import Util, copy_structure
|
||||||
from .willexecutors import Willexecutors
|
from .willexecutors import Willexecutors
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -321,40 +321,14 @@ def get_change_output(wallet, in_amount, out_amount, fee):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _json_safe(value, _path="heirs", _depth=0):
|
def _json_safe(value, _path="heirs"):
|
||||||
"""Return a JSON-serializable deep copy of *value*.
|
"""Backward-compatible alias of :func:`bal.core.util.copy_structure`.
|
||||||
|
|
||||||
The wallet DB persists the heirs dict via ``json_db.put``, which calls
|
Kept so call sites that imported ``_json_safe`` directly keep working; the
|
||||||
``copy.deepcopy`` on the value. If any nested element is a live runtime
|
actual implementation (a JSON-safe, deepcopy-free clone) lives in
|
||||||
object (e.g. one holding a ``threading.RLock``), deepcopy raises
|
``bal.core.util`` so every copy path shares one code base.
|
||||||
``TypeError: cannot pickle '_thread.RLock' object`` and the whole
|
|
||||||
"Build will" task fails.
|
|
||||||
|
|
||||||
To make persistence robust we coerce the structure to plain
|
|
||||||
JSON-compatible types (dict / list / str / int / float / bool / None).
|
|
||||||
Anything else is converted to ``str(value)`` and logged with its path so
|
|
||||||
the offending field can be identified, instead of crashing the task.
|
|
||||||
"""
|
"""
|
||||||
# Primitive JSON scalars are kept as-is.
|
return copy_structure(value, _path=_path)
|
||||||
if value is None or isinstance(value, (bool, int, float, str)):
|
|
||||||
return value
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return {
|
|
||||||
str(k): _json_safe(v, "{}[{!r}]".format(_path, k), _depth + 1)
|
|
||||||
for k, v in value.items()
|
|
||||||
}
|
|
||||||
if isinstance(value, (list, tuple)):
|
|
||||||
return [
|
|
||||||
_json_safe(v, "{}[{}]".format(_path, i), _depth + 1)
|
|
||||||
for i, v in enumerate(value)
|
|
||||||
]
|
|
||||||
# Unexpected runtime object: do not let it reach deepcopy. Log where it
|
|
||||||
# was found so the real source can be fixed, then store a safe string.
|
|
||||||
_logger.error(
|
|
||||||
"heirs.save: non-serializable value at {} (type={}); coercing to str. "
|
|
||||||
"value={!r}".format(_path, type(value).__name__, value)
|
|
||||||
)
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
class Heirs(dict, Logger):
|
class Heirs(dict, Logger):
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ def _checkbdy(s: str, pos: int, appendix: str) -> Tuple[int, str]:
|
|||||||
|
|
||||||
|
|
||||||
def normalize_locktime_raw_text(
|
def normalize_locktime_raw_text(
|
||||||
text: str, pos: int
|
text: str, pos: int, allow_negative: bool = False
|
||||||
) -> Tuple[str, bool, bool, int]:
|
) -> Tuple[str, bool, bool, int]:
|
||||||
"""Sanitise the RAW locktime field text.
|
"""Sanitise the RAW locktime field text.
|
||||||
|
|
||||||
@@ -117,6 +117,9 @@ def normalize_locktime_raw_text(
|
|||||||
Args:
|
Args:
|
||||||
text: the raw field text.
|
text: the raw field text.
|
||||||
pos: the cursor position within ``text``.
|
pos: the cursor position within ``text``.
|
||||||
|
allow_negative: when ``True`` a single leading ``-`` is kept (used by
|
||||||
|
the Check Alive field, whose sign selects the anchor date). The
|
||||||
|
delivery (locktime) field never accepts a sign.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised
|
``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised
|
||||||
@@ -124,11 +127,17 @@ def normalize_locktime_raw_text(
|
|||||||
"""
|
"""
|
||||||
text = text.strip()
|
text = text.strip()
|
||||||
chars = "0123456789dy"
|
chars = "0123456789dy"
|
||||||
|
if allow_negative:
|
||||||
|
chars += "-"
|
||||||
pos = len("".join([i for i in text[:pos] if i in chars]))
|
pos = len("".join([i for i in text[:pos] if i in chars]))
|
||||||
s = "".join([i for i in text if i in chars])
|
s = "".join([i for i in text if i in chars])
|
||||||
isdays = False
|
isdays = False
|
||||||
isyears = False
|
isyears = False
|
||||||
|
|
||||||
|
negative = allow_negative and "-" in s
|
||||||
|
if negative:
|
||||||
|
s = s.replace("-", "")
|
||||||
|
|
||||||
pos, s = _checkbdy(s, pos, "d")
|
pos, s = _checkbdy(s, pos, "d")
|
||||||
pos, s = _checkbdy(s, pos, "y")
|
pos, s = _checkbdy(s, pos, "y")
|
||||||
|
|
||||||
@@ -141,6 +150,10 @@ def normalize_locktime_raw_text(
|
|||||||
s = replace_dy_suffixes(s) + "d"
|
s = replace_dy_suffixes(s) + "d"
|
||||||
if isyears:
|
if isyears:
|
||||||
s = replace_dy_suffixes(s) + "y"
|
s = replace_dy_suffixes(s) + "y"
|
||||||
|
|
||||||
|
if negative:
|
||||||
|
s = "-" + s
|
||||||
|
pos = max(pos, 1)
|
||||||
return s, isdays, isyears, pos
|
return s, isdays, isyears, pos
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ This module performs **no** GUI work and imports nothing from PyQt / electrum.gu
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from electrum import constants, json_db
|
from electrum import constants, json_db
|
||||||
from electrum.logging import get_logger
|
from electrum.logging import get_logger
|
||||||
@@ -32,6 +32,8 @@ from electrum.plugin import BasePlugin
|
|||||||
from electrum.transaction import tx_from_any
|
from electrum.transaction import tx_from_any
|
||||||
from electrum.util import classproperty
|
from electrum.util import classproperty
|
||||||
|
|
||||||
|
from ..i18n import N_, _
|
||||||
|
|
||||||
_logger = get_logger(__name__)
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -109,7 +111,9 @@ def get_will(x):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Electrum >= 4.8.0
|
# Electrum >= 4.8.0
|
||||||
from electrum.stored_dict import register_name as _electrum_register_name # 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):
|
def _register_will_dict(name, method, _type=None):
|
||||||
"""Register a plugin dict in the wallet DB (Electrum >= 4.8.0 API)."""
|
"""Register a plugin dict in the wallet DB (Electrum >= 4.8.0 API)."""
|
||||||
@@ -132,12 +136,20 @@ class BalConfig:
|
|||||||
|
|
||||||
Wraps ``config.get`` / ``config.set_key`` and supplies a default value
|
Wraps ``config.get`` / ``config.set_key`` and supplies a default value
|
||||||
when the key is missing.
|
when the key is missing.
|
||||||
|
|
||||||
|
``translatable=True`` is for a default *text* (marked with ``N_()``) that
|
||||||
|
should follow the GUI language. The English default is what gets stored,
|
||||||
|
and :meth:`get` translates it when it is read, so the stored value never
|
||||||
|
depends on the GUI language. A text the user wrote is returned unchanged.
|
||||||
|
Only for texts that are shown: a text that is also used to recognise
|
||||||
|
stored data (e.g. ``HISTORY_LABEL``) must stay non-translatable.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config, name, default):
|
def __init__(self, config, name, default, translatable=False):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.name = name
|
self.name = name
|
||||||
self.default = default
|
self.default = default
|
||||||
|
self.translatable = translatable
|
||||||
|
|
||||||
def get(self, default=None):
|
def get(self, default=None):
|
||||||
"""Return the stored value, falling back to ``default`` then ``self.default``."""
|
"""Return the stored value, falling back to ``default`` then ``self.default``."""
|
||||||
@@ -147,10 +159,20 @@ class BalConfig:
|
|||||||
v = default
|
v = default
|
||||||
else:
|
else:
|
||||||
v = self.default
|
v = self.default
|
||||||
|
if self.translatable and v == self.default:
|
||||||
|
return _(v)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
def localized_default(self):
|
||||||
|
"""Return the default value, translated if the setting is translatable."""
|
||||||
|
return _(self.default) if self.translatable else self.default
|
||||||
|
|
||||||
def set(self, value, save=True):
|
def set(self, value, save=True):
|
||||||
"""Persist ``value`` for this key."""
|
"""Persist ``value`` for this key."""
|
||||||
|
if self.translatable and value == self.localized_default():
|
||||||
|
# The translated default is stored as its English original, so
|
||||||
|
# it keeps following the GUI language (see get()).
|
||||||
|
value = self.default
|
||||||
self.config.set_key(self.name, value, save=save)
|
self.config.set_key(self.name, value, save=save)
|
||||||
|
|
||||||
|
|
||||||
@@ -241,6 +263,10 @@ class BalPlugin(BasePlugin):
|
|||||||
# the wallet's local history. May contain the "{willexecutor}" token,
|
# the wallet's local history. May contain the "{willexecutor}" token,
|
||||||
# which is replaced with the will-executor URL of each will item at
|
# which is replaced with the will-executor URL of each will item at
|
||||||
# save time.
|
# 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(
|
self.HISTORY_LABEL = BalConfig(
|
||||||
config,
|
config,
|
||||||
"bal_history_label",
|
"bal_history_label",
|
||||||
@@ -279,6 +305,12 @@ class BalPlugin(BasePlugin):
|
|||||||
# stay display-only outside the wizard unless the user opts in.
|
# stay display-only outside the wizard unless the user opts in.
|
||||||
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
|
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
|
||||||
|
|
||||||
|
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in characters,
|
||||||
|
# used per QR frame when exporting/importing a will through the QR
|
||||||
|
# channel. The export dialog seeds its own size selector from this
|
||||||
|
# value (default 150, small QR, low-resolution cameras).
|
||||||
|
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
|
||||||
|
|
||||||
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
|
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
|
||||||
# exported .ics calendar should contain. Each reminder becomes its own
|
# exported .ics calendar should contain. Each reminder becomes its own
|
||||||
# VEVENT (its own date in the calendar). The dates are spread uniformly
|
# VEVENT (its own date in the calendar). The dates are spread uniformly
|
||||||
@@ -319,13 +351,20 @@ class BalPlugin(BasePlugin):
|
|||||||
self.WELIST_SERVER = BalConfig(
|
self.WELIST_SERVER = BalConfig(
|
||||||
config, "bal_welist_server", "https://welist.bitcoin-after.life/"
|
config, "bal_welist_server", "https://welist.bitcoin-after.life/"
|
||||||
)
|
)
|
||||||
|
# Calendar (.ics) texts: only shown, never used to recognise data, so
|
||||||
|
# their defaults follow the GUI language (translatable=True). The
|
||||||
|
# $tokens must survive translation.
|
||||||
self.EVENT_DESCRIPTION = BalConfig(
|
self.EVENT_DESCRIPTION = BalConfig(
|
||||||
config,
|
config,
|
||||||
"bal_event_description",
|
"bal_event_description",
|
||||||
"BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete",
|
N_("BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete"),
|
||||||
|
translatable=True,
|
||||||
)
|
)
|
||||||
self.EVENT_SUMMARY = BalConfig(
|
self.EVENT_SUMMARY = BalConfig(
|
||||||
config, "bal_event_summary", "BAL -Will execution of $wallet_name"
|
config,
|
||||||
|
"bal_event_summary",
|
||||||
|
N_("BAL -Will execution of $wallet_name"),
|
||||||
|
translatable=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default will-executor servers, keyed by network. These addresses are
|
# Default will-executor servers, keyed by network. These addresses are
|
||||||
@@ -458,22 +497,35 @@ class BalPlugin(BasePlugin):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def default_will_settings_absolute():
|
def default_will_settings_absolute():
|
||||||
"""Convert the default relative dates into absolute timestamps (from today)."""
|
"""Convert the default relative dates into absolute timestamps.
|
||||||
|
|
||||||
|
The Check Alive anchor follows its sign (see ``bal.core.checkalive``):
|
||||||
|
a negative default counts back from the delivery, a positive one counts
|
||||||
|
forward from today (the inheritance creation date).
|
||||||
|
"""
|
||||||
relative_dates = BalPlugin.default_will_settings_relative()
|
relative_dates = BalPlugin.default_will_settings_relative()
|
||||||
today = datetime.now(tz=timezone.utc).date()
|
today = datetime.now(tz=timezone.utc).date()
|
||||||
dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc)
|
dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
threshold = (
|
locktime_dt = dt + timedelta(
|
||||||
dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days())
|
days=BalTimestamp(relative_dates["locktime"]).duration_to_days()
|
||||||
).timestamp()
|
)
|
||||||
locktime = (
|
threshold = BalTimestamp(relative_dates["threshold"])
|
||||||
dt + timedelta(days=BalTimestamp(relative_dates["locktime"]).duration_to_days())
|
if threshold.unit is not None and threshold.value < 0:
|
||||||
).timestamp()
|
anchor_dt = locktime_dt
|
||||||
return {"threshold": threshold, "locktime": locktime}
|
else:
|
||||||
|
anchor_dt = dt
|
||||||
|
threshold_dt = anchor_dt + timedelta(days=threshold.duration_to_days())
|
||||||
|
return {"threshold": threshold_dt.timestamp(), "locktime": locktime_dt.timestamp()}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def default_will_settings_relative():
|
def default_will_settings_relative():
|
||||||
"""Default relative dates: 30 days threshold, 1 year locktime."""
|
"""Default relative dates: 30 days BEFORE the delivery, 1 year locktime.
|
||||||
return {"threshold": "30d", "locktime": "1y"}
|
|
||||||
|
The Check Alive default is NEGATIVE on purpose: it keeps counting back
|
||||||
|
from the delivery date, preserving the historical "check alive 30 days
|
||||||
|
before delivery" behaviour.
|
||||||
|
"""
|
||||||
|
return {"threshold": "-30d", "locktime": "1y"}
|
||||||
|
|
||||||
|
|
||||||
class BalTimestamp:
|
class BalTimestamp:
|
||||||
|
|||||||
80
bal/core/qrtransfer.py
Normal file
80
bal/core/qrtransfer.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
bal.core.qrtransfer
|
||||||
|
===================
|
||||||
|
|
||||||
|
GUI-free helpers for moving BAL will data between devices via QR codes or
|
||||||
|
the Electrum ``audio_modem`` plugin (see ``PLAN_QR_TRANSFER.md``).
|
||||||
|
|
||||||
|
Scope
|
||||||
|
-----
|
||||||
|
* converts will transactions into a compact ``transfer_string``
|
||||||
|
(newline-joined serialized transactions, optionally zlib + base64
|
||||||
|
compressed). The transfer string is the payload carried by BBQR QR frames
|
||||||
|
(``bal.core.animated_qr``).
|
||||||
|
|
||||||
|
The audio-modem channel carries the whole transfer string in a single blob
|
||||||
|
(PLAN_QR_TRANSFER.md section 4.4), so callers only use
|
||||||
|
:func:`encode_transfer` / :func:`decode_transfer`.
|
||||||
|
|
||||||
|
This module never imports Qt or any Electrum GUI code (house rule).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
# 4 standard presets (label, payload budget per QR). Ordered from
|
||||||
|
# low-resolution cameras to high-resolution cameras (owner decision D5).
|
||||||
|
CHUNK_PRESETS = (
|
||||||
|
("Small - ~150 bytes/QR (low-res cameras)", 150),
|
||||||
|
("Medium - ~400 bytes/QR", 400),
|
||||||
|
("Large - ~900 bytes/QR", 900),
|
||||||
|
("XL - ~1800 bytes/QR (high-res cameras)", 1800),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_transfer(tx_strings, compress=False):
|
||||||
|
"""Join serialized transaction strings into a transfer string.
|
||||||
|
|
||||||
|
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
|
||||||
|
the whole bundle shrinks before being printed/scanned.
|
||||||
|
"""
|
||||||
|
return __compress("\n".join(tx_strings), enabled=compress)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_transfer(transfer_string, compressed):
|
||||||
|
"""Inverse of :func:`encode_transfer`.
|
||||||
|
|
||||||
|
Returns the list of serialized transaction strings; empty frames are
|
||||||
|
dropped so a trailing newline (or an empty payload) cannot produce an
|
||||||
|
empty trailing element.
|
||||||
|
"""
|
||||||
|
text = __decompress(transfer_string, enabled=compressed)
|
||||||
|
return [part for part in text.split("\n") if part]
|
||||||
|
|
||||||
|
|
||||||
|
def preset_index_for_chunk_size(chunk_size):
|
||||||
|
"""Return the :data:`CHUNK_PRESETS` index whose budget best matches a size."""
|
||||||
|
best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1])
|
||||||
|
for index, (_label, budget) in enumerate(CHUNK_PRESETS):
|
||||||
|
diff = abs(chunk_size - budget)
|
||||||
|
if diff < best_diff:
|
||||||
|
best, best_diff = index, diff
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Internals
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def __compress(text, *, enabled):
|
||||||
|
if not enabled:
|
||||||
|
return text
|
||||||
|
return base64.b64encode(zlib.compress(text.encode("utf-8"))).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def __decompress(text, *, enabled):
|
||||||
|
if not enabled:
|
||||||
|
return text
|
||||||
|
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
|
||||||
@@ -147,6 +147,7 @@ def build_ics_reminders(
|
|||||||
num_reminders: int = 3,
|
num_reminders: int = 3,
|
||||||
now: Optional[datetime] = None,
|
now: Optional[datetime] = None,
|
||||||
threshold: Optional[datetime] = None,
|
threshold: Optional[datetime] = None,
|
||||||
|
reminder_suffix: str = "(reminder {idx}/{total})",
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Build the ``.ics`` content with one VEVENT per reminder date.
|
"""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).
|
num_reminders: requested reminder count (ADVANCED mode only).
|
||||||
now: "today" reference; defaults to ``datetime.now()``.
|
now: "today" reference; defaults to ``datetime.now()``.
|
||||||
threshold: check-alive date (ADVANCED mode only; required there).
|
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:
|
Returns:
|
||||||
The ``.ics`` content string, or ``None`` when no reminder falls in the
|
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.
|
# The visible date of this event: "offset" days before the deadline.
|
||||||
event_dt = format_time(locktime - timedelta(days=offset))
|
event_dt = format_time(locktime - timedelta(days=offset))
|
||||||
# Suffix the summary so the N events are easy to tell apart.
|
# 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([
|
lines.extend([
|
||||||
"BEGIN:VEVENT",
|
"BEGIN:VEVENT",
|
||||||
# Offset in the UID keeps each event unique (no merging).
|
# Offset in the UID keeps each event unique (no merging).
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ import bisect
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
||||||
|
from electrum.logging import get_logger
|
||||||
from electrum.transaction import PartialTxOutput
|
from electrum.transaction import PartialTxOutput
|
||||||
|
|
||||||
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
# Bitcoin consensus rule: an nLockTime value strictly below this threshold is
|
# Bitcoin consensus rule: an nLockTime value strictly below this threshold is
|
||||||
# interpreted as a *block height*, otherwise it is interpreted as a *UNIX
|
# interpreted as a *block height*, otherwise it is interpreted as a *UNIX
|
||||||
# timestamp*.
|
# timestamp*.
|
||||||
@@ -35,6 +38,41 @@ from electrum.transaction import PartialTxOutput
|
|||||||
LOCKTIME_THRESHOLD = 500000000
|
LOCKTIME_THRESHOLD = 500000000
|
||||||
|
|
||||||
|
|
||||||
|
def copy_structure(value, _path="copy"):
|
||||||
|
"""Return a JSON-serializable deep copy of *value*.
|
||||||
|
|
||||||
|
This is the ad-hoc, deepcopy-free stand-in used every time the plugin needs
|
||||||
|
an independent copy of a plain-data structure (heirs dicts, will-executor
|
||||||
|
dicts, status tables). It recursively clones dict / list / tuple values
|
||||||
|
while leaving JSON scalars (str / int / float / bool / None) as-is.
|
||||||
|
|
||||||
|
If any nested element is a live runtime object (e.g. one holding a
|
||||||
|
``threading.RLock``), ``copy.deepcopy`` would raise
|
||||||
|
``TypeError: cannot pickle '_thread.RLock' object``; instead we coerce the
|
||||||
|
offending value to ``str(value)`` and log it with its path so the source
|
||||||
|
field can be identified, without crashing the caller.
|
||||||
|
"""
|
||||||
|
# Primitive JSON scalars are kept as-is.
|
||||||
|
if value is None or isinstance(value, (bool, int, float, str)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
str(k): copy_structure(v, "{}[{!r}]".format(_path, k))
|
||||||
|
for k, v in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [
|
||||||
|
copy_structure(v, "{}[{}]".format(_path, i)) for i, v in enumerate(value)
|
||||||
|
]
|
||||||
|
# Unexpected runtime object: do not let it reach deepcopy. Log where it
|
||||||
|
# was found so the real source can be fixed, then store a safe string.
|
||||||
|
_logger.error(
|
||||||
|
"copy_structure: non-serializable value at {} (type={}); coercing to "
|
||||||
|
"str. value={!r}".format(_path, type(value).__name__, value)
|
||||||
|
)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
class Util:
|
class Util:
|
||||||
"""Namespace of static helpers (kept as a class to preserve the original
|
"""Namespace of static helpers (kept as a class to preserve the original
|
||||||
``Util.method(...)`` call sites used throughout the plugin)."""
|
``Util.method(...)`` call sites used throughout the plugin)."""
|
||||||
|
|||||||
296
bal/core/will.py
296
bal/core/will.py
@@ -26,10 +26,9 @@ The status flags themselves (the source of truth) stay here; only the mapping
|
|||||||
"status -> colour" now lives in the GUI layer. No behaviour changed.
|
"status -> colour" now lives in the GUI layer. No behaviour changed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from electrum.i18n import _
|
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
||||||
from electrum.logging import Logger, get_logger
|
from electrum.logging import Logger, get_logger
|
||||||
from electrum.transaction import (
|
from electrum.transaction import (
|
||||||
PartialTransaction,
|
PartialTransaction,
|
||||||
@@ -44,8 +43,9 @@ from electrum.util import (
|
|||||||
bfh,
|
bfh,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..i18n import N_, _
|
||||||
from .heirs import WillExecutorFeeTooHighException
|
from .heirs import WillExecutorFeeTooHighException
|
||||||
from .util import Util
|
from .util import Util, copy_structure
|
||||||
from .willexecutors import Willexecutors
|
from .willexecutors import Willexecutors
|
||||||
|
|
||||||
MIN_LOCKTIME = 1
|
MIN_LOCKTIME = 1
|
||||||
@@ -143,7 +143,7 @@ class Will:
|
|||||||
willitems = {}
|
willitems = {}
|
||||||
for wid in will:
|
for wid in will:
|
||||||
Will.add_info_from_will(will, wid, wallet)
|
Will.add_info_from_will(will, wid, wallet)
|
||||||
willitems[wid] = WillItem(will[wid])
|
willitems[wid] = WillItem(will[wid], wallet=wallet)
|
||||||
will = willitems
|
will = willitems
|
||||||
errors = {}
|
errors = {}
|
||||||
for wid in will:
|
for wid in will:
|
||||||
@@ -165,7 +165,7 @@ class Will:
|
|||||||
outputs = will[wid].tx.outputs()
|
outputs = will[wid].tx.outputs()
|
||||||
ow = will[wid]
|
ow = will[wid]
|
||||||
ow.normalize_locktime(others_input)
|
ow.normalize_locktime(others_input)
|
||||||
will[wid] = WillItem(ow.to_dict())
|
will[wid] = ow.copy()
|
||||||
|
|
||||||
for i in range(0, len(outputs)):
|
for i in range(0, len(outputs)):
|
||||||
Will.change_input(
|
Will.change_input(
|
||||||
@@ -465,7 +465,7 @@ class Will:
|
|||||||
continue
|
continue
|
||||||
utxo_str = utxo.prevout.to_str()
|
utxo_str = utxo.prevout.to_str()
|
||||||
if utxo_str in prevout_to_spend:
|
if utxo_str in prevout_to_spend:
|
||||||
balance += inputs[utxo_str][0][2].value_sats()
|
balance += utxo.value_sats()
|
||||||
utxo_to_spend.append(utxo)
|
utxo_to_spend.append(utxo)
|
||||||
_logger.debug("utxo to spend: {}".format(utxo_to_spend))
|
_logger.debug("utxo to spend: {}".format(utxo_to_spend))
|
||||||
if len(utxo_to_spend) > 0:
|
if len(utxo_to_spend) > 0:
|
||||||
@@ -848,6 +848,48 @@ class Will:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error(f"save_valid_transactions_to_history failed: {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
|
@staticmethod
|
||||||
def _add_transaction_to_history(wallet, tx, txid):
|
def _add_transaction_to_history(wallet, tx, txid):
|
||||||
"""Store *tx* into the wallet's local history via ``adb``.
|
"""Store *tx* into the wallet's local history via ``adb``.
|
||||||
@@ -930,6 +972,25 @@ class Will:
|
|||||||
def get_min_locktime(will,default_value=None):
|
def get_min_locktime(will,default_value=None):
|
||||||
return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value)
|
return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_creation_time(will):
|
||||||
|
"""Return the inheritance creation timestamp (oldest VALID will item).
|
||||||
|
|
||||||
|
A positive relative Check Alive value counts forward from this date.
|
||||||
|
The per-item ``time`` is stamped when the will is built and persisted
|
||||||
|
in the wallet DB, so the anchor does not drift with the clock. Only
|
||||||
|
VALID items are considered; imported items without a ``time`` are
|
||||||
|
ignored. Returns ``None`` when no VALID item carries a timestamp.
|
||||||
|
"""
|
||||||
|
return min(
|
||||||
|
(
|
||||||
|
v.time
|
||||||
|
for v in will.values()
|
||||||
|
if v.get_status('VALID') and v.time is not None
|
||||||
|
),
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1214,9 +1275,9 @@ class Will:
|
|||||||
|
|
||||||
if Util.parse_locktime_string(heirs[h][2]) >= check_date:
|
if Util.parse_locktime_string(heirs[h][2]) >= check_date:
|
||||||
count_heirs += 1
|
count_heirs += 1
|
||||||
if h not in heirs_found:
|
if h not in heirs_found:
|
||||||
_logger.debug(f"heir: {h} not found")
|
_logger.debug(f"heir: {h} not found")
|
||||||
raise HeirNotFoundException(h)
|
raise HeirNotFoundException(h)
|
||||||
if not count_heirs:
|
if not count_heirs:
|
||||||
raise NoHeirsException("there are not valid heirs")
|
raise NoHeirsException("there are not valid heirs")
|
||||||
if self_willexecutor and no_willexecutor == 0:
|
if self_willexecutor and no_willexecutor == 0:
|
||||||
@@ -1231,6 +1292,34 @@ class Will:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def format_status_history(status: str) -> str:
|
||||||
|
"""Return the saved status history of a will item in the GUI language.
|
||||||
|
|
||||||
|
``WillItem.status`` is a dot-separated history such as
|
||||||
|
``".Signed.Pushed.NOT Valid"``. It is stored in English (see
|
||||||
|
``WillItem.set_status``) and translated only here, for display. A token
|
||||||
|
that is not a known English label is shown unchanged: this keeps working
|
||||||
|
the histories written by older versions, which stored the label already
|
||||||
|
translated (for example ``"Firmato"``).
|
||||||
|
"""
|
||||||
|
labels = {label for label, _flag in WillItem.STATUS_DEFAULT.values()}
|
||||||
|
labels.update(WillItem.LEGACY_STATUS_LABELS)
|
||||||
|
|
||||||
|
def translate(token):
|
||||||
|
# "ERROR!!!" is glued to the end of the history when an item has no
|
||||||
|
# id (see WillItem.__init__): keep it, translate what comes before.
|
||||||
|
suffix = ""
|
||||||
|
if token.endswith("ERROR!!!"):
|
||||||
|
token, suffix = token[: -len("ERROR!!!")], "ERROR!!!"
|
||||||
|
if token in labels:
|
||||||
|
token = _(token)
|
||||||
|
elif token.startswith("NOT ") and token[len("NOT "):] in labels:
|
||||||
|
token = _("NOT {}").format(_(token[len("NOT "):]))
|
||||||
|
return token + suffix
|
||||||
|
|
||||||
|
return ".".join(translate(token) for token in status.split("."))
|
||||||
|
|
||||||
|
|
||||||
class WillItem(Logger):
|
class WillItem(Logger):
|
||||||
# Default status flags for an inheritance transaction.
|
# Default status flags for an inheritance transaction.
|
||||||
# Each entry maps an internal status key to [human-readable label, default
|
# Each entry maps an internal status key to [human-readable label, default
|
||||||
@@ -1244,28 +1333,68 @@ class WillItem(Logger):
|
|||||||
# * "UPDATED" was added: the transaction was spendable AND valid, and a new
|
# * "UPDATED" was added: the transaction was spendable AND valid, and a new
|
||||||
# transaction replaces it while keeping the SAME locktime and SAME heirs.
|
# transaction replaces it while keeping the SAME locktime and SAME heirs.
|
||||||
# UPDATED keeps the VALID flag (see set_status).
|
# UPDATED keeps the VALID flag (see set_status).
|
||||||
|
#
|
||||||
|
# The labels are marked with N_() but stay English here: they are written
|
||||||
|
# into the saved status history (see set_status) and translated only when
|
||||||
|
# shown, by format_status_history().
|
||||||
STATUS_DEFAULT = {
|
STATUS_DEFAULT = {
|
||||||
"ANTICIPATED": ["Anticipated", False],
|
"ANTICIPATED": [N_("Anticipated"), False],
|
||||||
"BROADCASTED": ["Broadcasted", False],
|
"BROADCASTED": [N_("Broadcasted"), False],
|
||||||
"CHECKED": ["Checked", False],
|
"CHECKED": [N_("Checked"), False],
|
||||||
"CHECK_FAIL": ["Check Failed", False],
|
"CHECK_FAIL": [N_("Check Failed"), False],
|
||||||
"COMPLETE": ["Signed", False],
|
"COMPLETE": [N_("Signed"), False],
|
||||||
"CONFIRMED": ["Confirmed", False],
|
"CONFIRMED": [N_("Confirmed"), False],
|
||||||
"ERROR": ["Error", False],
|
"ERROR": [N_("Error"), False],
|
||||||
"EXPIRED": ["Expired", False],
|
"EXPIRED": [N_("Expired"), False],
|
||||||
"EXPORTED": ["Exported", False],
|
"EXPORTED": [N_("Exported"), False],
|
||||||
"IMPORTED": ["Imported", False],
|
"IMPORTED": [N_("Imported"), False],
|
||||||
"INVALIDATED": ["Invalidated", False],
|
"INVALIDATED": [N_("Invalidated"), False],
|
||||||
"MEMPOOL": ["Mempool", False],
|
"MEMPOOL": [N_("Mempool"), False],
|
||||||
"PUSH_FAIL": ["Push failed", False],
|
"PUSH_FAIL": [N_("Push failed"), False],
|
||||||
"PUSHED": ["Pushed", False],
|
"PUSHED": [N_("Pushed"), False],
|
||||||
"PARTIALLY_SIGNED": ["Partially Signed", False],
|
"PARTIALLY_SIGNED": [N_("Partially Signed"), False],
|
||||||
"REPLACED": ["Replaced", False],
|
"REPLACED": [N_("Replaced"), False],
|
||||||
"RESTORED": ["Restored", False],
|
"RESTORED": [N_("Restored"), False],
|
||||||
"UPDATED": ["Updated", False],
|
"UPDATED": [N_("Updated"), False],
|
||||||
"VALID": ["Valid", True],
|
"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"),)
|
||||||
|
|
||||||
|
# Translations of "New" that test builds of the i18n work saved INTO the
|
||||||
|
# status history (window.py / the CLI used ``_("New")`` when building).
|
||||||
|
# They are mapped back to "New" on load so the history is language
|
||||||
|
# neutral again. Keep in sync with the "New" msgstr of every catalog in
|
||||||
|
# bal/locale (tests/test_core_status_language_neutral.py checks it).
|
||||||
|
TRANSLATED_NEW_LABELS = ("Nuovo", "Nuevo")
|
||||||
|
|
||||||
|
# Fields that Willexecutors.get_willexecutor_transactions /
|
||||||
|
# push_transactions_to_willexecutor used to write into the item's own
|
||||||
|
# executor record (older versions); they are transient push data.
|
||||||
|
TRANSIENT_PUSH_FIELDS = ("txs", "txsids", "broadcast_status")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _untranslate_new_status(status):
|
||||||
|
"""Map a translated leading "New" of a saved history back to "New"."""
|
||||||
|
head, sep, rest = status.partition(".")
|
||||||
|
if head in WillItem.TRANSLATED_NEW_LABELS:
|
||||||
|
return "New" + sep + rest
|
||||||
|
return status
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _without_transient_push_fields(we):
|
||||||
|
"""Return ``we`` without the transient push fields (a copy if needed)."""
|
||||||
|
if not isinstance(we, dict) or not any(
|
||||||
|
k in we for k in WillItem.TRANSIENT_PUSH_FIELDS
|
||||||
|
):
|
||||||
|
return we
|
||||||
|
return {
|
||||||
|
k: v for k, v in we.items() if k not in WillItem.TRANSIENT_PUSH_FIELDS
|
||||||
|
}
|
||||||
|
|
||||||
def set_status(self, status, value=True):
|
def set_status(self, status, value=True):
|
||||||
"""Set a status flag and apply the related side effects.
|
"""Set a status flag and apply the related side effects.
|
||||||
|
|
||||||
@@ -1299,7 +1428,11 @@ class WillItem(Logger):
|
|||||||
if self.STATUS[status][1] == bool(value):
|
if self.STATUS[status][1] == bool(value):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
self.status += "." + (("NOT " if not value else "") + _(self.STATUS[status][0]))
|
# The history is saved in the wallet, in exported files and in QR
|
||||||
|
# transfers, so it is always written in English: the saved data must
|
||||||
|
# not depend on the GUI language (older versions wrote the translated
|
||||||
|
# label). format_status_history() translates it when it is shown.
|
||||||
|
self.status += "." + (("NOT " if not value else "") + self.STATUS[status][0])
|
||||||
self.STATUS[status][1] = bool(value)
|
self.STATUS[status][1] = bool(value)
|
||||||
if value:
|
if value:
|
||||||
# NOTE: ANTICIPATED and UPDATED are intentionally NOT in this list,
|
# NOTE: ANTICIPATED and UPDATED are intentionally NOT in this list,
|
||||||
@@ -1327,49 +1460,78 @@ class WillItem(Logger):
|
|||||||
return self.STATUS[status][1]
|
return self.STATUS[status][1]
|
||||||
|
|
||||||
def __init__(self, w, _id=None, wallet=None):
|
def __init__(self, w, _id=None, wallet=None):
|
||||||
if isinstance(
|
if isinstance(w, WillItem):
|
||||||
w,
|
# Copy a WillItem WITHOUT deepcopy. Serialize it to its plain-dict
|
||||||
WillItem,
|
# form and deserialize from there: the tx is re-parsed into a fresh
|
||||||
):
|
# object, STATUS is rebuilt from the clones below and heirs /
|
||||||
self.__dict__ = w.__dict__.copy()
|
# will-executors are cloned recursively, so the copy shares no
|
||||||
self.STATUS = copy.deepcopy(w.STATUS)
|
# mutable state with the source. See also copy().
|
||||||
self.heirs = copy.deepcopy(w.heirs) if w.heirs is not None else None
|
data = w.to_dict()
|
||||||
else:
|
data["heirs"] = copy_structure(w.heirs) if w.heirs is not None else None
|
||||||
self.tx = Will.get_tx_from_any(w["tx"])
|
data["willexecutor"] = (
|
||||||
self.heirs = w.get("heirs", None)
|
copy_structure(w.we) if w.we is not None else None
|
||||||
self.we = w.get("willexecutor", None)
|
)
|
||||||
self.status = w.get("status", None)
|
|
||||||
self.description = w.get("description", None)
|
|
||||||
self.time = w.get("time", None)
|
|
||||||
self.change = w.get("change", None)
|
|
||||||
self.tx_fees = w.get("baltx_fees", 0)
|
|
||||||
self.sigs_required = int(w.get("sigs_required", 0))
|
|
||||||
self.sigs_have = int(w.get("sigs_have", 0))
|
|
||||||
self.father = w.get("Father", None)
|
|
||||||
self.children = w.get("Children", None)
|
|
||||||
self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
|
||||||
for s in self.STATUS:
|
|
||||||
self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1])
|
|
||||||
# Backward-compatibility migration (A2): the "PENDING" status was
|
|
||||||
# renamed to "MEMPOOL". Wills saved by older versions of the plugin
|
|
||||||
# store the flag under the legacy "PENDING" key, so if that key is
|
|
||||||
# present and set, carry it over to "MEMPOOL". This way no state is
|
|
||||||
# lost when loading an older will. The new key always wins if both
|
|
||||||
# happen to be present.
|
|
||||||
if "MEMPOOL" not in w and w.get("PENDING"):
|
|
||||||
self.STATUS["MEMPOOL"][1] = True
|
|
||||||
if not _id:
|
if not _id:
|
||||||
self._id = self.tx.txid()
|
_id = w._id
|
||||||
else:
|
w = data
|
||||||
self._id = _id
|
self.tx = Will.get_tx_from_any(w["tx"])
|
||||||
|
self.heirs = w.get("heirs", None)
|
||||||
|
self.we = WillItem._without_transient_push_fields(
|
||||||
|
w.get("willexecutor", None)
|
||||||
|
)
|
||||||
|
self.status = WillItem._untranslate_new_status(w.get("status") or "")
|
||||||
|
self.description = w.get("description", None)
|
||||||
|
self.time = w.get("time", None)
|
||||||
|
self.change = w.get("change", None)
|
||||||
|
self.tx_fees = w.get("baltx_fees", 0)
|
||||||
|
self.sigs_required = int(w.get("sigs_required", 0))
|
||||||
|
self.sigs_have = int(w.get("sigs_have", 0))
|
||||||
|
self.father = w.get("Father", None)
|
||||||
|
self.children = w.get("Children", None)
|
||||||
|
self.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
|
for s in self.STATUS:
|
||||||
|
self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1])
|
||||||
|
# Backward-compatibility migration (A2): the "PENDING" status was
|
||||||
|
# renamed to "MEMPOOL". Wills saved by older versions of the plugin
|
||||||
|
# store the flag under the legacy "PENDING" key, so if that key is
|
||||||
|
# present and set, carry it over to "MEMPOOL". This way no state is
|
||||||
|
# lost when loading an older will. The new key always wins if both
|
||||||
|
# happen to be present.
|
||||||
|
if "MEMPOOL" not in w and w.get("PENDING"):
|
||||||
|
self.STATUS["MEMPOOL"][1] = True
|
||||||
|
if not _id:
|
||||||
|
self._id = self.tx.txid()
|
||||||
|
else:
|
||||||
|
self._id = _id
|
||||||
|
|
||||||
if not self._id:
|
if not self._id:
|
||||||
self.status += "ERROR!!!"
|
self.status += "ERROR!!!"
|
||||||
self.valid = False
|
self.valid = False
|
||||||
|
|
||||||
if wallet:
|
if wallet:
|
||||||
self.tx.add_info_from_wallet(wallet)
|
self.tx.add_info_from_wallet(wallet)
|
||||||
|
|
||||||
|
def copy(self, wallet=None):
|
||||||
|
"""Return an independent copy of this WillItem (no deepcopy).
|
||||||
|
|
||||||
|
The copy is produced by serializing this item and deserializing it:
|
||||||
|
the transaction is re-parsed, the STATUS table is rebuilt and
|
||||||
|
heirs / will-executors are cloned recursively, so the result shares no
|
||||||
|
mutable state with ``self``. Pass a ``wallet`` when the copy's tx
|
||||||
|
needs its address/value information restored
|
||||||
|
(``tx.add_info_from_wallet``).
|
||||||
|
"""
|
||||||
|
return WillItem(self, _id=self._id, wallet=wallet)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def copy_status_table(status_table):
|
||||||
|
"""Clone a STATUS table (``{flag: [label, bool]}``) without deepcopy.
|
||||||
|
|
||||||
|
Both the outer dict and every inner ``[label, bool]`` list are new
|
||||||
|
objects, so mutating the returned table never affects the source.
|
||||||
|
"""
|
||||||
|
return {k: [label, value] for k, (label, value) in status_table.items()}
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
out = {
|
out = {
|
||||||
"_id": self._id,
|
"_id": self._id,
|
||||||
@@ -1383,6 +1545,8 @@ class WillItem(Logger):
|
|||||||
"baltx_fees": self.tx_fees,
|
"baltx_fees": self.tx_fees,
|
||||||
"sigs_required": self.sigs_required,
|
"sigs_required": self.sigs_required,
|
||||||
"sigs_have": self.sigs_have,
|
"sigs_have": self.sigs_have,
|
||||||
|
"Father": self.father,
|
||||||
|
"Children": self.children,
|
||||||
}
|
}
|
||||||
for key in self.STATUS:
|
for key in self.STATUS:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ from typing import Any
|
|||||||
from aiohttp import ClientResponse
|
from aiohttp import ClientResponse
|
||||||
from electrum import bitcoin, constants
|
from electrum import bitcoin, constants
|
||||||
from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
|
from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
|
||||||
from electrum.i18n import _
|
|
||||||
from electrum.logging import get_logger
|
from electrum.logging import get_logger
|
||||||
from electrum.network import Network
|
from electrum.network import Network
|
||||||
|
|
||||||
|
from ..i18n import _
|
||||||
from .plugin_base import BalPlugin, get_version
|
from .plugin_base import BalPlugin, get_version
|
||||||
|
|
||||||
# Per-request timeout (seconds) for interactive operations (ping / info /
|
# Per-request timeout (seconds) for interactive operations (ping / info /
|
||||||
@@ -231,6 +231,13 @@ class Willexecutors:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_willexecutor_transactions(will, force=False):
|
def get_willexecutor_transactions(will, force=False):
|
||||||
|
"""Group the valid, signed, not-yet-pushed will txs by will-executor.
|
||||||
|
|
||||||
|
Returns ``{url: executor_record}`` where each record gets ``txs`` (the
|
||||||
|
raw txs, one per line) and ``txsids``.
|
||||||
|
|
||||||
|
The returned records are COPIES: the will items are never modified.
|
||||||
|
"""
|
||||||
willexecutors = {}
|
willexecutors = {}
|
||||||
for wid, willitem in will.items():
|
for wid, willitem in will.items():
|
||||||
if willitem.get_status("VALID"):
|
if willitem.get_status("VALID"):
|
||||||
@@ -240,10 +247,18 @@ class Willexecutors:
|
|||||||
url = willexecutor["url"]
|
url = willexecutor["url"]
|
||||||
if willexecutor and Willexecutors.is_selected(willexecutor):
|
if willexecutor and Willexecutors.is_selected(willexecutor):
|
||||||
if url not in willexecutors:
|
if url not in willexecutors:
|
||||||
willexecutor["txs"] = ""
|
# Work on a COPY of the record: the item's
|
||||||
willexecutor["txsids"] = []
|
# own ``we`` dict is saved in the wallet, so
|
||||||
willexecutor["broadcast_status"] = _("Waiting...")
|
# the transient push fields below (raw txs,
|
||||||
willexecutors[url] = willexecutor
|
# txids and the - translated - broadcast
|
||||||
|
# status shown in the push dialog) must
|
||||||
|
# never end up in it (stored data must not
|
||||||
|
# depend on the UI language, see bal/i18n.py).
|
||||||
|
record = dict(willexecutor)
|
||||||
|
record["txs"] = ""
|
||||||
|
record["txsids"] = []
|
||||||
|
record["broadcast_status"] = _("Waiting...")
|
||||||
|
willexecutors[url] = record
|
||||||
willexecutors[url]["txs"] += str(willitem.tx) + "\n"
|
willexecutors[url]["txs"] += str(willitem.tx) + "\n"
|
||||||
willexecutors[url]["txsids"].append(wid)
|
willexecutors[url]["txsids"].append(wid)
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,10 @@ the Qt button and the OS/subprocess glue.
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
from electrum.gui.qt.util import getSaveFileName
|
||||||
from PyQt6.QtGui import QAction
|
from PyQt6.QtGui import QAction
|
||||||
from PyQt6.QtWidgets import QInputDialog, QMenu, QToolButton
|
from PyQt6.QtWidgets import QInputDialog, QMenu, QToolButton
|
||||||
|
|
||||||
from electrum.gui.qt.util import getSaveFileName
|
|
||||||
|
|
||||||
from ...core.reminders import write_temp_ics
|
from ...core.reminders import write_temp_ics
|
||||||
from .common import _, _logger
|
from .common import _, _logger
|
||||||
|
|
||||||
|
|||||||
@@ -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``.)
|
(:class:`CheckAliveError` now lives in ``bal.core.checkalive``.)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
|
||||||
import enum
|
import enum
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -28,6 +27,7 @@ from functools import partial
|
|||||||
from typing import Any, Callable, Mapping, Optional, Union
|
from typing import Any, Callable, Mapping, Optional, Union
|
||||||
|
|
||||||
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
|
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
|
||||||
|
from electrum.gui.common_qt.util import draw_qr
|
||||||
from electrum.gui.qt.amountedit import BTCAmountEdit
|
from electrum.gui.qt.amountedit import BTCAmountEdit
|
||||||
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
|
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
|
||||||
from electrum.gui.qt.my_treeview import MyTreeView
|
from electrum.gui.qt.my_treeview import MyTreeView
|
||||||
@@ -42,6 +42,7 @@ from electrum.gui.qt.util import (
|
|||||||
MessageBoxMixin,
|
MessageBoxMixin,
|
||||||
OkButton,
|
OkButton,
|
||||||
TaskThread,
|
TaskThread,
|
||||||
|
WaitingDialog,
|
||||||
WindowModalDialog,
|
WindowModalDialog,
|
||||||
char_width_in_lineedit,
|
char_width_in_lineedit,
|
||||||
getOpenFileName,
|
getOpenFileName,
|
||||||
@@ -51,7 +52,6 @@ from electrum.gui.qt.util import (
|
|||||||
read_QPixmap_from_bytes,
|
read_QPixmap_from_bytes,
|
||||||
webopen,
|
webopen,
|
||||||
)
|
)
|
||||||
from electrum.i18n import _
|
|
||||||
from electrum.logging import get_logger
|
from electrum.logging import get_logger
|
||||||
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError
|
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError
|
||||||
from electrum.payment_identifier import PaymentIdentifier
|
from electrum.payment_identifier import PaymentIdentifier
|
||||||
@@ -80,6 +80,7 @@ from PyQt6.QtWidgets import (
|
|||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QAbstractSpinBox,
|
QAbstractSpinBox,
|
||||||
QApplication,
|
QApplication,
|
||||||
|
QButtonGroup,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDateTimeEdit,
|
QDateTimeEdit,
|
||||||
@@ -92,6 +93,7 @@ from PyQt6.QtWidgets import (
|
|||||||
QMenu,
|
QMenu,
|
||||||
QMenuBar,
|
QMenuBar,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
|
QRadioButton,
|
||||||
QScrollArea,
|
QScrollArea,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSpinBox,
|
QSpinBox,
|
||||||
@@ -119,7 +121,7 @@ from ...core.heirs import (
|
|||||||
|
|
||||||
# --- Core (GUI-free) logic layer ---
|
# --- Core (GUI-free) logic layer ---
|
||||||
from ...core.plugin_base import BalPlugin, BalTimestamp
|
from ...core.plugin_base import BalPlugin, BalTimestamp
|
||||||
from ...core.util import Util
|
from ...core.util import Util, copy_structure
|
||||||
from ...core.will import (
|
from ...core.will import (
|
||||||
AmountException,
|
AmountException,
|
||||||
HeirChangeException,
|
HeirChangeException,
|
||||||
@@ -134,6 +136,7 @@ from ...core.will import (
|
|||||||
WillExpiredException,
|
WillExpiredException,
|
||||||
WillItem,
|
WillItem,
|
||||||
WillPostponedException,
|
WillPostponedException,
|
||||||
|
format_status_history,
|
||||||
)
|
)
|
||||||
from ...core.willexecutors import ( # noqa: F401
|
from ...core.willexecutors import ( # noqa: F401
|
||||||
Willexecutors,
|
Willexecutors,
|
||||||
@@ -141,6 +144,9 @@ from ...core.willexecutors import ( # noqa: F401
|
|||||||
is_tor_active,
|
is_tor_active,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# BAL's translator: Electrum's catalog first, then BAL's (see bal/i18n.py).
|
||||||
|
from ...i18n import N_, _
|
||||||
|
|
||||||
# --- Presentation helpers ---
|
# --- Presentation helpers ---
|
||||||
from .theme import (
|
from .theme import (
|
||||||
server_status_text,
|
server_status_text,
|
||||||
@@ -158,6 +164,17 @@ from .window_utils import (
|
|||||||
|
|
||||||
_logger = get_logger(__name__)
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Labels of bal.core.qrtransfer.CHUNK_PRESETS, marked for translation here
|
||||||
|
# because qrtransfer.py must stay free of Electrum imports (the Android reader
|
||||||
|
# ships a copy of it). The combos show them with _(label);
|
||||||
|
# tests/test_i18n.py checks that this list matches CHUNK_PRESETS.
|
||||||
|
QR_PRESET_LABELS = (
|
||||||
|
N_("Small - ~150 bytes/QR (low-res cameras)"),
|
||||||
|
N_("Medium - ~400 bytes/QR"),
|
||||||
|
N_("Large - ~900 bytes/QR"),
|
||||||
|
N_("XL - ~1800 bytes/QR (high-res cameras)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal naming)
|
class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal naming)
|
||||||
_type = bool
|
_type = bool
|
||||||
@@ -175,13 +192,30 @@ class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal namin
|
|||||||
|
|
||||||
|
|
||||||
def add_widget(grid, label, widget, row, help_):
|
def add_widget(grid, label, widget, row, help_):
|
||||||
grid.addWidget(QLabel(_(label)), row, 0)
|
"""Add a ``label | widget | help button`` row to ``grid``.
|
||||||
|
|
||||||
|
``label`` and ``help_`` must already be translated by the caller: a
|
||||||
|
variable passed to _() cannot be extracted into the catalog.
|
||||||
|
"""
|
||||||
|
grid.addWidget(QLabel(label), row, 0)
|
||||||
grid.addWidget(widget, row, 1)
|
grid.addWidget(widget, row, 1)
|
||||||
grid.addWidget(HelpButton(help_), row, 2)
|
grid.addWidget(HelpButton(help_), row, 2)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
def log_error(exec_info, window=None):
|
||||||
"""Log an error and optionally show it.
|
"""Log an error and optionally show it.
|
||||||
|
|
||||||
@@ -219,7 +253,7 @@ def export_meta_gui(electrum_window, title, exporter):
|
|||||||
filter_ = "All files (*)"
|
filter_ = "All files (*)"
|
||||||
filename = getSaveFileName(
|
filename = getSaveFileName(
|
||||||
parent=electrum_window,
|
parent=electrum_window,
|
||||||
title=_("Select file to save your {}".format(title)),
|
title=_("Select file to save your {}").format(title),
|
||||||
filename="BALplugin_{}_{}_{}".format(
|
filename="BALplugin_{}_{}_{}".format(
|
||||||
BalPlugin.chainname, str(electrum_window.wallet), title
|
BalPlugin.chainname, str(electrum_window.wallet), title
|
||||||
),
|
),
|
||||||
@@ -234,7 +268,7 @@ def export_meta_gui(electrum_window, title, exporter):
|
|||||||
electrum_window.show_critical(str(e))
|
electrum_window.show_critical(str(e))
|
||||||
else:
|
else:
|
||||||
electrum_window.show_message(
|
electrum_window.show_message(
|
||||||
_("Your {0} were exported to '{1}'".format(title, str(filename)))
|
_("Your {0} were exported to '{1}'").format(title, str(filename))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,15 +20,14 @@ from PyQt6.QtWidgets import QLineEdit as _QLineEdit
|
|||||||
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
|
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
_,
|
N_,
|
||||||
_logger,
|
OP_RETURN_PREFIX,
|
||||||
BalTimestamp,
|
BalTimestamp,
|
||||||
Buttons,
|
Buttons,
|
||||||
CancelButton,
|
CancelButton,
|
||||||
HelpButton,
|
HelpButton,
|
||||||
MessageBoxMixin,
|
MessageBoxMixin,
|
||||||
MyTreeView,
|
MyTreeView,
|
||||||
OP_RETURN_PREFIX,
|
|
||||||
OkButton,
|
OkButton,
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QApplication,
|
QApplication,
|
||||||
@@ -46,29 +45,33 @@ from .common import (
|
|||||||
QSpinBox,
|
QSpinBox,
|
||||||
QStandardItem,
|
QStandardItem,
|
||||||
QStandardItemModel,
|
QStandardItemModel,
|
||||||
|
Qt,
|
||||||
QToolButton,
|
QToolButton,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
Qt,
|
|
||||||
TaskThread,
|
TaskThread,
|
||||||
Util,
|
Util,
|
||||||
Will,
|
Will,
|
||||||
WillItem,
|
|
||||||
Willexecutors,
|
Willexecutors,
|
||||||
|
WillItem,
|
||||||
|
_,
|
||||||
|
_logger,
|
||||||
char_width_in_lineedit,
|
char_width_in_lineedit,
|
||||||
datetime,
|
datetime,
|
||||||
enum,
|
enum,
|
||||||
export_meta_gui,
|
export_meta_gui,
|
||||||
|
format_status_history,
|
||||||
getOpenFileName,
|
getOpenFileName,
|
||||||
import_meta_gui,
|
import_meta_gui,
|
||||||
is_op_return_address,
|
is_op_return_address,
|
||||||
partial,
|
partial,
|
||||||
read_QIcon_from_bytes,
|
|
||||||
read_json_file,
|
read_json_file,
|
||||||
|
read_QIcon_from_bytes,
|
||||||
server_status_text,
|
server_status_text,
|
||||||
server_status_tooltip,
|
server_status_tooltip,
|
||||||
signature_suffix,
|
signature_suffix,
|
||||||
status_color,
|
status_color,
|
||||||
|
translated_headers,
|
||||||
tx_from_any,
|
tx_from_any,
|
||||||
write_json_file,
|
write_json_file,
|
||||||
)
|
)
|
||||||
@@ -128,9 +131,9 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
AMOUNT = enum.auto()
|
AMOUNT = enum.auto()
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
Columns.NAME: _("Name"),
|
Columns.NAME: N_("Name"),
|
||||||
Columns.ADDRESS: _("Address"),
|
Columns.ADDRESS: N_("Address"),
|
||||||
Columns.AMOUNT: _("Amount"),
|
Columns.AMOUNT: N_("Amount"),
|
||||||
}
|
}
|
||||||
filter_columns = [Columns.NAME, Columns.ADDRESS]
|
filter_columns = [Columns.NAME, Columns.ADDRESS]
|
||||||
|
|
||||||
@@ -245,7 +248,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
col=self.Columns.NAME, role=self.ROLE_HEIR_KEY
|
col=self.Columns.NAME, role=self.ROLE_HEIR_KEY
|
||||||
)
|
)
|
||||||
self.model().clear()
|
self.model().clear()
|
||||||
self.update_headers(self.__class__.headers)
|
self.update_headers(translated_headers(self.__class__.headers))
|
||||||
set_current = None
|
set_current = None
|
||||||
for key in sorted(self.bal_window.heirs.keys()):
|
for key in sorted(self.bal_window.heirs.keys()):
|
||||||
heir = self.bal_window.heirs[key]
|
heir = self.bal_window.heirs[key]
|
||||||
@@ -337,11 +340,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
SERVER = enum.auto()
|
SERVER = enum.auto()
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
Columns.LOCKTIME: _("Locktime"),
|
Columns.LOCKTIME: N_("Locktime"),
|
||||||
Columns.TXID: _("Txid"),
|
Columns.TXID: N_("Txid"),
|
||||||
Columns.WILLEXECUTOR: _("Will-Executor"),
|
Columns.WILLEXECUTOR: N_("Will-Executor"),
|
||||||
Columns.STATUS: _("Status"),
|
Columns.STATUS: N_("Status"),
|
||||||
Columns.SERVER: _("Server"),
|
Columns.SERVER: N_("Server"),
|
||||||
}
|
}
|
||||||
|
|
||||||
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000
|
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000
|
||||||
@@ -586,7 +589,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
if bal_tx.we:
|
if bal_tx.we:
|
||||||
we = bal_tx.we["url"]
|
we = bal_tx.we["url"]
|
||||||
labels[self.Columns.WILLEXECUTOR] = we
|
labels[self.Columns.WILLEXECUTOR] = we
|
||||||
status = bal_tx.status + signature_suffix(bal_tx)
|
status = format_status_history(bal_tx.status) + signature_suffix(bal_tx)
|
||||||
if len(status) > 53:
|
if len(status) > 53:
|
||||||
status = "...{}".format(status[-50:])
|
status = "...{}".format(status[-50:])
|
||||||
labels[self.Columns.STATUS] = status
|
labels[self.Columns.STATUS] = status
|
||||||
@@ -644,7 +647,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
col=self.Columns.TXID, role=self.ROLE_HEIR_KEY
|
col=self.Columns.TXID, role=self.ROLE_HEIR_KEY
|
||||||
)
|
)
|
||||||
self.model().clear()
|
self.model().clear()
|
||||||
self.update_headers(self.__class__.headers)
|
self.update_headers(translated_headers(self.__class__.headers))
|
||||||
|
|
||||||
set_current = None
|
set_current = None
|
||||||
for txid, bal_tx in self.will.items():
|
for txid, bal_tx in self.will.items():
|
||||||
@@ -663,11 +666,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
menu.addAction(_("Prepare"), self.build_transactions)
|
menu.addAction(_("Prepare"), self.build_transactions)
|
||||||
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
|
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
|
||||||
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
|
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
|
||||||
export_menu = menu.addMenu(_("Export"))
|
# Export/Import open a single window that offers all transports
|
||||||
export_menu.addAction(_("All"), self.export_will)
|
# (file / QR / audio). The Choose Filter / transport settings live
|
||||||
export_menu.addAction(_("Valid"), self.export_will_valid)
|
# inside that window.
|
||||||
export_menu.addAction(_("Valid NC"), self.export_will_valid_incomplete)
|
menu.addAction(_("Export"), self.export_will)
|
||||||
menu.addAction(_("Import"), self.import_will_into_details)
|
menu.addAction(_("Import"), self.import_will)
|
||||||
menu.addAction(_("Merge"), self.merge_will)
|
menu.addAction(_("Merge"), self.merge_will)
|
||||||
menu.addAction(_("Broadcast"), self.broadcast)
|
menu.addAction(_("Broadcast"), self.broadcast)
|
||||||
menu.addAction(_("Check"), self.check)
|
menu.addAction(_("Check"), self.check)
|
||||||
@@ -676,7 +679,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
# The Wizard is the main entry point to create an inheritance, so make
|
# The Wizard is the main entry point to create an inheritance, so make
|
||||||
# it stand out: show a bold label next to a slightly larger icon (the
|
# it stand out: show a bold label next to a slightly larger icon (the
|
||||||
# plain icon-only button was too easy to overlook).
|
# plain icon-only button was too easy to overlook).
|
||||||
wizard = QPushButton(" " + _("Build Your Will"))
|
wizard = QPushButton(" {}".format(_("Build Your Will")))
|
||||||
wizard.setIcon(
|
wizard.setIcon(
|
||||||
read_QIcon_from_bytes(
|
read_QIcon_from_bytes(
|
||||||
self.bal_window.bal_plugin.read_file("icons/wizard.png")
|
self.bal_window.bal_plugin.read_file("icons/wizard.png")
|
||||||
@@ -733,38 +736,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
if will:
|
if will:
|
||||||
self.update_will(will)
|
self.update_will(will)
|
||||||
|
|
||||||
def export_json_file(self, path):
|
|
||||||
write_json_file(path, self.will)
|
|
||||||
|
|
||||||
def export_will(self):
|
def export_will(self):
|
||||||
self.bal_window.export_will()
|
self.bal_window.export_will_dialog()
|
||||||
self.update()
|
|
||||||
|
|
||||||
def export_will_valid(self):
|
def import_will(self):
|
||||||
"""Export only the will items that are valid."""
|
self.bal_window.import_will_dialog()
|
||||||
subset = {
|
|
||||||
wid: wi
|
|
||||||
for wid, wi in self.will.items()
|
|
||||||
if wi.get_status("VALID")
|
|
||||||
}
|
|
||||||
if not subset:
|
|
||||||
self.show_message(_("No valid will item to export"))
|
|
||||||
return
|
|
||||||
self.bal_window.export_will(will=subset)
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
def export_will_valid_incomplete(self):
|
|
||||||
"""Export only the will items that are valid but not yet fully signed (V-NC)."""
|
|
||||||
subset = {
|
|
||||||
wid: wi
|
|
||||||
for wid, wi in self.will.items()
|
|
||||||
if wi.get_status("VALID") and not wi.get_status("COMPLETE")
|
|
||||||
}
|
|
||||||
if not subset:
|
|
||||||
self.show_message(_("No valid, incomplete will item to export"))
|
|
||||||
return
|
|
||||||
self.bal_window.export_will(will=subset)
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
def import_will_into_details(self):
|
def import_will_into_details(self):
|
||||||
self.bal_window.import_will_into_details()
|
self.bal_window.import_will_into_details()
|
||||||
@@ -931,12 +907,12 @@ class WillExecutorListWidget(MyTreeView):
|
|||||||
ADDRESS = enum.auto()
|
ADDRESS = enum.auto()
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
Columns.SELECTED: _(""),
|
Columns.SELECTED: "",
|
||||||
Columns.URL: _("Url"),
|
Columns.URL: N_("Url"),
|
||||||
Columns.STATUS: _("S"),
|
Columns.STATUS: N_("S"),
|
||||||
Columns.BASE_FEE: _("Base fee"),
|
Columns.BASE_FEE: N_("Base fee"),
|
||||||
Columns.INFO: _("Info"),
|
Columns.INFO: N_("Info"),
|
||||||
Columns.ADDRESS: _("Default Address"),
|
Columns.ADDRESS: N_("Default Address"),
|
||||||
}
|
}
|
||||||
|
|
||||||
filter_columns = [Columns.URL]
|
filter_columns = [Columns.URL]
|
||||||
@@ -1118,7 +1094,7 @@ class WillExecutorListWidget(MyTreeView):
|
|||||||
col=self.Columns.URL, role=self.ROLE_HEIR_KEY
|
col=self.Columns.URL, role=self.ROLE_HEIR_KEY
|
||||||
)
|
)
|
||||||
self.model().clear()
|
self.model().clear()
|
||||||
self.update_headers(self.__class__.headers)
|
self.update_headers(translated_headers(self.__class__.headers))
|
||||||
|
|
||||||
set_current = None
|
set_current = None
|
||||||
|
|
||||||
@@ -1263,13 +1239,14 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
buttonbox.addWidget(b)
|
buttonbox.addWidget(b)
|
||||||
|
|
||||||
def _menu_button(label):
|
def _menu_button(label):
|
||||||
|
# ``label`` must already be translated by the caller.
|
||||||
btn = QToolButton()
|
btn = QToolButton()
|
||||||
btn.setText(_(label))
|
btn.setText(label)
|
||||||
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||||
buttonbox.addWidget(btn)
|
buttonbox.addWidget(btn)
|
||||||
return btn
|
return btn
|
||||||
|
|
||||||
export_btn = _menu_button("Export")
|
export_btn = _menu_button(_("Export"))
|
||||||
export_menu = QMenu(export_btn)
|
export_menu = QMenu(export_btn)
|
||||||
export_menu.addAction(_("Export all"), lambda: self.export_file())
|
export_menu.addAction(_("Export all"), lambda: self.export_file())
|
||||||
export_menu.addAction(
|
export_menu.addAction(
|
||||||
@@ -1280,7 +1257,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
)
|
)
|
||||||
export_btn.setMenu(export_menu)
|
export_btn.setMenu(export_menu)
|
||||||
|
|
||||||
ping_btn = _menu_button("Ping All")
|
ping_btn = _menu_button(_("Ping All"))
|
||||||
ping_menu = QMenu(ping_btn)
|
ping_menu = QMenu(ping_btn)
|
||||||
ping_menu.addAction(_("Ping all"), lambda: self.update_willexecutors())
|
ping_menu.addAction(_("Ping all"), lambda: self.update_willexecutors())
|
||||||
ping_menu.addAction(
|
ping_menu.addAction(
|
||||||
@@ -1288,7 +1265,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
)
|
)
|
||||||
ping_btn.setMenu(ping_menu)
|
ping_btn.setMenu(ping_menu)
|
||||||
|
|
||||||
select_btn = _menu_button("Select All")
|
select_btn = _menu_button(_("Select All"))
|
||||||
select_menu = QMenu(select_btn)
|
select_menu = QMenu(select_btn)
|
||||||
select_menu.addAction(_("Select all"), lambda: self.set_select_all(True))
|
select_menu.addAction(_("Select all"), lambda: self.set_select_all(True))
|
||||||
select_menu.addAction(
|
select_menu.addAction(
|
||||||
@@ -1322,7 +1299,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
|
|
||||||
url_edit = QLineEdit()
|
url_edit = QLineEdit()
|
||||||
url_edit.setFixedWidth(32 * char_width_in_lineedit())
|
url_edit.setFixedWidth(32 * char_width_in_lineedit())
|
||||||
info_edit = QLineEdit("New Will Executor")
|
info_edit = QLineEdit(_("New Will Executor"))
|
||||||
info_edit.setFixedWidth(32 * char_width_in_lineedit())
|
info_edit.setFixedWidth(32 * char_width_in_lineedit())
|
||||||
base_fee_spin = QSpinBox()
|
base_fee_spin = QSpinBox()
|
||||||
base_fee_spin.setRange(0, 1000000)
|
base_fee_spin.setRange(0, 1000000)
|
||||||
@@ -1336,7 +1313,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
|
|
||||||
if executor:
|
if executor:
|
||||||
url_edit.setText(edit_key)
|
url_edit.setText(edit_key)
|
||||||
info_edit.setText(str(executor.get("info", "New Will Executor")))
|
info_edit.setText(str(executor.get("info", _("New Will Executor"))))
|
||||||
base_fee_spin.setValue(int(executor.get("base_fee", 0)))
|
base_fee_spin.setValue(int(executor.get("base_fee", 0)))
|
||||||
address_edit.setText(str(executor.get("address", "")))
|
address_edit.setText(str(executor.get("address", "")))
|
||||||
|
|
||||||
@@ -1496,7 +1473,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
break
|
break
|
||||||
self._add_another = False
|
self._add_another = False
|
||||||
url_edit.clear()
|
url_edit.clear()
|
||||||
info_edit.setText("New Will Executor")
|
info_edit.setText(_("New Will Executor"))
|
||||||
base_fee_spin.setValue(0)
|
base_fee_spin.setValue(0)
|
||||||
address_edit.clear()
|
address_edit.clear()
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,8 @@ from electrum.plugin import hook
|
|||||||
from electrum.util import EventListener, event_listener
|
from electrum.util import EventListener, event_listener
|
||||||
from PyQt6.QtWidgets import QLayout
|
from PyQt6.QtWidgets import QLayout
|
||||||
|
|
||||||
|
from ...i18n import init_from_config
|
||||||
from .common import (
|
from .common import (
|
||||||
_,
|
|
||||||
_logger,
|
|
||||||
BalPlugin,
|
BalPlugin,
|
||||||
Buttons,
|
Buttons,
|
||||||
EnterButton,
|
EnterButton,
|
||||||
@@ -38,6 +37,8 @@ from .common import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
UserCancelled,
|
UserCancelled,
|
||||||
Willexecutors,
|
Willexecutors,
|
||||||
|
_,
|
||||||
|
_logger,
|
||||||
add_widget,
|
add_widget,
|
||||||
partial,
|
partial,
|
||||||
read_QIcon_from_bytes,
|
read_QIcon_from_bytes,
|
||||||
@@ -66,6 +67,11 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
def __init__(self, parent, config, name):
|
def __init__(self, parent, config, name):
|
||||||
_logger.info("INIT BALPLUGIN")
|
_logger.info("INIT BALPLUGIN")
|
||||||
BalPlugin.__init__(self, parent, config, name)
|
BalPlugin.__init__(self, parent, config, name)
|
||||||
|
# Load BAL's catalog for the language Electrum's GUI is using, before
|
||||||
|
# any window or dialog builds its texts. It must come after
|
||||||
|
# BalPlugin.__init__: read_file() needs the plugin's parent and name.
|
||||||
|
# The CLI plugin does not call this, so the CLI stays in English.
|
||||||
|
init_from_config(self, config)
|
||||||
self.bal_windows = {}
|
self.bal_windows = {}
|
||||||
# Status-bar buttons, keyed by id(sb.window()). Tracking them lets us
|
# Status-bar buttons, keyed by id(sb.window()). Tracking them lets us
|
||||||
# remove a stale button before creating a fresh one when a wallet is
|
# remove a stale button before creating a fresh one when a wallet is
|
||||||
@@ -297,7 +303,7 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
pass
|
pass
|
||||||
b = StatusBarButton(
|
b = StatusBarButton(
|
||||||
read_QIcon_from_bytes(self.read_file("icons/bal32x32.png")),
|
read_QIcon_from_bytes(self.read_file("icons/bal32x32.png")),
|
||||||
"Bal " + _("Bitcoin After Life"),
|
"Bal {}".format(_("Bitcoin After Life")),
|
||||||
lambda: self.settings_dialog(sb.window()),
|
lambda: self.settings_dialog(sb.window()),
|
||||||
sb.height(),
|
sb.height(),
|
||||||
)
|
)
|
||||||
@@ -441,7 +447,7 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
|
|
||||||
def settings_dialog(self, window=None, wallet=None):
|
def settings_dialog(self, window=None, wallet=None):
|
||||||
|
|
||||||
d = BalDialog(window, self, self.get_window_title("Settings"))
|
d = BalDialog(window, self, self.get_window_title(_("Settings")))
|
||||||
d.setMinimumSize(100, 200)
|
d.setMinimumSize(100, 200)
|
||||||
qicon = read_QPixmap_from_bytes(self.read_file("icons/bal16x16.png"))
|
qicon = read_QPixmap_from_bytes(self.read_file("icons/bal16x16.png"))
|
||||||
lbl_logo = QLabel()
|
lbl_logo = QLabel()
|
||||||
@@ -640,9 +646,9 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
elif kind == "spin":
|
elif kind == "spin":
|
||||||
widget.setValue(int(cfg.default))
|
widget.setValue(int(cfg.default))
|
||||||
elif kind == "line":
|
elif kind == "line":
|
||||||
widget.setText(cfg.default)
|
widget.setText(cfg.localized_default())
|
||||||
elif kind == "text":
|
elif kind == "text":
|
||||||
widget.setPlainText(cfg.default)
|
widget.setPlainText(cfg.localized_default())
|
||||||
elif kind == "user_type":
|
elif kind == "user_type":
|
||||||
widget.setCurrentIndex(
|
widget.setCurrentIndex(
|
||||||
1 if str(cfg.default).lower() == "advanced" else 0
|
1 if str(cfg.default).lower() == "advanced" else 0
|
||||||
@@ -650,7 +656,7 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
btn.clicked.connect(reset)
|
btn.clicked.connect(reset)
|
||||||
return btn
|
return btn
|
||||||
|
|
||||||
heir_repush = QPushButton("Rebroadcast transactions")
|
heir_repush = QPushButton(_("Rebroadcast transactions"))
|
||||||
heir_repush.clicked.connect(partial(self.broadcast_transactions, True))
|
heir_repush.clicked.connect(partial(self.broadcast_transactions, True))
|
||||||
bal_mode = QComboBox()
|
bal_mode = QComboBox()
|
||||||
options = ["Easy", "Advanced", "Experimental"]
|
options = ["Easy", "Advanced", "Experimental"]
|
||||||
@@ -676,27 +682,27 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
# advanced-only settings, so the user picks basic/advanced first.
|
# advanced-only settings, so the user picks basic/advanced first.
|
||||||
add_widget(
|
add_widget(
|
||||||
grid,
|
grid,
|
||||||
"Hide Replaced",
|
_("Hide Replaced"),
|
||||||
heir_hide_replaced,
|
heir_hide_replaced,
|
||||||
0,
|
0,
|
||||||
"Hide replaced transactions from will detail and list",
|
_("Hide replaced transactions from will detail and list"),
|
||||||
)
|
)
|
||||||
grid.addWidget(_make_reset_btn(self.HIDE_REPLACED, heir_hide_replaced, "check"), 0, 3)
|
grid.addWidget(_make_reset_btn(self.HIDE_REPLACED, heir_hide_replaced, "check"), 0, 3)
|
||||||
add_widget(
|
add_widget(
|
||||||
grid,
|
grid,
|
||||||
"Hide Invalidated",
|
_("Hide Invalidated"),
|
||||||
heir_hide_invalidated,
|
heir_hide_invalidated,
|
||||||
1,
|
1,
|
||||||
"Hide invalidated transactions from will detail and list",
|
_("Hide invalidated transactions from will detail and list"),
|
||||||
)
|
)
|
||||||
grid.addWidget(_make_reset_btn(self.HIDE_INVALIDATED, heir_hide_invalidated, "check"), 1, 3)
|
grid.addWidget(_make_reset_btn(self.HIDE_INVALIDATED, heir_hide_invalidated, "check"), 1, 3)
|
||||||
lbl_auto_sign = QLabel(_("Auto-sign on Check"))
|
lbl_auto_sign = QLabel(_("Auto-sign on Check"))
|
||||||
help_auto_sign = HelpButton(
|
help_auto_sign = HelpButton(_(
|
||||||
"When checking, automatically sign and broadcast the will "
|
"When checking, automatically sign and broadcast the will "
|
||||||
"transactions to their will-executors.\n"
|
"transactions to their will-executors.\n"
|
||||||
"The wallet password is requested only if the wallet is "
|
"The wallet password is requested only if the wallet is "
|
||||||
"encrypted."
|
"encrypted."
|
||||||
)
|
))
|
||||||
grid.addWidget(_hide_if_basic(lbl_auto_sign), 2, 0)
|
grid.addWidget(_hide_if_basic(lbl_auto_sign), 2, 0)
|
||||||
grid.addWidget(_hide_if_basic(heir_auto_sign), 2, 1)
|
grid.addWidget(_hide_if_basic(heir_auto_sign), 2, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_auto_sign), 2, 2)
|
grid.addWidget(_hide_if_basic(help_auto_sign), 2, 2)
|
||||||
@@ -706,10 +712,10 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
grid.addWidget(_hide_if_basic(reset_btn_auto_sign), 2, 3)
|
grid.addWidget(_hide_if_basic(reset_btn_auto_sign), 2, 3)
|
||||||
add_widget(
|
add_widget(
|
||||||
grid,
|
grid,
|
||||||
"Panel editable Date and Fee",
|
_("Panel editable Date and Fee"),
|
||||||
heir_editable_dates,
|
heir_editable_dates,
|
||||||
3,
|
3,
|
||||||
(
|
_(
|
||||||
"When enabled, the delivery-time and check-alive date fields "
|
"When enabled, the delivery-time and check-alive date fields "
|
||||||
"can be edited everywhere (toolbar / Heirs tab), not only in "
|
"can be edited everywhere (toolbar / Heirs tab), not only in "
|
||||||
"the will-building wizard.\n"
|
"the will-building wizard.\n"
|
||||||
@@ -721,10 +727,10 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
# will-executor. Visible to all users (BASIC and ADVANCED).
|
# will-executor. Visible to all users (BASIC and ADVANCED).
|
||||||
add_widget(
|
add_widget(
|
||||||
grid,
|
grid,
|
||||||
"Max Will-Executor Fee (satoshi)",
|
_("Max Will-Executor Fee (satoshi)"),
|
||||||
heir_max_willexecutor_fee,
|
heir_max_willexecutor_fee,
|
||||||
4,
|
4,
|
||||||
(
|
_(
|
||||||
"Maximum fee (in satoshi) allowed to be paid to a single "
|
"Maximum fee (in satoshi) allowed to be paid to a single "
|
||||||
"will-executor. If a will-executor charges more than this, "
|
"will-executor. If a will-executor charges more than this, "
|
||||||
"the will will not be built.\n"
|
"the will will not be built.\n"
|
||||||
@@ -736,10 +742,10 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
# user chooses basic/advanced first, then sees the relevant options.
|
# user chooses basic/advanced first, then sees the relevant options.
|
||||||
add_widget(
|
add_widget(
|
||||||
grid,
|
grid,
|
||||||
"User Type",
|
_("User Type"),
|
||||||
user_type_combo,
|
user_type_combo,
|
||||||
5,
|
5,
|
||||||
(
|
_(
|
||||||
"Choose how much detail the plugin shows.\n\n"
|
"Choose how much detail the plugin shows.\n\n"
|
||||||
"BASIC: simplified interface, safe configuration for most "
|
"BASIC: simplified interface, safe configuration for most "
|
||||||
"users.\n\n"
|
"users.\n\n"
|
||||||
@@ -754,10 +760,10 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
# only in ADVANCED mode. In BASIC mode the factory defaults are always
|
# only in ADVANCED mode. In BASIC mode the factory defaults are always
|
||||||
# used and these settings are hidden.
|
# used and these settings are hidden.
|
||||||
lbl_num_reminders = QLabel(_("Number of reminders"))
|
lbl_num_reminders = QLabel(_("Number of reminders"))
|
||||||
help_num_reminders = HelpButton(
|
help_num_reminders = HelpButton(_(
|
||||||
"How many reminder alarms the exported calendar (.ics) event "
|
"How many reminder alarms the exported calendar (.ics) event "
|
||||||
"contains. Range: 1 to 5 (default 3). Only used in ADVANCED mode."
|
"contains. Range: 1 to 5 (default 3). Only used in ADVANCED mode."
|
||||||
)
|
))
|
||||||
grid.addWidget(_hide_if_basic(lbl_num_reminders), 6, 0)
|
grid.addWidget(_hide_if_basic(lbl_num_reminders), 6, 0)
|
||||||
grid.addWidget(_hide_if_basic(heir_num_reminders), 6, 1)
|
grid.addWidget(_hide_if_basic(heir_num_reminders), 6, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_num_reminders), 6, 2)
|
grid.addWidget(_hide_if_basic(help_num_reminders), 6, 2)
|
||||||
@@ -765,13 +771,13 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
grid.addWidget(_hide_if_basic(reset_btn_6), 6, 3)
|
grid.addWidget(_hide_if_basic(reset_btn_6), 6, 3)
|
||||||
|
|
||||||
lbl_event_summary = QLabel(_("Event summary"))
|
lbl_event_summary = QLabel(_("Event summary"))
|
||||||
help_event_summary = HelpButton(
|
help_event_summary = HelpButton(_(
|
||||||
"Default message to be used in event summary\n"
|
"Default message to be used in event summary\n"
|
||||||
"Variables:\n"
|
"Variables:\n"
|
||||||
" $wallet_name: name of wallet\n"
|
" $wallet_name: name of wallet\n"
|
||||||
" $heirs_complete: list of heirs name,address,amount\n"
|
" $heirs_complete: list of heirs name,address,amount\n"
|
||||||
"Only used in ADVANCED mode."
|
"Only used in ADVANCED mode."
|
||||||
)
|
))
|
||||||
grid.addWidget(_hide_if_basic(lbl_event_summary), 7, 0)
|
grid.addWidget(_hide_if_basic(lbl_event_summary), 7, 0)
|
||||||
grid.addWidget(_hide_if_basic(edit_event_summary), 7, 1)
|
grid.addWidget(_hide_if_basic(edit_event_summary), 7, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_event_summary), 7, 2)
|
grid.addWidget(_hide_if_basic(help_event_summary), 7, 2)
|
||||||
@@ -779,13 +785,13 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
grid.addWidget(_hide_if_basic(reset_btn_7), 7, 3)
|
grid.addWidget(_hide_if_basic(reset_btn_7), 7, 3)
|
||||||
|
|
||||||
lbl_event_description = QLabel(_("Event description"))
|
lbl_event_description = QLabel(_("Event description"))
|
||||||
help_event_description = HelpButton(
|
help_event_description = HelpButton(_(
|
||||||
"Default message to be used in event description\n"
|
"Default message to be used in event description\n"
|
||||||
"Variables:\n"
|
"Variables:\n"
|
||||||
" $wallet_name: name of wallet\n"
|
" $wallet_name: name of wallet\n"
|
||||||
" $heirs_complete: list of heirs name,address,amount\n"
|
" $heirs_complete: list of heirs name,address,amount\n"
|
||||||
"Only used in ADVANCED mode."
|
"Only used in ADVANCED mode."
|
||||||
)
|
))
|
||||||
grid.addWidget(_hide_if_basic(lbl_event_description), 8, 0)
|
grid.addWidget(_hide_if_basic(lbl_event_description), 8, 0)
|
||||||
grid.addWidget(_hide_if_basic(edit_event_description), 8, 1)
|
grid.addWidget(_hide_if_basic(edit_event_description), 8, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_event_description), 8, 2)
|
grid.addWidget(_hide_if_basic(help_event_description), 8, 2)
|
||||||
@@ -794,10 +800,10 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
# Welist server URL: shown only in ADVANCED mode. In BASIC mode the
|
# Welist server URL: shown only in ADVANCED mode. In BASIC mode the
|
||||||
# factory default is always used and the setting is hidden.
|
# factory default is always used and the setting is hidden.
|
||||||
lbl_welist_server = QLabel(_("Welist Server URL"))
|
lbl_welist_server = QLabel(_("Welist Server URL"))
|
||||||
help_welist_server = HelpButton(
|
help_welist_server = HelpButton(_(
|
||||||
"URL of the server that provides the will-executor list. "
|
"URL of the server that provides the will-executor list. "
|
||||||
"Only available in ADVANCED mode."
|
"Only available in ADVANCED mode."
|
||||||
)
|
))
|
||||||
grid.addWidget(_hide_if_basic(lbl_welist_server), 9, 0)
|
grid.addWidget(_hide_if_basic(lbl_welist_server), 9, 0)
|
||||||
grid.addWidget(_hide_if_basic(edit_welist_server), 9, 1)
|
grid.addWidget(_hide_if_basic(edit_welist_server), 9, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_welist_server), 9, 2)
|
grid.addWidget(_hide_if_basic(help_welist_server), 9, 2)
|
||||||
@@ -805,11 +811,11 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
grid.addWidget(_hide_if_basic(reset_btn_9), 9, 3)
|
grid.addWidget(_hide_if_basic(reset_btn_9), 9, 3)
|
||||||
|
|
||||||
lbl_calendar_app = QLabel(_("Calendar app command"))
|
lbl_calendar_app = QLabel(_("Calendar app command"))
|
||||||
help_calendar_app = HelpButton(
|
help_calendar_app = HelpButton(_(
|
||||||
"Command used to open .ics calendar files.\n"
|
"Command used to open .ics calendar files.\n"
|
||||||
"Leave empty to use the system default (xdg-open/open/start).\n"
|
"Leave empty to use the system default (xdg-open/open/start).\n"
|
||||||
"Only used in ADVANCED mode."
|
"Only used in ADVANCED mode."
|
||||||
)
|
))
|
||||||
grid.addWidget(_hide_if_basic(lbl_calendar_app), 10, 0)
|
grid.addWidget(_hide_if_basic(lbl_calendar_app), 10, 0)
|
||||||
grid.addWidget(_hide_if_basic(edit_calendar_app), 10, 1)
|
grid.addWidget(_hide_if_basic(edit_calendar_app), 10, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_calendar_app), 10, 2)
|
grid.addWidget(_hide_if_basic(help_calendar_app), 10, 2)
|
||||||
@@ -820,13 +826,13 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
# label field is disabled while the checkbox is off (see
|
# label field is disabled while the checkbox is off (see
|
||||||
# on_save_history_change above).
|
# on_save_history_change above).
|
||||||
lbl_save_history = QLabel(_("Save inheritance transactions in history"))
|
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 "
|
"After each check, save the valid will transactions into the "
|
||||||
"wallet's local history (the History tab), each with a label.\n"
|
"wallet's local history (the History tab), each with a label.\n"
|
||||||
"The label may contain the variable:\n"
|
"The label may contain the variable:\n"
|
||||||
" {willexecutor}: replaced with the will-executor URL of the item\n"
|
" {willexecutor}: replaced with the will-executor URL of the item\n"
|
||||||
"Only used in ADVANCED mode."
|
"Only used in ADVANCED mode."
|
||||||
)
|
))
|
||||||
grid.addWidget(_hide_if_basic(lbl_save_history), 11, 0)
|
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(heir_save_history), 11, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_save_history), 11, 2)
|
grid.addWidget(_hide_if_basic(help_save_history), 11, 2)
|
||||||
@@ -834,13 +840,13 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
grid.addWidget(_hide_if_basic(reset_btn_11), 11, 3)
|
grid.addWidget(_hide_if_basic(reset_btn_11), 11, 3)
|
||||||
|
|
||||||
lbl_history_label = QLabel(_("History label"))
|
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 "
|
"Label applied to the will transactions saved into the wallet's "
|
||||||
"local history.\n"
|
"local history.\n"
|
||||||
"Variables:\n"
|
"Variables:\n"
|
||||||
" {willexecutor}: replaced with the will-executor URL of the item\n"
|
" {willexecutor}: replaced with the will-executor URL of the item\n"
|
||||||
"Only used in ADVANCED mode."
|
"Only used in ADVANCED mode."
|
||||||
)
|
))
|
||||||
grid.addWidget(_hide_if_basic(lbl_history_label), 12, 0)
|
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(edit_history_label), 12, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_history_label), 12, 2)
|
grid.addWidget(_hide_if_basic(help_history_label), 12, 2)
|
||||||
@@ -857,7 +863,7 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
grid.addWidget(heir_repush, 13, 0)
|
grid.addWidget(heir_repush, 13, 0)
|
||||||
grid.addWidget(
|
grid.addWidget(
|
||||||
HelpButton(
|
HelpButton(
|
||||||
"Broadcast all transactions to willexecutors including those already pushed"
|
_("Broadcast all transactions to willexecutors including those already pushed")
|
||||||
),
|
),
|
||||||
13,
|
13,
|
||||||
2,
|
2,
|
||||||
@@ -867,12 +873,12 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
# Placed below the rebroadcast button so the existing rows keep their
|
# Placed below the rebroadcast button so the existing rows keep their
|
||||||
# numbers.
|
# numbers.
|
||||||
lbl_rebuild_on_close = QLabel(_("Rebuild will on wallet close"))
|
lbl_rebuild_on_close = QLabel(_("Rebuild will on wallet close"))
|
||||||
help_rebuild_on_close = HelpButton(
|
help_rebuild_on_close = HelpButton(_(
|
||||||
"Run the 'Build your will' wizard every time the wallet is closed "
|
"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"
|
"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 "
|
"When disabled, the will is only rebuilt when you press Check or "
|
||||||
"Prepare. The last built state is still saved to the wallet."
|
"Prepare. The last built state is still saved to the wallet."
|
||||||
)
|
))
|
||||||
grid.addWidget(lbl_rebuild_on_close, 14, 0)
|
grid.addWidget(lbl_rebuild_on_close, 14, 0)
|
||||||
grid.addWidget(heir_rebuild_on_close, 14, 1)
|
grid.addWidget(heir_rebuild_on_close, 14, 1)
|
||||||
grid.addWidget(help_rebuild_on_close, 14, 2)
|
grid.addWidget(help_rebuild_on_close, 14, 2)
|
||||||
@@ -885,7 +891,7 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
# BASIC + ADVANCED), right below the "Rebuild will on wallet close"
|
# BASIC + ADVANCED), right below the "Rebuild will on wallet close"
|
||||||
# row.
|
# row.
|
||||||
lbl_auto_rebuild = QLabel(_("Rebuild automatically on new transactions"))
|
lbl_auto_rebuild = QLabel(_("Rebuild automatically on new transactions"))
|
||||||
help_auto_rebuild = HelpButton(
|
help_auto_rebuild = HelpButton(_(
|
||||||
"When a new transaction arrives for the wallet, automatically "
|
"When a new transaction arrives for the wallet, automatically "
|
||||||
"rebuild the will the same way the wizard does at wallet close: "
|
"rebuild the will the same way the wizard does at wallet close: "
|
||||||
"the delivery date is anticipated by one day so the new will "
|
"the delivery date is anticipated by one day so the new will "
|
||||||
@@ -896,7 +902,7 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
"threshold, or when the threshold is already in the past.\n"
|
"threshold, or when the threshold is already in the past.\n"
|
||||||
"When disabled (default), the will is only rebuilt on Check / "
|
"When disabled (default), the will is only rebuilt on Check / "
|
||||||
"Prepare / wallet close."
|
"Prepare / wallet close."
|
||||||
)
|
))
|
||||||
grid.addWidget(lbl_auto_rebuild, 15, 0)
|
grid.addWidget(lbl_auto_rebuild, 15, 0)
|
||||||
grid.addWidget(heir_auto_rebuild, 15, 1)
|
grid.addWidget(heir_auto_rebuild, 15, 1)
|
||||||
grid.addWidget(help_auto_rebuild, 15, 2)
|
grid.addWidget(help_auto_rebuild, 15, 2)
|
||||||
@@ -950,9 +956,9 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
elif kind == "spin":
|
elif kind == "spin":
|
||||||
widget.setValue(int(cfg.default))
|
widget.setValue(int(cfg.default))
|
||||||
elif kind == "line":
|
elif kind == "line":
|
||||||
widget.setText(cfg.default)
|
widget.setText(cfg.localized_default())
|
||||||
elif kind == "text":
|
elif kind == "text":
|
||||||
widget.setPlainText(cfg.default)
|
widget.setPlainText(cfg.localized_default())
|
||||||
elif kind == "user_type":
|
elif kind == "user_type":
|
||||||
# Default is "basic" -> combo index 0; "advanced" -> index 1.
|
# Default is "basic" -> combo index 0; "advanced" -> index 1.
|
||||||
widget.setCurrentIndex(
|
widget.setCurrentIndex(
|
||||||
@@ -987,7 +993,7 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
bottom_row = QHBoxLayout()
|
bottom_row = QHBoxLayout()
|
||||||
bottom_row.addWidget(btn_reset)
|
bottom_row.addWidget(btn_reset)
|
||||||
bottom_row.addStretch(1)
|
bottom_row.addStretch(1)
|
||||||
bottom_row.addWidget(QLabel("<b>" + _("Support:") + "</b>"))
|
bottom_row.addWidget(QLabel("<b>{}</b>".format(_("Support:"))))
|
||||||
bottom_row.addWidget(lbl_support)
|
bottom_row.addWidget(lbl_support)
|
||||||
|
|
||||||
# Outer layout: warning (top) -> settings grid -> bottom button row.
|
# Outer layout: warning (top) -> settings grid -> bottom button row.
|
||||||
@@ -1039,6 +1045,11 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_window_title(self, title):
|
def get_window_title(self, title):
|
||||||
return _("BAL - ") + _(title)
|
"""Return the window title "BAL - <title>".
|
||||||
|
|
||||||
|
``title`` must already be translated by the caller: a variable passed
|
||||||
|
to _() cannot be extracted into the catalog.
|
||||||
|
"""
|
||||||
|
return _("BAL - {}").format(title)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ def server_status_text(will_item) -> str:
|
|||||||
the user always knows whether each inheritance transaction is actually
|
the user always knows whether each inheritance transaction is actually
|
||||||
stored on the will-executor servers, regardless of the row colour.
|
stored on the will-executor servers, regardless of the row colour.
|
||||||
"""
|
"""
|
||||||
from electrum.i18n import _
|
from ...i18n import _
|
||||||
|
|
||||||
if will_item.get_status("CHECK_FAIL") and not will_item.get_status("CHECKED"):
|
if will_item.get_status("CHECK_FAIL") and not will_item.get_status("CHECKED"):
|
||||||
return _("Not on server")
|
return _("Not on server")
|
||||||
@@ -105,7 +105,7 @@ def server_status_text(will_item) -> str:
|
|||||||
def server_status_tooltip(will_item) -> str:
|
def server_status_tooltip(will_item) -> str:
|
||||||
"""Return a detailed tooltip for the "Server" column, including the
|
"""Return a detailed tooltip for the "Server" column, including the
|
||||||
will-executor URL (if any) and the current server state."""
|
will-executor URL (if any) and the current server state."""
|
||||||
from electrum.i18n import _
|
from ...i18n import _
|
||||||
|
|
||||||
url = None
|
url = None
|
||||||
we = getattr(will_item, "we", None)
|
we = getattr(will_item, "we", None)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ Contents:
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from ...core.checkalive import resolve_relative_check_alive_date
|
||||||
|
from ...core.heirs import get_op_return_hex, is_op_return_address
|
||||||
from ...core.input_rules import (
|
from ...core.input_rules import (
|
||||||
LockTimeEditor,
|
LockTimeEditor,
|
||||||
normalize_locktime_raw_text,
|
normalize_locktime_raw_text,
|
||||||
@@ -29,17 +31,16 @@ from ...core.input_rules import (
|
|||||||
from ...core.reminders import build_ics_reminders, write_temp_ics
|
from ...core.reminders import build_ics_reminders, write_temp_ics
|
||||||
from .calendar import BalCalendar, BalCalendarButton
|
from .calendar import BalCalendar, BalCalendarButton
|
||||||
from .common import (
|
from .common import (
|
||||||
_,
|
|
||||||
_logger,
|
|
||||||
Any,
|
|
||||||
BTCAmountEdit,
|
|
||||||
BalTimestamp,
|
|
||||||
ColorScheme,
|
|
||||||
DECIMAL_POINT,
|
DECIMAL_POINT,
|
||||||
Decimal,
|
N_,
|
||||||
HelpButton,
|
|
||||||
NLOCKTIME_BLOCKHEIGHT_MAX,
|
NLOCKTIME_BLOCKHEIGHT_MAX,
|
||||||
NLOCKTIME_MAX,
|
NLOCKTIME_MAX,
|
||||||
|
Any,
|
||||||
|
BalTimestamp,
|
||||||
|
BTCAmountEdit,
|
||||||
|
ColorScheme,
|
||||||
|
Decimal,
|
||||||
|
HelpButton,
|
||||||
Optional,
|
Optional,
|
||||||
QAbstractSpinBox,
|
QAbstractSpinBox,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
@@ -57,15 +58,18 @@ from .common import (
|
|||||||
QSpinBox,
|
QSpinBox,
|
||||||
QStyle,
|
QStyle,
|
||||||
QStyleOptionFrame,
|
QStyleOptionFrame,
|
||||||
|
Qt,
|
||||||
QTextEdit,
|
QTextEdit,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
Qt,
|
|
||||||
Union,
|
Union,
|
||||||
Util,
|
Util,
|
||||||
Will,
|
Will,
|
||||||
|
_,
|
||||||
|
_logger,
|
||||||
char_width_in_lineedit,
|
char_width_in_lineedit,
|
||||||
datetime,
|
datetime,
|
||||||
|
format_status_history,
|
||||||
getSaveFileName,
|
getSaveFileName,
|
||||||
log_error,
|
log_error,
|
||||||
os,
|
os,
|
||||||
@@ -206,14 +210,18 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
|||||||
current_index = None
|
current_index = None
|
||||||
default_value = None
|
default_value = None
|
||||||
|
|
||||||
help_text = (
|
help_text = N_(
|
||||||
"if you choose Raw, you can insert various options based on suffix:\n"
|
"if you choose Raw, you can insert various options based on suffix:\n"
|
||||||
+ " - d: number of days after current day(ex: 1d means tomorrow)\n"
|
" - d: number of days after current day(ex: 1d means tomorrow)\n"
|
||||||
+ " - y: number of years after currrent day(ex: 1y means one year from today)\n"
|
" - y: number of years after currrent day(ex: 1y means one year from today)\n"
|
||||||
)
|
)
|
||||||
label_text = None
|
label_text = None
|
||||||
tooltip_text = None
|
tooltip_text = None
|
||||||
base_field = None
|
base_field = None
|
||||||
|
# A leading "-" is accepted by the RAW editor only for the Check Alive
|
||||||
|
# field (its sign selects the anchor date); the delivery field never
|
||||||
|
# accepts a sign. Subclasses override this.
|
||||||
|
allow_negative_raw = False
|
||||||
|
|
||||||
def __init__(self, bal_window, parent, default_locktime=None):
|
def __init__(self, bal_window, parent, default_locktime=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -224,7 +232,9 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
|||||||
hbox.setContentsMargins(0, 0, 0, 0)
|
hbox.setContentsMargins(0, 0, 0, 0)
|
||||||
hbox.setSpacing(0)
|
hbox.setSpacing(0)
|
||||||
self.setMinimumWidth(40 * char_width_in_lineedit())
|
self.setMinimumWidth(40 * char_width_in_lineedit())
|
||||||
self.locktime_raw_e = TimeRawEditWidget(self, time_edit=self)
|
self.locktime_raw_e = TimeRawEditWidget(
|
||||||
|
self, time_edit=self, allow_negative=self.allow_negative_raw
|
||||||
|
)
|
||||||
self.locktime_date_e = LockTimeDateEdit(self, time_edit=self)
|
self.locktime_date_e = LockTimeDateEdit(self, time_edit=self)
|
||||||
self.editors = [self.locktime_raw_e, self.locktime_date_e]
|
self.editors = [self.locktime_raw_e, self.locktime_date_e]
|
||||||
self.combo = QComboBox()
|
self.combo = QComboBox()
|
||||||
@@ -275,7 +285,9 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
#hbox.addWidget(QLabel(self.label_text))
|
#hbox.addWidget(QLabel(self.label_text))
|
||||||
help_button=HelpButton(self.help_text)
|
# help_text and tooltip_text are class attributes marked with N_():
|
||||||
|
# translate them here, when shown.
|
||||||
|
help_button=HelpButton(_(self.help_text))
|
||||||
help_button.setText(self.label_text)
|
help_button.setText(self.label_text)
|
||||||
# Show a short label (e.g. "Delivery time" / "Check Alive") when the
|
# Show a short label (e.g. "Delivery time" / "Check Alive") when the
|
||||||
# user hovers the icon, so the emoji button is self-explanatory.
|
# user hovers the icon, so the emoji button is self-explanatory.
|
||||||
@@ -477,9 +489,9 @@ class TimeRawEditWidget(QWidget):
|
|||||||
def is_acceptable_locktime(self, value):
|
def is_acceptable_locktime(self, value):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def __init__(self, parent, time_edit=None):
|
def __init__(self, parent, time_edit=None, allow_negative=False):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.editor = LockTimeRawEdit(parent, time_edit)
|
self.editor = LockTimeRawEdit(parent, time_edit, allow_negative=allow_negative)
|
||||||
self.label = QLabel("")
|
self.label = QLabel("")
|
||||||
# Group C / C3: this trailing label shows the ABSOLUTE date computed
|
# Group C / C3: this trailing label shows the ABSOLUTE date computed
|
||||||
# from the RAW value (e.g. "30d" -> "2027-06-23"), so it needs room for a
|
# from the RAW value (e.g. "30d" -> "2027-06-23"), so it needs room for a
|
||||||
@@ -505,7 +517,7 @@ class TimeRawEditWidget(QWidget):
|
|||||||
|
|
||||||
|
|
||||||
class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
|
class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
|
||||||
def __init__(self, parent=None, time_edit=None):
|
def __init__(self, parent=None, time_edit=None, allow_negative=False):
|
||||||
QLineEdit.__init__(self, parent)
|
QLineEdit.__init__(self, parent)
|
||||||
# Group C / C3: narrow the RAW input to roughly a third of its former
|
# Group C / C3: narrow the RAW input to roughly a third of its former
|
||||||
# width. The accepted values are short relative durations such as "30d"
|
# width. The accepted values are short relative durations such as "30d"
|
||||||
@@ -515,16 +527,22 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
|
|||||||
self.textChanged.connect(self.numbify)
|
self.textChanged.connect(self.numbify)
|
||||||
self.isdays = False
|
self.isdays = False
|
||||||
self.isyears = False
|
self.isyears = False
|
||||||
|
# The Check Alive field accepts a leading "-" (the sign selects the
|
||||||
|
# anchor date); the delivery field does not.
|
||||||
|
self.allow_negative = allow_negative
|
||||||
self.time_edit = time_edit
|
self.time_edit = time_edit
|
||||||
|
|
||||||
def numbify(self):
|
def numbify(self):
|
||||||
# Only digits plus the day ("d") and year ("y") suffixes are accepted.
|
# Only digits plus the day ("d") and year ("y") suffixes are accepted
|
||||||
# The block-height suffix ("b") was removed (A1): locktimes are always
|
# (and, for the Check Alive field, a single leading "-"). The
|
||||||
# UNIX timestamps now, so block-relative input is no longer allowed.
|
# block-height suffix ("b") was removed (A1): locktimes are always UNIX
|
||||||
|
# timestamps now, so block-relative input is no longer allowed.
|
||||||
# The sanitisation itself lives in bal.core.input_rules.
|
# The sanitisation itself lives in bal.core.input_rules.
|
||||||
text = self.text().strip()
|
text = self.text().strip()
|
||||||
pos = self.cursorPosition()
|
pos = self.cursorPosition()
|
||||||
s, isdays, isyears, pos = normalize_locktime_raw_text(text, pos)
|
s, isdays, isyears, pos = normalize_locktime_raw_text(
|
||||||
|
text, pos, allow_negative=self.allow_negative
|
||||||
|
)
|
||||||
self.isdays = isdays
|
self.isdays = isdays
|
||||||
self.isyears = isyears
|
self.isyears = isyears
|
||||||
self.blockSignals(True)
|
self.blockSignals(True)
|
||||||
@@ -611,15 +629,20 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
|
|||||||
# Use the overflow-safe converter: on Windows datetime.fromtimestamp
|
# Use the overflow-safe converter: on Windows datetime.fromtimestamp
|
||||||
# raises OverflowError for timestamps past 2038 (e.g. NLOCKTIME_MAX).
|
# raises OverflowError for timestamps past 2038 (e.g. NLOCKTIME_MAX).
|
||||||
_dt = BalTimestamp._safe_fromtimestamp(x)
|
_dt = BalTimestamp._safe_fromtimestamp(x)
|
||||||
#if self.alarm != dt:
|
|
||||||
self.setDateTime(_dt)
|
|
||||||
self.alarm = _dt
|
self.alarm = _dt
|
||||||
|
# Store the LOCAL wall-clock time, not the aware-UTC datetime:
|
||||||
|
# QDateTimeEdit keeps the given wall time with a LocalTime spec, so
|
||||||
|
# an aware-UTC datetime would make get_value() read back a timezone-
|
||||||
|
# shifted epoch. That broke the set_value -> get_value roundtrip and
|
||||||
|
# kept the valueEdited -> update_setting_widgets -> set_value cycle
|
||||||
|
# firing forever (infinite RecursionError on wizard "Next").
|
||||||
|
self.setDateTime(_dt.astimezone().replace(tzinfo=None))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ThresholdTimeWidget(BalTimeEditWidget):
|
class ThresholdTimeWidget(BalTimeEditWidget):
|
||||||
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
|
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
|
||||||
help_text = (
|
help_text = N_(
|
||||||
"<b>CHECK ALIVE</b><br><br>"
|
"<b>CHECK ALIVE</b><br><br>"
|
||||||
"In DATA mode:<br>"
|
"In DATA mode:<br>"
|
||||||
"set the date for the \u201ccheck alive\u201d parameter.<br>"
|
"set the date for the \u201ccheck alive\u201d parameter.<br>"
|
||||||
@@ -633,13 +656,20 @@ class ThresholdTimeWidget(BalTimeEditWidget):
|
|||||||
"if you choose Raw, you can insert various options based on suffix:<br>"
|
"if you choose Raw, you can insert various options based on suffix:<br>"
|
||||||
" - d: number of days after current day(ex: 1d means tomorrow)<br>"
|
" - d: number of days after current day(ex: 1d means tomorrow)<br>"
|
||||||
" - y: number of years after current day(ex: 1y means one year from today)<br>"
|
" - y: number of years after current day(ex: 1y means one year from today)<br>"
|
||||||
|
"The sign selects the reference date:<br>"
|
||||||
|
" - a NEGATIVE value (ex: -30d) counts backwards from the "
|
||||||
|
"<b>delivery</b> date (30 days before the delivery);<br>"
|
||||||
|
" - a POSITIVE value (ex: 30d) counts forward from the "
|
||||||
|
"<b>inheritance creation</b> date (30 days after the will was created).<br>"
|
||||||
"<br><b>Note:</b> the date/time is expressed in your computer's "
|
"<br><b>Note:</b> the date/time is expressed in your computer's "
|
||||||
"<b>Local time</b> (not UTC time).<br>"
|
"<b>Local time</b> (not UTC time).<br>"
|
||||||
)
|
)
|
||||||
label_text = "🚨"
|
label_text = "🚨"
|
||||||
#label_text = "Check Alive"
|
#label_text = "Check Alive"
|
||||||
tooltip_text = "Check Alive"
|
tooltip_text = N_("Check Alive")
|
||||||
base_field = "threshold"
|
base_field = "threshold"
|
||||||
|
# The Check Alive field accepts a signed value; the delivery field does not.
|
||||||
|
allow_negative_raw = True
|
||||||
|
|
||||||
def __init__(self, bal_window, parent, init_value=None):
|
def __init__(self, bal_window, parent, init_value=None):
|
||||||
if init_value is None:
|
if init_value is None:
|
||||||
@@ -653,7 +683,7 @@ class ThresholdTimeWidget(BalTimeEditWidget):
|
|||||||
|
|
||||||
class LockTimeWidget(BalTimeEditWidget):
|
class LockTimeWidget(BalTimeEditWidget):
|
||||||
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
|
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
|
||||||
help_text = (
|
help_text = N_(
|
||||||
"<b>DELIVERY TIME</b><br><br>"
|
"<b>DELIVERY TIME</b><br><br>"
|
||||||
"Set Locktime for transactions.<br>"
|
"Set Locktime for transactions.<br>"
|
||||||
"Any time is needed transaction will be anticipated by 1day<br><br>"
|
"Any time is needed transaction will be anticipated by 1day<br><br>"
|
||||||
@@ -671,7 +701,7 @@ class LockTimeWidget(BalTimeEditWidget):
|
|||||||
#label_text = "Locktime"
|
#label_text = "Locktime"
|
||||||
# Hover tooltip for the delivery-time icon; mirrors the style of the fee
|
# Hover tooltip for the delivery-time icon; mirrors the style of the fee
|
||||||
# icon tooltip ("..., click for more information") so the two are consistent.
|
# icon tooltip ("..., click for more information") so the two are consistent.
|
||||||
tooltip_text = "Delivery Time, click for more information"
|
tooltip_text = N_("Delivery Time, click for more information")
|
||||||
base_field = "locktime"
|
base_field = "locktime"
|
||||||
|
|
||||||
def __init__(self, bal_window, parent, init_value=None):
|
def __init__(self, bal_window, parent, init_value=None):
|
||||||
@@ -1082,9 +1112,12 @@ class WillSettingsWidget(QWidget):
|
|||||||
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
|
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
|
||||||
if basic_mode:
|
if basic_mode:
|
||||||
# BASIC mode: use factory defaults (the hidden settings are
|
# BASIC mode: use factory defaults (the hidden settings are
|
||||||
# ignored) and the fixed 30/10/1 offsets.
|
# ignored) and the fixed 30/10/1 offsets. The defaults are in
|
||||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
# the GUI language (see BalConfig).
|
||||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
raw_description = (
|
||||||
|
self.bal_window.bal_plugin.EVENT_DESCRIPTION.localized_default()
|
||||||
|
)
|
||||||
|
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.localized_default()
|
||||||
threshold = None
|
threshold = None
|
||||||
num_reminders = 3
|
num_reminders = 3
|
||||||
else:
|
else:
|
||||||
@@ -1114,6 +1147,7 @@ class WillSettingsWidget(QWidget):
|
|||||||
version=self.bal_window.bal_plugin.version,
|
version=self.bal_window.bal_plugin.version,
|
||||||
num_reminders=num_reminders,
|
num_reminders=num_reminders,
|
||||||
threshold=threshold,
|
threshold=threshold,
|
||||||
|
reminder_suffix=_("(reminder {idx}/{total})"),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error(f"failed to generate .ics: {e}")
|
_logger.error(f"failed to generate .ics: {e}")
|
||||||
@@ -1121,18 +1155,23 @@ class WillSettingsWidget(QWidget):
|
|||||||
|
|
||||||
def on_locktime_change(self):
|
def on_locktime_change(self):
|
||||||
locktime = self.widgets["locktime"].get_value()
|
locktime = self.widgets["locktime"].get_value()
|
||||||
threshold = self.widgets["threshold"].get_value()
|
threshold_raw = self.widgets["threshold"].get_value()
|
||||||
locktime = BalTimestamp(locktime)
|
locktime = BalTimestamp(locktime)
|
||||||
threshold = BalTimestamp(threshold)
|
|
||||||
|
|
||||||
min_locktime = min(
|
min_locktime = min(
|
||||||
Will.get_min_locktime(self.bal_window.willitems, NLOCKTIME_MAX),
|
Will.get_min_locktime(self.bal_window.willitems, NLOCKTIME_MAX),
|
||||||
locktime.to_timestamp(),
|
locktime.to_timestamp(),
|
||||||
)
|
)
|
||||||
# Resolved Check Alive date. For relative values this counts BACKWARDS
|
# Resolved Check Alive date. The SIGN of the RAW value selects the
|
||||||
# from the delivery time (reverse=True): "90d" means "90 days BEFORE the
|
# anchor: negative counts backwards from the delivery, positive counts
|
||||||
# delivery", not "90 days from now".
|
# forward from the inheritance creation date. The rule lives in
|
||||||
td = threshold.to_date(min_locktime, True)
|
# ``bal.core.checkalive`` so this display matches every validity check.
|
||||||
|
resolved = resolve_relative_check_alive_date(
|
||||||
|
threshold_raw,
|
||||||
|
delivery_date=min_locktime,
|
||||||
|
creation_date=Will.get_creation_time(self.bal_window.willitems),
|
||||||
|
)
|
||||||
|
td = BalTimestamp._safe_fromtimestamp(resolved)
|
||||||
self.widgets["threshold"].alarm=td
|
self.widgets["threshold"].alarm=td
|
||||||
self.bal_window.will_settings["real_threshold"]=td.timestamp()
|
self.bal_window.will_settings["real_threshold"]=td.timestamp()
|
||||||
try:
|
try:
|
||||||
@@ -1193,7 +1232,7 @@ class PercAmountEdit(BTCAmountEdit):
|
|||||||
painter.drawText(
|
painter.drawText(
|
||||||
text_rect,
|
text_rect,
|
||||||
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
|
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
|
||||||
self.base_unit() + " or perc value",
|
_("{} or perc value").format(self.base_unit()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1311,11 +1350,12 @@ class WillWidget(QWidget):
|
|||||||
creation = str(BalTimestamp(self.will[w].time))
|
creation = str(BalTimestamp(self.will[w].time))
|
||||||
|
|
||||||
def qlabel(title, value):
|
def qlabel(title, value):
|
||||||
label = "<b>" + _(str(title)) + f":</b>\t{str(value)}"
|
# ``title`` is shown as it is: callers pass a translated
|
||||||
return QLabel(label)
|
# label, or data (an heir name, a will-executor URL).
|
||||||
|
return QLabel("<b>{}:</b>\t{}".format(title, value))
|
||||||
|
|
||||||
detaillayout.addWidget(qlabel("Locktime", locktime))
|
detaillayout.addWidget(qlabel(_("Locktime"), locktime))
|
||||||
detaillayout.addWidget(qlabel("Creation Time", creation))
|
detaillayout.addWidget(qlabel(_("Creation Time"), creation))
|
||||||
try:
|
try:
|
||||||
total_fees = (
|
total_fees = (
|
||||||
self.will[w].tx.input_value() - self.will[w].tx.output_value()
|
self.will[w].tx.input_value() - self.will[w].tx.output_value()
|
||||||
@@ -1325,25 +1365,46 @@ class WillWidget(QWidget):
|
|||||||
decoded_fees = total_fees
|
decoded_fees = total_fees
|
||||||
fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3)
|
fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3)
|
||||||
fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)"
|
fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)"
|
||||||
detaillayout.addWidget(qlabel("Transaction fees:", fees_str))
|
detaillayout.addWidget(qlabel(_("Transaction fees"), fees_str))
|
||||||
|
# The saved status history is English: translate it for display.
|
||||||
detaillayout.addWidget(
|
detaillayout.addWidget(
|
||||||
qlabel("Status:", self.will[w].status + signature_suffix(self.will[w]))
|
qlabel(
|
||||||
|
_("Status"),
|
||||||
|
format_status_history(self.will[w].status)
|
||||||
|
+ signature_suffix(self.will[w]),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
detaillayout.addWidget(QLabel(""))
|
detaillayout.addWidget(QLabel(""))
|
||||||
detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
|
detaillayout.addWidget(QLabel("<b>{}</b>".format(_("Heirs:"))))
|
||||||
for heir in self.will[w].heirs:
|
for heir_name in self.will[w].heirs:
|
||||||
if 'w!ll3x3c"' not in heir:
|
if 'w!ll3x3c"' in heir_name:
|
||||||
decoded_amount = Util.decode_amount(
|
continue
|
||||||
self.will[w].heirs[heir][3], self._bal_parent.decimal_point
|
h = self.will[w].heirs[heir_name]
|
||||||
)
|
decoded_amount = Util.decode_amount(
|
||||||
|
h[3], self._bal_parent.decimal_point
|
||||||
|
)
|
||||||
|
if is_op_return_address(h[0]):
|
||||||
|
data_hex = get_op_return_hex(h[0]) or ""
|
||||||
|
try:
|
||||||
|
decoded = bytes.fromhex(data_hex).decode(
|
||||||
|
"utf-8", errors="replace"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
decoded = h[0]
|
||||||
|
detaillayout.addWidget(qlabel(heir_name, "OP_RETURN: " + decoded))
|
||||||
|
else:
|
||||||
detaillayout.addWidget(
|
detaillayout.addWidget(
|
||||||
qlabel(
|
qlabel(
|
||||||
heir, f"{decoded_amount} {self._bal_parent.base_unit_name}"
|
heir_name,
|
||||||
|
f"{decoded_amount} {self._bal_parent.base_unit_name} "
|
||||||
|
f"[{h[0]}]",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if self.will[w].we:
|
if self.will[w].we:
|
||||||
detaillayout.addWidget(QLabel(""))
|
detaillayout.addWidget(QLabel(""))
|
||||||
detaillayout.addWidget(QLabel(_("<b>Willexecutor:</b:")))
|
detaillayout.addWidget(
|
||||||
|
QLabel("<b>{}</b>".format(_("Willexecutor:")))
|
||||||
|
)
|
||||||
decoded_amount = Util.decode_amount(
|
decoded_amount = Util.decode_amount(
|
||||||
self.will[w].we["base_fee"], self._bal_parent.decimal_point
|
self.will[w].we["base_fee"], self._bal_parent.decimal_point
|
||||||
)
|
)
|
||||||
@@ -1354,6 +1415,10 @@ class WillWidget(QWidget):
|
|||||||
f"{decoded_amount} {self._bal_parent.base_unit_name}",
|
f"{decoded_amount} {self._bal_parent.base_unit_name}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if self.will[w].we.get("address"):
|
||||||
|
detaillayout.addWidget(
|
||||||
|
qlabel(_("Address"), self.will[w].we["address"])
|
||||||
|
)
|
||||||
detaillayout.addStretch()
|
detaillayout.addStretch()
|
||||||
pal = QPalette()
|
pal = QPalette()
|
||||||
pal.setColor(
|
pal.setColor(
|
||||||
|
|||||||
@@ -20,13 +20,16 @@ import threading
|
|||||||
from electrum.util import MyEncoder
|
from electrum.util import MyEncoder
|
||||||
|
|
||||||
from ...core.checkalive import (
|
from ...core.checkalive import (
|
||||||
|
CheckAliveAfterDeliveryError,
|
||||||
CheckAliveError,
|
CheckAliveError,
|
||||||
check_alive_expired,
|
ensure_check_alive_valid,
|
||||||
resolve_date_to_check,
|
resolve_date_to_check,
|
||||||
|
resolve_delivery_date,
|
||||||
|
resolve_guard_threshold,
|
||||||
)
|
)
|
||||||
from .common import (
|
from .common import (
|
||||||
_,
|
N_,
|
||||||
_logger,
|
OP_RETURN_PREFIX,
|
||||||
AmountException,
|
AmountException,
|
||||||
BalPlugin,
|
BalPlugin,
|
||||||
Buttons,
|
Buttons,
|
||||||
@@ -40,10 +43,10 @@ from .common import (
|
|||||||
Mapping,
|
Mapping,
|
||||||
Network,
|
Network,
|
||||||
NoHeirsException,
|
NoHeirsException,
|
||||||
NoWillExecutorNotPresent,
|
|
||||||
NotCompleteWillException,
|
NotCompleteWillException,
|
||||||
OP_RETURN_PREFIX,
|
NoWillExecutorNotPresent,
|
||||||
OkButton,
|
OkButton,
|
||||||
|
Optional,
|
||||||
PaymentIdentifier,
|
PaymentIdentifier,
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -57,15 +60,17 @@ from .common import (
|
|||||||
TxFeesChangedException,
|
TxFeesChangedException,
|
||||||
Util,
|
Util,
|
||||||
Will,
|
Will,
|
||||||
|
WillexecutorChangeException,
|
||||||
WillExecutorFeeTooHighException,
|
WillExecutorFeeTooHighException,
|
||||||
WillExecutorNotPresent,
|
WillExecutorNotPresent,
|
||||||
|
Willexecutors,
|
||||||
WillExpiredException,
|
WillExpiredException,
|
||||||
WillItem,
|
WillItem,
|
||||||
WillPostponedException,
|
WillPostponedException,
|
||||||
WillexecutorChangeException,
|
_,
|
||||||
Willexecutors,
|
_logger,
|
||||||
char_width_in_lineedit,
|
char_width_in_lineedit,
|
||||||
copy,
|
copy_structure,
|
||||||
export_meta_gui,
|
export_meta_gui,
|
||||||
import_meta_gui,
|
import_meta_gui,
|
||||||
is_onion_url,
|
is_onion_url,
|
||||||
@@ -73,8 +78,8 @@ from .common import (
|
|||||||
is_tor_active,
|
is_tor_active,
|
||||||
log_error,
|
log_error,
|
||||||
partial,
|
partial,
|
||||||
read_QIcon_from_bytes,
|
|
||||||
read_json_file,
|
read_json_file,
|
||||||
|
read_QIcon_from_bytes,
|
||||||
show_on_top,
|
show_on_top,
|
||||||
shown_cv,
|
shown_cv,
|
||||||
time,
|
time,
|
||||||
@@ -88,6 +93,10 @@ from .dialogs import (
|
|||||||
BalWizardDialog,
|
BalWizardDialog,
|
||||||
WillDetailDialog,
|
WillDetailDialog,
|
||||||
WillExecutorDialog,
|
WillExecutorDialog,
|
||||||
|
WillExportDialog,
|
||||||
|
WillImportDialog,
|
||||||
|
_complete_import,
|
||||||
|
decode_will_payload,
|
||||||
)
|
)
|
||||||
from .lists import HeirListWidget, PreviewList
|
from .lists import HeirListWidget, PreviewList
|
||||||
from .widgets import LockTimeWidget, PercAmountEdit
|
from .widgets import LockTimeWidget, PercAmountEdit
|
||||||
@@ -276,12 +285,12 @@ class BalWindow:
|
|||||||
|
|
||||||
def new_heir_dialog(self, heir_key=None):
|
def new_heir_dialog(self, heir_key=None):
|
||||||
heir = self.heirs.get(heir_key)
|
heir = self.heirs.get(heir_key)
|
||||||
title = "New heir"
|
title = _("New heir")
|
||||||
if heir:
|
if heir:
|
||||||
title = f"Edit: {heir_key}"
|
title = _("Edit: {}").format(heir_key)
|
||||||
|
|
||||||
d = BalDialog(
|
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)
|
vbox = QVBoxLayout(d)
|
||||||
@@ -461,6 +470,32 @@ class BalWindow:
|
|||||||
|
|
||||||
def build_will(self, ignore_duplicate=True, keep_original=True):
|
def build_will(self, ignore_duplicate=True, keep_original=True):
|
||||||
_logger.debug("building will...")
|
_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,
|
||||||
|
creation_date=Will.get_creation_time(self.willitems),
|
||||||
|
)
|
||||||
will = {}
|
will = {}
|
||||||
# willtodelete = []
|
# willtodelete = []
|
||||||
# willtoappend = {}
|
# willtoappend = {}
|
||||||
@@ -515,11 +550,13 @@ class BalWindow:
|
|||||||
tx["my_locktime"] = txs[txid].my_locktime
|
tx["my_locktime"] = txs[txid].my_locktime
|
||||||
tx["heirsvalue"] = txs[txid].heirsvalue
|
tx["heirsvalue"] = txs[txid].heirsvalue
|
||||||
tx["description"] = txs[txid].description
|
tx["description"] = txs[txid].description
|
||||||
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
|
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
|
||||||
tx["status"] = _("New")
|
# Stored history: always English (translated only for display by
|
||||||
|
# format_status_history); never _() here.
|
||||||
|
tx["status"] = "New"
|
||||||
tx["baltx_fees"] = txs[txid].tx_fees
|
tx["baltx_fees"] = txs[txid].tx_fees
|
||||||
tx["time"] = creation_time
|
tx["time"] = creation_time
|
||||||
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
|
tx["heirs"] = copy_structure(txs[txid].heirs)
|
||||||
tx["txchildren"] = []
|
tx["txchildren"] = []
|
||||||
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
||||||
self.update_will(will)
|
self.update_will(will)
|
||||||
@@ -708,6 +745,7 @@ class BalWindow:
|
|||||||
self.bal_plugin.is_basic_mode(),
|
self.bal_plugin.is_basic_mode(),
|
||||||
self.will_settings,
|
self.will_settings,
|
||||||
built_locktime=Will.get_min_locktime(self.willitems),
|
built_locktime=Will.get_min_locktime(self.willitems),
|
||||||
|
creation_date=Will.get_creation_time(self.willitems),
|
||||||
)
|
)
|
||||||
# found = False
|
# found = False
|
||||||
# NOTE: block-height tracking removed (A1) - locktimes are always
|
# NOTE: block-height tracking removed (A1) - locktimes are always
|
||||||
@@ -718,19 +756,20 @@ class BalWindow:
|
|||||||
self.willexecutors = Willexecutors.get_willexecutors(
|
self.willexecutors = Willexecutors.get_willexecutors(
|
||||||
self.bal_plugin, update=True, bal_window=self, task=False
|
self.bal_plugin, update=True, bal_window=self, task=False
|
||||||
)
|
)
|
||||||
# SIMPLE / ADVANCED: in BASIC mode the "Check Alive" parameter must
|
# The Check Alive must be strictly after today and strictly before
|
||||||
# behave AS IF IT DID NOT EXIST. The check-alive threshold drives
|
# the delivery. In BASIC mode the single validity gate below is a
|
||||||
# the "you are alive -> postpone the inheritance" prompt; raising
|
# no-op (see ``bal.core.checkalive.ensure_check_alive_valid``), so a
|
||||||
# CheckAliveError here is what triggers that postpone/invalidate
|
# passed check-alive date never forces a postpone/rewrite; the
|
||||||
# flow. In BASIC we therefore SKIP this check entirely, so a passed
|
# delivery time (locktime) stays fully enforced.
|
||||||
# check-alive date never forces a postpone/rewrite of the will. The
|
delivery_date = resolve_delivery_date(
|
||||||
# delivery time (locktime) is unaffected and still fully enforced.
|
self.will_settings,
|
||||||
# The BASIC/ADVANCED rule lives in
|
built_locktime=Will.get_min_locktime(self.willitems),
|
||||||
# ``bal.core.checkalive.check_alive_expired``.
|
)
|
||||||
if check_alive_expired(
|
ensure_check_alive_valid(
|
||||||
self.bal_plugin.is_basic_mode(), self.date_to_check
|
self.bal_plugin.is_basic_mode(),
|
||||||
):
|
self.date_to_check,
|
||||||
raise CheckAliveError(self.date_to_check)
|
delivery_date=delivery_date,
|
||||||
|
)
|
||||||
|
|
||||||
self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get())
|
self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get())
|
||||||
|
|
||||||
@@ -740,6 +779,29 @@ class BalWindow:
|
|||||||
|
|
||||||
raise e
|
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,
|
||||||
|
creation_date=Will.get_creation_time(self.willitems),
|
||||||
|
)
|
||||||
|
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):
|
def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True):
|
||||||
try:
|
try:
|
||||||
_logger.info(
|
_logger.info(
|
||||||
@@ -752,6 +814,11 @@ class BalWindow:
|
|||||||
if not self.heirs:
|
if not self.heirs:
|
||||||
_logger.warning("not heirs {}".format(self.heirs))
|
_logger.warning("not heirs {}".format(self.heirs))
|
||||||
return
|
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:
|
try:
|
||||||
self.init_class_variables()
|
self.init_class_variables()
|
||||||
Will.check_amounts(
|
Will.check_amounts(
|
||||||
@@ -771,12 +838,19 @@ class BalWindow:
|
|||||||
except AmountException as e:
|
except AmountException as e:
|
||||||
self.show_warning(
|
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:
|
except WillExecutorFeeTooHighException as e:
|
||||||
self.show_error(
|
self.show_error(
|
||||||
_(f"Will-executor fee too high: {e}")
|
_("Will-executor fee too high: {}").format(e)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except CheckAliveAfterDeliveryError:
|
||||||
|
self.show_error(
|
||||||
|
_(
|
||||||
|
"Check Alive must be after today and before the delivery date, please update it"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
except CheckAliveError:
|
except CheckAliveError:
|
||||||
@@ -786,8 +860,7 @@ class BalWindow:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
if self.is_locktime_below_threshold():
|
||||||
if locktime < self.date_to_check:
|
|
||||||
self.show_error(_("locktime is lower than threshold"))
|
self.show_error(_("locktime is lower than threshold"))
|
||||||
return
|
return
|
||||||
if not self.no_willexecutor:
|
if not self.no_willexecutor:
|
||||||
@@ -863,27 +936,27 @@ class BalWindow:
|
|||||||
_logger.info("{}:{}".format(type(e), e))
|
_logger.info("{}:{}".format(type(e), e))
|
||||||
message = False
|
message = False
|
||||||
if isinstance(e, HeirChangeException):
|
if isinstance(e, HeirChangeException):
|
||||||
message = "Heirs changed:"
|
message = _("Heirs changed:")
|
||||||
elif isinstance(e, WillExecutorNotPresent):
|
elif isinstance(e, WillExecutorNotPresent):
|
||||||
message = "Will-Executor not present:"
|
message = _("Will-Executor not present:")
|
||||||
elif isinstance(e, WillexecutorChangeException):
|
elif isinstance(e, WillexecutorChangeException):
|
||||||
message = "Will-Executor changed"
|
message = _("Will-Executor changed")
|
||||||
elif isinstance(e, TxFeesChangedException):
|
elif isinstance(e, TxFeesChangedException):
|
||||||
message = "Txfees are changed"
|
message = _("Txfees are changed")
|
||||||
elif isinstance(e, HeirNotFoundException):
|
elif isinstance(e, HeirNotFoundException):
|
||||||
# Task #01b: replace the misleading "Heir not found" text.
|
# Task #01b: replace the misleading "Heir not found" text.
|
||||||
# This branch is most often hit because the delivery date
|
# This branch is most often hit because the delivery date
|
||||||
# was anticipated, not because an heir is missing, so we use
|
# was anticipated, not because an heir is missing, so we use
|
||||||
# a clear message that covers both the DATE and the HEIRS
|
# a clear message that covers both the DATE and the HEIRS
|
||||||
# cases (kept consistent with dialogs.py / the CHECK window).
|
# cases (kept consistent with dialogs.py / the CHECK window).
|
||||||
message = (
|
message = _(
|
||||||
"Found CHANGES to the DATE or the HEIRS,\n"
|
"Found CHANGES to the DATE or the HEIRS,\n"
|
||||||
"a NEW WILL must be prepared."
|
"a NEW WILL must be prepared."
|
||||||
)
|
)
|
||||||
|
|
||||||
if message:
|
if message:
|
||||||
self.show_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")
|
_logger.info("build will")
|
||||||
@@ -903,7 +976,7 @@ class BalWindow:
|
|||||||
self.invalidate_will()
|
self.invalidate_will()
|
||||||
except NotCompleteWillException as e:
|
except NotCompleteWillException as e:
|
||||||
self.show_error(
|
self.show_error(
|
||||||
"Error:{}\n {}".format(
|
_("Error:{}\n {}").format(
|
||||||
str(e),
|
str(e),
|
||||||
_("Please, check your heirs, locktime and threshold!"),
|
_("Please, check your heirs, locktime and threshold!"),
|
||||||
)
|
)
|
||||||
@@ -963,7 +1036,9 @@ class BalWindow:
|
|||||||
except SerializationError as e:
|
except SerializationError as e:
|
||||||
_logger.error("unable to deserialize the transaction")
|
_logger.error("unable to deserialize the transaction")
|
||||||
parent.show_critical(
|
parent.show_critical(
|
||||||
_("Electrum was unable to deserialize the transaction:") + "\n" + str(e)
|
"{}\n{}".format(
|
||||||
|
_("Electrum was unable to deserialize the transaction:"), e
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Electrum's own TxDialog: keep it in front of the main window.
|
# Electrum's own TxDialog: keep it in front of the main window.
|
||||||
@@ -980,6 +1055,13 @@ class BalWindow:
|
|||||||
return self.show_transaction_real(tx, parent=parent)
|
return self.show_transaction_real(tx, parent=parent)
|
||||||
|
|
||||||
def invalidate_will(self, will=None):
|
def invalidate_will(self, 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):
|
def on_success(result):
|
||||||
if result:
|
if result:
|
||||||
self.show_message(
|
self.show_message(
|
||||||
@@ -1015,75 +1097,93 @@ class BalWindow:
|
|||||||
self.waiting_dialog.exe()
|
self.waiting_dialog.exe()
|
||||||
|
|
||||||
def sign_transactions(self, password, will=None, txids=None):
|
def sign_transactions(self, password, will=None, txids=None):
|
||||||
try:
|
try:
|
||||||
willitems = will if will is not None else self.willitems
|
willitems = will if will is not None else self.willitems
|
||||||
txs = {}
|
txs = {}
|
||||||
signed = None
|
signed = None
|
||||||
tosign = None
|
tosign = None
|
||||||
|
|
||||||
def get_message():
|
def get_message():
|
||||||
msg = ""
|
msg = ""
|
||||||
if signed:
|
if signed:
|
||||||
msg = _(f"signed: {signed}\n")
|
msg = _("signed: {}").format(signed) + "\n"
|
||||||
return msg + _(f"signing: {tosign}")
|
return msg + _("signing: {}").format(tosign)
|
||||||
|
|
||||||
if txids is not None:
|
if txids is not None:
|
||||||
targets = [
|
targets = [
|
||||||
t for t in txids
|
t for t in txids
|
||||||
if t in willitems and willitems[t].get_status("VALID")
|
if t in willitems and willitems[t].get_status("VALID")
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
targets = Will.only_valid(willitems)
|
targets = Will.only_valid(willitems)
|
||||||
for txid in targets:
|
for txid in targets:
|
||||||
wi = willitems[txid]
|
wi = willitems[txid]
|
||||||
# Do NOT deepcopy: the stored tx carries wallet-derived objects
|
if wi.get_status("COMPLETE"):
|
||||||
# (utxo / script_descriptor) that hold a threading.RLock, and
|
# Already signed and complete: keep as-is (the single-tx
|
||||||
# copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse
|
# helper short-circuits without touching the wallet).
|
||||||
# from the serialized form instead, which is exactly how the will
|
tx, _ = self._prepare_and_sign_tx(willitems, txid, password)
|
||||||
# is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any).
|
txs[txid] = tx
|
||||||
tx = Will.get_tx_from_any(str(wi.tx))
|
continue
|
||||||
if wi.get_status("COMPLETE"):
|
tosign = txid
|
||||||
|
try:
|
||||||
|
self.waiting_dialog.update(get_message())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
tx, _signed = self._prepare_and_sign_tx(willitems, txid, password)
|
||||||
|
signed = tosign
|
||||||
txs[txid] = tx
|
txs[txid] = tx
|
||||||
continue
|
except Exception:
|
||||||
tosign = txid
|
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:
|
||||||
|
change = willitems[prevout[0]].tx.outputs()[prevout[1]]
|
||||||
|
txin._trusted_value_sats = change.value
|
||||||
try:
|
try:
|
||||||
self.waiting_dialog.update(get_message())
|
txin.script_descriptor = change.script_descriptor
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
for txin in tx.inputs():
|
txin.is_mine = True
|
||||||
prevout = txin.prevout.to_json()
|
txin._TxInput__address = change.address
|
||||||
if prevout[0] in willitems:
|
txin._TxInput__scriptpubkey = change.scriptpubkey
|
||||||
change = willitems[prevout[0]].tx.outputs()[prevout[1]]
|
txin._TxInput__value_sats = change.value
|
||||||
txin._trusted_value_sats = change.value
|
txin._trusted_value_sats = change.value
|
||||||
try:
|
|
||||||
txin.script_descriptor = change.script_descriptor
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
txin.is_mine = True
|
|
||||||
txin._TxInput__address = change.address
|
|
||||||
txin._TxInput__scriptpubkey = change.scriptpubkey
|
|
||||||
txin._TxInput__value_sats = change.value
|
|
||||||
|
|
||||||
self.wallet.sign_transaction(tx, password, ignore_warnings=True)
|
self.wallet.sign_transaction(tx, password, ignore_warnings=True)
|
||||||
signed = tosign
|
if tx.is_complete():
|
||||||
# is_complete = False
|
wi.set_status("COMPLETE", True)
|
||||||
if tx.is_complete():
|
# Refresh the per-item signature counts from the freshly signed
|
||||||
# is_complete = True
|
# partial tx: at this point the signatures are still present
|
||||||
wi.set_status("COMPLETE", True)
|
# (before any finalization), so the will list can show the real
|
||||||
# Refresh the per-item signature counts from the freshly signed
|
# "added/required" count (e.g. "1/2" for a multisig).
|
||||||
# partial tx: at this point the signatures are still present
|
try:
|
||||||
# (before any finalization), so the will list can show the real
|
have, required = tx.signature_count()
|
||||||
# "added/required" count (e.g. "1/2" for a multisig).
|
wi.sigs_have = int(have)
|
||||||
try:
|
wi.sigs_required = int(required)
|
||||||
have, required = tx.signature_count()
|
except Exception as e:
|
||||||
wi.sigs_have = int(have)
|
_logger.debug(f"signature_count after signing failed: {e}")
|
||||||
wi.sigs_required = int(required)
|
return tx, True
|
||||||
except Exception as e:
|
|
||||||
_logger.debug(f"signature_count after signing failed: {e}")
|
|
||||||
txs[txid] = tx
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return txs
|
|
||||||
|
|
||||||
def get_wallet_password(self, message=None, parent=None):
|
def get_wallet_password(self, message=None, parent=None):
|
||||||
parent = self.window if not parent else parent
|
parent = self.window if not parent else parent
|
||||||
@@ -1214,6 +1314,12 @@ class BalWindow:
|
|||||||
# means the old will must be invalidated on-chain.
|
# means the old will must be invalidated on-chain.
|
||||||
try:
|
try:
|
||||||
self.init_class_variables()
|
self.init_class_variables()
|
||||||
|
except CheckAliveAfterDeliveryError:
|
||||||
|
_logger.info(
|
||||||
|
"auto rebuild: check-alive not before the delivery, "
|
||||||
|
"nothing to rebuild/invalidate"
|
||||||
|
)
|
||||||
|
return False
|
||||||
except CheckAliveError:
|
except CheckAliveError:
|
||||||
_logger.info("auto rebuild: check-alive threshold passed -> invalidate")
|
_logger.info("auto rebuild: check-alive threshold passed -> invalidate")
|
||||||
self._auto_invalidate_will()
|
self._auto_invalidate_will()
|
||||||
@@ -1529,7 +1635,7 @@ class BalWindow:
|
|||||||
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
|
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
|
||||||
|
|
||||||
def getMsg(willexecutors):
|
def getMsg(willexecutors):
|
||||||
msg = "Broadcasting Transactions to Will-Executors:\n"
|
msg = _("Broadcasting Transactions to Will-Executors:") + "\n"
|
||||||
for url in willexecutors:
|
for url in willexecutors:
|
||||||
msg += f"{url}:\t{willexecutors[url]['broadcast_status']}\n"
|
msg += f"{url}:\t{willexecutors[url]['broadcast_status']}\n"
|
||||||
return msg
|
return msg
|
||||||
@@ -1583,8 +1689,8 @@ class BalWindow:
|
|||||||
if self.waiting_dialog._stopping:
|
if self.waiting_dialog._stopping:
|
||||||
return
|
return
|
||||||
self.waiting_dialog.update(
|
self.waiting_dialog.update(
|
||||||
"checking {} - {} : {}".format(
|
_("checking {} - {} : {}").format(
|
||||||
willitems[wid].we["url"], wid, "Waiting"
|
willitems[wid].we["url"], wid, _("Waiting")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
w = willitems[wid]
|
w = willitems[wid]
|
||||||
@@ -1592,7 +1698,7 @@ class BalWindow:
|
|||||||
Willexecutors.check_transaction(wid, w.we["url"])
|
Willexecutors.check_transaction(wid, w.we["url"])
|
||||||
)
|
)
|
||||||
self.waiting_dialog.update(
|
self.waiting_dialog.update(
|
||||||
"checked {} - {} : {}".format(
|
_("checked {} - {} : {}").format(
|
||||||
willitems[wid].we["url"],
|
willitems[wid].we["url"],
|
||||||
wid,
|
wid,
|
||||||
willitems[wid].get_status("CHECKED"),
|
willitems[wid].get_status("CHECKED"),
|
||||||
@@ -1611,6 +1717,19 @@ class BalWindow:
|
|||||||
else:
|
else:
|
||||||
write_json_file(path, {wid: wi.to_dict() for wid, wi in will.items()})
|
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):
|
def export_will(self, will=None):
|
||||||
try:
|
try:
|
||||||
export_meta_gui(
|
export_meta_gui(
|
||||||
@@ -1620,6 +1739,73 @@ class BalWindow:
|
|||||||
self.show_error(str(e))
|
self.show_error(str(e))
|
||||||
raise 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):
|
def merge_will(self, imported):
|
||||||
"""Merge imported will items into the live will.
|
"""Merge imported will items into the live will.
|
||||||
|
|
||||||
@@ -1743,16 +1929,34 @@ class BalWindow:
|
|||||||
|
|
||||||
def on_file(path):
|
def on_file(path):
|
||||||
try:
|
try:
|
||||||
willitems = self._load_will_file(path)
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.show_error(_("Invalid will file: {}").format(e))
|
self.show_error(_("Invalid will file: {}").format(e))
|
||||||
return
|
return
|
||||||
# Attach wallet/input info so the imported txs can be signed and
|
kind, data = decode_will_payload(text)
|
||||||
# broadcast (mirrors what merge_will_from_file does).
|
try:
|
||||||
Will.normalize_will(willitems, self.wallet)
|
if kind == "will":
|
||||||
for wi in willitems.values():
|
willitems = self._load_will_payload(data)
|
||||||
wi.set_status("IMPORTED", True)
|
# Attach wallet/input info so the imported txs can be
|
||||||
imported.update(willitems)
|
# 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():
|
def on_success():
|
||||||
if not imported:
|
if not imported:
|
||||||
@@ -1762,6 +1966,18 @@ class BalWindow:
|
|||||||
|
|
||||||
import_meta_gui(self.window, _("will"), on_file, on_success)
|
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):
|
def _load_will_file(self, path):
|
||||||
data = read_json_file(path)
|
data = read_json_file(path)
|
||||||
willitems = {}
|
willitems = {}
|
||||||
@@ -1770,6 +1986,15 @@ class BalWindow:
|
|||||||
willitems[k] = WillItem(data[k], _id=k)
|
willitems[k] = WillItem(data[k], _id=k)
|
||||||
return willitems
|
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):
|
def check_transactions_task(self, will):
|
||||||
start = time.time()
|
start = time.time()
|
||||||
# Servers are now contacted in parallel (see
|
# Servers are now contacted in parallel (see
|
||||||
@@ -1942,7 +2167,7 @@ class BalWindow:
|
|||||||
|
|
||||||
# Simple, user-facing message shown when the download fails for any reason
|
# Simple, user-facing message shown when the download fails for any reason
|
||||||
# (the technical cause is in the Electrum log).
|
# (the technical cause is in the Electrum log).
|
||||||
DOWNLOAD_FAILED_MESSAGE = (
|
DOWNLOAD_FAILED_MESSAGE = N_(
|
||||||
"Could not download the will-executors list.\n\n"
|
"Could not download the will-executors list.\n\n"
|
||||||
"This is usually caused by your internet connection or a firewall, "
|
"This is usually caused by your internet connection or a firewall, "
|
||||||
"not by the plugin. Please check your connection (a VPN often helps) "
|
"not by the plugin. Please check your connection (a VPN often helps) "
|
||||||
@@ -1952,7 +2177,7 @@ class BalWindow:
|
|||||||
# Shown when the download fails while Electrum is connected through Tor:
|
# Shown when the download fails while Electrum is connected through Tor:
|
||||||
# the most common cause is a slow Tor connection, so guide the user
|
# the most common cause is a slow Tor connection, so guide the user
|
||||||
# accordingly instead of the generic message above.
|
# 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"
|
"Could not download the will-executors list over Tor.\n\n"
|
||||||
"Electrum is connected through Tor and the connection is taking too "
|
"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 "
|
"long. Your Tor connection may be slow. Please try again, or use a VPN "
|
||||||
@@ -2025,27 +2250,27 @@ class BalWindow:
|
|||||||
if err.url and err.reason == "empty response":
|
if err.url and err.reason == "empty response":
|
||||||
# Server reached but returned no data for this chain.
|
# Server reached but returned no data for this chain.
|
||||||
self.show_warning(_(
|
self.show_warning(_(
|
||||||
f"No active will-executor servers found for the "
|
"No active will-executor servers found for the "
|
||||||
f"{err.chain} network.\n\n"
|
"{chain} network.\n\n"
|
||||||
f"The welist server at {err.url} responded but "
|
"The welist server at {url} responded but "
|
||||||
f"returned no will-executors for this chain."
|
"returned no will-executors for this chain."
|
||||||
))
|
).format(chain=err.chain, url=err.url))
|
||||||
elif err.url:
|
elif err.url:
|
||||||
# Advanced mode with a non-empty error (network issue).
|
# Advanced mode with a non-empty error (network issue).
|
||||||
self.show_warning(_(
|
self.show_warning(_(
|
||||||
f"Could not reach the configured welist server.\n\n"
|
"Could not reach the configured welist server.\n\n"
|
||||||
f"Server: {err.url}\n"
|
"Server: {url}\n"
|
||||||
f"Error: {err.reason}\n\n"
|
"Error: {reason}\n\n"
|
||||||
f"Please verify the welist server URL in the plugin "
|
"Please verify the welist server URL in the plugin "
|
||||||
f"settings."
|
"settings."
|
||||||
))
|
).format(url=err.url, reason=err.reason))
|
||||||
else:
|
else:
|
||||||
# Basic mode: the server responded but has no data for this
|
# Basic mode: the server responded but has no data for this
|
||||||
# chain.
|
# chain.
|
||||||
self.show_warning(_(
|
self.show_warning(_(
|
||||||
f"No active will-executor found for the "
|
"No active will-executor found for the "
|
||||||
f"{err.chain} network."
|
"{chain} network."
|
||||||
))
|
).format(chain=err.chain))
|
||||||
else:
|
else:
|
||||||
# Tor-aware: a generic failure/timeout while on Tor is most
|
# Tor-aware: a generic failure/timeout while on Tor is most
|
||||||
# likely a slow Tor connection.
|
# 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)
|
||||||
1820
bal/locale/bal.pot
Normal file
1820
bal/locale/bal.pot
Normal file
File diff suppressed because it is too large
Load Diff
2033
bal/locale/es_ES/LC_MESSAGES/bal.po
Normal file
2033
bal/locale/es_ES/LC_MESSAGES/bal.po
Normal file
File diff suppressed because it is too large
Load Diff
2033
bal/locale/it_IT/LC_MESSAGES/bal.po
Normal file
2033
bal/locale/it_IT/LC_MESSAGES/bal.po
Normal file
File diff suppressed because it is too large
Load Diff
55
build_zip.py
55
build_zip.py
@@ -9,7 +9,12 @@ Electrum loads external plugins from a ``.zip`` using Python's ``zipimport``.
|
|||||||
* uses standard DEFLATE compression (well supported by ``zipimport``);
|
* uses standard DEFLATE compression (well supported by ``zipimport``);
|
||||||
* emits entries in a deterministic, sorted order so the archive is
|
* emits entries in a deterministic, sorted order so the archive is
|
||||||
reproducible (stable SHA-256);
|
reproducible (stable SHA-256);
|
||||||
* skips ``__pycache__`` directories and compiled ``*.pyc``/``*.pyo`` files.
|
* skips ``__pycache__`` directories and compiled ``*.pyc``/``*.pyo`` files;
|
||||||
|
* compiles each translation catalog ``bal/locale/<lang>/LC_MESSAGES/bal.po``
|
||||||
|
into ``bal.mo`` inside the archive (the ``.mo`` files are not kept in
|
||||||
|
git, and the ``.po``/``.pot`` sources are not shipped). This needs Babel
|
||||||
|
(``pip install babel``); without it the build stops, so a release can
|
||||||
|
never ship without its translations by mistake.
|
||||||
|
|
||||||
The archive keeps the top-level ``bal/`` directory so that the package is
|
The archive keeps the top-level ``bal/`` directory so that the package is
|
||||||
importable as ``bal`` (and Electrum derives ``dirname='bal'`` from the path of
|
importable as ``bal`` (and Electrum derives ``dirname='bal'`` from the path of
|
||||||
@@ -23,14 +28,36 @@ Prints the resulting size and SHA-256 so the download can be integrity-checked.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
SRC_ROOT = "bal"
|
SRC_ROOT = "bal"
|
||||||
DEFAULT_OUT = "bal-electrum-plugin.zip"
|
DEFAULT_OUT = "bal-electrum-plugin.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def compile_catalog(po_path: str) -> bytes:
|
||||||
|
"""Return the compiled ``.mo`` bytes of the gettext catalog ``po_path``.
|
||||||
|
|
||||||
|
Fuzzy (unreviewed) entries are left out, as ``msgfmt`` does.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from babel.messages.mofile import write_mo
|
||||||
|
from babel.messages.pofile import read_po
|
||||||
|
except ImportError:
|
||||||
|
raise SystemExit(
|
||||||
|
"ERROR: Babel is needed to compile the translations "
|
||||||
|
"(pip install babel)"
|
||||||
|
) from None
|
||||||
|
with open(po_path, "rb") as f:
|
||||||
|
catalog = read_po(f)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
write_mo(buf, catalog, use_fuzzy=False)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def build(out_path: str) -> None:
|
def build(out_path: str) -> None:
|
||||||
if os.path.exists(out_path):
|
if os.path.exists(out_path):
|
||||||
os.remove(out_path)
|
os.remove(out_path)
|
||||||
@@ -40,17 +67,34 @@ def build(out_path: str) -> None:
|
|||||||
# prune cache dirs in place so os.walk does not descend into them
|
# prune cache dirs in place so os.walk does not descend into them
|
||||||
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
|
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
|
||||||
for fn in filenames:
|
for fn in filenames:
|
||||||
if fn.endswith((".pyc", ".pyo")):
|
# .mo files are rebuilt from the .po sources below; the .po/.pot
|
||||||
|
# sources themselves are not shipped.
|
||||||
|
if fn.endswith((".pyc", ".pyo", ".po", ".pot", ".mo")):
|
||||||
continue
|
continue
|
||||||
files.append(os.path.join(dirpath, fn))
|
files.append(os.path.join(dirpath, fn))
|
||||||
|
catalogs = {} # archive path of the .mo -> source .po
|
||||||
|
for dirpath, _dirnames, filenames in os.walk(os.path.join(SRC_ROOT, "locale")):
|
||||||
|
for fn in filenames:
|
||||||
|
if fn.endswith(".po"):
|
||||||
|
po = os.path.join(dirpath, fn)
|
||||||
|
catalogs[po[: -len(".po")] + ".mo"] = po
|
||||||
files.sort()
|
files.sort()
|
||||||
|
|
||||||
with zipfile.ZipFile(
|
with zipfile.ZipFile(
|
||||||
out_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
|
out_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
|
||||||
) as z:
|
) as z:
|
||||||
for f in files:
|
entries = [(f, None) for f in files] + [
|
||||||
|
(mo, po) for mo, po in catalogs.items()
|
||||||
|
]
|
||||||
|
for f, po in sorted(entries):
|
||||||
arc = f.replace(os.sep, "/") # forward slashes inside the archive
|
arc = f.replace(os.sep, "/") # forward slashes inside the archive
|
||||||
z.write(f, arc)
|
if po is None:
|
||||||
|
z.write(f, arc)
|
||||||
|
else:
|
||||||
|
# Same timestamp rule as z.write(): the source file's mtime.
|
||||||
|
info = zipfile.ZipInfo(arc, time.localtime(os.path.getmtime(po))[:6])
|
||||||
|
info.compress_type = zipfile.ZIP_DEFLATED
|
||||||
|
z.writestr(info, compile_catalog(po))
|
||||||
|
|
||||||
# Integrity + summary
|
# Integrity + summary
|
||||||
with zipfile.ZipFile(out_path) as z:
|
with zipfile.ZipFile(out_path) as z:
|
||||||
@@ -63,7 +107,8 @@ def build(out_path: str) -> None:
|
|||||||
|
|
||||||
data = open(out_path, "rb").read()
|
data = open(out_path, "rb").read()
|
||||||
print(f"built : {out_path}")
|
print(f"built : {out_path}")
|
||||||
print(f"files : {len(files)}")
|
print(f"files : {len(files) + len(catalogs)}")
|
||||||
|
print(f"langs : {', '.join(sorted(os.path.basename(os.path.dirname(os.path.dirname(m))) for m in catalogs)) or '-'}")
|
||||||
print(f"size : {len(data)} bytes")
|
print(f"size : {len(data)} bytes")
|
||||||
print(f"sha256: {hashlib.sha256(data).hexdigest()}")
|
print(f"sha256: {hashlib.sha256(data).hexdigest()}")
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ you change anything in your will, BAL must decide between three outcomes:
|
|||||||
The whole point of rule 3 is safety: **a will‑executor must never be able to
|
The whole point of rule 3 is safety: **a will‑executor must never be able to
|
||||||
broadcast an old transaction that would execute your inheritance too early.**
|
broadcast an old transaction that would execute your inheritance too early.**
|
||||||
|
|
||||||
|
**Check‑Alive relative values are signed.** A RELATIVE Check‑Alive
|
||||||
|
(``-30d`` / ``30d``) is anchored according to its sign:
|
||||||
|
|
||||||
|
- **NEGATIVE** (``-30d``) counts **backwards from the delivery date** — the
|
||||||
|
classic "check alive 30 days before the delivery".
|
||||||
|
- **POSITIVE** (``30d``) counts **forward from the inheritance creation date**
|
||||||
|
(the date the will was built), so it stays fixed while the will is unchanged.
|
||||||
|
|
||||||
|
The Check‑Alive date must always be **after today** and **before the delivery**;
|
||||||
|
when it is not, BAL raises `CheckAliveAfterDeliveryError` (a configuration error
|
||||||
|
that aborts the action and never invalidates the will).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Transaction states (status flags)
|
## 2. Transaction states (status flags)
|
||||||
@@ -164,6 +176,7 @@ exactly what the will‑executors hold — not the in‑memory copy.
|
|||||||
| **Move date EARLIER, still in the future** (anticipate) | **any** (signed or not) | Plain **rebuild** with the new earlier locktime (`HeirNotFound` fall‑through) — **never** an on‑chain invalidation | **No** |
|
| **Move date EARLIER, still in the future** (anticipate) | **any** (signed or not) | Plain **rebuild** with the new earlier locktime (`HeirNotFound` fall‑through) — **never** an on‑chain invalidation | **No** |
|
||||||
| **Move date EARLIER into the past** (new date already passed) | — | Will is genuinely **expired** → **invalidate** (`WillExpired`) | **Yes** |
|
| **Move date EARLIER into the past** (new date already passed) | — | Will is genuinely **expired** → **invalidate** (`WillExpired`) | **Yes** |
|
||||||
| Check‑Alive threshold already passed | — | **Invalidate** (`CheckAliveError`) | **Yes** |
|
| Check‑Alive threshold already passed | — | **Invalidate** (`CheckAliveError`) | **Yes** |
|
||||||
|
| Check‑Alive date not before the delivery (config error) | — | **Abort**, no rebuild/invalidate (`CheckAliveAfterDeliveryError`) | **No** |
|
||||||
|
|
||||||
> **Why postpone needs an on‑chain invalidation:** the will‑executor still holds
|
> **Why postpone needs an on‑chain invalidation:** the will‑executor still holds
|
||||||
> the *old* transaction with the *earlier* locktime. If you simply re‑signed a
|
> the *old* transaction with the *earlier* locktime. If you simply re‑signed a
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# ./make-release.sh # read version from bal/manifest.json
|
# ./make-release.sh # read version from bal/manifest.json
|
||||||
# ./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
|
# ./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
|
||||||
#
|
#
|
||||||
# Requires: git, gpg, curl, python3, sha256sum
|
# Requires: git, gpg, curl, python3, sha256sum, Babel for python3 (pip install babel)
|
||||||
# Optional: ruff (lint skipped if not installed)
|
# Optional: ruff (lint skipped if not installed)
|
||||||
# Credentials: ~/.git-credentials or GITEA_USER / GITEA_TOKEN env vars
|
# Credentials: ~/.git-credentials or GITEA_USER / GITEA_TOKEN env vars
|
||||||
|
|
||||||
@@ -33,6 +33,12 @@ else
|
|||||||
NEW_VER=""
|
NEW_VER=""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Babel is needed by the i18n tests (step 3) and by build_zip.py, which
|
||||||
|
# compiles the translation catalogs into the zip (step 5). Check it up front
|
||||||
|
# so a release never stops half-way.
|
||||||
|
python3 -c "import babel" 2>/dev/null \
|
||||||
|
|| die "Babel is missing for $(command -v python3): pip install babel"
|
||||||
|
|
||||||
echo "=== Release ${TAG} ==="
|
echo "=== Release ${TAG} ==="
|
||||||
echo "Current manifest version: ${CURRENT_VER}"
|
echo "Current manifest version: ${CURRENT_VER}"
|
||||||
[ -n "$NEW_VER" ] && echo "New version (will bump): ${NEW_VER}"
|
[ -n "$NEW_VER" ] && echo "New version (will bump): ${NEW_VER}"
|
||||||
@@ -68,6 +74,8 @@ if QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
|
|||||||
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
|
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
|
||||||
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
|
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
|
||||||
tests/test_group_h_v048.py \
|
tests/test_group_h_v048.py \
|
||||||
|
tests/test_i18n.py tests/test_translations.py \
|
||||||
|
tests/test_cli_*.py tests/test_import_will_details.py \
|
||||||
-q 2>&1; then
|
-q 2>&1; then
|
||||||
echo "All tests passed."
|
echo "All tests passed."
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -3,9 +3,18 @@ line-length = 88
|
|||||||
target-version = "py312"
|
target-version = "py312"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[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"]
|
ignore = ["E501"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
# Classify the plugin package explicitly: otherwise ruff decides from where it
|
||||||
|
# is run / how the project root is detected, and the same import block is
|
||||||
|
# "sorted" on one machine and I001 on another.
|
||||||
|
known-first-party = ["bal"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.pep8-naming]
|
||||||
|
classmethod-decorators = ["classmethod", "classproperty"] # electrum.util.classproperty uses cls
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"bal/gui/qt/common.py" = ["F401"] # re-exports consumed via explicit imports
|
"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/dialogs.py" = ["N802"] # Qt overrides: closeEvent/hideEvent/getText
|
||||||
|
|||||||
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
|
||||||
2700
tests/karen7
2700
tests/karen7
File diff suppressed because it is too large
Load Diff
@@ -21,12 +21,12 @@ Run:
|
|||||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from bal.core.util import copy_structure
|
||||||
from bal.core.will import (
|
from bal.core.will import (
|
||||||
HeirNotFoundException,
|
HeirNotFoundException,
|
||||||
NoHeirsException,
|
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)."""
|
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
|
||||||
d = {
|
d = {
|
||||||
"tx": _VALID_TX_HEX,
|
"tx": _VALID_TX_HEX,
|
||||||
"heirs": copy.deepcopy(heirs),
|
"heirs": copy_structure(heirs),
|
||||||
"willexecutor": None,
|
"willexecutor": None,
|
||||||
"status": "",
|
"status": "",
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -67,7 +67,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
|||||||
"baltx_fees": TX_FEES,
|
"baltx_fees": TX_FEES,
|
||||||
}
|
}
|
||||||
item = WillItem(d, _id="willid_1")
|
item = WillItem(d, _id="willid_1")
|
||||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
# Force the locktime frozen "inside" the signed tx.
|
# Force the locktime frozen "inside" the signed tx.
|
||||||
item.tx.locktime = tx_locktime
|
item.tx.locktime = tx_locktime
|
||||||
if status_complete:
|
if status_complete:
|
||||||
@@ -118,7 +118,7 @@ def main():
|
|||||||
# Scenario 0: nothing changed -> should be coherent.
|
# Scenario 0: nothing changed -> should be coherent.
|
||||||
heirs = {"alice": ["addr_alice", 5000, same_lt]}
|
heirs = {"alice": ["addr_alice", 5000, same_lt]}
|
||||||
_run("0. nothing changed",
|
_run("0. nothing changed",
|
||||||
will_heirs=heirs, current_heirs=copy.deepcopy(heirs),
|
will_heirs=heirs, current_heirs=copy_structure(heirs),
|
||||||
tx_locktime=base_lt, check_date=0)
|
tx_locktime=base_lt, check_date=0)
|
||||||
|
|
||||||
# Scenario 1: delivery date moved forward (postpone), will NOT yet signed.
|
# Scenario 1: delivery date moved forward (postpone), will NOT yet signed.
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ Run:
|
|||||||
tests/test_anticipate_manual_locktime.py -q
|
tests/test_anticipate_manual_locktime.py -q
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
|
||||||
import os
|
import os
|
||||||
import sys
|
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]
|
import pytest # noqa: E402 # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
|
from bal.core.util import copy_structure # noqa: E402
|
||||||
from bal.core.will import ( # noqa: E402
|
from bal.core.will import ( # noqa: E402
|
||||||
NotCompleteWillException,
|
NotCompleteWillException,
|
||||||
Will,
|
Will,
|
||||||
@@ -70,7 +70,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
|||||||
"""
|
"""
|
||||||
d = {
|
d = {
|
||||||
"tx": _VALID_TX_HEX,
|
"tx": _VALID_TX_HEX,
|
||||||
"heirs": copy.deepcopy(heirs),
|
"heirs": copy_structure(heirs),
|
||||||
"willexecutor": None,
|
"willexecutor": None,
|
||||||
"status": "",
|
"status": "",
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -79,7 +79,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
|||||||
"baltx_fees": TX_FEES,
|
"baltx_fees": TX_FEES,
|
||||||
}
|
}
|
||||||
item = WillItem(d, _id="willid_1")
|
item = WillItem(d, _id="willid_1")
|
||||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
item.tx.locktime = tx_locktime
|
item.tx.locktime = tx_locktime
|
||||||
if status_complete:
|
if status_complete:
|
||||||
item.set_status("COMPLETE", True)
|
item.set_status("COMPLETE", True)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import shutil
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ from electrum.simple_config import SimpleConfig
|
|||||||
from electrum.util import UserFacingException
|
from electrum.util import UserFacingException
|
||||||
|
|
||||||
from bal.cli.controller import BalController
|
from bal.cli.controller import BalController
|
||||||
|
from bal.core.heirs import Heirs
|
||||||
|
from bal.core.util import Util
|
||||||
|
|
||||||
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
|
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
|
||||||
|
|
||||||
@@ -227,6 +230,39 @@ def test_auto_rebuild_threshold_passed_invalidates():
|
|||||||
assert result["invalidation_tx"] == {"txid": None, "tx": None}
|
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
|
# runner
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
139
tests/test_core_animated_qr.py
Normal file
139
tests/test_core_animated_qr.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""
|
||||||
|
Tests for ``bal.core.animated_qr`` (BBQR interop).
|
||||||
|
|
||||||
|
Validates the self-contained BBQR codec against round trips, out-of-order
|
||||||
|
assembly, compression default/fallback, malformed-input rejection and the
|
||||||
|
format detection / parse_for_detection helpers.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source electrum/env/bin/activate
|
||||||
|
python3 tests/test_core_animated_qr.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from bal.core import animated_qr as aq
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# BBQR
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbqr_all_encodings_roundtrip():
|
||||||
|
payload = ("BBQR payload " * 8).encode()
|
||||||
|
for encoding in ("Z", "2", "H"):
|
||||||
|
frames = aq.bbqr_frames(payload, 90, encoding=encoding)
|
||||||
|
assert len(frames) >= 1
|
||||||
|
order = list(range(len(frames)))
|
||||||
|
random.Random(3).shuffle(order)
|
||||||
|
session = aq.AnimatedQrSession()
|
||||||
|
for i in order:
|
||||||
|
session.add_part(frames[i])
|
||||||
|
assert session.done
|
||||||
|
assert session.resolve()[0] == payload.decode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbqr_compression_default_and_fallback():
|
||||||
|
payload = ("repetitive data " * 40).encode() # compresses well
|
||||||
|
frames_z = aq.bbqr_frames(payload, 90, encoding="Z")
|
||||||
|
# Highly compressible: Z yields one frame and a 'Z' flag.
|
||||||
|
assert all(f[2] == "Z" for f in frames_z)
|
||||||
|
assert len(frames_z) == 1
|
||||||
|
raw = os.urandom(600) # incompressible
|
||||||
|
frames_2 = aq.bbqr_frames(raw, 90, encoding="Z")
|
||||||
|
assert all(f[2] == "2" for f in frames_2) # Z loses, '2' is used
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbqr_hex_uppercase():
|
||||||
|
payload = b"\xde\xad\xbe\xef"
|
||||||
|
frame = aq.bbqr_frames(payload, 50, encoding="H")[0]
|
||||||
|
assert "DEADBEEF" in frame
|
||||||
|
encoding, _type, total, index, frag = aq.bbqr_parse_part(frame)
|
||||||
|
assert (encoding, total, index) == ("H", 1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbqr_runt_last_part():
|
||||||
|
payload = os.urandom(33)
|
||||||
|
frames = aq.bbqr_frames(payload, 60, encoding="2")
|
||||||
|
parts = [aq.bbqr_parse_part(f)[4] for f in frames]
|
||||||
|
joined = aq._bbqr_decode(parts, "2")
|
||||||
|
assert joined == payload
|
||||||
|
assert len(parts[-1]) < len(parts[0]) # last part is a runt
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbqr_zlib_bomb_rejected():
|
||||||
|
compressed = aq._bbqr_encode(b"\x00" * 1000000, "Z")[1]
|
||||||
|
try:
|
||||||
|
aq._bbqr_decode(["0" * len(compressed)], "2") # not zlib data
|
||||||
|
except aq.AnimatedQrError:
|
||||||
|
pass
|
||||||
|
# direct inflate bomb guard:
|
||||||
|
inflated = aq._bbqr_encode(b"\x00" * 1000000, "Z")
|
||||||
|
assert inflated[0] == "Z" # 1MB zeros compresses
|
||||||
|
bomb = aq._bbqr_encode(b"\x00" * (aq._MAX_MESSAGE_BYTES + 100), "Z")[1]
|
||||||
|
parts = [bomb[i : i + 90] for i in range(0, len(bomb), 90)]
|
||||||
|
try:
|
||||||
|
aq._bbqr_decode(parts, "Z")
|
||||||
|
except aq.AnimatedQrError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected AnimatedQrError for an oversized decompression")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbqr_part_number_limits():
|
||||||
|
try:
|
||||||
|
aq.bbqr_frames(os.urandom(30000), 40, encoding="2")
|
||||||
|
except aq.AnimatedQrError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected AnimatedQrError for too many BBQR parts")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Detection / parse_for_detection
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_format_recognises_bbqr():
|
||||||
|
assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "bbqr"
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_format_rejects_garbage():
|
||||||
|
for text in ("", "hello world", "BALQR1|1|1||payload", "ur:", "ur:bytes/xyz"):
|
||||||
|
assert aq.detect_format(text) is None, text
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_for_detection_keys():
|
||||||
|
bb = aq.parse_for_detection(aq.bbqr_frames(b"x" * 50, 40)[0])
|
||||||
|
assert bb[0] == "bbqr" and bb[2] >= 1 and 0 <= bb[3] < bb[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_names_exist():
|
||||||
|
assert aq.format_name("bbqr")
|
||||||
|
assert aq.format_name("nope") == "nope"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
failures = 0
|
||||||
|
for _name, fn in sorted(globals().items()):
|
||||||
|
if _name.startswith("test_") and callable(fn):
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
print("ok: {}".format(_name))
|
||||||
|
except Exception:
|
||||||
|
failures += 1
|
||||||
|
print("FAIL: {}".format(_name))
|
||||||
|
traceback.print_exc()
|
||||||
|
if failures:
|
||||||
|
print("{} test(s) failed".format(failures))
|
||||||
|
sys.exit(1)
|
||||||
|
print("all tests passed")
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Tests for ``bal.core.checkalive`` (pure, GUI-free).
|
Tests for ``bal.core.checkalive`` (pure, GUI-free).
|
||||||
|
|
||||||
Covers the CheckAliveError exception and the BASIC/ADVANCED date_to_check
|
Covers the CheckAlive exceptions and the BASIC/ADVANCED date_to_check policy
|
||||||
policy (``resolve_date_to_check`` / ``check_alive_expired``).
|
(``resolve_date_to_check`` / ``resolve_relative_check_alive_date`` /
|
||||||
|
``is_check_alive_date_valid`` / ``ensure_check_alive_valid``), including the
|
||||||
|
sign rule: a NEGATIVE relative Check Alive counts back from the delivery, a
|
||||||
|
POSITIVE one counts forward from the inheritance creation date.
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
source "$BAL_HOME/electrum/env/bin/activate"
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
@@ -11,23 +14,41 @@ Run:
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
from bal.core.checkalive import ( # noqa: E402 (path insert above)
|
from bal.core.checkalive import ( # noqa: E402 (path insert above)
|
||||||
|
CheckAliveAfterDeliveryError,
|
||||||
CheckAliveError,
|
CheckAliveError,
|
||||||
check_alive_expired,
|
ensure_check_alive_valid,
|
||||||
|
is_check_alive_date_valid,
|
||||||
resolve_date_to_check,
|
resolve_date_to_check,
|
||||||
|
resolve_delivery_date,
|
||||||
|
resolve_guard_threshold,
|
||||||
|
resolve_relative_check_alive_date,
|
||||||
)
|
)
|
||||||
|
from bal.core.plugin_base import BalTimestamp # noqa: E402
|
||||||
|
from bal.core.will import Will # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _midnight(ts):
|
||||||
|
return (
|
||||||
|
datetime.fromtimestamp(ts, tz=timezone.utc)
|
||||||
|
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
.timestamp()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# CheckAliveError
|
# CheckAliveError / CheckAliveAfterDeliveryError
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
def test_check_alive_error_default():
|
def test_check_alive_error_default():
|
||||||
err = CheckAliveError(1000000)
|
err = CheckAliveError(1000000)
|
||||||
assert err.timestamp_to_check == 1000000
|
assert err.timestamp_to_check == 1000000
|
||||||
|
assert err.reason is None
|
||||||
|
|
||||||
|
|
||||||
def test_check_alive_error_str():
|
def test_check_alive_error_str():
|
||||||
@@ -37,10 +58,107 @@ def test_check_alive_error_str():
|
|||||||
assert "1970" in s
|
assert "1970" in s
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_alive_error_str_with_reason():
|
||||||
|
err = CheckAliveError(1000000, reason="too early")
|
||||||
|
assert "too early" in str(err)
|
||||||
|
|
||||||
|
|
||||||
def test_check_alive_error_subclass():
|
def test_check_alive_error_subclass():
|
||||||
assert issubclass(CheckAliveError, Exception)
|
assert issubclass(CheckAliveError, Exception)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_alive_after_delivery_error_is_sibling():
|
||||||
|
# NOT a subclass of CheckAliveError: the existing ``except CheckAliveError``
|
||||||
|
# handlers must not invalidate the will for this configuration error.
|
||||||
|
assert not issubclass(CheckAliveAfterDeliveryError, CheckAliveError)
|
||||||
|
err = CheckAliveAfterDeliveryError(2000, 1000)
|
||||||
|
assert err.timestamp_to_check == 2000
|
||||||
|
assert err.delivery_timestamp == 1000
|
||||||
|
assert "delivery" in str(err)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# resolve_delivery_date
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_absolute_locktime():
|
||||||
|
settings = {"locktime": 1_800_000_000}
|
||||||
|
assert resolve_delivery_date(settings) == 1_800_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_relative_locktime_resolved_from_now():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
settings = {"locktime": "90d"}
|
||||||
|
result = resolve_delivery_date(settings, now=fake_now)
|
||||||
|
expected = _midnight(fake_now) + 90 * 86400
|
||||||
|
assert abs(result - expected) < 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_relative_locktime_anchored_to_built_tx():
|
||||||
|
frozen = 1817424000
|
||||||
|
settings = {"locktime": "2y"}
|
||||||
|
result = resolve_delivery_date(
|
||||||
|
settings, now=1_800_000_000.0, built_locktime=frozen
|
||||||
|
)
|
||||||
|
assert result == frozen
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_missing_locktime_returns_none():
|
||||||
|
assert resolve_delivery_date({}) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# resolve_relative_check_alive_date (the single calculation rule)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_negative_anchors_to_delivery():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
delivery = fake_now + 90 * 86400
|
||||||
|
result = resolve_relative_check_alive_date(
|
||||||
|
"-30d", delivery_date=delivery, creation_date=fake_now, now=fake_now
|
||||||
|
)
|
||||||
|
expected = _midnight(delivery) - 30 * 86400
|
||||||
|
assert abs(result - expected) < 1
|
||||||
|
assert result < delivery
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_positive_anchors_to_creation():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
creation = fake_now + 5 * 86400
|
||||||
|
delivery = fake_now + 365 * 86400
|
||||||
|
result = resolve_relative_check_alive_date(
|
||||||
|
"30d", delivery_date=delivery, creation_date=creation, now=fake_now
|
||||||
|
)
|
||||||
|
expected = _midnight(creation) + 30 * 86400
|
||||||
|
assert abs(result - expected) < 1
|
||||||
|
# The delivery anchor is irrelevant for a positive value.
|
||||||
|
assert result < delivery
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_positive_without_creation_falls_back_to_now():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
result = resolve_relative_check_alive_date(
|
||||||
|
"30d", delivery_date=fake_now + 365 * 86400, creation_date=None, now=fake_now
|
||||||
|
)
|
||||||
|
expected = _midnight(fake_now) + 30 * 86400
|
||||||
|
assert abs(result - expected) < 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_negative_without_delivery_falls_back_to_now():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
result = resolve_relative_check_alive_date(
|
||||||
|
"-30d", delivery_date=None, creation_date=fake_now, now=fake_now
|
||||||
|
)
|
||||||
|
expected = _midnight(fake_now) - 30 * 86400
|
||||||
|
assert abs(result - expected) < 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_absolute_value_returned_unchanged():
|
||||||
|
assert resolve_relative_check_alive_date(1_700_000_000, None, None) == 1_700_000_000
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# resolve_date_to_check
|
# resolve_date_to_check
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -58,69 +176,60 @@ def test_advanced_mode_uses_threshold_absolute():
|
|||||||
assert abs(result - threshold) < 1
|
assert abs(result - threshold) < 1
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_parses_relative_threshold():
|
def test_advanced_mode_negative_relative_anchored_to_delivery():
|
||||||
# A relative threshold means "N days BEFORE the delivery": it resolves
|
|
||||||
# against the stored locktime (backwards), not forward from now.
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
fake_now = 1_800_000_000.0
|
fake_now = 1_800_000_000.0
|
||||||
locktime = fake_now + 90 * 86400
|
locktime = fake_now + 90 * 86400
|
||||||
settings = {"threshold": "30d", "locktime": locktime}
|
settings = {"threshold": "-30d", "locktime": locktime}
|
||||||
result = resolve_date_to_check(False, settings, now=fake_now)
|
result = resolve_date_to_check(False, settings, now=fake_now)
|
||||||
# date_to_check = (locktime, midnight-normalised) - 30 days.
|
expected = _midnight(locktime) - 30 * 86400
|
||||||
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
|
assert abs(result - expected) < 1
|
||||||
# 90d delivery with a 30d window: the window starts 60 days after now.
|
|
||||||
assert result > fake_now
|
assert result > fake_now
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_relative_threshold_anchored_to_locktime():
|
def test_advanced_mode_positive_relative_anchored_to_creation():
|
||||||
"""A relative threshold never drifts with the clock: re-resolving it a day
|
fake_now = 1_800_000_000.0
|
||||||
later, with the same fixed absolute locktime, yields the same date."""
|
creation = fake_now + 10 * 86400
|
||||||
|
locktime = fake_now + 365 * 86400
|
||||||
|
settings = {"threshold": "30d", "locktime": locktime}
|
||||||
|
result = resolve_date_to_check(
|
||||||
|
False, settings, now=fake_now, creation_date=creation
|
||||||
|
)
|
||||||
|
expected = _midnight(creation) + 30 * 86400
|
||||||
|
assert abs(result - expected) < 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_mode_negative_relative_never_drifts():
|
||||||
|
"""A negative relative threshold anchored to a fixed absolute delivery does
|
||||||
|
not drift with the clock."""
|
||||||
fake_now = 1_800_000_000.0
|
fake_now = 1_800_000_000.0
|
||||||
locktime = fake_now + 90 * 86400
|
locktime = fake_now + 90 * 86400
|
||||||
settings = {"threshold": "30d", "locktime": locktime}
|
settings = {"threshold": "-30d", "locktime": locktime}
|
||||||
first = resolve_date_to_check(False, settings, now=fake_now)
|
first = resolve_date_to_check(False, settings, now=fake_now)
|
||||||
# Next day: same stored settings (the fixed delivery), a later clock.
|
|
||||||
second = resolve_date_to_check(False, settings, now=fake_now + 86400)
|
second = resolve_date_to_check(False, settings, now=fake_now + 86400)
|
||||||
assert first == second
|
assert first == second
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_relative_threshold_with_relative_locktime():
|
def test_advanced_mode_positive_relative_creation_never_drifts():
|
||||||
"""A relative locktime is resolved against 'now' first, then the relative
|
"""A positive relative threshold anchored to the frozen creation date does
|
||||||
threshold counts N days back from it (matches the settings widget)."""
|
not drift with the clock."""
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from bal.core.plugin_base import BalTimestamp
|
|
||||||
|
|
||||||
fake_now = 1_800_000_000.0
|
fake_now = 1_800_000_000.0
|
||||||
settings = {"threshold": "30d", "locktime": "90d"}
|
creation = fake_now + 2 * 86400
|
||||||
result = resolve_date_to_check(False, settings, now=fake_now)
|
settings = {"threshold": "30d", "locktime": fake_now + 365 * 86400}
|
||||||
# Recompute the expected value with the same resolution rules:
|
first = resolve_date_to_check(
|
||||||
# locktime = now + 90d (midnight-normalised), threshold = locktime - 30d.
|
False, settings, now=fake_now, creation_date=creation
|
||||||
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc))
|
)
|
||||||
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
|
second = resolve_date_to_check(
|
||||||
assert abs(result - expected) < 1
|
False, settings, now=fake_now + 5 * 86400, creation_date=creation
|
||||||
assert result > fake_now
|
)
|
||||||
|
assert first == second
|
||||||
|
|
||||||
def test_advanced_mode_relative_threshold_no_locktime_falls_back():
|
|
||||||
# Without a locktime reference, fall back to the legacy forward resolution.
|
|
||||||
settings = {"threshold": "30d"}
|
|
||||||
result = resolve_date_to_check(False, settings)
|
|
||||||
assert result > time.time()
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_relative_locktime_anchored_to_built_tx():
|
def test_advanced_mode_relative_locktime_anchored_to_built_tx():
|
||||||
"""A RELATIVE stored locktime is anchored to the built will's frozen
|
"""A RELATIVE stored locktime is anchored to the built will's frozen
|
||||||
delivery date (built_locktime), not to "now": an unchanged will must not
|
delivery date (built_locktime), not to "now": an unchanged will must not
|
||||||
read as expired as the clock advances (the karen7 daily-invalidate bug)."""
|
read as expired as the clock advances (the karen7 daily-invalidate bug)."""
|
||||||
frozen = 1817424000 # frozen tx locktime (2027-08-05 00:00 UTC), built 2026-08-05
|
frozen = 1817424000 # frozen tx locktime, built 2026-08-05
|
||||||
settings = {"threshold": "30d", "locktime": "2y"}
|
settings = {"threshold": "-30d", "locktime": "2y"}
|
||||||
# On build day the frozen delivery is authoritative: date_to_check is
|
|
||||||
# frozen - 30d and NEVER drifts, however much later the clock gets.
|
|
||||||
first = resolve_date_to_check(
|
first = resolve_date_to_check(
|
||||||
False, settings, now=1_800_000_000.0, built_locktime=frozen
|
False, settings, now=1_800_000_000.0, built_locktime=frozen
|
||||||
)
|
)
|
||||||
@@ -129,41 +238,164 @@ def test_advanced_mode_relative_locktime_anchored_to_built_tx():
|
|||||||
False, settings, now=1_800_000_000.0 + 10 * 86400, built_locktime=frozen
|
False, settings, now=1_800_000_000.0 + 10 * 86400, built_locktime=frozen
|
||||||
)
|
)
|
||||||
assert first == later
|
assert first == later
|
||||||
# The check window must start BEFORE the frozen delivery (never expired).
|
|
||||||
assert first < frozen
|
assert first < frozen
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
|
def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
|
||||||
"""Without a built will there is no anchor: keeps the legacy now-based
|
|
||||||
resolution (a moving target, used only before the first build)."""
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from bal.core.plugin_base import BalTimestamp
|
|
||||||
|
|
||||||
fake_now = 1_800_000_000.0
|
fake_now = 1_800_000_000.0
|
||||||
settings = {"threshold": "30d", "locktime": "90d"}
|
settings = {"threshold": "-30d", "locktime": "90d"}
|
||||||
result = resolve_date_to_check(False, settings, now=fake_now)
|
result = resolve_date_to_check(False, settings, now=fake_now)
|
||||||
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc))
|
locktime_dt = BalTimestamp("90d").to_date(fake_now)
|
||||||
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
|
expected = BalTimestamp("-30d").to_date(locktime_dt).timestamp()
|
||||||
assert abs(result - expected) < 1
|
assert abs(result - expected) < 1
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# check_alive_expired
|
# resolve_guard_threshold
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_threshold_basic_mode_returns_none():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
threshold = resolve_guard_threshold(True, {"threshold": "-30d"}, now=fake_now)
|
||||||
|
assert threshold is None
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_locktime(settings, fake_now):
|
||||||
|
"""Reproduce the call-site locktime expression of the guard."""
|
||||||
|
return BalTimestamp(settings["locktime"]).to_timestamp(fake_now)
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_threshold_absolute():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
locktime = fake_now + 90 * 86400
|
||||||
|
threshold = locktime - 30 * 86400
|
||||||
|
settings = {"locktime": locktime, "threshold": threshold}
|
||||||
|
assert resolve_guard_threshold(False, settings, now=fake_now) == threshold
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_threshold_negative_relative_fresh_anchor():
|
||||||
|
"""A negative relative threshold is anchored to the FRESH locktime so the
|
||||||
|
guard and the settings always share one reference frame."""
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
settings = {"locktime": "1y", "threshold": "-30d"}
|
||||||
|
locktime = _guard_locktime(settings, fake_now)
|
||||||
|
threshold = resolve_guard_threshold(False, settings, now=fake_now)
|
||||||
|
assert threshold is not None
|
||||||
|
assert locktime > threshold # internally consistent: no fire
|
||||||
|
assert threshold > fake_now
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_threshold_positive_relative_anchored_to_creation():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
creation = fake_now + 4 * 86400
|
||||||
|
settings = {"locktime": "1y", "threshold": "30d"}
|
||||||
|
threshold = resolve_guard_threshold(
|
||||||
|
False, settings, now=fake_now, creation_date=creation
|
||||||
|
)
|
||||||
|
expected = _midnight(creation) + 30 * 86400
|
||||||
|
assert abs(threshold - expected) < 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_threshold_missing_returns_none():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
assert resolve_guard_threshold(False, {"locktime": "1y"}, now=fake_now) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# is_check_alive_date_valid / ensure_check_alive_valid
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_valid_inside_window():
|
||||||
|
now = 1_800_000_000.0
|
||||||
|
assert is_check_alive_date_valid(now + 100, now + 1000, now=now) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_valid_rejects_past():
|
||||||
|
now = 1_800_000_000.0
|
||||||
|
assert is_check_alive_date_valid(now - 100, now + 1000, now=now) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_valid_rejects_on_or_after_delivery():
|
||||||
|
now = 1_800_000_000.0
|
||||||
|
delivery = now + 1000
|
||||||
|
assert is_check_alive_date_valid(delivery, delivery, now=now) is False
|
||||||
|
assert is_check_alive_date_valid(delivery + 1, delivery, now=now) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_valid_without_delivery_skips_upper_bound():
|
||||||
|
now = 1_800_000_000.0
|
||||||
|
assert is_check_alive_date_valid(now + 100, None, now=now) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_valid_none_is_false():
|
||||||
|
assert is_check_alive_date_valid(None, 1000) is False
|
||||||
|
|
||||||
|
|
||||||
def test_basic_mode_never_expired():
|
def test_basic_mode_never_expired():
|
||||||
assert check_alive_expired(True, 1_000_000_000.0) is False
|
assert ensure_check_alive_valid(True, time.time() - 10_000) is None
|
||||||
assert check_alive_expired(True, time.time() - 10_000) is False
|
assert ensure_check_alive_valid(True, 1_000_000_000.0) is None
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_expired_when_past():
|
def test_advanced_raises_when_past():
|
||||||
assert check_alive_expired(False, time.time() - 10_000) is True
|
try:
|
||||||
|
ensure_check_alive_valid(False, time.time() - 10_000)
|
||||||
|
except CheckAliveError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected CheckAliveError")
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_not_expired_when_future():
|
def test_advanced_raises_when_after_delivery():
|
||||||
assert check_alive_expired(False, time.time() + 10_000) is False
|
now = time.time()
|
||||||
|
try:
|
||||||
|
ensure_check_alive_valid(False, now + 100, delivery_date=now + 50)
|
||||||
|
except CheckAliveAfterDeliveryError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected CheckAliveAfterDeliveryError")
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_valid_does_not_raise():
|
||||||
|
now = time.time()
|
||||||
|
assert ensure_check_alive_valid(False, now + 100, delivery_date=now + 1000) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Will.get_creation_time
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeWillItem:
|
||||||
|
def __init__(self, time_value, valid=True):
|
||||||
|
self.time = time_value
|
||||||
|
self._valid = valid
|
||||||
|
|
||||||
|
def get_status(self, status):
|
||||||
|
return self._valid if status == "VALID" else False
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_creation_time_min_of_valid_items():
|
||||||
|
will = {
|
||||||
|
"a": _FakeWillItem(2000),
|
||||||
|
"b": _FakeWillItem(1000),
|
||||||
|
"c": _FakeWillItem(3000),
|
||||||
|
}
|
||||||
|
assert Will.get_creation_time(will) == 1000
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_creation_time_ignores_invalid_and_none():
|
||||||
|
will = {
|
||||||
|
"a": _FakeWillItem(500, valid=False),
|
||||||
|
"b": _FakeWillItem(None),
|
||||||
|
"c": _FakeWillItem(2000),
|
||||||
|
}
|
||||||
|
assert Will.get_creation_time(will) == 2000
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_creation_time_empty_returns_none():
|
||||||
|
assert Will.get_creation_time({}) is None
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from bal.core.heirs import (
|
|||||||
is_op_return_address,
|
is_op_return_address,
|
||||||
validate_op_return_hex,
|
validate_op_return_hex,
|
||||||
)
|
)
|
||||||
|
from bal.core.util import Util
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Constants
|
# Constants
|
||||||
@@ -167,6 +168,32 @@ def test_heirs_amount_to_float():
|
|||||||
assert heirs.amount_to_float("notanumber") == 0.0
|
assert heirs.amount_to_float("notanumber") == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixed_percent_lists_uses_build_anchor_for_relative_heirs():
|
||||||
|
"""A relative heir must survive the amount filter when the build anchor
|
||||||
|
(``from_locktime``) is recalculated for the anticipated delivery.
|
||||||
|
|
||||||
|
Before the fix, ``build_will`` kept ``date_to_check`` anchored to the OLD
|
||||||
|
(longer) built will; an "1y" heir resolved before that anchor was excluded
|
||||||
|
by the ``cmp <= 0`` filter and the build reported NO_FUTURE_DATE. With the
|
||||||
|
anchor recomputed for the new locktime (karen7: 2y -> 1y delivery) the
|
||||||
|
"1y" heir is kept.
|
||||||
|
"""
|
||||||
|
wallet = FakeWallet()
|
||||||
|
heirs = Heirs(wallet)
|
||||||
|
heirs["carol"] = ["addr1", "100%", "1y"]
|
||||||
|
|
||||||
|
# Stale anchor (old built 2y will still frozen): "1y" is in the past
|
||||||
|
# relative to it -> excluded from the amount calculation.
|
||||||
|
stale_anchor = Util.parse_locktime_string("2y") - 150 * 86400
|
||||||
|
_, _, percent_heirs, _, _ = heirs.fixed_percent_lists_amount(stale_anchor, 500)
|
||||||
|
assert "carol" not in percent_heirs
|
||||||
|
|
||||||
|
# Recalculated anchor for the new (1y) delivery: the heir is retained.
|
||||||
|
new_anchor = Util.parse_locktime_string("1y") - 150 * 86400
|
||||||
|
_, _, percent_heirs, _, _ = heirs.fixed_percent_lists_amount(new_anchor, 500)
|
||||||
|
assert "carol" in percent_heirs
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Validation (static methods)
|
# Validation (static methods)
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -117,6 +117,30 @@ def test_normalize_locktime_raw_single_suffix():
|
|||||||
assert s == "130d"
|
assert s == "130d"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_locktime_raw_negative_disallowed_by_default():
|
||||||
|
# The delivery field must never accept a sign.
|
||||||
|
s, isdays, isyears, pos = normalize_locktime_raw_text("-30d", 4)
|
||||||
|
assert s == "30d"
|
||||||
|
assert isdays is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_locktime_raw_negative_allowed_keeps_single_sign():
|
||||||
|
s, isdays, isyears, pos = normalize_locktime_raw_text(
|
||||||
|
"-30d", 4, allow_negative=True
|
||||||
|
)
|
||||||
|
assert s == "-30d"
|
||||||
|
assert isdays is True
|
||||||
|
assert pos >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_locktime_raw_negative_only_leading():
|
||||||
|
# Extra signs are dropped; a single leading sign survives.
|
||||||
|
s, _isdays, _isyears, _pos = normalize_locktime_raw_text(
|
||||||
|
"--1y", 4, allow_negative=True
|
||||||
|
)
|
||||||
|
assert s == "-1y"
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Percentage-or-amount normalisation
|
# Percentage-or-amount normalisation
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import time
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
from bal.core.plugin_base import BalConfig, BalPlugin, BalTimestamp
|
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():
|
def test_bt_to_date_relative():
|
||||||
now = datetime.now()
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# relative days from now
|
# relative days from now
|
||||||
bt = BalTimestamp("7d")
|
bt = BalTimestamp("7d")
|
||||||
@@ -86,8 +86,8 @@ def test_bt_to_date_relative():
|
|||||||
d_rev = bt.to_date(reverse=True)
|
d_rev = bt.to_date(reverse=True)
|
||||||
assert d_rev < now
|
assert d_rev < now
|
||||||
|
|
||||||
# from explicit datetime
|
# from explicit datetime (UTC, so the naive-timestamp roundtrip below is stable)
|
||||||
base = datetime(2025, 6, 1, 12, 0, 0)
|
base = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
d = bt.to_date(from_date=base)
|
d = bt.to_date(from_date=base)
|
||||||
expected = (base + timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0)
|
expected = (base + timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
assert d == expected
|
assert d == expected
|
||||||
@@ -101,7 +101,7 @@ def test_bt_to_date_relative():
|
|||||||
def test_bt_to_date_years():
|
def test_bt_to_date_years():
|
||||||
bt = BalTimestamp("1y")
|
bt = BalTimestamp("1y")
|
||||||
d = bt.to_date()
|
d = bt.to_date()
|
||||||
assert d > datetime.now()
|
assert d > datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def test_bt_to_date_overflow():
|
def test_bt_to_date_overflow():
|
||||||
@@ -201,7 +201,7 @@ def test_balconfig_set():
|
|||||||
|
|
||||||
def test_default_will_settings_relative():
|
def test_default_will_settings_relative():
|
||||||
rel = BalPlugin.default_will_settings_relative()
|
rel = BalPlugin.default_will_settings_relative()
|
||||||
assert rel["threshold"] == "30d"
|
assert rel["threshold"] == "-30d"
|
||||||
assert rel["locktime"] == "1y"
|
assert rel["locktime"] == "1y"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
81
tests/test_core_qr_transfer.py
Normal file
81
tests/test_core_qr_transfer.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
Tests for ``bal.core.qrtransfer``.
|
||||||
|
|
||||||
|
Covers the transfer-string encoding used for will transfer via BBQR QR codes
|
||||||
|
/ audio modem: encoding, decoding and the preset list.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source electrum/env/bin/activate
|
||||||
|
python3 tests/test_core_qr_transfer.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from bal.core.qrtransfer import (
|
||||||
|
CHUNK_PRESETS,
|
||||||
|
decode_transfer,
|
||||||
|
encode_transfer,
|
||||||
|
preset_index_for_chunk_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Round trips
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_decode_plain():
|
||||||
|
tx_strings = ["00" * 32, "aa" * 40, "ff" * 50]
|
||||||
|
payload = encode_transfer(tx_strings, compress=False)
|
||||||
|
assert decode_transfer(payload, compressed=False) == tx_strings
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_decode_compressed():
|
||||||
|
tx_strings = ["00" * 32, "aa" * 40, "ff" * 50]
|
||||||
|
payload = encode_transfer(tx_strings, compress=True)
|
||||||
|
assert decode_transfer(payload, compressed=True) == tx_strings
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_list_roundtrip():
|
||||||
|
assert decode_transfer(encode_transfer([]), compressed=False) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Constants / presets
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_count_and_order():
|
||||||
|
assert len(CHUNK_PRESETS) == 4
|
||||||
|
budgets = [budget for _label, budget in CHUNK_PRESETS]
|
||||||
|
assert budgets == sorted(budgets)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_index_for_chunk_size():
|
||||||
|
for index, (_label, budget) in enumerate(CHUNK_PRESETS):
|
||||||
|
assert preset_index_for_chunk_size(budget) == index
|
||||||
|
assert preset_index_for_chunk_size(150) == 0
|
||||||
|
assert preset_index_for_chunk_size(1800) == 3
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
failures = 0
|
||||||
|
for _name, fn in sorted(globals().items()):
|
||||||
|
if _name.startswith("test_") and callable(fn):
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
print("ok: {}".format(_name))
|
||||||
|
except Exception:
|
||||||
|
failures += 1
|
||||||
|
print("FAIL: {}".format(_name))
|
||||||
|
traceback.print_exc()
|
||||||
|
if failures:
|
||||||
|
print("{} test(s) failed".format(failures))
|
||||||
|
sys.exit(1)
|
||||||
|
print("all tests passed")
|
||||||
136
tests/test_core_status_language_neutral.py
Normal file
136
tests/test_core_status_language_neutral.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
Stored will data must not depend on the GUI language (rule in bal/i18n.py).
|
||||||
|
|
||||||
|
Covers the two findings of the i18n review:
|
||||||
|
|
||||||
|
* the status history of a new will item is saved as English "New" (not
|
||||||
|
``_("New")``), and histories saved by test builds as "Nuovo"/"Nuevo" are
|
||||||
|
mapped back to "New" on load;
|
||||||
|
* the push helper no longer writes transient fields (``txs``, ``txsids`` and
|
||||||
|
the translated ``broadcast_status``) into the will item's executor record,
|
||||||
|
and such fields saved by older versions are dropped on load.
|
||||||
|
|
||||||
|
Run (standalone or with pytest):
|
||||||
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||||
|
python3 tests/test_core_status_language_neutral.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from electrum.transaction import Transaction
|
||||||
|
|
||||||
|
from bal.core.will import WillItem
|
||||||
|
from bal.core.willexecutors import Willexecutors
|
||||||
|
|
||||||
|
# WillItem parses a raw tx and calls add_info_from_wallet; no live wallet here.
|
||||||
|
_patcher = patch.object(Transaction, "add_info_from_wallet")
|
||||||
|
_patcher.start()
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
_VALID_TX_HEX = (
|
||||||
|
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||||
|
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||||
|
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||||
|
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||||
|
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||||
|
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||||
|
"42146f11ef8414ae929feaafc388ac00000000"
|
||||||
|
)
|
||||||
|
URL = "https://we.example"
|
||||||
|
|
||||||
|
|
||||||
|
def _item(status="", we=None):
|
||||||
|
return WillItem(
|
||||||
|
{"tx": _VALID_TX_HEX, "heirs": {}, "willexecutor": we, "status": status}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Status history "New"
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_builders_store_english_new():
|
||||||
|
"""window.py and the CLI controller must not translate the stored status."""
|
||||||
|
for rel in ("bal/gui/qt/window.py", "bal/cli/controller.py"):
|
||||||
|
src = (ROOT / rel).read_text(encoding="utf-8")
|
||||||
|
assert re.search(r'tx\["status"\]\s*=\s*"New"', src), rel
|
||||||
|
assert not re.search(r'tx\["status"\]\s*=\s*_\(', src), rel
|
||||||
|
|
||||||
|
|
||||||
|
def test_translated_new_is_mapped_back_on_load():
|
||||||
|
assert _item("Nuovo.Signed.Pushed").status == "New.Signed.Pushed"
|
||||||
|
assert _item("Nuevo").status == "New"
|
||||||
|
assert _item("New.Signed").status == "New.Signed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_the_leading_new_is_touched():
|
||||||
|
assert _item(".Nuovo").status == ".Nuovo"
|
||||||
|
assert _item("Signed.Nuovo").status == "Signed.Nuovo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_translated_new_labels_match_the_catalogs():
|
||||||
|
"""Every catalog's translation of "New" must be recognised on load."""
|
||||||
|
found = set()
|
||||||
|
for po in (ROOT / "bal" / "locale").glob("*/LC_MESSAGES/bal.po"):
|
||||||
|
text = po.read_text(encoding="utf-8")
|
||||||
|
m = re.search(r'^msgid "New"\nmsgstr "(.*)"$', text, re.MULTILINE)
|
||||||
|
if m and m.group(1):
|
||||||
|
found.add(m.group(1))
|
||||||
|
assert found, "no catalog translates 'New'"
|
||||||
|
assert found <= set(WillItem.TRANSLATED_NEW_LABELS), found
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Transient push fields
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
class FakeItem:
|
||||||
|
def __init__(self):
|
||||||
|
self.we = {"url": URL, "selected": True, "address": "a", "base_fee": 1}
|
||||||
|
self.tx = "rawtx"
|
||||||
|
|
||||||
|
def get_status(self, name):
|
||||||
|
return name in ("VALID", "COMPLETE")
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_helper_does_not_modify_the_will_item():
|
||||||
|
item = FakeItem()
|
||||||
|
before = dict(item.we)
|
||||||
|
out = Willexecutors.get_willexecutor_transactions({"t1": item})
|
||||||
|
assert out[URL]["txsids"] == ["t1"]
|
||||||
|
assert "broadcast_status" in out[URL]
|
||||||
|
assert item.we == before
|
||||||
|
assert out[URL] is not item.we
|
||||||
|
|
||||||
|
|
||||||
|
def test_transient_fields_saved_by_older_versions_are_dropped_on_load():
|
||||||
|
we = {
|
||||||
|
"url": URL,
|
||||||
|
"selected": True,
|
||||||
|
"txs": "raw\n",
|
||||||
|
"txsids": ["t1"],
|
||||||
|
"broadcast_status": "Riuscito",
|
||||||
|
}
|
||||||
|
item = _item("New", we)
|
||||||
|
assert item.we == {"url": URL, "selected": True}
|
||||||
|
assert "broadcast_status" not in item.to_dict()["willexecutor"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_executor_record_is_kept_as_is():
|
||||||
|
we = {"url": URL, "selected": True}
|
||||||
|
assert _item("New", we).we is we
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All language-neutral status tests passed")
|
||||||
@@ -8,13 +8,19 @@ Run:
|
|||||||
python3 tests/test_core_will.py
|
python3 tests/test_core_will.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.will import Will, 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)
|
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
|
||||||
_VALID_TX_HEX = (
|
_VALID_TX_HEX = (
|
||||||
@@ -48,7 +54,7 @@ def _make_willitem_blank():
|
|||||||
"""Create a fresh WillItem from scratch."""
|
"""Create a fresh WillItem from scratch."""
|
||||||
item = WillItem(_make_minimal_willitem_dict())
|
item = WillItem(_make_minimal_willitem_dict())
|
||||||
# Reset STATUS to clean defaults
|
# Reset STATUS to clean defaults
|
||||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
@@ -151,8 +157,8 @@ def test_will_only_valid_list():
|
|||||||
def _make_will_with_heirs(heirs, tx_locktime):
|
def _make_will_with_heirs(heirs, tx_locktime):
|
||||||
"""Build a single-item will whose stored heirs == ``heirs`` and whose
|
"""Build a single-item will whose stored heirs == ``heirs`` and whose
|
||||||
frozen tx.locktime == ``tx_locktime`` (what the will-executors hold)."""
|
frozen tx.locktime == ``tx_locktime`` (what the will-executors hold)."""
|
||||||
item = WillItem(_make_minimal_willitem_dict(heirs=copy.deepcopy(heirs)))
|
item = WillItem(_make_minimal_willitem_dict(heirs=copy_structure(heirs)))
|
||||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
item.tx.locktime = tx_locktime
|
item.tx.locktime = tx_locktime
|
||||||
return {"willid_1": item}
|
return {"willid_1": item}
|
||||||
|
|
||||||
@@ -163,7 +169,7 @@ def test_check_heirs_unchanged_is_coherent():
|
|||||||
heirs = {"alice": ["addr_alice", 5000, str(lt)]}
|
heirs = {"alice": ["addr_alice", 5000, str(lt)]}
|
||||||
will = _make_will_with_heirs(heirs, lt)
|
will = _make_will_with_heirs(heirs, lt)
|
||||||
result = Will.check_willexecutors_and_heirs(
|
result = Will.check_willexecutors_and_heirs(
|
||||||
will, copy.deepcopy(heirs), {}, False, 0, 100
|
will, copy_structure(heirs), {}, False, 0, 100
|
||||||
)
|
)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
@@ -210,6 +216,59 @@ def test_check_heir_added_triggers_rebuild():
|
|||||||
assert raised, "adding an heir must raise HeirNotFoundException"
|
assert raised, "adding an heir must raise HeirNotFoundException"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shortened_relative_recipe_on_signed_rebuilds_not_noheirs():
|
||||||
|
"""Regression (karen7): heirs shortened "2y"->"1y" on a signed will whose
|
||||||
|
ADVANCED check window is anchored to the frozen built delivery must trigger
|
||||||
|
a plain rebuild (HeirNotFoundException), NOT "No Heirs".
|
||||||
|
|
||||||
|
Earlier the count gate resolved each current relative recipe from *now*
|
||||||
|
while ``check_date`` was anchored to the (longer) frozen built locktime, so
|
||||||
|
every heir fell below the window and was silently excluded -> NoHeirs even
|
||||||
|
though the will simply needs rebuilding on the new, shorter schedule."""
|
||||||
|
lt = 2_100_000_000 # a far-future frozen delivery (a "2y" build)
|
||||||
|
will_heirs = {"alice": ["addr_alice", 5000, "2y"]}
|
||||||
|
current_heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
||||||
|
will = _make_will_with_heirs(will_heirs, lt)
|
||||||
|
will["willid_1"].set_status("COMPLETE", True)
|
||||||
|
check_date = resolve_date_to_check(
|
||||||
|
False, {"locktime": "2y", "threshold": "-150d"}, built_locktime=lt
|
||||||
|
)
|
||||||
|
assert check_date < lt # the anchored window really precedes the delivery
|
||||||
|
raised = None
|
||||||
|
try:
|
||||||
|
Will.check_willexecutors_and_heirs(
|
||||||
|
will, copy_structure(current_heirs), {}, False, check_date, 100
|
||||||
|
)
|
||||||
|
except HeirNotFoundException:
|
||||||
|
raised = "rebuild"
|
||||||
|
except NoHeirsException:
|
||||||
|
raised = "noheirs"
|
||||||
|
assert raised == "rebuild", (
|
||||||
|
f"shortened recipe on a signed will must rebuild, got {raised!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_heirs_past_check_date_still_noheirs():
|
||||||
|
"""The "no valid heirs" gate is preserved: when every heir is coherent with
|
||||||
|
the built will but its delivery lies before ``check_date``, the check still
|
||||||
|
reports NoHeirsException (there is literally nothing future to inherit)."""
|
||||||
|
lt = 1_900_000_000
|
||||||
|
will_heirs = {"alice": ["addr_alice", 5000, str(lt)]}
|
||||||
|
will = _make_will_with_heirs(will_heirs, lt)
|
||||||
|
raised = None
|
||||||
|
try:
|
||||||
|
Will.check_willexecutors_and_heirs(
|
||||||
|
will, copy_structure(will_heirs), {}, False, lt + 86400, 100
|
||||||
|
)
|
||||||
|
except HeirNotFoundException:
|
||||||
|
raised = "rebuild"
|
||||||
|
except NoHeirsException:
|
||||||
|
raised = "noheirs"
|
||||||
|
assert raised == "noheirs", (
|
||||||
|
f"a fully delivered will must report NoHeirs, got {raised!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_needs_server_check():
|
def test_needs_server_check():
|
||||||
"""Check button selection logic: only a VALID, PUSHED will with a
|
"""Check button selection logic: only a VALID, PUSHED will with a
|
||||||
will-executor that is not yet CHECKED must be queried on the server.
|
will-executor that is not yet CHECKED must be queried on the server.
|
||||||
|
|||||||
@@ -450,6 +450,12 @@ class FakeADB:
|
|||||||
|
|
||||||
def remove_transaction(self, txid):
|
def remove_transaction(self, txid):
|
||||||
self.removed.append(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):
|
def get_spender(self, outpoint):
|
||||||
txid = self.spenders.get(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) == []
|
assert Util.get_available_utxos(None, _HISTORY_TEMPLATE, 1000) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Will.remove_stale_wallet_history (pre-build history purge)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_remove_stale_wallet_history_frees_equal_locktime_spend():
|
||||||
|
# The stale placeholders (saved by a previous prepare) have the SAME
|
||||||
|
# locktime as the will being rebuilt, so get_available_utxos does NOT
|
||||||
|
# restore their coins (see test_...does_not_restore_not_later_locktime).
|
||||||
|
# The pre-build purge deletes them and the coins become available again.
|
||||||
|
wallet, utxo = _wallet_with_local_spend(locktime=1000)
|
||||||
|
spender = "ab" * 32
|
||||||
|
assert Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000) == []
|
||||||
|
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
|
||||||
|
assert removed == [spender]
|
||||||
|
assert wallet.adb.removed == [spender]
|
||||||
|
assert spender not in wallet.labels
|
||||||
|
assert [
|
||||||
|
u.prevout.to_str()
|
||||||
|
for u in Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
|
||||||
|
] == [utxo.prevout.to_str()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_stale_wallet_history_keeps_confirmed_spender():
|
||||||
|
# A broadcast (confirmed) BAL-labelled tx is never purged.
|
||||||
|
addr = "bcrt1qexample"
|
||||||
|
spender = "ab" * 32
|
||||||
|
utxo = _make_utxo(spent_txid=spender, spent_height=100)
|
||||||
|
wallet = FakeWallet(
|
||||||
|
stored_txs={spender: _make_multisig_ptx(0, locktime=2000)},
|
||||||
|
heights={spender: 100},
|
||||||
|
outputs={addr: {utxo.prevout.to_str(): utxo}},
|
||||||
|
addresses=[addr],
|
||||||
|
)
|
||||||
|
wallet.labels[spender] = _HISTORY_LABEL
|
||||||
|
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
|
||||||
|
assert removed == []
|
||||||
|
assert wallet.adb.removed == []
|
||||||
|
assert wallet.labels[spender] == _HISTORY_LABEL
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_stale_wallet_history_keeps_unlabeled_local_spender():
|
||||||
|
# Wallet-local BAL-status tx without a matching history label stays.
|
||||||
|
wallet, _ = _wallet_with_local_spend(locktime=1000)
|
||||||
|
spender = "ab" * 32
|
||||||
|
wallet.labels[spender] = "some other label"
|
||||||
|
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
|
||||||
|
assert removed == []
|
||||||
|
assert wallet.adb.removed == []
|
||||||
|
assert wallet.labels[spender] == "some other label"
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_stale_wallet_history_noop_without_wallet_or_adb():
|
||||||
|
assert Will.remove_stale_wallet_history(None, _HISTORY_TEMPLATE) == []
|
||||||
|
wallet = FakeWallet()
|
||||||
|
wallet.adb = None
|
||||||
|
assert Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_stale_wallet_history_never_raises():
|
||||||
|
adb = MagicMock()
|
||||||
|
adb.get_tx_height.side_effect = RuntimeError("boom")
|
||||||
|
wallet = MagicMock()
|
||||||
|
wallet.adb = adb
|
||||||
|
wallet.get_all_labels.return_value = {"ab" * 32: _HISTORY_LABEL}
|
||||||
|
assert Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) == []
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Main
|
# Main
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ Run:
|
|||||||
python3 -m pytest tests/test_core_will_invalidate.py -q
|
python3 -m pytest tests/test_core_will_invalidate.py -q
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
@@ -75,7 +74,7 @@ def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None):
|
|||||||
"change": "",
|
"change": "",
|
||||||
"baltx_fees": 100,
|
"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.
|
# Set the input value so the balance calculation works.
|
||||||
# Use the name-mangled attribute because tx_from_any creates a
|
# Use the name-mangled attribute because tx_from_any creates a
|
||||||
# Transaction whose inputs are TxInput objects; TxInput.value_sats()
|
# Transaction whose inputs are TxInput objects; TxInput.value_sats()
|
||||||
@@ -270,7 +269,7 @@ class TestInvalidateWill:
|
|||||||
"""
|
"""
|
||||||
item = _make_willitem(value_sats=100)
|
item = _make_willitem(value_sats=100)
|
||||||
will = {"willtxid1": item}
|
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)
|
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
|
python3 -m pytest tests/test_group_e_karen7_invalidate.py -q
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -46,6 +45,7 @@ from electrum.transaction import (
|
|||||||
from electrum.util import bfh
|
from electrum.util import bfh
|
||||||
|
|
||||||
from bal.core.heirs import Heirs
|
from bal.core.heirs import Heirs
|
||||||
|
from bal.core.util import copy_structure
|
||||||
from bal.core.will import Will, WillItem
|
from bal.core.will import Will, WillItem
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -219,7 +219,7 @@ def _txs_to_will(txs, heirs_data):
|
|||||||
for txid, tx in txs.items():
|
for txid, tx in txs.items():
|
||||||
item_dict = {
|
item_dict = {
|
||||||
"tx": tx,
|
"tx": tx,
|
||||||
"heirs": copy.deepcopy(heirs_data),
|
"heirs": copy_structure(heirs_data),
|
||||||
"willexecutor": None,
|
"willexecutor": None,
|
||||||
"status": "",
|
"status": "",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ Run:
|
|||||||
python3 -m pytest tests/test_group_e_mock_karen7.py -q
|
python3 -m pytest tests/test_group_e_mock_karen7.py -q
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -43,6 +42,7 @@ from bal.core.reminders import (
|
|||||||
ical_escape,
|
ical_escape,
|
||||||
write_temp_ics,
|
write_temp_ics,
|
||||||
)
|
)
|
||||||
|
from bal.core.util import copy_structure
|
||||||
from bal.core.will import HeirNotFoundException, Will, WillItem
|
from bal.core.will import HeirNotFoundException, Will, WillItem
|
||||||
from bal.core.willexecutors import Willexecutors
|
from bal.core.willexecutors import Willexecutors
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ def _make_willitem(**overrides):
|
|||||||
}
|
}
|
||||||
d.update(overrides)
|
d.update(overrides)
|
||||||
item = WillItem(d)
|
item = WillItem(d)
|
||||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
@@ -343,7 +343,7 @@ def test_e2_heir_change_triggers_rebuild():
|
|||||||
item = WillItem(
|
item = WillItem(
|
||||||
{
|
{
|
||||||
"tx": _VALID_TX_HEX,
|
"tx": _VALID_TX_HEX,
|
||||||
"heirs": copy.deepcopy(will_heirs),
|
"heirs": copy_structure(will_heirs),
|
||||||
"willexecutor": None,
|
"willexecutor": None,
|
||||||
"status": "",
|
"status": "",
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -352,7 +352,7 @@ def test_e2_heir_change_triggers_rebuild():
|
|||||||
"baltx_fees": 100,
|
"baltx_fees": 100,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
item.tx.locktime = lt
|
item.tx.locktime = lt
|
||||||
will = {"willid_1": item}
|
will = {"willid_1": item}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ pure policy in ``bal.core.checkalive``:
|
|||||||
|
|
||||||
* ``resolve_date_to_check(is_basic_mode, will_settings)`` - the single
|
* ``resolve_date_to_check(is_basic_mode, will_settings)`` - the single
|
||||||
reference timestamp: "now" in BASIC, the stored threshold in ADVANCED;
|
reference timestamp: "now" in BASIC, the stored threshold in ADVANCED;
|
||||||
* ``check_alive_expired(is_basic_mode, date_to_check)`` - whether the
|
* ``ensure_check_alive_valid(is_basic_mode, ...)`` - whether the
|
||||||
Check-Alive guard should fire (never in BASIC).
|
Check-Alive guard should fire (never in BASIC).
|
||||||
|
|
||||||
These tests exercise the exact code used at runtime (no GUI/Electrum wallet
|
These tests exercise the exact code used at runtime (no GUI/Electrum wallet
|
||||||
@@ -33,7 +33,8 @@ sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
|||||||
|
|
||||||
from bal.core.checkalive import ( # noqa: E402 (path insert above)
|
from bal.core.checkalive import ( # noqa: E402 (path insert above)
|
||||||
CheckAliveError,
|
CheckAliveError,
|
||||||
check_alive_expired,
|
ensure_check_alive_valid,
|
||||||
|
is_check_alive_date_valid,
|
||||||
resolve_date_to_check,
|
resolve_date_to_check,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -72,23 +73,29 @@ def test_advanced_mode_parses_relative_threshold():
|
|||||||
def test_basic_mode_never_expired():
|
def test_basic_mode_never_expired():
|
||||||
"""BASIC mode can never be "expired": a passed check-alive date must never
|
"""BASIC mode can never be "expired": a passed check-alive date must never
|
||||||
force a postpone/rewrite of the will."""
|
force a postpone/rewrite of the will."""
|
||||||
assert check_alive_expired(True, time.time() - 10_000) is False
|
assert ensure_check_alive_valid(True, time.time() - 10_000) is None
|
||||||
assert check_alive_expired(True, 1_000_000_000.0) is False
|
assert ensure_check_alive_valid(True, 1_000_000_000.0) is None
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_expired_when_past():
|
def test_advanced_mode_expired_when_past():
|
||||||
assert check_alive_expired(False, time.time() - 10_000) is True
|
assert is_check_alive_date_valid(time.time() - 10_000, None) is False
|
||||||
|
try:
|
||||||
|
ensure_check_alive_valid(False, time.time() - 10_000)
|
||||||
|
except CheckAliveError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected CheckAliveError")
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_not_expired_when_future():
|
def test_advanced_mode_not_expired_when_future():
|
||||||
assert check_alive_expired(False, time.time() + 10_000) is False
|
assert is_check_alive_date_valid(time.time() + 10_000, None) is True
|
||||||
|
|
||||||
|
|
||||||
def test_basic_mode_never_raises_check_alive_error():
|
def test_basic_mode_never_raises_check_alive_error():
|
||||||
"""Regression guard for the reported bug: whatever the delivery date,
|
"""Regression guard for the reported bug: whatever the delivery date,
|
||||||
BASIC mode never raises CheckAliveError (the postpone/invalidate trigger)."""
|
BASIC mode never raises CheckAliveError (the postpone/invalidate trigger)."""
|
||||||
date_to_check = resolve_date_to_check(True, {"threshold": "30d"})
|
date_to_check = resolve_date_to_check(True, {"threshold": "-30d"})
|
||||||
assert not check_alive_expired(True, date_to_check)
|
assert ensure_check_alive_valid(True, date_to_check) is None
|
||||||
# If it did fire, this is the exact exception that would be raised.
|
# If it did fire, this is the exact exception that would be raised.
|
||||||
assert issubclass(CheckAliveError, Exception)
|
assert issubclass(CheckAliveError, Exception)
|
||||||
|
|
||||||
|
|||||||
464
tests/test_gui_export_dialogs.py
Normal file
464
tests/test_gui_export_dialogs.py
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
"""
|
||||||
|
Tests for the filter-based unified export/import dialogs and the BalWindow
|
||||||
|
transport helpers (``bal.gui.qt.dialogs``, ``bal.gui.qt.window``).
|
||||||
|
|
||||||
|
Covers the shared export filters (All / Valid / Valid NC), the unified
|
||||||
|
``WillExportDialog`` file page (whole item vs tx-only content, empty-filter
|
||||||
|
abort), the audio export/import pages (KB/sec wiring, missing plugin guard,
|
||||||
|
receive flow) and the comma-separated tx-only file writer. The audio pages
|
||||||
|
run against a stub plugin so no sound hardware is exercised.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_export_dialogs.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import zlib
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||||
|
|
||||||
|
import bal.gui.qt.dialogs as dialogs
|
||||||
|
from bal.core.qrtransfer import CHUNK_PRESETS
|
||||||
|
|
||||||
|
_app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
|
# A valid 1x1 transparent PNG, good enough for BalDialog's window icon.
|
||||||
|
_PNG_BYTES = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||||
|
)
|
||||||
|
|
||||||
|
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
|
||||||
|
# version 2); used for real-WillItem serialization tests.
|
||||||
|
_VALID_TX_HEX = (
|
||||||
|
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||||
|
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||||
|
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||||
|
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||||
|
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||||
|
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||||
|
"42146f11ef8414ae929feaafc388ac00000000"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Cfg:
|
||||||
|
def __init__(self, value):
|
||||||
|
self._value = value
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
def set(self, value, save=True):
|
||||||
|
self._value = value
|
||||||
|
|
||||||
|
|
||||||
|
class FakePlugin:
|
||||||
|
def __init__(self):
|
||||||
|
self.QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||||
|
|
||||||
|
def read_file(self, path):
|
||||||
|
return _PNG_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWindow(QMainWindow):
|
||||||
|
config = {}
|
||||||
|
|
||||||
|
def format_amount(self, amount):
|
||||||
|
return "{:.8f}".format(amount)
|
||||||
|
|
||||||
|
def format_amount_and_units(self, amount):
|
||||||
|
return "{:.8f} sat".format(amount)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAudioPlugin:
|
||||||
|
"""Duck-typed ``audio_modem`` plugin for the audio pages."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.modem_config = None
|
||||||
|
|
||||||
|
def is_available(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeModemConfig:
|
||||||
|
def __init__(self, kbps):
|
||||||
|
self.modem_bps = kbps * 1000
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBalWindow:
|
||||||
|
"""Duck-typed stand-in for BalWindow (dialog layer only)."""
|
||||||
|
|
||||||
|
def __init__(self, audio_plugin=None):
|
||||||
|
self.window = FakeWindow()
|
||||||
|
self.bal_plugin = FakePlugin()
|
||||||
|
self.willitems = {}
|
||||||
|
self.audio_plugin = audio_plugin
|
||||||
|
self.bitrate_set = None
|
||||||
|
self.audio_payloads = []
|
||||||
|
|
||||||
|
def get_audio_modem_plugin(self):
|
||||||
|
return self.audio_plugin
|
||||||
|
|
||||||
|
def set_audio_modem_bitrate(self, kbps):
|
||||||
|
self.bitrate_set = kbps
|
||||||
|
if self.audio_plugin is not None:
|
||||||
|
self.audio_plugin.modem_config = _FakeModemConfig(kbps)
|
||||||
|
|
||||||
|
def _audio_send_payload(self, payload):
|
||||||
|
self.audio_payloads.append(payload)
|
||||||
|
|
||||||
|
def export_json_file(self, path, will=None):
|
||||||
|
items = will if will is not None else self.willitems
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({wid: wi.to_dict() for wid, wi in items.items()}, f)
|
||||||
|
|
||||||
|
def export_tx_file(self, path, will=None):
|
||||||
|
items = will if will is not None else self.willitems
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(",".join(str(wi.tx) for _, wi in items.items()))
|
||||||
|
|
||||||
|
|
||||||
|
class StubTx:
|
||||||
|
def __init__(self, payload):
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def txid(self):
|
||||||
|
return "{:064x}".format(hash(self.payload) & 0xFFFFFFFFFFFFFFFF)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.payload
|
||||||
|
|
||||||
|
|
||||||
|
class StubWillItem:
|
||||||
|
def __init__(self, payload, statuses=None):
|
||||||
|
self.tx = StubTx(payload)
|
||||||
|
self.statuses = statuses or {}
|
||||||
|
|
||||||
|
def get_status(self, name):
|
||||||
|
return self.statuses.get(name, False)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"tx": str(self.tx)}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_willitems(n=3, payload_len=60, statuses=None):
|
||||||
|
return {
|
||||||
|
"item{}".format(i): StubWillItem(
|
||||||
|
"T{}".format(i) * payload_len, statuses=statuses
|
||||||
|
)
|
||||||
|
for i in range(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Shared export filters
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_export_filter_options():
|
||||||
|
opts = dialogs.export_filter_options()
|
||||||
|
assert [label for label, _fn in opts] == ["All", "Valid", "Valid NC"]
|
||||||
|
complete = StubWillItem("C*", statuses={"VALID": True, "COMPLETE": True})
|
||||||
|
valid = StubWillItem("V*", statuses={"VALID": True})
|
||||||
|
plain = StubWillItem("P*")
|
||||||
|
assert opts[0][1](complete) and opts[0][1](plain)
|
||||||
|
assert opts[1][1](complete) and opts[1][1](valid) and not opts[1][1](plain)
|
||||||
|
assert not opts[2][1](complete)
|
||||||
|
assert opts[2][1](valid) and not opts[2][1](plain)
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_willitems_by_index():
|
||||||
|
items = {
|
||||||
|
"a": StubWillItem("A*", statuses={"VALID": True, "COMPLETE": True}),
|
||||||
|
"b": StubWillItem("B*", statuses={"VALID": True}),
|
||||||
|
"c": StubWillItem("C*"),
|
||||||
|
}
|
||||||
|
opts = dialogs.export_filter_options()
|
||||||
|
assert set(dialogs.filter_willitems(items, opts, 0)) == {"a", "b", "c"}
|
||||||
|
assert set(dialogs.filter_willitems(items, opts, 1)) == {"a", "b"}
|
||||||
|
assert dialogs.filter_willitems(items, opts, 2) == {"b": items["b"]}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# WillExportDialog file page
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_file_export_selects_by_filter():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
items = _make_willitems(3)
|
||||||
|
items["item0"].statuses = {"VALID": True, "COMPLETE": True}
|
||||||
|
items["item1"].statuses = {"VALID": True}
|
||||||
|
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||||
|
assert set(d._selected_items()) == set(items)
|
||||||
|
|
||||||
|
d._on_filter_change(1)
|
||||||
|
assert set(d._selected_items()) == {"item0", "item1"}
|
||||||
|
d._on_filter_change(2)
|
||||||
|
assert list(d._selected_items()) == ["item1"]
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_export_run_tx_only(tmpdir):
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
items = _make_willitems(3)
|
||||||
|
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||||
|
d.content_check.setChecked(False)
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_gui(window, title, exporter):
|
||||||
|
captured["title"] = title
|
||||||
|
captured["exporter"] = exporter
|
||||||
|
|
||||||
|
with patch.object(dialogs, "export_meta_gui", side_effect=fake_gui):
|
||||||
|
d._export_file()
|
||||||
|
assert captured["title"] == "will_tx"
|
||||||
|
out = tmpdir.join("will_tx.txt").strpath
|
||||||
|
captured["exporter"](out)
|
||||||
|
expected = ",".join(str(wi.tx) for _, wi in items.items())
|
||||||
|
assert open(out, encoding="utf-8").read() == expected
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_export_run_willitem(tmpdir):
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
items = _make_willitems(2)
|
||||||
|
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||||
|
assert d.content_check.isChecked()
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_gui(window, title, exporter):
|
||||||
|
captured["title"] = title
|
||||||
|
captured["exporter"] = exporter
|
||||||
|
|
||||||
|
with patch.object(dialogs, "export_meta_gui", side_effect=fake_gui):
|
||||||
|
d._export_file()
|
||||||
|
assert captured["title"] == "will"
|
||||||
|
out = tmpdir.join("will.json").strpath
|
||||||
|
captured["exporter"](out)
|
||||||
|
data = json.load(open(out, encoding="utf-8"))
|
||||||
|
assert set(data) == set(items)
|
||||||
|
assert data["item0"]["tx"] == str(items["item0"].tx)
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_export_empty_under_filter_aborts():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
items = {
|
||||||
|
"a": StubWillItem("A*", statuses={"VALID": True, "COMPLETE": True})
|
||||||
|
}
|
||||||
|
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||||
|
messages = []
|
||||||
|
d.show_message = lambda msg: messages.append(msg) # type: ignore[assignment]
|
||||||
|
d._filter_index = 2 # force an empty "Valid NC" selection
|
||||||
|
with patch.object(dialogs, "export_meta_gui") as gui:
|
||||||
|
d._export_file()
|
||||||
|
assert not gui.called
|
||||||
|
assert messages
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# BalWindow transport helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_bal_window_export_tx_file(tmpdir):
|
||||||
|
import bal.gui.qt.window as window
|
||||||
|
|
||||||
|
items = _make_willitems(3)
|
||||||
|
bw = object.__new__(window.BalWindow)
|
||||||
|
bw.willitems = items
|
||||||
|
out = tmpdir.join("will_tx.txt").strpath
|
||||||
|
bw.export_tx_file(out)
|
||||||
|
expected = ",".join(str(wi.tx) for _, wi in items.items())
|
||||||
|
assert open(out, encoding="utf-8").read() == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_bal_window_set_audio_modem_bitrate():
|
||||||
|
try:
|
||||||
|
import amodem.config
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
import bal.gui.qt.window as window
|
||||||
|
|
||||||
|
class P:
|
||||||
|
def __init__(self):
|
||||||
|
self.modem_config = None
|
||||||
|
|
||||||
|
probe = P()
|
||||||
|
bw = object.__new__(window.BalWindow)
|
||||||
|
bw.get_audio_modem_plugin = lambda: probe
|
||||||
|
bw.set_audio_modem_bitrate(1)
|
||||||
|
assert probe.modem_config is amodem.config.bitrates[1]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# WillExportDialog audio page
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_audio_export_page_send():
|
||||||
|
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||||
|
items = _make_willitems(3, payload_len=30)
|
||||||
|
bw.willitems = items
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
assert d.audio_send_btn.text() == dialogs._("Send")
|
||||||
|
assert d.audio_send_btn.isEnabled()
|
||||||
|
assert d.transport_audio.isEnabled()
|
||||||
|
assert d.kbps_combo.count() > 0
|
||||||
|
|
||||||
|
kbps = int(d.kbps_combo.currentText())
|
||||||
|
d._send_audio()
|
||||||
|
assert bw.bitrate_set == kbps
|
||||||
|
# Whole-will default: the audio payload is a single JSON document.
|
||||||
|
assert len(bw.audio_payloads) == 1
|
||||||
|
data = json.loads(bw.audio_payloads[0])
|
||||||
|
assert set(data) == set(items)
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_export_page_send_tx_only():
|
||||||
|
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||||
|
items = _make_willitems(3, payload_len=30)
|
||||||
|
bw.willitems = items
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
d.content_check.setChecked(False)
|
||||||
|
|
||||||
|
kbps = int(d.kbps_combo.currentText())
|
||||||
|
d._send_audio()
|
||||||
|
assert bw.bitrate_set == kbps
|
||||||
|
expected = "\n".join(dialogs.serialize_tx_list(items))
|
||||||
|
assert bw.audio_payloads == [expected]
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_export_page_plugin_missing():
|
||||||
|
# The window stays usable: only the audio option is disabled.
|
||||||
|
bw = FakeBalWindow(audio_plugin=None)
|
||||||
|
bw.willitems = _make_willitems(2)
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
assert not d.audio_send_btn.isEnabled()
|
||||||
|
assert not d.transport_audio.isEnabled()
|
||||||
|
assert "not available" in d.audio_warn_label.text()
|
||||||
|
assert len(d.qr_page.frames) >= 1 # QR still usable
|
||||||
|
assert d.file_export_btn.isEnabled()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# WillImportDialog audio page
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_audio_import_page_build():
|
||||||
|
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
assert d.kbps_combo.count() > 0
|
||||||
|
assert d.receive_btn.text() == dialogs._("Receive by audio…")
|
||||||
|
assert d.receive_btn.isEnabled()
|
||||||
|
assert d.transport_audio.isEnabled()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_import_page_plugin_missing():
|
||||||
|
bw = FakeBalWindow(audio_plugin=None)
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
assert not d.receive_btn.isEnabled()
|
||||||
|
assert not d.transport_audio.isEnabled()
|
||||||
|
assert "not available" in d.audio_warn_label.text()
|
||||||
|
assert d.qr_page is not None # QR import still usable
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_import_receive_wiring():
|
||||||
|
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class FakeWaitingDialog:
|
||||||
|
def __init__(self, parent, msg, task, on_success=None, on_error=None):
|
||||||
|
captured["msg"] = msg
|
||||||
|
captured["task"] = task
|
||||||
|
captured["success"] = on_success
|
||||||
|
captured["error"] = on_error
|
||||||
|
|
||||||
|
imported = []
|
||||||
|
|
||||||
|
def fake_complete(bal_window, bal_plugin, payload, **kwargs):
|
||||||
|
imported.append(payload)
|
||||||
|
|
||||||
|
blob = zlib.compress(b"A" * 40 + b"\n" + b"B" * 40)
|
||||||
|
with patch.object(dialogs, "WaitingDialog", FakeWaitingDialog), patch.object(
|
||||||
|
dialogs, "_complete_import", side_effect=fake_complete
|
||||||
|
):
|
||||||
|
d._audio_receive()
|
||||||
|
kbps = int(d.kbps_combo.currentText())
|
||||||
|
assert bw.bitrate_set == kbps
|
||||||
|
assert captured["task"] is not None
|
||||||
|
captured["success"](blob)
|
||||||
|
# Payload is the raw decompressed text; autodetect handles the splitting.
|
||||||
|
assert imported == ["A" * 40 + "\n" + "B" * 40]
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Whole-will JSON payload serializes a real Transaction (MyEncoder)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_qr_whole_will_json_serializes_transaction():
|
||||||
|
"""Regression: _whole_will_json must not raise
|
||||||
|
"Object of type Transaction is not JSON serializable".
|
||||||
|
|
||||||
|
Real WillItems keep a ``Transaction`` object in ``tx``; the whole-will
|
||||||
|
QR payload (default content scope) must serialize it via MyEncoder the
|
||||||
|
same way write_json_file does.
|
||||||
|
"""
|
||||||
|
from bal.core.will import WillItem
|
||||||
|
|
||||||
|
item = WillItem({
|
||||||
|
"tx": _VALID_TX_HEX,
|
||||||
|
"heirs": {},
|
||||||
|
"willexecutor": None,
|
||||||
|
"status": "",
|
||||||
|
"description": "",
|
||||||
|
"time": 0,
|
||||||
|
"change": "",
|
||||||
|
"baltx_fees": 100,
|
||||||
|
})
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillExportDialog(
|
||||||
|
bw, will={"imp0": item}, bal_plugin=bw.bal_plugin, initial_mode="qr"
|
||||||
|
)
|
||||||
|
j = d._whole_will_json()
|
||||||
|
data = json.loads(j)
|
||||||
|
assert data["imp0"]["tx"] == _VALID_TX_HEX
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Main
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import inspect
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
class _Path:
|
||||||
|
def __init__(self, d, name):
|
||||||
|
self.strpath = os.path.join(d, name)
|
||||||
|
|
||||||
|
class _Tmp:
|
||||||
|
def __init__(self):
|
||||||
|
self._d = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def join(self, name):
|
||||||
|
return _Path(self._d, name)
|
||||||
|
|
||||||
|
tmp = _Tmp()
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
fn = globals()[name]
|
||||||
|
fn(tmp) if inspect.signature(fn).parameters else fn()
|
||||||
|
print(" [OK] {}".format(name))
|
||||||
|
print("[OK] All export dialog GUI tests passed")
|
||||||
@@ -179,6 +179,7 @@ def test_rebuild_path_schedules_full_refresh():
|
|||||||
win.date_to_check = 1_800_000_000
|
win.date_to_check = 1_800_000_000
|
||||||
win.will_settings = {"baltx_fees": 1, "locktime": "1 month"}
|
win.will_settings = {"baltx_fees": 1, "locktime": "1 month"}
|
||||||
win.bal_plugin = _CfgBag(
|
win.bal_plugin = _CfgBag(
|
||||||
|
is_basic_mode=lambda: False,
|
||||||
MAX_WILLEXECUTOR_FEE=_Cfg(1),
|
MAX_WILLEXECUTOR_FEE=_Cfg(1),
|
||||||
SAVE_HISTORY=_Cfg(True),
|
SAVE_HISTORY=_Cfg(True),
|
||||||
HISTORY_LABEL=_Cfg("LBL"),
|
HISTORY_LABEL=_Cfg("LBL"),
|
||||||
@@ -204,6 +205,46 @@ def test_rebuild_path_schedules_full_refresh():
|
|||||||
schedule_mock.assert_called_once_with()
|
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
|
# Main
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
739
tests/test_gui_qr_transfer.py
Normal file
739
tests/test_gui_qr_transfer.py
Normal file
@@ -0,0 +1,739 @@
|
|||||||
|
"""
|
||||||
|
Tests for the QR / audio will-transfer dialogs (``bal.gui.qt.dialogs``).
|
||||||
|
|
||||||
|
Covers the unified ``WillExportDialog`` (transport radios, stacked pages,
|
||||||
|
QR build/navigation, chunk-size / autoplay, filter revert) and the unified
|
||||||
|
``WillImportDialog`` (QR frame capture, slot grid, complete-review enabling,
|
||||||
|
total mismatch reset, frame assembly -> decode). The QR transport now uses
|
||||||
|
only the BBQR wire format. The wizard and the camera/audio paths need a live
|
||||||
|
wallet/hardware and are exercised only through the shared frame-assembly
|
||||||
|
path here.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
|
from electrum.transaction import Transaction
|
||||||
|
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||||
|
|
||||||
|
import bal.gui.qt.dialogs as dialogs
|
||||||
|
from bal.core.animated_qr import bbqr_frames
|
||||||
|
from bal.core.qrtransfer import CHUNK_PRESETS, encode_transfer
|
||||||
|
from bal.core.will import WillItem
|
||||||
|
|
||||||
|
_app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
|
# A valid 1x1 transparent PNG, good enough for BalDialog's window icon.
|
||||||
|
_PNG_BYTES = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||||
|
)
|
||||||
|
|
||||||
|
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output),
|
||||||
|
# reused for the WillItem status regression test.
|
||||||
|
_VALID_TX_HEX = (
|
||||||
|
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||||
|
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||||
|
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||||
|
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||||
|
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||||
|
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||||
|
"42146f11ef8414ae929feaafc388ac00000000"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rand_hex(n):
|
||||||
|
"""Incompressible lowercase hex string of ``n`` random bytes."""
|
||||||
|
return os.urandom(n).hex()
|
||||||
|
|
||||||
|
|
||||||
|
def _bbqr_frames(transfer, budget):
|
||||||
|
return bbqr_frames(transfer.encode("utf-8"), budget, encoding="Z")
|
||||||
|
|
||||||
|
|
||||||
|
class _Cfg:
|
||||||
|
def __init__(self, value):
|
||||||
|
self._value = value
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
def set(self, value, save=True):
|
||||||
|
self._value = value
|
||||||
|
|
||||||
|
|
||||||
|
class FakePlugin:
|
||||||
|
# Smallest QR preset: long transfers produce several frames.
|
||||||
|
def __init__(self):
|
||||||
|
self.QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||||
|
|
||||||
|
def read_file(self, path):
|
||||||
|
return _PNG_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWindow(QMainWindow):
|
||||||
|
config = {}
|
||||||
|
|
||||||
|
def format_amount(self, amount):
|
||||||
|
return "{:.8f}".format(amount)
|
||||||
|
|
||||||
|
def format_amount_and_units(self, amount):
|
||||||
|
return "{:.8f} sat".format(amount)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBalWindow:
|
||||||
|
"""Duck-typed stand-in for BalWindow (dialog layer only)."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.window = FakeWindow()
|
||||||
|
self.bal_plugin = FakePlugin()
|
||||||
|
self.willitems = {}
|
||||||
|
|
||||||
|
def get_audio_modem_plugin(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class StubTx:
|
||||||
|
def __init__(self, payload):
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def txid(self):
|
||||||
|
return "{:064x}".format(hash(self.payload) & 0xFFFFFFFFFFFFFFFF)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.payload
|
||||||
|
|
||||||
|
|
||||||
|
class StubWillItem:
|
||||||
|
def __init__(self, payload, statuses=None):
|
||||||
|
self.tx = StubTx(payload)
|
||||||
|
self.statuses = statuses or {}
|
||||||
|
|
||||||
|
def get_status(self, name):
|
||||||
|
return self.statuses.get(name, False)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"tx": str(self.tx), "status": self.statuses}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_willitems(n=3, payload_len=120, payloads=None):
|
||||||
|
if payloads is None:
|
||||||
|
payloads = [_rand_hex(payload_len) for _ in range(n)]
|
||||||
|
return {
|
||||||
|
"item{}".format(i): StubWillItem(p)
|
||||||
|
for i, p in enumerate(payloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# WillExportDialog (QR transport via d.qr_page)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_export_dialog_builds():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
bw.willitems = _make_willitems()
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
assert d.transport_qr.isChecked()
|
||||||
|
assert page.tx_strings
|
||||||
|
assert page.frames
|
||||||
|
assert len(page.frames) >= 1
|
||||||
|
# Frame 1 is shown, and it is a BBQR frame.
|
||||||
|
assert page.qr_view.text == page.frames[0]
|
||||||
|
assert page.frames[0].startswith("B$")
|
||||||
|
assert "1" in page.progress_label.text()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_dialog_unified_transports():
|
||||||
|
# One window hosts the three transports as stacked, radio-selected pages.
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
bw.willitems = _make_willitems()
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
assert d.stacked.count() == 3
|
||||||
|
assert d.transport_file.isChecked()
|
||||||
|
assert d.stacked.currentWidget() is d.file_page
|
||||||
|
d._on_mode_clicked(d.MODE_QR)
|
||||||
|
assert d.stacked.currentWidget() is d.qr_page
|
||||||
|
d._on_mode_clicked(d.MODE_AUDIO)
|
||||||
|
assert d.stacked.currentWidget() is d.audio_page
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_dialog_qr_page_has_no_audio():
|
||||||
|
# Audio lives on the import dialog's own audio page, never in the QR page.
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
assert not hasattr(page, "_audio_receive")
|
||||||
|
texts = [b.text() for b in page.findChildren(dialogs.QPushButton)]
|
||||||
|
assert not any("Audio" in t for t in texts)
|
||||||
|
assert d.receive_btn is not None
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_dialog_empty_close():
|
||||||
|
# An empty will shows a modal message and no widgets are built; stub the
|
||||||
|
# message out for the test.
|
||||||
|
orig = dialogs.MessageBoxMixin.show_message
|
||||||
|
dialogs.MessageBoxMixin.show_message = lambda self, msg, icon=None: None
|
||||||
|
try:
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
assert not d.isVisible()
|
||||||
|
assert not hasattr(d, "qr_page")
|
||||||
|
d.close()
|
||||||
|
finally:
|
||||||
|
dialogs.MessageBoxMixin.show_message = orig
|
||||||
|
|
||||||
|
|
||||||
|
def test_imported_item_status_not_none():
|
||||||
|
# Regression: a WillItem built from a bare {"tx": hex} had a None status,
|
||||||
|
# so set_status (e.g. IMPORTED / INVALIDATED from the import validity
|
||||||
|
# pass) crashed with "unsupported operand type(s) for +=: 'NoneType' and
|
||||||
|
# 'str'".
|
||||||
|
with patch.object(Transaction, "add_info_from_wallet"):
|
||||||
|
wi = WillItem({"tx": _VALID_TX_HEX}, wallet=None)
|
||||||
|
assert wi.status == ""
|
||||||
|
assert wi.set_status("IMPORTED", True) is True
|
||||||
|
assert wi.set_status("INVALIDATED", True) is True
|
||||||
|
assert "Imported" in wi.status and "Invalidated" in wi.status
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_auto_scroll():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
assert page.fps_spin is not None
|
||||||
|
assert len(page.frames) > 1
|
||||||
|
assert not page.auto_timer.isActive()
|
||||||
|
|
||||||
|
page.fps_spin.setValue(2)
|
||||||
|
page._toggle_auto()
|
||||||
|
assert page.auto_timer.isActive()
|
||||||
|
assert page.auto_btn.text() == dialogs._("Stop")
|
||||||
|
page._auto_step()
|
||||||
|
assert page.index == 1
|
||||||
|
page._toggle_auto()
|
||||||
|
assert not page.auto_timer.isActive()
|
||||||
|
assert page.auto_btn.text() == dialogs._("Auto")
|
||||||
|
|
||||||
|
# Advancing past the last frame stops the slideshow automatically.
|
||||||
|
page._toggle_auto()
|
||||||
|
page.index = len(page.frames) - 1
|
||||||
|
page._auto_step()
|
||||||
|
assert not page.auto_timer.isActive()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_auto_scroll_loop():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
assert page.loop_check is not None
|
||||||
|
assert len(page.frames) > 1
|
||||||
|
|
||||||
|
# Loop on: reaching the last code wraps back to the first and keeps going.
|
||||||
|
page.loop_check.setChecked(True)
|
||||||
|
page._toggle_auto()
|
||||||
|
assert page.auto_timer.isActive()
|
||||||
|
page.index = len(page.frames) - 1
|
||||||
|
page._auto_step()
|
||||||
|
assert page.index == 0
|
||||||
|
assert page.auto_timer.isActive()
|
||||||
|
page._toggle_auto()
|
||||||
|
|
||||||
|
# Loop off: reaching the last code stops the slideshow.
|
||||||
|
page.loop_check.setChecked(False)
|
||||||
|
page._toggle_auto()
|
||||||
|
page.index = len(page.frames) - 1
|
||||||
|
page._auto_step()
|
||||||
|
assert not page.auto_timer.isActive()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_filter_valid_and_valid_nc():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
a = StubWillItem(_rand_hex(120), statuses={"VALID": True, "COMPLETE": True})
|
||||||
|
b = StubWillItem(_rand_hex(120), statuses={"VALID": True})
|
||||||
|
c = StubWillItem(_rand_hex(120))
|
||||||
|
bw.willitems = {"a": a, "b": b, "c": c}
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
assert d.filter_combo.count() == 3
|
||||||
|
# Whole-will default: a single JSON document carrying all selected items.
|
||||||
|
assert d.content_check.isChecked()
|
||||||
|
assert len(page.tx_strings) == 1
|
||||||
|
data = json.loads(page.tx_strings[0])
|
||||||
|
assert set(data) == {"a", "b", "c"}
|
||||||
|
|
||||||
|
# Switch to tx-only content, then exercise the filters.
|
||||||
|
d.content_check.setChecked(False)
|
||||||
|
assert len(page.tx_strings) == 3
|
||||||
|
|
||||||
|
# "Valid" filter -> only the valid items (a, b).
|
||||||
|
d._on_filter_change(1)
|
||||||
|
assert sorted(page.tx_strings) == sorted([str(a.tx), str(b.tx)])
|
||||||
|
|
||||||
|
# "Valid NC" filter -> only the valid, not-complete item (b).
|
||||||
|
d._on_filter_change(2)
|
||||||
|
assert sorted(page.tx_strings) == [str(b.tx)]
|
||||||
|
assert page.qr_view.text == page.frames[0]
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_filter_empty_reverts():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
# Only a COMPLETE valid item: "Valid NC" selects nothing -> revert.
|
||||||
|
bw.willitems = {
|
||||||
|
"a": StubWillItem(_rand_hex(120), statuses={"VALID": True, "COMPLETE": True})
|
||||||
|
}
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
messages = []
|
||||||
|
d.show_message = lambda msg: messages.append(msg) # type: ignore[assignment]
|
||||||
|
d._on_filter_change(2) # "Valid NC" -> empty subset
|
||||||
|
assert messages
|
||||||
|
assert d._filter_index == 0 # reverted to "All"
|
||||||
|
assert d.filter_combo.currentIndex() == 0
|
||||||
|
assert len(d.qr_page.tx_strings) == 1
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_navigation_and_chunk_change():
|
||||||
|
# Incompressible serialized transactions resist deflate, so the BBQR
|
||||||
|
# transfer still needs several frames at the default chunk and navigation
|
||||||
|
# across frames is exercised.
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
first_count = len(page.frames)
|
||||||
|
assert first_count > 1 # long transfer, small default chunk
|
||||||
|
|
||||||
|
assert not page.prev_btn.isEnabled()
|
||||||
|
page._next()
|
||||||
|
assert page.index == 1
|
||||||
|
assert page.qr_view.text == page.frames[1]
|
||||||
|
assert page.prev_btn.isEnabled()
|
||||||
|
page._prev()
|
||||||
|
assert page.index == 0
|
||||||
|
assert page.qr_view.text == page.frames[0]
|
||||||
|
|
||||||
|
# Switch to the largest preset: fewer, bigger frames.
|
||||||
|
page._on_chunk_change(len(dialogs.CHUNK_PRESETS) - 1)
|
||||||
|
assert len(page.frames) < first_count
|
||||||
|
assert page.index == 0
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_size_change_is_remembered():
|
||||||
|
# Changing the QR size inside the export dialog persists the choice so the
|
||||||
|
# next export starts from the last selected size.
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
assert bw.bal_plugin.QR_CHUNK_SIZE.get() == dialogs.CHUNK_PRESETS[0][1]
|
||||||
|
|
||||||
|
page._on_chunk_change(len(dialogs.CHUNK_PRESETS) - 1)
|
||||||
|
assert bw.bal_plugin.QR_CHUNK_SIZE.get() == dialogs.CHUNK_PRESETS[-1][1]
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# WillImportDialog (QR transport via d.qr_page)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_import_frame_flow():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
assert not page.review_btn.isEnabled()
|
||||||
|
assert not page.slot_area.isVisible()
|
||||||
|
|
||||||
|
transfer = encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)])
|
||||||
|
frames = _bbqr_frames(transfer, 150)
|
||||||
|
assert len(frames) > 1
|
||||||
|
|
||||||
|
for frame in frames:
|
||||||
|
page._add_frame(frame)
|
||||||
|
assert page.total == len(frames)
|
||||||
|
assert page.review_btn.isEnabled()
|
||||||
|
# isVisible() needs a shown parent; assert the widget is not hidden instead.
|
||||||
|
assert not page.slot_area.isHidden()
|
||||||
|
assert len(page.slot_widgets) == page.total
|
||||||
|
assert "All" in page.status_label.text()
|
||||||
|
# Duplicate capture is harmless.
|
||||||
|
page._add_frame(frames[0])
|
||||||
|
assert len(page.frames) == page.total
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_assembles_and_decodes():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_finish(payload):
|
||||||
|
captured["payload"] = payload
|
||||||
|
|
||||||
|
page._finish_import = fake_finish
|
||||||
|
tx_a = _rand_hex(130)
|
||||||
|
tx_b = _rand_hex(130)
|
||||||
|
transfer = encode_transfer([tx_a, tx_b])
|
||||||
|
for frame in _bbqr_frames(transfer, 150):
|
||||||
|
page._add_frame(frame)
|
||||||
|
page._review_and_sign()
|
||||||
|
# The payload is rejoined into an opaque string for autodetect.
|
||||||
|
assert captured["payload"].split("\n") == [tx_a, tx_b]
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_will_payload_autodetect():
|
||||||
|
# Whole-will JSON is recognized as a will document.
|
||||||
|
payload = json.dumps({"item1": {"tx": "AAAA", "status": {"VALID": True}}})
|
||||||
|
kind, data = dialogs.decode_will_payload(payload)
|
||||||
|
assert kind == "will"
|
||||||
|
assert data["item1"]["tx"] == "AAAA"
|
||||||
|
|
||||||
|
# A singleton dict whose value is not an item dict falls back to txs.
|
||||||
|
kind, data = dialogs.decode_will_payload('{"foo": 1}')
|
||||||
|
assert kind == "txs"
|
||||||
|
|
||||||
|
# Comma and/or newline separated transactions.
|
||||||
|
kind, data = dialogs.decode_will_payload("AAAA,BBBB\nCCCC")
|
||||||
|
assert kind == "txs"
|
||||||
|
assert data == ["AAAA", "BBBB", "CCCC"]
|
||||||
|
|
||||||
|
# A single transaction with no separators.
|
||||||
|
kind, data = dialogs.decode_will_payload("HEXHEX")
|
||||||
|
assert kind == "txs"
|
||||||
|
assert data == ["HEXHEX"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_whole_will_qr_roundtrip():
|
||||||
|
# "Whole will" produces a single JSON document that survives a full
|
||||||
|
# QR encode -> frame capture -> assemble -> decode cycle.
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
items = _make_willitems(2)
|
||||||
|
bw.willitems = items
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
assert d.content_check.isChecked()
|
||||||
|
assert len(page.tx_strings) == 1
|
||||||
|
|
||||||
|
# Rebuild the transfer from the dialog's own strings, as the importer does.
|
||||||
|
transfer = encode_transfer(page.tx_strings)
|
||||||
|
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
import_page = impl.qr_page
|
||||||
|
for frame in _bbqr_frames(transfer, dialogs.CHUNK_PRESETS[0][1]):
|
||||||
|
import_page._add_frame(frame)
|
||||||
|
assert import_page.review_btn.isEnabled()
|
||||||
|
caught = {}
|
||||||
|
|
||||||
|
def fake_finish(payload):
|
||||||
|
caught["payload"] = payload
|
||||||
|
|
||||||
|
import_page._finish_import = fake_finish
|
||||||
|
import_page._review_and_sign()
|
||||||
|
kind, data = dialogs.decode_will_payload(caught["payload"])
|
||||||
|
assert kind == "will"
|
||||||
|
assert set(data) == {"item0", "item1"}
|
||||||
|
d.close()
|
||||||
|
impl.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_total_mismatch_resets():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
warnings = []
|
||||||
|
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
|
||||||
|
|
||||||
|
frames_a = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||||
|
frames_b = _bbqr_frames(
|
||||||
|
encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)]), 150
|
||||||
|
)
|
||||||
|
for frame in frames_a:
|
||||||
|
page._add_frame(frame)
|
||||||
|
assert page.total == len(frames_a)
|
||||||
|
|
||||||
|
# A frame with a different total wipes the import; the first frame of
|
||||||
|
# the new transfer must be scanned afresh.
|
||||||
|
page._add_frame(frames_b[0])
|
||||||
|
assert warnings
|
||||||
|
assert page.total == 0
|
||||||
|
assert not page.frames
|
||||||
|
assert not page.review_btn.isEnabled()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_manual_entry():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
frames = _bbqr_frames(encode_transfer(["M" * 20]), 150)
|
||||||
|
page.manual_edit.setText(frames[0])
|
||||||
|
page._add_from_manual()
|
||||||
|
assert page.manual_edit.text() == ""
|
||||||
|
assert page.total == 1
|
||||||
|
assert page.review_btn.isEnabled()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Continuous camera scan (change/detection debounce + auto-finish)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _fresh_debounce():
|
||||||
|
return {
|
||||||
|
"last_index": None,
|
||||||
|
"last_payload": None,
|
||||||
|
"pending_index": None,
|
||||||
|
"pending_payload": None,
|
||||||
|
"pending_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_import_debounce_pending_then_accept():
|
||||||
|
s = _fresh_debounce()
|
||||||
|
# First sighting of a new identity: pending, not yet stored.
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "PAYLOAD1") == "pending"
|
||||||
|
assert s["pending_count"] == 1
|
||||||
|
assert s["last_index"] is None
|
||||||
|
# A second stable read of the same identity: accepted.
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "PAYLOAD1") == "accept"
|
||||||
|
assert s["last_index"] == 0
|
||||||
|
assert s["last_payload"] == "PAYLOAD1"
|
||||||
|
assert s["pending_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_import_debounce_re_reading_last_is_ignored():
|
||||||
|
s = _fresh_debounce()
|
||||||
|
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1")
|
||||||
|
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") # accepted
|
||||||
|
# The exporter is still showing frame 0: must be ignored, not accepted.
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") == "ignore"
|
||||||
|
assert s["last_index"] == 0
|
||||||
|
assert s["pending_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_import_debounce_transition_pending_resets():
|
||||||
|
s = _fresh_debounce()
|
||||||
|
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1")
|
||||||
|
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") # accept frame 0
|
||||||
|
# A new identity interrupts the pending accumulation.
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "pending"
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "accept"
|
||||||
|
# Same-index duplicate with different payload is treated as new identity.
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "ignore"
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_import_debounce_total_mismatch_resets():
|
||||||
|
s = _fresh_debounce()
|
||||||
|
# In-range frame is accepted even though its declared total is ignored.
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:3", 3, 1, "P2") == "pending"
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:3", 3, 1, "P2") == "accept"
|
||||||
|
# A frame that belongs to a different transfer.
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:4", 4, 1, "P2B") == "reset"
|
||||||
|
# Once the policy is rebased on the new transfer, frames resume normally
|
||||||
|
# (the widget clears the debounce while wiping the import).
|
||||||
|
s["key"] = None
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:4", 4, 1, "P2B") == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_import_handle_scanned_text_autofinish():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
reviewed = []
|
||||||
|
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||||
|
|
||||||
|
frames = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||||
|
assert len(frames) > 1
|
||||||
|
|
||||||
|
# The camera session is running and every frame needs two stable reads.
|
||||||
|
page._scanning = True
|
||||||
|
for frame in frames:
|
||||||
|
for _rep in range(2):
|
||||||
|
page._handle_scanned_text(frame)
|
||||||
|
assert page.total == len(frames)
|
||||||
|
assert len(page.frames) == len(frames)
|
||||||
|
assert page.review_btn.isEnabled()
|
||||||
|
|
||||||
|
# With all frames stored, the loop auto-finishes exactly once.
|
||||||
|
_app.processEvents()
|
||||||
|
assert reviewed == [True]
|
||||||
|
# The camera loop was stopped before handing over to the review step.
|
||||||
|
assert not page._scanning
|
||||||
|
assert not page._scan_timer.isActive()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_import_handle_scanned_text_manual_does_not_autofinish():
|
||||||
|
# Without a camera session running, extra frames never auto-proceed.
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
reviewed = []
|
||||||
|
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||||
|
|
||||||
|
frames = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||||
|
assert len(frames) > 1
|
||||||
|
assert not page._scanning
|
||||||
|
for frame in frames:
|
||||||
|
for _rep in range(2):
|
||||||
|
page._handle_scanned_text(frame)
|
||||||
|
assert len(page.frames) == len(frames)
|
||||||
|
_app.processEvents()
|
||||||
|
assert reviewed == []
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# BBQR import/export round trips
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_import_bbqr_roundtrip():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
bw.willitems = _make_willitems(2)
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
frames = list(page.frames)
|
||||||
|
assert frames and all(f.startswith("B$") for f in frames)
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
import_page = impl.qr_page
|
||||||
|
caught = {}
|
||||||
|
import_page._finish_import = lambda payload: caught.__setitem__("payload", payload)
|
||||||
|
for frame in frames:
|
||||||
|
import_page._add_frame(frame)
|
||||||
|
assert import_page.review_btn.isEnabled()
|
||||||
|
import_page._review_and_sign()
|
||||||
|
kind, data = dialogs.decode_will_payload(caught["payload"])
|
||||||
|
assert kind == "will"
|
||||||
|
assert set(data) == {"item0", "item1"}
|
||||||
|
impl.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_animated_scan_debounce_autofinish():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
bw.willitems = _make_willitems(2)
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
frames = list(page.frames)
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
import_page = impl.qr_page
|
||||||
|
reviewed = []
|
||||||
|
import_page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||||
|
import_page._scanning = True
|
||||||
|
for frame in frames:
|
||||||
|
for _rep in range(2):
|
||||||
|
import_page._handle_scanned_text(frame)
|
||||||
|
assert import_page.review_btn.isEnabled()
|
||||||
|
assert import_page.total == len(frames)
|
||||||
|
assert len(import_page.frames) >= import_page.total
|
||||||
|
_app.processEvents()
|
||||||
|
assert reviewed == [True]
|
||||||
|
assert not import_page._scanning
|
||||||
|
impl.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_garbage_scan_is_ignored():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
for garbage in ("hello world", "12345", "B$ZZ", "ur:bytes/xyz", ""):
|
||||||
|
page._handle_scanned_text(garbage)
|
||||||
|
assert not page.frames
|
||||||
|
assert page.total == 0
|
||||||
|
assert not page.review_btn.isEnabled()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_different_animated_transfer_resets():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
warnings = []
|
||||||
|
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
|
||||||
|
|
||||||
|
frames_a = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||||
|
frames_b = _bbqr_frames(
|
||||||
|
encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)]), 150
|
||||||
|
)
|
||||||
|
for frame in frames_a:
|
||||||
|
page._add_frame(frame)
|
||||||
|
assert page.total == len(frames_a)
|
||||||
|
assert not warnings
|
||||||
|
|
||||||
|
page._add_frame(frames_b[0])
|
||||||
|
assert warnings
|
||||||
|
assert page.total == 0
|
||||||
|
assert not page.frames
|
||||||
|
assert not page.review_btn.isEnabled()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_start_stop_scan_signal_wiring():
|
||||||
|
"""Regression: _start_scan/_stop_scan must use the QVideoSink signal
|
||||||
|
videoFrameChanged, not the videoFrame frame getter.
|
||||||
|
|
||||||
|
On PyQt6, ``QVideoSink.videoFrame`` is a method (the frame getter), so
|
||||||
|
``.videoFrame.connect(...)`` raises AttributeError. This test drives the
|
||||||
|
real sink life-cycle with a mocked camera and asserts the scan session
|
||||||
|
starts/ends cleanly with no error.
|
||||||
|
"""
|
||||||
|
from PyQt6.QtMultimedia import QCamera, QMediaCaptureSession, QMediaDevices
|
||||||
|
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
errors = []
|
||||||
|
page.show_error = lambda msg: errors.append(msg) # type: ignore[assignment]
|
||||||
|
|
||||||
|
fake_device = MagicMock()
|
||||||
|
fake_device.isNull.return_value = False
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(QMediaDevices, "defaultVideoInput", return_value=fake_device),
|
||||||
|
# Mock camera + capture session; the QVideoSink stays real so the
|
||||||
|
# videoFrameChanged connect/disconnect wiring is exercised for real.
|
||||||
|
patch.object(QCamera, "__new__", return_value=MagicMock()),
|
||||||
|
patch.object(QMediaCaptureSession, "__new__", return_value=MagicMock()),
|
||||||
|
):
|
||||||
|
page._start_scan()
|
||||||
|
assert page._scanning is True
|
||||||
|
assert not errors
|
||||||
|
|
||||||
|
page._stop_scan()
|
||||||
|
assert page._scanning is False
|
||||||
|
assert page._camera is None
|
||||||
|
assert page._video_sink is None
|
||||||
|
assert not errors
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Main
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All QR transfer GUI tests passed")
|
||||||
@@ -183,6 +183,22 @@ def test_locktime_raw_edit_get_set_value():
|
|||||||
assert "d" in val
|
assert "d" in val
|
||||||
|
|
||||||
|
|
||||||
|
def test_locktime_date_edit_get_set_value_roundtrip():
|
||||||
|
"""set_value(x) must roundtrip to get_value() == x (same timezone).
|
||||||
|
|
||||||
|
Guards a timezone regression that made the Date editor return the stored
|
||||||
|
wall clock re-read as local time, i.e. ``x + utc_offset``. That broke the
|
||||||
|
set_value/get_value roundtrip and kept the valueEdited ->
|
||||||
|
update_setting_widgets -> set_value signal cycle alive forever, ending in a
|
||||||
|
RecursionError when opening the "Build your will" wizard (Next button).
|
||||||
|
"""
|
||||||
|
from bal.gui.qt.widgets import LockTimeDateEdit
|
||||||
|
edit = LockTimeDateEdit()
|
||||||
|
for ts in (1700000000, 1750000000, 2147483647):
|
||||||
|
edit.set_value(ts)
|
||||||
|
assert edit.get_value() == ts
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# PercAmountEdit
|
# PercAmountEdit
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -390,6 +390,110 @@ def test_insufficient_funds_warns():
|
|||||||
assert not ctl.willitems
|
assert not ctl.willitems
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_not_blocked_by_old_built_will():
|
||||||
|
"""Regression: shortening the delivery in the STORED settings (relative
|
||||||
|
"1y"/"30d") while an old, still-VALID built will is frozen at a longer
|
||||||
|
locktime must NOT fire the "locktime is lower than threshold" guard.
|
||||||
|
|
||||||
|
The old guard compared the fresh settings locktime against ``date_to_check``
|
||||||
|
anchored to the built will (see ``resolve_date_to_check``), so a built-will
|
||||||
|
delivery longer than the settings' one made it fire even though the settings
|
||||||
|
are internally consistent (locktime is 30d AFTER the threshold). The guard
|
||||||
|
must instead compare the stored settings on a single reference frame
|
||||||
|
(``BalWindow.is_locktime_below_threshold``); ``date_to_check`` keeps its
|
||||||
|
built anchor for the expiry/validity checks.
|
||||||
|
"""
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.bal_plugin.USER_TYPE.set("advanced") # ADVANCED Check-Alive mode
|
||||||
|
ctl.prepare_will()
|
||||||
|
txid, item = _single(ctl)
|
||||||
|
|
||||||
|
# Freeze the built (VALID) will at a delivery one year longer than the
|
||||||
|
# now-shortened settings: the pre-fix guard would reject the rebuild.
|
||||||
|
item.tx.locktime = item.tx.locktime + 365 * 86400
|
||||||
|
ctl.will_settings = {"locktime": "1y", "threshold": "-30d"}
|
||||||
|
Util.fix_will_settings_tx_fees(ctl.will_settings)
|
||||||
|
|
||||||
|
ctl.init_class_variables()
|
||||||
|
|
||||||
|
# date_to_check is anchored to the built will (long delivery)...
|
||||||
|
assert ctl.date_to_check == item.tx.locktime - 30 * 86400
|
||||||
|
# ...and the OLD guard would have fired here:
|
||||||
|
old_locktime = Util.parse_locktime_string(ctl.will_settings["locktime"])
|
||||||
|
assert old_locktime < ctl.date_to_check
|
||||||
|
# but the settings themselves are consistent, so the guard must pass:
|
||||||
|
assert ctl.is_locktime_below_threshold() is False
|
||||||
|
assert not ctl.window.errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_anticipated_rebuild_reanchors_date_to_check():
|
||||||
|
"""Regression (karen7): rebuilding a SIGNED will whose delivery was
|
||||||
|
anticipated (per-heir recipes shortened from 2y to 1y, ADVANCED mode) must
|
||||||
|
succeed.
|
||||||
|
|
||||||
|
``date_to_check`` stays anchored to the OLD built delivery for the validity
|
||||||
|
checks, but ``build_will`` must re-anchor it to the NEW (earliest current)
|
||||||
|
delivery as its build filter: before the fix the stale 2028 anchor rejected
|
||||||
|
every "1y" heir (cmp <= 0 in ``fixed_percent_lists_amount``) and the build
|
||||||
|
reported ``NO_FUTURE_DATE``. The old signed item is then superseded by
|
||||||
|
``search_rai`` (REPLACED -> no on-chain invalidation) and the rebuilt will
|
||||||
|
is coherent again.
|
||||||
|
"""
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.bal_plugin.USER_TYPE.set("advanced")
|
||||||
|
# Per-heir deliveries require multiverse mode (the only way heirs can
|
||||||
|
# carry a different recipe than the settings locktime).
|
||||||
|
ctl.bal_plugin.ENABLE_MULTIVERSE.set(True)
|
||||||
|
ctl.will_settings = {"locktime": "2y", "threshold": "-150d", "baltx_fees": 20}
|
||||||
|
Util.fix_will_settings_tx_fees(ctl.will_settings)
|
||||||
|
ctl.heirs["alice"][2] = "2y"
|
||||||
|
ctl.heirs["bob"][2] = "2y"
|
||||||
|
|
||||||
|
# Build and sign a 2y will (the old, committed delivery).
|
||||||
|
ctl.prepare_will()
|
||||||
|
old_txid, _old_item = _single(ctl)
|
||||||
|
old_locktime = _old_item.tx.locktime
|
||||||
|
signed = ctl.sign_transactions(None)
|
||||||
|
_old_item.tx = Will.get_tx_from_any(str(signed[old_txid]))
|
||||||
|
Will.check_signatures(ctl.willitems, ctl.wallet)
|
||||||
|
assert _old_item.get_status("COMPLETE")
|
||||||
|
|
||||||
|
# Anticipate: shorten every heir to 1y.
|
||||||
|
ctl.heirs["alice"][2] = "1y"
|
||||||
|
ctl.heirs["bob"][2] = "1y"
|
||||||
|
|
||||||
|
ctl.init_class_variables()
|
||||||
|
# date_to_check stays anchored to the OLD built delivery...
|
||||||
|
assert ctl.date_to_check == old_locktime - 150 * 86400
|
||||||
|
# ...and that stale anchor would reject the anticipated "1y" dates.
|
||||||
|
assert Util.parse_locktime_string("1y") < ctl.date_to_check
|
||||||
|
|
||||||
|
# The rebuild must succeed (re-anchored to the new delivery).
|
||||||
|
willitems = ctl.build_inheritance_transaction()
|
||||||
|
|
||||||
|
assert ctl.heirs.last_build_error is None, "NO_FUTURE_DATE must not fire"
|
||||||
|
new_valid = [
|
||||||
|
it for tid, it in willitems.items()
|
||||||
|
if tid != old_txid and it.get_status("VALID")
|
||||||
|
]
|
||||||
|
assert new_valid, "the anticipated (1y) will must build and stay VALID"
|
||||||
|
new_item = new_valid[0]
|
||||||
|
assert new_item.tx.locktime < old_locktime, "delivery must be anticipated"
|
||||||
|
# date_to_check was re-anchored to the rebuilt delivery (1y minus 150d).
|
||||||
|
assert abs(ctl.date_to_check - (new_item.tx.locktime - 150 * 86400)) < 3600
|
||||||
|
|
||||||
|
# The old signed item is kept but superseded (REPLACED -> not VALID).
|
||||||
|
assert _old_item.get_status("REPLACED") is True
|
||||||
|
assert _old_item.get_status("VALID") is False
|
||||||
|
|
||||||
|
# The rebuilt will is coherent (plain rebuild, no on-chain invalidation).
|
||||||
|
assert ctl.check_will() is True
|
||||||
|
assert not any("delivery date" in m for m in ctl.window.messages)
|
||||||
|
assert not ctl.window.errors
|
||||||
|
|
||||||
|
|
||||||
def _run_all():
|
def _run_all():
|
||||||
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
|
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
|
||||||
for fn in tests:
|
for fn in tests:
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ The two gates that produced the prompt are covered here:
|
|||||||
never read as EXPIRED because the check window drifts past the frozen
|
never read as EXPIRED because the check window drifts past the frozen
|
||||||
tx locktime.
|
tx locktime.
|
||||||
|
|
||||||
The karen7 regtest wallet fixture (``tests/karen7``) reproduces the exact
|
The reported state (reproduced hermetically here — the original live wallet
|
||||||
reported state: heirs with ``"1y"``, a signed/pushed/checked item whose frozen
|
dump ``tests/karen7`` is gitignored and regenerated as the wallet evolves) is:
|
||||||
tx.locktime is 2027-08-05 (built 2026-08-05), and will_settings
|
heirs with ``"1y"``, a signed/pushed/checked item whose frozen tx.locktime is
|
||||||
|
2027-08-05 (built 2026-08-05), and will_settings
|
||||||
``{"locktime": "2y", "threshold": "150d"}``.
|
``{"locktime": "2y", "threshold": "150d"}``.
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
@@ -28,18 +29,16 @@ Run:
|
|||||||
python3 tests/test_heir_relative_anchor.py
|
python3 tests/test_heir_relative_anchor.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
import pytest # noqa: E402 (path insert above)
|
||||||
from electrum import constants # noqa: E402 (path insert above)
|
from electrum import constants # noqa: E402 (path insert above)
|
||||||
|
|
||||||
constants.net = constants.BitcoinRegtest
|
|
||||||
|
|
||||||
from bal.core.checkalive import resolve_date_to_check # noqa: E402
|
from bal.core.checkalive import resolve_date_to_check # noqa: E402
|
||||||
|
from bal.core.util import copy_structure # noqa: E402
|
||||||
from bal.core.will import ( # noqa: E402
|
from bal.core.will import ( # noqa: E402
|
||||||
HeirNotFoundException,
|
HeirNotFoundException,
|
||||||
NoHeirsException,
|
NoHeirsException,
|
||||||
@@ -49,6 +48,16 @@ from bal.core.will import ( # noqa: E402
|
|||||||
WillPostponedException,
|
WillPostponedException,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _regtest_net():
|
||||||
|
"""Run these regtest-focused tests with BitcoinRegtest, restoring mainnet
|
||||||
|
afterwards so sibling test modules are unaffected by the net switch."""
|
||||||
|
constants.net = constants.BitcoinRegtest
|
||||||
|
yield
|
||||||
|
constants.net = constants.BitcoinMainnet
|
||||||
|
|
||||||
|
|
||||||
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0;
|
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0;
|
||||||
# the tests override ``tx.locktime`` to simulate the frozen signed locktime.
|
# the tests override ``tx.locktime`` to simulate the frozen signed locktime.
|
||||||
_VALID_TX_HEX = (
|
_VALID_TX_HEX = (
|
||||||
@@ -71,7 +80,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
|||||||
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
|
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
|
||||||
d = {
|
d = {
|
||||||
"tx": _VALID_TX_HEX,
|
"tx": _VALID_TX_HEX,
|
||||||
"heirs": copy.deepcopy(heirs),
|
"heirs": copy_structure(heirs),
|
||||||
"willexecutor": None,
|
"willexecutor": None,
|
||||||
"status": "",
|
"status": "",
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -80,7 +89,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
|||||||
"baltx_fees": 1,
|
"baltx_fees": 1,
|
||||||
}
|
}
|
||||||
item = WillItem(d, _id="willid_1")
|
item = WillItem(d, _id="willid_1")
|
||||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
item.tx.locktime = tx_locktime
|
item.tx.locktime = tx_locktime
|
||||||
if status_complete:
|
if status_complete:
|
||||||
item.set_status("COMPLETE", True)
|
item.set_status("COMPLETE", True)
|
||||||
@@ -111,7 +120,7 @@ def test_unchanged_relative_recipe_signed_is_coherent():
|
|||||||
read as a postpone just because the clock has advanced past build day."""
|
read as a postpone just because the clock has advanced past build day."""
|
||||||
heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
||||||
outcome = _run_heir_check(
|
outcome = _run_heir_check(
|
||||||
copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=True
|
copy_structure(heirs), copy_structure(heirs), _FROZEN, status_complete=True
|
||||||
)
|
)
|
||||||
assert outcome.startswith("coherent"), outcome
|
assert outcome.startswith("coherent"), outcome
|
||||||
|
|
||||||
@@ -119,7 +128,7 @@ def test_unchanged_relative_recipe_signed_is_coherent():
|
|||||||
def test_unchanged_relative_recipe_unsigned_is_coherent():
|
def test_unchanged_relative_recipe_unsigned_is_coherent():
|
||||||
heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
||||||
outcome = _run_heir_check(
|
outcome = _run_heir_check(
|
||||||
copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=False
|
copy_structure(heirs), copy_structure(heirs), _FROZEN, status_complete=False
|
||||||
)
|
)
|
||||||
assert outcome.startswith("coherent"), outcome
|
assert outcome.startswith("coherent"), outcome
|
||||||
|
|
||||||
@@ -145,7 +154,7 @@ def test_relative_recipe_shortened_on_signed_is_rebuild():
|
|||||||
def test_unchanged_absolute_recipe_is_coherent():
|
def test_unchanged_absolute_recipe_is_coherent():
|
||||||
built = {"alice": ["addr_alice", 5000, str(_FROZEN)]}
|
built = {"alice": ["addr_alice", 5000, str(_FROZEN)]}
|
||||||
outcome = _run_heir_check(
|
outcome = _run_heir_check(
|
||||||
copy.deepcopy(built), copy.deepcopy(built), _FROZEN, status_complete=True
|
copy_structure(built), copy_structure(built), _FROZEN, status_complete=True
|
||||||
)
|
)
|
||||||
assert outcome.startswith("coherent"), outcome
|
assert outcome.startswith("coherent"), outcome
|
||||||
|
|
||||||
@@ -158,55 +167,52 @@ def test_absolute_postpone_on_signed_still_detected():
|
|||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# karen7 wallet regression (real fixture)
|
# karen7 regression (hermetic, no live wallet fixture)
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
# karen7's reported state, reproduced hermetically: heirs "1y", a signed item
|
||||||
def _load_karen7():
|
# frozen at delivery 2027-08-05 (built 2026-08-05), will_settings with a
|
||||||
path = os.path.join(os.path.dirname(__file__), "karen7")
|
# relative "150d" delivery window and a "2y" promised locktime.
|
||||||
with open(path) as f:
|
_WILL_SETTINGS = {"locktime": "2y", "threshold": "150d"}
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def test_karen7_frozen_delivery_not_expired():
|
def test_karen7_frozen_delivery_not_expired():
|
||||||
"""ADVANCED date_to_check anchored to the frozen tx locktime: the check
|
"""ADVANCED date_to_check anchored to the frozen tx locktime: the check
|
||||||
window opens BEFORE the delivery, so the will is never read as expired."""
|
window opens BEFORE the delivery, so the will is never read as expired."""
|
||||||
data = _load_karen7()
|
heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
||||||
will_settings = data["will_settings"]
|
item = _make_will_item(copy_structure(heirs), _FROZEN, status_complete=True)
|
||||||
valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
|
will = {"willid_1": item}
|
||||||
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
|
built_locktime = Will.get_min_locktime(will)
|
||||||
built_locktime = Will.get_min_locktime({valid_wid: wi})
|
assert built_locktime is not None
|
||||||
assert built_locktime == int(wi.tx.locktime)
|
assert built_locktime == int(item.tx.locktime)
|
||||||
|
|
||||||
date_to_check = resolve_date_to_check(
|
date_to_check = resolve_date_to_check(
|
||||||
False, will_settings, now=1_800_000_000.0, built_locktime=built_locktime
|
False, _WILL_SETTINGS, now=1_800_000_000.0, built_locktime=built_locktime
|
||||||
)
|
)
|
||||||
assert int(date_to_check) < built_locktime
|
assert int(date_to_check) < built_locktime
|
||||||
# Re-evaluated 10 days later the window is identical (no daily drift).
|
# Re-evaluated 10 days later the window is identical (no daily drift).
|
||||||
later = resolve_date_to_check(
|
later = resolve_date_to_check(
|
||||||
False, will_settings, now=1_800_000_000.0 + 10 * 86400,
|
False, _WILL_SETTINGS, now=1_800_000_000.0 + 10 * 86400,
|
||||||
built_locktime=built_locktime,
|
built_locktime=built_locktime,
|
||||||
)
|
)
|
||||||
assert date_to_check == later
|
assert date_to_check == later
|
||||||
|
|
||||||
|
|
||||||
def test_karen7_unchanged_heirs_are_coherent():
|
def test_karen7_unchanged_heirs_are_coherent():
|
||||||
"""The karen7 heirs (unchanged relative "2d") are coherent with the frozen
|
"""Unchanged relative "1y" heirs are coherent with the frozen signed tx:
|
||||||
signed tx: the plugin must NOT ask to invalidate the will."""
|
the plugin must NOT ask to invalidate the will."""
|
||||||
data = _load_karen7()
|
heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
||||||
valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
|
|
||||||
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
|
|
||||||
# Use _FROZEN (a UTC-midnight value) so the check is compatible with
|
# Use _FROZEN (a UTC-midnight value) so the check is compatible with
|
||||||
# the UTC anchoring code.
|
# the UTC anchoring code.
|
||||||
frozen_locktime = _FROZEN
|
frozen_locktime = _FROZEN
|
||||||
date_to_check = resolve_date_to_check(
|
date_to_check = resolve_date_to_check(
|
||||||
False, data["will_settings"],
|
False, _WILL_SETTINGS,
|
||||||
now=1_800_000_000.0,
|
now=1_800_000_000.0,
|
||||||
built_locktime=frozen_locktime,
|
built_locktime=frozen_locktime,
|
||||||
)
|
)
|
||||||
outcome = _run_heir_check(
|
outcome = _run_heir_check(
|
||||||
data["will"][valid_wid]["heirs"],
|
copy_structure(heirs),
|
||||||
data["heirs"],
|
copy_structure(heirs),
|
||||||
frozen_locktime,
|
frozen_locktime,
|
||||||
status_complete=True,
|
status_complete=True,
|
||||||
)
|
)
|
||||||
@@ -219,6 +225,7 @@ def test_karen7_unchanged_heirs_are_coherent():
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
constants.net = constants.BitcoinRegtest
|
||||||
for name in sorted(dir()):
|
for name in sorted(dir()):
|
||||||
if name.startswith("test_"):
|
if name.startswith("test_"):
|
||||||
globals()[name]()
|
globals()[name]()
|
||||||
|
|||||||
309
tests/test_i18n.py
Normal file
309
tests/test_i18n.py
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
"""Tests for bal/i18n.py, BAL's translation layer (PLAN_I18N.md, phase 1).
|
||||||
|
|
||||||
|
The BAL catalogs are built in memory (see ``_mo_bytes``), so these tests need
|
||||||
|
no compiled .mo file and no Babel. Electrum's translator is replaced by a
|
||||||
|
small dictionary, so the tests do not depend on Electrum's own catalogs.
|
||||||
|
|
||||||
|
Run standalone (``python3 tests/test_i18n.py``) or with pytest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import array
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from bal import i18n # noqa: E402
|
||||||
|
|
||||||
|
IT_MO = "locale/it_IT/LC_MESSAGES/bal.mo"
|
||||||
|
|
||||||
|
|
||||||
|
def _mo_bytes(messages):
|
||||||
|
"""Return a GNU .mo catalog (UTF-8) holding ``messages`` {msgid: msgstr}.
|
||||||
|
|
||||||
|
Same layout as CPython's Tools/i18n/msgfmt.py: header, the sorted msgid
|
||||||
|
and msgstr index tables, then the NUL-terminated strings.
|
||||||
|
"""
|
||||||
|
entries = {"": "Content-Type: text/plain; charset=UTF-8\n"}
|
||||||
|
entries.update(messages)
|
||||||
|
keys = sorted(entries)
|
||||||
|
ids = strs = b""
|
||||||
|
offsets = []
|
||||||
|
for key in keys:
|
||||||
|
msgid, msgstr = key.encode("utf-8"), entries[key].encode("utf-8")
|
||||||
|
offsets.append((len(ids), len(msgid), len(strs), len(msgstr)))
|
||||||
|
ids += msgid + b"\0"
|
||||||
|
strs += msgstr + b"\0"
|
||||||
|
keystart = 7 * 4 + 16 * len(keys)
|
||||||
|
valuestart = keystart + len(ids)
|
||||||
|
koffsets, voffsets = [], []
|
||||||
|
for o1, l1, o2, l2 in offsets:
|
||||||
|
koffsets += [l1, o1 + keystart]
|
||||||
|
voffsets += [l2, o2 + valuestart]
|
||||||
|
header = struct.pack(
|
||||||
|
"Iiiiiii", 0x950412DE, 0, len(keys), 7 * 4, 7 * 4 + len(keys) * 8, 0, 0
|
||||||
|
)
|
||||||
|
return header + array.array("i", koffsets + voffsets).tobytes() + ids + strs
|
||||||
|
|
||||||
|
|
||||||
|
class FakePlugin:
|
||||||
|
"""Stands in for Electrum's BasePlugin: serves read_file() from a dict."""
|
||||||
|
|
||||||
|
def __init__(self, files=None):
|
||||||
|
self.files = files or {}
|
||||||
|
self.requested = []
|
||||||
|
|
||||||
|
def read_file(self, filename):
|
||||||
|
self.requested.append(filename)
|
||||||
|
if filename not in self.files:
|
||||||
|
raise KeyError(filename) # what a missing entry in the zip raises
|
||||||
|
return self.files[filename]
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _i18n_state(electrum=None, files=None, lang=None):
|
||||||
|
"""Fake Electrum's translations, load a fake BAL catalog, restore after.
|
||||||
|
|
||||||
|
Restoring matters: bal.i18n keeps module-level state, shared with every
|
||||||
|
other test of the same pytest run.
|
||||||
|
"""
|
||||||
|
saved = (i18n._electrum_gettext, i18n._catalog)
|
||||||
|
table = electrum or {}
|
||||||
|
i18n._electrum_gettext = lambda msg: table.get(msg, msg)
|
||||||
|
plugin = FakePlugin(files)
|
||||||
|
try:
|
||||||
|
i18n.set_language(plugin, lang)
|
||||||
|
yield plugin
|
||||||
|
finally:
|
||||||
|
i18n._electrum_gettext, i18n._catalog = saved
|
||||||
|
i18n._rejected.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_string_is_never_translated():
|
||||||
|
# For gettext "" is the key of the catalog header.
|
||||||
|
with _i18n_state(files={IT_MO: _mo_bytes({})}, lang="it_IT"):
|
||||||
|
assert i18n._catalog is not None
|
||||||
|
assert i18n._("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_electrum_translation_wins_over_bal_catalog():
|
||||||
|
catalog = _mo_bytes({"Cancel": "Cancella", "Heirs": "Eredi"})
|
||||||
|
with _i18n_state(
|
||||||
|
electrum={"Cancel": "Annulla"}, files={IT_MO: catalog}, lang="it_IT"
|
||||||
|
):
|
||||||
|
assert i18n._("Cancel") == "Annulla" # Electrum has it: Electrum wins
|
||||||
|
assert i18n._("Heirs") == "Eredi" # only BAL has it
|
||||||
|
|
||||||
|
|
||||||
|
def test_english_source_when_nobody_translates():
|
||||||
|
with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang="it_IT"):
|
||||||
|
assert i18n._("Not in any catalog") == "Not in any catalog"
|
||||||
|
|
||||||
|
|
||||||
|
def test_translation_with_different_fields_is_rejected():
|
||||||
|
catalog = _mo_bytes(
|
||||||
|
{
|
||||||
|
"{} heirs": "eredi", # field dropped: rejected
|
||||||
|
"Fee: {}": "Commissione: {}",
|
||||||
|
"{0} of {1}": "{1} di {0}", # re-ordered: allowed
|
||||||
|
"Hello {name}": "Ciao {nome}", # field renamed: rejected
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with _i18n_state(files={IT_MO: catalog}, lang="it_IT"):
|
||||||
|
assert i18n._("{} heirs") == "{} heirs"
|
||||||
|
assert i18n._("Fee: {}") == "Commissione: {}"
|
||||||
|
assert i18n._("{0} of {1}") == "{1} di {0}"
|
||||||
|
assert i18n._("Hello {name}") == "Hello {name}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_keeps_format_fields():
|
||||||
|
assert i18n.keeps_format_fields("Amount: {}", "Importo: {}")
|
||||||
|
assert not i18n.keeps_format_fields("Amount: {}", "Importo")
|
||||||
|
assert not i18n.keeps_format_fields("Amount: {}", "Importo: {} {}")
|
||||||
|
assert not i18n.keeps_format_fields("Amount: {}", "Importo: {") # malformed
|
||||||
|
assert i18n.keeps_format_fields("$wallet_name", "$wallet_name")
|
||||||
|
|
||||||
|
|
||||||
|
def test_english_loads_no_catalog():
|
||||||
|
with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang="en_UK") as p:
|
||||||
|
assert i18n._catalog is None
|
||||||
|
assert p.requested == []
|
||||||
|
assert i18n._("Heirs") == "Heirs"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_language_loads_no_catalog():
|
||||||
|
for lang in (None, ""):
|
||||||
|
with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang=lang):
|
||||||
|
assert i18n._catalog is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_catalog_falls_back_to_english():
|
||||||
|
with _i18n_state(files={}, lang="it_IT") as plugin:
|
||||||
|
assert i18n._catalog is None
|
||||||
|
assert plugin.requested == [IT_MO, "locale/it/LC_MESSAGES/bal.mo"]
|
||||||
|
assert i18n._("Heirs") == "Heirs"
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_catalog_falls_back_to_english():
|
||||||
|
with _i18n_state(files={IT_MO: b"this is not a catalog"}, lang="it_IT"):
|
||||||
|
assert i18n._catalog is None
|
||||||
|
assert i18n._("Heirs") == "Heirs"
|
||||||
|
|
||||||
|
|
||||||
|
def test_language_code_without_country_is_tried():
|
||||||
|
files = {"locale/it/LC_MESSAGES/bal.mo": _mo_bytes({"Heirs": "Eredi"})}
|
||||||
|
with _i18n_state(files=files, lang="it_IT"):
|
||||||
|
assert i18n._("Heirs") == "Eredi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_n_marks_without_translating():
|
||||||
|
with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang="it_IT"):
|
||||||
|
assert i18n.N_("Heirs") == "Heirs"
|
||||||
|
assert i18n._(i18n.N_("Heirs")) == "Eredi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_from_config_uses_electrum_language_setting():
|
||||||
|
config = SimpleNamespace(LOCALIZATION_LANGUAGE="it_IT")
|
||||||
|
plugin = FakePlugin({IT_MO: _mo_bytes({"Heirs": "Eredi"})})
|
||||||
|
with _i18n_state():
|
||||||
|
i18n.init_from_config(plugin, config)
|
||||||
|
assert i18n._("Heirs") == "Eredi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_from_config_without_setting_uses_system_language():
|
||||||
|
import electrum.gui.default_lang as default_lang
|
||||||
|
|
||||||
|
saved = default_lang.get_default_language
|
||||||
|
default_lang.get_default_language = lambda gui_name=None: "it_IT"
|
||||||
|
config = SimpleNamespace(LOCALIZATION_LANGUAGE="")
|
||||||
|
plugin = FakePlugin({IT_MO: _mo_bytes({"Heirs": "Eredi"})})
|
||||||
|
try:
|
||||||
|
with _i18n_state():
|
||||||
|
i18n.init_from_config(plugin, config)
|
||||||
|
assert i18n._("Heirs") == "Eredi"
|
||||||
|
finally:
|
||||||
|
default_lang.get_default_language = saved
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_from_config_never_raises():
|
||||||
|
plugin = FakePlugin({IT_MO: _mo_bytes({"Heirs": "Eredi"})})
|
||||||
|
with _i18n_state():
|
||||||
|
i18n.init_from_config(plugin, object()) # config without the setting
|
||||||
|
assert i18n._catalog is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Stored data stays language-neutral (PLAN_I18N.md, phase 2) -------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_history_is_stored_in_english():
|
||||||
|
from bal.core.will import WillItem
|
||||||
|
|
||||||
|
catalog = _mo_bytes({"Signed": "Firmato", "Valid": "Valida"})
|
||||||
|
with _i18n_state(files={IT_MO: catalog}, lang="it_IT"):
|
||||||
|
# A bare item: set_status() only needs its status fields.
|
||||||
|
item = WillItem.__new__(WillItem)
|
||||||
|
item.status = ""
|
||||||
|
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
|
item.set_status("COMPLETE", True)
|
||||||
|
item.set_status("VALID", False)
|
||||||
|
assert item.status == ".Signed.NOT Valid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_history_is_translated_for_display():
|
||||||
|
from bal.core.will import format_status_history
|
||||||
|
|
||||||
|
catalog = _mo_bytes(
|
||||||
|
{
|
||||||
|
"Signed": "Firmato",
|
||||||
|
"Valid": "Valida",
|
||||||
|
"NOT {}": "NON {}",
|
||||||
|
"New": "Nuovo",
|
||||||
|
"Replaced": "Sostituita",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with _i18n_state(files={IT_MO: catalog}, lang="it_IT"):
|
||||||
|
assert format_status_history(".Signed.NOT Valid") == ".Firmato.NON Valida"
|
||||||
|
# A real history saved by an older version (tests/samanta7), which
|
||||||
|
# stored some labels already translated: known English labels are
|
||||||
|
# translated, the other tokens are kept as they are.
|
||||||
|
assert (
|
||||||
|
format_status_history("New.Firmato.Pushed.Checked.Replaced")
|
||||||
|
== "Nuovo.Firmato.Pushed.Checked.Sostituita"
|
||||||
|
)
|
||||||
|
assert format_status_history(".SignedERROR!!!") == ".FirmatoERROR!!!"
|
||||||
|
with _i18n_state(): # English GUI: shown exactly as stored
|
||||||
|
assert format_status_history("New.Firmato.Pushed") == "New.Firmato.Pushed"
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeConfig(dict):
|
||||||
|
"""Minimal stand-in for Electrum's SimpleConfig (get / set_key)."""
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return dict.get(self, key, default)
|
||||||
|
|
||||||
|
def set_key(self, key, value, save=True):
|
||||||
|
self[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def test_translatable_config_default_follows_language():
|
||||||
|
from bal.core.plugin_base import BalConfig
|
||||||
|
|
||||||
|
config = _FakeConfig()
|
||||||
|
summary = BalConfig(config, "summary", "Will of $wallet_name", translatable=True)
|
||||||
|
plain = BalConfig(config, "label", "BAL label")
|
||||||
|
catalog = _mo_bytes(
|
||||||
|
{"Will of $wallet_name": "Testamento di $wallet_name", "BAL label": "X"}
|
||||||
|
)
|
||||||
|
with _i18n_state(files={IT_MO: catalog}, lang="it_IT"):
|
||||||
|
assert summary.get() == "Testamento di $wallet_name" # nothing stored
|
||||||
|
assert summary.localized_default() == "Testamento di $wallet_name"
|
||||||
|
summary.set("Testamento di $wallet_name") # e.g. the reset button
|
||||||
|
assert config["summary"] == "Will of $wallet_name" # stored in English
|
||||||
|
assert summary.get() == "Testamento di $wallet_name"
|
||||||
|
summary.set("My own text") # the user's text is kept as it is
|
||||||
|
assert summary.get() == "My own text"
|
||||||
|
assert plain.get() == "BAL label" # not translatable: never translated
|
||||||
|
with _i18n_state():
|
||||||
|
summary.set("Will of $wallet_name")
|
||||||
|
assert summary.get() == "Will of $wallet_name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calendar_reminder_suffix_is_translatable():
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from bal.core.reminders import build_ics_reminders
|
||||||
|
|
||||||
|
now = datetime(2026, 1, 1)
|
||||||
|
kwargs = dict(
|
||||||
|
locktime=now + timedelta(days=400), basic_mode=True, description="d",
|
||||||
|
summary="s", wallet_name="w", heirs_details="", version="0", now=now,
|
||||||
|
)
|
||||||
|
assert "s (reminder 1/" in build_ics_reminders(**kwargs) # CLI default
|
||||||
|
ics = build_ics_reminders(reminder_suffix="(promemoria {idx}/{total})", **kwargs)
|
||||||
|
assert "s (promemoria 1/" in ics
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_preset_labels_match_chunk_presets():
|
||||||
|
# The labels are marked for translation in common.py because
|
||||||
|
# qrtransfer.py must stay free of Electrum imports (Android copy).
|
||||||
|
from bal.core.qrtransfer import CHUNK_PRESETS
|
||||||
|
from bal.gui.qt.common import QR_PRESET_LABELS
|
||||||
|
|
||||||
|
assert tuple(label for label, _budget in CHUNK_PRESETS) == QR_PRESET_LABELS
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_translator_is_electrums():
|
||||||
|
# Outside the fakes above, BAL asks Electrum's real translator first.
|
||||||
|
from electrum.i18n import _ as electrum_gettext
|
||||||
|
|
||||||
|
assert i18n._electrum_gettext is electrum_gettext
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||||
|
for test in tests:
|
||||||
|
test()
|
||||||
|
print(f"[OK] All {len(tests)} i18n tests passed")
|
||||||
@@ -127,6 +127,11 @@ def test_sign_transactions_external_only():
|
|||||||
wallet=FakeWallet(),
|
wallet=FakeWallet(),
|
||||||
waiting_dialog=SimpleNamespace(update=lambda msg: None),
|
waiting_dialog=SimpleNamespace(update=lambda msg: None),
|
||||||
)
|
)
|
||||||
|
# sign_transactions dispatches to self._prepare_and_sign_tx; bind the real
|
||||||
|
# implementation onto the fake so the external-sign run actually executes.
|
||||||
|
fake._prepare_and_sign_tx = MethodType(
|
||||||
|
window_mod.BalWindow._prepare_and_sign_tx, fake
|
||||||
|
)
|
||||||
|
|
||||||
result = window_mod.BalWindow.sign_transactions(fake, None, will=imported)
|
result = window_mod.BalWindow.sign_transactions(fake, None, will=imported)
|
||||||
|
|
||||||
@@ -166,6 +171,50 @@ def test_will_widget_explicit_will():
|
|||||||
assert w2.will is live
|
assert w2.will is live
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# WillWidget shows heir/willexecutor addresses and decodes OP_RETURN
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_will_widget_shows_addresses_and_decodes_opreturn():
|
||||||
|
from PyQt6.QtWidgets import QLabel
|
||||||
|
|
||||||
|
from bal.core.will import WillItem
|
||||||
|
from bal.gui.qt.widgets import WillWidget
|
||||||
|
|
||||||
|
op_hex = "68656c6c6f" # "hello" in hex
|
||||||
|
heirs = {
|
||||||
|
"bob": ["bc1qtestaddr", 1_000_000, 1000, 500_000],
|
||||||
|
"msg": [f"OP_RETURN:{op_hex}", 0, 1000, 0],
|
||||||
|
}
|
||||||
|
wi = WillItem(
|
||||||
|
_make_willitem_dict(
|
||||||
|
heirs=heirs,
|
||||||
|
willexecutor={
|
||||||
|
"url": "https://exec.example",
|
||||||
|
"address": "bc1qexecaddr",
|
||||||
|
"base_fee": 200_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
wi._id = "w0"
|
||||||
|
fake_parent = SimpleNamespace(
|
||||||
|
decimal_point=8,
|
||||||
|
base_unit_name="BTC",
|
||||||
|
bal_window=SimpleNamespace(
|
||||||
|
willitems={},
|
||||||
|
bal_plugin=SimpleNamespace(
|
||||||
|
_hide_replaced=False, _hide_invalidated=False
|
||||||
|
),
|
||||||
|
show_transaction=lambda *a, **k: None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
w = WillWidget(parent=fake_parent, will={"w0": wi})
|
||||||
|
texts = [lbl.text() for lbl in w.findChildren(QLabel)]
|
||||||
|
assert any("bc1qtestaddr" in t for t in texts), texts
|
||||||
|
assert any("OP_RETURN: hello" in t for t in texts), texts
|
||||||
|
assert any("bc1qexecaddr" in t for t in texts), texts
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# WillDetailDialog external-will mode
|
# WillDetailDialog external-will mode
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ Run::
|
|||||||
python3 -m pytest tests/test_no_willexecutor_karen7.py -v -s
|
python3 -m pytest tests/test_no_willexecutor_karen7.py -v -s
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -49,6 +48,7 @@ from electrum.transaction import PartialTxInput, TxOutpoint
|
|||||||
from electrum.util import bfh
|
from electrum.util import bfh
|
||||||
|
|
||||||
from bal.core.heirs import Heirs
|
from bal.core.heirs import Heirs
|
||||||
|
from bal.core.util import copy_structure
|
||||||
from bal.core.will import (
|
from bal.core.will import (
|
||||||
NotCompleteWillException,
|
NotCompleteWillException,
|
||||||
NoWillExecutorNotPresent,
|
NoWillExecutorNotPresent,
|
||||||
@@ -278,11 +278,11 @@ class FakeBalWindow:
|
|||||||
tx["my_locktime"] = txs[txid].my_locktime
|
tx["my_locktime"] = txs[txid].my_locktime
|
||||||
tx["heirsvalue"] = txs[txid].heirsvalue
|
tx["heirsvalue"] = txs[txid].heirsvalue
|
||||||
tx["description"] = txs[txid].description
|
tx["description"] = txs[txid].description
|
||||||
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
|
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
|
||||||
tx["status"] = "New"
|
tx["status"] = "New"
|
||||||
tx["baltx_fees"] = txs[txid].tx_fees
|
tx["baltx_fees"] = txs[txid].tx_fees
|
||||||
tx["time"] = creation_time
|
tx["time"] = creation_time
|
||||||
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
|
tx["heirs"] = copy_structure(txs[txid].heirs)
|
||||||
tx["txchildren"] = []
|
tx["txchildren"] = []
|
||||||
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
||||||
self.update_will(will)
|
self.update_will(will)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ but without requiring a full Qt event loop.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import copy
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -24,6 +23,7 @@ if os.path.isdir(ELECTRUM_DIR):
|
|||||||
|
|
||||||
from bal.core.heirs import Heirs
|
from bal.core.heirs import Heirs
|
||||||
from bal.core.plugin_base import BalPlugin, BalTimestamp
|
from bal.core.plugin_base import BalPlugin, BalTimestamp
|
||||||
|
from bal.core.util import copy_structure
|
||||||
from bal.core.will import (
|
from bal.core.will import (
|
||||||
NoHeirsException,
|
NoHeirsException,
|
||||||
NotCompleteWillException,
|
NotCompleteWillException,
|
||||||
@@ -145,11 +145,11 @@ class FakeBalWindow:
|
|||||||
tx["my_locktime"] = txs[txid].my_locktime
|
tx["my_locktime"] = txs[txid].my_locktime
|
||||||
tx["heirsvalue"] = txs[txid].heirsvalue
|
tx["heirsvalue"] = txs[txid].heirsvalue
|
||||||
tx["description"] = txs[txid].description
|
tx["description"] = txs[txid].description
|
||||||
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
|
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
|
||||||
tx["status"] = "New"
|
tx["status"] = "New"
|
||||||
tx["baltx_fees"] = txs[txid].tx_fees
|
tx["baltx_fees"] = txs[txid].tx_fees
|
||||||
tx["time"] = creation_time
|
tx["time"] = creation_time
|
||||||
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
|
tx["heirs"] = copy_structure(txs[txid].heirs)
|
||||||
tx["txchildren"] = []
|
tx["txchildren"] = []
|
||||||
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
||||||
Will.update_will(self.willitems, will)
|
Will.update_will(self.willitems, will)
|
||||||
|
|||||||
@@ -2,13 +2,17 @@
|
|||||||
Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs``.
|
Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs``.
|
||||||
|
|
||||||
This is the post-build sync that keeps the plugin's stored delivery date
|
This is the post-build sync that keeps the plugin's stored delivery date
|
||||||
(WILL_SETTINGS["locktime"]) and check-alive threshold in lockstep with the
|
(WILL_SETTINGS["locktime"]) in lockstep with the BUILT transactions' fixed
|
||||||
BUILT transactions' fixed locktime. The bug it fixes (reported by the owner):
|
locktime when the core AUTOMATICALLY anticipates it (one day earlier than
|
||||||
|
stored).
|
||||||
|
|
||||||
ADVANCED mode + RELATIVE locktime ("90d") / threshold ("30d") -> the plugin
|
RELATIVE recipes ("90d" / "1y") are now PRESERVED: the daily-drift problem
|
||||||
asks to invalidate the will EVERY DAY. The relative value is re-parsed
|
that once forced freezing them to absolute timestamps is solved at the root by
|
||||||
against "now" on every check, so it drifts one day per day away from the
|
anchoring every relative recipe against the built transactions
|
||||||
fixed tx locktime and the postpone check always sees a "postpone".
|
(``Util.resolve_locktime_against_tx`` for the postpone detection,
|
||||||
|
``resolve_date_to_check(..., built_locktime=...)`` for the reference
|
||||||
|
timestamp). Only a genuine automatic anticipation on an ABSOLUTE stored date
|
||||||
|
moves the stored value.
|
||||||
|
|
||||||
The method is exercised with a lightweight fake ``self`` (no Qt event loop, no
|
The method is exercised with a lightweight fake ``self`` (no Qt event loop, no
|
||||||
Electrum wallet) by calling it as an unbound method.
|
Electrum wallet) by calling it as an unbound method.
|
||||||
@@ -24,7 +28,6 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.plugin_base import BalTimestamp # noqa: E402 (path insert above)
|
|
||||||
from bal.gui.qt.dialogs import BalBuildWillDialog # noqa: E402 (path insert above)
|
from bal.gui.qt.dialogs import BalBuildWillDialog # noqa: E402 (path insert above)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -68,34 +71,30 @@ def _call_sync(will_settings, tx_locktimes, recorded):
|
|||||||
# Tests
|
# Tests
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_relative_locktime_normalized_to_absolute():
|
def test_relative_locktime_preserved():
|
||||||
"""The reported bug: a relative stored locktime is frozen to the absolute
|
"""A RELATIVE stored locktime ("90d"/"1y") is PRESERVED after a rebuild:
|
||||||
value of the built transaction, even when it parses to the same moment."""
|
it is anchored against the built transactions on every check, so it must
|
||||||
|
not be frozen to an absolute timestamp in WILL_SETTINGS."""
|
||||||
tx_locktime = 1_800_000_000
|
tx_locktime = 1_800_000_000
|
||||||
recorded = []
|
recorded = []
|
||||||
fake = _call_sync(
|
fake = _call_sync(
|
||||||
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
||||||
)
|
)
|
||||||
assert fake.bal_window.will_settings["locktime"] == tx_locktime
|
assert fake.bal_window.will_settings["locktime"] == "90d"
|
||||||
assert fake.bal_window.will_settings["locktime"] != "90d"
|
assert fake.bal_window.will_settings["threshold"] == "30d"
|
||||||
# A pure relative->absolute normalisation is NOT an anticipation: the sign
|
assert recorded == [], "a relative recipe must never be rewritten"
|
||||||
# prompt must not claim the date was anticipated.
|
|
||||||
assert fake._date_was_anticipated is False
|
assert fake._date_was_anticipated is False
|
||||||
|
|
||||||
|
|
||||||
def test_relative_threshold_frozen_to_absolute():
|
def test_relative_threshold_preserved():
|
||||||
"""A relative threshold ("N days BEFORE the delivery") is normalised to the
|
"""Same for the relative "Check Alive" threshold: it stays relative."""
|
||||||
same absolute value the settings widget computes (real_threshold)."""
|
|
||||||
tx_locktime = 1_800_000_000
|
tx_locktime = 1_800_000_000
|
||||||
recorded = []
|
recorded = []
|
||||||
fake = _call_sync(
|
fake = _call_sync(
|
||||||
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
||||||
)
|
)
|
||||||
expected = int(
|
assert fake.bal_window.will_settings["threshold"] == "30d"
|
||||||
BalTimestamp("30d").to_date(tx_locktime, reverse=True).timestamp()
|
assert recorded == []
|
||||||
)
|
|
||||||
assert fake.bal_window.will_settings["threshold"] == expected
|
|
||||||
assert ("threshold", expected, True) in recorded
|
|
||||||
|
|
||||||
|
|
||||||
def test_absolute_locktime_unchanged_on_equal():
|
def test_absolute_locktime_unchanged_on_equal():
|
||||||
@@ -113,8 +112,8 @@ def test_absolute_locktime_unchanged_on_equal():
|
|||||||
|
|
||||||
|
|
||||||
def test_anticipation_sets_flag_and_moves_earlier():
|
def test_anticipation_sets_flag_and_moves_earlier():
|
||||||
"""A real anticipation (built locktime earlier than the stored absolute
|
"""A real automatic anticipation of an ABSOLUTE stored date (built earlier
|
||||||
one) still moves the date earlier and flags the sign prompt."""
|
than stored) still moves the date earlier and flags the sign prompt."""
|
||||||
tx_locktime = 1_700_000_000
|
tx_locktime = 1_700_000_000
|
||||||
recorded = []
|
recorded = []
|
||||||
fake = _call_sync(
|
fake = _call_sync(
|
||||||
@@ -129,7 +128,7 @@ def test_anticipation_sets_flag_and_moves_earlier():
|
|||||||
def test_stored_earlier_than_built_never_moved_later():
|
def test_stored_earlier_than_built_never_moved_later():
|
||||||
"""A stored absolute date that is already EARLIER than the built txs (the
|
"""A stored absolute date that is already EARLIER than the built txs (the
|
||||||
user moved the delivery later) is never pulled back up on rebuild: only
|
user moved the delivery later) is never pulled back up on rebuild: only
|
||||||
anticipation (built < stored) and relative normalisation move the value."""
|
anticipation (built < stored) moves the value."""
|
||||||
stored = 1_800_000_000
|
stored = 1_800_000_000
|
||||||
recorded = []
|
recorded = []
|
||||||
fake = _call_sync(
|
fake = _call_sync(
|
||||||
@@ -142,39 +141,39 @@ def test_stored_earlier_than_built_never_moved_later():
|
|||||||
|
|
||||||
|
|
||||||
def test_multiple_txs_uses_minimum_locktime():
|
def test_multiple_txs_uses_minimum_locktime():
|
||||||
"""When several transactions carry different locktimes, the minimum is used
|
"""When several ABSOLUTE transactions carry different locktimes, the minimum
|
||||||
(owner-confirmed behaviour for the delivery date shown in the UI)."""
|
is used for a genuine automatic anticipation (owner-confirmed behaviour for
|
||||||
|
the delivery date shown in the UI)."""
|
||||||
min_locktime = 1_750_000_000
|
min_locktime = 1_750_000_000
|
||||||
recorded = []
|
recorded = []
|
||||||
fake = _call_sync(
|
fake = _call_sync(
|
||||||
{"locktime": "90d", "threshold": "30d"},
|
{"locktime": 1_800_000_000, "threshold": 1_600_000_000},
|
||||||
[min_locktime, min_locktime + 86_400],
|
[min_locktime, min_locktime + 86_400],
|
||||||
recorded,
|
recorded,
|
||||||
)
|
)
|
||||||
assert fake.bal_window.will_settings["locktime"] == min_locktime
|
assert fake.bal_window.will_settings["locktime"] == min_locktime
|
||||||
|
|
||||||
|
|
||||||
def test_relative_locktime_stops_daily_postpone():
|
def test_relative_locktime_stays_coherent_via_anchor():
|
||||||
"""End-to-end guard for the reported bug: after the sync, re-parsing the
|
"""Daily-drift guard: an UNCHANGED relative recipe is resolved against the
|
||||||
stored (now absolute) locktime on later days always equals the built
|
tx build moment (``Util.resolve_locktime_against_tx``), so even WITHOUT
|
||||||
tx locktime, so the postpone check never fires again."""
|
being frozen to an absolute value it still reads as COHERENT (== tx
|
||||||
from datetime import datetime, timedelta
|
locktime) on later days - the postpone check never fires again."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from bal.core.util import Util
|
from bal.core.util import Util
|
||||||
|
|
||||||
tx_locktime = 1_800_000_000
|
# resolve_locktime_against_tx normalises to UTC midnight before anchoring,
|
||||||
recorded = []
|
# so use a midnight-UTC frozen tx locktime (the timestamp the engine itself
|
||||||
fake = _call_sync(
|
# stores after building).
|
||||||
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
tx_locktime = int(datetime(2027, 1, 15, tzinfo=timezone.utc).timestamp())
|
||||||
)
|
built = "90d" # recipe frozen at build time
|
||||||
stored = fake.bal_window.will_settings["locktime"]
|
current = "90d" # unchanged recipe today
|
||||||
for _day in range(0, 7):
|
for _day in range(0, 7):
|
||||||
# Simulate the check on later days: parse the STORED value (which is
|
resolved = Util.resolve_locktime_against_tx(current, built, tx_locktime)
|
||||||
# now the absolute tx locktime) and compare with the fixed tx locktime.
|
assert resolved == tx_locktime # no POSTPONE / drift
|
||||||
new_locktime = Util.parse_locktime_string(stored)
|
# Sanity: a naive forward-from-now re-parse would have drifted past it
|
||||||
assert new_locktime == tx_locktime
|
# (the bug the anchor fixes).
|
||||||
assert new_locktime <= tx_locktime # no POSTPONE / drift
|
|
||||||
# Sanity: a RELATIVE value would have drifted past it (the bug).
|
|
||||||
drifted = int(
|
drifted = int(
|
||||||
(
|
(
|
||||||
datetime.fromtimestamp(tx_locktime) + timedelta(days=1)
|
datetime.fromtimestamp(tx_locktime) + timedelta(days=1)
|
||||||
|
|||||||
135
tests/test_translations.py
Normal file
135
tests/test_translations.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"""Safety checks on BAL's translation catalogs (PLAN_I18N.md, section 4.4).
|
||||||
|
|
||||||
|
Runs on every ``bal/locale/<lang>/LC_MESSAGES/bal.po`` (the sources that
|
||||||
|
build_zip.py compiles into the zip). A translation is shown to the user as
|
||||||
|
if BAL wrote it, so a wrong or malicious one must fail here, before a zip is
|
||||||
|
built:
|
||||||
|
|
||||||
|
* the catalog must parse and compile;
|
||||||
|
* the ``{}`` replacement fields must match the English text (the same rule
|
||||||
|
bal.i18n applies at run time), and so must the ``$tokens`` of the calendar
|
||||||
|
texts (``$wallet_name``, ``$heirs_complete``), which Electrum does not check;
|
||||||
|
* no Bitcoin address, e-mail address, URL or long letters-and-digits word may
|
||||||
|
appear in a translation unless the English text has the same one: the
|
||||||
|
patterns are copied from Electrum's ``electrum-locale/update.py`` (MIT
|
||||||
|
licence, Copyright (C) The Electrum developers), which rejects translations
|
||||||
|
that try to slip in an address or a link.
|
||||||
|
|
||||||
|
Run standalone (``python3 tests/test_translations.py``, prints a per-language
|
||||||
|
summary) or with pytest. Needs Babel, like build_zip.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from babel.messages.mofile import write_mo # noqa: E402
|
||||||
|
from babel.messages.pofile import read_po # noqa: E402
|
||||||
|
|
||||||
|
from bal.i18n import keeps_format_fields # noqa: E402
|
||||||
|
|
||||||
|
LOCALE_DIR = Path(__file__).resolve().parent.parent / "bal" / "locale"
|
||||||
|
|
||||||
|
# From electrum-locale/update.py (see the module docstring).
|
||||||
|
SUSPICIOUS = {
|
||||||
|
"Bitcoin address": re.compile("([13]|bc1)[a-zA-Z0-9]{30,}"),
|
||||||
|
"e-mail address": re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"),
|
||||||
|
"URL": re.compile(r"\S+\.\S*\w+\S*/\S+"),
|
||||||
|
"URL scheme": re.compile(r"http(s){0,1}://"),
|
||||||
|
"letters-and-digits word": re.compile(
|
||||||
|
r"(?a)(?=\w{16,})((\w*[a-zA-Z]\w*[0-9]\w*)|(\w*[0-9]\w*[a-zA-Z]\w*))"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
TOKEN = re.compile(r"\$\w+")
|
||||||
|
|
||||||
|
|
||||||
|
def _catalogs():
|
||||||
|
return sorted(LOCALE_DIR.glob("*/LC_MESSAGES/bal.po"))
|
||||||
|
|
||||||
|
|
||||||
|
def _messages(po_path):
|
||||||
|
"""Yield the translated, non-fuzzy (msgid, msgstr) pairs of a catalog."""
|
||||||
|
with open(po_path, "rb") as f:
|
||||||
|
catalog = read_po(f)
|
||||||
|
for m in catalog:
|
||||||
|
if m.id and isinstance(m.id, str) and m.string and not m.fuzzy:
|
||||||
|
yield m.id, m.string
|
||||||
|
|
||||||
|
|
||||||
|
def check_catalog(po_path):
|
||||||
|
"""Return a list of problems found in one catalog (empty = fine)."""
|
||||||
|
problems = []
|
||||||
|
for msgid, msgstr in _messages(po_path):
|
||||||
|
if not keeps_format_fields(msgid, msgstr):
|
||||||
|
problems.append("{} fields differ: {!r}".format("{}", msgid))
|
||||||
|
if sorted(TOKEN.findall(msgid)) != sorted(TOKEN.findall(msgstr)):
|
||||||
|
problems.append("$tokens differ: {!r}".format(msgid))
|
||||||
|
for name, regex in SUSPICIOUS.items():
|
||||||
|
for match in regex.finditer(msgstr):
|
||||||
|
if match.group(0) not in msgid:
|
||||||
|
problems.append(
|
||||||
|
"{} {!r} not in the English text: {!r}".format(
|
||||||
|
name, match.group(0), msgid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def summary(po_path):
|
||||||
|
"""Return (translated, untranslated, fuzzy) counts of a catalog."""
|
||||||
|
with open(po_path, "rb") as f:
|
||||||
|
catalog = read_po(f)
|
||||||
|
msgs = [m for m in catalog if m.id]
|
||||||
|
fuzzy = sum(1 for m in msgs if m.fuzzy)
|
||||||
|
translated = sum(1 for m in msgs if m.string and not m.fuzzy)
|
||||||
|
return translated, len(msgs) - translated - fuzzy, fuzzy
|
||||||
|
|
||||||
|
|
||||||
|
def test_there_is_an_italian_catalog():
|
||||||
|
assert LOCALE_DIR / "it_IT" / "LC_MESSAGES" / "bal.po" in _catalogs()
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalogs_compile():
|
||||||
|
for po in _catalogs():
|
||||||
|
with open(po, "rb") as f:
|
||||||
|
catalog = read_po(f)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
write_mo(buf, catalog)
|
||||||
|
assert buf.getvalue(), po
|
||||||
|
|
||||||
|
|
||||||
|
def test_translations_are_safe():
|
||||||
|
problems = [
|
||||||
|
"{}: {}".format(po.parent.parent.name, p)
|
||||||
|
for po in _catalogs()
|
||||||
|
for p in check_catalog(po)
|
||||||
|
]
|
||||||
|
assert not problems, "\n".join(problems)
|
||||||
|
|
||||||
|
|
||||||
|
def test_suspicious_patterns_are_detected():
|
||||||
|
# A translation that adds an address or a link must be caught.
|
||||||
|
regexes = SUSPICIOUS.values()
|
||||||
|
assert any(r.search("invia a bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq") for r in regexes)
|
||||||
|
assert any(r.search("scarica da https://example.com") for r in regexes)
|
||||||
|
assert any(r.search("scrivi a truffa@example.com") for r in regexes)
|
||||||
|
assert not any(r.search("Firma il testamento") for r in regexes)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_there_is_an_italian_catalog()
|
||||||
|
test_catalogs_compile()
|
||||||
|
test_suspicious_patterns_are_detected()
|
||||||
|
for po in _catalogs():
|
||||||
|
translated, untranslated, fuzzy = summary(po)
|
||||||
|
print(
|
||||||
|
"{}: {} translated, {} untranslated, {} fuzzy".format(
|
||||||
|
po.parent.parent.name, translated, untranslated, fuzzy
|
||||||
|
)
|
||||||
|
)
|
||||||
|
test_translations_are_safe()
|
||||||
|
print("[OK] translation catalogs are safe")
|
||||||
Reference in New Issue
Block a user