5 Commits

Author SHA1 Message Date
deeec042d6 docs: QR/audio will transfer notes 2026-09-09 09:13:57 -04:00
085d39a2a5 core+gui+cli: animated QR will transfer (balqr/UR1/UR2/BBQR codecs, audio channel, export/import wizard) 2026-09-09 08:43:32 -04:00
539e32e837 Merge remote-tracking branch 'origin/main' (resolve CHANGELOG conflict, keep both entries) 2026-09-09 08:35:26 -04:00
1da724b591 chore: stop tracking tests/karen7 (generated wallet fixture); add to .gitignore 2026-08-28 16:28:59 -04:00
8647eee586 core+gui: chunked multi-QR will transfer (export/import + review/sign wizard, audio channel)
BALQR-framed chunk scheduler (bal/core/qrtransfer.py) with zlib compression,
four chunk presets and a QR_CHUNK_SIZE setting (row 16).
Export/import dialogs (WillQrExportDialog/WillQrImportDialog), per-tx
review-and-sign wizard, export filters (All/Valid/Valid-NC) and an Auto
slideshow with speed + loop for the QR codes; optional audio_modem channel
with a local receive mirror. _prepare_and_sign_tx refactor (no behaviour
change); WillItem.status defaults to '' instead of None (import crash fix);
invalidate_will guard for a missing date_to_check. Docs: PLAN_QR_TRANSFER,
QML_PLAN, README, HANDOFF, CHANGELOG entry 56, AUDIO_MODEM_DEBIAN.
2026-08-28 16:28:37 -04:00
58 changed files with 2892 additions and 9972 deletions

6
.gitignore vendored
View File

@@ -34,9 +34,3 @@ tmp*
# Release artifacts # Release artifacts
bal_v*.zip.* bal_v*.zip.*
tests/karen7 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/

View File

@@ -1,231 +0,0 @@
# PLAN_ANDROID_READER.md — BAL Reader: an Android QR will reader
**Date:** 2026-09-09 · **Status:** proposed · **Ties into:** BAL plugin QR export (BALQR / BC-UR v1 / BC-UR v2 / BBQR)
## 1. Goal
Ship a minimal single-activity Android app that reads any QR will exported by the BAL
Electrum plugin, decodes all four supported wire formats, and lets the user view, copy,
share, or save the recovered will data. The app is a **reader only** — it never signs or
broadcasts.
## 2. Scope
**In scope**
- Continuous camera capture (CameraX + ML Kit on-device barcode scanning, QR-only).
- Auto-detection of the four formats (`detect_format`: `"balqr" / "ur1" / "ur2" / "bbqr"`).
- Order-independent frame assembly: duplicates ignored, out-of-order accepted, UR v2
XOR-fountain redundancy exploited, session-level reset on transfer switch.
- Whole-will JSON **and** tx-hex-list payloads, both decoded to a readable view.
- Copy raw transfer text / Share / Save via Storage Access Framework.
- Verified decode chain runnable on the dev machine (no Android needed).
**Out of scope**
- Signing, broadcasting, password handling, wallet integration.
- Export/authoring QR codes *from* the phone.
- Audio-modem transport (unchanged, plugin-only).
## 3. Architecture
### 3.1 Why Chaquopy (reuse the tested Python codecs)
`bal/core/animated_qr.py` (1178 lines) and `bal/core/qrtransfer.py` (212 lines) are
**pure stdlib** (`base64`, `hashlib`, `zlib`), GUI-free, Python-3.8-clean (verified: no
walrus/match/`X|Y`/PEP-585 generics), and `bal/core/__init__.py` is an empty docstring.
Chaquopy bundles CPython into the APK, so **the exact, battle-tested codec modules run
unchanged on Android** with zero port risk. The Kotlin side is only camera glue + UI.
### 3.2 Decode chain (mirror of the plugin's import tail)
The plugin's `_review_and_sign` (dialogs.py:3792) does exactly:
1. `session.resolve()` -> `(transfer_text, compressed: bool)`
(UR v1/v2/BBQR for the first pair; `(text, flag)` for BAL QR).
2. `decode_transfer(transfer_text, compressed)` -> list of parts (tx hexes, or the single
whole-will JSON).
3. `"\n".join(parts)` -> opaque payload.
4. `decode_will_payload(payload)` -> `("will", dict)` **or** `("txs", [strings])`.
The app reuses all of it verbatim through the `AnimatedQrSession` facade (`add_part`
auto-detects per frame, dedups, tracks `received/total/done`).
### 3.3 Data flow
```
CameraX ImageAnalysis -> ML Kit BarcodeScanning (QR) -> raw string
-> BalDecoder.add(text) [Chaquopy -> AnimatedQrSession.add_part]
-> status: format chip + received/total, duplicate-tolerant
-> when done: auto-stop -> BalDecoder.finish() (4-step decode)
-> ResultActivity: JSON view OR tx-list view + Copy/Share/Save
```
## 4. Repository layout (new `android/` subfolder)
```
android/
README.md # build (Android Studio), usage, codec-sync rule, MIT note
settings.gradle.kts
build.gradle.kts # root: plugins (AGP 8.10, Kotlin 2.0.21, Chaquopy 17, apply false)
gradle.properties
gradlew, gradlew.bat
gradle/wrapper/ # gradle-wrapper.properties + gradle-wrapper.jar (fetched; see §7)
scripts/
sync_codecs.py # copy bal/core/{__init__,animated_qr,qrtransfer}.py -> app python dir; import-check
test_chain/
verify_chain.py # full decode-chain simulation, runs on dev machine
app/
build.gradle.kts # com.android.application + com.chaquo.python; CameraX/ML Kit deps
src/main/
AndroidManifest.xml # CAMERA permission
java/life/after/bitcoin/
MainActivity.kt # permission + PreviewView + ImageAnalysis + status header
BalDecoder.kt # Chaquopy bridge (§5.1)
ResultActivity.kt # viewer + copy/share/save (§5.3)
res/layout/activity_main.xml, activity_result.xml
res/values/strings.xml
python/bal/core/ # SYNCED COPIES (committed, deterministic; regenerate with script)
__init__.py
animated_qr.py
qrtransfer.py
```
## 5. Component specifications
### 5.1 `BalDecoder` (Kotlin, Chaquopy bridge)
- Lazy init: `Python.start(AndroidPlatform(context))`, `getModule("bal.core.animated_qr")`.
- `fun add(text: String): String` -> `session.add_part(text)`; surfaces `"ok"`/`"dup"`;
maps `AnimatedQrError` subclasses to a result the UI can ignore (garbage frames) vs
reset (transfer switch -> `TransferConflictError` -> tell user to rescan).
- `val format: String?` (`"balqr"/"ur1"/"ur2"/"bbqr"`), `val received: Int`,
`val total: Int`, `val done: Boolean` (auto-converted by Chaquopy).
- `fun finish(): DecodedResult` - the 4-step chain; returns
`data class DecodedResult(kind: "will"|"txs", data: Map<String,Any>|List<String>, rawTransfer: String)`.
- `fun reset()` -> new `AnimatedQrSession` (new scan).
### 5.2 `MainActivity` (camera + scan loop)
- Launches CameraX via `ProcessCameraProvider`; `PreviewView` fills screen; camera
permission via `ActivityResultContracts.RequestPermission`.
- `ImageAnalysis` `STRATEGY_KEEP_ONLY_LATEST`; analyzer throttled (~100 ms) calls ML Kit
`BarcodeScanning` with `Barcode.FORMAT_QR_CODE`.
- Thread-safe feed to `BalDecoder` (analyzer runs on a background executor); UI status
via `runOnUiThread`.
- Header row: format chip + `received/total`; on `done` -> stop analyzer -> launch
`ResultActivity` (results as Parcelable); "New scan" restarts.
- Garbage / incomplete frames silently ignored (same policy as the plugin); a
`TransferConflictError` mid-scan resets the session and signals the user to rescan.
### 5.3 `ResultActivity` (viewer)
- `kind == "will"`: whole-will JSON - each item's `tx` hex shown truncated with full view
on demand.
- `kind == "txs"`: list of tx hexes.
- Action bar: **Copy** (raw transfer text to clipboard), **Share** (ACTION_SEND
text/plain), **Save** (SAF `ACTION_CREATE_DOCUMENT` -> `will.json` / `will_tx.txt`).
- "Scan another" button -> finish -> back to camera.
### 5.4 `scripts/sync_codecs.py`
- Copies the 3 files from `bal/core/` -> `app/src/main/python/bal/core/` (idempotent).
- Post-copy check (dev machine): import `bal.core.animated_qr`, run one UR v2 frame +
decode cycle to prove the copy imports standalone.
- Documented as the rule after any codec change (README).
### 5.5 `test_chain/verify_chain.py`
Simulates the exact APK runtime path on the dev machine (no Android). Generates frames
precisely as the export page does, then feeds `AnimatedQrSession.add_part` in
scrambled/duplicated/dropped order and asserts correct results for:
- BAL QR multi-frame, plain **and** compressed (`Z` flag): `split_frames(encode_transfer(...))`.
- UR v1 single-part (`ur1_frames` headerless) and `1ofN` multipart.
- UR v2 single-part and fountain multipart with >=1 frame dropped and >=1 duplicated
(exercises the XOR solve).
- BBQR `Z`/`H`/`2`: `bbqr_frames(..., encoding=...)`, with the `Z` auto-decompress branch.
- payload kinds: whole-will JSON **and** tx-hex list.
- transfer-switch: feed a frame of a different transfer mid-session -> expect conflict, as
the UI will.
Runs under the runtime venv:
`source .../electrum/env/bin/activate && QT_QPA_PLATFORM=offscreen python3 android/test_chain/verify_chain.py`
## 6. Wire-format reference (bundled codecs)
- **BAL QR**: `BALQR1|<total>|<index>|<flags>|<payload>`, flags `""` or `Z` (zlib+base64).
Concatenate payloads 1..total -> transfer string -> `decode_transfer` splits on `\n`.
- **BC-UR v1**: multipart `ur:bytes/<seq>of<seq_len>/<sha256-bc32-digest>/<bc32-frag>`;
single-part `ur:bytes/<bc32>` (digest-less). BC32 = bech32_bis (XOR `0x3FFFFFFF`)
5-bit alphabet.
- **BC-UR v2**: multipart `ur:bytes/<seq>-of-<seq_len>/<bytewords-minimal-part>` +
single-part headerless; part body = CBOR `[seq, seq_len, msg_len, crc32, data]` +
per-part CRC-32, bytewords-minimal; fountain via `choose_fragments` (xoshiro256** +
alias/threshold), mixed by XOR - decoder solves from any sufficient subset.
- **BBQR**: `B$<encoding><type><base36 total><base36 index><payload>`; encodings `H`
(upper hex), `2` (base32nal), `Z` (deflate `wbits=-10` -> base32).
## 7. Toolchain & versions
| Item | Version | Notes |
|---|---|---|
| Chaquopy | 17.0.0 | Python 3.10-3.14, AGP 7.3-9.2, minSdk 24 |
| AGP | 8.10.0 | in Chaquopy 17 range |
| Gradle wrapper | 8.14 | required by AGP 8.10 |
| Kotlin | 2.0.21 | |
| compile/target SDK | 35 / min 24 | |
| JDK | 17 (machine has OpenJDK 17) | |
| CameraX | 1.3.4 | `camera-core`, `camera-camera2`, `camera-lifecycle`, `camera-view` |
| ML Kit barcode | 17.3.0 | on-device, no API key |
| Python (codec) | 3.12 (Chaquopy) | codecs verified 3.8-clean |
Dev machine: Android Studio + JDK 17 present; SDK platforms/build-tools/gradle absent ->
the **first real APK build happens in Android Studio with network**. `gradle-wrapper.jar`
is fetched via `curl` (canonical location) so `./gradlew` works; if network is blocked,
README documents Android Studio regenerating it.
## 8. Build & run outline (for README)
1. Open `android/` in Android Studio (or `./gradlew assembleDebug`).
2. Allow Gradle to fetch wrapper/deps (network).
3. Install on device; grant camera permission.
4. In the plugin: export dialog -> pick format (start with BAL QR default; also test
UR v1/UR v2/BBQR) -> show the animated QR on screen.
5. Point camera at screen; watch `received/total`; decoded result appears ->
Copy/Share/Save.
## 9. Verification
**On this machine (no Android needed)**
1. `python3 android/scripts/sync_codecs.py` -> copy + import/roundtrip sanity.
2. `QT_QPA_PLATFORM=offscreen python3 android/test_chain/verify_chain.py` -> all
formats/payload kinds/conflict cases pass.
3. `ruff check android/scripts/sync_codecs.py android/test_chain/verify_chain.py` -> clean.
4. `pytest tests/test_core_*.py tests/test_gui_*.py` -> still **445 passed**
(payload code untouched; only new files).
5. `python3 build_zip.py` unaffected (no change under `bal/`).
**On-device (manual, user)**
- Walk the 4 formats against the plugin's export page, single- and multi-frame
(animated), on a real phone.
- Confirm format chip, progress, auto-finish, Copy/Share/Save.
**Boundary** - no APK is produced by this machine's environment; the artifact is a
complete, independently buildable source tree + verified codec path.
## 10. Risks & notes
- First Gradle sync needs network (deps + wrapper). Pinned versions are conservative;
knobs documented.
- Bundled codec copies must be regenerated after any `bal/core/animated_qr.py` /
`qrtransfer.py` change - handled by `sync_codecs.py` + README note (copies are
committed for deterministic builds).
- Animated QR reading depends on the camera catching enough distinct frames (ML Kit
analyzer keeps scanning; the session dedups and accepts out-of-order). Slow phone
screens / glare may raise time-to-complete - expected, same as the plugin.
- App name/package (`life.after.bitcoin`, label "BAL Reader") are placeholders - trivial
to change.
- MIT: bundled codec files inherit the plugin's MIT license (noted in README).

View File

@@ -3,9 +3,6 @@
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.
@@ -83,32 +80,6 @@ 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
@@ -116,11 +87,6 @@ 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.

View File

@@ -3265,350 +3265,3 @@ as the default.
`external_zip_test.py` all pass. `external_zip_test.py` all pass.
**Outcome:** DONE (uncommitted). **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).

View File

@@ -25,15 +25,22 @@ and 4.8.0).
## QR wire-format compatibility ## QR wire-format compatibility
BAL exports/imports wills as QR codes using **BBQR**: BAL exports/imports wills as QR codes. **BAL QR** (the default) is the plugin's
own frame format and is only understood by BAL itself. The export page also
supports **BC-UR v1**, **BC-UR v2** and **BBQR**:
| Format | Wire appearance | Interop target | | Format | Wire appearance | Interop target |
|-----------|----------------------------|------------------------------------------------------| |-----------|----------------------------|------------------------------------------------------|
| BAL QR | `BALQR1\|total\|index\|…` | Past/other BAL versions (default, always exported) |
| BC-UR v1 | `ur:bytes/<bc32>` | Blockchain Commons / Coldcard-style UR (BC32, SHA-256 digest, part counts per part) |
| BC-UR v2 | `ur:bytes/<seq>-<seqlen>/<bytewords>` | BC-UR 2.x fountain codes (CBOR parts, CRC-32, bytewords-minimal) |
| BBQR | `B$<enc><type><N><n>…` | Coinkite BitKit / Coldcard's BBQR animated-QR mode | | 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 Import auto-detects the format of each scanned code; out-of-order, duplicate
BBQR frames are handled. Interop has not yet been cross-verified against and (for UR v2) partially-lost fountain frames are handled. Interop is
third-party libraries (`bbqr`, Coldcard firmwares). validation-tested against the reference C++ bc-ur encoder output and the
BCR-2020-004/005 BC32 test vectors; it has not yet been cross-verified against
third-party libraries (`ur`, `bbqr`, Coldcard firmwares).
## Reporting compatibility issues ## Reporting compatibility issues

View File

@@ -68,8 +68,7 @@ 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, resolve_relative_check_alive_date, checkalive.py <- resolve_date_to_check, check_alive_expired (GUI-free).
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)
@@ -112,8 +111,7 @@ 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 (the checkout directory; set Run everything from the repo root.
`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:
@@ -421,29 +419,50 @@ See Section 5 for details.
the audio path — buttons only appear when the `audio_modem` plugin + the audio path — buttons only appear when the `audio_modem` plugin +
`amodem` are installed; see the prerequisites below). `amodem` are installed; see the prerequisites below).
### Animated-QR interop: BBQR only ### In progress: animated-QR interop (BC-UR v1/v2, BBQR)
- `bal/core/animated_qr.py` implements a stdlib-only codec for **BBQR** - `bal/core/animated_qr.py` implements stdlib-only codecs for **BC-UR v1**
(BC32 + SHA-256 digest; the bech32_bis checksum variant per
BCR-2020-004/005), **BC-UR v2** (CBOR part structure, bytewords-minimal,
CRC-32, xoshiro256-based fountain with alias-sampled mixing) and **BBQR**
(Coinkite `B$…` base32/hex/zlib frames), plus one shared (Coinkite `B$…` base32/hex/zlib frames), plus one shared
`AnimatedQrSession` with `detect_format` auto-detection and `AnimatedQrSession` with `detect_format` auto-detection and
`parse_for_detection` frame identity for the GUI debounce. The previously `parse_for_detection` frame identity for the GUI debounce.
supported **BAL QR**, **BC-UR v1** and **BC-UR v2** wire formats were - Current status as of this session: reference parity, GUI, and tests done;
removed; only BBQR remains. not yet committed.
- **Sessions:** BBQR full-frame decode in any order for Z/2/H encodings, - **BC32/bytewords/codec parity:** BC32 reproduces the BCR-2020-004/005
duplicate drops. test vectors (`Hello, world`, `Hello world`, the long seed vector);
- **Safety:** `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`, bytewords-minimal round-trips with CRC rejection; UR v2 part encode +
`TransferConflictError` on a frame from a different transfer, decode is byte-exact against the reference C++ bc-ur encoder for a
`SessionLimitError`, BBQR zlib-bomb guard, UTF-8 payloads only. single part, seq_len=2 (12 frames) and seq_len=7 (3 sampled mixes),
- **GUI:** `BalQrExportWidget` emits BBQR frames using the QR-size presets; validating CBOR framing, bytewords, alias+ary-threshold sampling,
the importer routes every frame through `detect_format` + xoshiro256** and the XOR mix.
`AnimatedQrSession.add_part` with the shared - **Sessions:** UR v2 single-part (no seq header), out-of-order frames,
`qr_import_accept_frame(state, fmt, session_key, frame_total, index, duplicate drops, solve with a missing pure fragment (a second redundant
payload, stable_reads=2)` debounce (reset on session-key change). mixed wave is emitted by `ur2_frames`), UR v1 single-part
`_review_and_sign` resolves the session to the transfer text and decodes (digest-less `ur:bytes/<bc32>` accepted) and multipart, BBQR full-frame
parts uniformly. decode in any order for Z/2/H encodings.
- **Verification:** `tests/test_core_animated_qr.py` (BBQR round-trips, - **Safety:** `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`,
encoding fallback, zlib-bomb guard, detection) and the extended `TransferConflictError` on a frame from a different transfer,
`tests/test_gui_qr_transfer.py` pass. `SessionLimitError`, BBQR zlib-bomb guard, UTF-8 payloads only.
- **GUI:** `BalQrExportWidget` gained a Format selector (BAL QR default,
BC-UR v1, BC-UR v2, BBQR) reusing the QR-size presets; the importer now
routes every frame through `detect_format` +
`AnimatedQrSession.add_part` with the shared
`qr_import_accept_frame(state, fmt, session_key, frame_total, index,
payload, stable_reads=2)` debounce (reset on session-key change).
`_review_and_sign` resolves the session to the transfer text and decodes
parts uniformly across formats.
- **Verification:** `tests/test_core_animated_qr.py` (32 tests incl. the
C++-reference parity vectors and BC32 spec vectors) and the extended
`tests/test_gui_qr_transfer.py` pass; ruff clean on the new/changed
files; pyright 0 errors; smoke test, `build_zip.py` and
`external_zip_test.py` green. Only the pre-existing failures remain
(`test_bt_to_date_*`, fee-exceeds-balance, karen7 pollution).
- **Any remaining work:** manual on-device walkthrough of the QR path with
the new formats; optionally validate against third-party libraries
(`ur`, `bbqr`) once available; add the docstrings/branch notes already
captured in `ag1.md`/`ag2.md` context where needed.
**Dev-box audio prerequisites (audio_modem channel):** **Dev-box audio prerequisites (audio_modem channel):**
See `AUDIO_MODEM_DEBIAN.md` — the full Debian setup + verification, with the See `AUDIO_MODEM_DEBIAN.md` — the full Debian setup + verification, with the

View File

@@ -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`, `resolve_relative_check_alive_date`, `ensure_check_alive_valid` (riferimento temporale unico per ogni check; relativo negativo = prima della delivery, positivo = dopo la creazione). - `checkalive.py` — `resolve_date_to_check`, `check_alive_expired` (riferimento temporale unico per ogni check).
- `util.py` — `Util` (locktime, quantità, confronto tx/heirs, `get_available_utxos`, `fix_will_settings_tx_fees`). - `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 `ensure_check_alive_valid`. | | `init_class_variables()` | 618 | `date_to_check`, `no_willexecutor`, `willexecutors`, check `check_alive_expired`. |
| `check_will()` | 473 | `Will.is_will_valid(...)`; le eccezioni di dominio vengono propagate al comando. | | `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`. |

View File

@@ -1,453 +0,0 @@
# 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

View File

@@ -62,9 +62,9 @@
| # | Decision | | # | Decision |
|---|----------| |---|----------|
| D1 | QML part = update `QML_PLAN.md` document only; implementation later. | | 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. | | D2 | Frames carry a small ASCII header (`BALQR1\|N\|i\|flags`) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. |
| 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. | | 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. | | D4 | Compression (zlib+base64 over the whole payload) = checkbox in the export dialog, **default OFF**, advertised via a frame flag. |
| 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. | | 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. | | 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. | | 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. |
@@ -120,30 +120,19 @@ transfer_string = "\n".join( tx_str(tx) for tx in valid_txs_sorted_by_txid )
### 4.2 Frame layout (one frame = content of ONE QR code) ### 4.2 Frame layout (one frame = content of ONE QR code)
Wire format v2 (compact, current export):
``` ```
BAL1<TTT><iii><F><payload> BALQR1|<total>|<index>|<flags>|<payload>
``` ```
- Magic+version literal `BAL1` (reject anything else with a clear message). - Magic+version literal `BALQR1` (reject anything else with a clear message;
- `<TTT>` = `<iii>` — **base36** zero-padded 3-char strings (`000`…`ZZZ`), keeps the door open for a future `BALQR2`).
representing total N and index i, `1 ≤ i ≤ N ≤ 46655`. Fixed width means a - `<total>` N, `<index>` i — integers, `1 ≤ i ≤ N`.
3-digit count field costs the same for a 1-frame or a 46655-frame transfer. - `<flags>`: subset of chars, today `` (empty ⇒ plain) or `Z` (compressed).
- `<F>`: single flag char — `0` ⇒ plain, `Z` ⇒ zlib+base64 compressed.
- `<payload>`: the i-th slice of `transfer_string`, exactly - `<payload>`: the i-th slice of `transfer_string`, exactly
`chunk_size` bytes each (last slice may be shorter). No separators: both `chunk_size` bytes each (last slice may be shorter).
base36 count fields are fixed-width, so the header is unambiguously 11 - Header overhead ≈ 16–20 bytes → effective payload = `chunk_size − overhead`;
chars and the payload starts at offset 11. the chunker slices the transfer string so that **header+payload ≤ preset
- Header overhead is a constant **11 bytes** → effective payload = size**.
`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) ### 4.3 Size presets (D5)
@@ -184,18 +173,12 @@ def encode_transfer(tx_strings: list[str], compress: bool = False) -> str
"""Join -> optional zlib+base64 -> return transfer_string.""" """Join -> optional zlib+base64 -> return transfer_string."""
def split_frames(transfer_string: str, chunk_size: int) -> list[str] def split_frames(transfer_string: str, chunk_size: int) -> list[str]
"""Slice into frames 'BAL1<TTT><iii><F>payload' (v2) — header+payload <= """Slice into full frames 'BALQR1|N|i|flags|payload'. Raises
chunk_size. Raises QrTransferError over the 46655-frame base36 cap, or
ValueError if chunk_size < MIN_CHUNK_SIZE.""" 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] def parse_frame(frame: str) -> tuple[int, int, bool, str]
"""-> (total, index, compressed, payload); accepts v2 'BAL1…' and legacy """-> (total, index, compressed, payload); ValueError on bad magic/
'BALQR1|…' (wrapped as _parse_v1/_parse_v2); ValueError on bad magic/ version/arity/non-int fields."""
version/arity/non-numeric fields."""
def assemble(frames: dict[int, str]) -> str def assemble(frames: dict[int, str]) -> str
"""Validate indices form exactly range(1..max_total) (taken from any """Validate indices form exactly range(1..max_total) (taken from any
@@ -277,7 +260,7 @@ def get_audio_modem_plugin(self): # on BalWindow
Layout: Layout:
``` ```
[Size ▾ Small/Medium/Large/XL] ← compressed best-of automatically (no checkbox) [Size ▾ Small/Medium/Large/XL] [x Compress (zlib+base64)]
[ QR image ] ← BalQrImage (see below) [ QR image ] ← BalQrImage (see below)
«i di N» [◀ Prev] [Next ▶] «i di N» [◀ Prev] [Next ▶]
[Save current QR as PNG…] [Send via Audio Modem…] [Close] [Save current QR as PNG…] [Send via Audio Modem…] [Close]
@@ -285,7 +268,7 @@ Layout:
Behaviour: Behaviour:
- On any control change: rebuild `split_frames(encode_transfer_best(...))`, - On any control change: rebuild `split_frames(encode_transfer(...))`,
reset index to frame 1, refresh counter (owner requirement: "cambiare la reset index to frame 1, refresh counter (owner requirement: "cambiare la
risoluzione"). risoluzione").
- `BalQrImage(QWidget)` ≈ trimmed copy of `QRCodeWidget` - `BalQrImage(QWidget)` ≈ trimmed copy of `QRCodeWidget`

379
QML_PLAN.md Normal file
View File

@@ -0,0 +1,379 @@
# QML PLAN — BAL on Electrum QML / Android (Option B: minimal viable support)
> Goal: let Android users (Electrum QML GUI) use BAL. **The Android device is
> the OFFLINE SIGNING DEVICE**: its primary job is to receive unsigned will
> transactions from an online machine (desktop/another phone), sign them with
> the wallet keys held on it, and return the signed transactions — a classic
> air-gapped signer workflow. Online features (willexecutor contact,
> broadcast, build) are secondary on Android and belong mainly to the online
> machine.
> Strategy: a *third frontend* (`bal/gui/qml/`) that reuses `bal/core` logic
> through the existing GUI-free `BalController`, exactly like `bal/cli/`
> already does. The PyQt6 desktop GUI remains untouched and primary.
>
> Status: DRAFT for owner review (rule R4 — no code until explicit OK).
> Chat language: Italian; this document is in English per rule R1.
---
## 1. Verified facts (research done on the local checkouts)
All items below were verified by reading source, not assumed.
| # | Fact | Where |
|---|------|-------|
| F1 | The fork's Electrum ships a full QML GUI built on **PyQt6.QtQml** (PyQt6 6.11 installed in runtime env imports `QtQml`/`QtQuick` fine). | `electrum/electrum/gui/qml/` |
| F2 | The QML GUI has a **plugin mechanism**: manifest `"available_for"` must contain `"qml"`; Electrum then loads `<plugin>/qml.py` and calls the `init_qml(app)` hook. | `electrum/plugin.py` (`load_plugin_by_name`, gui_name), `gui/qml/__init__.py:88` |
| F3 | On load, `main.qml` reads `plugin.so.loader` and auto-creates the component from `<plugins>/<name>/qml/<loader>.qml`. The plugin itself sets `.so` (a `PluginQObject`). Canonical example: `electrum/plugins/labels/qml.py`. | `gui/qml/components/main.qml` (`onPluginLoaded`), `gui/common_qt/plugins.py` |
| F4 | The plugin must support **both** target versions: the QML GUI + `common_qt/plugins.py` exist in the 4.7.x line too (verified on the local 4.7.0 checkout). Exact 4.7.2 parity is Phase-0 task T1. | `$BAL_HOME/electrum470/electrum/gui/{qml,common_qt}` |
| F5 | `BalController(plugin, wallet)` is GUI-free, per-wallet, and already implements state init + sign/build/broadcast flows against the bare `wallet` object (no `ElectrumWindow` needed). This is the reuse cornerstone of this plan. | `bal/cli/controller.py:119-175`, `sign_transactions` at :646 |
| F6 | In the repo, `bal` is already symlinked into the Electrum tree as an **internal** plugin: `electrum/electrum/plugins/bal -> ../../../bal-electrum-plugin/bal`. Internal plugins are plain files on disk → the QML engine can load `.qml` assets directly. | `ls -la electrum/electrum/plugins/` |
| F7 | The APK build spec lists packaged plugins explicitly and **BAL is not yet in that list**. | `electrum/contrib/android/buildozer_qml.spec:34-48` |
| F8 | The QML Preferences page has **hardcoded toggles only** for `labels` and `psbt_nostr`; there is no generic plugin manager UI. Plugin enabling works via config regardless (`plugins.bal.enabled = true`). | `gui/qml/components/Preferences.qml:168,186,511-512` |
| F9 | Extension points inside the QML app are minimal: `run_hook('init_qml', app)`, `run_hook('load_wallet', wallet)` (**one** argument, unlike Qt's two), `get_tx_extra_fee`, `tc_sign_wrapper`, and one named-component injection slot (`pluginsComponentsByName('export_tx_button')`). No tools menu, no status bar. | grep over `gui/qml/*.py`, `main.qml:778` |
| F10 | External ZIP plugins cannot serve `.qml` files from inside the zip (zipimport exposes Python modules only; `Qt.resolvedUrl` needs real disk paths). Distribution as internal plugin (F6) or runtime extraction avoids this. | consequence of F3 |
| F11 | Core signing path used by the CLI controller calls `wallet.sign_transaction(tx, password)` and updates signature counts — identical flow works under `QEWallet.wallet`. | `bal/cli/controller.py:646-700` |
---
## 2. Scope
### In scope (MVP)
Two usage **profiles** share one codebase:
- **Offline signer profile** (Android, PRIORITY): works with no network.
1. Import a will bundle exported by the online machine (existing JSON will
format, see F12) — via file share, paste, or QR.
2. Review what is being signed (destinations, amounts, locktimes, fees).
3. Sign internally (`wallet.sign_transaction` + password prompt).
Partial signatures are combined when the bundle is re-imported
(`combine_with_other_psbt`, already supported by `merge_will` logic).
4. Export the signed bundle back to the online machine.
- **Online manager profile** (desktop QML, secondary):
- Will status overview (state, expiry/check-alive date, reminder info).
- Heirs list (view/add/edit/remove, addresses or URIs).
- Will-executor selection (list, enable/disable, fee display, refresh).
- Build will (simplified wizard reusing core validation).
- Sign in place (password) or hand off to an offline signer via bundles.
- Broadcast / push to will-executors; invalidate will; check-alive refresh.
- Basic settings mapped onto `will_settings`.
The offline signer pages are built first and must function with the network
disabled (Electrum runs fine offline; willexecutor refresh simply degrades).
Also in scope:
- Android packaging: BAL bundled as internal plugin in the custom APK,
**enabled by default** (owner decision D3).
- Keep desktop (`qt`) and CLI (`cmdline`) behavior byte-for-byte unchanged.
### Out of scope (explicitly deferred)
- Full parity with the PyQt6 GUI (calendar widget, preview list editor,
advanced fee controls, themes).
- External-ZIP distribution of QML assets (F10 workaround postponed; ZIP
builds keep working for desktop exactly as today, without `qml` UI).
- iOS, upstream-Electrum (spesmilo) compatibility.
- Lightning-related features (irrelevant to BAL).
---
## 3. Architecture
```
┌───────────────────────────────────────────┐
│ bal/core │
│ heirs, will, willexecutors, checkalive, │
│ reminders, input_rules, plugin_base │
└────────────┬──────────────────────────────┘
│ (no Qt anywhere)
┌─────────────────────┼──────────────────────┐
▼ ▼ ▼
bal/gui/qt/ bal/cli/ bal/gui/qml/ ← NEW
BalWindow etc. BalController qml_plugin.py (BalQmlPlugin)
(~9.3k lines) (headless flows) models.py (QObject VMs)
so.py (PluginQObject)
*.qml (views)
▲
wraps ONE BalController
per loaded wallet
```
Design rules:
- **Reuse, do not duplicate.** `bal/gui/qml/models.py` holds thin QObject
wrappers around one `BalController` instance per wallet. No business logic
in QML or in the wrappers beyond formatting.
- **Same persistence.** Wallet DB dicts (`heirs`, `will`, `will_settings`)
are registered by `bal/core/plugin_base.py` already; QML reads/writes them
through the controller, so a wallet moves between desktop/Android unchanged.
- **Threading.** Network operations (willexecutor fetch/push, broadcast)
run in worker threads exactly as the CLI does; results marshalled to the UI
thread via Qt signals on the wrapper objects. No blocking calls in slots.
- **One transfer format.** The airgap round trip reuses the existing JSON
will serialization (`WillItem.to_dict()` maps, exactly what the Qt GUI's
`export_json_file`/`import` + `merge_will` flow already produces and
consumes — see F12). No new format is invented; export/merge logic gets a
single shared home usable by both frontends.
- **Version gating.** Every import of `electrum.gui.qml.*` happens lazily and
defensively; if absent (e.g., odd build), the plugin degrades to core-only
behavior instead of crashing the daemon.
---
## 4. Work breakdown
### Phase 0 — Verification spikes (no product code)
| Task | Description | Exit criterion |
|------|-------------|----------------|
| T1 | Diff `gui/qml` + `gui/common_qt` between the 4.7.0 checkout here and current 4.8.x, focused on: `PluginQObject`, `init_qml` hook call sites, `onPluginLoaded` handler, `load_wallet` hook arity. If 4.7.2 differs, note shims needed. | Written compatibility note appended to COMPATIBILITY.md draft section |
| T2 | Run desktop QML GUI headless with BAL enabled via config: `QT_QPA_PLATFORM=offscreen run_electrum -g qml` with `plugins.bal.enabled=true`, manifest updated ad-hoc (throwaway branch). Confirms discovery/loading path end-to-end before writing any code. | Log shows `init_qml` called for bal; no crash |
| T3 | APK feasibility: add `electrum/plugins/bal` to `buildozer_qml.spec` package list locally, confirm p4a includes `.qml` data files and icons (may need `source.include_exts` adjustment). Do NOT ship. | Test APK contains `plugins/bal/qml/*.qml` |
| T4 | Decide entry-point UX given F9 (no menu hook): candidate = tiny patch in fork's `main.qml` adding a "BAL" item in the wallet drawer/menu that opens our window object from `app.pluginobjects['bal']`. Confirm with owner. | Decision recorded in this file |
Deliverable: short findings report appended to this document; go/no-go.
### Phase 1 — Skeleton integration
Files (all NEW unless noted):
```
bal/qml.py zipimport-style shim mirroring qt.py/cmdline.py
bal/gui/qml/__init__.py package docstring
bal/gui/qml/qml_plugin.py class BalQmlPlugin(BalPluginBase)
bal/gui/qml/so.py class BalSignalObject(PluginQObject)
bal/manifest.json MODIFIED: available_for += ["qml"]
```
Details:
- `qml_plugin.py`:
- `@hook init_qml(self, app)`: store app ref; create `so` parented to app;
for each already-loaded wallet call `_on_wallet_loaded(wallet)`
(mirrors labels' pattern, see F3).
- `@hook load_wallet(self, wallet)` — **single argument** (F9); creates the
per-wallet view-model bundle (Phase 2) keyed by `wallet`.
- `@hook unload_wallet(self, wallet)`: drop controllers, close windows.
- `so.py`: `BalSignalObject(PluginQObject)` exposing:
- `loader` property returning `"BalMain.qml"` (drives F3 auto-create);
- signals: `walletChanged`, `willStateChanged`, `heirsChanged`,
`willexecutorsChanged`, `busyChanged`;
- slots called from QML: open/close window, refresh willexecutors,
check-alive now, build/sign/broadcast/invalidate commands.
- `manifest.json`: append `"qml"` to `available_for`. Desktop untouched
(Electrum filters per running GUI, verified F2).
Exit criterion: with `-g qml`, plugin loads, `so.loader` component is created
(log line from `onPluginLoaded`), no functional UI yet.
### Phase 2 — View-models (QObject layer)
New file `bal/gui/qml/models.py`:
- `BalQmlWallet(QObject)`: owns one `BalController`; exposes read-only
properties (`willState`, `dateToCheck`, `expired`, `reminderInfo`,
`sigsHave/sigsRequired` per tx) + notification signals; forwards actions to
controller methods (`build_will`, `sign_transactions`, `broadcast_will`,
`invalidate_will_headless`, `check_alive`... — names per controller).
- **Airgap methods (priority):** `export_will_bundle()` and
`import_will_bundle(json_text)` returning summary of what changed. These
are small ports of the Qt GUI's `export_json_file` (window.py:1605) and
`merge_will` (window.py:1620) semantics. Preferred implementation: move
the logic into shared helpers (controller level or `bal/core/will.py`
static functions) and make the Qt GUI call the same helpers, so the two
frontends cannot diverge; regression-covered by existing core tests plus
new round-trip tests.
- Offline profile detection: expose an `isOffline` property derived from
`wallet.network is None` / config, so QML can hide online-only pages.
- `HeirListModel(QAbstractListModel)`: roles `name`, `address`, `amountPct`,
`valid`; edit methods delegate to `Heirs` helpers through controller.
- `WillTxListModel(QAbstractListModel)`: roles `txid`, `status`, `fee`,
`sigsHave`, `sigsRequired`, `isComplete`.
- `WillExecutorListModel(QAbstractListModel)`: roles `url`, `selected`,
`fee`, `valid`; toggle + async refresh.
- `BalQrTransferModel(QObject)`: thin scheduler over the shared transfer
planner `bal.core.qrtransfer` (already battle-tested by the desktop Qt
plugin, P1-P4). Exposes `encode(items)` → frames, `frameAt(i)` (data URL /
pixmap for QML), `decode(text)` → tx list, `total`, `current`, presets;
re-emits a `frameChanged` notifier so the QML page can step 1..N. No QR
rendering inside the model (QML paints it).
- All list mutations happen on the controller state then `beginResetModel/
endResetModel` (datasets are small; simplicity over incremental updates).
Exit criterion: pytest-driven model tests pass offscreen (create models over a
regtest/testnet wallet fixture, assert roles after mutations).
### Phase 3 — QML views
New directory `bal/gui/qml/components/`:
```
BalMain.qml top-level Window; stack of pages below
BalSignPage.qml PRIORITY (offline signer): import bundle
(paste / file / QR), review summary of each tx,
password-sign, export signed bundle back
BalQrExportPage.qml QR export view: drives BalQrTransferModel, one
frame at a time (Prev/Next, progress i/N, chunk
preset selector), mirror of desktop Qt dialog
BalQrImportPage.qml QR import view: slot grid (1..N), camera via
Electrum `QRScan` reuse, manual paste fallback,
then jump into BalSignPage review/sign
BalStatusPage.qml will state, expiry countdown, check-alive button
(works offline with last-known data)
BalHeirsPage.qml ListView + add/edit dialog [online profile]
BalExecutorsPage.qml ListView with switches + refresh [online profile]
BalBuildPage.qml simplified build form (threshold selector, fees,
executor pick) → runs controller.build_will
[online profile]
BalSettingsPage.qml maps onto will_settings subset
controls/BalButton.qml, BalField.qml minimal styled primitives
qmldir module registration
```
Notes:
- **BalSignPage review screen is mandatory**: before signing, the user must
see per-transaction heir address, amount, locktime (date), and fee — this
device is the security boundary, so no "blind signing".
- QR transfer: a full will bundle may exceed one QR's capacity when there are
many heirs/transactions. The desktop Qt plugin now resolves this with
chunked multi-QR streams (`bal/core/qrtransfer.py`, chunk presets
150/400/900/1800 bytes/frame, EC level M); the QML GUI reuses the same
scheduler via a thin model. Order of preference stays (1) share/save file +
paste text; (2) chunked QR streams through `BalQrTransferModel`.
- Styling minimal, follow existing QML components' look (reuse
`controls/` from Electrum where importable — prefer copying tiny primitives
to avoid coupling to upstream churn; decide during implementation).
- Every string wrapped with `electrum.i18n._`.
Exit criterion: full manual walkthrough on desktop QML GUI (offscreen +
interactive) performing BOTH: (a) online profile — configure heirs → select
executors → build → sign → broadcast → invalidate; and (b) offline signer
profile — export unsigned bundle from an online wallet, import into a second
offline wallet instance, sign, re-export, import signed bundle back into the
first wallet and verify signatures combined/status COMPLETE. Walkthrough (b)
must pass with networking disabled.
### Phase 4 — Android integration
- Add `electrum/plugins/bal` (+ data extensions for `.qml`, `icons/*`) to
`contrib/android/buildozer_qml.spec` in the Electrum fork.
- Patch fork's `Preferences.qml` with a BAL toggle **(owner approved, D1)**
and default-enable BAL in the APK build (D3: enabled by default).
- Entry point per T4 decision (menu/drawer patch in fork's `main.qml`, D1
approved). Fallback if T4 picks auto-open: window opens on wallet load.
- Rebuild APK; smoke-test on device/emulator:
install → BAL already enabled → open wallet → full MVP walkthrough,
including airplane-mode signing round trip (file/QR transfer between an
online desktop and the offline device — for emulator testing, "offline" =
network disabled via settings).
- Watch-outs: filesystem paths (use `os.path.join`, no hardcoded separators —
already house style), background network on mobile (willexecutor timeouts),
APK size impact (bal is small; icons only), share-intent/file access
permissions for bundle import/export.
Exit criterion: signed test APK passes the same walkthrough as Phase 3.
### Phase 5 — Tests & CI hygiene
- New tests following repo conventions (`def test_*` + `__main__` block):
- `tests/test_qml_models.py` — models over fake/controller-backed wallet
(offscreen, no network).
- `tests/test_qml_airgap_roundtrip.py` — PRIORITY: export unsigned bundle
from wallet A → import into wallet B (same seed, offline) → sign →
export signed → merge back into A; assert COMPLETE status and signature
counts. Must pass with no network.
- `tests/test_qml_plugin_loading.py` — plugin instantiates under a stubbed
QML app object; `so` wiring correct; load/unload wallet lifecycle.
- Extend `tests/smoke_test.py` usage: `QT_QPA_PLATFORM=offscreen python3
tests/smoke_test.py electrum.plugins.bal` still green (qt path intact).
- Regression gate: full `tests/test_core_*.py` batch + ruff (no NEW
violations) before any delivery ZIP.
- Manual matrix recorded in CHANGELOG entry: [desktop qt, desktop qml
offscreen, Android APK] × [4.7.2, 4.8.0] where applicable.
### Phase 6 — Release plumbing & docs
- `build_zip.py`: ensure new `bal/gui/qml/**` and `components/*.qml` included
in deterministic zip (harmless on desktop; enables future extraction-based
loading).
- `COMPATIBILITY.md`, `README.md`, `HANDOFF.md`: document QML/Android status,
limitations, and how to enable (`-g qml` / APK toggle).
- `CHANGELOG.md`: numbered entry at END per house rules.
- Version bump + release handled by `make-release.sh` as usual (owner-driven).
---
## 5. Risks & mitigations
| Risk | Impact | Mitigation |
|------|--------|------------|
| R1: 4.7.2 vs 4.8 QML internals drift | broken load on one version | Phase-0 T1 diff first; lazy imports + capability checks; shim module if needed |
| R2: no generic plugin UI in QML (F8) | users can't enable BAL from UI | config default-enable in fork APK; small Preferences.qml patch in fork (we control it); document manual config for desktop |
| R3: no natural entry point in main.qml (F9) | user can't find/open BAL window | T4 decision: fork-side menu/drawer patch; fallback = auto-open window on wallet load behind a setting |
| R4: dual-GUI maintenance burden | long-term cost | strict reuse of `BalController`; QML layer forbidden from business logic (review rule); parity features stay in qt GUI |
| R5: threading bugs on mobile networks | ANRs/crashes | all network ops in threads like CLI; signals-only UI updates; timeouts already configurable |
| R6: airgap transfer friction (bundle size vs QR capacity, share permissions on Android) | users cannot move bundles reliably | desktop Qt: chunked multi-QR streams + audio-modem optional channel shipped (P1-P4) and regression-gated; QML: file share + paste first, QR via `BalQrTransferModel` (Phase 2/3 notes); Android camera on Qt6 is known-flaky, file/paste stays the primary mobile fallback and is tested first in Phase 4 |
| R7: export/merge semantics divergence between frontends | signed bundles rejected or double-counted | single shared helper used by qt GUI and qml layer (Phase 2); round-trip regression test |
| R8: hidden coupling of qt code into shared modules | qml import pulls QtWidgets | lint guard idea: import-linter/ruff rule forbidding `PyQt6.QtWidgets` under `bal/gui/qml/` |
| R9: zip distribution ambiguity (F10) | confusion about what ships where | clear policy: ZIP = desktop qt+cmdline only; QML requires internal-plugin/APK route (Phase 6 documents this) |
| R10: unknown Android/Electrum baseline (owner to confirm, OQ4) | wrong Qt/PyQt6 assumptions in APK build | Phase-0 T3 builds against the fork's current toolchain; code keeps 4.7.x/4.8.x dual support so the answer can arrive late without rework |
---
## 6. Questions for the owner — ANSWERED (2026-08-25)
1. **Fork patches (T4/R3):** ✅ **D1 — APPROVED.** Patching the fork's
`main.qml`/`Preferences.qml` is allowed for the BAL menu entry and toggle.
2. **Offline signing topology:** ✅ **D2 — Android IS the offline device.**
The phone holds the keys and acts as air-gapped signer: import unsigned
bundle → review → sign → export signed bundle back to the online machine.
The sign/import/export page is therefore the top priority of Phase 3, and
the round-trip test is the top priority of Phase 5.
3. **Enable-by-default:** ✅ **D3 — BAL pre-enabled in the custom APK**
(toggle still available to disable).
4. **Target Android/Electrum baseline:** ⏳ **OPEN (OQ4)** — owner will get
back later. Not blocking: see risk R10 mitigation.
---
## 7. Effort estimate
| Phase | Rough size |
|-------|-----------|
| 0 spikes | ~half day (mostly reading + one throwaway branch) |
| 1 skeleton | ~300 lines Python |
| 2 models (incl. shared export/merge helpers) | ~600–800 lines Python |
| 3 views (offline signer page first) | ~900–1300 lines QML |
| 4 android | fork-side patches + build iteration (device-dependent) |
| 5 tests | ~500 lines |
| 6 release/docs | small |
Overall: comparable to a medium feature, dominated by Phase 3 UI polish and
Phase 4 device iteration.
---
## 8. Findings log (append-only)
- **F12** — The airgap round trip already exists in the Qt GUI layer and can
be ported almost verbatim: `export_json_file()` (window.py:1605) exports
all will items as `{wid: WillItem.to_dict()}` JSON (marking them
`EXPORTED`); the import side goes through `merge_will()`
(window.py:1620), which carries operational statuses, combines partial
signatures via `tx.combine_with_other_psbt()` when txids match, substitutes
the tx otherwise, and recomputes validity locally without network.
Conclusion: no new transfer format is needed; the plan is to give this
logic a shared home (controller/core) so Qt, CLI-adjacent tooling and QML
all use one implementation.
### Decisions
- **D1** — Fork-side patches to `main.qml` / `Preferences.qml` approved by
the owner.
- **D2** — Android = offline signing device; sign/import/export flow has
top priority.
- **D3** — BAL enabled by default in the custom APK.
- **OQ4** — Android/Electrum baseline: open, non-blocking (see R10).

View File

@@ -26,8 +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 │ ├── qrtransfer.py BAL QR will-transfer wire format / chunk scheduler
│ ├── animated_qr.py BBQR codec (stdlib-only) │ ├── animated_qr.py BC-UR v1/v2 + BBQR codecs (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)
@@ -94,20 +94,23 @@ exists, then enable it from **Tools → Plugins**.
From the will list (**Export → QR Codes**) a will can be exported as a 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 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 export offers All / Valid / Valid-NC filters plus a QR size preset
(150–1800 bytes/frame) and ships the **BBQR** format; the import (150–1800 bytes/frame); the import flow reviews and sign each transaction
flow reviews and sign each transaction
one at a time, then proposes exporting the signed transactions. When one at a time, then proposes exporting the signed transactions. When
Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` + Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` +
PortAudio) Send/Receive audio buttons complement the QR channel. See PortAudio) Send/Receive audio buttons complement the QR channel. See
[`PLAN_QR_TRANSFER.md`](PLAN_QR_TRANSFER.md) for the transfer wire-format spec. [`PLAN_QR_TRANSFER.md`](PLAN_QR_TRANSFER.md) for the BAL QR wire-format spec.
### Animated-QR format (BBQR) ### Animated-QR formats (interop)
The export page emits **BBQR** (`B$…`, Coinkite, used by BitKit and Coldcard) BAL QR is the default export format, but the export page's **Format** selector
animated-QR sequences. The importer auto-detects each code it sees, so a BAL also emits **BC-UR v1** (`ur:bytes`, BC32 + SHA-256), **BC-UR v2**
export can be imported by any tool that understands BBQR. BBQR frames may (`ur:bytes`, CBOR fountain codes) and **BBQR** (`B$…`, Coinkite, used by
arrive in any order, and duplicates are ignored. The 32 MB message limit BitKit) animated-QR sequences. The importer auto-detects the format of each
(zlib-bomb guard) bounds untrusted scanner input. code it sees, so any of the four formats can be imported on a BAL device, and
a BAL export can be imported by any tool that understands these standards.
UR v2 imports tolerate out-of-order and duplicate frames (fountain decoding);
BBQR frames may arrive in any order. Rotation/redundancy caps and the
32 MB message limit (zlib-bomb guard) bound untrusted scanner input.
## Command-line / headless usage ## Command-line / headless usage

View File

@@ -1,5 +0,0 @@
# 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]

View File

@@ -40,12 +40,9 @@ 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,
ensure_check_alive_valid, check_alive_expired,
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
@@ -93,27 +90,21 @@ 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 "
"amounts: {}" f"amounts: {e}"
).format(e) )
) )
if isinstance(e, heirs_mod.WillExecutorFeeTooHighException): if isinstance(e, heirs_mod.WillExecutorFeeTooHighException):
return UserFacingException(_("Will-executor fee too high: {}").format(e)) return UserFacingException(_(f"Will-executor fee too high: {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(_("not an address, {}").format(e)) return UserFacingException(_(f"not an address, {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(
_( _(
@@ -280,21 +271,13 @@ 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
) )
delivery_date = resolve_delivery_date( if check_alive_expired(self.plugin.is_basic_mode(), self.date_to_check):
self.will_settings, raise CheckAliveError(self.date_to_check)
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):
@@ -331,28 +314,6 @@ 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
) )
@@ -385,9 +346,7 @@ class BalController:
tx["heirsvalue"] = txs[txid].heirsvalue tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description tx["description"] = txs[txid].description
tx["willexecutor"] = copy_structure(txs[txid].willexecutor) tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
# Stored history: always English (translated only for display by tx["status"] = _("New")
# 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_structure(txs[txid].heirs) tx["heirs"] = copy_structure(txs[txid].heirs)
@@ -475,15 +434,7 @@ 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"])
threshold_ts = resolve_guard_threshold( if locktime < date_to_check:
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:
@@ -603,11 +554,6 @@ 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")

View File

@@ -2,18 +2,31 @@
bal.core.animated_qr bal.core.animated_qr
==================== ====================
GUI-free implementation of the BBQR animated-QR transfer format used to GUI-free implementation of the interoperable animated-QR transfer formats
move BAL will data between devices. used to move BAL will data between devices.
Supported wire format (self-describing and order-independent on receive): Supported wire formats (each self-describing and order-independent on
receive):
* **BALQR** (native, unchanged): ``BALQR1|total|index|flags|payload``.
* **BC-UR v1** (BCR-2020-005 rev1 draft, May 2020)::
ur:bytes/1of7/<bc32-digest>/<bc32-fragment>
Fragments partition the BC32 rendering of the CBOR byte string; the
SHA-256 digest of the wrapped payload ties the parts together.
* **BC-UR v2** (BCR-2020-005 rev 2 / BCR-2020-012)::
ur:bytes/2-9/<bytewords-minimal fragment>
Fountain-coded parts; each part is a CBOR array
``[seq_num, seq_len, message_len, checksum, data]`` whose CBOR bytes are
bytewords-minimal encoded with a trailing per-part CRC-32. The
``checksum`` field holds the CRC-32 of the whole wrapped message, so the
parts are mixable and order-independent.
* **BBQR** (Coinkite):: * **BBQR** (Coinkite)::
B$<encoding><type><2 base36 total><2 base36 index><payload> B$<encoding><type><2 base36 total><2 base36 index><payload>
Equal-length text frames; the payload is uppercase hex, RFC-4648 Equal-length text frames; the payload is uppercase hex, RFC-4648
base32, or raw-deflate (``wbits=-10``) zlib plus base32. base32, or raw-deflate (``wbits=-10``) zlib plus base32.
Everything is implemented from scratch on top of the Python standard library Everything is implemented from scratch on top of the Python standard library
only (``zlib``, ``base64``), so the shipped plugin zip stays a only (``zlib``, ``hashlib``, ``base64``), so the shipped plugin zip stays a
self-contained bundle with no third-party dependencies (house rule). self-contained bundle with no third-party dependencies (house rule).
This module never imports Qt or any Electrum GUI code (house rule). This module never imports Qt or any Electrum GUI code (house rule).
@@ -22,8 +35,9 @@ This module never imports Qt or any Electrum GUI code (house rule).
from __future__ import annotations from __future__ import annotations
import base64 import base64
import hashlib
import zlib import zlib
from typing import Dict, List, Optional, Sequence, Tuple from typing import Dict, FrozenSet, List, Optional, Sequence, Set, Tuple
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Errors & safety caps # Errors & safety caps
@@ -46,10 +60,604 @@ class SessionLimitError(AnimatedQrError):
"""A receive session exceeded its safety caps.""" """A receive session exceeded its safety caps."""
class ChecksumError(AnimatedQrError):
"""A part failed its checksum / digest validation."""
# Safety caps for untrusted scanner input. # Safety caps for untrusted scanner input.
_MAX_SESSION_PARTS = 20000 _MAX_SESSION_PARTS = 20000
_MAX_MESSAGE_BYTES = 32 * 1024 * 1024 _MAX_MESSAGE_BYTES = 32 * 1024 * 1024
# --------------------------------------------------------------------------- #
# CBOR minimals (byte-string envelope + the fountain part header)
# --------------------------------------------------------------------------- #
_BYTE_STR_RES = 0x40 # byte string, length < 24
_BYTE_STR_1 = 0x58 # byte string, 1-byte length
_BYTE_STR_2 = 0x59 # byte string, 2-byte length
_BYTE_STR_4 = 0x60 # byte string, 4-byte length
_ARRAY_RES = 0x80
_UNSIGNED_RES = 0x00
def cbor_byte_string(data: bytes) -> bytes:
"""Wrap ``data`` in the minimal CBOR byte-string envelope (0x40..0x60)."""
n = len(data)
if n < 24:
head = bytes([_BYTE_STR_RES + n])
elif n <= 0xFF:
head = bytes([_BYTE_STR_1, n])
elif n <= 0xFFFF:
head = bytes([_BYTE_STR_2]) + n.to_bytes(2, "big")
elif n <= 0xFFFFFFFF:
head = bytes([_BYTE_STR_4]) + n.to_bytes(4, "big")
else:
raise AnimatedQrError("payload too large for the UR byte-string envelope")
return head + data
def unwrap_ur_cbor(message: bytes) -> bytes:
"""Strip the CBOR byte-string envelope, falling back to the raw bytes.
Receivers keep working even when the emitter embedded the payload without
any CBOR wrapping (some third-party ``ur:bytes`` emitters do).
"""
if not message:
raise AnimatedQrError("empty decoded message")
b0 = message[0]
if _BYTE_STR_RES <= b0 <= 0x57:
header_len, n = 1, b0 - _BYTE_STR_RES
elif b0 == _BYTE_STR_1 and len(message) >= 2:
header_len, n = 2, message[1]
elif b0 == _BYTE_STR_2 and len(message) >= 3:
header_len, n = 3, int.from_bytes(message[1:3], "big")
elif b0 == _BYTE_STR_4 and len(message) >= 5:
header_len, n = 5, int.from_bytes(message[1:5], "big")
else:
return message
if header_len + n != len(message):
raise AnimatedQrError("decoded message has an inconsistent CBOR length")
return message[header_len:]
def _cbor_unsigned(value: int) -> bytes:
if value < 24:
return bytes([_UNSIGNED_RES + value])
if value <= 0xFF:
return bytes([0x18, value])
if value <= 0xFFFF:
return bytes([0x19]) + value.to_bytes(2, "big")
if value <= 0xFFFFFFFF:
return bytes([0x1A]) + value.to_bytes(4, "big")
return bytes([0x1B]) + value.to_bytes(8, "big")
def cbor_part(seq_num: int, seq_len: int, message_len: int, checksum: int, data: bytes) -> bytes:
"""The CBOR body of a BC-UR v2 fountain part (``[seq, seq_len, message_len, checksum, data]``)."""
out = bytearray([_ARRAY_RES + 5])
out += _cbor_unsigned(seq_num)
out += _cbor_unsigned(seq_len)
out += _cbor_unsigned(message_len)
out += _cbor_unsigned(checksum)
out += cbor_byte_string(data)
return bytes(out)
def _need(buf: bytes, pos: int, count: int) -> None:
if pos + count > len(buf):
raise AnimatedQrError("truncated CBOR part header")
def _cbor_read_unsigned(buf: bytes, pos: int) -> Tuple[int, int]:
if pos >= len(buf):
raise AnimatedQrError("truncated CBOR part header")
octet = buf[pos]
if octet & 0xE0 != _UNSIGNED_RES:
raise AnimatedQrError("unexpected CBOR type in part header")
pos += 1
additional = octet & 0x1F
if additional < 24:
return additional, pos
if additional == 24:
_need(buf, pos, 1)
return buf[pos], pos + 1
if additional == 25:
_need(buf, pos, 2)
return int.from_bytes(buf[pos : pos + 2], "big"), pos + 2
if additional == 26:
_need(buf, pos, 4)
return int.from_bytes(buf[pos : pos + 4], "big"), pos + 4
if additional == 27:
_need(buf, pos, 8)
return int.from_bytes(buf[pos : pos + 8], "big"), pos + 8
raise AnimatedQrError("unsupported CBOR integer width in part header")
def _cbor_read_bytes(buf: bytes, pos: int) -> Tuple[bytes, int]:
if pos >= len(buf):
raise AnimatedQrError("truncated CBOR part header")
octet = buf[pos]
pos += 1
if octet & 0xE0 != _BYTE_STR_RES:
raise AnimatedQrError("expected a CBOR byte string in part header")
additional = octet & 0x1F
if additional < 24:
n = additional
elif additional == 24:
_need(buf, pos, 1)
n, pos = buf[pos], pos + 1
elif additional == 25:
_need(buf, pos, 2)
n, pos = int.from_bytes(buf[pos : pos + 2], "big"), pos + 2
elif additional == 26:
_need(buf, pos, 4)
n, pos = int.from_bytes(buf[pos : pos + 4], "big"), pos + 4
else:
raise AnimatedQrError("unsupported CBOR byte-string width in part header")
_need(buf, pos, n)
return buf[pos : pos + n], pos + n
def _cbor_read_array(buf: bytes, pos: int) -> Tuple[int, int]:
if pos >= len(buf):
raise AnimatedQrError("truncated CBOR part header")
octet = buf[pos]
pos += 1
if octet & 0xE0 != _ARRAY_RES:
raise AnimatedQrError("expected a CBOR array in part header")
additional = octet & 0x1F
if additional < 24:
return additional, pos
if additional == 24:
_need(buf, pos, 1)
return buf[pos], pos + 1
if additional == 25:
_need(buf, pos, 2)
return int.from_bytes(buf[pos : pos + 2], "big"), pos + 2
raise AnimatedQrError("unsupported CBOR array header in part")
# --------------------------------------------------------------------------- #
# CRC-32 (same polynomial as ``zlib.crc32``, network byte order)
# --------------------------------------------------------------------------- #
def crc32_int(data: bytes) -> int:
"""CRC-32 over ``data`` as an unsigned 32-bit integer."""
return zlib.crc32(data) & 0xFFFFFFFF
def crc32_bytes(data: bytes) -> bytes:
"""CRC-32 over ``data`` as 4 network-order (big-endian) bytes."""
return crc32_int(data).to_bytes(4, "big")
# --------------------------------------------------------------------------- #
# Bytewords (BCR-2020-012)
# --------------------------------------------------------------------------- #
_BYTEWORDS = (
"ableacidalsoapexaquaarchatomauntawayaxisbackbaldbarnbeltbetabiasbluebodybragbr"
"ewbulbbuzzcalmcashcatschefcityclawcodecolacookcostcruxcurlcuspcyandarkdatadays"
"delidicedietdoordowndrawdropdrumdulldutyeacheasyechoedgeepicevenexamexiteyesfa"
"ctfairfernfigsfilmfishfizzflapflewfluxfoxyfreefrogfuelfundgalagamegeargemsgift"
"girlglowgoodgraygrimgurugushgyrohalfhanghardhawkheathelphighhillholyhopehornhu"
"tsicedideaidleinchinkyintoirisironitemjadejazzjoinjoltjowljudojugsjumpjunkjury"
"keepkenokeptkeyskickkilnkingkitekiwiknoblamblavalazyleaflegsliarlimplionlistlo"
"goloudloveluaulucklungmainmanymathmazememomenumeowmildmintmissmonknailnavyneed"
"newsnextnoonnotenumbobeyoboeomitonyxopenovalowlspaidpartpeckplaypluspoempoolpo"
"sepuffpumapurrquadquizraceramprealredorichroadrockroofrubyruinrunsrustsafesaga"
"scarsetssilkskewslotsoapsolosongstubsurfswantacotasktaxitenttiedtimetinytoilto"
"mbtoystriptunatwinuglyundouniturgeuservastveryvetovialvibeviewvisavoidvowswall"
"wandwarmwaspwavewaxywebswhatwhenwhizwolfworkyankyawnyellyogayurtzapszerozestzi"
"nczonezoom"
)
_WORDS = [_BYTEWORDS[i : i + 4] for i in range(0, 1024, 4)]
_DIM = 26
_WORD_LOOKUP: Optional[List[int]] = None
def _word_lookup() -> List[int]:
"""First/last-letter lookup table (built lazily, mirrors Bytewords)."""
global _WORD_LOOKUP
if _WORD_LOOKUP is None:
table = [-1] * (_DIM * _DIM)
for i, word in enumerate(_WORDS):
x = ord(word[0]) - ord("a")
y = ord(word[3]) - ord("a")
table[y * _DIM + x] = i
_WORD_LOOKUP = table
return _WORD_LOOKUP
def _decode_word(word: str, word_len: int) -> int:
if len(word) != word_len:
raise AnimatedQrError("invalid bytewords word length")
x = ord(word[0]) - ord("a")
y = ord(word[3] if word_len == 4 else word[1]) - ord("a")
if not (0 <= x < _DIM and 0 <= y < _DIM):
raise AnimatedQrError("invalid bytewords characters")
value = _word_lookup()[y * _DIM + x]
if value == -1:
raise AnimatedQrError("invalid bytewords first/last pair")
if word_len == 4:
full = _WORDS[value]
if word[1] != full[1] or word[2] != full[2]:
raise AnimatedQrError("invalid bytewords middle letters")
return value
def bytewords_minimal_encode(data: bytes) -> str:
"""BCR-2020-012 bytewords-minimal: one two-letter word per byte, then CRC."""
crc = data + crc32_bytes(data)
return "".join(_WORDS[b][0] + _WORDS[b][3] for b in crc)
def bytewords_minimal_decode(text: str) -> bytes:
"""Inverse of :func:`bytewords_minimal_encode` (validates the CRC-32)."""
if len(text) % 2:
raise AnimatedQrError("invalid bytewords length (odd)")
values = [_decode_word(text[i : i + 2], 2) for i in range(0, len(text), 2)]
payload = bytes(values)
if len(payload) < 5:
raise AnimatedQrError("bytewords payload too short")
body, checksum = payload[:-4], payload[-4:]
if crc32_bytes(body) != checksum:
raise AnimatedQrError("bytewords CRC-32 mismatch")
return body
# --------------------------------------------------------------------------- #
# BC32 (the deprecated bech32-derived codec used by BC-UR v1)
# --------------------------------------------------------------------------- #
_BC32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
_BC32_REV = {ch: i for i, ch in enumerate(_BC32_ALPHABET)}
_BECH32_GENERATOR = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
def bech32_polymod(values: Sequence[int]) -> int:
chk = 1
for value in values:
top = chk >> 25
chk = (chk & 0x1FFFFFF) << 5 ^ value
for i in range(5):
if (top >> i) & 1:
chk ^= _BECH32_GENERATOR[i]
return chk
def _bc32_checksum(values: List[int]) -> List[int]:
polymod = bech32_polymod([0] + values + [0] * 6) ^ 0x3FFFFFFF
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
def _bc32_verify(values: List[int]) -> bool:
return bech32_polymod([0] + values) == 0x3FFFFFFF
def bc32_encode(data: bytes) -> str:
"""BCR-2020-005 BC32: bech32 without the human-readable part and divider."""
acc = 0
bits = 0
values: List[int] = []
for byte in data:
acc = (acc << 8) | byte
bits += 8
while bits >= 5:
bits -= 5
values.append((acc >> bits) & 31)
if bits:
values.append((acc << (5 - bits)) & 31)
values += _bc32_checksum(values)
return "".join(_BC32_ALPHABET[v] for v in values)
def bc32_decode(text: str) -> bytes:
"""Inverse of :func:`bc32_encode` (validates the 6-char checksum)."""
lowered = text.lower()
try:
values = [_BC32_REV[ch] for ch in lowered]
except KeyError:
raise AnimatedQrError("invalid BC-UR v1 character") from None
if len(values) < 6 or not _bc32_verify(values):
raise AnimatedQrError("invalid BC-UR v1 checksum")
data = values[:-6]
acc = 0
bits = 0
out = bytearray()
for value in data:
acc = (acc << 5) | value
bits += 5
if bits >= 8:
bits -= 8
out.append((acc >> bits) & 0xFF)
return bytes(out)
# --------------------------------------------------------------------------- #
# xoshiro256** + alias sampler (exact ports of the reference RNG chain)
# --------------------------------------------------------------------------- #
_MASK64 = (1 << 64) - 1
class _Xoshiro256:
"""xoshiro256** 1.0, seeded via SHA-256 of a byte sequence."""
def __init__(self, seed: bytes):
digest = hashlib.sha256(seed).digest()
self._s = [
int.from_bytes(digest[offset : offset + 8], "big")
for offset in range(0, 32, 8)
]
@staticmethod
def _rotl(x: int, k: int) -> int:
return ((x << k) | (x >> (64 - k))) & _MASK64
def next(self) -> int:
result = (self._rotl((self._s[1] * 5) & _MASK64, 7) * 9) & _MASK64
t = (self._s[1] << 17) & _MASK64
s = self._s
s[2] ^= s[0]
s[3] ^= s[1]
s[1] ^= s[2]
s[0] ^= s[3]
s[2] ^= t
s[3] = self._rotl(s[3], 45)
return result
def next_double(self) -> float:
return self.next() / float(1 << 64)
def next_int(self, low: int, high: int) -> int:
return int(self.next_double() * (high - low + 1)) + low
class _RandomAliasSampler:
"""Vose's alias method, built in the exact order of the reference code."""
def __init__(self, probs: Sequence[float]):
total = sum(probs)
assert total > 0
n = len(probs)
normalized = [p * float(n) / total for p in probs]
small: List[int] = []
large: List[int] = []
for i in range(n - 1, -1, -1):
(small if normalized[i] < 1 else large).append(i)
self._probs = [0] * n
self._aliases = [0] * n
while small and large:
a = small.pop()
g = large.pop()
self._probs[a] = normalized[a]
self._aliases[a] = g
normalized[g] += normalized[a] - 1
(small if normalized[g] < 1 else large).append(g)
while large:
self._probs[large.pop()] = 1
while small:
self._probs[small.pop()] = 1
def next(self, rng: _Xoshiro256) -> int:
r1 = rng.next_double()
r2 = rng.next_double()
n = len(self._probs)
i = int(float(n) * r1)
return i if r2 < self._probs[i] else self._aliases[i]
def choose_fragments(seq_num: int, seq_len: int, checksum: int) -> Set[int]:
"""The fragments mixed into a BC-UR v2 fountain part (reference seed math).
Sequence numbers ``1..seq_len`` emit the pure fragment ``{seq_num - 1}``;
every larger sequence number deterministically mixes a pseudo-random
subset of fragments seeded by ``SHA256(seq ‖ checksum)``.
"""
if seq_num <= seq_len:
return {seq_num - 1}
seed = seq_num.to_bytes(4, "big") + checksum.to_bytes(4, "big")
rng = _Xoshiro256(seed)
probs: List[float] = [1.0 / i for i in range(1, seq_len + 1)]
degree = _RandomAliasSampler(probs).next(rng) + 1
remaining = list(range(seq_len))
shuffled: List[int] = []
while remaining:
index = rng.next_int(0, len(remaining) - 1)
shuffled.append(remaining.pop(index))
return set(shuffled[:degree])
def _partition_message(message: bytes, fragment_len: int) -> List[bytes]:
fragments: List[bytes] = []
for offset in range(0, len(message), fragment_len):
fragment = message[offset : offset + fragment_len]
if len(fragment) < fragment_len:
fragment += b"\x00" * (fragment_len - len(fragment))
fragments.append(fragment)
return fragments
def _mix_fragments(fragments: Sequence[bytes], indexes: Set[int], fragment_len: int) -> bytes:
result = bytearray(fragment_len)
for index in indexes:
for i, byte in enumerate(fragments[index]):
result[i] ^= byte
return bytes(result)
# --------------------------------------------------------------------------- #
# BC-UR v2 (bytewords-minimal + fountain)
# --------------------------------------------------------------------------- #
def _ur2_header(seq_num: int, seq_len: int) -> str:
return "ur:bytes/{}-{}/".format(seq_num, seq_len)
def _ur2_part_string(seq_num: int, seq_len: int, message_len: int, checksum: int, data: bytes) -> str:
body = cbor_part(seq_num, seq_len, message_len, checksum, data)
return _ur2_header(seq_num, seq_len) + bytewords_minimal_encode(body)
def _ur2_part_cost(seq_num: int, seq_len: int, message_len: int, checksum: int, data_len: int) -> int:
body_len = len(cbor_part(seq_num, seq_len, message_len, checksum, b"\x00" * data_len))
# bytewords_minimal_encode appends a 4-byte CRC over the body.
return len(_ur2_header(seq_num, seq_len)) + 2 * (body_len + 4)
def ur2_frames(payload: bytes, budget_chars: int) -> List[str]:
"""Encode ``payload`` into BC-UR v2 fountain frames.
``budget_chars`` is the largest frame string the carrying QR code may
hold. The first ``seq_len`` frames are pure (one fragment each); a second
wave of ``seq_len`` mixed (fountain) frames follows so the receiver can
recover with a few parts still missing.
"""
message = cbor_byte_string(payload)
message_len = len(message)
checksum = crc32_int(message)
single_cost = len("ur:bytes/") + len(bytewords_minimal_encode(message))
if single_cost <= budget_chars:
return ["ur:bytes/" + bytewords_minimal_encode(message)]
fragment_len = message_len
fragment_count = 1
while True:
seq_len = fragment_count
worst_seq = 2 * seq_len # the export loop emits up to 2*seq_len parts
cost = _ur2_part_cost(worst_seq, seq_len, message_len, checksum, fragment_len)
if cost <= budget_chars:
break
fragment_count += 1
fragment_len = -(-message_len // fragment_count)
if fragment_count > message_len:
raise AnimatedQrError("QR budget too small for a BC-UR v2 part")
fragments = _partition_message(message, fragment_len)
frames: List[str] = []
for seq_num in range(1, seq_len + 1):
frames.append(_ur2_part_string(seq_num, seq_len, message_len, checksum, fragments[seq_num - 1]))
for seq_num in range(seq_len + 1, 2 * seq_len + 1):
data = _mix_fragments(fragments, choose_fragments(seq_num, seq_len, checksum), fragment_len)
frames.append(_ur2_part_string(seq_num, seq_len, message_len, checksum, data))
return frames
def ur2_parse_part(frame_text: str) -> Tuple[int, int, int, int, bytes]:
"""Parse a BC-UR v2 part into ``(seq, seq_len, message_len, checksum, data)``."""
frame_text = frame_text.strip().lower()
prefix = "ur:bytes/"
if not frame_text.startswith(prefix):
raise AnimatedQrError("not a BC-UR v2 part")
tail = frame_text[len(prefix) :]
if "/" not in tail:
body = bytewords_minimal_decode(tail)
return 1, 1, len(body), crc32_int(body), body
seq_head, words = tail.split("/", 1)
try:
seq_num_s, seq_len_s = seq_head.split("-", 1)
seq_num, seq_len = int(seq_num_s), int(seq_len_s)
except ValueError:
raise AnimatedQrError("bad BC-UR v2 sequence header") from None
if seq_len < 1 or not 1 <= seq_num <= 2**32 - 1:
raise AnimatedQrError("bad BC-UR v2 sequence numbers")
body = bytewords_minimal_decode(words)
arr, pos = _cbor_read_array(body, 0)
if arr != 5:
raise AnimatedQrError("bad BC-UR v2 part header arity")
seq_again, pos = _cbor_read_unsigned(body, pos)
seq_len_again, pos = _cbor_read_unsigned(body, pos)
message_len, pos = _cbor_read_unsigned(body, pos)
checksum, pos = _cbor_read_unsigned(body, pos)
data, pos = _cbor_read_bytes(body, pos)
if pos != len(body):
raise AnimatedQrError("trailing garbage in BC-UR v2 part header")
if seq_again != seq_num or seq_len_again != seq_len:
raise AnimatedQrError("BC-UR v2 part header mismatch")
return seq_num, seq_len, message_len, checksum, bytes(data)
# --------------------------------------------------------------------------- #
# BC-UR v1 (BCR-2020-005 rev1: BC32 fragments + SHA-256 digest)
# --------------------------------------------------------------------------- #
def _ur1_digest(message: bytes) -> str:
return bc32_encode(hashlib.sha256(message).digest())
def _ur1_prefix(index: int, total: int, digest: str) -> str:
return "ur:bytes/{}{}/{}/".format(
index, "of{}".format(total), digest
)
def ur1_frames(payload: bytes, budget_chars: int) -> List[str]:
"""Encode ``payload`` into BC-UR v1 fragments (``NofM`` + BC32 + digest)."""
message = cbor_byte_string(payload)
digest = _ur1_digest(message)
full = bc32_encode(message)
total = 1
while True:
longest = _ur1_prefix(total, total, digest)
capacity = budget_chars - len(longest)
if capacity < 1:
raise AnimatedQrError("QR budget too small for BC-UR v1")
if len(full) <= capacity * total:
break
total += 1
if total > _MAX_SESSION_PARTS:
raise AnimatedQrError("BC-UR v1 transfer demands too many parts")
frames: List[str] = []
pos = 0
for index in range(1, total + 1):
prefix = _ur1_prefix(index, total, digest)
capacity = budget_chars - len(prefix)
frames.append(prefix + full[pos : pos + capacity])
pos += capacity
return frames
def ur1_parse_part(frame_text: str) -> Tuple[int, int, str, str]:
"""Parse a BC-UR v1 part into ``(index, total, digest, fragment)``.
Accepts both the multipart form (``ur:bytes/NofM/<digest>/<frag>``) and
the single-part form (``ur:bytes/<frag>``, no sequence header or digest).
"""
frame_text = frame_text.strip().lower()
prefix = "ur:bytes/"
if not frame_text.startswith(prefix):
raise AnimatedQrError("not a BC-UR v1 part")
tail = frame_text[len(prefix) :]
parts = tail.split("/")
if len(parts) == 1:
return 1, 1, "", parts[0]
if len(parts) != 3:
raise AnimatedQrError("bad BC-UR v1 part structure")
seq_head, digest, fragment = parts
if "of" not in seq_head:
raise AnimatedQrError("BC-UR v1 part misses the sequence header")
try:
index_s, total_s = seq_head.split("of", 1)
index, total = int(index_s), int(total_s)
except ValueError:
raise AnimatedQrError("bad BC-UR v1 sequence header") from None
if total < 1 or not 1 <= index <= total:
raise AnimatedQrError("bad BC-UR v1 sequence numbers")
if len(digest) != 58:
raise AnimatedQrError("bad BC-UR v1 digest")
return index, total, digest, fragment
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# BBQR (Coinkite) # BBQR (Coinkite)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -164,6 +772,9 @@ def _bbqr_decode(encoded_parts: Sequence[str], encoding: str) -> bytes:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
FORMAT_LABELS = { FORMAT_LABELS = {
"balqr": "BAL QR",
"ur1": "BC-UR v1",
"ur2": "BC-UR v2",
"bbqr": "BBQR", "bbqr": "BBQR",
} }
@@ -178,8 +789,34 @@ def detect_format(text: str) -> Optional[str]:
text = text.strip() text = text.strip()
if not text: if not text:
return None return None
lowered = text.lower()
if lowered.startswith("balqr"):
return "balqr"
if text.startswith(_BBQR_PREFIX): if text.startswith(_BBQR_PREFIX):
return "bbqr" return "bbqr"
if not lowered.startswith("ur:"):
return None
if lowered.startswith("ur:bytes/"):
remainder = lowered[len("ur:bytes/") :]
first = remainder.split("/", 1)[0]
if "of" in first:
return "ur1"
if "-" in first:
return "ur2"
# Single-part: the whole remainder is the body. Prefer a bytewords v2
# body (CBOR byte-string head 0x40..0x60), then BC32 v1.
try:
body = bytewords_minimal_decode(remainder)
except AnimatedQrError:
pass
else:
if body and 0x40 <= body[0] <= 0x60:
return "ur2"
try:
bc32_decode(remainder)
except AnimatedQrError:
return None
return "ur1"
return None return None
@@ -191,16 +828,253 @@ def parse_for_detection(text: str) -> Tuple[str, str, int, int]:
:class:`AnimatedQrError` when the text cannot be parsed. :class:`AnimatedQrError` when the text cannot be parsed.
""" """
fmt = detect_format(text) fmt = detect_format(text)
if fmt == "balqr":
total, index, _compressed, _payload = _parse_balqr(text)
return "balqr", "balqr:{}".format(total), total, index
if fmt == "ur1":
index, total, digest, _frag = ur1_parse_part(text)
return "ur1", "ur1:{}".format(digest), total, index
if fmt == "ur2":
seq, seq_len, message_len, checksum, _data = ur2_parse_part(text)
return "ur2", "ur2:{}-{}-{}".format(seq_len, message_len, checksum), seq_len, seq
if fmt == "bbqr": if fmt == "bbqr":
encoding, type_code, total, index, _payload = bbqr_parse_part(text) encoding, type_code, total, index, _payload = bbqr_parse_part(text)
return "bbqr", "bbqr:{}{}:{}".format(encoding, type_code, total), total, index return "bbqr", "bbqr:{}{}:{}".format(encoding, type_code, total), total, index
raise FormatNotDetectedError("Not a supported QR transfer format") raise FormatNotDetectedError("Not a supported QR transfer format")
def _parse_balqr(text: str) -> Tuple[int, int, bool, str]:
from bal.core.qrtransfer import parse_frame
return parse_frame(text)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Receive session (order-independent assembly) # Receive sessions (order-independent assembly per format)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
class _BalQrSession:
def __init__(self):
self._frames: Dict[int, str] = {}
self._total = 0
self._compressed = False
@property
def total(self) -> int:
return self._total
@property
def received(self) -> int:
return len(self._frames)
@property
def done(self) -> bool:
return bool(self._total) and len(self._frames) >= self._total
def add(self, text: str) -> str:
total, index, compressed, payload = _parse_balqr(text)
if self._total and total != self._total:
raise TransferConflictError("BAL QR transfer total changed")
if len(self._frames) >= _MAX_SESSION_PARTS:
raise SessionLimitError("too many BAL QR frames")
if not self._total:
self._total = total
self._compressed = compressed
if index in self._frames:
return "dup"
self._frames[index] = payload
return "ok"
def resolve(self) -> Tuple[str, bool]:
from bal.core.qrtransfer import assemble
text = assemble(self._frames, self._total)
return text, self._compressed
class _Ur1Session:
def __init__(self):
self._total = 0
self._digest = ""
self._fragments: Dict[int, str] = {}
@property
def total(self) -> int:
return self._total
@property
def received(self) -> int:
return len(self._fragments)
@property
def done(self) -> bool:
return bool(self._total) and len(self._fragments) >= self._total
def add(self, text: str) -> str:
index, total, digest, fragment = ur1_parse_part(text)
if self._total:
if total != self._total or digest != self._digest:
raise TransferConflictError("BC-UR v1 transfer digest changed")
else:
self._total = total
self._digest = digest
if total > _MAX_SESSION_PARTS:
raise SessionLimitError("BC-UR v1 demands too many parts")
if index in self._fragments:
return "dup"
self._fragments[index] = fragment
return "ok"
def resolve(self) -> Tuple[str, bool]:
full = "".join(self._fragments[i] for i in range(1, self._total + 1))
try:
message = bc32_decode(full)
except AnimatedQrError:
raise ChecksumError("BC-UR v1 checksum mismatch") from None
if self._digest and _ur1_digest(message) != self._digest:
raise ChecksumError("BC-UR v1 digest mismatch")
return _transfer_text(unwrap_ur_cbor(message)), False
class _Ur2Session:
"""Fountain decoder mirroring the reference (C++/python) semantics."""
def __init__(self):
self._seq_len = 0
self._message_len = 0
self._checksum = 0
self._fragment_len = 0
self._received: Set[int] = set()
self._simple: Dict[FrozenSet[int], bytes] = {}
self._mixed: Dict[FrozenSet[int], bytes] = {}
self._queue: List[Tuple[FrozenSet[int], bytes]] = []
self._processed = 0
self._result: Optional[bytes] = None
self._bad = False
@property
def total(self) -> int:
return self._seq_len
@property
def received(self) -> int:
return self._processed
@property
def done(self) -> bool:
return self._result is not None
def add(self, text: str) -> str:
seq, seq_len, message_len, checksum, data = ur2_parse_part(text)
if self._seq_len:
if not self._validate(seq_len, message_len, checksum, len(data)):
raise TransferConflictError("BC-UR v2 transfer header changed")
else:
self._seq_len = seq_len
self._message_len = message_len
self._checksum = checksum
self._fragment_len = len(data)
if seq_len > _MAX_SESSION_PARTS or message_len > _MAX_MESSAGE_BYTES:
raise SessionLimitError("BC-UR v2 session exceeds safety caps")
indexes = frozenset(choose_fragments(seq, self._seq_len, self._checksum))
self._receive(indexes, bytes(data))
return "ok"
def _validate(self, seq_len: int, message_len: int, checksum: int, data_len: int) -> bool:
return (
seq_len == self._seq_len
and message_len == self._message_len
and checksum == self._checksum
and data_len == self._fragment_len
)
def _receive(self, indexes: FrozenSet[int], data: bytes) -> None:
if self._result is not None or self._bad:
return
self._queue.append((indexes, data))
while self._result is None and not self._bad and self._queue:
self._process(self._queue.pop(0))
self._processed += 1
def _process(self, item: Tuple[FrozenSet[int], bytes]) -> None:
indexes, data = item
if len(indexes) == 1:
self._process_simple(indexes, data)
else:
self._process_mixed(indexes, data)
def _process_simple(self, indexes: FrozenSet[int], data: bytes) -> None:
fragment_index = next(iter(indexes))
if fragment_index in self._received:
return
self._simple[indexes] = data
self._received.add(fragment_index)
if self._received == set(range(self._seq_len)):
self._finish()
return
self._reduce_mixed_by(indexes, data)
def _reduce_mixed_by(self, indexes: FrozenSet[int], data: bytes) -> None:
new_mixed: Dict[FrozenSet[int], bytes] = {}
for other_indexes, other_data in self._mixed.items():
reduced = self._reduce_part(other_indexes, other_data, indexes, data)
if len(reduced[0]) == 1:
self._queue.append(reduced)
else:
new_mixed[reduced[0]] = reduced[1]
self._mixed = new_mixed
def _process_mixed(self, indexes: FrozenSet[int], data: bytes) -> None:
if indexes in self._mixed:
return
reduced_indexes, reduced_data = indexes, data
for simple_indexes, simple_data in self._simple.items():
reduced_indexes, reduced_data = self._reduce_part(
reduced_indexes, reduced_data, simple_indexes, simple_data
)
for other_indexes, other_data in list(self._mixed.items()):
reduced_indexes, reduced_data = self._reduce_part(
reduced_indexes, reduced_data, other_indexes, other_data
)
if len(reduced_indexes) == 1:
self._queue.append((reduced_indexes, reduced_data))
else:
self._reduce_mixed_by(reduced_indexes, reduced_data)
if reduced_indexes not in self._mixed:
self._mixed[reduced_indexes] = reduced_data
@staticmethod
def _reduce_part(
a_indexes: FrozenSet[int], a_data: bytes, b_indexes: FrozenSet[int], b_data: bytes
) -> Tuple[FrozenSet[int], bytes]:
if b_indexes == a_indexes or not b_indexes.issubset(a_indexes):
return a_indexes, a_data
new_indexes = a_indexes - b_indexes
new_data = bytes(x ^ y for x, y in zip(a_data, b_data, strict=True))
return new_indexes, new_data
def _finish(self) -> None:
fragments = []
for index in range(self._seq_len):
key = frozenset([index])
if key not in self._simple:
self._bad = True
return
fragments.append(self._simple[key])
message = b"".join(fragments)[: self._message_len]
if crc32_int(message) != self._checksum:
self._bad = True
return
self._result = message
def resolve(self) -> Tuple[str, bool]:
if self._bad:
raise ChecksumError("BC-UR v2 message checksum mismatch")
if self._result is None:
raise AnimatedQrError("BC-UR v2 session is not complete")
return _transfer_text(unwrap_ur_cbor(self._result)), False
class _BbqrSession: class _BbqrSession:
def __init__(self): def __init__(self):
self._total = 0 self._total = 0
@@ -250,6 +1124,8 @@ def _transfer_text(raw: bytes) -> str:
class AnimatedQrSession: class AnimatedQrSession:
"""Facade over the per-format receive sessions used by the QR import page.""" """Facade over the per-format receive sessions used by the QR import page."""
_DIALECTS = (("balqr", "_BalQrSession"), ("ur1", "_Ur1Session"), ("ur2", "_Ur2Session"), ("bbqr", "_BbqrSession"))
def __init__(self): def __init__(self):
self._inner: Optional[object] = None self._inner: Optional[object] = None
self._fmt: Optional[str] = None self._fmt: Optional[str] = None
@@ -274,6 +1150,12 @@ class AnimatedQrSession:
@staticmethod @staticmethod
def _make(fmt: str) -> object: def _make(fmt: str) -> object:
if fmt == "balqr":
return _BalQrSession()
if fmt == "ur1":
return _Ur1Session()
if fmt == "ur2":
return _Ur2Session()
if fmt == "bbqr": if fmt == "bbqr":
return _BbqrSession() return _BbqrSession()
raise AssertionError("unknown animated-QR format {}".format(fmt)) raise AssertionError("unknown animated-QR format {}".format(fmt))

View File

@@ -6,21 +6,8 @@ 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.
A RELATIVE Check Alive value is anchored according to its SIGN: Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the
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
@@ -32,114 +19,13 @@ 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, reason: str | None = None): def __init__(self, timestamp_to_check):
self.timestamp_to_check = timestamp_to_check self.timestamp_to_check = timestamp_to_check
self.reason = reason
def __str__(self): def __str__(self):
message = "Check alive expired please update it: {}".format( return "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(
@@ -147,7 +33,6 @@ 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.
@@ -156,11 +41,22 @@ 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*. govern those checks. ``date_to_check`` is set to *now*: every check is
* ADVANCED mode: an ABSOLUTE stored threshold is returned unchanged; a evaluated against the current moment (the Check Alive effectively does not
RELATIVE one is resolved by :func:`resolve_relative_check_alive_date` exist) while the delivery locktime is still fully enforced.
(negative -> anchored to the delivery, positive -> anchored to the * ADVANCED mode: the user-controlled stored threshold is used as-is. An
inheritance creation date). ABSOLUTE threshold is returned unchanged; a RELATIVE one (``"30d"``/``"1y"``)
means "N days BEFORE the delivery date" and is resolved against the stored
locktime (matching the date the settings widget displays), so it stays in
lockstep with the built transactions instead of drifting with the clock.
A RELATIVE stored locktime is resolved against the frozen delivery date of
the built will (``built_locktime``, the locktime inside the signed tx) when
one exists: the will's real delivery date is authoritative, and resolving
the relative locktime from *now* would drift ``date_to_check`` past the
frozen tx locktime so an unchanged will wrongly reads as expired (asking to
invalidate) every day. Without a built will the legacy forward-from-now
resolution is kept.
Args: Args:
is_basic_mode: ``True`` for the SIMPLE / BASIC user type. is_basic_mode: ``True`` for the SIMPLE / BASIC user type.
@@ -169,8 +65,6 @@ 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).
@@ -179,113 +73,39 @@ 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"])
if threshold.unit is None: # A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery":
return threshold.to_timestamp() # the settings widget resolves it as real_threshold = locktime - N days.
delivery_date = resolve_delivery_date( # Resolving it FORWARD from now (BalTimestamp.to_timestamp) turns
will_settings, now=now, built_locktime=built_locktime # date_to_check into a moving target that disagrees with the fixed
) # locktime of the built transactions (and with the date shown in the UI),
return resolve_relative_check_alive_date( # which can wrongly mark the will as expired/postponed. Resolve it
will_settings["threshold"], # against the delivery date instead.
delivery_date, if threshold.unit is not None:
creation_date=creation_date, locktime_raw = will_settings.get("locktime")
now=now, if locktime_raw is None:
) # No delivery reference to anchor to: fall back to the legacy
# forward-from-now resolution.
return threshold.to_timestamp()
locktime_dt = BalTimestamp(locktime_raw).to_date(now)
# A RELATIVE stored locktime ("2y") is itself a moving target; when a
# will has already been built, its frozen delivery date (the tx
# locktime) is the authoritative anchor (see docstring).
if BalTimestamp(locktime_raw).unit is not None and built_locktime:
locktime_dt = BalTimestamp(int(built_locktime)).to_date(now)
return threshold.to_date(locktime_dt, reverse=True).timestamp()
return threshold.to_timestamp()
def resolve_guard_threshold( def check_alive_expired(
is_basic_mode: bool, is_basic_mode: bool, date_to_check: float, now: float | None = None
will_settings: Any,
now: float | None = None,
creation_date: float | None = None,
) -> float | None:
"""Resolve the "locktime is lower than threshold" guard's reference.
The guard compares the stored settings on ONE reference frame: the
delivery (``locktime``, kept as at the call site) against this threshold.
Unlike :func:`resolve_date_to_check` -- which may be *anchored* to the
built will's frozen tx locktime so that an unchanged will never reads as
expired -- this helper resolves the threshold from the **stored settings
alone** (no ``built_locktime``). Otherwise, when the stored relative
locktime is shorter than the frozen locktime of an old (still valid) built
will (e.g. the delivery was shortened from ``"2y"`` to ``"1y"``), the guard
would compare the fresh "1y" locktime against the old will's anchored
threshold and wrongly fire, even though locktime > threshold by the
settings themselves.
* BASIC mode: no threshold exists. Returns ``None`` and the caller falls
back to comparing the locktime against ``date_to_check`` (= now), so its
behaviour is unchanged.
* ADVANCED mode with an ABSOLUTE threshold: returns the stored threshold
as-is.
* ADVANCED mode with a RELATIVE threshold: resolved by
:func:`resolve_relative_check_alive_date` (negative -> delivery anchor,
positive -> inheritance creation date), using *now* for the delivery
reading and never a built tx.
Returns ``None`` when there is no threshold to enforce (BASIC mode or a
missing stored value).
"""
if is_basic_mode:
return None
threshold_raw = will_settings.get("threshold")
if threshold_raw is None:
return None
threshold = BalTimestamp(threshold_raw)
if threshold.unit is None:
return threshold.to_timestamp()
delivery_date = resolve_delivery_date(will_settings, now=now, built_locktime=None)
return resolve_relative_check_alive_date(
threshold_raw,
delivery_date,
creation_date=creation_date,
now=now,
)
def is_check_alive_date_valid(
date_to_check: float | None,
delivery_date: float | None,
now: float | None = None,
) -> bool: ) -> bool:
"""True when the Check Alive date is inside ``(now, delivery_date)``. """True when the Check Alive guard should fire (``CheckAliveError``).
The single validity rule for the Check Alive reference: it must be AFTER Only ADVANCED mode can be "expired": in BASIC mode the Check Alive is inert
today and BEFORE the delivery. A ``None`` ``delivery_date`` skips the by construction, so a passed check-alive date must never force a postpone or
upper bound (no delivery is known yet). rewrite of the will.
"""
if date_to_check is None:
return False
current = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
if date_to_check < current:
return False
if delivery_date is not None and date_to_check >= delivery_date:
return False
return True
def ensure_check_alive_valid(
is_basic_mode: bool,
date_to_check: float | None,
delivery_date: float | None = None,
now: float | None = None,
) -> None:
"""Raise when the Check Alive date is not valid.
Only ADVANCED mode can be invalid: in BASIC mode the Check Alive is inert
by construction, so a passed check-alive date must never force a postpone
or rewrite of the will.
Raises:
CheckAliveError: the date is in the past.
CheckAliveAfterDeliveryError: the date is on/after the delivery.
""" """
if is_basic_mode: if is_basic_mode:
return return False
if date_to_check is None:
return
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()
if date_to_check < current: return 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)

View File

@@ -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, allow_negative: bool = False text: str, pos: int
) -> Tuple[str, bool, bool, int]: ) -> Tuple[str, bool, bool, int]:
"""Sanitise the RAW locktime field text. """Sanitise the RAW locktime field text.
@@ -117,9 +117,6 @@ 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
@@ -127,17 +124,11 @@ 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")
@@ -150,10 +141,6 @@ 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

View File

@@ -32,8 +32,6 @@ 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__)
@@ -136,20 +134,12 @@ 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, translatable=False): def __init__(self, config, name, default):
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``."""
@@ -159,20 +149,10 @@ 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)
@@ -263,10 +243,6 @@ 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",
@@ -305,10 +281,11 @@ 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, # QR_CHUNK_SIZE (will transfer via QR): payload budget, in bytes, used
# used per QR frame when exporting/importing a will through the QR # per QR frame when exporting/importing a will through the QR channel.
# channel. The export dialog seeds its own size selector from this # The settings dialog offers the 4 standard presets of
# value (default 150, small QR, low-resolution cameras). # bal.core.qrtransfer.CHUNK_PRESETS; this stores the selected budget.
# Default 150 (small QR, low-resolution cameras).
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150) 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
@@ -351,20 +328,13 @@ 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",
N_("BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete"), "BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete",
translatable=True,
) )
self.EVENT_SUMMARY = BalConfig( self.EVENT_SUMMARY = BalConfig(
config, config, "bal_event_summary", "BAL -Will execution of $wallet_name"
"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
@@ -497,35 +467,22 @@ class BalPlugin(BasePlugin):
@staticmethod @staticmethod
def default_will_settings_absolute(): def default_will_settings_absolute():
"""Convert the default relative dates into absolute timestamps. """Convert the default relative dates into absolute timestamps (from today)."""
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)
locktime_dt = dt + timedelta( threshold = (
days=BalTimestamp(relative_dates["locktime"]).duration_to_days() dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days())
) ).timestamp()
threshold = BalTimestamp(relative_dates["threshold"]) locktime = (
if threshold.unit is not None and threshold.value < 0: dt + timedelta(days=BalTimestamp(relative_dates["locktime"]).duration_to_days())
anchor_dt = locktime_dt ).timestamp()
else: return {"threshold": threshold, "locktime": locktime}
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 BEFORE the delivery, 1 year locktime. """Default relative dates: 30 days threshold, 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:

View File

@@ -9,11 +9,13 @@ Scope
----- -----
* converts will transactions into a compact ``transfer_string`` * converts will transactions into a compact ``transfer_string``
(newline-joined serialized transactions, optionally zlib + base64 (newline-joined serialized transactions, optionally zlib + base64
compressed). The transfer string is the payload carried by BBQR QR frames compressed);
(``bal.core.animated_qr``). * splits that string into fixed-size ``BALQR1|N|i|flags|payload`` frames for
multi-QR export, and reassembles/validates them on import.
The audio-modem channel carries the whole transfer string in a single blob The audio-modem channel deliberately bypasses the framing helpers here
(PLAN_QR_TRANSFER.md section 4.4), so callers only use (PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and
carries the whole transfer string in a single blob, so callers only use
:func:`encode_transfer` / :func:`decode_transfer`. :func:`encode_transfer` / :func:`decode_transfer`.
This module never imports Qt or any Electrum GUI code (house rule). This module never imports Qt or any Electrum GUI code (house rule).
@@ -24,7 +26,11 @@ from __future__ import annotations
import base64 import base64
import zlib import zlib
# 4 standard presets (label, payload budget per QR). Ordered from MAGIC = "BALQR"
VERSION = 1
FLAG_COMPRESSED = "Z"
# 4 standard presets (label, payload budget in bytes per QR). Ordered from
# low-resolution cameras to high-resolution cameras (owner decision D5). # low-resolution cameras to high-resolution cameras (owner decision D5).
CHUNK_PRESETS = ( CHUNK_PRESETS = (
("Small - ~150 bytes/QR (low-res cameras)", 150), ("Small - ~150 bytes/QR (low-res cameras)", 150),
@@ -33,12 +39,35 @@ CHUNK_PRESETS = (
("XL - ~1800 bytes/QR (high-res cameras)", 1800), ("XL - ~1800 bytes/QR (high-res cameras)", 1800),
) )
# Smallest allowed payload budget per frame, below which the frame header
# could consume the whole budget.
MIN_CHUNK_SIZE = 40
_FRAME_MAGIC = MAGIC + str(VERSION)
class QrTransferError(ValueError):
"""Base error for will QR / audio transfer processing."""
class MissingFramesError(QrTransferError):
"""Some frame indices of a multi-QR transfer are missing."""
def __init__(self, missing):
self.missing = list(missing)
super().__init__("Missing QR frames: {}".format(self.missing))
class InconsistentTotalError(QrTransferError):
"""Frames disagree about the advertised frame total."""
def encode_transfer(tx_strings, compress=False): def encode_transfer(tx_strings, compress=False):
"""Join serialized transaction strings into a transfer string. """Join serialized transaction strings into a transfer string.
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so ``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
the whole bundle shrinks before being printed/scanned. the whole bundle shrinks before being printed/scanned. The optional flags
of the frame header let the importer reverse this automatically.
""" """
return __compress("\n".join(tx_strings), enabled=compress) return __compress("\n".join(tx_strings), enabled=compress)
@@ -54,6 +83,78 @@ def decode_transfer(transfer_string, compressed):
return [part for part in text.split("\n") if part] return [part for part in text.split("\n") if part]
def split_frames(transfer_string, chunk_size, compressed=False):
"""Split ``transfer_string`` into full ``BALQR`` frames.
Every returned frame is at most ``chunk_size`` characters long (header
included). ``compressed`` propagates the ``Z`` flag into every frame so
the importer knows how to reverse the encoding.
Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold
the header plus any payload.
"""
flags = FLAG_COMPRESSED if compressed else ""
total = __compute_total(len(transfer_string), chunk_size, flags)
frames = []
pos = 0
length = len(transfer_string)
for index in range(1, total + 1):
overhead = len(__frame_header(total, index, flags))
budget = chunk_size - overhead
end = min(pos + budget, length)
frames.append(__build_frame(total, index, flags, transfer_string[pos:end]))
pos = end
if pos >= length:
break
if pos < length:
# __compute_total guarantees this cannot happen; keep a safety net.
raise QrTransferError("internal error: frames did not cover the transfer string")
return frames
def parse_frame(frame):
"""Parse a single frame.
Returns ``(total, index, compressed: bool, payload: str)``. Raises
:class:`QrTransferError` on malformed input (bad magic/version, wrong
arity, non-integer or out-of-range frame numbers, unknown flags).
"""
parts = frame.split("|", maxsplit=4)
if len(parts) != 5:
raise QrTransferError("Not a BAL will QR (bad frame structure)")
magic_seen, total_s, index_s, flags, payload = parts
if magic_seen != _FRAME_MAGIC:
raise QrTransferError("Not a BAL will QR (unknown magic/version)")
try:
total = int(total_s)
index = int(index_s)
except ValueError as e:
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e
if total < 1 or not 1 <= index <= total:
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
if flags not in ("", FLAG_COMPRESSED):
raise QrTransferError("Not a BAL will QR (unknown flags)")
return total, index, flags == FLAG_COMPRESSED, payload
def assemble(frames, total):
"""Concatenate frame payloads back into a transfer string.
``frames`` maps 1-based index -> payload. Every index ``1..total`` must
be present (else :class:`MissingFramesError`) and no index may exceed
``total`` (else :class:`InconsistentTotalError`).
"""
if total < 1:
raise QrTransferError("invalid frame total")
missing = [index for index in range(1, total + 1) if index not in frames]
if missing:
raise MissingFramesError(missing)
extra = [index for index in frames if index > total]
if extra:
raise InconsistentTotalError()
return "".join(frames[index] for index in range(1, total + 1))
def preset_index_for_chunk_size(chunk_size): def preset_index_for_chunk_size(chunk_size):
"""Return the :data:`CHUNK_PRESETS` index whose budget best matches a size.""" """Return the :data:`CHUNK_PRESETS` index whose budget best matches a size."""
best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1]) best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1])
@@ -78,3 +179,34 @@ def __decompress(text, *, enabled):
if not enabled: if not enabled:
return text return text
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8") return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
def __frame_header(total, index, flags):
return "{}|{}|{}|{}|".format(_FRAME_MAGIC, total, index, flags)
def __build_frame(total, index, flags, payload):
return __frame_header(total, index, flags) + payload
def __compute_total(transfer_len, chunk_size, flags):
"""Smallest frame count whose budget covers the whole transfer string.
The budget shrinks as ``total`` gains digits (wider header), so the count
is recomputed iteratively until it converges.
"""
if chunk_size < MIN_CHUNK_SIZE:
raise QrTransferError(
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
)
total = 1
while True:
overhead = len(__frame_header(total, total, flags))
budget = chunk_size - overhead
if budget <= 0:
raise QrTransferError(
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
)
if transfer_len <= budget * total:
return total
total += 1

View File

@@ -147,7 +147,6 @@ 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.
@@ -170,10 +169,6 @@ 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
@@ -215,9 +210,7 @@ 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( event_summary = ical_escape(f"{summary_base} (reminder {idx}/{total})")
"{} {}".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).

View File

@@ -28,7 +28,7 @@ The status flags themselves (the source of truth) stay here; only the mapping
from datetime import datetime, timezone from datetime import datetime, timezone
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL from electrum.i18n import _
from electrum.logging import Logger, get_logger from electrum.logging import Logger, get_logger
from electrum.transaction import ( from electrum.transaction import (
PartialTransaction, PartialTransaction,
@@ -43,7 +43,6 @@ from electrum.util import (
bfh, bfh,
) )
from ..i18n import N_, _
from .heirs import WillExecutorFeeTooHighException from .heirs import WillExecutorFeeTooHighException
from .util import Util, copy_structure from .util import Util, copy_structure
from .willexecutors import Willexecutors from .willexecutors import Willexecutors
@@ -848,48 +847,6 @@ 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``.
@@ -972,25 +929,6 @@ 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
@@ -1275,9 +1213,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:
@@ -1292,34 +1230,6 @@ 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
@@ -1333,68 +1243,28 @@ 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": [N_("Anticipated"), False], "ANTICIPATED": ["Anticipated", False],
"BROADCASTED": [N_("Broadcasted"), False], "BROADCASTED": ["Broadcasted", False],
"CHECKED": [N_("Checked"), False], "CHECKED": ["Checked", False],
"CHECK_FAIL": [N_("Check Failed"), False], "CHECK_FAIL": ["Check Failed", False],
"COMPLETE": [N_("Signed"), False], "COMPLETE": ["Signed", False],
"CONFIRMED": [N_("Confirmed"), False], "CONFIRMED": ["Confirmed", False],
"ERROR": [N_("Error"), False], "ERROR": ["Error", False],
"EXPIRED": [N_("Expired"), False], "EXPIRED": ["Expired", False],
"EXPORTED": [N_("Exported"), False], "EXPORTED": ["Exported", False],
"IMPORTED": [N_("Imported"), False], "IMPORTED": ["Imported", False],
"INVALIDATED": [N_("Invalidated"), False], "INVALIDATED": ["Invalidated", False],
"MEMPOOL": [N_("Mempool"), False], "MEMPOOL": ["Mempool", False],
"PUSH_FAIL": [N_("Push failed"), False], "PUSH_FAIL": ["Push failed", False],
"PUSHED": [N_("Pushed"), False], "PUSHED": ["Pushed", False],
"PARTIALLY_SIGNED": [N_("Partially Signed"), False], "PARTIALLY_SIGNED": ["Partially Signed", False],
"REPLACED": [N_("Replaced"), False], "REPLACED": ["Replaced", False],
"RESTORED": [N_("Restored"), False], "RESTORED": ["Restored", False],
"UPDATED": [N_("Updated"), False], "UPDATED": ["Updated", False],
"VALID": [N_("Valid"), True], "VALID": ["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.
@@ -1428,11 +1298,7 @@ class WillItem(Logger):
if self.STATUS[status][1] == bool(value): if self.STATUS[status][1] == bool(value):
return None return None
# The history is saved in the wallet, in exported files and in QR self.status += "." + (("NOT " if not value else "") + _(self.STATUS[status][0]))
# 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,
@@ -1476,10 +1342,8 @@ class WillItem(Logger):
w = data w = data
self.tx = Will.get_tx_from_any(w["tx"]) self.tx = Will.get_tx_from_any(w["tx"])
self.heirs = w.get("heirs", None) self.heirs = w.get("heirs", None)
self.we = WillItem._without_transient_push_fields( self.we = w.get("willexecutor", None)
w.get("willexecutor", None) self.status = w.get("status") or ""
)
self.status = WillItem._untranslate_new_status(w.get("status") or "")
self.description = w.get("description", None) self.description = w.get("description", None)
self.time = w.get("time", None) self.time = w.get("time", None)
self.change = w.get("change", None) self.change = w.get("change", None)

View File

@@ -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,13 +231,6 @@ 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"):
@@ -247,18 +240,10 @@ 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:
# Work on a COPY of the record: the item's willexecutor["txs"] = ""
# own ``we`` dict is saved in the wallet, so willexecutor["txsids"] = []
# the transient push fields below (raw txs, willexecutor["broadcast_status"] = _("Waiting...")
# txids and the - translated - broadcast willexecutors[url] = willexecutor
# 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)

View File

@@ -52,6 +52,7 @@ 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
@@ -136,7 +137,6 @@ 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,
@@ -144,9 +144,6 @@ 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,
@@ -164,17 +161,6 @@ 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
@@ -192,30 +178,13 @@ 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_):
"""Add a ``label | widget | help button`` row to ``grid``. grid.addWidget(QLabel(_(label)), row, 0)
``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.
@@ -253,7 +222,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
), ),
@@ -268,7 +237,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)))
) )

View File

@@ -43,25 +43,24 @@ from ...core.animated_qr import (
bbqr_frames, bbqr_frames,
format_name, format_name,
parse_for_detection, parse_for_detection,
ur1_frames,
ur2_frames,
) )
from ...core.checkalive import ( from ...core.checkalive import CheckAliveError, resolve_date_to_check
CheckAliveAfterDeliveryError,
CheckAliveError,
resolve_date_to_check,
resolve_relative_check_alive_date,
)
from ...core.qrtransfer import ( from ...core.qrtransfer import (
CHUNK_PRESETS, CHUNK_PRESETS,
MissingFramesError,
QrTransferError,
decode_transfer, decode_transfer,
encode_transfer, encode_transfer,
preset_index_for_chunk_size, preset_index_for_chunk_size,
split_frames,
) )
from ...core.reminders import build_ics_reminders from ...core.reminders import build_ics_reminders
from .calendar import BalCalendarButton from .calendar import BalCalendarButton
from .common import ( from .common import (
HEIR_DUST_AMOUNT, HEIR_DUST_AMOUNT,
HEIR_REAL_AMOUNT, HEIR_REAL_AMOUNT,
N_,
AmountException, AmountException,
Any, Any,
BalTimestamp, BalTimestamp,
@@ -141,6 +140,29 @@ if TYPE_CHECKING:
# imported lazily where needed to avoid a dialogs<->lists import cycle. # imported lazily where needed to avoid a dialogs<->lists import cycle.
# Animated-QR formats beyond the legacy BAL QR. Combined with the format
# selector in :class:`BalQrExportWidget` they give the export page
# interoperable BC-UR v1/v2 and BBQR output while keeping BAL QR as the
# default (and the only format understood by older plugin versions).
ANIMATED_QR_FORMATS = ("ur1", "ur2", "bbqr")
def encode_animated_frames(transfer, fmt, budget_chars):
"""Encode the transfer text into the given animated-QR format.
``transfer`` is the BAL QR transfer text (str). ``budget_chars`` is the
maximum length of one frame (the exporter's "QR code size" preset).
"""
payload = transfer.encode("utf-8")
if fmt == "ur1":
return ur1_frames(payload, budget_chars)
if fmt == "ur2":
return ur2_frames(payload, budget_chars)
if fmt == "bbqr":
return bbqr_frames(payload, budget_chars, encoding="Z")
raise QrTransferError("unknown animated QR format: {}".format(fmt))
class BalDialog(QDialog,MessageBoxMixin): class BalDialog(QDialog,MessageBoxMixin):
_stopping = False _stopping = False
def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"): def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"):
@@ -299,9 +321,7 @@ class BalWizardWidget(QWidget):
self._bal_parent = parent self._bal_parent = parent
self.on_next = on_next self.on_next = on_next
self.on_cancel = on_cancel self.on_cancel = on_cancel
# title/message are class attributes marked with N_(): translate them self.titleLabel = QLabel(self.title)
# here, when shown (a class body runs before the catalog is loaded).
self.titleLabel = QLabel(_(self.title) if self.title else "")
self.vbox.addWidget(self.titleLabel) self.vbox.addWidget(self.titleLabel)
self.messageLabel = QLabel(_(self.message)) self.messageLabel = QLabel(_(self.message))
self.vbox.addWidget(self.messageLabel) self.vbox.addWidget(self.messageLabel)
@@ -356,8 +376,8 @@ class BalWizardWidget(QWidget):
class BalWizardHeirsWidget(BalWizardWidget): class BalWizardHeirsWidget(BalWizardWidget):
title = N_("Bitcoin After Life Heirs") title = "Bitcoin After Life Heirs"
message = N_( message = (
"Please add your heirs\n remember that 100% of wallet balance will be spent" "Please add your heirs\n remember that 100% of wallet balance will be spent"
) )
@@ -395,18 +415,18 @@ class BalWizardHeirsWidget(BalWizardWidget):
class BalWizardWEDownloadWidget(BalWizardWidget): class BalWizardWEDownloadWidget(BalWizardWidget):
title = N_("Bitcoin After Life Will-Executors") title = _("Bitcoin After Life Will-Executors")
message = N_("Choose willexecutors download method") message = _("Choose willexecutors download method")
def get_content(self): def get_content(self):
# question = QLabel() # question = QLabel()
self.combo = QComboBox() self.combo = QComboBox()
self.combo.addItems( self.combo.addItems(
[ [
_("Automatically download and select willexecutors"), "Automatically download and select willexecutors",
_("Only download willexecutors list"), "Only download willexecutors list",
_("Import willexecutor list from file"), "Import willexecutor list from file",
_("Manual"), "Manual",
] ]
) )
# heir_name.setFixedWidth(32 * char_width_in_lineedit()) # heir_name.setFixedWidth(32 * char_width_in_lineedit())
@@ -503,8 +523,8 @@ class BalWizardWEDownloadWidget(BalWizardWidget):
class BalWizardWEWidget(BalWizardWidget): class BalWizardWEWidget(BalWizardWidget):
title = N_("Bitcoin After Life Will-Executors") title = "Bitcoin After Life Will-Executors"
message = N_("Configure and select your willexecutors") message = _("Configure and select your willexecutors")
def get_content(self): def get_content(self):
# Lazy import to avoid a dialogs<->lists import cycle. # Lazy import to avoid a dialogs<->lists import cycle.
@@ -523,8 +543,8 @@ class BalWizardWEWidget(BalWizardWidget):
class BalWizardLocktimeAndFeeWidget(BalWizardWidget): class BalWizardLocktimeAndFeeWidget(BalWizardWidget):
title = N_("Bitcoin After Life Will Settings") title = "Bitcoin After Life Will Settings"
message = "" message = _("")
def get_content(self): def get_content(self):
widget = QWidget() widget = QWidget()
@@ -727,16 +747,9 @@ class BalBuildWillDialog(BalDialog):
return return
txs = None txs = None
_logger.debug("close plugin phase 1 started") _logger.debug("close plugin phase 1 started")
varrow = self.msg_set_status(_("Checking variables")) varrow = self.msg_set_status("Checking variables")
try: try:
self.bal_window.init_class_variables() self.bal_window.init_class_variables()
except CheckAliveAfterDeliveryError:
self.msg_set_status(
"Checking variables", varrow,
"Check Alive must be after today and before the delivery date",
self.COLOR_ERROR,
)
return "check_alive_before_delivery", None
except CheckAliveError as cae: except CheckAliveError as cae:
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1) fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1)
tx = Will.invalidate_will( tx = Will.invalidate_will(
@@ -752,9 +765,9 @@ class BalBuildWillDialog(BalDialog):
"during phase1 CAE: {}, Continue to invalidate".format(cae) "during phase1 CAE: {}, Continue to invalidate".format(cae)
) )
self.msg_set_status( self.msg_set_status(
_("Checking variables"), varrow, "Checking variables", varrow,
_("Check Alive Threshold Passed: you have to Invalidate " "Check Alive Threshold Passed: you have to Invalidate "
"your old Will"), "your old Will",
self.COLOR_ERROR, self.COLOR_ERROR,
) )
else: else:
@@ -762,7 +775,7 @@ class BalBuildWillDialog(BalDialog):
return None, tx return None, tx
except NoHeirsException: except NoHeirsException:
self.msg_set_status( self.msg_set_status(
_("Checking variables"), varrow, self.msg_alert(_("No Heirs")) "Checking variables", varrow, self.msg_alert("No Heirs")
) )
return "no_heirs", None return "no_heirs", None
except Exception as e: except Exception as e:
@@ -785,20 +798,18 @@ class BalBuildWillDialog(BalDialog):
max_fee=self.bal_window.bal_plugin.MAX_WILLEXECUTOR_FEE.get(), max_fee=self.bal_window.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
) )
_logger.debug("variables ok") _logger.debug("variables ok")
self.msg_set_status(_("Checking variables"), varrow, _("Ok"), self.COLOR_OK) self.msg_set_status("Checking variables", varrow, "Ok", self.COLOR_OK)
except AmountException: except AmountException:
self.msg_set_checking( self.msg_set_checking(
self.msg_warning( self.msg_warning(
_( "In the inheritance process, "
"In the inheritance process, " + "the entire wallet will always be fully emptied. \n"
"the entire wallet will always be fully emptied. \n" + "Your settings require an adjustment of the amounts"
"Your settings require an adjustment of the amounts"
)
) )
) )
except WillExecutorFeeTooHighException as e: except WillExecutorFeeTooHighException as e:
self.msg_set_checking( self.msg_set_checking(
self.msg_warning(_("Will-executor fee too high: {}").format(e)) self.msg_warning(f"Will-executor fee too high: {e}")
) )
self.msg_set_checking() self.msg_set_checking()
@@ -821,7 +832,7 @@ class BalBuildWillDialog(BalDialog):
# makes the CHECK button and the WIZARD behave identically and fixes # makes the CHECK button and the WIZARD behave identically and fixes
# the missing-label bug (#03). # the missing-label bug (#03).
_logger.debug("expired") _logger.debug("expired")
self.msg_set_checking(_("Expired")) self.msg_set_checking("Expired")
return "invalidate_classic", None return "invalidate_classic", None
except WillPostponedException as e: except WillPostponedException as e:
# An already signed/sent will is being postponed. Like an expired # An already signed/sent will is being postponed. Like an expired
@@ -844,7 +855,7 @@ class BalBuildWillDialog(BalDialog):
) )
except NoHeirsException: except NoHeirsException:
_logger.debug("no heirs") _logger.debug("no heirs")
self.msg_set_checking(_("No Heirs")) self.msg_set_checking("No Heirs")
except NotCompleteWillException as e: except NotCompleteWillException as e:
_logger.debug(f"not complete {e} true") _logger.debug(f"not complete {e} true")
message = False message = False
@@ -1039,11 +1050,9 @@ class BalBuildWillDialog(BalDialog):
dust_heirs.setdefault(hid, heir[HEIR_DUST_AMOUNT]) dust_heirs.setdefault(hid, heir[HEIR_DUST_AMOUNT])
for hid, dust_amount in dust_heirs.items(): for hid, dust_amount in dust_heirs.items():
self.msg_set_status( self.msg_set_status(
_("Heir {}").format(hid), f"{_('Heir')} {hid}",
None, None,
_("{} is DUST - excluded (amount below dust limit)").format( f"{dust_amount} is DUST - excluded (amount below dust limit)",
dust_amount
),
self.COLOR_WARNING, self.COLOR_WARNING,
) )
@@ -1126,14 +1135,13 @@ class BalBuildWillDialog(BalDialog):
desired behaviour, and that the date shown in the panel/wizard must desired behaviour, and that the date shown in the panel/wizard must
reflect this anticipated date (so the calendar .ics also uses it). reflect this anticipated date (so the calendar .ics also uses it).
RELATIVE recipes ("30d"/"1y") are PRESERVED: they are resolved against RELATIVE dates are additionally normalised here: a relative value
the built will's frozen locktime on every check (via ("30d"/"1y") is re-parsed against "now" on every check, so it drifts
``Util.resolve_locktime_against_tx`` for the postpone detection and away from the fixed transaction locktime and the postpone check would
``resolve_date_to_check(..., built_locktime=...)`` for the reference wrongly ask to invalidate the will every day. The stored locktime is
timestamp), so they no longer drift away from the built transactions therefore frozen to the built transactions' absolute locktime, and a
and never trigger the daily invalidate prompt. Freezing them to an relative threshold is frozen to its "N days before the delivery"
absolute timestamp here would silently erase the user's relative absolute value.
choice from WILL_SETTINGS.
We route the update through BalWindow.update_setting_widgets, which is We route the update through BalWindow.update_setting_widgets, which is
the single place that (1) stores the value in WILL_SETTINGS, (2) the single place that (1) stores the value in WILL_SETTINGS, (2)
@@ -1148,47 +1156,72 @@ class BalBuildWillDialog(BalDialog):
return return
min_locktime = int(min_locktime) min_locktime = int(min_locktime)
stored_locktime = self.bal_window.will_settings["locktime"] stored_locktime = self.bal_window.will_settings["locktime"]
# A RELATIVE stored value ("30d"/"1y") is PRESERVED: it is resolved # A relative value ("30d"/"1y") is a MOVING TARGET: it is re-parsed
# against the built transactions on every check (the post-build # against "now" on every check, so it drifts one day per day away from
# `resolve_date_to_check` anchoring and `resolve_locktime_against_tx` # the fixed tx locktime and the postpone check would ALWAYS see a
# in the postpone detection), so it no longer drifts and must not be # postpone -> the plugin asks to invalidate the will every day. It must
# frozen to an absolute timestamp here. Only an ABSOLUTE stored value # therefore be normalised here to the frozen absolute locktime of the
# is compared with the built transactions (see below). # built transactions, even when it happens to parse to the same moment
# today. (Only an absolute stored value is comparable, see below.)
is_relative_locktime = ( is_relative_locktime = (
isinstance(stored_locktime, str) isinstance(stored_locktime, str)
and stored_locktime[-1:].lower() in ("d", "y") and stored_locktime[-1:].lower() in ("d", "y")
) )
if not is_relative_locktime: # Current stored delivery date, as a comparable UNIX timestamp.
# Current stored delivery date, as a comparable UNIX timestamp. try:
try: current = int(Util.parse_locktime_string(stored_locktime))
current = int(Util.parse_locktime_string(stored_locktime)) except Exception:
except Exception: # If the stored value cannot be parsed, fall back to syncing.
# If the stored value cannot be parsed, fall back to syncing. current = None
current = None # A genuine user-chosen POSTPONE (a later absolute date) is never
# A genuine user-chosen POSTPONE (a later absolute date) is never # overwritten; anything else is synced to the built transactions.
# overwritten; a genuine automatic ANTICIPATION (built earlier was_anticipation = current is not None and min_locktime < current
# than stored) is synced to the built transactions. if not is_relative_locktime and current is not None and not was_anticipation:
was_anticipation = current is not None and min_locktime < current pass
else:
_logger.debug(
f"sync delivery date to built tx locktime: "
f"{current} -> {min_locktime}"
)
# Remember that we anticipated the date, so the later sign prompt can
# explain WHY signing is needed (see on_success_phase1). A pure
# relative->absolute normalisation is NOT an anticipation.
if was_anticipation: if was_anticipation:
_logger.debug(
f"sync delivery date to built tx locktime: "
f"{current} -> {min_locktime}"
)
# Remember that we anticipated the date, so the later sign
# prompt can explain WHY signing is needed
# (see on_success_phase1).
self._date_was_anticipated = True self._date_was_anticipated = True
# update_setting_widgets stores the value, persists it and # update_setting_widgets stores the value, persists it and refreshes
# refreshes the date widgets in all panels/wizard (the .ics # the date widgets in all panels/wizard (the .ics calendar too).
# calendar too). self.bal_window.update_setting_widgets(
self.bal_window.update_setting_widgets( min_locktime, "locktime", update_all=True
min_locktime, "locktime", update_all=True )
# Same moving-target problem for a relative "Check Alive" threshold:
# it means "N days BEFORE the delivery" (the settings widget resolves it
# as real_threshold = locktime - N days), so it is normalised to that
# absolute date, referenced against the now-absolute stored locktime.
threshold_raw = self.bal_window.will_settings.get("threshold")
if (
isinstance(threshold_raw, str)
and threshold_raw[-1:].lower() in ("d", "y")
):
try:
locktime_ts = int(
Util.parse_locktime_string(
self.bal_window.will_settings["locktime"]
)
)
real_threshold = int(
BalTimestamp(threshold_raw)
.to_date(locktime_ts, reverse=True)
.timestamp()
)
except Exception as e:
_logger.error(f"sync threshold to absolute failed: {e}")
else:
_logger.debug(
f"sync threshold {threshold_raw} -> absolute {real_threshold}"
)
self.bal_window.update_setting_widgets(
real_threshold, "threshold", update_all=True
) )
# A relative "Check Alive" threshold is also PRESERVED: it is anchored
# on every check by ``resolve_date_to_check`` / ``resolve_guard_threshold``
# (negative counts back from the delivery, positive forward from the
# inheritance creation date), so it never needs freezing to an absolute
# date here.
def on_accept(self): def on_accept(self):
try: try:
@@ -1214,14 +1247,14 @@ class BalBuildWillDialog(BalDialog):
for i in range(secs, 0, -1): for i in range(secs, 0, -1):
if self._stopping: if self._stopping:
return return
wait_row = self.msg_edit_row(_("Please wait {}secs").format(i), wait_row) wait_row = self.msg_edit_row(_(f"Please wait {i}secs"), wait_row)
time.sleep(1) time.sleep(1)
self.msg_del_row(wait_row) self.msg_del_row(wait_row)
def loop_broadcast_invalidating(self, tx): def loop_broadcast_invalidating(self, tx):
if self._stopping: if self._stopping:
return return
self.msg_set_invalidating(_("Broadcasting")) self.msg_set_invalidating("Broadcasting")
try: try:
tx.add_info_from_wallet(self.bal_window.wallet) tx.add_info_from_wallet(self.bal_window.wallet)
self.network.run_from_another_thread(tx.add_info_from_network(self.network)) self.network.run_from_another_thread(tx.add_info_from_network(self.network))
@@ -1332,7 +1365,7 @@ class BalBuildWillDialog(BalDialog):
done["count"] += 1 done["count"] += 1
# Show the per-server result (Ok/Ko) in bold + color so the # Show the per-server result (Ok/Ko) in bold + color so the
# outcome stands out, keeping the server URL in normal weight. # outcome stands out, keeping the server URL in normal weight.
result = self.msg_ok(_("Ok")) if ok else self.msg_error(_("Ko")) result = self.msg_ok("Ok") if ok else self.msg_error("Ko")
self.msg_edit_row("{} : {}".format(url, result)) self.msg_edit_row("{} : {}".format(url, result))
self.msg_set_pushing(_status_line()) self.msg_set_pushing(_status_line())
@@ -1384,11 +1417,8 @@ class BalBuildWillDialog(BalDialog):
if self._stopping: if self._stopping:
return return
row = self.msg_edit_row( row = self.msg_edit_row(
"{} : <b>{}</b>".format( "checking {} - {} : <b>{}</b>".format(
_("checking {} - {}").format( self.bal_window.willitems[wid].we["url"], wid, "Waiting"
self.bal_window.willitems[wid].we["url"], wid
),
_("Waiting"),
) )
) )
w = self.bal_window.willitems[wid] w = self.bal_window.willitems[wid]
@@ -1401,10 +1431,9 @@ class BalBuildWillDialog(BalDialog):
checked = self.bal_window.willitems[wid].get_status("CHECKED") checked = self.bal_window.willitems[wid].get_status("CHECKED")
result = self.msg_ok(checked) if checked else self.msg_error(checked) result = self.msg_ok(checked) if checked else self.msg_error(checked)
row = self.msg_edit_row( row = self.msg_edit_row(
"{} : {}".format( "checked {} - {} : {}".format(
_("checked {} - {}").format( self.bal_window.willitems[wid].we["url"],
self.bal_window.willitems[wid].we["url"], wid wid,
),
result, result,
), ),
row, row,
@@ -1452,7 +1481,7 @@ class BalBuildWillDialog(BalDialog):
raise Exception("not tx") raise Exception("not tx")
except Exception as e: except Exception as e:
(f"exception:{e}") (f"exception:{e}")
self.msg_set_invalidating(_("Error: {}").format(e)) self.msg_set_invalidating(f"Error: {e}")
raise Exception("Impossible to sign") from e raise Exception("Impossible to sign") from e
def on_success_invalidate(self, success): def on_success_invalidate(self, success):
@@ -1537,18 +1566,6 @@ class BalBuildWillDialog(BalDialog):
self._add_no_heirs_buttons() self._add_no_heirs_buttons()
return return
if self.have_to_sign == "check_alive_before_delivery":
# The Check Alive date is on/after the delivery (a configuration
# error, not an expired will): do NOT invalidate, just tell the
# user and let them close the wizard.
self.bal_window.show_error(
_(
"Check Alive must be after today and before the delivery date, please update it"
)
)
self._add_close_button()
return
_logger.debug("have to sign {}".format(self.have_to_sign)) _logger.debug("have to sign {}".format(self.have_to_sign))
password = None password = None
if self.have_to_sign is None: if self.have_to_sign is None:
@@ -1832,22 +1849,14 @@ class BalBuildWillDialog(BalDialog):
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:
# Factory defaults, in the GUI language (see BalConfig). raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_description = ( raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
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:
# The Check Alive reference shares the same sign rule as the threshold_ts = BalTimestamp(
# core: negative counts back from the delivery, positive counts self.bal_window.will_settings["threshold"]
# forward from the inheritance creation date. ).to_timestamp()
threshold_ts = resolve_relative_check_alive_date(
self.bal_window.will_settings["threshold"],
delivery_date=locktime_ts,
creation_date=Will.get_creation_time(self.bal_window.willitems),
)
threshold = datetime.fromtimestamp(threshold_ts) threshold = datetime.fromtimestamp(threshold_ts)
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get() raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get() raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
@@ -1878,7 +1887,6 @@ class BalBuildWillDialog(BalDialog):
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}")
@@ -2022,7 +2030,7 @@ class BalBuildWillDialog(BalDialog):
def on_error_phase1(self, error): def on_error_phase1(self, error):
self.bal_window.update_all() self.bal_window.update_all()
a, b, c = error a, b, c = error
self.msg_edit_row(self.msg_error(_("Error: {}").format(b))) self.msg_edit_row(self.msg_error(f"Error: {b}"))
import traceback import traceback
_logger.error(f"error phase1: {b}\n{''.join(traceback.format_exception(a, b, c))}") _logger.error(f"error phase1: {b}\n{''.join(traceback.format_exception(a, b, c))}")
button=QPushButton(_("Close")) button=QPushButton(_("Close"))
@@ -2033,7 +2041,7 @@ class BalBuildWillDialog(BalDialog):
def on_error_phase2(self, error): def on_error_phase2(self, error):
self.bal_window.upade_all() self.bal_window.upade_all()
a, b, c = error a, b, c = error
self.msg_edit_row(self.msg_error(_("Error: {}").format(b))) self.msg_edit_row(self.msg_error(f"Error: {b}"))
_logger.error(f"error phase2: {b}") _logger.error(f"error phase2: {b}")
def _executed_inheritance_status(self): def _executed_inheritance_status(self):
@@ -2219,9 +2227,9 @@ class BalBuildWillDialog(BalDialog):
} }
if reason in messages: if reason in messages:
return "{}\n\n{}".format(messages[reason], _("Skipped")) return messages[reason] + "\n\n" + _("Skipped")
return "{}\n\n{}".format( return (
_( _(
"Could not build the will, and the exact cause could not be " "Could not build the will, and the exact cause could not be "
"determined. Please check that:\n" "determined. Please check that:\n"
@@ -2229,14 +2237,12 @@ class BalBuildWillDialog(BalDialog):
"- each heir's share is above the minimum (dust limit),\n" "- each heir's share is above the minimum (dust limit),\n"
"- the Check Alive date is EARLIER than the delivery date,\n" "- the Check Alive date is EARLIER than the delivery date,\n"
"- at least one will-executor is selected and reachable." "- at least one will-executor is selected and reachable."
), )
_("Skipped"), + "\n\n"
+ _("Skipped")
) )
def msg_set_checking(self, status=None, row=None): def msg_set_checking(self, status="Waiting", row=None):
# The default is resolved here, not in the signature: a default
# argument is evaluated at import time, before the catalog is loaded.
status = _("Waiting") if status is None else status
row = self.check_row if row is None else row row = self.check_row if row is None else row
self.check_row = self.msg_set_status(_("Checking your will"), row, status) self.check_row = self.msg_set_status(_("Checking your will"), row, status)
@@ -2249,36 +2255,30 @@ class BalBuildWillDialog(BalDialog):
def msg_set_building(self, status=None, row=None,color=None): def msg_set_building(self, status=None, row=None,color=None):
row = self.build_row if row is None else row row = self.build_row if row is None else row
self.build_row = self.msg_set_status( self.build_row = self.msg_set_status(
_("Building your will"), self.build_row, status, color "Building your will", self.build_row, status, color
) )
def msg_set_signing(self, status=None, row=None): def msg_set_signing(self, status=None, row=None):
row = self.sign_row if row is None else row row = self.sign_row if row is None else row
self.sign_row = self.msg_set_status( self.sign_row = self.msg_set_status("Signing your will", self.sign_row, status)
_("Signing your will"), self.sign_row, status
)
def msg_set_pushing(self, status=None, row=None): def msg_set_pushing(self, status=None, row=None):
row = self.push_row if row is None else row row = self.push_row if row is None else row
self.push_row = self.msg_set_status( self.push_row = self.msg_set_status(
_("Broadcasting your will to executors"), self.push_row, status "Broadcasting your will to executors", self.push_row, status
) )
def msg_set_waiting(self, status=None, row=None): def msg_set_waiting(self, status=None, row=None):
row = self.wait_row if row is None else row row = self.wait_row if row is None else row
self.wait_row = self.msg_edit_row( self.wait_row = self.msg_edit_row(f"Please wait {status}secs", self.wait_row)
_("Please wait {}secs").format(status), self.wait_row
)
def msg_error(self, e): def msg_error(self, e):
# Results are shown in bold so the outcome stands out from the # Results are shown in bold so the outcome stands out from the
# left-side state label (which stays in normal weight). # left-side state label (which stays in normal weight).
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_ERROR, e) return "<font color='{}'><b>{}</b></font>".format(self.COLOR_ERROR, e)
def msg_ok(self, e=None): def msg_ok(self, e="Ok"):
# Results are shown in bold (see msg_error). The default "Ok" is # Results are shown in bold (see msg_error).
# translated here, not in the signature (evaluated at import time).
e = _("Ok") if e is None else e
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_OK, e) return "<font color='{}'><b>{}</b></font>".format(self.COLOR_OK, e)
def msg_warning(self, e): def msg_warning(self, e):
@@ -2307,14 +2307,12 @@ class BalBuildWillDialog(BalDialog):
# glance. ``status`` may already contain rich-text emitted by # glance. ``status`` may already contain rich-text emitted by
# msg_ok/msg_error/msg_warning (which add their own <b>...</b>); wrapping # msg_ok/msg_error/msg_warning (which add their own <b>...</b>); wrapping
# it again in <b> is harmless for those cases. # it again in <b> is harmless for those cases.
# ``msg`` must already be translated by the caller: a variable status = "Wait" if status is None else status
# passed to _() cannot be extracted into the catalog.
status = _("Wait") if status is None else status
if color is None: if color is None:
line = "{}:\t<b>{}</b>".format(msg, status) line = "{}:\t<b>{}</b>".format(_(msg), status)
else: else:
line = "{}:\t<font color={}><b>{}</b></font>".format( line = "{}:\t<font color={}><b>{}</b></font>".format(
msg, color, status _(msg), color, status
) )
return self.msg_edit_row(line, row) return self.msg_edit_row(line, row)
@@ -2442,15 +2440,15 @@ class WillDetailDialog(BalDialog):
self.paint_scroll_area() self.paint_scroll_area()
self.vlayout.addWidget( self.vlayout.addWidget(
QLabel(_("Expiration date: {}").format(BalTimestamp(self.threshold))) QLabel(_("Expiration date: ") + str(BalTimestamp(self.threshold)))
) )
self.vlayout.addWidget(self.scrollbox) self.vlayout.addWidget(self.scrollbox)
w = QWidget() w = QWidget()
hlayout = QHBoxLayout(w) hlayout = QHBoxLayout(w)
hlayout.addWidget( hlayout.addWidget(
QLabel(_("Valid Txs:{}").format(len(Will.only_valid_list(self.will)))) QLabel(_("Valid Txs:") + str(len(Will.only_valid_list(self.will))))
) )
hlayout.addWidget(QLabel(_("Total Txs:{}").format(len(self.will)))) hlayout.addWidget(QLabel(_("Total Txs:") + str(len(self.will))))
self.vlayout.addWidget(w) self.vlayout.addWidget(w)
self.setLayout(self.vlayout) self.setLayout(self.vlayout)
@@ -2503,19 +2501,18 @@ class WillDetailDialog(BalDialog):
def toggle_replaced(self): def toggle_replaced(self):
self.bal_window.bal_plugin.hide_replaced() self.bal_window.bal_plugin.hide_replaced()
# Whole sentences, so a translation can change the word order. toggle = _("Hide")
text = _("Hide replaced")
if self.bal_window.bal_plugin._hide_replaced: if self.bal_window.bal_plugin._hide_replaced:
text = _("Unhide replaced") toggle = _("Unhide")
self.toggle_replace_button.setText(text) self.toggle_replace_button.setText(f"{toggle} {_('replaced')}")
self.update() self.update()
def toggle_invalidated(self): def toggle_invalidated(self):
self.bal_window.bal_plugin.hide_invalidated() self.bal_window.bal_plugin.hide_invalidated()
text = _("Hide invalidated") toggle = _("Hide")
if self.bal_window.bal_plugin._hide_invalidated: if self.bal_window.bal_plugin._hide_invalidated:
text = _("Unhide invalidated") toggle = _("Unhide")
self.toggle_invalidate_button.setText(text) self.toggle_invalidate_button.setText(_(f"{toggle} {_('invalidated')}"))
self.update() self.update()
def update(self): def update(self):
@@ -2735,10 +2732,10 @@ class BalQrExportWidget(QWidget):
The page (re)displays itself on :meth:`set_tx_strings`. The page (re)displays itself on :meth:`set_tx_strings`.
""" """
def __init__(self, chunk_size=CHUNK_PRESETS[0][1], parent=None, on_size_change=None): def __init__(self, chunk_size=CHUNK_PRESETS[0][1], parent=None):
QWidget.__init__(self, parent) QWidget.__init__(self, parent)
self.chunk_size = chunk_size self.chunk_size = chunk_size
self._on_size_change = on_size_change self.format = "balqr"
self.tx_strings = [] self.tx_strings = []
self.transfer = "" self.transfer = ""
self.frames = [] self.frames = []
@@ -2795,7 +2792,7 @@ class BalQrExportWidget(QWidget):
size_row = QHBoxLayout() size_row = QHBoxLayout()
size_row.addWidget(QLabel(_("QR code size:"))) size_row.addWidget(QLabel(_("QR code size:")))
self.size_combo = QComboBox() self.size_combo = QComboBox()
self.size_combo.addItems([_(label) for label, _budget in CHUNK_PRESETS]) self.size_combo.addItems([label for label, _budget in CHUNK_PRESETS])
self.size_combo.setCurrentIndex( self.size_combo.setCurrentIndex(
preset_index_for_chunk_size(self.chunk_size) preset_index_for_chunk_size(self.chunk_size)
) )
@@ -2804,6 +2801,24 @@ class BalQrExportWidget(QWidget):
size_row.addStretch(1) size_row.addStretch(1)
vbox.addLayout(size_row) vbox.addLayout(size_row)
format_row = QHBoxLayout()
format_row.addWidget(QLabel(_("Format:")))
self.format_combo = QComboBox()
self._format_options = ["balqr"] + list(ANIMATED_QR_FORMATS)
self.format_combo.addItems(
[
format_name(fmt) + ((" (default)") if fmt == "balqr" else "")
for fmt in self._format_options
]
)
self.format_combo.setCurrentIndex(0)
self.format_combo.currentIndexChanged.connect(self._on_format_change)
format_row.addWidget(self.format_combo)
self.format_hint = QLabel()
self.format_hint.setWordWrap(True)
format_row.addWidget(self.format_hint, 1)
vbox.addLayout(format_row)
def set_tx_strings(self, tx_strings): def set_tx_strings(self, tx_strings):
"""Rebuild the transfer frames from the given serialized transactions.""" """Rebuild the transfer frames from the given serialized transactions."""
self.tx_strings = list(tx_strings) self.tx_strings = list(tx_strings)
@@ -2818,18 +2833,28 @@ class BalQrExportWidget(QWidget):
return len(self.frames) return len(self.frames)
def _refresh_frames(self): def _refresh_frames(self):
self.frames = bbqr_frames( if self.format == "balqr":
self.transfer.encode("utf-8"), self.chunk_size, encoding="Z" self.frames = split_frames(
) self.transfer, self.chunk_size, compressed=False
)
else:
self.frames = encode_animated_frames(
self.transfer, self.format, self.chunk_size
)
self.index = 0 self.index = 0
def _on_format_change(self, index):
self._stop_auto()
self.format = self._format_options[index]
self._refresh_frames()
self._render()
self._update_intro()
def _on_chunk_change(self, index): def _on_chunk_change(self, index):
self._stop_auto() self._stop_auto()
self.chunk_size = CHUNK_PRESETS[index][1] self.chunk_size = CHUNK_PRESETS[index][1]
self._refresh_frames() self._refresh_frames()
self._render() self._render()
if self._on_size_change is not None:
self._on_size_change(self.chunk_size)
def _toggle_auto(self): def _toggle_auto(self):
"""Start/stop the automatic QR slideshow.""" """Start/stop the automatic QR slideshow."""
@@ -2861,13 +2886,32 @@ class BalQrExportWidget(QWidget):
self._next() self._next()
def _update_intro(self): def _update_intro(self):
self.intro_label.setText( self.format_hint.setText(
_( {
"Scan the QR codes below with the will-opening device.\n" "balqr": _(
"{} codes carry the whole transfer (any order works, " "Proprietary format; every other BAL wallet understands it."
"duplicates are ignored)." ),
).format(self.total_frames) "ur1": _("Legacy BC-UR v1 (ur:bytes), compatible with older "
"Blockchain Commons tools."),
"ur2": _("Standard BC-UR v2 fountain codes (ur:bytes)."),
"bbqr": _("Coinkite BBQR (B$ frames) for BitKit & friends."),
}[self.format]
) )
if self.format == "balqr":
self.intro_label.setText(
_(
"Scan the QR codes below, in order, with the will-opening "
"device.\nFrame 1 of {} carries the total number of codes."
).format(self.total_frames)
)
else:
self.intro_label.setText(
_(
"Scan the QR codes below with the will-opening device.\n"
"{} codes carry the whole transfer (any order works, "
"duplicates are ignored)."
).format(self.total_frames)
)
def _prev(self): def _prev(self):
if self.index > 0: if self.index > 0:
@@ -2909,7 +2953,6 @@ class WillExportDialog(BalDialog):
def __init__(self, bal_window, will=None, bal_plugin=None, initial_mode="file"): def __init__(self, bal_window, will=None, bal_plugin=None, initial_mode="file"):
BalDialog.__init__(self, bal_window.window, bal_plugin, _("Export will")) BalDialog.__init__(self, bal_window.window, bal_plugin, _("Export will"))
self.bal_window = bal_window self.bal_window = bal_window
self.bal_plugin = bal_plugin
self._source = will if will is not None else bal_window.willitems self._source = will if will is not None else bal_window.willitems
self._filters = export_filter_options() self._filters = export_filter_options()
self._filter_index = 0 self._filter_index = 0
@@ -2963,10 +3006,7 @@ class WillExportDialog(BalDialog):
self.stacked = QStackedWidget() self.stacked = QStackedWidget()
self.file_page = self._build_file_page() self.file_page = self._build_file_page()
self.stacked.addWidget(self.file_page) self.stacked.addWidget(self.file_page)
self.qr_page = BalQrExportWidget( self.qr_page = BalQrExportWidget(chunk_size=chunk)
chunk_size=chunk,
on_size_change=self._remember_qr_size,
)
self.stacked.addWidget(self.qr_page) self.stacked.addWidget(self.qr_page)
self.audio_page = self._build_audio_page() self.audio_page = self._build_audio_page()
self.stacked.addWidget(self.audio_page) self.stacked.addWidget(self.audio_page)
@@ -2976,19 +3016,12 @@ class WillExportDialog(BalDialog):
close_btn.clicked.connect(self.close) close_btn.clicked.connect(self.close)
vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight) vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
# The audio transport is only offered when the audio_modem plugin is
# available; otherwise its radio button is disabled (file and QR stay
# usable).
self.transport_audio.setEnabled(self._audio_available)
mode_ids = { mode_ids = {
"file": self.MODE_FILE, "file": self.MODE_FILE,
"qr": self.MODE_QR, "qr": self.MODE_QR,
"audio": self.MODE_AUDIO, "audio": self.MODE_AUDIO,
} }
mode = mode_ids.get(initial_mode, self.MODE_FILE) mode = mode_ids.get(initial_mode, self.MODE_FILE)
if mode == self.MODE_AUDIO and not self._audio_available:
mode = self.MODE_FILE
(self.transport_file, self.transport_qr, self.transport_audio)[ (self.transport_file, self.transport_qr, self.transport_audio)[
mode mode
].setChecked(True) ].setChecked(True)
@@ -3023,7 +3056,6 @@ class WillExportDialog(BalDialog):
self._audio_plugin = self.bal_window.get_audio_modem_plugin() self._audio_plugin = self.bal_window.get_audio_modem_plugin()
self.bitrates = audio_bitrates() self.bitrates = audio_bitrates()
available = self._audio_plugin is not None available = self._audio_plugin is not None
self._audio_available = available
if available: if available:
self.audio_warn_label.hide() self.audio_warn_label.hide()
else: else:
@@ -3052,14 +3084,6 @@ class WillExportDialog(BalDialog):
def _selected_items(self): def _selected_items(self):
return filter_willitems(self._source, self._filters, self._filter_index) return filter_willitems(self._source, self._filters, self._filter_index)
def _remember_qr_size(self, size):
"""Persist the QR size chosen in the export page for the next export."""
if self.bal_plugin is not None:
try:
self.bal_plugin.QR_CHUNK_SIZE.set(size)
except Exception:
pass
def _payload_strings(self): def _payload_strings(self):
"""The data handed to the QR page, honouring the content scope. """The data handed to the QR page, honouring the content scope.
@@ -3231,10 +3255,6 @@ class WillImportDialog(BalDialog):
close_btn.clicked.connect(self.close) close_btn.clicked.connect(self.close)
vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight) vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
# The audio transport is only offered when the audio_modem plugin is
# available; otherwise its radio button is disabled.
self.transport_audio.setEnabled(self._audio_available)
self.transport_file.setChecked(True) self.transport_file.setChecked(True)
self._on_mode_clicked(self.MODE_FILE) self._on_mode_clicked(self.MODE_FILE)
@@ -3262,7 +3282,6 @@ class WillImportDialog(BalDialog):
self._audio_plugin = self.bal_window.get_audio_modem_plugin() self._audio_plugin = self.bal_window.get_audio_modem_plugin()
self.bitrates = audio_bitrates() self.bitrates = audio_bitrates()
available = self._audio_plugin is not None available = self._audio_plugin is not None
self._audio_available = available
if available: if available:
self.audio_warn_label.hide() self.audio_warn_label.hide()
else: else:
@@ -3503,9 +3522,9 @@ def qr_import_accept_frame(
seen ``stable_reads`` times in a row -- this mirrors the export-side seen ``stable_reads`` times in a row -- this mirrors the export-side
slideshow pacing and swallows transition artifacts; slideshow pacing and swallows transition artifacts;
* re-reading the currently accepted frame is ``ignore``d; * re-reading the currently accepted frame is ``ignore``d;
* a frame whose ``session_key`` (transfer identity, e.g. ``"bbqr:Z:B:3"``) * a frame whose ``session_key`` (transfer identity, e.g. ``"balqr:3"`` or
contradicts the transfer already being built is a ``reset`` (a ``"ur2:2-31-3804692811"``) contradicts the transfer already being built
different transfer was presented). is a ``reset`` (a different transfer was presented).
``state`` is a mutable mapping with keys ``last_index``, ``last_payload``, ``state`` is a mutable mapping with keys ``last_index``, ``last_payload``,
``pending_index``, ``pending_payload``, ``pending_count`` and ``key``. ``pending_index``, ``pending_payload``, ``pending_count`` and ``key``.
@@ -3691,8 +3710,8 @@ class BalQrImportWidget(QWidget):
self._key = key self._key = key
self._init_slots() self._init_slots()
if status == "ok": if status == "ok":
# Slot grid is always 1-based even for the 0-based BBQR indexes. # Slot grid is always 1-based even for 0-based wire formats.
slot = index + 1 slot = index + 1 if fmt == "bbqr" else index
self.frames[slot] = frame_text self.frames[slot] = frame_text
self._update_slots() self._update_slots()
self._update_status() self._update_status()
@@ -3948,7 +3967,7 @@ class BalQrImportWidget(QWidget):
try: try:
transfer, compressed = session.resolve() transfer, compressed = session.resolve()
parts = decode_transfer(transfer, compressed) parts = decode_transfer(transfer, compressed)
except AnimatedQrError as e: except (MissingFramesError, QrTransferError, AnimatedQrError) as e:
self.show_error(str(e)) self.show_error(str(e))
self._reset_all() self._reset_all()
return return

View File

@@ -20,7 +20,6 @@ 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_,
OP_RETURN_PREFIX, OP_RETURN_PREFIX,
BalTimestamp, BalTimestamp,
Buttons, Buttons,
@@ -60,7 +59,6 @@ from .common import (
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,
@@ -71,7 +69,6 @@ from .common import (
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,
) )
@@ -131,9 +128,9 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
AMOUNT = enum.auto() AMOUNT = enum.auto()
headers = { headers = {
Columns.NAME: N_("Name"), Columns.NAME: _("Name"),
Columns.ADDRESS: N_("Address"), Columns.ADDRESS: _("Address"),
Columns.AMOUNT: N_("Amount"), Columns.AMOUNT: _("Amount"),
} }
filter_columns = [Columns.NAME, Columns.ADDRESS] filter_columns = [Columns.NAME, Columns.ADDRESS]
@@ -248,7 +245,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(translated_headers(self.__class__.headers)) self.update_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]
@@ -340,11 +337,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
SERVER = enum.auto() SERVER = enum.auto()
headers = { headers = {
Columns.LOCKTIME: N_("Locktime"), Columns.LOCKTIME: _("Locktime"),
Columns.TXID: N_("Txid"), Columns.TXID: _("Txid"),
Columns.WILLEXECUTOR: N_("Will-Executor"), Columns.WILLEXECUTOR: _("Will-Executor"),
Columns.STATUS: N_("Status"), Columns.STATUS: _("Status"),
Columns.SERVER: N_("Server"), Columns.SERVER: _("Server"),
} }
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000 ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000
@@ -589,7 +586,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 = format_status_history(bal_tx.status) + signature_suffix(bal_tx) status = 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
@@ -647,7 +644,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(translated_headers(self.__class__.headers)) self.update_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():
@@ -679,7 +676,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(" {}".format(_("Build Your Will"))) wizard = QPushButton(" " + _("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")
@@ -907,12 +904,12 @@ class WillExecutorListWidget(MyTreeView):
ADDRESS = enum.auto() ADDRESS = enum.auto()
headers = { headers = {
Columns.SELECTED: "", Columns.SELECTED: _(""),
Columns.URL: N_("Url"), Columns.URL: _("Url"),
Columns.STATUS: N_("S"), Columns.STATUS: _("S"),
Columns.BASE_FEE: N_("Base fee"), Columns.BASE_FEE: _("Base fee"),
Columns.INFO: N_("Info"), Columns.INFO: _("Info"),
Columns.ADDRESS: N_("Default Address"), Columns.ADDRESS: _("Default Address"),
} }
filter_columns = [Columns.URL] filter_columns = [Columns.URL]
@@ -1094,7 +1091,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(translated_headers(self.__class__.headers)) self.update_headers(self.__class__.headers)
set_current = None set_current = None
@@ -1239,14 +1236,13 @@ 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(
@@ -1257,7 +1253,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(
@@ -1265,7 +1261,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(
@@ -1299,7 +1295,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)
@@ -1313,7 +1309,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", "")))
@@ -1473,7 +1469,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()

View File

@@ -19,7 +19,7 @@ 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 ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size
from .common import ( from .common import (
BalPlugin, BalPlugin,
Buttons, Buttons,
@@ -67,11 +67,6 @@ 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
@@ -303,7 +298,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 {}".format(_("Bitcoin After Life")), "Bal " + _("Bitcoin After Life"),
lambda: self.settings_dialog(sb.window()), lambda: self.settings_dialog(sb.window()),
sb.height(), sb.height(),
) )
@@ -447,7 +442,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()
@@ -537,6 +532,21 @@ class Plugin(BalPlugin, EventListener):
# users (BASIC and ADVANCED). # users (BASIC and ADVANCED).
heir_auto_rebuild = BalCheckBox(self.AUTO_REBUILD) heir_auto_rebuild = BalCheckBox(self.AUTO_REBUILD)
# QR Code Size selector (will transfer via QR). A 4-standard-size combo
# bound to the QR_CHUNK_SIZE config (payload budget in bytes per frame).
# Ordered low -> high so the user picks the resolution matching their
# camera. Visible to all users (BASIC and ADVANCED).
qr_size_combo = QComboBox()
qr_size_combo.addItems([label for label, _budget in CHUNK_PRESETS])
qr_size_combo.setCurrentIndex(
preset_index_for_chunk_size(int(self.QR_CHUNK_SIZE.get()))
)
def on_qr_size_change(index):
self.QR_CHUNK_SIZE.set(CHUNK_PRESETS[index][1])
qr_size_combo.currentIndexChanged.connect(on_qr_size_change)
# USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo # USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo
# (not a free-text field) bound to the USER_TYPE config: # (not a free-text field) bound to the USER_TYPE config:
# index 0 -> "BASIC" -> stored value "basic" (DEFAULT) # index 0 -> "BASIC" -> stored value "basic" (DEFAULT)
@@ -646,17 +656,21 @@ 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.localized_default()) widget.setText(cfg.default)
elif kind == "text": elif kind == "text":
widget.setPlainText(cfg.localized_default()) widget.setPlainText(cfg.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
) )
elif kind == "qr_size":
widget.setCurrentIndex(
preset_index_for_chunk_size(int(cfg.default))
)
btn.clicked.connect(reset) btn.clicked.connect(reset)
return btn return btn
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"]
@@ -682,27 +696,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)
@@ -712,10 +726,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"
@@ -727,10 +741,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"
@@ -742,10 +756,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"
@@ -760,10 +774,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)
@@ -771,13 +785,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)
@@ -785,13 +799,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)
@@ -800,10 +814,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)
@@ -811,11 +825,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)
@@ -826,13 +840,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)
@@ -840,13 +854,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)
@@ -863,7 +877,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,
@@ -873,12 +887,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)
@@ -891,7 +905,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 "
@@ -902,7 +916,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)
@@ -911,6 +925,25 @@ class Plugin(BalPlugin, EventListener):
) )
grid.addWidget(reset_btn_auto_rebuild, 15, 3) grid.addWidget(reset_btn_auto_rebuild, 15, 3)
# "QR Code Size" row (always visible, BASIC + ADVANCED). Default QR
# size used when exporting a will via QR codes; changeable per export
# inside the export dialog itself.
lbl_qr_size = QLabel(_("QR Code Size"))
help_qr_size = HelpButton(
"Payload size of a single QR code when exporting a will via QR.\n\n"
"Larger QR codes hold more data (fewer shots) but are easier to "
"scan with a high-resolution camera; smaller QR codes scan fine "
"even with low-resolution cameras but require more shots.\n"
"The same selector is available inside the export dialog."
)
grid.addWidget(lbl_qr_size, 16, 0)
grid.addWidget(qr_size_combo, 16, 1)
grid.addWidget(help_qr_size, 16, 2)
reset_btn_qr_size = _make_reset_btn(
self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"
)
grid.addWidget(reset_btn_qr_size, 16, 3)
# ----------------------------------------------------------------- # # ----------------------------------------------------------------- #
# Group C / C4b: "Reset" button that restores the dialog settings to # # Group C / C4b: "Reset" button that restores the dialog settings to #
# their factory defaults. It only resets the settings exposed by THIS # # their factory defaults. It only resets the settings exposed by THIS #
@@ -944,6 +977,7 @@ class Plugin(BalPlugin, EventListener):
(self.HISTORY_LABEL, edit_history_label, "line"), (self.HISTORY_LABEL, edit_history_label, "line"),
(self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"), (self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"),
(self.AUTO_REBUILD, heir_auto_rebuild, "check"), (self.AUTO_REBUILD, heir_auto_rebuild, "check"),
(self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"),
] ]
for cfg, widget, kind in resets: for cfg, widget, kind in resets:
# Persist the default value back into the Electrum config. # Persist the default value back into the Electrum config.
@@ -956,14 +990,18 @@ 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.localized_default()) widget.setText(cfg.default)
elif kind == "text": elif kind == "text":
widget.setPlainText(cfg.localized_default()) widget.setPlainText(cfg.default)
elif kind == "user_type": elif kind == "user_type":
# Default is "basic" -> combo index 0; "advanced" -> index 1. # Default is "basic" -> combo index 0; "advanced" -> index 1.
widget.setCurrentIndex( widget.setCurrentIndex(
1 if str(cfg.default).lower() == "advanced" else 0 1 if str(cfg.default).lower() == "advanced" else 0
) )
elif kind == "qr_size":
widget.setCurrentIndex(
preset_index_for_chunk_size(int(cfg.default))
)
# Re-sync the history-label field's enabled state after a reset: the # Re-sync the history-label field's enabled state after a reset: the
# reset restores SAVE_HISTORY to its default, so the field must # reset restores SAVE_HISTORY to its default, so the field must
# follow the (default) checkbox state again. # follow the (default) checkbox state again.
@@ -993,7 +1031,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>{}</b>".format(_("Support:")))) bottom_row.addWidget(QLabel("<b>" + _("Support:") + "</b>"))
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.
@@ -1045,11 +1083,6 @@ class Plugin(BalPlugin, EventListener):
) )
def get_window_title(self, title): def get_window_title(self, title):
"""Return the window title "BAL - <title>". return _("BAL - ") + _(title)
``title`` must already be translated by the caller: a variable passed
to _() cannot be extracted into the catalog.
"""
return _("BAL - {}").format(title)

View File

@@ -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 ...i18n import _ from electrum.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 ...i18n import _ from electrum.i18n import _
url = None url = None
we = getattr(will_item, "we", None) we = getattr(will_item, "we", None)

View File

@@ -20,8 +20,6 @@ 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,
@@ -32,7 +30,6 @@ 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 (
DECIMAL_POINT, DECIMAL_POINT,
N_,
NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_BLOCKHEIGHT_MAX,
NLOCKTIME_MAX, NLOCKTIME_MAX,
Any, Any,
@@ -69,7 +66,6 @@ from .common import (
_logger, _logger,
char_width_in_lineedit, char_width_in_lineedit,
datetime, datetime,
format_status_history,
getSaveFileName, getSaveFileName,
log_error, log_error,
os, os,
@@ -210,18 +206,14 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
current_index = None current_index = None
default_value = None default_value = None
help_text = N_( help_text = (
"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)
@@ -232,9 +224,7 @@ 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.locktime_raw_e = TimeRawEditWidget(self, time_edit=self)
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()
@@ -285,9 +275,7 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
] ]
) )
#hbox.addWidget(QLabel(self.label_text)) #hbox.addWidget(QLabel(self.label_text))
# help_text and tooltip_text are class attributes marked with N_(): help_button=HelpButton(self.help_text)
# 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.
@@ -489,9 +477,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, allow_negative=False): def __init__(self, parent, time_edit=None):
super().__init__(parent) super().__init__(parent)
self.editor = LockTimeRawEdit(parent, time_edit, allow_negative=allow_negative) self.editor = LockTimeRawEdit(parent, time_edit)
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
@@ -517,7 +505,7 @@ class TimeRawEditWidget(QWidget):
class LockTimeRawEdit(QLineEdit, _LockTimeEditor): class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
def __init__(self, parent=None, time_edit=None, allow_negative=False): def __init__(self, parent=None, time_edit=None):
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"
@@ -527,22 +515,16 @@ 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.
# (and, for the Check Alive field, a single leading "-"). The # The block-height suffix ("b") was removed (A1): locktimes are always
# block-height suffix ("b") was removed (A1): locktimes are always UNIX # UNIX timestamps now, so block-relative input is no longer allowed.
# 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( s, isdays, isyears, pos = normalize_locktime_raw_text(text, pos)
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)
@@ -629,20 +611,15 @@ 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 = N_( help_text = (
"<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>"
@@ -656,20 +633,13 @@ 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 = N_("Check Alive") tooltip_text = "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:
@@ -683,7 +653,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 = N_( help_text = (
"<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>"
@@ -701,7 +671,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 = N_("Delivery Time, click for more information") tooltip_text = "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):
@@ -1112,12 +1082,9 @@ 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. The defaults are in # ignored) and the fixed 30/10/1 offsets.
# the GUI language (see BalConfig). raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_description = ( raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
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:
@@ -1147,7 +1114,6 @@ 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}")
@@ -1155,23 +1121,18 @@ 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_raw = self.widgets["threshold"].get_value() threshold = 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. The SIGN of the RAW value selects the # Resolved Check Alive date. For relative values this counts BACKWARDS
# anchor: negative counts backwards from the delivery, positive counts # from the delivery time (reverse=True): "90d" means "90 days BEFORE the
# forward from the inheritance creation date. The rule lives in # delivery", not "90 days from now".
# ``bal.core.checkalive`` so this display matches every validity check. td = threshold.to_date(min_locktime, True)
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:
@@ -1232,7 +1193,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),
_("{} or perc value").format(self.base_unit()), self.base_unit() + " or perc value",
) )
@@ -1350,12 +1311,11 @@ 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):
# ``title`` is shown as it is: callers pass a translated label = "<b>" + _(str(title)) + f":</b>\t{str(value)}"
# label, or data (an heir name, a will-executor URL). return QLabel(label)
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()
@@ -1365,46 +1325,25 @@ 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( qlabel("Status:", self.will[w].status + signature_suffix(self.will[w]))
_("Status"),
format_status_history(self.will[w].status)
+ signature_suffix(self.will[w]),
)
) )
detaillayout.addWidget(QLabel("")) detaillayout.addWidget(QLabel(""))
detaillayout.addWidget(QLabel("<b>{}</b>".format(_("Heirs:")))) detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
for heir_name in self.will[w].heirs: for heir in self.will[w].heirs:
if 'w!ll3x3c"' in heir_name: if 'w!ll3x3c"' not in heir:
continue decoded_amount = Util.decode_amount(
h = self.will[w].heirs[heir_name] self.will[w].heirs[heir][3], self._bal_parent.decimal_point
decoded_amount = Util.decode_amount( )
h[3], self._bal_parent.decimal_point
)
if is_op_return_address(h[0]):
data_hex = get_op_return_hex(h[0]) or ""
try:
decoded = bytes.fromhex(data_hex).decode(
"utf-8", errors="replace"
)
except Exception:
decoded = h[0]
detaillayout.addWidget(qlabel(heir_name, "OP_RETURN: " + decoded))
else:
detaillayout.addWidget( detaillayout.addWidget(
qlabel( qlabel(
heir_name, heir, f"{decoded_amount} {self._bal_parent.base_unit_name}"
f"{decoded_amount} {self._bal_parent.base_unit_name} "
f"[{h[0]}]",
) )
) )
if self.will[w].we: if self.will[w].we:
detaillayout.addWidget(QLabel("")) detaillayout.addWidget(QLabel(""))
detaillayout.addWidget( detaillayout.addWidget(QLabel(_("<b>Willexecutor:</b:")))
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
) )
@@ -1415,10 +1354,6 @@ class WillWidget(QWidget):
f"{decoded_amount} {self._bal_parent.base_unit_name}", f"{decoded_amount} {self._bal_parent.base_unit_name}",
) )
) )
if self.will[w].we.get("address"):
detaillayout.addWidget(
qlabel(_("Address"), self.will[w].we["address"])
)
detaillayout.addStretch() detaillayout.addStretch()
pal = QPalette() pal = QPalette()
pal.setColor( pal.setColor(

View File

@@ -20,15 +20,11 @@ import threading
from electrum.util import MyEncoder from electrum.util import MyEncoder
from ...core.checkalive import ( from ...core.checkalive import (
CheckAliveAfterDeliveryError,
CheckAliveError, CheckAliveError,
ensure_check_alive_valid, check_alive_expired,
resolve_date_to_check, resolve_date_to_check,
resolve_delivery_date,
resolve_guard_threshold,
) )
from .common import ( from .common import (
N_,
OP_RETURN_PREFIX, OP_RETURN_PREFIX,
AmountException, AmountException,
BalPlugin, BalPlugin,
@@ -285,12 +281,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 = _("Edit: {}").format(heir_key) title = f"Edit: {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)
@@ -470,32 +466,6 @@ 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 = {}
@@ -551,9 +521,7 @@ class BalWindow:
tx["heirsvalue"] = txs[txid].heirsvalue tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description tx["description"] = txs[txid].description
tx["willexecutor"] = copy_structure(txs[txid].willexecutor) tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
# Stored history: always English (translated only for display by tx["status"] = _("New")
# 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_structure(txs[txid].heirs) tx["heirs"] = copy_structure(txs[txid].heirs)
@@ -745,7 +713,6 @@ 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
@@ -756,20 +723,19 @@ 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
) )
# The Check Alive must be strictly after today and strictly before # SIMPLE / ADVANCED: in BASIC mode the "Check Alive" parameter must
# the delivery. In BASIC mode the single validity gate below is a # behave AS IF IT DID NOT EXIST. The check-alive threshold drives
# no-op (see ``bal.core.checkalive.ensure_check_alive_valid``), so a # the "you are alive -> postpone the inheritance" prompt; raising
# passed check-alive date never forces a postpone/rewrite; the # CheckAliveError here is what triggers that postpone/invalidate
# delivery time (locktime) stays fully enforced. # flow. In BASIC we therefore SKIP this check entirely, so a passed
delivery_date = resolve_delivery_date( # check-alive date never forces a postpone/rewrite of the will. The
self.will_settings, # delivery time (locktime) is unaffected and still fully enforced.
built_locktime=Will.get_min_locktime(self.willitems), # The BASIC/ADVANCED rule lives in
) # ``bal.core.checkalive.check_alive_expired``.
ensure_check_alive_valid( if check_alive_expired(
self.bal_plugin.is_basic_mode(), self.bal_plugin.is_basic_mode(), self.date_to_check
self.date_to_check, ):
delivery_date=delivery_date, raise CheckAliveError(self.date_to_check)
)
self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get()) self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get())
@@ -779,29 +745,6 @@ 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(
@@ -814,11 +757,6 @@ 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(
@@ -838,19 +776,12 @@ class BalWindow:
except AmountException as e: except AmountException as e:
self.show_warning( self.show_warning(
_( _(
"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{}" f"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{e}"
).format(e) )
) )
except WillExecutorFeeTooHighException as e: except WillExecutorFeeTooHighException as e:
self.show_error( self.show_error(
_("Will-executor fee too high: {}").format(e) _(f"Will-executor fee too high: {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:
@@ -860,7 +791,8 @@ class BalWindow:
) )
) )
return return
if self.is_locktime_below_threshold(): locktime = Util.parse_locktime_string(self.will_settings["locktime"])
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:
@@ -936,27 +868,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(
"{}:\n {}\n{}".format(message, e, _("will have to be built")) f"{_(message)}:\n {e}\n{_('will have to be built')}"
) )
_logger.info("build will") _logger.info("build will")
@@ -976,7 +908,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!"),
) )
@@ -1036,9 +968,7 @@ 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(
"{}\n{}".format( _("Electrum was unable to deserialize the transaction:") + "\n" + str(e)
_("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.
@@ -1106,8 +1036,8 @@ class BalWindow:
def get_message(): def get_message():
msg = "" msg = ""
if signed: if signed:
msg = _("signed: {}").format(signed) + "\n" msg = _(f"signed: {signed}\n")
return msg + _("signing: {}").format(tosign) return msg + _(f"signing: {tosign}")
if txids is not None: if txids is not None:
targets = [ targets = [
@@ -1314,12 +1244,6 @@ 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()
@@ -1635,7 +1559,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
@@ -1689,8 +1613,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]
@@ -1698,7 +1622,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"),
@@ -2167,7 +2091,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 = N_( DOWNLOAD_FAILED_MESSAGE = (
"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) "
@@ -2177,7 +2101,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 = N_( DOWNLOAD_FAILED_TOR_MESSAGE = (
"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 "
@@ -2250,27 +2174,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(_(
"No active will-executor servers found for the " f"No active will-executor servers found for the "
"{chain} network.\n\n" f"{err.chain} network.\n\n"
"The welist server at {url} responded but " f"The welist server at {err.url} responded but "
"returned no will-executors for this chain." f"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(_(
"Could not reach the configured welist server.\n\n" f"Could not reach the configured welist server.\n\n"
"Server: {url}\n" f"Server: {err.url}\n"
"Error: {reason}\n\n" f"Error: {err.reason}\n\n"
"Please verify the welist server URL in the plugin " f"Please verify the welist server URL in the plugin "
"settings." f"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(_(
"No active will-executor found for the " f"No active will-executor found for the "
"{chain} network." f"{err.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.

View File

@@ -1,198 +0,0 @@
"""
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)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -9,12 +9,7 @@ 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
@@ -28,36 +23,14 @@ 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)
@@ -67,34 +40,17 @@ 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:
# .mo files are rebuilt from the .po sources below; the .po/.pot if fn.endswith((".pyc", ".pyo")):
# 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:
entries = [(f, None) for f in files] + [ 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
if po is None: z.write(f, arc)
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:
@@ -107,8 +63,7 @@ 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) + len(catalogs)}") print(f"files : {len(files)}")
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()}")

View File

@@ -32,18 +32,6 @@ 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)
@@ -176,7 +164,6 @@ 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

View File

@@ -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, Babel for python3 (pip install babel) # Requires: git, gpg, curl, python3, sha256sum
# 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,12 +33,6 @@ 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}"
@@ -74,8 +68,6 @@ 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

View File

@@ -3,15 +3,9 @@ line-length = 88
target-version = "py312" target-version = "py312"
[tool.ruff.lint] [tool.ruff.lint]
select =["E", "W", "F", "I", "N", "B", "INT"] # INT: gettext rules (see bal/i18n.py) select =["E", "W", "F", "I", "N", "B"]
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] [tool.ruff.lint.pep8-naming]
classmethod-decorators = ["classmethod", "classproperty"] # electrum.util.classproperty uses cls classmethod-decorators = ["classmethod", "classproperty"] # electrum.util.classproperty uses cls

View File

@@ -1,18 +0,0 @@
"""Shared pytest fixtures.
Guards every test against cross-file network pollution: several karen7
regtest modules historically flipped ``electrum.constants.net`` to regtest at
import time, which broke unrelated offline tests (e.g. the CLI controller
suite) run in the same pytest process.
"""
import pytest
from electrum import constants
@pytest.fixture(autouse=True)
def _restore_network():
"""Snapshot ``constants.net`` before each test and restore it after."""
prev = constants.net
yield
constants.net = prev

View File

@@ -18,7 +18,6 @@ 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))
@@ -26,8 +25,6 @@ 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"
@@ -230,39 +227,6 @@ 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
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -1,9 +1,11 @@
""" """
Tests for ``bal.core.animated_qr`` (BBQR interop). Tests for ``bal.core.animated_qr`` (BC-UR v1, BC-UR v2, BBQR interop).
Validates the self-contained BBQR codec against round trips, out-of-order Validates the self-contained codecs against the published spec vectors
assembly, compression default/fallback, malformed-input rejection and the (BCR-2020-004/005 BC32, BCR-2020-012 bytewords) and against byte-exact
format detection / parse_for_detection helpers. output captured from the reference C++ bc-ur encoder (fountain/xoshiro/
alias-sampler parity), plus round trips, out-of-order assembly, missing-part
fountain solving and malformed-input rejection for all four formats.
Run: Run:
source electrum/env/bin/activate source electrum/env/bin/activate
@@ -20,6 +22,328 @@ import random
from bal.core import animated_qr as aq from bal.core import animated_qr as aq
def _payload(plen: int) -> bytes:
"""Deterministic payload matching the C++ reference driver (``(i*7)&0xff``)."""
return bytes((i * 7) & 0xFF for i in range(plen))
# --------------------------------------------------------------------------- #
# BC32 (BCR-2020-004 / bcr-2020-005 rev1 reference implementation vectors)
# --------------------------------------------------------------------------- #
def test_bc32_official_vectors():
cases = [
(b"Hello, world", "fpjkcmr09ss8wmmjd3jq6ax7w9"),
(b"Hello world", "fpjkcmr0ypmk7unvvsh4ra4j"),
(
bytes.fromhex("d934063e82001eec0585ee41ab5d8e4b703a4be1f73aec21e143912c56"),
"my6qv05zqq0wcpv9aeq6khvwfdcr5jlp7uawcg0pgwgjc4shjm6xu",
),
]
for payload, encoded in cases:
assert aq.bc32_encode(payload) == encoded
assert aq.bc32_decode(encoded) == payload
def test_bc32_checksum_rejected():
good = aq.bc32_encode(b"Hello, world")
corrupted = good[:-1] + ("a" if good[-1] != "a" else "b")
try:
aq.bc32_decode(corrupted)
except aq.AnimatedQrError:
pass
else:
raise AssertionError("expected AnimatedQrError for corrupted BC32")
def test_bc32_bad_char_rejected():
try:
aq.bc32_decode("1" * 26)
except aq.AnimatedQrError:
pass
else:
raise AssertionError("expected AnimatedQrError for '1' (not in alphabet)")
# --------------------------------------------------------------------------- #
# Bytewords (BCR-2020-012)
# --------------------------------------------------------------------------- #
def test_bytewords_minimal_roundtrip():
samples = [bytes(range(256)), _payload(59), b"\x00"] + [
os.urandom(64) for _ in range(4)
]
for data in samples:
words = aq.bytewords_minimal_encode(data)
assert len(words) == (len(data) + 4) * 2 # 2 chars per byte incl. CRC
assert aq.bytewords_minimal_decode(words) == data
def test_bytewords_rejects_corrupted_crc():
data = _payload(40)
words = aq.bytewords_minimal_encode(data)
flip = "a" if words[-1] != "a" else "b"
try:
aq.bytewords_minimal_decode(words[:-1] + flip)
except aq.AnimatedQrError:
pass
else:
raise AssertionError("expected AnimatedQrError for corrupted CRC")
def test_bytewords_rejects_odd_length():
try:
aq.bytewords_minimal_decode("abc")
except aq.AnimatedQrError:
pass
else:
raise AssertionError("expected AnimatedQrError for odd-length bytewords")
# --------------------------------------------------------------------------- #
# BC-UR v2: byte-exact parity with the reference C++ encoder
# --------------------------------------------------------------------------- #
# Reference frames from the bc-ur C++ fountain encoder
# (payload x=(i*7)&0xFF, cbor wrapped, single-part and multipart).
REF_V2_SINGLE_12 = "ur:bytes/gsaeatbabzcecndrehetfhfggtoeemhpmo"
REF_V2_MULTI_59 = [
"ur:bytes/2-2/lpaoaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeeccasket",
"ur:bytes/3-2/lpaxaocsfscyrpdpjzbyhdcthdfraeatbabzcecndrehetfhfggtghhpidinjoktkblplkmunyoypdperpryssimryrldt",
"ur:bytes/4-2/lpaaaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaefeimteue",
"ur:bytes/5-2/lpahaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssgdaontls",
"ur:bytes/6-2/lpamaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssisescmwt",
"ur:bytes/7-2/lpataocsfscyrpdpjzbyhdcthdfraeatbabzcecndrehetfhfggtghhpidinjoktkblplkmunyoypdperprysslsspplgm",
"ur:bytes/8-2/lpayaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeonlrzebg",
"ur:bytes/9-2/lpasaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeaaryknzt",
"ur:bytes/10-2/lpbkaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnsslotsfrfn",
"ur:bytes/11-2/lpbdaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssdtwyrstd",
"ur:bytes/12-2/lpbnaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaegswnvdin",
"ur:bytes/13-2/lpbtaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnsshknlptee",
]
# Reference message for the 59-byte payload: byte-string head (0x58,0x3b) + data.
REF_V2_MULTI_59_MSG = bytes([0x58, 0x3B]) + _payload(59)
def test_v2_single_part_matches_reference():
frames = aq.ur2_frames(_payload(12), len(REF_V2_SINGLE_12))
assert frames == [REF_V2_SINGLE_12]
def test_v2_reference_frames_decode_and_reencode_exactly():
message = REF_V2_MULTI_59_MSG
fragment_len = -(-len(message) // 2)
for frame in REF_V2_MULTI_59:
seq, seq_len, message_len, checksum, data = aq.ur2_parse_part(frame)
assert seq_len == 2
assert message_len == len(message)
assert checksum == aq.crc32_int(message)
assert len(data) == fragment_len
# re-encoding the parsed values reproduces the reference line exactly
assert aq._ur2_part_string(seq, seq_len, message_len, checksum, data) == frame
# our choose_fragments + partition + xor reproduces the reference data
indexes = aq.choose_fragments(seq, seq_len, checksum)
assert seq_num_indexes_valid(seq, seq_len, indexes)
mixed = aq._mix_fragments(aq._partition_message(message, fragment_len), indexes, fragment_len)
assert mixed == data
def seq_num_indexes_valid(seq, seq_len, indexes):
# pure part for seq <= seq_len contains exactly fragment seq-1
if seq <= seq_len:
return indexes == {seq - 1}
return set(indexes) <= set(range(seq_len)) and bool(indexes)
def test_v2_multipart_encoder_matches_reference_from_seq2():
# Our frames start at seq 1 (spec-aligned); parts seq 2.. must equal the
# reference (which starts at seq 2 due to first_seq_num=1).
mine = aq.ur2_frames(_payload(59), 120)
assert mine[0].split("/", 1)[1].startswith("1-2") or "1-2" in mine[0].split("/")[1]
assert mine[1:4] == REF_V2_MULTI_59[:3]
def test_v2_reference_seq7_mix_parity():
# Higher-degree mixed parts (seq_len=7) also match: message uses the
# reference head 0x58|0x00 for the 256-byte driver payload.
message = bytes([0x58, 0x00]) + _payload(256)
seq_len = 7
fragment_len = -(-len(message) // seq_len)
frames = [
"ur:bytes/9-7/lpasatcfadaocyfysnjlsrhddaykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtntoxpyprrhrtsttotluovlwdwnsrfejzhd",
"ur:bytes/10-7/lpbkatcfadaocyfysnjlsrhddazeahbnbwcycldedlenfsfygrgmhkhniojtkpkelslememkneolpmqzrksasotitsuevwwpwfzswzpmdrvo",
"ur:bytes/11-7/lpbdatcfadaocyfysnjlsrhddawkwtbbbefnaefnbebbjojybebnaebndybbbewkwtceaecedyeebebbjobnaebnbeeedybbbeztwproyapd",
]
for frame in frames:
seq, sl, mlen, checksum, data = aq.ur2_parse_part(frame)
assert sl == seq_len and mlen == len(message)
assert checksum == aq.crc32_int(message)
mixed = aq._mix_fragments(
aq._partition_message(message, fragment_len),
aq.choose_fragments(seq, seq_len, checksum),
fragment_len,
)
assert mixed == data
# --------------------------------------------------------------------------- #
# BC-UR v2: sessions / fountain decoding
# --------------------------------------------------------------------------- #
def test_v2_roundtrip_in_order():
payload = ("BAL transfer " * 9).encode()
frames = aq.ur2_frames(payload, 120)
seq_len = int(frames[0].split("/")[1].split("-")[1])
assert len(frames) == 2 * seq_len # pure wave + redundant mixed wave
session = aq.AnimatedQrSession()
for frame in frames:
session.add_part(frame)
assert session.done
assert session.received == session.total
text, _ = session.resolve()
assert text == payload.decode()
def test_v2_out_of_order_and_duplicate():
payload = ("BAL transfer " * 9).encode()
frames = aq.ur2_frames(payload, 120)
order = list(range(len(frames)))
random.Random(11).shuffle(order)
session = aq.AnimatedQrSession()
for i in order:
status = session.add_part(frames[i])
assert status in ("ok", "dup")
session.add_part(frames[0]) # duplicate of an already-received part
assert session.done
assert session.resolve()[0] == payload.decode()
def test_v2_solves_without_a_pure_fragment():
payload = ("BAL transfer " * 9).encode()
frames = aq.ur2_frames(payload, 120)
session = aq.AnimatedQrSession()
for frame in frames[1:]: # drop the first pure fragment
session.add_part(frame)
assert session.done
assert session.resolve()[0] == payload.decode()
def test_v2_single_part_import():
session = aq.AnimatedQrSession()
session.add_part(REF_V2_SINGLE_12)
assert session.done and session.total == 1
assert session.resolve()[0] == _payload(12).decode("latin-1")
def test_v2_conflicting_transfer_rejected():
payload_a = b"AAAAAAAAAAAAAAAA"
payload_b = b"BBBBBBBBBBBBBBBB"
fa = aq.ur2_frames(payload_a, 500)[0]
fb = aq.ur2_frames(payload_b, 500)[0]
session = aq.AnimatedQrSession()
session.add_part(fa)
try:
session.add_part(fb)
except aq.TransferConflictError:
pass
else:
raise AssertionError("expected TransferConflictError for a different transfer")
def test_v2_corrupt_crc_rejected():
frame = list(REF_V2_MULTI_59[0])
idx = len(frame) - 1
frame[idx] = "a" if frame[idx] != "a" else "b"
try:
aq.ur2_parse_part("".join(frame))
except aq.AnimatedQrError:
pass
else:
raise AssertionError("expected AnimatedQrError for a corrupt v2 part")
def test_v2_session_cap_rejected():
part = aq._ur2_part_string(1, 30000, 100, 1234, b"\x00" * 100)
session = aq._Ur2Session()
try:
session.add(part)
except aq.SessionLimitError:
pass
else:
raise AssertionError("expected SessionLimitError for oversized seq_len")
# --------------------------------------------------------------------------- #
# BC-UR v1
# --------------------------------------------------------------------------- #
def test_v1_multipart_roundtrip():
payload = ("v1 transfer payload " * 6).encode()
frames = aq.ur1_frames(payload, 120)
assert len(frames) > 1
session = aq.AnimatedQrSession()
for frame in reversed(frames):
session.add_part(frame)
assert session.done
assert session.resolve()[0] == payload.decode()
def test_v1_single_part_roundtrip():
payload = b"hello, bal"
frames = aq.ur1_frames(payload, 400)
assert len(frames) == 1
session = aq.AnimatedQrSession()
session.add_part(frames[0])
assert session.done and session.total == 1
assert session.resolve()[0] == payload.decode()
def test_v1_headerless_single_part_import():
# bcr-2020-005 rev1 allows omitting the sequence header + digest entirely.
payload = b"hello, bal"
message = aq.cbor_byte_string(payload)
single = "ur:bytes/" + aq.bc32_encode(message)
assert aq.detect_format(single) == "ur1"
session = aq.AnimatedQrSession()
session.add_part(single)
assert session.done
assert session.resolve()[0] == payload.decode()
def test_v1_digest_mismatch_rejected():
frame = aq.ur1_frames(b"hello, bal", 400)[0]
tampered = frame[:-4] + "abcd"
session = aq.AnimatedQrSession()
session.add_part(tampered)
try:
session.resolve()
except aq.ChecksumError:
pass
else:
raise AssertionError("expected ChecksumError for a tampered v1 digest")
def test_v1_part_numbers_validated():
for bad in (
"ur:bytes/0of1/{}full".format("x" * 51),
"ur:bytes/2of1/{}full".format("x" * 51),
"ur:bytes/1of0/{}full".format("x" * 51),
"ur:bytes/1aof1/{}full".format("x" * 51),
):
try:
aq.ur1_parse_part(bad)
except aq.AnimatedQrError:
pass
else:
raise AssertionError("expected AnimatedQrError for: {}".format(bad))
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# BBQR # BBQR
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -100,22 +424,37 @@ def test_bbqr_part_number_limits():
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_detect_format_recognises_bbqr(): def test_detect_format_recognises_all_formats():
assert aq.detect_format("BALQR1|1|1||payload") == "balqr"
assert aq.detect_format(aq.ur1_frames(b"x", 400)[0]) == "ur1"
assert aq.detect_format(aq.ur2_frames(b"x", 400)[0]) == "ur2"
assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "bbqr" assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "bbqr"
assert aq.detect_format(REF_V2_SINGLE_12) == "ur2"
assert aq.detect_format("ur:bytes/" + aq.bc32_encode(aq.cbor_byte_string(b"x"))) == "ur1"
def test_detect_format_rejects_garbage(): def test_detect_format_rejects_garbage():
for text in ("", "hello world", "BALQR1|1|1||payload", "ur:", "ur:bytes/xyz"): for text in ("", "hello world", "BALQ|1|1||a", "ur:", "ur:txn/xyz"):
assert aq.detect_format(text) is None, text assert aq.detect_format(text) is None, text
# Lenient prefix probe: a string that merely *starts* with "balqr" is
# reported as balqr (the strict parse then rejects it downstream).
assert aq.detect_format("BALQRX|1|1||a") == "balqr"
def test_parse_for_detection_keys(): def test_parse_for_detection_keys():
bb = aq.parse_for_detection(aq.bbqr_frames(b"x" * 50, 40)[0]) bal = aq.parse_for_detection("BALQR1|3|2||payload")
assert bal == ("balqr", "balqr:3", 3, 2)
v2 = aq.parse_for_detection(aq.ur2_frames(b"x"*50, 400)[0])
assert v2[0] == "ur2" and v2[2] == 1 and v2[3] == 1
v1 = aq.parse_for_detection(aq.ur1_frames(b"x"*50, 120)[0])
assert v1[0] == "ur1" and v1[2] > 1 and 1 <= v1[3] <= v1[2]
bb = aq.parse_for_detection(aq.bbqr_frames(b"x"*50, 40)[0])
assert bb[0] == "bbqr" and bb[2] >= 1 and 0 <= bb[3] < bb[2] assert bb[0] == "bbqr" and bb[2] >= 1 and 0 <= bb[3] < bb[2]
def test_format_names_exist(): def test_format_names_exist():
assert aq.format_name("bbqr") for fmt in ("balqr", "ur1", "ur2", "bbqr"):
assert aq.format_name(fmt)
assert aq.format_name("nope") == "nope" assert aq.format_name("nope") == "nope"

View File

@@ -1,11 +1,8 @@
""" """
Tests for ``bal.core.checkalive`` (pure, GUI-free). Tests for ``bal.core.checkalive`` (pure, GUI-free).
Covers the CheckAlive exceptions and the BASIC/ADVANCED date_to_check policy Covers the CheckAliveError exception and the BASIC/ADVANCED date_to_check
(``resolve_date_to_check`` / ``resolve_relative_check_alive_date`` / policy (``resolve_date_to_check`` / ``check_alive_expired``).
``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"
@@ -14,41 +11,23 @@ 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,
ensure_check_alive_valid, check_alive_expired,
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 / CheckAliveAfterDeliveryError # CheckAliveError
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
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():
@@ -58,107 +37,10 @@ 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
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -176,60 +58,69 @@ def test_advanced_mode_uses_threshold_absolute():
assert abs(result - threshold) < 1 assert abs(result - threshold) < 1
def test_advanced_mode_negative_relative_anchored_to_delivery(): def test_advanced_mode_parses_relative_threshold():
# A relative threshold means "N days BEFORE the delivery": it resolves
# against the stored locktime (backwards), not forward from now.
from datetime import datetime, timedelta, timezone
fake_now = 1_800_000_000.0 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)
expected = _midnight(locktime) - 30 * 86400 # date_to_check = (locktime, midnight-normalised) - 30 days.
expected = (datetime.fromtimestamp(locktime, tz=timezone.utc)
.replace(hour=0, minute=0, second=0, microsecond=0)
- timedelta(days=30)).timestamp()
assert abs(result - expected) < 1 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_positive_relative_anchored_to_creation(): def test_advanced_mode_relative_threshold_anchored_to_locktime():
fake_now = 1_800_000_000.0 """A relative threshold never drifts with the clock: re-resolving it a day
creation = fake_now + 10 * 86400 later, with the same fixed absolute locktime, yields the same date."""
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_positive_relative_creation_never_drifts(): def test_advanced_mode_relative_threshold_with_relative_locktime():
"""A positive relative threshold anchored to the frozen creation date does """A relative locktime is resolved against 'now' first, then the relative
not drift with the clock.""" threshold counts N days back from it (matches the settings widget)."""
from datetime import datetime, timezone
from bal.core.plugin_base import BalTimestamp
fake_now = 1_800_000_000.0 fake_now = 1_800_000_000.0
creation = fake_now + 2 * 86400 settings = {"threshold": "30d", "locktime": "90d"}
settings = {"threshold": "30d", "locktime": fake_now + 365 * 86400} result = resolve_date_to_check(False, settings, now=fake_now)
first = resolve_date_to_check( # Recompute the expected value with the same resolution rules:
False, settings, now=fake_now, creation_date=creation # locktime = now + 90d (midnight-normalised), threshold = locktime - 30d.
) locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc))
second = resolve_date_to_check( expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
False, settings, now=fake_now + 5 * 86400, creation_date=creation assert abs(result - expected) < 1
) 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, built 2026-08-05 frozen = 1817424000 # frozen tx locktime (2027-08-05 00:00 UTC), 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
) )
@@ -238,164 +129,41 @@ 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(fake_now) locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc))
expected = BalTimestamp("-30d").to_date(locktime_dt).timestamp() expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
assert abs(result - expected) < 1 assert abs(result - expected) < 1
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# resolve_guard_threshold # check_alive_expired
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
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 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 assert check_alive_expired(True, time.time() - 10_000) is False
def test_advanced_raises_when_past(): def test_advanced_expired_when_past():
try: assert check_alive_expired(False, time.time() - 10_000) is True
ensure_check_alive_valid(False, time.time() - 10_000)
except CheckAliveError:
pass
else:
raise AssertionError("expected CheckAliveError")
def test_advanced_raises_when_after_delivery(): def test_advanced_not_expired_when_future():
now = time.time() assert check_alive_expired(False, time.time() + 10_000) is False
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
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -36,7 +36,6 @@ 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
@@ -168,32 +167,6 @@ def test_heirs_amount_to_float():
assert heirs.amount_to_float("notanumber") == 0.0 assert heirs.amount_to_float("notanumber") == 0.0
def test_fixed_percent_lists_uses_build_anchor_for_relative_heirs():
"""A relative heir must survive the amount filter when the build anchor
(``from_locktime``) is recalculated for the anticipated delivery.
Before the fix, ``build_will`` kept ``date_to_check`` anchored to the OLD
(longer) built will; an "1y" heir resolved before that anchor was excluded
by the ``cmp <= 0`` filter and the build reported NO_FUTURE_DATE. With the
anchor recomputed for the new locktime (karen7: 2y -> 1y delivery) the
"1y" heir is kept.
"""
wallet = FakeWallet()
heirs = Heirs(wallet)
heirs["carol"] = ["addr1", "100%", "1y"]
# Stale anchor (old built 2y will still frozen): "1y" is in the past
# relative to it -> excluded from the amount calculation.
stale_anchor = Util.parse_locktime_string("2y") - 150 * 86400
_, _, percent_heirs, _, _ = heirs.fixed_percent_lists_amount(stale_anchor, 500)
assert "carol" not in percent_heirs
# Recalculated anchor for the new (1y) delivery: the heir is retained.
new_anchor = Util.parse_locktime_string("1y") - 150 * 86400
_, _, percent_heirs, _, _ = heirs.fixed_percent_lists_amount(new_anchor, 500)
assert "carol" in percent_heirs
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Validation (static methods) # Validation (static methods)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -117,30 +117,6 @@ 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
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -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"

View File

@@ -1,8 +1,10 @@
""" """
Tests for ``bal.core.qrtransfer``. Tests for ``bal.core.qrtransfer``.
Covers the transfer-string encoding used for will transfer via BBQR QR codes Covers the BALQR frame encoding used for will transfer via QR codes /
/ audio modem: encoding, decoding and the preset list. audio modem: encoding, framing, reassembly, malformed input and the preset
list (optionally cross-checked against the ``qrcode`` library's EC-M
capacity when it is installed).
Run: Run:
source electrum/env/bin/activate source electrum/env/bin/activate
@@ -16,12 +18,34 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.qrtransfer import ( from bal.core.qrtransfer import (
CHUNK_PRESETS, CHUNK_PRESETS,
MIN_CHUNK_SIZE,
InconsistentTotalError,
MissingFramesError,
QrTransferError,
assemble,
decode_transfer, decode_transfer,
encode_transfer, encode_transfer,
parse_frame,
preset_index_for_chunk_size, preset_index_for_chunk_size,
split_frames,
) )
def _frames(tx_strings, chunk_size, compress=False):
"""Split a payload and return (payload, total, {index: payload})."""
payload = encode_transfer(tx_strings, compress=compress)
parsed = {}
total = None
for frame in split_frames(payload, chunk_size, compressed=compress):
t, index, _compressed, p = parse_frame(frame)
if total is not None:
assert total == t
total = t
parsed[index] = p
assert total is not None
return payload, total, parsed
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Round trips # Round trips
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -43,6 +67,187 @@ def test_empty_list_roundtrip():
assert decode_transfer(encode_transfer([]), compressed=False) == [] assert decode_transfer(encode_transfer([]), compressed=False) == []
# --------------------------------------------------------------------------- #
# Framing
# --------------------------------------------------------------------------- #
def test_single_frame():
tx_strings = ["11" * 10]
payload = encode_transfer(tx_strings)
frames = split_frames(payload, 150)
assert len(frames) == 1
total, index, compressed, p = parse_frame(frames[0])
assert (total, index, compressed) == (1, 1, False)
assert p == payload
def test_multiple_frames_reassemble():
tx_strings = ["ab" * 100, "cd" * 100] # 600 chars -> multiple frames
_payload, total, parsed = _frames(tx_strings, CHUNK_PRESETS[0][1])
assert total > 1
decoded = decode_transfer(assemble(parsed, total), compressed=False)
assert decoded == tx_strings
def test_size_greater_than_payload():
tx_strings = ["12" * 5]
payload = encode_transfer(tx_strings)
frames = split_frames(payload, 1800)
assert len(frames) == 1
_t, _i, _c, p = parse_frame(frames[0])
assert p == payload
def test_exact_single_frame_boundary():
# A 138-byte payload exactly fills the 150-byte preset budget (the 12-char
# empty-flags header plus payload), so the encoded frame is exactly 150.
tx_strings = ["a" * 138]
payload = encode_transfer(tx_strings)
frames = split_frames(payload, 150)
assert len(frames) == 1
assert len(frames[0]) == 150
_t, _i, _c, p = parse_frame(frames[0])
assert p == payload
def test_frames_fit_chunk_size():
tx_strings = ["".join("{:02x}".format(i) * 2) for i in range(300)]
payload = encode_transfer(tx_strings)
for _label, size in CHUNK_PRESETS:
for frame in split_frames(payload, size):
assert len(frame) <= size, (size, len(frame))
def test_single_tx_larger_than_chunk():
# A huge serialized tx must be split over several frames and reassemble
# exactly (positional slicing is safe for hex/base64 text).
tx_strings = ["7b" * 1000] # 2000 chars
payload = encode_transfer(tx_strings)
frames = split_frames(payload, 150)
assert len(frames) > 1
parsed = {parse_frame(f)[1]: parse_frame(f)[3] for f in frames}
total = parse_frame(frames[0])[0]
assert assemble(parsed, total) == payload
def test_compressed_frames_carry_flag():
tx_strings = ["ab" * 40]
frames = split_frames(encode_transfer(tx_strings, compress=True), 150, compressed=True)
for frame in frames:
_t, _i, compressed, _p = parse_frame(frame)
assert compressed is True
# Plain frames do not.
frames_plain = split_frames(encode_transfer(tx_strings), 150)
_t, _i, compressed, _p = parse_frame(frames_plain[0])
assert compressed is False
def test_compressed_roundtrip_through_frames():
tx_strings = ["ab" * 50, "cd" * 50, "12" * 60]
payload = encode_transfer(tx_strings, compress=True)
parsed = {}
total = None
for frame in split_frames(payload, 400, compressed=True):
t, index, _c, p = parse_frame(frame)
total = t
parsed[index] = p
assert total is not None
decoded = decode_transfer(assemble(parsed, total), compressed=True)
assert decoded == tx_strings
# --------------------------------------------------------------------------- #
# Malformed input
# --------------------------------------------------------------------------- #
def test_parse_bad_magic_and_version():
for frame in (
"BALQR|1|1||a", # missing version
"BALQR2|1|1||a", # unknown version
"XXXXX1|1|1||a", # unknown magic
):
try:
parse_frame(frame)
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError for: {}".format(frame))
def test_parse_bad_arity():
for frame in ("BALQR1", "BALQR1|1|1|"):
try:
parse_frame(frame)
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError for: {}".format(frame))
def test_parse_pipe_in_payload_is_folded():
# maxsplit keeps the tail (including any inner '|') in the payload part.
frame = "BALQR1|1|1||a|b|c"
total, index, compressed, payload = parse_frame(frame)
assert (total, index, compressed) == (1, 1, False)
assert payload == "a|b|c"
def test_parse_bad_numbers():
for frame in (
"BALQR1|x|1||a",
"BALQR1|1|y||a",
"BALQR1|0|1||a",
"BALQR1|1|0||a",
"BALQR1|1|2||a", # index beyond total
"BALQR1|-1|1||a",
):
try:
parse_frame(frame)
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError for: {}".format(frame))
def test_parse_bad_flags():
try:
parse_frame("BALQR1|1|1|Q|payload")
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError for unknown flags")
def test_assemble_missing_frames():
try:
assemble({1: "a", 3: "c"}, total=3)
except MissingFramesError as e:
assert e.missing == [2]
else:
raise AssertionError("expected MissingFramesError")
def test_assemble_index_beyond_total():
try:
assemble({1: "a", 2: "b"}, total=1)
except InconsistentTotalError:
pass
else:
raise AssertionError("expected InconsistentTotalError")
def test_assemble_order_and_total_validation():
assert assemble({1: "a", 2: "b"}, total=2) == "ab"
try:
assemble({}, total=0)
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError")
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Constants / presets # Constants / presets
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -61,6 +266,46 @@ def test_preset_index_for_chunk_size():
assert preset_index_for_chunk_size(1800) == 3 assert preset_index_for_chunk_size(1800) == 3
def test_min_chunk_size_guard():
try:
split_frames("x" * 10, MIN_CHUNK_SIZE - 1)
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError for tiny chunk size")
def test_split_frame_headers_consistent():
tx_strings = ["ab" * 80]
payload = encode_transfer(tx_strings)
frames = split_frames(payload, 150)
totals = {parse_frame(frame)[0] for frame in frames}
assert len(totals) == 1
assert totals.pop() == len(frames)
# --------------------------------------------------------------------------- #
# Optional: cross-check presets against the qrcode library (EC level M)
# --------------------------------------------------------------------------- #
def test_presets_fit_qrcode_ec_m():
"""Every preset budget must render inside a QR at EC level M."""
try:
import qrcode
from qrcode.constants import ERROR_CORRECT_M
except ImportError:
print("qrcode not installed - skipping capacity check")
return
for _label, size in CHUNK_PRESETS:
# Worst-case frame: header with the largest plausible total/index plus
# a full payload of the preset budget.
frame = "BALQR1|9999|9999|Z|" + "a" * (size - 14)
qr = qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2)
qr.add_data(frame)
qr.get_matrix() # raises DataOverflowError if it does not fit
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
if __name__ == "__main__": if __name__ == "__main__":
import traceback import traceback

View File

@@ -1,136 +0,0 @@
"""
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")

View File

@@ -13,14 +13,8 @@ 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.checkalive import resolve_date_to_check
from bal.core.util import copy_structure from bal.core.util import copy_structure
from bal.core.will import ( from bal.core.will import Will, WillItem
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 = (
@@ -216,59 +210,6 @@ 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.

View File

@@ -450,12 +450,6 @@ 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)
@@ -843,73 +837,6 @@ 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
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -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;
* ``ensure_check_alive_valid(is_basic_mode, ...)`` - whether the * ``check_alive_expired(is_basic_mode, date_to_check)`` - 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,8 +33,7 @@ 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,
ensure_check_alive_valid, check_alive_expired,
is_check_alive_date_valid,
resolve_date_to_check, resolve_date_to_check,
) )
@@ -73,29 +72,23 @@ 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 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 assert check_alive_expired(True, 1_000_000_000.0) is False
def test_advanced_mode_expired_when_past(): def test_advanced_mode_expired_when_past():
assert is_check_alive_date_valid(time.time() - 10_000, None) is False 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_mode_not_expired_when_future(): def test_advanced_mode_not_expired_when_future():
assert is_check_alive_date_valid(time.time() + 10_000, None) is True assert check_alive_expired(False, time.time() + 10_000) is False
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 ensure_check_alive_valid(True, date_to_check) is None assert not check_alive_expired(True, date_to_check)
# 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)

View File

@@ -52,13 +52,9 @@ class _Cfg:
def get(self): def get(self):
return self._value return self._value
def set(self, value, save=True):
self._value = value
class FakePlugin: class FakePlugin:
def __init__(self): QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
self.QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
def read_file(self, path): def read_file(self, path):
return _PNG_BYTES return _PNG_BYTES
@@ -305,7 +301,6 @@ def test_audio_export_page_send():
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin) d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
assert d.audio_send_btn.text() == dialogs._("Send") assert d.audio_send_btn.text() == dialogs._("Send")
assert d.audio_send_btn.isEnabled() assert d.audio_send_btn.isEnabled()
assert d.transport_audio.isEnabled()
assert d.kbps_combo.count() > 0 assert d.kbps_combo.count() > 0
kbps = int(d.kbps_combo.currentText()) kbps = int(d.kbps_combo.currentText())
@@ -339,7 +334,6 @@ def test_audio_export_page_plugin_missing():
bw.willitems = _make_willitems(2) bw.willitems = _make_willitems(2)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin) d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
assert not d.audio_send_btn.isEnabled() assert not d.audio_send_btn.isEnabled()
assert not d.transport_audio.isEnabled()
assert "not available" in d.audio_warn_label.text() assert "not available" in d.audio_warn_label.text()
assert len(d.qr_page.frames) >= 1 # QR still usable assert len(d.qr_page.frames) >= 1 # QR still usable
assert d.file_export_btn.isEnabled() assert d.file_export_btn.isEnabled()
@@ -356,7 +350,6 @@ def test_audio_import_page_build():
assert d.kbps_combo.count() > 0 assert d.kbps_combo.count() > 0
assert d.receive_btn.text() == dialogs._("Receive by audio…") assert d.receive_btn.text() == dialogs._("Receive by audio…")
assert d.receive_btn.isEnabled() assert d.receive_btn.isEnabled()
assert d.transport_audio.isEnabled()
d.close() d.close()
@@ -364,7 +357,6 @@ def test_audio_import_page_plugin_missing():
bw = FakeBalWindow(audio_plugin=None) bw = FakeBalWindow(audio_plugin=None)
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
assert not d.receive_btn.isEnabled() assert not d.receive_btn.isEnabled()
assert not d.transport_audio.isEnabled()
assert "not available" in d.audio_warn_label.text() assert "not available" in d.audio_warn_label.text()
assert d.qr_page is not None # QR import still usable assert d.qr_page is not None # QR import still usable
d.close() d.close()

View File

@@ -179,7 +179,6 @@ 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"),
@@ -205,46 +204,6 @@ 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
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -4,10 +4,9 @@ Tests for the QR / audio will-transfer dialogs (``bal.gui.qt.dialogs``).
Covers the unified ``WillExportDialog`` (transport radios, stacked pages, Covers the unified ``WillExportDialog`` (transport radios, stacked pages,
QR build/navigation, chunk-size / autoplay, filter revert) and the unified QR build/navigation, chunk-size / autoplay, filter revert) and the unified
``WillImportDialog`` (QR frame capture, slot grid, complete-review enabling, ``WillImportDialog`` (QR frame capture, slot grid, complete-review enabling,
total mismatch reset, frame assembly -> decode). The QR transport now uses total mismatch reset, frame assembly -> decode). The wizard and the
only the BBQR wire format. The wizard and the camera/audio paths need a live camera/audio paths need a live wallet/hardware and are exercised only
wallet/hardware and are exercised only through the shared frame-assembly through the shared frame-assembly path here.
path here.
Run: Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py
@@ -15,7 +14,6 @@ Run:
import base64 import base64
import json import json
import os
import sys import sys
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -25,8 +23,7 @@ from electrum.transaction import Transaction
from PyQt6.QtWidgets import QApplication, QMainWindow from PyQt6.QtWidgets import QApplication, QMainWindow
import bal.gui.qt.dialogs as dialogs import bal.gui.qt.dialogs as dialogs
from bal.core.animated_qr import bbqr_frames from bal.core.qrtransfer import CHUNK_PRESETS, encode_transfer, split_frames
from bal.core.qrtransfer import CHUNK_PRESETS, encode_transfer
from bal.core.will import WillItem from bal.core.will import WillItem
_app = QApplication.instance() or QApplication(sys.argv) _app = QApplication.instance() or QApplication(sys.argv)
@@ -49,15 +46,6 @@ _VALID_TX_HEX = (
) )
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: class _Cfg:
def __init__(self, value): def __init__(self, value):
self._value = value self._value = value
@@ -65,14 +53,10 @@ class _Cfg:
def get(self): def get(self):
return self._value return self._value
def set(self, value, save=True):
self._value = value
class FakePlugin: class FakePlugin:
# Smallest QR preset: long transfers produce several frames. # Smallest QR preset: long transfers produce several frames.
def __init__(self): QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
self.QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
def read_file(self, path): def read_file(self, path):
return _PNG_BYTES return _PNG_BYTES
@@ -123,12 +107,10 @@ class StubWillItem:
return {"tx": str(self.tx), "status": self.statuses} return {"tx": str(self.tx), "status": self.statuses}
def _make_willitems(n=3, payload_len=120, payloads=None): def _make_willitems(n=3, payload_len=120):
if payloads is None:
payloads = [_rand_hex(payload_len) for _ in range(n)]
return { return {
"item{}".format(i): StubWillItem(p) "item{}".format(i): StubWillItem("T{}".format(i) * payload_len)
for i, p in enumerate(payloads) for i in range(n)
} }
@@ -145,9 +127,8 @@ def test_export_dialog_builds():
assert page.tx_strings assert page.tx_strings
assert page.frames assert page.frames
assert len(page.frames) >= 1 assert len(page.frames) >= 1
# Frame 1 is shown, and it is a BBQR frame. # Frame 1 is shown.
assert page.qr_view.text == page.frames[0] assert page.qr_view.text == page.frames[0]
assert page.frames[0].startswith("B$")
assert "1" in page.progress_label.text() assert "1" in page.progress_label.text()
d.close() d.close()
@@ -213,7 +194,6 @@ def test_export_auto_scroll():
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page page = d.qr_page
assert page.fps_spin is not None assert page.fps_spin is not None
assert len(page.frames) > 1
assert not page.auto_timer.isActive() assert not page.auto_timer.isActive()
page.fps_spin.setValue(2) page.fps_spin.setValue(2)
@@ -240,7 +220,6 @@ def test_export_auto_scroll_loop():
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page page = d.qr_page
assert page.loop_check is not None 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. # Loop on: reaching the last code wraps back to the first and keeps going.
page.loop_check.setChecked(True) page.loop_check.setChecked(True)
@@ -263,9 +242,9 @@ def test_export_auto_scroll_loop():
def test_export_filter_valid_and_valid_nc(): def test_export_filter_valid_and_valid_nc():
bw = FakeBalWindow() bw = FakeBalWindow()
a = StubWillItem(_rand_hex(120), statuses={"VALID": True, "COMPLETE": True}) a = StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True})
b = StubWillItem(_rand_hex(120), statuses={"VALID": True}) b = StubWillItem("B" * 120, statuses={"VALID": True})
c = StubWillItem(_rand_hex(120)) c = StubWillItem("C" * 120)
bw.willitems = {"a": a, "b": b, "c": c} bw.willitems = {"a": a, "b": b, "c": c}
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page page = d.qr_page
@@ -282,11 +261,11 @@ def test_export_filter_valid_and_valid_nc():
# "Valid" filter -> only the valid items (a, b). # "Valid" filter -> only the valid items (a, b).
d._on_filter_change(1) d._on_filter_change(1)
assert sorted(page.tx_strings) == sorted([str(a.tx), str(b.tx)]) assert sorted(page.tx_strings) == ["A" * 120, "B" * 120]
# "Valid NC" filter -> only the valid, not-complete item (b). # "Valid NC" filter -> only the valid, not-complete item (b).
d._on_filter_change(2) d._on_filter_change(2)
assert sorted(page.tx_strings) == [str(b.tx)] assert sorted(page.tx_strings) == ["B" * 120]
assert page.qr_view.text == page.frames[0] assert page.qr_view.text == page.frames[0]
d.close() d.close()
@@ -295,7 +274,7 @@ def test_export_filter_empty_reverts():
bw = FakeBalWindow() bw = FakeBalWindow()
# Only a COMPLETE valid item: "Valid NC" selects nothing -> revert. # Only a COMPLETE valid item: "Valid NC" selects nothing -> revert.
bw.willitems = { bw.willitems = {
"a": StubWillItem(_rand_hex(120), statuses={"VALID": True, "COMPLETE": True}) "a": StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True})
} }
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
messages = [] messages = []
@@ -309,9 +288,6 @@ def test_export_filter_empty_reverts():
def test_export_navigation_and_chunk_change(): 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 = FakeBalWindow()
bw.willitems = _make_willitems(n=6, payload_len=400) bw.willitems = _make_willitems(n=6, payload_len=400)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
@@ -335,20 +311,6 @@ def test_export_navigation_and_chunk_change():
d.close() 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) # WillImportDialog (QR transport via d.qr_page)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -360,8 +322,8 @@ def test_import_frame_flow():
assert not page.review_btn.isEnabled() assert not page.review_btn.isEnabled()
assert not page.slot_area.isVisible() assert not page.slot_area.isVisible()
transfer = encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)]) transfer = encode_transfer(["A" * 120, "B" * 120, "C" * 120])
frames = _bbqr_frames(transfer, 150) frames = split_frames(transfer, 150)
assert len(frames) > 1 assert len(frames) > 1
for frame in frames: for frame in frames:
@@ -388,14 +350,12 @@ def test_import_assembles_and_decodes():
captured["payload"] = payload captured["payload"] = payload
page._finish_import = fake_finish page._finish_import = fake_finish
tx_a = _rand_hex(130) transfer = encode_transfer(["P" * 130, "Q" * 130])
tx_b = _rand_hex(130) for frame in split_frames(transfer, 150):
transfer = encode_transfer([tx_a, tx_b])
for frame in _bbqr_frames(transfer, 150):
page._add_frame(frame) page._add_frame(frame)
page._review_and_sign() page._review_and_sign()
# The payload is rejoined into an opaque string for autodetect. # The payload is rejoined into an opaque string for autodetect.
assert captured["payload"].split("\n") == [tx_a, tx_b] assert captured["payload"].split("\n") == ["P" * 130, "Q" * 130]
d.close() d.close()
@@ -436,7 +396,7 @@ def test_whole_will_qr_roundtrip():
transfer = encode_transfer(page.tx_strings) transfer = encode_transfer(page.tx_strings)
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
import_page = impl.qr_page import_page = impl.qr_page
for frame in _bbqr_frames(transfer, dialogs.CHUNK_PRESETS[0][1]): for frame in split_frames(transfer, dialogs.CHUNK_PRESETS[0][1]):
import_page._add_frame(frame) import_page._add_frame(frame)
assert import_page.review_btn.isEnabled() assert import_page.review_btn.isEnabled()
caught = {} caught = {}
@@ -460,10 +420,8 @@ def test_import_total_mismatch_resets():
warnings = [] warnings = []
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment] 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_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
frames_b = _bbqr_frames( frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150)
encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)]), 150
)
for frame in frames_a: for frame in frames_a:
page._add_frame(frame) page._add_frame(frame)
assert page.total == len(frames_a) assert page.total == len(frames_a)
@@ -482,7 +440,7 @@ def test_import_manual_entry():
bw = FakeBalWindow() bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page page = d.qr_page
frames = _bbqr_frames(encode_transfer(["M" * 20]), 150) frames = split_frames(encode_transfer(["M" * 120]), 150)
page.manual_edit.setText(frames[0]) page.manual_edit.setText(frames[0])
page._add_from_manual() page._add_from_manual()
assert page.manual_edit.text() == "" assert page.manual_edit.text() == ""
@@ -508,48 +466,48 @@ def _fresh_debounce():
def test_qr_import_debounce_pending_then_accept(): def test_qr_import_debounce_pending_then_accept():
s = _fresh_debounce() s = _fresh_debounce()
# First sighting of a new identity: pending, not yet stored. # 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 dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "PAYLOAD1") == "pending"
assert s["pending_count"] == 1 assert s["pending_count"] == 1
assert s["last_index"] is None assert s["last_index"] is None
# A second stable read of the same identity: accepted. # 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 dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "PAYLOAD1") == "accept"
assert s["last_index"] == 0 assert s["last_index"] == 1
assert s["last_payload"] == "PAYLOAD1" assert s["last_payload"] == "PAYLOAD1"
assert s["pending_count"] == 0 assert s["pending_count"] == 0
def test_qr_import_debounce_re_reading_last_is_ignored(): def test_qr_import_debounce_re_reading_last_is_ignored():
s = _fresh_debounce() s = _fresh_debounce()
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1")
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") # accepted dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") # accepted
# The exporter is still showing frame 0: must be ignored, not accepted. # The exporter is still showing frame 1: must be ignored, not accepted.
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") == "ignore" assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") == "ignore"
assert s["last_index"] == 0 assert s["last_index"] == 1
assert s["pending_count"] == 0 assert s["pending_count"] == 0
def test_qr_import_debounce_transition_pending_resets(): def test_qr_import_debounce_transition_pending_resets():
s = _fresh_debounce() s = _fresh_debounce()
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1")
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") # accept frame 0 dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") # accept frame 1
# A new identity interrupts the pending accumulation. # 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, "balqr", "balqr:2", 2, 2, "P2") == "pending"
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "accept" assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "accept"
# Same-index duplicate with different payload is treated as new identity. # 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" assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "ignore"
def test_qr_import_debounce_total_mismatch_resets(): def test_qr_import_debounce_total_mismatch_resets():
s = _fresh_debounce() s = _fresh_debounce()
# In-range frame is accepted even though its declared total is ignored. # 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, "balqr", "balqr:3", 3, 2, "P2") == "pending"
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:3", 3, 1, "P2") == "accept" assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:3", 3, 2, "P2") == "accept"
# A frame that belongs to a different transfer. # A frame that belongs to a different transfer.
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:4", 4, 1, "P2B") == "reset" assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:4", 4, 2, "P2B") == "reset"
# Once the policy is rebased on the new transfer, frames resume normally # Once the policy is rebased on the new transfer, frames resume normally
# (the widget clears the debounce while wiping the import). # (the widget clears the debounce while wiping the import).
s["key"] = None s["key"] = None
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:4", 4, 1, "P2B") == "pending" assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:4", 4, 2, "P2B") == "pending"
def test_qr_import_handle_scanned_text_autofinish(): def test_qr_import_handle_scanned_text_autofinish():
@@ -559,7 +517,7 @@ def test_qr_import_handle_scanned_text_autofinish():
reviewed = [] reviewed = []
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment] page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
frames = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150) frames = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
assert len(frames) > 1 assert len(frames) > 1
# The camera session is running and every frame needs two stable reads. # The camera session is running and every frame needs two stable reads.
@@ -588,7 +546,7 @@ def test_qr_import_handle_scanned_text_manual_does_not_autofinish():
reviewed = [] reviewed = []
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment] page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
frames = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150) frames = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
assert len(frames) > 1 assert len(frames) > 1
assert not page._scanning assert not page._scanning
for frame in frames: for frame in frames:
@@ -601,16 +559,54 @@ def test_qr_import_handle_scanned_text_manual_does_not_autofinish():
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# BBQR import/export round trips # Animated-QR formats (BC-UR v1/v2, BBQR) via the export/import pages
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_import_bbqr_roundtrip(): def test_export_format_combo_switches_codecs():
bw = FakeBalWindow()
bw.willitems = _make_willitems(n=6, payload_len=400)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
assert page.format == "balqr"
assert page.frames[0].startswith("BALQR1|")
assert page.format_combo.count() == 4
page._on_format_change(1) # BC-UR v1
assert page.format == "ur1"
assert page.frames[0].startswith("ur:bytes/")
assert page.index == 0
assert page.qr_view.text == page.frames[0]
page._on_format_change(2) # BC-UR v2
assert page.format == "ur2"
assert page.frames[0].startswith("ur:bytes/")
page._on_format_change(3) # BBQR
assert page.format == "bbqr"
assert page.frames[0].startswith("B$")
d.close()
def test_export_animated_format_frames_fit_budget():
bw = FakeBalWindow()
bw.willitems = _make_willitems(n=6, payload_len=400)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
for index in range(1, 4):
page._on_format_change(index)
for frame in page.frames:
assert len(frame) <= dialogs.CHUNK_PRESETS[0][1]
d.close()
def _import_roundtrip_fmt(fmt_index):
bw = FakeBalWindow() bw = FakeBalWindow()
bw.willitems = _make_willitems(2) bw.willitems = _make_willitems(2)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page page = d.qr_page
page._on_format_change(fmt_index)
frames = list(page.frames) frames = list(page.frames)
assert frames and all(f.startswith("B$") for f in frames) assert frames
d.close() d.close()
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
@@ -627,11 +623,24 @@ def test_import_bbqr_roundtrip():
impl.close() impl.close()
def test_import_ur1_roundtrip():
_import_roundtrip_fmt(1)
def test_import_ur2_roundtrip():
_import_roundtrip_fmt(2)
def test_import_bbqr_roundtrip():
_import_roundtrip_fmt(3)
def test_import_animated_scan_debounce_autofinish(): def test_import_animated_scan_debounce_autofinish():
bw = FakeBalWindow() bw = FakeBalWindow()
bw.willitems = _make_willitems(2) bw.willitems = _make_willitems(2)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page page = d.qr_page
page._on_format_change(2) # BC-UR v2 fountain
frames = list(page.frames) frames = list(page.frames)
d.close() d.close()
@@ -644,8 +653,11 @@ def test_import_animated_scan_debounce_autofinish():
for _rep in range(2): for _rep in range(2):
import_page._handle_scanned_text(frame) import_page._handle_scanned_text(frame)
assert import_page.review_btn.isEnabled() assert import_page.review_btn.isEnabled()
assert import_page.total == len(frames) # The fountain transfer's part count is ``len(frames) // 2`` (pure + one
# redundant mixed wave).
assert import_page.total == len(frames) // 2
assert len(import_page.frames) >= import_page.total assert len(import_page.frames) >= import_page.total
assert import_page.review_btn.isEnabled()
_app.processEvents() _app.processEvents()
assert reviewed == [True] assert reviewed == [True]
assert not import_page._scanning assert not import_page._scanning
@@ -656,7 +668,7 @@ def test_import_garbage_scan_is_ignored():
bw = FakeBalWindow() bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page page = d.qr_page
for garbage in ("hello world", "12345", "B$ZZ", "ur:bytes/xyz", ""): for garbage in ("hello world", "12345", "B$ZZ", ""):
page._handle_scanned_text(garbage) page._handle_scanned_text(garbage)
assert not page.frames assert not page.frames
assert page.total == 0 assert page.total == 0
@@ -671,10 +683,8 @@ def test_import_different_animated_transfer_resets():
warnings = [] warnings = []
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment] 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_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
frames_b = _bbqr_frames( frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150)
encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)]), 150
)
for frame in frames_a: for frame in frames_a:
page._add_frame(frame) page._add_frame(frame)
assert page.total == len(frames_a) assert page.total == len(frames_a)

View File

@@ -183,22 +183,6 @@ 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
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -390,110 +390,6 @@ 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:

View File

@@ -18,10 +18,9 @@ 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 reported state (reproduced hermetically here — the original live wallet The karen7 regtest wallet fixture (``tests/karen7``) reproduces the exact
dump ``tests/karen7`` is gitignored and regenerated as the wallet evolves) is: reported state: heirs with ``"1y"``, a signed/pushed/checked item whose frozen
heirs with ``"1y"``, a signed/pushed/checked item whose frozen tx.locktime is tx.locktime is 2027-08-05 (built 2026-08-05), and will_settings
2027-08-05 (built 2026-08-05), and will_settings
``{"locktime": "2y", "threshold": "150d"}``. ``{"locktime": "2y", "threshold": "150d"}``.
Run: Run:
@@ -29,14 +28,16 @@ Run:
python3 tests/test_heir_relative_anchor.py python3 tests/test_heir_relative_anchor.py
""" """
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.util import copy_structure # noqa: E402
from bal.core.will import ( # noqa: E402 from bal.core.will import ( # noqa: E402
@@ -48,16 +49,6 @@ 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 = (
@@ -167,52 +158,55 @@ def test_absolute_postpone_on_signed_still_detected():
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# karen7 regression (hermetic, no live wallet fixture) # karen7 wallet regression (real fixture)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# karen7's reported state, reproduced hermetically: heirs "1y", a signed item
# frozen at delivery 2027-08-05 (built 2026-08-05), will_settings with a def _load_karen7():
# relative "150d" delivery window and a "2y" promised locktime. path = os.path.join(os.path.dirname(__file__), "karen7")
_WILL_SETTINGS = {"locktime": "2y", "threshold": "150d"} with open(path) as f:
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."""
heirs = {"alice": ["addr_alice", 5000, "1y"]} data = _load_karen7()
item = _make_will_item(copy_structure(heirs), _FROZEN, status_complete=True) will_settings = data["will_settings"]
will = {"willid_1": item} valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
built_locktime = Will.get_min_locktime(will) wi = WillItem(data["will"][valid_wid], _id=valid_wid)
built_locktime = Will.get_min_locktime({valid_wid: wi})
assert built_locktime is not None assert built_locktime is not None
assert built_locktime == int(item.tx.locktime) assert built_locktime == int(wi.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():
"""Unchanged relative "1y" heirs are coherent with the frozen signed tx: """The karen7 heirs (unchanged relative "2d") are coherent with the frozen
the plugin must NOT ask to invalidate the will.""" signed tx: the plugin must NOT ask to invalidate the will."""
heirs = {"alice": ["addr_alice", 5000, "1y"]} data = _load_karen7()
valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
# 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, _WILL_SETTINGS, False, data["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(
copy_structure(heirs), data["will"][valid_wid]["heirs"],
copy_structure(heirs), data["heirs"],
frozen_locktime, frozen_locktime,
status_complete=True, status_complete=True,
) )
@@ -225,7 +219,6 @@ 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]()

View File

@@ -1,309 +0,0 @@
"""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")

View File

@@ -171,50 +171,6 @@ 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
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -2,17 +2,13 @@
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"]) in lockstep with the BUILT transactions' fixed (WILL_SETTINGS["locktime"]) and check-alive threshold in lockstep with the
locktime when the core AUTOMATICALLY anticipates it (one day earlier than BUILT transactions' fixed locktime. The bug it fixes (reported by the owner):
stored).
RELATIVE recipes ("90d" / "1y") are now PRESERVED: the daily-drift problem ADVANCED mode + RELATIVE locktime ("90d") / threshold ("30d") -> the plugin
that once forced freezing them to absolute timestamps is solved at the root by asks to invalidate the will EVERY DAY. The relative value is re-parsed
anchoring every relative recipe against the built transactions against "now" on every check, so it drifts one day per day away from the
(``Util.resolve_locktime_against_tx`` for the postpone detection, fixed tx locktime and the postpone check always sees a "postpone".
``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.
@@ -28,6 +24,7 @@ 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)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -71,30 +68,34 @@ def _call_sync(will_settings, tx_locktimes, recorded):
# Tests # Tests
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_relative_locktime_preserved(): def test_relative_locktime_normalized_to_absolute():
"""A RELATIVE stored locktime ("90d"/"1y") is PRESERVED after a rebuild: """The reported bug: a relative stored locktime is frozen to the absolute
it is anchored against the built transactions on every check, so it must value of the built transaction, even when it parses to the same moment."""
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"] == "90d" assert fake.bal_window.will_settings["locktime"] == tx_locktime
assert fake.bal_window.will_settings["threshold"] == "30d" assert fake.bal_window.will_settings["locktime"] != "90d"
assert recorded == [], "a relative recipe must never be rewritten" # A pure relative->absolute normalisation is NOT an anticipation: the sign
# 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_preserved(): def test_relative_threshold_frozen_to_absolute():
"""Same for the relative "Check Alive" threshold: it stays relative.""" """A relative threshold ("N days BEFORE the delivery") is normalised to the
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
) )
assert fake.bal_window.will_settings["threshold"] == "30d" expected = int(
assert recorded == [] BalTimestamp("30d").to_date(tx_locktime, reverse=True).timestamp()
)
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():
@@ -112,8 +113,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 automatic anticipation of an ABSOLUTE stored date (built earlier """A real anticipation (built locktime earlier than the stored absolute
than stored) still moves the date earlier and flags the sign prompt.""" one) 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(
@@ -128,7 +129,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) moves the value.""" anticipation (built < stored) and relative normalisation move the value."""
stored = 1_800_000_000 stored = 1_800_000_000
recorded = [] recorded = []
fake = _call_sync( fake = _call_sync(
@@ -141,39 +142,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 ABSOLUTE transactions carry different locktimes, the minimum """When several transactions carry different locktimes, the minimum is used
is used for a genuine automatic anticipation (owner-confirmed behaviour for (owner-confirmed behaviour for the delivery date shown in the UI)."""
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": 1_800_000_000, "threshold": 1_600_000_000}, {"locktime": "90d", "threshold": "30d"},
[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_stays_coherent_via_anchor(): def test_relative_locktime_stops_daily_postpone():
"""Daily-drift guard: an UNCHANGED relative recipe is resolved against the """End-to-end guard for the reported bug: after the sync, re-parsing the
tx build moment (``Util.resolve_locktime_against_tx``), so even WITHOUT stored (now absolute) locktime on later days always equals the built
being frozen to an absolute value it still reads as COHERENT (== tx tx locktime, so the postpone check never fires again."""
locktime) on later days - the postpone check never fires again.""" from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from bal.core.util import Util from bal.core.util import Util
# resolve_locktime_against_tx normalises to UTC midnight before anchoring, tx_locktime = 1_800_000_000
# so use a midnight-UTC frozen tx locktime (the timestamp the engine itself recorded = []
# stores after building). fake = _call_sync(
tx_locktime = int(datetime(2027, 1, 15, tzinfo=timezone.utc).timestamp()) {"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
built = "90d" # recipe frozen at build time )
current = "90d" # unchanged recipe today stored = fake.bal_window.will_settings["locktime"]
for _day in range(0, 7): for _day in range(0, 7):
resolved = Util.resolve_locktime_against_tx(current, built, tx_locktime) # Simulate the check on later days: parse the STORED value (which is
assert resolved == tx_locktime # no POSTPONE / drift # now the absolute tx locktime) and compare with the fixed tx locktime.
# Sanity: a naive forward-from-now re-parse would have drifted past it new_locktime = Util.parse_locktime_string(stored)
# (the bug the anchor fixes). assert new_locktime == tx_locktime
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)

View File

@@ -1,135 +0,0 @@
"""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")