25 Commits

Author SHA1 Message Date
346aeee62b Merge remote-tracking branch 'origin/main' 2026-10-08 19:01:23 -04:00
3b2eed37cc i18n: fix Babel format-string error on the heirs wizard text
The literal '%' in "100% of wallet balance" was detected as a Python
format specifier (%o in English vs %d in the translations), so
pybabel compile failed and the catalogs could not be built. Reword to
put '100%' at the end of the sentence and refresh es_ES/it_IT.
2026-10-08 19:00:51 -04:00
0c60462163 Merge pull request 'i18n review fixes (#63)' (#10) from fix/i18n-review into main
Reviewed-on: #10
2026-10-08 20:43:21 +00:00
a81443ff67 i18n review fixes (#63)
Truman's i18n review: release gate runs the i18n, CLI and import tests and checks Babel first; ruff known-first-party = bal; no translated text stored in the wallet (executor push records are copies, status history always saved as 'New', old 'Nuovo'/'Nuevo' mapped back on load).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01HxDDvLym1NEbFNYMc1rr7S
2026-10-07 15:03:25 +02:00
827a1d1e54 chore: gitignore the Android companion app 2026-10-05 17:53:20 -04:00
d6fd20ad9c chore: remove the Android companion app from the repo
Keep it only locally (now gitignored); the plugin repo tracks only the
Electrum plugin sources.
2026-10-05 17:45:17 -04:00
5ec2b79d03 core+gui: BBQR-only QR transfer, disable Audio when modem plugin unavailable
- Drop BALQR / BC-UR v1 / v2 wire formats, keep BBQR only (animated_qr,
  qrtransfer, dialogs); remove the settings "QR Code Size" selector and
  persist the export-dialog size choice instead.
- Disable the Audio transport radio button in the import/export dialogs
  when the audio_modem plugin (or its amodem dependency) is unavailable.
- Refresh translation catalogs (bal.pot, es_ES, it_IT).
2026-10-03 13:00:23 -04:00
47b120f526 i18n: add Spanish (es_ES) translation 2026-10-03 11:48:14 -04:00
1b601609f0 i18n: refresh catalogs and translate the new Check Alive texts
Unify the Check Alive-after-delivery error message across GUI and CLI, then
re-extract the catalog and translate the two new/changed texts into Italian.
2026-10-03 11:37:12 -04:00
b1f78700d1 Merge wip/checkalive-relative into main
Signed relative Check Alive (negative = before delivery, positive = after
creation). Resolves CHANGELOG.md (new section 61).
2026-10-03 11:37:03 -04:00
beab22ce23 core+gui: signed relative Check Alive (negative = before delivery, positive = after creation) 2026-10-03 10:49:25 -04:00
70196fc3cd i18n phase 3: Italian catalog
- babel.cfg and the catalog sources: bal/locale/bal.pot (424 texts) and
  bal/locale/it_IT/LC_MESSAGES/bal.po, fully translated (43 entries taken
  from Electrum's it_IT catalog, the rest following the owner's glossary
  and review: "locktime" kept in English, "transazione senza
  Will-Executor" for the backup transaction).
- build_zip.py compiles each bal.po into bal.mo inside the zip (Babel
  required); .po/.pot are not shipped and *.mo is ignored by git.
- tests/test_translations.py: catalogs compile, {} fields and $tokens
  match, no address/e-mail/URL added by a translation (patterns from
  electrum-locale).
- AGENTS.md: how to update the catalogs or add a language.

See CHANGELOG entry 60 and PLAN_I18N.md.
2026-09-26 22:59:38 +02:00
9c654bf2bf i18n phases 1+2: BAL translation layer and translatable texts
Phase 1: new bal/i18n.py, BAL's own gettext layer (domain "bal", catalogs
read with plugin.read_file()). _() asks Electrum's catalog first, then
BAL's, then returns the English source. The Qt plugin loads the catalog of
Electrum's GUI language at start-up; the CLI stays English.

Phase 2: every user-visible GUI text is now a whole, extractable sentence
(Ruff INT rules enabled). Class-level texts are marked with N_() and
translated when shown. Stored data stays language-neutral: the status
history is written in English and translated for display, the calendar
defaults follow the GUI language, and the history label and wallet labels
are never translated because BAL uses them to recognise its transactions.

No visible change apart from the double colon fixed in the will detail.
See CHANGELOG entries 58 and 59 and PLAN_I18N.md.
2026-09-26 21:54:50 +02:00
e8db76338d docs: add PLAN_I18N.md (Italian translation plan)
Plan for translating the Qt GUI of the plugin into Italian via a
BAL-owned gettext catalog, with the owner's decisions recorded in
section 6. No code changes.
2026-09-26 17:01:38 +02:00
4a33116b66 fix: stop infinite RecursionError in will wizard (Date editor timezone roundtrip)
LockTimeDateEdit stored the aware-UTC datetime from _safe_fromtimestamp
into the QDateTimeEdit, which keeps the wall time with a LocalTime spec.
get_value() therefore read back x + utc_offset, so set_value(x) never
equalized x and the valueEdited -> update_setting_widgets -> set_value
signal cycle fired forever, crashing with RecursionError when clicking
Next in the 'Build your will' wizard. Store the local wall-clock time
instead (alarm stays aware-UTC for the .ics export).
2026-09-19 17:42:11 -04:00
fb88d7540c feat: animated-QR transfer, Android reader, relative-locktime preservation, karen7 hermetic tests 2026-09-14 09:11:50 -04:00
9c4697c923 Merge pull request 'core+gui: name the real cause of a failed build instead of guessing' (#7) from ui/build-will-error-messages into main
Reviewed-on: #7
2026-09-09 12:31:28 +00:00
42f05d3c4f core+gui: name the real cause of a failed build instead of guessing
The Building Will report showed a fixed list of three "possible reasons"
whenever a build produced nothing, regardless of what actually happened; in a
case reproduced from the owner log all three were false and the real cause was
not even listed. The "Checking your will" row had the same flaw, showing one
sentence ("Found CHANGES to the DATE or the HEIRS") for five situations,
including one where it is plainly wrong (funds received).

core/heirs.py: record WHY buildTransactions gave up in a new last_build_error
attribute (8 reason codes), set at each path that previously returned empty
with no explanation, plus a processed_willexecutors counter to tell "every
will-executor was skipped" apart from "we tried and failed". Also fix a latent
crash in the prepare_transactions handler, which read a no-longer-existing
e.heirname attribute and re-raised the resulting AttributeError, masking the
real error.

gui/qt/dialogs.py: add msg_alert() (amber warning sign, body text in the theme
colour, readable in both themes), _build_failure_message() and
_check_failure_message() to turn those causes into one precise sentence each,
with an honest "cause could not be determined" fallback. Catch
BalanceTooLowException, which already carried the figures but fell through to
the generic red technical error.

No new exception classes were introduced (owner request): the plain
NotCompleteWillException cases are told apart structurally, not by text.
2026-09-04 11:52:21 +02:00
3a9ee5adb9 core+gui: timezone-correct datetimes, deep-copy WillItem, dead code removal, explicit imports 2026-08-19 20:57:22 -04:00
2c9f6bdd9d gui: remove 'Add transaction without willexecutor' from plugin settings dialog
The checkbox is already available in the Will-Executor tab; showing it
in the settings dialog was redundant. Renumber grid rows 5-16 -> 4-15
to close the gap.
2026-08-17 23:42:01 -04:00
4dfb3fc41c fix: chainname regtest bug (classproperty); add no-heirs buttons in build-will dialog
- bal/core/plugin_base.py: change chainname from frozen class attribute
  to @classproperty so it reads constants.net.NET_NAME at runtime, fixing
  regtest/testnet always downloading the mainnet executor list
- bal/core/willexecutors.py: remove module-level chainname capture;
  all uses now read BalPlugin.chainname directly
- bal/gui/qt/dialogs.py: when BalBuildWillDialog detects no heirs,
  return 'no_heirs' signal and show Heirs/Wizard/Close buttons (mirroring
  the existing no-willexecutor pattern); add HeirsDialog with full
  HeirListWidget (New Heir, Import, Export)
2026-08-17 12:06:21 -04:00
1ae1617172 cli: add bal_will_autorebuild (headless one-shot check/rebuild/sign/push flow with invalidation tx) 2026-08-16 06:40:40 -04:00
125bf09b9a gui: rebuild the will automatically on new transactions (AUTO_REBUILD, default off; anticipate delivery by 1 day, invalidate on-chain only when the anticipated locktime crosses the check-alive threshold) 2026-08-16 06:40:35 -04:00
b5752a42f0 cli: add headless command-line layer (bal_* commands for the daemon, cmdline entry point, manifest 'cmdline' support, offline controller tests) 2026-08-14 23:57:28 -04:00
ce659048ca core: add 'Rebuild will on wallet close' setting to skip the build wizard at close (REBUILD_ON_CLOSE); settings checkbox + tests 2026-08-14 23:56:14 -04:00
82 changed files with 20044 additions and 13069 deletions

7
.gitignore vendored
View File

@@ -33,3 +33,10 @@ tmp*
# Release artifacts
bal_v*.zip.*
tests/karen7
# Compiled translations: build_zip.py rebuilds them from the .po sources
*.mo
# Android companion app: kept locally only, not part of the plugin repo
android/

View File

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

View File

@@ -3,6 +3,9 @@
BAL — Bitcoin After Life, an Electrum plugin (inheritance / dead-man's-switch).
Source-of-truth docs: `README.md`, `HANDOFF.md`, `COMPATIBILITY.md`.
Paths below are relative to the repo, or use `$BAL_HOME` (the directory
containing this repo and the sibling `electrum/` checkout).
## Environments (critical)
Two separate venvs; using the wrong one is the #1 mistake.
@@ -20,8 +23,9 @@ The plugin's `bal/` directory is symlinked into
## Test & verify
Tests are **standalone scripts**, not pytest. Each `tests/test_*.py` file runs
its `test_*` functions from `if __name__ == "__main__"`. Run a file directly:
Tests work **both** as standalone scripts and via pytest (tests use `def test_*`
naming and also have `if __name__ == "__main__"` blocks). Run a single file
directly:
```bash
source "$BAL_HOME/electrum/env/bin/activate"
@@ -29,6 +33,13 @@ python3 tests/test_core_heirs.py # core, no Qt needed
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need offscreen
```
Or run a batch with pytest (as `make-release.sh` does):
```bash
source "$BAL_HOME/electrum/env/bin/activate"
QT_QPA_PLATFORM=offscreen python3 -m pytest tests/test_core_*.py -q
```
- Most core tests run offline (no wallet/network). Some files
(`test_group_*.py`, `test_no_willexecutor_karen7.py`, `parallel_ping_test.py`)
exercise will-executor/network flows and need the live servers — don't rely on
@@ -43,7 +54,8 @@ QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need of
- **Ruff is NOT clean** (hundreds of pre-existing errors in `bal/` and
`tests/`). Do not run `--fix` wholesale and do not try to silence everything;
just avoid adding new violations. Config: `pyproject.toml` (line-length 88,
E501 ignored).
E501 ignored). Per-file ignores suppress `F403`/`F405` for the intentional
`from .common import *` hub pattern in `bal/gui/qt/`.
- Lint via the repo venv: `./venv/bin/ruff`
- Typecheck: `pyright` (npm, `node_modules/`), config `pyrightconfig.json`
(`extraPaths: ["../electrum"]`). Pyright reports many false positives on
@@ -53,15 +65,50 @@ QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need of
## Architecture
- `bal/core/` = GUI-free logic (`heirs.py`, `will.py`, `willexecutors.py`,
`plugin_base.py`, `util.py`). Must never import Qt.
`plugin_base.py`, `util.py`, `checkalive.py`, `reminders.py`,
`input_rules.py`).
Must never import Qt.
- `bal/gui/qt/` = PyQt6 layer. `window.py` is the per-wallet controller,
`plugin.py` is the Electrum `@hooks` entry, `qt.py` is a zipimport shim.
`plugin.py` is the Electrum `@hooks` entry. `qt.py` is a zipimport shim.
`common.py` uses `import *` intentionally (ruff suppresses F403/F405 here);
`bal/gui/qt/*.py` all import from it.
- `bal/cli/` = headless command-line layer (no Qt). `plugin.py` is the daemon
entry point, `commands.py` registers `bal_*` commands with Electrum.
- `bal/wallet_util/` = wallet helper utilities for Qt and core.
- `bal/qt.py` and `bal/cmdline.py` are thin shims that Electrum discovers
via `manifest.json`; they import the real `Plugin` class via `importlib`.
- `bal/manifest.json` = version source of truth (Electrum reads it; also read by
`make-release.sh`).
- Compatibility constraint: must support Electrum **4.7.2 and 4.8.0**; the DB
registration API differs between them (`json_db.register_dict` vs
`stored_dict.register_name`).
## Translations (i18n)
How it works: `bal/i18n.py` (Electrum's catalog first, then BAL's, then
English). Plan and decisions: `PLAN_I18N.md`.
- Sources in git: `babel.cfg`, `bal/locale/bal.pot` (template) and
`bal/locale/<lang>/LC_MESSAGES/bal.po` (one per language). The compiled
`.mo` files are NOT in git: `build_zip.py` builds them into the zip.
- Writing texts: `_("... {}").format(x)`, never f-strings or `%` inside
`_()` (Ruff INT); `N_()` for class attributes/constants, translated with
`_()` when shown. Never translate stored or compared text (wallet labels,
`HISTORY_LABEL`, the status history).
- After changing texts, refresh the catalogs (then translate the new
entries, e.g. with Poedit):
```bash
pybabel extract -F babel.cfg --no-wrap -o bal/locale/bal.pot .
pybabel update -i bal/locale/bal.pot -d bal/locale -D bal --no-wrap
```
- New language: `pybabel init -i bal/locale/bal.pot -d bal/locale -D bal -l <lang>`
(use Electrum's language code, e.g. `de_DE`).
- Dev install (symlink, not the zip): compile once to see the translations,
`pybabel compile -d bal/locale -D bal`.
- `tests/test_translations.py` checks every catalog ({} fields, `$tokens`,
no addresses/links/e-mails added by a translation) and prints a summary
when run standalone.
## Build / release
```bash
@@ -69,6 +116,11 @@ python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha25
./make-release.sh [v0.x.y] # bump manifest version, tag, sign, push Gitea release
```
- `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`
or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the
version there, never invent a new source of truth.

170
AUDIO_MODEM_DEBIAN.md Normal file
View File

@@ -0,0 +1,170 @@
# Audio MODEM on Debian — setup & troubleshooting
How to make the optional **audio channel** of BAL (and Electrum's own
`audio_modem` plugin) work on Debian/Ubuntu. The channel lets you send a will
to another device as acoustic OFDM tones instead of scanning QR codes.
Recommended reading before starting: `CHANGELOG.md` entry 56
(Audio-environment notes) and `HANDOFF.md` (Dev-box audio prerequisites).
---
## 1. What you need (three independent pieces)
| Piece | Provides | Where it comes from |
|--------------------------|--------------------------------------------|--------------------------------------|
| `amodem` (Python) | OFDM modulation/demodulation | `pip install amodem` (any venv) |
| `libportaudio.so` | sound I/O backend used by `amodem.audio` | Debian package `libportaudio2` (+ dev symlink, see §2) |
| Electrum `audio_modem` | the plugin whose `_send`/`_recv` BAL reuses | built into Electrum |
BAL shows the audio buttons only when the plugin is **enabled** (Tools →
Plugins → Audio Modem) and `amodem` is importable.
> On a headless/CI box there is no speaker/mic, but the channel can still be
> verified with the **sink-monitor loopback** in §4.
---
## 2. The two line fixes (this is the part everyone forgets)
Debian ships a versioned `libportaudio.so.2` but **not** the unversioned
`libportaudio.so` that old `amodem` code uses, and `amodem` uses NumPy APIs
removed in NumPy 2.x. Both fail **silently** (the plugin's `_send` runs the
load inside a `WaitingDialog` thread without an `on_error` handler).
### 2a. PortAudio unversioned symlink
Install the dev package (creates the unversioned symlink), or create it by
hand:
```bash
sudo apt install libportaudio2 libportaudio-dev # preferred
# or, without the package:
sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 \
/usr/lib/x86_64-linux-gnu/libportaudio.so
```
Verify:
```bash
source "$BAL_HOME/electrum/env/bin/activate"
python3 -c "import amodem.audio; print(amodem.audio.Interface(config=None).load('libportaudio.so').call('GetVersionText'))"
# b'PortAudio V19...' <-- success
```
> **No-sudo alternative** (fine for one-shot tests): point `LD_LIBRARY_PATH`
> at a directory containing a `libportaudio.so` symlink to the `.so.2`:
> ```bash
> mkdir -p /tmp/portaudio_stub
> ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /tmp/portaudio_stub/libportaudio.so
> export LD_LIBRARY_PATH=/tmp/portaudio_stub:$LD_LIBRARY_PATH
> ```
### 2b. amodem vs NumPy 2.x (`tostring` removed)
`amodem` 1.16.0 calls `numpy.ndarray.tostring()`, removed in NumPy 2.x
(≥ 2.4.6 dies with `AttributeError` on the first sample write, so **no carrier
is ever emitted**). Either pin NumPy < 2, or patch the single line in the
installed package:
```bash
source "$BAL_HOME/electrum/env/bin/activate"
python3 -m pip install "numpy<2" # option A (downgrade)
# option B (patch; path depends on your site-packages):
sed -i "s/sym.astype('int16').tostring()/sym.astype('int16').tobytes()/" \
"$BAL_HOME/electrum/env/lib/python3.11/site-packages/amodem/common.py"
```
> This must be done on **every** machine that receives/sends audio (both ends
> of the channel use the same code), and again after reinstalling/upgrading
> `amodem`.
---
## 3. Environment checklist (dev box, already applied)
These were applied on the current dev box and do NOT need to be re-done:
- `amodem` installed in the runtime venv (`1.16.0`).
- `amodem/common.py` patched `tostring()` → `tobytes()`.
- System symlink or `LD_LIBRARY_PATH` stub for `libportaudio.so`.
- PulseAudio running; default sink `ALC236 Analog`, default source DMIC.
Check them in one command:
```bash
source "$BAL_HOME/electrum/env/bin/activate"
python3 - <<'EOF'
import amodem, ctypes, numpy, zlib
print("amodem", amodem.__version__)
print("numpy", numpy.__version__, "(2.x needs the tobytes patch)")
import amodem.audio
amodem.audio.Interface(config=None).load("libportaudio.so")
print("libportaudio.so loaded OK (symlink or LD_LIBRARY_PATH in place)")
EOF
```
---
## 4. Verifying the channel (no speakers/mic needed)
Full **send → sink → sink-monitor → recv** round-trip on one machine:
```bash
# 1) route capture at the loop and remember the original source
MON="$(pactl get-default-sink).monitor"; ORIG=$(pactl get-default-source)
pactl set-default-source "$MON"
# 2) run the round-trip (uses zlib-compressed payload like the plugin)
source "$BAL_HOME/electrum/env/bin/activate"
timeout 90 python3 /tmp/opencode/bal_audio_loopback.py
# expected: bitrate 1.0 kbps ... send done ... RECV OK
# 3) restore the original source
pactl set-default-source "$ORIG"
```
Any payload you like: `python3 /tmp/opencode/bal_audio_loopback.py "BALQR|1|1|0|hi"`.
With real speakers + mic instead, skip the `pactl` swapping, put the devices
close, keep volumes high, and run the same script.
---
## 5. Testing through the real GUI
1. **Tools → (Plugins) → Audio Modem** → enable it. If asked for settings,
pick a bitrate: default `slowest()` is ~1.0–1.2 kbps (a ~2 KB will takes
~15–20 s of audio); higher bitrates are faster but less robust.
2. Wallet A → BAL will list → **Export → QR Codes → Audio…**
(the audio transport sends the raw newline-joined tx list, no BAL framing).
3. Wallet B → will list → **Import via QR → Audio…** → wait for
"Waiting for audio (... kbps)…", a loading cursor while demodulating,
then the decoded slots appear → review/sign wizard opens.
4. One machine only: apply the §4 monitor trick in the shell where Electrum
runs (export plays to the sink; import records from the sink monitor).
---
## 6. Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| No sound at all, no error anywhere in the log | `libportaudio.so` not loadable (silent) | §2a symlink or `LD_LIBRARY_PATH` stub |
| Sound played, "Timeout waiting for carrier" on the receive end | Capture routed to the wrong device / mic muted / no speakers | §4 monitor trick; `pactl` source check; raise volume; move devices closer |
| "Decoding failed" after carrier, no payload | Send side died with numpy `tostring` → nothing modulated | §2b patch or `numpy<2` on BOTH machines |
| Buttons "Audio…" missing in BAL dialogs | `audio_modem` disabled in Plugins, or `amodem` not importable in the running venv | Enable plugin; `pip install amodem` |
| Audio too long / too slow | 1 kbps default | Raise bitrate in Audio Modem settings dialog |
---
## 7. No-sudo quick reference (all commands)
```bash
python3 -m pip install amodem
mkdir -p /tmp/portaudio_stub
ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /tmp/portaudio_stub/libportaudio.so
export LD_LIBRARY_PATH=/tmp/portaudio_stub:$LD_LIBRARY_PATH
# numpy >= 2 (one of):
pip install "numpy<2" # or patch amodem/common.py tobytes
```

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,18 @@ is updated to mark them as supported.
See [`README.md`](README.md) for supported Electrum versions (currently 4.7.2
and 4.8.0).
## QR wire-format compatibility
BAL exports/imports wills as QR codes using **BBQR**:
| Format | Wire appearance | Interop target |
|-----------|----------------------------|------------------------------------------------------|
| BBQR | `B$<enc><type><N><n>…` | Coinkite BitKit / Coldcard's BBQR animated-QR mode |
Import auto-detects the format of each scanned code; out-of-order and duplicate
BBQR frames are handled. Interop has not yet been cross-verified against
third-party libraries (`bbqr`, Coldcard firmwares).
## Reporting compatibility issues
If you find a compatibility problem not listed here, please open an issue on

View File

@@ -58,6 +58,7 @@ bal/ <- the plugin package (this is what ships in the ZI
__init__.py <- package docstring (no version here anymore)
manifest.json <- plugin manifest, "version" field (SINGLE SOURCE OF TRUTH for the version)
qt.py <- zipimport shim used when loaded as an external ZIP plugin
cmdline.py <- CLI entry-point shim (Electrum gui_name='cmdline')
core/
plugin_base.py <- get_version() reads the version from manifest.json (zip-safe)
heirs.py <- HEIRS + transaction building (prepare_lists,
@@ -67,6 +68,15 @@ bal/ <- the plugin package (this is what ships in the ZI
willexecutors.py <- remote will-executor services handling (is_selected / is_valid,
parallel push/check).
util.py <- locktime parsing/most helpers (timestamps only).
checkalive.py <- resolve_date_to_check, resolve_relative_check_alive_date,
ensure_check_alive_valid (GUI-free; signed relative rule).
reminders.py <- compute_reminder_offsets, BALCalendar .ics generation (GUI-free).
input_rules.py <- locktime/threshold data models, Raw/Date selector logic (GUI-free).
cli/ <- headless command-line layer (no Qt)
__init__.py <- registers bal_* commands on import
commands.py <- bal_* daemon commands (@plugin_command, async, thin transport)
controller.py <- BalController: headless replica of BalWindow (no Qt)
plugin.py <- CLI Plugin entry point (extends BalPlugin, no Qt hooks)
gui/qt/
common.py <- shared imports; every gui module does
`from .common import *`. Add new shared imports HERE.
@@ -80,7 +90,6 @@ bal/ <- the plugin package (this is what ships in the ZI
wallet_util/ <- standalone wallet-inspection helpers, no Qt
tests/ <- standalone test scripts (see Section 3).
docs/ <- user manual + inheritance-options guide (.md sources).
bal_cli.py <- headless CLI (heirs/will build/sign/push/check), no Qt.
build_zip.py <- builds the shippable ZIP (36 files).
CHANGELOG.md <- numbered task log (English).
.agent_memory_tasks.md <- terse internal memory notes per task batch.
@@ -103,7 +112,8 @@ Two separate venvs — using the wrong one is the #1 mistake:
- **Lint venv** (repo-local `venv/`): ruff, black, flake8 only. It cannot
import `electrum` or `PyQt6`. Do NOT use it to run tests.
Run everything from the repo root.
Run everything from the repo root (the checkout directory; set
`BAL_HOME` to its parent to use `$BAL_HOME/electrum`).
**Tests are standalone scripts (not pytest):** each `tests/test_*.py` runs its
`test_*` functions from `if __name__ == "__main__"`. Run a file directly:
@@ -295,8 +305,8 @@ See Section 5 for details.
update `GITEA_TOKEN` env var or `~/.git-credentials`, then retry.
- Older PR history (pre-`main` direct workflow): **#13** (v0.4.7), **#14**
(docs/DUST section + translation), **#15** (v0.4.8), **#4** (v0.6.1 —
manifest.json version). All merged into `main`.
- Releases: latest is **v0.6.1**; v0.6.0 and v0.5.18 before it; the older
manifest.json version); all merged into `main`.
- Releases: latest is **v0.7.0**; v0.6.1, v0.6.0 and v0.5.18 before it; the older
v0.2.x line is kept in history.
---
@@ -346,6 +356,11 @@ See Section 5 for details.
- **#47 / #48 (post-v0.6.1)** — `is_selected`/`is_valid` fee bounds (extremes
allowed) and the `merge_will` missing-`date_to_check` crash fix (see
CHANGELOG).
- **v0.7.0** — OP_RETURN heirs; core extraction (checkalive, reminders,
input_rules); RLock pickle fix; `REBUILD_ON_CLOSE`; headless CLI layer
(`bal/cli/`, `bal/cmdline.py`, 30 `bal_*` commands); `AUTO_REBUILD` on new
transactions; `bal_will_autorebuild` CLI command; removed redundant
"Add transaction without willexecutor" from settings dialog.
### Open / suspended / backlog items (see `.agent_memory_tasks.md` for detail)
- **SUSPENDED — "(UTC)" label in the wizard.** The owner asked to show an
@@ -379,3 +394,64 @@ See Section 5 for details.
push to `origin/main`, then run `./make-release.sh` to create the Gitea
**Release** with the ZIP + signatures attached (it becomes the owner's
"Latest" download). Always give the owner the Release URL.
### In progress: QR / audio will transfer (branch `feature/bal-qr-transfer`)
- The full QR-transfer feature (P0–P6) is implemented, tested and committed on
`feature/bal-qr-transfer` (commits `d288b55`, `ce3e36d`, pushed to
`origin`). PR creation URL:
`https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin/pulls/new/feature/bal-qr-transfer`
- Included: core scheduler (`bal/core/qrtransfer.py`), `QR_CHUNK_SIZE`
setting (4 export presets), export/import dialogs + review/sign wizard +
lists/window wiring, export filters, auto slideshow with per-second rate +
loop option, audio send/receive buttons, and the crash fixes
(`status` default, `invalidate_will` guard). Docs: README, CHANGELOG entry
56, QML_PLAN, `AUDIO_MODEM_DEBIAN.md`.
- Follow-up refactor (CHANGELOG entry 57): all `copy.deepcopy` removed —
`copy_structure()` in `bal/core/util.py`, `WillItem.copy()` / ctor
serialize/deserialize, `copy_status_table()`. Working tree clean after the
branch's three commits.
- Verification: batch 377 passed / 2 pre-existing `test_bt_to_date_*`
failures; ruff no new violations; smoke + `build_zip.py` +
external-zip OK; pyright clean. The isolated
`test_heir_relative_anchor.py::test_karen7_frozen_delivery_not_expired`
failure is pre-existing test pollution (fails identically on clean HEAD,
passes inside the full batch) — not caused by entry 57.
- Remaining: manual on-device walkthrough of the QR path (and, if wanted,
the audio path — buttons only appear when the `audio_modem` plugin +
`amodem` are installed; see the prerequisites below).
### Animated-QR interop: BBQR only
- `bal/core/animated_qr.py` implements a stdlib-only codec for **BBQR**
(Coinkite `B$…` base32/hex/zlib frames), plus one shared
`AnimatedQrSession` with `detect_format` auto-detection and
`parse_for_detection` frame identity for the GUI debounce. The previously
supported **BAL QR**, **BC-UR v1** and **BC-UR v2** wire formats were
removed; only BBQR remains.
- **Sessions:** BBQR full-frame decode in any order for Z/2/H encodings,
duplicate drops.
- **Safety:** `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`,
`TransferConflictError` on a frame from a different transfer,
`SessionLimitError`, BBQR zlib-bomb guard, UTF-8 payloads only.
- **GUI:** `BalQrExportWidget` emits BBQR frames using the QR-size presets;
the importer routes every frame through `detect_format` +
`AnimatedQrSession.add_part` with the shared
`qr_import_accept_frame(state, fmt, session_key, frame_total, index,
payload, stable_reads=2)` debounce (reset on session-key change).
`_review_and_sign` resolves the session to the transfer text and decodes
parts uniformly.
- **Verification:** `tests/test_core_animated_qr.py` (BBQR round-trips,
encoding fallback, zlib-bomb guard, detection) and the extended
`tests/test_gui_qr_transfer.py` pass.
**Dev-box audio prerequisites (audio_modem channel):**
See `AUDIO_MODEM_DEBIAN.md` — the full Debian setup + verification, with the
two pitfalls (unversioned `libportaudio.so`, numpy>=2 `tostring` removal):
- `sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /usr/lib/x86_64-linux-gnu/libportaudio.so`
(the unversioned name the plugin loads; Debian ships only `.so.2`).
- numpy>=2 patch in `electrum/env/.../amodem/common.py`: `tostring()` →
`tobytes()` (already applied locally). Both are runtime-env fixes, not repo
changes; see CHANGELOG entry 56.
- To loop-test on one machine without speakers/mic: during receive,
`pactl set-default-source <sink>.monitor` (restore after).

415
PLAN_CMDLINE_PLUGIN.md Normal file
View File

@@ -0,0 +1,415 @@
# Piano: supporto da riga di comando (CLI) per il plugin BAL
> **Stato**: solo piano. Nessun codice viene modificato finché il piano non viene approvato.
>
> **Versione di riferimento**: commit `2221389` (`core: anchor relative locktime/threshold recipes...`), working tree pulito.
---
## 1. Obiettivo
Rendere il plugin **Bitcoin After Life** utilizzabile da riga di comando / daemon
di Electrum, senza GUI Qt, esponendo comandi per:
1. **Willexecutors** — elenco, aggiunta, modifica, selezione, eliminazione, import/export, ping, download lista.
2. **Heirs** — elenco, aggiunta, modifica, eliminazione, import/export.
3. **Impostazioni** — lettura e modifica (`settings set chiave=valore`), reset a default.
4. **Will** — ciclo di vita completo: visualizza stato, check di coerenza, prepara/ricostruisci, firma, import/merge, esporta, invalida, trasmette ai will-executor, verifica lato will-executor (searchtx).
Il tutto riusando **esclusivamente la logica già presente in `bal/core/`** (che è
già GUI-free) e senza importare mai PyQt.
---
## 2. Stato attuale (verificato sul codice)
### 2.1 Meccanica di Electrum (4.8.0, checkout `electrum/`)
Ho verificato sul codice reale (`electrum/commands.py`, `electrum/plugin.py`,
`electrum/daemon.py`, `run_electrum`) i punti che governano i comandi dei plugin:
- **Registrazione comandi**: `@plugin_command(s, plugin_name)` in
`electrum/commands.py:2317`. Un comando plugin:
- è **sempre** un `async def`;
- viene registrato come `bal_<nome_funzione>` su `Commands` (quindi anche nel parser CLI);
- **forza il flag `'n'`** (richiede rete/daemon): *tutti* i comandi plugin richiedono un daemon in esecuzione e NON funzionano con `--offline`;
- alla chiamata inietta `plugin = daemon._plugins.get_plugin('bal')` (riga 2337).
- **Pre-parse CLI** (`run_electrum` riga 425): `Plugins(tmp_config, cmd_only=True)` importa solo l'`__init__.py` di ogni plugin abilitato per registrare i comandi nel parser. In modalità `cmd_only` il filtro `available_for` viene **saltato** (`plugin.py:128`), ma serve `config['plugins.bal.enabled'] is True` (`plugin.py:117`).
- **Daemon** (`daemon.py:626`): `Plugins(self.config, 'cmdline')`. Qui il filtro `available_for` **vale**: il plugin deve dichiarare `"cmdline"`.
- **Caricamento entry-point** (`plugin.py:622`): il daemon importa `electrum.plugins.bal.<gui_name>` con `gui_name='cmdline'`, quindi serve un modulo `bal/cmdline.py` con una classe `Plugin`.
- **Iniezione wallet**: il decorator `@command` (righe 170-194) gestisce i flag:
- `'w'` → risolve e inietta `wallet` da `daemon.get_wallet(wallet_path)` (il wallet deve essere già caricato con `electrum load_wallet`);
- `'p'` → richiede `--password` (o wallet già sbloccato) per le operazioni di firma.
- **Output**: il valore di ritorno del comando viene stampato come JSON da `run_electrum` (righe 626-630); in modalità daemon gli errori `UserFacingException` vengono stampati con exit code 1.
### 2.2 Il plugin (bal v0.6.1)
- `bal/core/` è già GUI-free e contiene tutta la logica riutilizzabile:
- `heirs.py` — `Heirs` (dict persistito in wallet DB, chiave `"heirs"`), validazione (`validate_heir`, `_validate`), `import_file`/`export_file`, `get_transactions`/`buildTransactions`.
- `willexecutors.py` — `Willexecutors` (config `bal_willexecutors`, chiave per `chainname`), `get_willexecutors`, `save`, `initialize_willexecutor`, `is_selected`, `is_valid`, `ping_servers_parallel`, `push_transactions_parallel`, `check_transactions_parallel`, `check_transaction`, `download_list`, `get_willexecutors_list_from_json`.
- `will.py` — `Will` (statiche) e `WillItem` (stato per-tx: `VALID/COMPLETE/PUSHED/CHECKED/...`), `is_will_valid`, `check_will`, `check_willexecutors_and_heirs`, `invalidate_will`, `normalize_will`, `get_min_locktime`, `get_tx_from_any`, `set_check_willexecutor`, `save_valid_transactions_to_history`.
- `plugin_base.py` — `BalPlugin` (tutte le `BalConfig`: chiavi `bal_*`), `BalTimestamp`, `get_version`, registrazione dei dict `heirs`/`will`/`will_settings` nel wallet DB.
- `checkalive.py` — `resolve_date_to_check`, `resolve_relative_check_alive_date`, `ensure_check_alive_valid` (riferimento temporale unico per ogni check; relativo negativo = prima della delivery, positivo = dopo la creazione).
- `util.py` — `Util` (locktime, quantità, confronto tx/heirs, `get_available_utxos`, `fix_will_settings_tx_fees`).
- `bal/gui/qt/window.py` — `BalWindow` contiene i flussi da **replicare in headless** (non riusabile direttamente perché legato a Qt):
- `init_will` (riga 151), `load_willitems`/`save_willitems` (120/129),
- `init_class_variables` (618) e `build_will` (397),
- `build_inheritance_transaction` (678) → il flusso completo "prepara will",
- `sign_transactions` (952), `ask_password_and_sign_transactions` (1084),
- `push_transactions_to_willexecutors` (1164), `broadcast_transactions` (1127),
- `check_transactions_task`/`check_transactions` (1414/1464),
- `export_json_file` (1246), `merge_will` (1264), `merge_will_from_file` (1348), `_load_will_file` (1406),
- `invalidate_will` (917).
- `bal/manifest.json`: `"available_for": ["qt"]`, `"version": "0.6.1"`.
- `build_zip.py`: cammina ricorsivamente su `bal/` (esclude `__pycache__`, `.pyc`), quindi **includerà automaticamente** i nuovi file di `bal/cli/` e `bal/cmdline.py`.
---
## 3. Architettura proposta
```
bal/
__init__.py # MODIFICATO: importa ``from .cli import commands`` (registra i comandi)
cmdline.py # NUOVO: shim zip-safe (come qt.py) che ri-espone Plugin da bal.cli.plugin
cli/
__init__.py # NUOVO
commands.py # NUOVO: tutti i @plugin_command (async), sottili, delegano al controller
controller.py # NUOVO: BalController — facciata headless per-wallet (replica di BalWindow senza Qt)
plugin.py # NUOVO: class Plugin(BalPlugin) — entry-point per il daemon (gui_name='cmdline')
manifest.json # MODIFICATO: available_for = ["qt", "cmdline"]
```
Principi:
- **`bal/cli/` non importa mai Qt** (stessa regola di `bal/core/`). Può importare solo `bal.core`, `electrum.*` e stdlib.
- **`commands.py` = livello di trasporto**: firma `async def bal_x(self, wallet=None, plugin=None, ...)`, valida/parsa argomenti, chiama il controller, ritorna strutture JSON-serializzabili. Zero logica di business.
- **`controller.py` = il cuore**: replica i passi GUI-free di `BalWindow`, ma con errori espressi come eccezioni (i messaggi GUI `show_message`/`show_error` diventano raise/ritorni), e persiste esplicitamente su wallet DB.
- **`plugin.py`** è quasi vuoto: eredita `BalPlugin.__init__` e basta (serve solo perché Electrum istanzi `module.Plugin(self, config, name)`).
- **Nessuna dipendenza nuova** richiesta: `aiohttp`, `dns` e il resto sono già usati da `bal/core`.
### 3.1 Perché i comandi richiedono il daemon
`plugin_command` forza il flag `'n'` in `commands.py:2321-2322`. Conseguenza
architetturale da documentare chiaramente:
```
electrum daemon -d # avvia il daemon (rete + plugin cmdline)
electrum load_wallet # carica/sblocca il wallet
electrum bal_heirs_list # i comandi BAL girano contro il daemon
```
Questa è la stessa limitazione di tutti gli altri plugin con comandi CLI
(es. `swapserver`, `nwc`). Non è aggirabile senza hackare `plugin_command`, che
escludiamo dal piano.
---
## 4. Modifiche ai file esistenti
### 4.1 `bal/manifest.json`
- `"available_for": ["qt", "cmdline"]`.
Nessun cambio di versione necessario per lo sviluppo; la versione si alzerà in
`make-release.sh` come già avviene.
### 4.2 `bal/__init__.py`
- Aggiungere in fondo:
```python
# Registra i comandi CLI (bal_*) appena Electrum importa il pacchetto,
# sia in modalità cmd_only (pre-parse) sia nel daemon.
from . import cli # noqa: F401 (importa bal.cli.commands, che registra i @plugin_command)
```
(oppure `from .cli import commands` esplicito).
- Accortezza: `bal/cli/commands.py` deve essere importabile **senza Qt** e senza
effetti collaterali pesanti, perché viene importato anche nel pre-parse CLI e
all'avvio della GUI.
### 4.3 `build_zip.py`
- Nessuna modifica obbligatoria: il walker include già `cli/` e `cmdline.py`.
- **Opzionale (consigliato)**: aggiungere una stampa di avviso quando l'archivio
contiene sia `cmdline.py` che `qt.py`, e verificare che `manifest.json` abbia
entrambi i valori in `available_for`.
---
## 5. Nuovi file
### 5.1 `bal/cmdline.py` (shim, ~stesso schema di `qt.py`)
Riproduce il pattern zip-safe di `qt.py` (creazione dei package intermedi in
`sys.modules`, import via `importlib.import_module`), ma punta a
`bal.cli.plugin`:
```python
Plugin = _plugin_module.Plugin
```
### 5.2 `bal/cli/plugin.py`
```python
class Plugin(BalPlugin):
def __init__(self, parent, config, name):
BalPlugin.__init__(self, parent, config, name)
```
Niente hook Qt, niente `bal_windows`. Il daemon lo istanzia quando
`get_plugin('bal')` viene chiamato dal wrapper di `plugin_command`.
### 5.3 `bal/cli/controller.py` — `BalController`
Facciata per-wallet che incapsula lo stato e i flussi. Attributi (speculari a
`BalWindow`):
- `plugin` (il `BalPlugin`/`Plugin` iniettato),
- `wallet` (iniettato da Electrum),
- `will_settings` (da `plugin.WILL_SETTINGS.get()` + `Util.fix_will_settings_tx_fees`),
- `heirs` (`Heirs(wallet)` validati),
- `willexecutors` (`Willexecutors.get_willexecutors(plugin)`),
- `willitems` (da `wallet.db.get_dict("will")` → `WillItem(w, wallet=wallet)`),
- `date_to_check` (via `resolve_date_to_check`).
Metodi principali (replicano le funzioni Qt, senza dialoghi):
| Metodo | Replica di (`window.py`) | Note |
|---|---|---|
| `load_willitems()` | 120 | Costruisce i `WillItem` dal dict `will` del wallet DB. |
| `save_willitems()` | 129 | `to_dict()` con `tx` serializzato a stringa, `json.dumps` di prova, scrittura su `wallet.db` + `wallet.save_db()`. |
| `init_class_variables()` | 618 | `date_to_check`, `no_willexecutor`, `willexecutors`, check `ensure_check_alive_valid`. |
| `check_will()` | 473 | `Will.is_will_valid(...)`; le eccezioni di dominio vengono propagate al comando. |
| `build_inheritance_transaction()` | 678 | Flusso 1/7→2/7 replicato: `Will.check_amounts`, guardie locktime/willexecutor, `check_will()` e rebuild su `NotCompleteWillException`. Le `show_message/show_error` diventano raise (`UserFacingException` con testo chiaro) oppure ritorni `{"status": "postponed", "invalidation": tx}`. |
| `sign_transactions(password)` | 952 | Firma i `VALID` non completi: fixup input dai willitems padre, `wallet.sign_transaction(tx, password, ignore_warnings=True)`, `set_status("COMPLETE")`, `check_signatures`. |
| `push_transactions_to_willexecutors(force)` | 1164 | `get_willexecutor_transactions` + `push_transactions_parallel` + gestione "already present" con `check_transaction`. Aggiorna `PUSHED/PUSH_FAIL`. |
| `check_transactions()` | 1414 | `check_transactions_parallel` + `set_check_willexecutor(res)` per item. |
| `export_json_file(path)` | 1246 | `write_json_file(path, {wid: wi.to_dict()...})` con `tx` come stringa (formato identico a `_load_will_file`). |
| `merge_will_from_file(path)` | 1348 | `_load_will_file` + `merge_will` (stessa semantica di `window.py:1264`). |
| `_load_will_file(path)` | 1406 | `read_json_file` + `tx_from_any` + `WillItem`. |
| `invalidate_will()` | 917 | `Will.invalidate_will(...)` con `history_label` e `will_locktime`. |
| `fetch_will_executors_list()` / `ping()` | 1491/1771 | `download_list(old, welist_server)` + `ping_servers_parallel`, poi `Willexecutors.save(plugin, ...)`. |
| `apply_settings(cfg_name, value)` | — | Mappa il nome chiave all'attributo `BalConfig` del plugin e fa `set(...)`. |
Regole di persistenza (fondamentali):
- **heirs** → `heirs.save()` (via `__setitem__`/`pop` già implementati) + `wallet.save_db()`.
- **will** → `save_willitems()` + `wallet.save_db()`.
- **willexecutors** → `Willexecutors.save(plugin, willexecutors)` (config, non wallet DB).
- **settings** → `BalConfig.set(...)` (config).
### 5.4 `bal/cli/commands.py` — comandi (tutti `async def` + `@plugin_command`)
Firma standard: `async def bal_x(self, wallet=None, plugin=None, ...)`. Flag:
- `'n'` — imposto automaticamente da `plugin_command` (rete/daemon).
- `'w'` — wallet richiesto e iniettato da Electrum.
- `'p'` — solo per i comandi che firmano (richiede `--password`).
Tutti i comandi costruiscono `controller = BalController(plugin, wallet)` e
ritornano strutture JSON-serializzabili. Elenco completo al §6.
---
## 6. Tabella comandi
Convenzioni:
- `<WALLET>`: wallet caricato nel daemon (non serve passarlo; Electrum usa quello
configurato o `--wallet`).
- Output: `list`/`dict` stampati come JSON; exit 0 su successo, 1 su errore.
- `*` = richiede password (`--password`) se il wallet è cifrato.
### 6.1 Willexecutors
| Comando | Flag | Argomenti | Descrizione / output |
|---|---|---|---|
| `bal_willexecutors_list` | `nw` | — | Elenco `{url: {address, base_fee, status, info, selected, last_update, sort}}` per la chain corrente. |
| `bal_willexecutors_show` | `nw` | `url` | Dettaglio di un singolo will-executor. |
| `bal_willexecutors_add` | `nw` | `url` `address` `base_fee` | Aggiunge/aggiorna un will-executor (via `initialize_willexecutor`), `selected=false` di default. Ritorna il record. |
| `bal_willexecutors_update` | `nw` | `url` `[address]` `[base_fee]` `[info]` `[promo_code]` | Modifica i campi indicati e salva. |
| `bal_willexecutors_select` | `nw` | `url` `value` | `is_selected(we, eval_bool(value))` + salva. |
| `bal_willexecutors_delete` | `nw` | `url` | Rimuove dalla lista e salva. |
| `bal_willexecutors_ping` | `nw` | `[url]` | `ping_servers_parallel` (tutti o uno); aggiorna `status/base_fee/address`; salva. Output: risultati per url. |
| `bal_willexecutors_download` | `nw` | — | `download_list(old, plugin.WELIST_SERVER.get())`; unisce e salva. Output: n. record. |
| `bal_willexecutors_import` | `nw` | `path` | Legge un JSON `{url: record}` (stesso formato di export), `initialize_willexecutor` per record, salva. |
| `bal_willexecutors_export` | `nw` | `path` | Scrive `{url: record}` su file JSON. |
### 6.2 Heirs
| Comando | Flag | Argomenti | Descrizione / output |
|---|---|---|---|
| `bal_heirs_list` | `nw` | — | `{name: [address, amount, locktime, ...]}` (tutte le colonne `HEIR_*`). |
| `bal_heirs_show` | `nw` | `name` | Dettaglio di un singolo heir. |
| `bal_heirs_add` | `nw` | `name` `address` `amount` `locktime` | Valida con `Heirs.validate_heir` (OP_RETURN incluso) e salva. `amount` può essere satoshi o `"50%"`. `locktime` può essere timestamp assoluto o relativo `"30d"`/`"1y"`. |
| `bal_heirs_update` | `nw` | `name` `[address]` `[amount]` `[locktime]` | Modifica i campi indicati (ri-validazione) e salva. |
| `bal_heirs_delete` | `nw` | `name` | `heirs.pop(name)` + `save_db()`. |
| `bal_heirs_import` | `nw` | `path` | `Heirs.import_file(path)` (validazione + merge). |
| `bal_heirs_export` | `nw` | `path` | `Heirs.export_file(path)`. |
### 6.3 Impostazioni
| Comando | Flag | Argomenti | Descrizione / output |
|---|---|---|---|
| `bal_settings_list` | `n` | — | Elenco di tutte le `BalConfig` del plugin: `{chiave: {value, default, name}}` (nome leggibile). |
| `bal_settings_get` | `n` | `key` | Valore corrente di una chiave (`bal_*`). |
| `bal_settings_set` | `n` | `key=value` | Scrive il valore (conversione di tipo: bool/int/str/JSON) via `BalConfig.set(...)`. `bal_will_settings` accetta JSON. |
| `bal_settings_reset` | `n` | `key` | `BalConfig.set(cfg.default)`. |
### 6.4 Will
| Comando | Flag | Argomenti | Descrizione / output |
|---|---|---|---|
| `bal_will_status` | `nw` | — | Per ogni `wid` (txid): locktime, `heirsvalue`, executor, flag di stato (`VALID/COMPLETE/PUSHED/CHECKED/CHECK_FAIL/...`), `sigs_have/sigs_required`, `tx_fees`, executor URL. |
| `bal_will_check` | `nw` | — | `check_will()` (coerenza heirs+executor+fees+locktime, in locale). Ritorna `{"valid": true}` o un errore esplicito (es. `HeirNotFound`, `WillPostponed`, `WillExpired`, `NoHeirs`). |
| `bal_will_prepare` | `nw` | — | Flusso completo `build_inheritance_transaction`: check → rebuild se non coerente → persiste. Output: riepilogo tx nuova/aggiornata per wid. |
| `bal_will_sign` | `nwp` | `[txid]` | Firma i `VALID` non completi (o solo `txid`). Aggiorna `COMPLETE` e `sigs_*`; persiste. Output per txid. |
| `bal_will_broadcast` | `nw` | `[txid]` `force` | `push_transactions_to_willexecutors(force, txids)` parallelo; aggiorna `PUSHED/PUSH_FAIL`. Output: `{url: status}`. |
| `bal_will_export` | `nw` | `path` | `export_json_file(path)`. |
| `bal_will_import_merge` | `nw` | `path` | `merge_will_from_file(path)` (stessa semantica GUI: merge psbt/stati, mai perdere una tx viva). |
| `bal_will_invalidate` | `nw` | — | `Will.invalidate_will(...)`; ritorna la tx di invalidazione (da firmare+trasmettere con i comandi sopra). |
| `bal_will_check_executor` | `nw` | `[txid]` | Verifica lato will-executor: `check_transactions_parallel` (searchtx) per i `VALID+PUSHED` non `CHECKED`; applica `set_check_willexecutor`. Output: `{wid: {url, checked, ok}}`. |
---
## 7. Flusso dati e persistenza
```
CLI (electrum bal_*) Daemon (Electrum 4.8.0)
┌───────────────────────┐ ┌──────────────────────────────────────┐
│ run_electrum │ RPC │ Daemon.run_cmdline │
│ pre-parse cmd_only │ ─────────────► │ plugin_command wrapper │
│ -> importa bal │ jsonrpc │ inietta plugin + wallet │
│ (registra bal_*) │ │ bal/cli/commands.py │
└───────────────────────┘ │ -> BalController(plugin, wallet) │
│ -> bal.core.* │
│ -> wallet.db / config (persist) │
└──────────────────────────────────────┘
```
- **Lettura**: `wallet.db.get_dict("will")` (wills), `Heirs(wallet)` (heirs),
`plugin.WILLEXECUTORS.get()`/`plugin.WILL_SETTINGS.get()` (config).
- **Scrittura**: `save_willitems()` → `wallet.db` + `wallet.save_db()`;
`heirs.save()`; `Willexecutors.save(...)`; `BalConfig.set(...)`.
- **Firma**: `wallet.sign_transaction(tx, password, ignore_warnings=True)` —
idem GUI, quindi compatibile con multisig e wallet cifrati (password via `--password`).
- **Rete**: `Network.get_instance()` già usato da `bal/core/willexecutors.py`
(i comandi `'n'` garantiscono rete attiva).
---
## 8. Errori, exit code, output
- Ritorno `None` → nessun output; `str` → stampato; `dict`/`list` → `json_encode`.
- Errori utente: sollevare `electrum.util.UserFacingException(msg)` → in modalità
daemon viene stampato `msg` con exit 1.
- Errori di dominio BAL (`WillExpiredException`, `WillPostponedException`,
`HeirNotFoundException`, `NoWillExecutorNotPresent`, `CheckAliveError`,
`AmountException`, ...): il controller le converte in `UserFacingException`
con testo in chiaro (riuso dei messaggi già presenti, senza HTML/Qt).
- Convenzione consigliata per comandi che producono più di un risultato:
ritornare un `dict` con chiave `"result"`/`"warnings"` quando servono avvisi
(es. dopo `prepare` con heirs scartati per dust).
---
## 9. Compatibilità Electrum 4.7.2 / 4.8.0
- `plugin_command`, il wrapper `@command` e `daemon._plugins.get_plugin` esistono
in entrambe le versioni (verificati su 4.8.0; usati identici da `swapserver`).
- Il `BalPlugin` già gestisce il cambio API di registrazione dict
(`json_db.register_dict` vs `stored_dict.register_name`): nessun intervento.
- `available_for: ["cmdline"]` è lo stesso meccanismo di `trustedcoin`
(che ha già `cmdline.py` in 4.8.0).
- **Nessun nuovo import Qt** in `bal/cli/`: verificabile in CI con un check
statico su `bal/cli/*.py` e `bal/cmdline.py`.
---
## 10. Build / release
- `python3 build_zip.py` produce `bal-electrum-plugin.zip` con `cli/`, `cmdline.py`
e il manifest aggiornato. Lo zip serve sia per la GUI che per il daemon.
- Il test `external_zip_test.py` andrà esteso (vedi §11) per verificare che il
zip, caricato da Electrum, registri anche i comandi `bal_*`.
- Nessun cambiamento a `make-release.sh` (la versione resta nel manifest).
---
## 11. Piano di test e verifica
### 11.1 Nuovi test standalone (stile repo: `tests/test_*.py` con `if __name__ == "__main__"`)
- `tests/test_cli_commands_registered.py` (runtime env):
- importa `electrum.plugins.bal` con `Plugins(config, cmd_only=True)`;
- asserisce che `known_commands` contenga tutti i nomi `bal_*` della tabella;
- asserisce che ogni funzione sia coroutine e abbia il flag `n`.
- `tests/test_cli_controller.py` (runtime env, offline, senza rete):
- wallet "fake"/temporaneo (pattern di `test_core_heirs.py`);
- CRUD heirs e willexecutors, settings get/set/reset, export/import will
(merge), build will con fixtures note.
- `tests/test_cli_zip.py` (o estensione di `external_zip_test.py`):
- costruisce lo zip, lo carica come `electrum_external_plugins.bal` con
`Plugins(config, 'cmdline')`, asserisce `available_for` include `"cmdline"`
e che `get_plugin('bal')` restituisca il `Plugin` di `bal.cli.plugin`
(nessun import Qt eseguito).
- `tests/test_cli_will_flows.py` (offline, dove possibile):
- prepare → sign → export → merge su un wallet di test con heirs fissi;
- verifica che `wallet.db.get_dict("will")` rifletta COMPLETE/PUSHED dopo
le operazioni che non toccano rete.
### 11.2 Verifica manuale (da documentare nel README/HANDOFF)
```bash
source "$BAL_HOME/electrum/env/bin/activate"
electrum daemon -d
electrum load_wallet
electrum bal_heirs_list
electrum bal_settings_list
electrum bal_will_status
electrum bal_will_prepare
electrum bal_will_sign --password '...' # se wallet cifrato
electrum bal_will_broadcast
electrum bal_will_check_executor
electrum bal_willexecutors_ping
electrum stop
```
### 11.3 Regressione
- `QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py electrum.plugins.bal`
deve continuare a passare (prova che `bal/__init__` + Qt convivono con il
nuovo import di `bal.cli.commands`).
- Eseguire i `test_core_*.py` esistenti (nessuna logica core toccata).
- Ruff: evitare nuove violazioni in `bal/cli/`.
---
## 12. Rischi e decisioni aperte
1. **Daemon obbligatorio** (non `--offline`): imposto da `plugin_command`.
→ Accettato; documentato al §3.1.
2. **Wallet pre-caricato**: i comandi `w` falliscono con "wallet not loaded" se
non si lancia prima `electrum load_wallet`. → Documentare.
3. **`bal/__init__.py` che importa `bal.cli.commands`**: viene eseguito anche
all'avvio della GUI. `commands.py` deve restare leggero (solo definizioni +
import di `electrum.commands` e `bal.core`). Da verificare con `smoke_test.py`.
4. **Doppio caricamento**: se un install è contemporaneamente interno E zip
esterno, la seconda importazione di `commands.py` potrebbe sollevare
"Command name bal_... already exists". Pratica corrente: un solo install;
si può mitigare con un guard `if not getattr(module, '_registered')`.
5. **OP_RETURN heirs** in CLI: gestiti come in GUI (`validate_op_return_hex`,
colonne quantità `"0"`). Da testare.
6. **Persistenza `will_settings`**: oggi letta dalla config globale
(`bal_will_settings`) in `BalWindow.__init__`, non dal wallet DB. Il
controller deve replicare esattamente questo (config), non introdurre una
seconda sorgente.
7. **Multisig**: la firma usa `wallet.sign_transaction` → supportata; il flusso
"merge PSBT" copre la firma parziale. Test dedicato con wallet multisig in
fase di implementazione.
---
## 13. Fasi di implementazione (ordine proposto)
1. `bal/cli/__init__.py`, `bal/cli/plugin.py`, `bal/cmdline.py`, update
`bal/manifest.json` + `bal/__init__.py`.
2. `tests/test_cli_commands_registered.py` + verifica `smoke_test.py`.
3. `bal/cli/controller.py` (read-only: status/list/show) → `commands.py` per
willexecutors/heirs/settings (senza rete).
4. Comandi will: `prepare`, `sign`, `export`, `import_merge`, `invalidate`.
5. Comandi di rete: `ping`, `download`, `broadcast`, `check_executor`.
6. Test zip (`test_cli_zip.py`), estensione `external_zip_test.py`, prova
manuale col daemon, aggiornamento README/HANDOFF.

453
PLAN_I18N.md Normal file
View File

@@ -0,0 +1,453 @@
# PLAN_I18N — Italian translation of the BAL Electrum plugin (i18n)
> **Status:** DRAFT. Every phase still needs the owner's explicit "OK" before any code is written (HANDOFF.md, rule R4).
> **Prepared:** 2026-09-25, from a read-only analysis (no code was changed). Owner's decisions recorded on 2026-09-26 (section 6).
> **Code base analysed:** `bal-electrum-plugin` `main` @ `4a33116` (2026-09-19, manifest `0.7.0`) and Electrum `4.8.0`.
> **Line numbers** refer to that commit. After merging a newer `main`, find the code by content.
## 0. Read this first
1. Read `HANDOFF.md` (rules R1–R4, ZIP-FIRST, CREDIT-SAVING) and `AGENTS.md` (venvs, tests). They still apply.
2. **One exception to HANDOFF.md.** Section 5 ("Git / delivery workflow") and section 7, step 7, say: work directly on `main`, push to `origin/main`, run `make-release.sh`. **That does NOT apply to this task.** See section 3 (dedicated branch).
3. The owner's decisions (D1–D7) are recorded in section 6. For anything not covered there, ask the owner (rule R3).
## 1. Goal and scope
**Goal:** when Electrum is set to Italian, the whole BAL interface appears in Italian. The mechanism must allow adding more languages later just by adding one `.po` file.
**In scope**
- The Qt GUI of the plugin (`bal/gui/qt/`) and the GUI-free strings it displays (`bal/core/`).
- Languages: Italian (`it_IT`) and Spanish (`es_ES`).
**Out of scope** (owner's decisions, or by design)
- **BAL Easy Heirs**: excluded by the owner.
- **CLI output** (`bal/cli/`): stays English, exactly like Electrum (`run_electrum`: "the CLI is intentionally always non-localized").
- `bal/wallet_util/` (standalone helper scripts), the Android reader (`android/`, which uses Android resources), the manual, the website and the docs.
- Other languages: later. The infrastructure will support them.
## 2. Background (verified)
### 2.1 How Electrum 4.8.0 does it
- **gettext with one domain, `electrum`.** Catalogs live in `electrum/locale/locale/<lang>/LC_MESSAGES/electrum.mo` (`electrum/i18n.py`, `LOCALE_DIR`). Every string goes through `electrum.i18n._()`.
- **The language is set once, at startup, for the GUI only** (`run_electrum` L433–451):
- it uses `config.LOCALIZATION_LANGUAGE` (config key `language`);
- if that is empty, it uses `electrum.gui.default_lang.get_default_language(gui_name)`, i.e. `QLocale.system().name()`, **but only if** that value is in `electrum.i18n.languages`; otherwise `en_UK`;
- `en_*` means no translation (the English source strings are shown);
- changing the language requires a restart.
- **Strings evaluated too early stay English.** Anything evaluated before `set_language()` (for example at import time) is not translated.
- **Safety check inside `_()`.** The decorator `_ensure_translation_keeps_format_string_syntax_similar` rejects a translation whose `{}` fields differ from the source. `_("")` returns `""`. There is an optional `context=` argument (pgettext).
- **Coding rules** (comments in `i18n.py`): no f-strings inside `_()`, no `%` formatting; write `_("… {}").format(x)`. There are no plural forms (`ngettext` is not used anywhere).
- **Pipeline:**
1. `contrib/locale/push_locale.py` runs xgettext over `electrum/**/*.py`. This includes *internal* plugins.
2. The strings go to Crowdin, where volunteers translate them.
3. The `.po` files are stored in the `electrum-locale` repo.
4. `contrib/locale/build_locale.sh` compiles them with msgfmt into `.mo` files and writes `stats.json`.
- **Translation security:**
- `electrum-locale/update.py` refuses any translation whose `msgstr` contains a Bitcoin address, an e-mail address, a URL, or a long word mixing letters and digits;
- `llm_proofreader/` checks every translation PR for vandalism in CI.
- **External plugins:**
- they are always ZIP files, imported with `zipimport` as `electrum_external_plugins.<name>`;
- `BasePlugin.read_file(filename) -> bytes` reads a file from inside the ZIP, or from the internal plugin folder;
- **Electrum provides nothing for translating external plugins.** Their strings are not in `electrum.mo`.
- `i18n.py`, `gui/default_lang.py` and `read_file()` are identical in 4.7.2, 4.8.0 and 4.8.2.
- **Swiss locales** (`it_CH`, `de_CH`, `fr_CH`) are not in Electrum's list, so Electrum starts in English until the user picks a language. BAL must simply follow Electrum and not detect the language itself. Otherwise the UI would be half in one language and half in another.
### 2.2 BAL today (`main` @ `4a33116`)
- Source strings are in English. Most are wrapped in `_`, imported from `electrum.i18n` in these places:
- `gui/qt/common.py:55`, which re-exports it to every `gui/qt` module;
- `gui/qt/theme.py:90,108`;
- `core/will.py:32` and `core/willexecutors.py:25`;
- `cli/controller.py:29`.
- The package has 483 `_()` calls in 11 files. xgettext finds 365 distinct strings (≈ 2,230 words).
- **With Electrum set to Italian, only 39 of the 365 strings (≈ 11 %) appear in Italian.** They are the generic words that Electrum already translates (Cancel, Save, Address…). Everything specific to BAL stays in English.
- **Issues to fix before translating** (exact locations are in the Appendix):
- 14 f-strings or `.format()` calls inside `_()`: these strings can never be translated (Ruff INT001/INT002);
- 15 `_()` calls with a non-literal argument: extraction tools cannot find the text;
- 14 concatenations around `_()`: the word order breaks in other languages;
- 18 `_()` calls evaluated at import time (in class bodies): they would stay English (see 4.2);
- 2 × `_("")`: plain gettext returns the catalog header instead of an empty string;
- 12 settings help texts (`HelpButton("…")`) and the help texts of 5 `add_widget()` calls are never translated (≈ 500 words in total);
- other text shown to the user without `_()`.
- **Some stored data depends on the UI language.**
- `WillItem.set_status()` (`core/will.py:1344`) appends the *translated* status label to `WillItem.status`.
- That field is saved via `to_dict()` in the wallet DB (`wallet.db.get_dict("will")`, `gui/qt/window.py:232`), in exported files and in QR transfers.
- Real example from `tests/samanta7`: `"New.Firmato.Pushed.Checked.Replaced"`. Electrum's Italian catalog already translates *Signed*, *Error* and *Expired*, so the saved history mixes languages today.
- **Default texts in `core/plugin_base.py` exist only in English:**
- `HISTORY_LABEL` (around L246, token `{willexecutor}`);
- `EVENT_DESCRIPTION` and `EVENT_SUMMARY` (around L331–337, tokens `$wallet_name` and `$heirs_complete`).
## 3. Branch and workflow for this task
The owner decided, on Truman's suggestion, to do this work on a **separate branch on Gitea**, not on `main`.
- **Branch:** `feature/i18n` (proposed name, see D6), created from `main`.
- **Never** commit or push to `main` during this task.
- **Never** run `make-release.sh` from the branch: it tags and publishes a Gitea release. Build test ZIPs with `python3 build_zip.py` only.
- **At the start of every phase:**
1. merge `origin/main` into `feature/i18n`;
2. resolve any conflicts;
3. run the tests again.
- **Where conflicts are likely:**
- `gui/qt/dialogs.py`, `window.py`, `lists.py`, `plugin.py`, `widgets.py` and `core/will.py`: all were changed on `main` on 2026-09-04, 09-14 and 09-19, and commit `fb88d75` alone changed ≈ 1,800 lines of `dialogs.py`;
- `CHANGELOG.md`: both sides append at the end, so keep both entries and renumber.
- **ZIP-FIRST for every phase:**
1. build the ZIP;
2. the owner (and Truman) test it;
3. only after the owner's explicit OK, commit on `feature/i18n` and push to `origin/feature/i18n` (who does this: see D3).
- Add one numbered `CHANGELOG.md` entry per phase, in English.
- Run ruff and the tests before every ZIP (HANDOFF.md). The known pre-existing test failures are listed in HANDOFF.md, section 7, step 3.
- **At the end:** open a Pull Request `feature/i18n` → `main` on Gitea (there is a precedent: `feature/bal-qr-transfer`). Release from `main` only after the merge.
## 4. Design (decided)
**Chosen:** a gettext catalog owned by BAL, domain `bal`, shipped inside the plugin ZIP, that follows Electrum's language and is consulted **after** Electrum's own catalog (D7).
**Rejected:**
- keep using `electrum.i18n._`: 89 % of the text stays English;
- Python dictionaries or JSON files: a home-made format with no tooling;
- Qt Linguist (`.ts` / `.qm`): Electrum desktop does not use it, and `core/` has no Qt.
### 4.1 New module `bal/i18n.py`
| Function | Behaviour |
|---|---|
| `_(msg)` | 1. `""` returns `""`. 2. **Electrum first (D7):** if `electrum.i18n._(msg)` differs from `msg`, return it (Electrum has already applied its own `{}` check). 3. Otherwise, if a BAL catalog is loaded and translates `msg`, apply the `{}` safety check and return the translation. The check uses the same logic as Electrum's decorator: **copy it** with an attribution comment (MIT); do not import Electrum's private function. 4. Otherwise return `msg` (English). The CLI and English behave exactly as in Electrum, because no BAL catalog is loaded there. |
| `N_(msg)` | Returns `msg` unchanged. It marks strings defined in tables, constants and class attributes, which are translated later with `_()` when displayed. Babel extracts `N_` by default. |
| `set_language(plugin, lang)` | `None`, `""` or `en_*`: no catalog. Otherwise read `locale/<lang>/LC_MESSAGES/bal.mo` with `plugin.read_file()`, falling back to `locale/<lang[:2]>/…`, and build `gettext.GNUTranslations(io.BytesIO(data))`. If the catalog is missing or broken, log at info level and continue in English. Never raise. |
| `init_from_config(plugin, config)` | `lang = config.LOCALIZATION_LANGUAGE`. If it is empty, use `electrum.gui.default_lang.get_default_language(gui_name="qt")`, guarding against `ImportError` as `run_electrum` does. Then call `set_language()`. |
- **Why Electrum first (D7):** BAL follows Electrum's wording wherever Electrum has it. If Electrum ever ships BAL as an internal plugin, Electrum's own translations (Crowdin) would be used automatically and the BAL catalog would only fill the gaps.
- **Trade-off of D7:** for a text that Electrum already translates, the BAL catalog cannot override Electrum's wording, not even with the D1 glossary. If one such case reads badly in BAL, handle that single case with the owner.
- A text that Electrum "translates" into the identical English text counts as not found and falls through to the BAL catalog. This is harmless: the BAL catalog is prefilled with Electrum's translations (3.3).
- **Do not use `gettext.translation(localedir=…)`.** When pointed inside a ZIP it silently returns `NullTranslations` (verified).
- `plugin.read_file()` is the same API BAL already uses for its icons. It works both for the ZIP install and for the dev symlink install (`AGENTS.md`).
- The state is module-level and set once. Changing the language requires restarting Electrum, as in Electrum itself.
### 4.2 Where the language is set
- In `bal/gui/qt/plugin.py`, `Plugin.__init__` (L67): call `init_from_config(self, config)` first.
- The CLI entry point (`bal/cli/plugin.py`) does not call it, so the CLI stays English.
- **Consequence:** `gui/qt/plugin.py` imports `dialogs`, `widgets` and `window` at module level (L49–51), and those import `lists`. So every class body is evaluated *before* `Plugin.__init__` runs, and any `_()` evaluated at import time would stay English. The cases in Appendix D must be converted in Phase 2.
### 4.3 Files and tools
```
babel.cfg # extraction mapping (repo root)
bal/locale/bal.pot # template, generated
bal/locale/it_IT/LC_MESSAGES/bal.po # Italian translations (source of truth)
bal/locale/it_IT/LC_MESSAGES/bal.mo # compiled (see D4)
```
The tool is **Babel** (`pip install babel`). It is pure Python and works on Windows without GNU gettext. These commands were tested on this code base:
```ini
# babel.cfg
[ignore: bal/cli/**]
[ignore: bal/wallet_util/**]
[python: bal/**.py]
```
```bash
mkdir -p bal/locale # extract does not create it
pybabel extract -F babel.cfg --no-wrap -o bal/locale/bal.pot .
pybabel init -i bal/locale/bal.pot -d bal/locale -D bal -l it_IT # once
pybabel update -i bal/locale/bal.pot -d bal/locale -D bal --no-wrap # after each extract
pybabel compile -d bal/locale -D bal --statistics
```
- Today this extracts 323 strings (≈ 1,900 words) from the GUI and core.
- After Phase 2, expect about 360 strings (≈ 2,500 words).
- Poedit (free) can be used to translate or review the `.po` file by hand.
### 4.4 Translation safety (as in Electrum)
- Add a check script (for example `scripts/check_translations.py`) and run it before every ZIP. It must check that:
- every `.po` file compiles;
- the `{}` fields are identical to the source (the same rule as the runtime check);
- the `$tokens` are identical (`$wallet_name`, `$heirs_complete`, …): Electrum does not check these;
- no `msgstr` contains a Bitcoin address, an e-mail address, a URL or a long letter-and-digit word (use the regexes from `electrum-locale/update.py`), unless the same text is in the `msgid`.
It must also print the counts of translated, untranslated and fuzzy strings.
- Never put addresses or URLs inside translatable strings; pass them with `{}`.
- The owner reviews every Italian string, especially the warnings about invalidation, locktime, fees and signing.
## 5. Phases
Each phase follows HANDOFF.md: DISCOVER → PLAN (owner's "OK") → EXECUTE → VERIFY → ZIP → test by the owner and Truman → commit on `feature/i18n`.
Phases 1 and 2 change nothing visible, so they may be delivered as a single ZIP/test cycle (see D5).
### Phase 1 — Infrastructure (no visible change)
- NEW `bal/i18n.py` (section 4.1), with docstrings explaining *why* (rule R2).
- `bal/gui/qt/common.py:55`: replace `from electrum.i18n import _` with an import of `_` and `N_` from `bal.i18n`. Use a relative import, zip-safe like the existing `from ...core…` imports.
- `bal/gui/qt/theme.py:90,108`: change the local imports to `bal.i18n`.
- `bal/core/will.py:32` and `bal/core/willexecutors.py:25`: import from `bal.i18n`.
- `bal/cli/controller.py:29`: **unchanged** (the CLI stays English by design).
- `bal/gui/qt/plugin.py`, `Plugin.__init__`: call `init_from_config(self, config)`.
- NEW `tests/test_i18n.py`, covering:
- the empty string;
- Electrum's translation wins; the BAL catalog is used only when Electrum has none (D7);
- rejection of a `{}` mismatch;
- `en_UK` means no catalog;
- a missing `.mo` does not crash;
- a catalog loaded from bytes (use a small test catalog).
- **Verify:**
- the existing tests (baseline as in HANDOFF.md, section 7);
- `tests/smoke_test.py`;
- `build_zip.py` followed by `tests/external_zip_test.py`;
- manually on Windows 11 with Electrum 4.8.0, once in Italian and once in English: the UI must look exactly as before.
### Phase 2 — String clean-up and language-neutral stored data (no visible change)
- **2.0** Merge `origin/main` first.
- **2.1** Appendix A: turn `_(f"…")` and `_("…".format(x))` into `_("… {}").format(x)`. Include `cli/controller.py`, for lint consistency only.
- **2.2** Appendix C: turn each concatenation into one string with `{}`.
- Keep HTML outside the strings where possible, e.g. `"<b>{}</b>".format(_("Support:"))`.
- Fix the malformed `_("<b>Willexecutor:</b:")` (`widgets.py:1366`).
- **2.3** Appendix B: mark the literal with `N_()` where it is defined, or translate it at the call site.
- The helpers `add_widget()` (`common.py:180`), `get_window_title()` (`plugin.py:1085`) and `qlabel()` (`widgets.py:1319`) need ONE rule: callers pass text that is already translated, and the helper does not call `_()` again.
- Today `window.py:290` translates twice.
- **2.4** Appendix D: replace class-level `_()` with `N_()` and translate when the text is displayed.
- In `lists.py`, `update_headers(self.__class__.headers)` (L248, 647, 1094) must receive a translated copy.
- For the wizard `title` / `message` (`dialogs.py` L380, 419–420, 527–528, 547–548), use `_(self.title)` and `_(self.message)` at L325 and L327.
- **2.5** Appendix E: replace `_("")` with `""`.
- **2.6** Appendix F and G: make the settings help texts and labels in `plugin.py` translatable.
- **2.7** Appendix H, plus a full sweep for any other text shown to the user.
- **2.8** **Stored status** (`core/will.py:1344`):
- store the **English** label (mark the `STATUS_DEFAULT` labels with `N_()`); no `_()` at write time;
- add a helper `format_status_history(status) -> str` that splits on `.`, translates the tokens that are known English labels (with an optional `NOT ` prefix), and leaves unknown or legacy tokens unchanged (`New`, `Firmato`, …);
- use it wherever the status is shown (`lists.py:589`, `widgets.py:1336`);
- this stays backward compatible: existing wallets keep displaying correctly, and older BAL versions still read plain English;
- test it with the real legacy strings in `tests/samanta7`.
- **2.9** **Default texts** (`core/plugin_base.py`: `HISTORY_LABEL`, `EVENT_DESCRIPTION`, `EVENT_SUMMARY`):
- mark the English defaults with `N_()`;
- when the stored value is missing **or equal to the English default**, use the translated default at the moment the text is used (calendar export, history label);
- the tokens must survive translation.
- **Changed during Phase 2 (safety):** `HISTORY_LABEL` stays English and is *not* translatable. `Util._label_matches_history()` uses it to recognise BAL's own local transactions (stale-history cleanup, spendable UTXOs), so a label that changed with the GUI language would no longer match the transactions saved before. Only `EVENT_SUMMARY` and `EVENT_DESCRIPTION` follow the language (`BalConfig(..., translatable=True)`). The same rule applies to every stored or compared text (wallet labels, `heirs.TRANSACTION_LABEL`, the status history).
- **2.10** `pyproject.toml`: add `"INT"` to `[tool.ruff.lint] select`.
- Ruff's hint suggests `%` formatting: **do not follow it**. Use `.format()`, which is Electrum's rule.
- `make-release.sh` treats Ruff errors as blocking, so INT must end up clean.
- **Verify:**
- Ruff INT is clean;
- the extraction count is about 360 (compare with 4.3);
- the tests pass, plus new tests for 2.8 and 2.9;
- manually in English: no visible text changed.
### Phase 3 — Italian catalog (visible change)
- **3.0** Merge `origin/main` first.
- **3.1** Add `babel.cfg` and the commands from 4.3, documented in `README.md` or `AGENTS.md`.
- **3.2** Generate `bal/locale/bal.pot` and create `it_IT/LC_MESSAGES/bal.po`.
- **3.3** Draft the translation using the glossary (D1); the owner reviews it (D2). Where Electrum's `it_IT` catalog already translates the exact same English text, copy Electrum's translation, so the two UIs match.
- **3.4** `build_zip.py`:
- compile `.po` into `.mo`, according to D4;
- put only the `.mo` files in the ZIP (no `.po` / `.pot`);
- run the check script (4.4) before zipping.
- **Verify:**
- Electrum in Italian shows BAL in Italian;
- Electrum in English shows BAL in English;
- missing entries fall back to English;
- `external_zip_test.py` passes;
- it works on Windows 11 with Electrum 4.8.0 (and 4.7.2);
- manual walkthrough of: wizard, settings, heirs list, will list and detail, will-executors, check / sign / broadcast, invalidate, export / import, QR transfer, calendar export.
### Phase 4 — Final test and merge
- The owner and Truman test the final branch ZIP.
- Merge `origin/main` into the branch one last time and test again.
- Open the Pull Request `feature/i18n` → `main` on Gitea. After the merge, follow the normal release flow from `main`.
## 6. Decisions
Recorded on 2026-09-26 from the owner's answers.
- **D1 — Italian glossary.** Rule: follow Electrum's `it_IT` wording wherever Electrum already has the term or the sentence. Grammatical variants (nouns, past participles, plurals, capitals in headers and buttons) are adapted in the draft and checked by the owner during the review (D2).
| English | Italian |
|---|---|
| will | testamento |
| heir / heirs | erede / eredi |
| will-executor | Will-Executor (kept in English) |
| check-alive | verifica se sei vivo |
| invalidate | invalida il piano |
| invalidation (noun) | invalidazione del piano |
| anticipate / postpone | anticipa / posticipa |
| locktime | locktime (kept in English; owner, 2026-09-26) |
| fee | commissione (as Electrum) |
| broadcast | trasmetti il piano |
| sign / signed | firma / firmato (as Electrum) |
| wallet | portafogli (as Electrum) |
- **D2 — Who translates.** The developer drafts `it_IT/bal.po`; the owner reviews and corrects it (for example in Poedit). For later languages: Weblate (supports Gitea and `.po`) or Crowdin, as Electrum does.
- **D3 — Branch and commits.** The developer creates `feature/i18n`, and commits and pushes on it, each time only after the owner's explicit OK (ZIP-FIRST).
- **D4 — `.mo` files.** `build_zip.py` compiles `.po` into `.mo` at build time with Babel; `.mo` files stay out of git (add `*.mo` to `.gitignore`). If Babel is missing, the build stops with a clear error, so an English-only ZIP is never shipped by mistake. Reasons: the `.po` is the only source (no stale `.mo`), every change to a translation is readable in review, and it is how Electrum works.
- **D5 — Cycles.** Phases 1 and 2 are delivered in one ZIP/test cycle. Phase 1 is not merged into `main` early.
- **D6 — Branch name:** `feature/i18n`.
- **D7 — Lookup order.** Electrum's catalog first, then BAL's, then English (see 4.1). Proposed by the owner so that BAL and Electrum read as one, also if Electrum ever integrates BAL.
## 7. Reminders for the developer
- Talk to the owner in **Italian**, with simple words and no jargon (the owner is not a programmer). Code, comments, docstrings, UI source strings, docs and commits are in **English**.
- UI source strings stay in English; Italian lives only in the `.po` file.
- Never invent. When in doubt, stop and ask.
- Before writing any code, present the phase PLAN and wait for "OK".
- ZIP-FIRST; run ruff and the tests before every ZIP; one CHANGELOG entry per phase; keep reports brief (CREDIT-SAVING).
- Wallet data compatibility comes first: apart from 2.8 (status labels), do not change what is stored.
## Appendix — Inventory at `main` @ `4a33116`
Generated by a static scan (AST, Ruff `INT` and xgettext). The line numbers will move after merging a newer `main`: re-run the scan or search by content. These lists cover the known cases; Phase 2 still needs a full sweep.
#### A. f-string / `.format()` inside `_()` — Ruff INT001/INT002 (14)
| Location | Rule |
|---|---|
| `bal/cli/controller.py:92` | INT001 |
| `bal/cli/controller.py:98` | INT001 |
| `bal/cli/controller.py:104` | INT001 |
| `bal/gui/qt/common.py:225` | INT002 |
| `bal/gui/qt/common.py:240` | INT002 |
| `bal/gui/qt/dialogs.py:1226` | INT001 |
| `bal/gui/qt/dialogs.py:2491` | INT001 |
| `bal/gui/qt/window.py:831` | INT001 |
| `bal/gui/qt/window.py:836` | INT001 |
| `bal/gui/qt/window.py:1090` | INT001 |
| `bal/gui/qt/window.py:1091` | INT001 |
| `bal/gui/qt/window.py:2228` | INT001 |
| `bal/gui/qt/window.py:2236` | INT001 |
| `bal/gui/qt/window.py:2246` | INT001 |
#### B. `_()` with a non-literal argument (not extractable) (15)
| Location | Code |
|---|---|
| `bal/core/will.py:1344` | `_(self.STATUS[status][0])` |
| `bal/gui/qt/common.py:181` | `_(label)` |
| `bal/gui/qt/dialogs.py:327` | `_(self.message)` |
| `bal/gui/qt/dialogs.py:2288` | `_(msg)` |
| `bal/gui/qt/dialogs.py:2291` | `_(msg)` |
| `bal/gui/qt/lists.py:1240` | `_(label)` |
| `bal/gui/qt/plugin.py:1086` | `_(title)` |
| `bal/gui/qt/widgets.py:284` | `_(self.tooltip_text)` |
| `bal/gui/qt/widgets.py:1320` | `_(str(title))` |
| `bal/gui/qt/window.py:290` | `_(title)` |
| `bal/gui/qt/window.py:942` | `_(message)` |
| `bal/gui/qt/window.py:2217` | `_(self.DOWNLOAD_FAILED_TOR_MESSAGE)` |
| `bal/gui/qt/window.py:2219` | `_(self.DOWNLOAD_FAILED_MESSAGE)` |
| `bal/gui/qt/window.py:2253` | `_(self.DOWNLOAD_FAILED_TOR_MESSAGE)` |
| `bal/gui/qt/window.py:2255` | `_(self.DOWNLOAD_FAILED_MESSAGE)` |
#### C. Concatenations around `_()` (14)
| Location | Expression |
|---|---|
| `bal/core/will.py:1344` | `'.' + (('NOT ' if not value else '') + _(self.STATUS[status][0]))` |
| `bal/gui/qt/dialogs.py:1010` | `_('Wallet balance is too low: {} satoshi available, but the miner and…` |
| `bal/gui/qt/dialogs.py:2206` | `messages[reason] + '\n\n' + _('Skipped')` |
| `bal/gui/qt/dialogs.py:2209` | `_("Could not build the will, and the exact cause could not be determi…` |
| `bal/gui/qt/dialogs.py:2419` | `_('Expiration date: ') + str(BalTimestamp(self.threshold))` |
| `bal/gui/qt/dialogs.py:2425` | `_('Valid Txs:') + str(len(Will.only_valid_list(self.will)))` |
| `bal/gui/qt/dialogs.py:2427` | `_('Total Txs:') + str(len(self.will))` |
| `bal/gui/qt/lists.py:679` | `' ' + _('Build Your Will')` |
| `bal/gui/qt/plugin.py:301` | `'Bal ' + _('Bitcoin After Life')` |
| `bal/gui/qt/plugin.py:1034` | `'<b>' + _('Support:') + '</b>'` |
| `bal/gui/qt/plugin.py:1086` | `_('BAL - ') + _(title)` |
| `bal/gui/qt/widgets.py:1320` | `'<b>' + _(str(title)) + f':</b>\t{str(value)}'` |
| `bal/gui/qt/window.py:1022` | `_('Electrum was unable to deserialize the transaction:') + '\n' + str…` |
| `bal/gui/qt/window.py:1091` | `msg + _(f'signing: {tosign}')` |
#### D. `_()` evaluated at import time (class body) (18)
| Location | Scope | Call |
|---|---|---|
| `bal/gui/qt/dialogs.py:419` | class body | `_('Bitcoin After Life Will-Executors')` |
| `bal/gui/qt/dialogs.py:420` | class body | `_('Choose willexecutors download method')` |
| `bal/gui/qt/dialogs.py:528` | class body | `_('Configure and select your willexecutors')` |
| `bal/gui/qt/dialogs.py:548` | class body | `_('')` |
| `bal/gui/qt/lists.py:131` | class body | `_('Name')` |
| `bal/gui/qt/lists.py:132` | class body | `_('Address')` |
| `bal/gui/qt/lists.py:133` | class body | `_('Amount')` |
| `bal/gui/qt/lists.py:340` | class body | `_('Locktime')` |
| `bal/gui/qt/lists.py:341` | class body | `_('Txid')` |
| `bal/gui/qt/lists.py:342` | class body | `_('Will-Executor')` |
| `bal/gui/qt/lists.py:343` | class body | `_('Status')` |
| `bal/gui/qt/lists.py:344` | class body | `_('Server')` |
| `bal/gui/qt/lists.py:907` | class body | `_('')` |
| `bal/gui/qt/lists.py:908` | class body | `_('Url')` |
| `bal/gui/qt/lists.py:909` | class body | `_('S')` |
| `bal/gui/qt/lists.py:910` | class body | `_('Base fee')` |
| `bal/gui/qt/lists.py:911` | class body | `_('Info')` |
| `bal/gui/qt/lists.py:912` | class body | `_('Default Address')` |
#### E. `_("")` (2)
| Location | Call |
|---|---|
| `bal/gui/qt/dialogs.py:548` | `_('')` |
| `bal/gui/qt/lists.py:907` | `_('')` |
#### F. Settings help texts never passed to `_()` (12)
| Location | Widget | Text |
|---|---|---|
| `bal/gui/qt/plugin.py:715` | HelpButton | When checking, automatically sign and broadcast the will transactions… |
| `bal/gui/qt/plugin.py:778` | HelpButton | `How many reminder alarms the exported calendar (.ics) event contains.…` |
| `bal/gui/qt/plugin.py:789` | HelpButton | `Default message to be used in event summary\nVariables:\n $wallet_na…` |
| `bal/gui/qt/plugin.py:803` | HelpButton | `Default message to be used in event description\nVariables:\n $walle…` |
| `bal/gui/qt/plugin.py:818` | HelpButton | URL of the server that provides the will-executor list. Only availab… |
| `bal/gui/qt/plugin.py:829` | HelpButton | `Command used to open .ics calendar files.\nLeave empty to use the sys…` |
| `bal/gui/qt/plugin.py:844` | HelpButton | `After each check, save the valid will transactions into the wallet's …` |
| `bal/gui/qt/plugin.py:858` | HelpButton | `Label applied to the will transactions saved into the wallet's local …` |
| `bal/gui/qt/plugin.py:880` | HelpButton | Broadcast all transactions to willexecutors including those already p… |
| `bal/gui/qt/plugin.py:891` | HelpButton | `Run the 'Build your will' wizard every time the wallet is closed or E…` |
| `bal/gui/qt/plugin.py:909` | HelpButton | When a new transaction arrives for the wallet, automatically rebuild … |
| `bal/gui/qt/plugin.py:933` | HelpButton | `Payload size of a single QR code when exporting a will via QR.\n\nLar…` |
#### G. `add_widget()` calls (label translated at runtime, help text never) (5)
| Location | Label | Help text |
|---|---|---|
| `bal/gui/qt/plugin.py:697` | Hide Replaced | Hide replaced transactions from will detail and l… |
| `bal/gui/qt/plugin.py:705` | Hide Invalidated | Hide invalidated transactions from will detail an… |
| `bal/gui/qt/plugin.py:727` | Panel editable Date and Fee | When enabled, the delivery-time and check-alive d… |
| `bal/gui/qt/plugin.py:742` | `Max Will-Executor Fee (satoshi)` | `Maximum fee (in satoshi) allowed to be paid to a …` |
| `bal/gui/qt/plugin.py:757` | User Type | `Choose how much detail the plugin shows.\n\nBASIC…` |
#### H. Other user-visible text not (correctly) translatable — known cases, not exhaustive
| Location | Code | Note |
|---|---|---|
| `bal/gui/qt/dialogs.py:380` | `title = "Bitcoin After Life Heirs"` | Wizard title, shown untranslated at `dialogs.py:325` |
| `bal/gui/qt/dialogs.py:527` | `title = "Bitcoin After Life Will-Executors"` | Same |
| `bal/gui/qt/dialogs.py:547` | `title = "Bitcoin After Life Will Settings"` | Same |
| `bal/gui/qt/lists.py:1472` | `setText("New Will Executor")` | Not wrapped |
| `bal/gui/qt/plugin.py:673` | `QPushButton("Rebroadcast transactions")` | Not wrapped |
| `bal/gui/qt/widgets.py:1323` | `qlabel("Locktime", …)` | Translated inside `qlabel()` but not extractable |
| `bal/gui/qt/widgets.py:1324` | `qlabel("Creation Time", …)` | Same |
| `bal/gui/qt/widgets.py:1334` | `qlabel("Transaction fees:", …)` | Same; `qlabel()` adds another `:` |
| `bal/gui/qt/widgets.py:1336` | `qlabel("Status:", …)` | Same; also shows the stored status (see 2.8) |
| `bal/gui/qt/widgets.py:1339` | `QLabel("<b>Heirs:</b>")` | Not wrapped |
| `bal/gui/qt/widgets.py:1366` | `_("<b>Willexecutor:</b:")` | Malformed HTML inside the string |
| `bal/gui/qt/window.py:1613` | `msg = "Broadcasting Transactions to Will-Executors:\n"` | Not wrapped |
Not to translate: `widgets.py:122` (icon glyph), `plugin.py:1020` (link to bitcoin-after.life).
#### Numbers (`main` @ `4a33116`)
| Measure | Value |
|---|---|
| `_()` calls | 483 in 11 files |
| Distinct strings, whole package (xgettext) | 365 (≈ 2,230 words) |
| …of which Electrum `it_IT` already translates | 39 (≈ 11 %) |
| Strings used only by the CLI | 36 (≈ 315 words), out of scope |
| Babel extraction today, GUI + core | 323 (≈ 1,900 words) |
| Expected after Phase 2 | ≈ 360 (≈ 2,500 words) |
## Sources
- Electrum 4.8.0: `electrum/i18n.py`, `run_electrum` (L433–451), `electrum/gui/default_lang.py`, `electrum/plugin.py` (`maybe_load_plugin_init_method`, `read_file`), `contrib/locale/` — https://github.com/spesmilo/electrum/tree/4.8.0
- `electrum-locale` at the 4.8.0 submodule commit `3d7594c`: `update.py`, `llm_proofreader/` — https://github.com/spesmilo/electrum-locale
- Gitea "Update branch" button on pull requests — https://github.com/go-gitea/gitea/pull/9784
- Weblate: Gitea and gettext PO support — https://docs.weblate.org/en/latest/vcs.html

499
PLAN_QR_TRANSFER.md Normal file
View File

@@ -0,0 +1,499 @@
# PLAN — Will transfer via QR codes / audio modem (Qt now, QML planned)
> Goal: let the user move an inheritance ("will") between devices over two
> air-gap channels:
>
> 1. **QR codes** (primary): export the **valid** inheritance transactions as
> a sequence of QR codes, and import them back on another machine with the
> camera;
> 2. **Audio modem** (secondary, when Electrum's `audio_modem` plugin is
> enabled): send/receive the same payload through the PC speaker +
> microphone.
>
> Both channels converge on the same review-and-sign flow afterwards.
>
> Status: APPROVED by owner (2026-08-25). No code written yet — this document
> is the implementation contract. Work top-down through §9 Checklist.
>
> Chat language: Italian; this document is in English (global rule R1).
---
## 1. Scope
### In scope
- **Desktop PyQt6 GUI** (primary, implemented by this plan):
- New plugin setting: QR chunk size, offered as **4 standard presets**.
- *"Export via QR"* action: serializes the **valid** will transactions,
optionally compresses, splits the resulting string into fixed-size frames,
shows one QR at a time with prev/next navigation, live re-chunking and
progress (`i di N`).
- *"Import via QR"* action: camera capture dialog with a slot grid
(1..N); the user selects which shot he is about to capture, scans, the
frame lands in its slot; when 1..N are filled the payload is assembled,
parsed into `WillItem`s, validity-checked locally.
- **Post-capture flow (owner decision D6, amended)**: after capture
completes there is NO read-only preview. Instead a review-and-sign wizard
walks through every transaction one at a time showing **outputs
(address + amount), total outputs, total fees**, signs it (wallet
password asked once), and at the end **proposes exporting the signed
transactions** (to file and/or back via QR).
- **Audio-modem channel** (owner decision D7): when the Electrum
`audio_modem` plugin is enabled and available, the export dialog gains a
*"Send via Audio Modem…"* button and the import dialog a *"Receive via
Audio Modem…"* button, reusing the same transfer string (no QR framing).
- **QML**: document-only update to `QML_PLAN.md` adding dedicated view specs
(owner decision D1). No QML code in this feature.
### Out of scope
- Implementing the QML frontend (gated behind `QML_PLAN.md` Phases 0–2).
- Merging imported wills into the live wallet state (existing Merge flows
stay unchanged).
- Broadcast of the reviewed transactions (user exports them; broadcasting
remains an explicit action elsewhere).
- CLI/cmdline parity for QR transfer.
---
## 2. Owner decisions (locked)
| # | Decision |
|---|----------|
| D1 | QML part = update `QML_PLAN.md` document only; implementation later. |
| D2 | Frames carry a small **compact** ASCII header (`BAL1<total><index><flag>`, fixed 11 chars, base36 count fields) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. Legacy `BALQR1\|N\|i\|flags\|` export removed; legacy import kept. |
| D3 | Payload = **serialized transaction strings only** (`str(wi.tx)`), NOT the JSON will dump. Loses statuses/metadata on purpose; import rebuilds items like `merge_single_transaction` does. |
| D4 | Compression (zlib+base64 over the whole payload) exported as **best-of**: `encode_transfer_best` ships compressed when it is shorter, plain otherwise; flag `0` = plain, `Z` = compressed. No user-facing checkbox. |
| D5 | 4 standard size presets: **~150 / ~400 / ~900 / ~1800 bytes** of payload per QR (low-res cams → high-res cams). Error-correction level fixed **M**. Stored as plugin config default; selectable again inside the export dialog. |
| D6 | After capture completes: **review + sign each tx one at a time** (show outputs, total outputs, total fees), then **propose export of the signed txs** (file and/or QR). Supersedes the earlier "WillDetailDialog preview" answer. |
| D7 | Add an **audio-modem transfer path** gated on Electrum's `audio_modem` plugin being enabled and available (`amodem` importable). Same payload semantics as QR (transfer string of serialized txs), but NO BAL frame chunking — `amodem` handles transport framing internally. Buttons simply hidden when the plugin is absent/unavailable (info message pointing at `pip install amodem` when enabled-but-broken); graceful degradation, never a hard dependency. |
---
## 3. Verified facts (research done on the local checkouts)
All verified by reading source; references are `file:line`.
| # | Fact | Where |
|---|------|-------|
| F1 | Export today: `BalWindow.export_will()` writes `{wid: WillItem.to_dict()}` JSON; subsets All/Valid/Valid-NC built in `WillList` | `bal/gui/qt/window.py:1605-1621`, `lists.py:666-669, 743-767` |
| F2 | Batch signer: `BalWindow.sign_transactions(password, will, txids)` loops valid txs, resolves input values from change prevouts (`txin._trusted_value_sats` …), calls `wallet.sign_transaction`, updates `COMPLETE` + signature counts | `window.py:1017-1086` |
| F3 | External-will signing already supported (`will=` param, nothing saved to live wallet/history) | `window.py:1443-1484` |
| F4 | Single-tx import precedent: `merge_single_transaction` wraps `WillItem({"tx": str(tx)}, _id=tx.txid(), wallet=...)` | `window.py:1715-1724` |
| F5 | Local validity recomputation recipe (no network): `add_willtree` → `Util.get_available_utxos` → `check_invalidated` → `search_rai` → `check_signatures` | `window.py:1678-1701` |
| F6 | Plugin config accessor pattern `BalConfig`; keys declared in ctor | `bal/core/plugin_base.py:130-154, 211-264` |
| F7 | Plugin settings dialog: grid rows 0..12, `add_widget(grid,label,widget,row,help)` + `_make_reset_btn(cfgvar,widget,kind)`; ADVANCED-only rows wrapped with `_hide_if_basic(...)` | `bal/gui/qt/plugin.py:442-850` (rows at 677-850) |
| F8 | `BalDialog(parent, bal_plugin, title=None, icon=...)` base class anchors to top-level window | `bal/gui/qt/dialogs.py:92-129` |
| F9 | Fee display precedent: `fee = tx.input_value() - tx.output_value()`, fee rate = `fee / tx.estimated_size()` | `bal/gui/qt/widgets.py:1319-1328` |
| F10 | Input-value resolution helper exists: `Will.add_info_from_will(will, wid, wallet)` sets trusted input values from sibling will change outputs | `bal/core/will.py:118-136` |
| F11 | `str(tx)` = `tx.serialize()`: raw hex for complete txs; `PartialTransaction.serialize()` → base64 PSBT. Both accepted by `tx_from_any` (= `Will.get_tx_from_any`). This is exactly how BAL persists/reloads txs today | `electrum/transaction.py:907, 2539`; `will.py:106-113`; `window.py:1041-1044` |
| F12 | `QRCodeWidget` exists but **hardcodes `ERROR_CORRECT_L`** → cannot satisfy D5/M; must render our own `qrcode` instance | `electrum/gui/qt/qrcodewidget.py:35-37` |
| F13 | Camera scanning one-shot API with OS-permission handling: `scan_qrcode_from_camera(*, parent, config, callback(success: bool, error: str, data: Optional[str]))`; on Linux uses zbar CLI backend | `electrum/gui/qt/qrreader/__init__.py:47-64` |
| F14 | QR painting without PIL: `draw_qr(qr, paint_device, ...)` from `electrum.gui.common_qt.util` (what `QRCodeWidget.paintEvent` uses) | `electrum/gui/qt/qrcodewidget.py:63-72` |
| F15 | QR capacity sanity (byte mode, EC **M**): v40-M ≈ 2331 B ≥ 1800 ✓; v10-M ≈ 213 B ≥ 150 ✓; the `qrcode` lib auto-picks the version | `qrcode` lib |
| F16 | `build_zip.py` walks the tree with `os.walk` → new `.py` files ship automatically | `build_zip.py:39-47` |
| F17 | QML fork already has `QRImage.qml`, `QRScan.qml`, `ScanDialog.qml`; ScanDialog carries upstream comment "currently not used on android … qt6 camera support stops crashing" | `electrum/gui/qml/components/ScanDialog.qml:8-9` |
| F18 | `QML_PLAN.md` currently defers chunked multi-QR streams (Phase 3 note + risk R6) — this feature supersedes that deferral | `QML_PLAN.md:228-231, 304` |
| F19 | House test conventions: `def test_*` + `if __name__ == "__main__"` + `sys.path.insert(0, ..pardir)`; run standalone or via pytest | `tests/test_core_heirs.py:1-24` |
| F20 | Fork ships an `audio_modem` plugin. `_send(parent, blob)` zlib-compresses an **ASCII** blob, plays it via speaker through `amodem` inside a `WaitingDialog`; bit-rate selectable in the plugin's own settings (default = `amodem.config.slowest()`) | `electrum/plugins/audio_modem/qt.py:96-110` |
| F21 | `_recv(parent)` records from mic and delivers the decompressed ASCII text by calling `parent.setText(blob)` — the only integration contract is "an object with `setText(str)`"; there is no callback API | `audio_modem/qt.py:112-127` |
| F22 | Plugin lookup for enabled plugins: `window.plugins.get(name)` → instance or `None` (`Plugins.get`, electrum/plugin.py:575-576); availability check is the plugin's own `is_available()` (imports `amodem`). **`amodem` is NOT installed in the runtime env today** → optional dependency (pip `amodem` + libportaudio); feature must degrade gracefully | `electrum/plugin.py:575`, runtime-env check |
| F23 | `amodem.main.send/recv` stream the whole blob with their own framing/training → BAL must NOT apply QR frame chunking on this channel; and since `_send` compresses internally, BAL sends the **plain** transfer string to avoid double compression | consequence of F20/F21 |
---
## 4. Wire format specification
### 4.1 Transfer string
```
transfer_string = "\n".join( tx_str(tx) for tx in valid_txs_sorted_by_txid )
```
- `tx_str(tx)` = `str(tx)` (F11): hex for complete txs, base64-PSBT for
partially-signed ones. Neither alphabet contains `\n` or `|`, so both are
safe delimiters.
- Ordering: ascending `tx.txid()` → deterministic output for identical input.
- If compression enabled (D4):
`transfer_string = base64_ascii( zlib_compress( transfer_string ) )`.
### 4.2 Frame layout (one frame = content of ONE QR code)
Wire format v2 (compact, current export):
```
BAL1<TTT><iii><F><payload>
```
- Magic+version literal `BAL1` (reject anything else with a clear message).
- `<TTT>` = `<iii>` — **base36** zero-padded 3-char strings (`000`…`ZZZ`),
representing total N and index i, `1 ≤ i ≤ N ≤ 46655`. Fixed width means a
3-digit count field costs the same for a 1-frame or a 46655-frame transfer.
- `<F>`: single flag char — `0` ⇒ plain, `Z` ⇒ zlib+base64 compressed.
- `<payload>`: the i-th slice of `transfer_string`, exactly
`chunk_size` bytes each (last slice may be shorter). No separators: both
base36 count fields are fixed-width, so the header is unambiguously 11
chars and the payload starts at offset 11.
- Header overhead is a constant **11 bytes** → effective payload =
`chunk_size − 11`; the chunker slices the transfer string so that
**header+payload ≤ preset size**.
Legacy frames `BALQR1|<total>|<index>|<flags>|<payload>` (variable-width
decimal header, pipe-separated) are still **imported** by `parse_frame`
(`_parse_v1`), so old exports keep working; the exporter emits v2 only.
That is the one deliberate compatibility break: **v2 frames are not readable
by builds older than this change.**
### 4.3 Size presets (D5)
| Preset label | Payload budget (bytes/frame) | Typical QR version @EC-M |
|--------------|------------------------------|--------------------------|
| Small (low-res cameras) | 150 | ~v10 |
| Medium | 400 | ~v15 |
| Large | 900 | ~v22 |
| XL (high-res cameras) | 1800 | ~v40 |
EC level fixed **M** for scan reliability (D5). Presets live in
`bal/core/qrtransfer.py::CHUNK_PRESETS` so core tests can cover them.
### 4.4 Audio-modem channel (D7, F20-F23)
- Payload = the **plain** `transfer_string` of §4.1 — no BAL frames
(`split_frames`/`parse_frame` are QR-only), no BAL compression (the plugin
compresses internally; double compression wastes airtime).
- The existing core functions `encode_transfer(tx_strings,
compress=False)` + `decode_transfer(text, compressed=False)` are reused
unchanged; only the transport differs.
- Bit-rate is owned by the audio_modem plugin's settings dialog — BAL adds
no setting of its own.
---
## 5. New core module — `bal/core/qrtransfer.py`
GUI-free (never imports Qt — house rule). Public API:
```python
MAGIC = "BALQR"
VERSION = 1
FLAG_COMPRESSED = "Z"
CHUNK_PRESETS = [(label_en, budget_bytes), ...] # §4.3 table
def encode_transfer(tx_strings: list[str], compress: bool = False) -> str
"""Join -> optional zlib+base64 -> return transfer_string."""
def split_frames(transfer_string: str, chunk_size: int) -> list[str]
"""Slice into frames 'BAL1<TTT><iii><F>payload' (v2) — header+payload <=
chunk_size. Raises QrTransferError over the 46655-frame base36 cap, or
ValueError if chunk_size < MIN_CHUNK_SIZE."""
def encode_transfer_best(tx_strings: list[str]) -> tuple[str, bool]
"""-> (transfer_string, compressed); ships the shorter of plain vs
zlib+base64 so the export emits the densest frames."""
def parse_frame(frame: str) -> tuple[int, int, bool, str]
"""-> (total, index, compressed, payload); accepts v2 'BAL1…' and legacy
'BALQR1|…' (wrapped as _parse_v1/_parse_v2); ValueError on bad magic/
version/arity/non-numeric fields."""
def assemble(frames: dict[int, str]) -> str
"""Validate indices form exactly range(1..max_total) (taken from any
frame header), concatenate payloads in order, decode flags ->
transfer_string. Raises MissingFramesError(indexes) / InconsistentTotalError."""
def decode_transfer(transfer_string: str, compressed: bool) -> list[str]
"""Inverse of encode_transfer -> list of tx strings."""
```
Plus exceptions `QrTransferError(ValueError)`, `MissingFramesError`,
`InconsistentTotalError`. All docstrings/comments English; ruff-clean
(line-length 88, E501 ignored).
---
## 6. Settings (Qt)
1. `bal/core/plugin_base.py`: after `REBUILD_ON_CLOSE` (~line 264) add
```python
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
```
2. `bal/gui/qt/plugin.py::settings_dialog` (rows end at 12, ~line 844):
append row **13** — visible in BASIC and ADVANCED (do NOT wrap with
`_hide_if_basic`):
- Label: `"QR Code Size"`
- `QComboBox` fed from `CHUNK_PRESETS`; item text e.g.
`"Small — ~150 bytes/QR (low-res cameras)"`; `currentIndexChanged`
→ `self.QR_CHUNK_SIZE.set(budget_bytes)`; initial index from
`QR_CHUNK_SIZE.get()` (fallback to nearest preset if the stored value
was customized).
- `HelpButton` text: explains trade-off (small QR = more shots but easier
to scan with poor cameras; large QR = fewer shots, needs good camera)
and that the size can also be changed inside the export dialog.
- Reset button via existing `_make_reset_btn(self.QR_CHUNK_SIZE, combo, ...)`
pattern (plugin.py:684).
---
## 7. Qt export flow
### 7.1 Entry point
`bal/gui/qt/lists.py::WillList.create_toolbar` (menu block lines 666-670):
```python
export_menu.addAction(_("Via QR…"), self.export_will_valid_qr)
```
New `WillList.export_will_valid_qr()` mirrors `export_will_valid`
(lists.py:743-754): builds `{wid: wi}` subset of `VALID` items, empty →
`show_message(_("No valid will item to export"))`, else
`self.bal_window.export_will_via_qr(will=subset)`.
### 7.2 `BalWindow.export_will_via_qr(will=None)` (new, `window.py` near
`export_will`)
- Collect `tx_strings = [str(wi.tx) for wid, wi in sorted-by-txid ...]`
(F11).
- Mark exported items `EXPORTED` (parity with `export_json_file`,
window.py:1607-1609) — only when `will` came from the live list.
- Open `WillQrExportDialog(self, tx_strings)`.
Shared helper used by both dialogs:
```python
def get_audio_modem_plugin(self): # on BalWindow
"""Return the loaded audio_modem plugin if enabled AND available
(amodem importable), else None. Never raises."""
p = self.window.plugins.get("audio_modem") # F22
return p if p is not None and p.is_available() else None
```
### 7.3 `WillQrExportDialog(BalDialog)` (new class in `dialogs.py`)
Layout:
```
[Size ▾ Small/Medium/Large/XL] ← compressed best-of automatically (no checkbox)
[ QR image ] ← BalQrImage (see below)
«i di N» [◀ Prev] [Next ▶]
[Save current QR as PNG…] [Send via Audio Modem…] [Close]
```
Behaviour:
- On any control change: rebuild `split_frames(encode_transfer_best(...))`,
reset index to frame 1, refresh counter (owner requirement: "cambiare la
risoluzione").
- `BalQrImage(QWidget)` ≈ trimmed copy of `QRCodeWidget`
(`electrum/gui/qt/qrcodewidget.py:21-72`) but constructing
`qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2)` and painting
via `electrum.gui.common_qt.util.draw_qr` (F12/F14). ~30 lines.
- Prev/Next wrap or disable at ends (disable chosen: clearer).
- PNG export optional convenience via existing
`getSaveFileName` + `QWidget.grab()` (same trick as
`qrcodewidget.py:110`).
- **Send via Audio Modem…** (D7): shown only when
`bal_window.get_audio_modem_plugin()` returns a usable instance (below);
otherwise hidden. Handler: re-encode the payload **plain**
(`encode_transfer(tx_strings, compress=False)`) and call the plugin's
`_send(parent=self, blob=transfer_string)` — its own WaitingDialog owns
progress/cancellation (F20). Tooltip when hidden is unnecessary; instead,
if the plugin is enabled but `is_available()` is False, show an info
message pointing to `pip install amodem` + portaudio (F22).
---
## 8. Qt import flow + review/sign wizard
### 8.1 Entry point
`lists.py` toolbar menu, next to Import/Merge (lines 670-671):
```python
menu.addAction(_("Import via QR…"), lambda: self.bal_window.import_will_via_qr())
```
`BalWindow.import_will_via_qr()` opens `WillQrImportDialog(self)`.
### 8.2 `WillQrImportDialog(BalDialog)`
State: `self.frames: dict[int, str]`, `self.total: int | None`,
`self.target_index: int | None`.
Layout:
```
«Captured k of N» [Scan ▶] [Reset]
[slot grid: push-buttons 1..N; states: empty / filled ✓ / selected-target]
hint line («Select a slot, then scan» / «Scan the first QR»)
[Receive via Audio Modem…] [Review & Sign ▶] [Close]
```
Behaviour:
- **Scan** → `scan_qrcode_from_camera(parent=self,
config=self.bal_window.window.config, callback=self._on_scan)`
(F13). One-shot per press; dialog stays open between shots (simplest,
matches Electrum UX; no continuous mode).
- `_on_scan(success, error, data)`:
- failure → `show_error(error)` (covers missing zbar/camera too);
- `parse_frame` errors → `show_warning(_("Not a BAL will QR"))`;
- first valid frame adopts `total` and materializes the slot grid;
- frame whose `total` ≠ adopted total → warn + offer Reset (user may have
restarted the export with another size);
- valid → `frames[index] = payload`; auto-advance `target_index` to the
lowest missing index; refresh grid + counter.
- Clicking an empty slot sets `target_index` (owner requirement: manual
shot selection); a filled slot click asks to overwrite.
- **Receive via Audio Modem…** (D7): shown only when
`bal_window.get_audio_modem_plugin()` returns a usable instance. Handler:
build a tiny adapter object exposing `setText(str)` that stores the text
and invokes the shared post-receive continuation, then call
`plugin._recv(parent=self, ...)`-style flow (F21 contract). On success the
received string is treated as the **whole payload**: skip frames/slots
entirely → `decode_transfer(text, compressed=False)` → continue at §8.2's
item-building step (WillItem construction + validity pass + wizard).
Errors from the modem surface through the plugin's own dialog; empty
result (user cancelled) is silently ignored.
- **Review & Sign** enabled only when `set(frames) == set(range(1, N+1))`:
runs `assemble` + `decode_transfer` → `list[str]`; any `QrTransferError`
surfaces as `show_error` and keeps the dialog open.
- Build items exactly like `merge_single_transaction` (F4):
`WillItem({"tx": s}, wallet=self.wallet)` per string; failures per-string
are collected and reported at the end (bad string ≠ fatal for the rest).
- Local validity pass (F5 recipe) on the resulting dict; items failing
`VALID` are dropped and listed in a warning. Set
`wi.set_status("IMPORTED", True)` on survivors (mirrors
`import_will_into_details`, window.py:1753-1754).
- Then `close()` and start the wizard (§8.3) with the valid subset. Empty
result → stop with a message.
### 8.3 `WillTxReviewSignDialog(BalDialog)` — post-capture wizard (D6)
Constructed with `(bal_window, willitems: dict[str, WillItem])` — the
imported subset lives **outside** the live wallet state (external mode,
F3).
Flow:
1. **Password once**: `password = bal_window.get_wallet_password()`
(window.py:1088-1100). Returns `False` on cancel → abort wizard; `None`
means unencrypted wallet → proceed without password.
2. **Per-transaction page** (one `QStackedWidget` step per tx, ordered by
txid like export):
```
Tx 2 of 5 — a1b2…c3d1 (short txid)
Locktime: 2033-04-05 Status: unsigned (0/1 sigs)
┌ outputs ─────────────────────────────────┐
│ bc1q…heir1 0,042 BTC │
│ bc1q…willexec fee 0,00012 BTC │
│ bc1q…change 0,00988 BTC │
└───────────────────────────────────────────┘
Total outputs: 0,052 BTC Fees: 420 sat (1.2 sat/vB)
[Sign & Next ▶] [Skip] [Cancel all]
```
- Outputs from `tx.outputs()` (address via `TxOutput.get_ui_address_str()`
style helpers already imported in the qt layer; value via
`bal_window.window.format_amount`).
- Totals: `output_value()` sum; fees via `input_value() - output_value()`
after resolving inputs with `Will.add_info_from_will(will, wid, wallet)`
(F10); `-1`/unknown handled like widgets.py:1319-1324 (F9).
3. **Sign & Next** → sign this single tx through a **refactored helper**
extracted from the loop body of `sign_transactions`
(window.py:1037-1083 → `_sign_single_tx(tx, willitems, password)` kept
byte-equivalent; batch method calls the helper per iteration so existing
behaviour/tests are unaffected). Update `COMPLETE`/sig-counts exactly as
today; then advance.
4. **Skip** leaves the tx untouched and advances. **Cancel all** stops; the
already-signed txs remain in the wizard's local dict (still exportable —
confirmation dialog warns about skipped ones).
5. **Summary page**: `signed X of Y`, skipped/failed lists, then:
```
[Save signed file…] [Show QR…] [Close]
```
- *Save file* = existing JSON path: `export_meta_gui(window,
"will.json", writer)` writing `{wid: wi.to_dict()}` of the signed
subset (same serializer as `export_json_file`, window.py:1605).
- *Show QR* = `WillQrExportDialog` over `[str(wi.tx)]` of the signed
subset (the online machine can scan them straight into Merge).
- Nothing touches `self.willitems`/history (external-mode rule, F3).
---
## 9. Checklist (execution order — tick here when resuming work)
- [x] **P0** `bal/core/qrtransfer.py` + unit tests `tests/test_core_qr_transfer.py`
(cases: round-trip plain/compressed; boundaries: len%size==0, size>len,
min-size guard; bad magic/version; missing middle frame; duplicate
overwrite; inconsistent totals; multi-PSBT mixes; presets sanity vs
qrcode capacities F15). Run:
`QT_QPA_PLATFORM=offscreen python3 tests/test_core_qr_transfer.py`
- [x] **P1** Settings: `QR_CHUNK_SIZE` config var + settings-dialog row 16
(ø16) + reset kind (§6). Verify in `QT_QPA_PLATFORM=offscreen` GUI run.
- [x] **P2** Export: `BalWindow.export_will_via_qr`, `get_audio_modem_plugin`
helper, `WillList` menu action, `WillQrExportDialog` + `BalQrImage`,
audio-modem send button (§7).
- [x] **P3** Import: `import_will_via_qr`, `WillQrImportDialog` (§8.2),
incl. camera error paths, audio-modem receive button (local mirror of
`_recv`, `setText` sink replaced by a callback), plain-payload fast
path into the wizard.
- [x] **P4** Wizard: `_prepare_and_sign_tx` refactor + `WillTxReviewSignDialog`
(§8.3). Regression-gate: full batch sign still green
(`tests/test_core_*.py` offline batch; `tests/test_gui_*.py` batch
including new `tests/test_gui_qr_transfer.py`).
- [x] **P5** Docs & QML plan sync: update `QML_PLAN.md` — Phase 2 models +=
`BalQrTransferModel` (thin QObject over `bal.core.qrtransfer`),
Phase 3 += dedicated views `BalQrExportPage.qml` /
`BalQrImportPage.qml` (slot grid + `QRScan` reuse), delete the
"chunked streams deferred" note, rewrite R6 mitigation, add Android
caveat quoting F17 with file/paste fallback; README/HANDOFF sections;
CHANGELOG numbered entry 56 at END (house rule).
- [x] **P6** Release hygiene: `python3 build_zip.py` +
`QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py
electrum.plugins.bal` + external-zip test; ruff (repo venv
`venv/bin/ruff`) no NEW violations; pyright false-positive policy per
AGENTS.md. Version bump only via `make-release.sh` (owner-driven).
Docs: document audio-modem as OPTIONAL channel — requires the
Electrum `audio_modem` plugin enabled plus `pip install amodem`
and libportaudio (not installed in the dev runtime env today, F22);
manual test matrix gains an audiomodem round-trip row (two machines,
default slowest bitrate) marked optional/skippable when hardware
unavailable.
---
## 10. Risks & mitigations
| Risk | Mitigation |
|------|------------|
| High frame counts annoy users (e.g. 40+ QR at 150 B) | Presets span 150→1800; compress option; counter always visible |
| Big QR versions fail on cheap cameras | EC=M fixed; Small preset targets low-res cams (D5 rationale) |
| User rescans old export with different total | `InconsistentTotalError` → clear warning + Reset (§8.2) |
| `_sign_single_tx` refactor regresses batch signing | Byte-equivalent extraction; batch callers unchanged; offline core tests gate P4 |
| Imported txs reference UTXOs the importing wallet doesn't know | Validity pass drops them with an explicit report instead of silently merging garbage |
| zbar/camera unavailable (esp. Windows/macOS packaging) | `scan_qrcode_from_camera` error path → suggest file export/import fallback |
| `amodem`/portaudio not installed (current dev env state, F22) or audio_modem plugin disabled | Buttons simply hidden; QR/file remain the primary channels; P6 documents the optional dependency |
| Audio transfer fails mid-way (noise, wrong volume) | Plugin's WaitingDialog surfaces the error; user retries — nothing to clean up on BAL side (single atomic blob, no slot state touched) |
| Very slow airtime at default slowest bitrate | Bitrate is selectable in the audio_modem plugin's own settings (F20); BAL adds no knob; tooltip in export dialog hints at large payloads |
| Qt6 camera instability on Android (future QML work) | Recorded as caveat in QML_PLAN update (P5), file/paste stays the primary mobile fallback |
---
## 11. Findings log (append-only)
- 2026-08-25: plan drafted after code exploration; owner answered D1-D6
(D6 amended live from "preview dialog" to "review+sign wizard").
- Verified F12 (QRCodeWidget hardcodes EC-L) and F11 (str(tx) round-trip
guarantees) — both shaped §§4/7.
- 2026-08-25: owner requested an audio-modem transfer path → researched
`electrum/plugins/audio_modem/qt.py`, added D7 + F20-F23, §4.4, buttons
in §§7.3/8.2, checklist/risk updates. Key constraint found: `_recv`'s
only contract is `parent.setText(blob)` (F21) → thin adapter object; and
BAL must not chunk/compress on this channel (F23). `amodem` is NOT in
the runtime env yet — feature is strictly optional.

View File

@@ -5,10 +5,11 @@ Free and decentralized **Bitcoin inheritance** support for the
that transfer your funds to your heirs if you stop refreshing them
(dead-man's switch), optionally relayed by will-executor servers.
This repository contains a **behavior-preserving refactor** of the original
plugin. The logic was kept byte-identical wherever possible; only the file
layout was reorganized to cleanly separate **business logic** from the
**PyQt GUI**.
This repository contains a **refactored and extended** version of the original
plugin. The logic was reorganized to cleanly separate **business logic** from the
**PyQt GUI**, and new features have been added including a headless CLI,
auto-rebuild on new transactions, OP_RETURN heirs, and configurable calendar
reminders.
## Repository layout
@@ -16,12 +17,22 @@ layout was reorganized to cleanly separate **business logic** from the
bal/ the installable Electrum plugin package
├── manifest.json plugin metadata (Electrum reads this)
├── qt.py Qt entry-point shim (re-exports Plugin)
├── cmdline.py CLI entry-point shim (re-exports Plugin)
├── core/ GUI-free logic (importable without Qt)
│ ├── util.py
│ ├── plugin_base.py
│ ├── heirs.py
│ ├── will.py
│ └── willexecutors.py
│ ├── willexecutors.py
│ ├── checkalive.py
│ ├── reminders.py
│ ├── qrtransfer.py transfer-string codec / QR size presets
│ ├── animated_qr.py BBQR codec (stdlib-only)
│ └── input_rules.py
├── cli/ headless command-line layer (no Qt)
│ ├── commands.py bal_* daemon commands (@plugin_command)
│ ├── controller.py headless BalController (replicates BalWindow)
│ └── plugin.py CLI Plugin entry point
├── gui/qt/ PyQt6 presentation layer
│ ├── theme.py status → color mapping
│ ├── common.py shared imports / helpers
@@ -30,6 +41,7 @@ bal/ the installable Electrum plugin package
│ ├── dialogs.py dialog windows
│ ├── lists.py tree/list views
│ ├── window.py per-wallet GUI controller
│ ├── window_utils.py GUI utility helpers
│ └── plugin.py Plugin (Electrum @hooks → GUI)
├── icons/ wallet_util/ LICENSE README.md
build_zip.py builds a clean, zipimport-friendly distribution zip
@@ -77,6 +89,62 @@ Copy the `bal/` directory into your Electrum installation's
`electrum/plugins/` directory, so that `electrum/plugins/bal/manifest.json`
exists, then enable it from **Tools → Plugins**.
## Transfer a will with QR codes (or audio)
From the will list (**Export → QR Codes**) a will can be exported as a
sequence of QR codes and imported on another device (**Import via QR**). The
export offers All / Valid / Valid-NC filters plus a QR size preset
(150–1800 bytes/frame) and ships the **BBQR** format; the import
flow reviews and sign each transaction
one at a time, then proposes exporting the signed transactions. When
Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` +
PortAudio) Send/Receive audio buttons complement the QR channel. See
[`PLAN_QR_TRANSFER.md`](PLAN_QR_TRANSFER.md) for the transfer wire-format spec.
### Animated-QR format (BBQR)
The export page emits **BBQR** (`B$…`, Coinkite, used by BitKit and Coldcard)
animated-QR sequences. The importer auto-detects each code it sees, so a BAL
export can be imported by any tool that understands BBQR. BBQR frames may
arrive in any order, and duplicates are ignored. The 32 MB message limit
(zlib-bomb guard) bounds untrusted scanner input.
## Command-line / headless usage
BAL can be used without the Qt GUI via Electrum's daemon mode. The CLI layer
exposes `bal_*` commands that replicate the full inheritance cycle.
### Prerequisites
- An **Electrum daemon** running (`electrum daemon -d`)
- A wallet loaded (`electrum load_wallet`)
### Available commands
| Category | Commands |
|----------|----------|
| Settings | `bal_settings_list`, `bal_settings_get`, `bal_settings_set`, `bal_settings_reset` |
| Heirs | `bal_heirs_list`, `bal_heirs_show`, `bal_heirs_add`, `bal_heirs_update`, `bal_heirs_delete`, `bal_heirs_import`, `bal_heirs_export` |
| Will-Executors | `bal_willexecutors_list`, `bal_willexecutors_show`, `bal_willexecutors_add`, `bal_willexecutors_update`, `bal_willexecutors_select`, `bal_willexecutors_delete`, `bal_willexecutors_ping`, `bal_willexecutors_download`, `bal_willexecutors_import`, `bal_willexecutors_export` |
| Will | `bal_will_status`, `bal_will_check`, `bal_will_prepare`, `bal_will_autorebuild`, `bal_will_sign`, `bal_will_broadcast`, `bal_will_export`, `bal_will_import_merge`, `bal_will_invalidate`, `bal_will_check_executor` |
### Example workflow
```bash
electrum daemon -d
electrum load_wallet
electrum bal_heirs_list
electrum bal_will_prepare
electrum bal_will_sign --password '...'
electrum bal_will_broadcast
electrum stop
```
All commands require a running daemon (Electrum's `plugin_command` enforces
this). Wallet-bound commands (`bal_heirs_*`, `bal_will_*`, etc.) require the
wallet to be loaded first. Signing commands require `--password` for encrypted
wallets.
## Inheritance safety: anticipate / postpone
A will transaction is signed with a **fixed, immutable locktime** and then

5
babel.cfg Normal file
View File

@@ -0,0 +1,5 @@
# Babel extraction mapping for the BAL gettext catalog (see bal/i18n.py).
# The CLI and the wallet_util helpers stay English, like Electrum's CLI.
[ignore: bal/cli/**]
[ignore: bal/wallet_util/**]
[python: bal/**.py]

View File

@@ -24,11 +24,18 @@ distinct sub-packages:
lists.py Tree/list views (heirs, preview, will-executors)
window.py BalWindow controller (per-wallet GUI state)
plugin.py Plugin class wiring Electrum @hooks to the GUI
cli/ Headless command-line layer (no Qt)
commands.py The @plugin_command transport layer (registers
the ``bal_*`` commands)
controller.py Headless replica of the Qt flows (later phases)
plugin.py Plugin(BalPlugin) entry point for the daemon
qt.py Thin loader shim re-exporting `Plugin` for Electrum
cmdline.py Thin loader shim re-exporting `Plugin` for the daemon
Electrum discovers the plugin through ``manifest.json`` and loads the GUI
entry point from ``qt.py`` (the shim), which imports the real ``Plugin``
from ``gui.qt.plugin``.
from ``gui.qt.plugin``; the command-line/daemon entry point is ``cmdline.py``
(the shim), which imports ``Plugin`` from ``cli.plugin``.
The plugin supports Electrum 4.7.2 and 4.8.0 with PyQt6. Electrum 4.8.0 removed
``json_db.register_dict`` and replaced it with the path-based
@@ -40,3 +47,85 @@ available and adapts, so both releases keep working.
# (the single source of truth) and is read at runtime via ``get_version()`` in
# ``bal/core/plugin_base.py`` (exposed as the ``BalPlugin.version`` property).
# Keeping a hardcoded ``__version__`` here would just be a stale duplicate.
# --------------------------------------------------------------------------- #
# CLI command registration
# --------------------------------------------------------------------------- #
# Electrum's CLI pre-parse (run_electrum calls ``Plugins(config, cmd_only=True)``)
# only imports the plugin package ``__init__`` to discover its commands.
# Importing ``bal.cli.commands`` here registers every ``bal_*`` command with
# ``electrum.commands`` (``known_commands`` + the ``Commands`` class), so the
# commands become available on the command line and over JSON-RPC without any Qt.
#
# The import must be zip-safe: when the plugin is loaded as an external zip,
# Electrum registers the package under the synthetic name
# ``electrum_external_plugins.bal``, but the module's ``__package__`` is only
# ``bal`` (the zip-internal directory name), which is not present in
# ``sys.modules`` and cannot be used for sub-module imports. We therefore
# resolve the real package name and import through ``importlib`` (the same
# trick as ``qt.py``).
import importlib
import sys as _sys
def _resolve_package_name() -> str:
"""Return the name this package is registered under in ``sys.modules``.
Internal plugins are imported as ``electrum.plugins.bal`` (a normal import,
so ``__package__`` is already correct). External zip plugins are imported
under the synthetic name ``electrum_external_plugins.bal`` with
``__package__`` set to just the zip-internal directory name (``bal``); only
the synthetic name is present in ``sys.modules``.
"""
pkg = __package__ or "bal"
if pkg in _sys.modules:
return pkg
synthetic = "electrum_external_plugins." + __name__
if synthetic in _sys.modules:
return synthetic
return pkg
def _ensure_parent_packages(pkg_name: str) -> None:
"""Backfill missing ancestor packages in ``sys.modules``.
When loaded from a zip as an external plugin, Electrum only executes the
package ``__init__``; the synthetic root package (``electrum_external_plugins``)
may be missing, which would break sub-module imports. We stub it out as a
namespace package so ``importlib`` can still resolve its children (same
helper as ``qt.py``).
"""
parts = pkg_name.split(".")
for i in range(1, len(parts)):
ancestor = ".".join(parts[:i])
if ancestor in _sys.modules:
continue
try:
importlib.import_module(ancestor)
except Exception:
import types
module = types.ModuleType(ancestor)
module.__path__ = [] # mark as a (namespace) package
_sys.modules[ancestor] = module
def _register_cli_commands() -> None:
"""Import ``bal.cli.commands`` so Electrum registers the ``bal_*`` commands.
Guarded so a dual install (internal package AND external zip) cannot
register the same command names twice, which would make
``electrum.commands.plugin_command`` raise
"Command name bal_... already exists".
"""
from electrum import commands as _electrum_commands
if getattr(_electrum_commands, "_bal_cli_commands_registered", False):
return
pkg = _resolve_package_name()
_ensure_parent_packages(pkg)
importlib.import_module(pkg + ".cli.commands")
_electrum_commands._bal_cli_commands_registered = True
_register_cli_commands()

19
bal/cli/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
"""
bal.cli
=======
Headless command-line layer of the Bitcoin After Life (BAL) Electrum plugin.
This sub-package implements the ``"cmdline"`` front-end: it exposes the
plugin's functionality through Electrum ``bal_*`` commands while reusing only
the GUI-free logic from ``bal.core``. Like ``bal.core``, it MUST never import
PyQt or ``electrum.gui``.
* ``bal.cli.commands`` -> the ``@plugin_command`` transport layer
* ``bal.cli.controller`` -> headless replica of the Qt flows (later phases)
* ``bal.cli.plugin`` -> ``Plugin(BalPlugin)`` entry point for the daemon
Electrum discovers the plugin through ``manifest.json`` (``available_for``
includes ``"cmdline"``) and loads the entry point from ``cmdline.py``, a thin
zip-safe shim following the same pattern as ``qt.py``.
"""

424
bal/cli/commands.py Normal file
View File

@@ -0,0 +1,424 @@
"""
bal.cli.commands
================
CLI commands (``bal_*``) for the Bitcoin After Life plugin.
This module is the *transport layer* of the command-line front-end: every
function is a coroutine decorated with ``@plugin_command`` so Electrum exposes
it as ``bal_<name>`` both on the command line and over JSON-RPC. The functions
validate their arguments and delegate the real work to
:mod:`bal.cli.controller` (a headless replica of the Qt flows); this module
never imports Qt.
It must stay lightweight: Electrum imports it during the CLI pre-parse
(``run_electrum`` calls ``Plugins(config, cmd_only=True)``) and on every
GUI/daemon startup, before any wallet or network object exists. The heavy
imports (``bal.core``, the controller) happen lazily inside each command.
Flags (see ``electrum.commands.plugin_command``):
* ``n`` -> requires a running daemon/network (always set for plugins);
* ``w`` -> resolves and injects the wallet from the daemon;
* ``p`` -> requires the wallet password (for signing).
"""
from electrum.commands import plugin_command
from electrum.util import UserFacingException
from .controller import BalController, _user_facing
plugin_name = "bal"
def _controller(plugin, wallet):
"""Build the headless controller, or fail with a clear message."""
if plugin is None:
raise UserFacingException("the bal plugin is not enabled in this daemon")
if wallet is None:
raise UserFacingException("wallet not loaded")
return BalController(plugin, wallet)
def _call(plugin, wallet, method, *args, **kwargs):
controller = _controller(plugin, wallet)
try:
return getattr(controller, method)(*args, **kwargs)
except Exception as e:
raise _user_facing(e) from e
# --------------------------------------------------------------------------- #
# Settings
# --------------------------------------------------------------------------- #
@plugin_command("n", plugin_name)
async def settings_list(self, plugin=None):
"""List all BAL plugin configuration options (key, name and value).
Returns a JSON object mapping every BAL configuration option (``bal_*``)
to an object with ``value``, ``default`` and ``name``.
"""
return _call(plugin, None, "settings_list")
@plugin_command("n", plugin_name)
async def settings_get(self, key, plugin=None):
"""Show the current value of one BAL configuration option.
arg:str:key:The configuration key (e.g. ``bal_tx_fees``).
"""
return _call(plugin, None, "settings_get", key)
@plugin_command("n", plugin_name)
async def settings_set(self, key, value, plugin=None):
"""Set a BAL configuration option (booleans, integers, strings, JSON).
arg:str:key:The configuration key (e.g. ``bal_user_type``).
arg:str:value:The new value; JSON for object-typed keys such as ``bal_will_settings``.
"""
return _call(plugin, None, "settings_set", key, value)
@plugin_command("n", plugin_name)
async def settings_reset(self, key, plugin=None):
"""Reset a BAL configuration option to its default value.
arg:str:key:The configuration key (e.g. ``bal_tx_fees``).
"""
return _call(plugin, None, "settings_reset", key)
# --------------------------------------------------------------------------- #
# Heirs
# --------------------------------------------------------------------------- #
@plugin_command("nw", plugin_name)
async def heirs_list(self, wallet=None, plugin=None):
"""List the heirs of the current wallet.
Returns a JSON object mapping heir names to their ``[address, amount,
locktime]`` values.
"""
return _call(plugin, wallet, "heirs_list")
@plugin_command("nw", plugin_name)
async def heirs_show(self, name, wallet=None, plugin=None):
"""Show the details of a single heir.
arg:str:name:The heir name.
"""
return _call(plugin, wallet, "heirs_show", name)
@plugin_command("nw", plugin_name)
async def heirs_add(self, name, address, amount, locktime=None, wallet=None, plugin=None):
"""Add (or replace) an heir in the current wallet.
arg:str:name:The heir name.
arg:str:address:The destination address (or ``OP_RETURN:<hex>`` for an OP_RETURN heir).
arg:str:amount:The amount in satoshis or a percentage like ``50%%``.
arg:str:locktime:The delivery locktime (absolute timestamp or ``30d``/``1y``); defaults to the will locktime.
"""
return _call(plugin, wallet, "heirs_add", name, address, amount, locktime)
@plugin_command("nw", plugin_name)
async def heirs_update(
self,
name,
address=None,
amount=None,
locktime=None,
wallet=None,
plugin=None,
):
"""Update an existing heir (only the given fields).
arg:str:name:The heir name.
arg:str:address:The new destination address.
arg:str:amount:The new amount in satoshis or a percentage.
arg:str:locktime:The new delivery locktime.
"""
return _call(plugin, wallet, "heirs_update", name, address, amount, locktime)
@plugin_command("nw", plugin_name)
async def heirs_delete(self, names, wallet=None, plugin=None):
"""Delete one or more heirs.
arg:json:names:A JSON array of heir names (e.g. ``["Alice","Bob"]``).
"""
return _call(plugin, wallet, "heirs_delete", names)
@plugin_command("nw", plugin_name)
async def heirs_import(self, path, wallet=None, plugin=None):
"""Import heirs from a JSON file (validated, merged).
arg:str:path:Path to the JSON file.
"""
return _call(plugin, wallet, "heirs_import", path)
@plugin_command("nw", plugin_name)
async def heirs_export(self, path, wallet=None, plugin=None):
"""Export the heirs to a JSON file.
arg:str:path:Destination file path.
"""
return _call(plugin, wallet, "heirs_export", path)
# --------------------------------------------------------------------------- #
# Will-Executors
# --------------------------------------------------------------------------- #
@plugin_command("nw", plugin_name)
async def willexecutors_list(self, wallet=None, plugin=None):
"""List the will-executors for the current network.
Returns a JSON object mapping executor URLs to their records (address,
base_fee, status, info, selected, ...).
"""
return _call(plugin, wallet, "willexecutors_list")
@plugin_command("nw", plugin_name)
async def willexecutors_show(self, url, wallet=None, plugin=None):
"""Show the details of a single will-executor.
arg:str:url:The will-executor URL.
"""
return _call(plugin, wallet, "willexecutors_show", url)
@plugin_command("nw", plugin_name)
async def willexecutors_add(
self,
url,
address="",
base_fee=0,
info=None,
wallet=None,
plugin=None,
):
"""Add a new will-executor (not selected by default).
arg:str:url:The will-executor base URL.
arg:str:address:The executor fee address for this network.
arg:int:base_fee:The executor base fee in satoshis.
arg:str:info:A human-readable description.
"""
return _call(plugin, wallet, "willexecutors_add", url, address, base_fee, info)
@plugin_command("nw", plugin_name)
async def willexecutors_update(
self,
url,
address=None,
base_fee=None,
info=None,
promo_code=None,
rename_to=None,
wallet=None,
plugin=None,
):
"""Update an existing will-executor (only the given fields).
arg:str:url:The will-executor URL to update.
arg:str:address:The new fee address.
arg:int:base_fee:The new base fee in satoshis.
arg:str:info:The new description.
arg:str:promo_code:The new promo code.
arg:str:rename_to:Optionally move the record to a new URL.
"""
return _call(
plugin,
wallet,
"willexecutors_update",
url,
address,
base_fee,
info,
promo_code,
rename_to,
)
@plugin_command("nw", plugin_name)
async def willexecutors_select(
self, url, value=True, wallet=None, plugin=None
):
"""Select (or deselect) a will-executor.
arg:str:url:The will-executor URL.
arg:bool:value:True to select, False to deselect.
"""
return _call(plugin, wallet, "willexecutors_select", [url], value)
@plugin_command("nw", plugin_name)
async def willexecutors_delete(self, urls, wallet=None, plugin=None):
"""Delete one or more will-executors.
arg:json:urls:A JSON array of executor URLs (e.g. ``["https://we.example.com"]``).
"""
return _call(plugin, wallet, "willexecutors_delete", urls)
@plugin_command("nw", plugin_name)
async def willexecutors_ping(self, urls=None, wallet=None, plugin=None):
"""Ping the selected (or the given) will-executor servers.
Updates status/base_fee/address from each server and saves. Returns
``{url: {status, ok}}``.
arg:json:urls:Optional JSON array of URLs to ping; defaults to the selected executors.
"""
return _call(plugin, wallet, "willexecutors_ping", urls)
@plugin_command("nw", plugin_name)
async def willexecutors_download(self, wallet=None, plugin=None):
"""Download the will-executor list from the welist server and merge it.
Returns the number of records downloaded and the new total.
"""
return _call(plugin, wallet, "willexecutors_download")
@plugin_command("nw", plugin_name)
async def willexecutors_import(self, path, wallet=None, plugin=None):
"""Import will-executors from a JSON file (``{url: record}``).
arg:str:path:Path to the JSON file.
"""
return _call(plugin, wallet, "willexecutors_import", path)
@plugin_command("nw", plugin_name)
async def willexecutors_export(self, path, wallet=None, plugin=None):
"""Export the will-executors to a JSON file.
arg:str:path:Destination file path.
"""
return _call(plugin, wallet, "willexecutors_export", path)
# --------------------------------------------------------------------------- #
# Will
# --------------------------------------------------------------------------- #
@plugin_command("nw", plugin_name)
async def will_status(self, wallet=None, plugin=None):
"""Show the current will: per-transaction status, locktime and executors.
Returns a JSON object with a per-txid detail list and global status counts.
"""
return _call(plugin, wallet, "will_status")
@plugin_command("nw", plugin_name)
async def will_check(self, wallet=None, plugin=None):
"""Check the local coherence of the will (heirs, executors, fees, locktime).
Returns ``{"valid": true}`` when coherent, or raises a descriptive error.
"""
return _call(plugin, wallet, "will_check")
@plugin_command("nw", plugin_name)
async def will_prepare(self, wallet=None, plugin=None):
"""Run the full prepare/inheritance flow (check, rebuild, persist).
Returns a JSON object with ``result`` (``coherent``, ``rebuilt``,
``expired``, ``postponed``) and, when needed, the invalidation
transaction to sign and broadcast.
"""
return _call(plugin, wallet, "prepare_will")
@plugin_command("nw", plugin_name)
async def will_autorebuild(self, wallet=None, plugin=None):
"""Run the automatic rebuild flow in one shot (check, rebuild, sign, push).
The same flow the GUI runs automatically on new wallet transactions:
the delivery date is anticipated by one day to orphan the old will on-chain
and, only when the anticipated locktime crosses the Check Alive threshold
(or the threshold is already in the past), an invalidation transaction is
returned instead. Signing needs a passwordless wallet.
Returns a JSON object with ``result``: ``valid``, ``no_heirs``,
``invalidated`` (with ``invalidation_tx``), ``nothing``,
``needs_signing`` or ``rebuilt``.
"""
return _call(plugin, wallet, "auto_rebuild")
@plugin_command("nwp", plugin_name)
async def will_sign(self, txid=None, password=None, wallet=None, plugin=None):
"""Sign the valid, not-yet-complete will transactions (or just one).
Updates the COMPLETE status and the signature counters and persists.
arg:str:txid:Optional transaction id to sign; signs all valid ones when omitted.
"""
txids = [txid] if txid is not None else None
txs = _call(plugin, wallet, "sign_transactions", password, txids)
return {wid: str(tx) for wid, tx in txs.items()}
@plugin_command("nw", plugin_name)
async def will_broadcast(
self, txid=None, force=False, wallet=None, plugin=None
):
"""Send the signed will transactions to their will-executors (in parallel).
Updates the PUSHED/PUSH_FAIL statuses and persists. Returns ``{url: status}``.
arg:str:txid:Optional transaction id to broadcast; all valid+signed ones when omitted.
arg:bool:force:Force re-pushing transactions already marked as PUSHED.
"""
txids = [txid] if txid is not None else None
return _call(plugin, wallet, "push_transactions_to_willexecutors", force, txids)
@plugin_command("nw", plugin_name)
async def will_export(self, path, wallet=None, plugin=None):
"""Export the whole will to a JSON file.
arg:str:path:Destination file path.
"""
return _call(plugin, wallet, "export_will", path)
@plugin_command("nw", plugin_name)
async def will_import_merge(self, path, wallet=None, plugin=None):
"""Merge a will file into the current will (PSBTs and statuses are merged).
arg:str:path:Path to the will JSON file.
"""
return _call(plugin, wallet, "merge_will_from_file", path)
@plugin_command("nw", plugin_name)
async def will_invalidate(self, wallet=None, plugin=None):
"""Build the on-chain invalidation transaction for the current will.
Returns ``{txid, tx}`` (or nulls when there is nothing to invalidate); the
transaction still needs to be signed and broadcast.
"""
return _call(plugin, wallet, "invalidate_will_command")
@plugin_command("nw", plugin_name)
async def will_check_executor(self, txid=None, wallet=None, plugin=None):
"""Ask the will-executors whether they hold our pushed transactions.
Runs the searchtx check in parallel, applies the per-item status and
persists. Returns ``{txid: {url, pushed, checked, check_fail}}``.
arg:str:txid:Optional transaction id to check; checks all pending ones when omitted.
"""
txids = [txid] if txid is not None else None
return _call(plugin, wallet, "check_transactions", txids)

1299
bal/cli/controller.py Normal file

File diff suppressed because it is too large Load Diff

21
bal/cli/plugin.py Normal file
View File

@@ -0,0 +1,21 @@
"""
bal.cli.plugin
==============
The headless (command-line) entry point of the plugin.
:class:`Plugin` subclasses :class:`bal.core.plugin_base.BalPlugin` without
adding any Qt hooks or per-window state. Electrum instantiates this class when
the plugin runs with ``gui_name='cmdline'`` (the daemon loads
``bal/cmdline.py``, which re-exports it), and it is the object injected as
``plugin`` into every ``bal_*`` command by ``electrum.commands.plugin_command``.
"""
from ..core.plugin_base import BalPlugin
class Plugin(BalPlugin):
"""Minimal ``BasePlugin`` subclass for the command-line front-end."""
def __init__(self, parent, config, name):
BalPlugin.__init__(self, parent, config, name)

69
bal/cmdline.py Normal file
View File

@@ -0,0 +1,69 @@
"""
bal.cmdline
===========
Compatibility shim for Electrum's plugin loader (command-line front-end).
Electrum loads a plugin with ``gui_name='cmdline'`` by importing the
``cmdline`` module of the plugin package and looking for a ``Plugin`` class.
The real implementation lives in the ``bal.cli`` sub-package, so this module
re-exports ``Plugin`` from ``bal.cli.plugin``.
Like ``qt.py``, this file is not a one-line relative import because the very
same code may be loaded as an *external* plugin from a ``.zip``, where Electrum
imports the package under the synthetic top-level name
``electrum_external_plugins.bal`` and never registers the intermediate parent
packages. See the module docstring of ``bal.qt`` for the full rationale. The
shim resolves the run-time package name, backfills the missing parents into
``sys.modules`` and imports the real implementation via
:func:`importlib.import_module`.
Unlike ``qt.py``, this module MUST never import PyQt (the daemon loads it in a
headless process).
"""
import importlib
import sys
def _ensure_parent_packages(pkg_name: str) -> None:
"""Make sure every ancestor package of *pkg_name* is in ``sys.modules``.
When loaded from a zip as an external plugin, Electrum only executes the
plugin package ``__init__`` and the ``cmdline`` module. The synthetic root
package (e.g. ``electrum_external_plugins``) and any intermediate packages
may be missing from ``sys.modules``, which breaks relative/absolute
sub-module imports. We backfill them here using this module's own loader
so that ``importlib`` can find sibling sub-packages.
"""
parts = pkg_name.split(".")
# Walk from the top-most ancestor down to (but not including) pkg_name.
for i in range(1, len(parts)):
ancestor = ".".join(parts[:i])
if ancestor in sys.modules:
continue
try:
importlib.import_module(ancestor)
except Exception:
# The synthetic root (e.g. 'electrum_external_plugins') often has no
# real spec. Create a minimal namespace package stub so that the
# import machinery can still resolve its children.
import types
module = types.ModuleType(ancestor)
module.__path__ = [] # mark as a (namespace) package
sys.modules[ancestor] = module
# The package this module belongs to. Could be 'electrum.plugins.bal' (internal)
# or 'electrum_external_plugins.bal' (external zip), depending on how Electrum
# loaded us.
_PKG = __package__ or "bal"
_ensure_parent_packages(_PKG)
# Import the real implementation using the fully-qualified, run-time package
# name so it works regardless of the synthetic prefix Electrum assigned.
_plugin_module = importlib.import_module(_PKG + ".cli.plugin")
Plugin = _plugin_module.Plugin # noqa: F401 (re-exported for Electrum)

296
bal/core/animated_qr.py Normal file
View File

@@ -0,0 +1,296 @@
"""
bal.core.animated_qr
====================
GUI-free implementation of the BBQR animated-QR transfer format used to
move BAL will data between devices.
Supported wire format (self-describing and order-independent on receive):
* **BBQR** (Coinkite)::
B$<encoding><type><2 base36 total><2 base36 index><payload>
Equal-length text frames; the payload is uppercase hex, RFC-4648
base32, or raw-deflate (``wbits=-10``) zlib plus base32.
Everything is implemented from scratch on top of the Python standard library
only (``zlib``, ``base64``), so the shipped plugin zip stays a
self-contained bundle with no third-party dependencies (house rule).
This module never imports Qt or any Electrum GUI code (house rule).
"""
from __future__ import annotations
import base64
import zlib
from typing import Dict, List, Optional, Sequence, Tuple
# --------------------------------------------------------------------------- #
# Errors & safety caps
# --------------------------------------------------------------------------- #
class AnimatedQrError(ValueError):
"""Base error for all animated-QR codec failures."""
class FormatNotDetectedError(AnimatedQrError):
"""The scanned text does not look like any known animated-QR format."""
class TransferConflictError(AnimatedQrError):
"""An incoming frame belongs to a different transfer than the open one."""
class SessionLimitError(AnimatedQrError):
"""A receive session exceeded its safety caps."""
# Safety caps for untrusted scanner input.
_MAX_SESSION_PARTS = 20000
_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
# --------------------------------------------------------------------------- #
# BBQR (Coinkite)
# --------------------------------------------------------------------------- #
_BBQR_PREFIX = "B$"
def _bbqr_base36(n: int) -> str:
if not 0 <= n <= 1295:
raise AnimatedQrError("BBQR part count out of range")
def digit(x: int) -> str:
return chr(48 + x) if x < 10 else chr(65 + x - 10)
return digit(n // 36) + digit(n % 36)
def _bbqr_base32(data: bytes) -> str:
return base64.b32encode(data).decode("ascii").rstrip("=")
def _bbqr_encode(raw: bytes, encoding: str) -> Tuple[str, str, int]:
"""Return ``(encoding, encoded_text, split_mod)`` honouring the reference."""
if encoding == "H":
return "H", raw.hex().upper(), 2
if encoding == "Z":
compressor = zlib.compressobj(wbits=-10)
compressed = compressor.compress(raw) + compressor.flush()
if len(compressed) < len(raw):
return "Z", _bbqr_base32(compressed), 8
encoding = "2"
if encoding != "2":
raise AnimatedQrError("unknown BBQR encoding")
return "2", _bbqr_base32(raw), 8
def bbqr_frames(payload: bytes, budget_chars: int, encoding: str = "Z", type_code: str = "B") -> List[str]:
"""Encode ``payload`` into BBQR frames (``B$<enc><type><N><n>…``)."""
if len(type_code) != 1 or not type_code.isalnum():
raise AnimatedQrError("bad BBQR type code")
encoding, encoded, split_mod = _bbqr_encode(payload, encoding)
chunk = budget_chars - 8
if chunk < split_mod:
raise AnimatedQrError("QR budget too small for a BBQR frame")
chunk -= chunk % split_mod
if chunk < 1:
raise AnimatedQrError("QR budget too small for a BBQR frame")
if len(payload) > _MAX_MESSAGE_BYTES:
raise AnimatedQrError("BBQR payload exceeds the size cap")
total = -(-len(encoded) // chunk)
if total > 1295:
raise AnimatedQrError("BBQR transfer demands too many parts")
header = _BBQR_PREFIX + encoding + type_code + _bbqr_base36(total)
frames: List[str] = []
pos = 0
for index in range(total):
frames.append(header + _bbqr_base36(index) + encoded[pos : pos + chunk])
pos += chunk
return frames
def bbqr_parse_part(frame_text: str) -> Tuple[str, str, int, int, str]:
"""Parse a BBQR frame into ``(encoding, type_code, total, index, payload)``."""
frame_text = frame_text.strip()
if len(frame_text) < 10 or not frame_text.startswith(_BBQR_PREFIX):
raise AnimatedQrError("not a BBQR frame")
encoding = frame_text[2]
type_code = frame_text[3]
if encoding not in ("H", "2", "Z"):
raise AnimatedQrError("unknown BBQR encoding")
try:
total = int(frame_text[4:6], 36)
index = int(frame_text[6:8], 36)
except ValueError:
raise AnimatedQrError("bad BBQR part numbers") from None
if total < 1 or not 0 <= index < total:
raise AnimatedQrError("bad BBQR part numbers")
if index >= _MAX_SESSION_PARTS:
raise AnimatedQrError("BBQR part number out of range")
return encoding, type_code, total, index, frame_text[8:]
def _bbqr_decode(encoded_parts: Sequence[str], encoding: str) -> bytes:
pieces: List[bytes] = []
for part in encoded_parts:
if encoding == "H":
try:
pieces.append(bytes.fromhex(part))
except ValueError:
raise AnimatedQrError("invalid BBQR hex payload") from None
continue
padding = (8 - (len(part) % 8)) % 8
try:
pieces.append(base64.b32decode(part + "=" * padding))
except (ValueError, TypeError):
raise AnimatedQrError("invalid BBQR base32 payload") from None
raw = b"".join(pieces)
if encoding == "Z":
try:
inflater = zlib.decompressobj(wbits=-10)
out = inflater.decompress(raw, _MAX_MESSAGE_BYTES + 1)
except zlib.error:
raise AnimatedQrError("invalid BBQR zlib payload") from None
if len(out) > _MAX_MESSAGE_BYTES or inflater.unconsumed_tail:
raise AnimatedQrError("BBQR payload exceeds the size cap")
return out
return raw
# --------------------------------------------------------------------------- #
# Format detection & per-frame identity for the shared debounce
# --------------------------------------------------------------------------- #
FORMAT_LABELS = {
"bbqr": "BBQR",
}
def format_name(fmt: str) -> str:
"""Human-readable name of a wire format for UI labels."""
return FORMAT_LABELS.get(fmt, fmt)
def detect_format(text: str) -> Optional[str]:
"""Return the wire format of a scanned string, or ``None``."""
text = text.strip()
if not text:
return None
if text.startswith(_BBQR_PREFIX):
return "bbqr"
return None
def parse_for_detection(text: str) -> Tuple[str, str, int, int]:
"""Parse a frame and return ``(format, session_key, frame_total, index)``.
``session_key`` identifies the transfer the frame belongs to and drives
the shared reset/ignore/accept debounce. Raises
:class:`AnimatedQrError` when the text cannot be parsed.
"""
fmt = detect_format(text)
if fmt == "bbqr":
encoding, type_code, total, index, _payload = bbqr_parse_part(text)
return "bbqr", "bbqr:{}{}:{}".format(encoding, type_code, total), total, index
raise FormatNotDetectedError("Not a supported QR transfer format")
# --------------------------------------------------------------------------- #
# Receive session (order-independent assembly)
# --------------------------------------------------------------------------- #
class _BbqrSession:
def __init__(self):
self._total = 0
self._encoding = ""
self._type_code = ""
self._parts: Dict[int, str] = {}
@property
def total(self) -> int:
return self._total
@property
def received(self) -> int:
return len(self._parts)
@property
def done(self) -> bool:
return bool(self._total) and len(self._parts) >= self._total
def add(self, text: str) -> str:
encoding, type_code, total, index, payload = bbqr_parse_part(text)
if self._total:
if (encoding, type_code, total) != (self._encoding, self._type_code, self._total):
raise TransferConflictError("BBQR frame header changed")
else:
self._total = total
self._encoding = encoding
self._type_code = type_code
if index in self._parts:
return "dup"
self._parts[index] = payload
return "ok"
def resolve(self) -> Tuple[str, bool]:
ordered = [self._parts[i] for i in range(self._total)]
raw = _bbqr_decode(ordered, self._encoding)
return _transfer_text(raw), False
def _transfer_text(raw: bytes) -> str:
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
raise AnimatedQrError("decoded transfer is not valid UTF-8") from None
class AnimatedQrSession:
"""Facade over the per-format receive sessions used by the QR import page."""
def __init__(self):
self._inner: Optional[object] = None
self._fmt: Optional[str] = None
@property
def format(self) -> Optional[str]:
return self._fmt
def add_part(self, text: str) -> str:
"""Feed one scanned frame; returns ``"ok"``/``"dup"``, raises on bad input."""
fmt = detect_format(text)
if fmt is None:
raise FormatNotDetectedError("Not a supported QR transfer format")
if self._inner is None:
self._fmt = fmt
self._inner = self._make(fmt)
elif fmt != self._fmt:
raise TransferConflictError(
"Switched QR format mid-import ({} -> {})".format(self.format, fmt)
)
return self._inner.add(text) # type: ignore[no-any-return]
@staticmethod
def _make(fmt: str) -> object:
if fmt == "bbqr":
return _BbqrSession()
raise AssertionError("unknown animated-QR format {}".format(fmt))
@property
def total(self) -> int:
return self._inner.total if self._inner is not None else 0
@property
def received(self) -> int:
return self._inner.received if self._inner is not None else 0
@property
def done(self) -> bool:
return bool(self._inner is not None and self._inner.done)
def resolve(self) -> Tuple[str, bool]:
if self._inner is None:
raise AnimatedQrError("no transfer has been received")
return self._inner.resolve() # type: ignore[no-any-return]

View File

@@ -6,11 +6,24 @@ The "Check Alive" policy: the single reference timestamp (``date_to_check``)
against which every will-validity check is evaluated, and the BASIC/ADVANCED
mode rules that decide it.
Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the
postpone/invalidate flow; the decision that it *should* be raised lives here.
A RELATIVE Check Alive value is anchored according to its SIGN:
* 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
from datetime import datetime, timezone
from typing import Any
from .plugin_base import BalTimestamp
@@ -19,13 +32,114 @@ from .plugin_base import BalTimestamp
class CheckAliveError(Exception):
"""Raised when the "check alive" date is in the past."""
def __init__(self, timestamp_to_check):
def __init__(self, timestamp_to_check, reason: str | None = None):
self.timestamp_to_check = timestamp_to_check
self.reason = reason
def __str__(self):
return "Check alive expired please update it: {}".format(
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
message = "Check alive expired please update it: {}".format(
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(
@@ -33,6 +147,7 @@ def resolve_date_to_check(
will_settings: Any,
now: float | None = None,
built_locktime: float | int | None = None,
creation_date: float | None = None,
) -> float:
"""Return the reference timestamp for every will-validity check.
@@ -41,22 +156,11 @@ def resolve_date_to_check(
``check_amounts`` and the locktime-vs-threshold guard.
* BASIC mode: the Check Alive is hidden and NOT editable, so it must never
govern those checks. ``date_to_check`` is set to *now*: every check is
evaluated against the current moment (the Check Alive effectively does not
exist) while the delivery locktime is still fully enforced.
* ADVANCED mode: the user-controlled stored threshold is used as-is. An
ABSOLUTE threshold is returned unchanged; a RELATIVE one (``"30d"``/``"1y"``)
means "N days BEFORE the delivery date" and is resolved against the stored
locktime (matching the date the settings widget displays), so it stays in
lockstep with the built transactions instead of drifting with the clock.
A RELATIVE stored locktime is resolved against the frozen delivery date of
the built will (``built_locktime``, the locktime inside the signed tx) when
one exists: the will's real delivery date is authoritative, and resolving
the relative locktime from *now* would drift ``date_to_check`` past the
frozen tx locktime so an unchanged will wrongly reads as expired (asking to
invalidate) every day. Without a built will the legacy forward-from-now
resolution is kept.
govern those checks. ``date_to_check`` is set to *now*.
* ADVANCED mode: an ABSOLUTE stored threshold is returned unchanged; a
RELATIVE one is resolved by :func:`resolve_relative_check_alive_date`
(negative -> anchored to the delivery, positive -> anchored to the
inheritance creation date).
Args:
is_basic_mode: ``True`` for the SIMPLE / BASIC user type.
@@ -65,47 +169,123 @@ def resolve_date_to_check(
now: overridable clock for tests; defaults to ``datetime.now()``.
built_locktime: the absolute locktime frozen inside the built will's
transactions (``None`` when there is no built will yet).
creation_date: the inheritance creation timestamp
(``Will.get_creation_time``); ``None`` falls back to *now*.
Returns:
The reference timestamp (float, UNIX seconds).
"""
if is_basic_mode:
return (now if now is not None else datetime.now().timestamp())
return (now if now is not None else datetime.now(tz=timezone.utc).timestamp())
threshold = BalTimestamp(will_settings["threshold"])
# A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery":
# the settings widget resolves it as real_threshold = locktime - N days.
# Resolving it FORWARD from now (BalTimestamp.to_timestamp) turns
# date_to_check into a moving target that disagrees with the fixed
# locktime of the built transactions (and with the date shown in the UI),
# which can wrongly mark the will as expired/postponed. Resolve it
# against the delivery date instead.
if threshold.unit is not None:
locktime_raw = will_settings.get("locktime")
if locktime_raw is None:
# No delivery reference to anchor to: fall back to the legacy
# forward-from-now resolution.
return threshold.to_timestamp()
locktime_dt = BalTimestamp(locktime_raw).to_date(now)
# A RELATIVE stored locktime ("2y") is itself a moving target; when a
# will has already been built, its frozen delivery date (the tx
# locktime) is the authoritative anchor (see docstring).
if BalTimestamp(locktime_raw).unit is not None and built_locktime:
locktime_dt = BalTimestamp(int(built_locktime)).to_date(now)
return threshold.to_date(locktime_dt, reverse=True).timestamp()
return threshold.to_timestamp()
if threshold.unit is None:
return threshold.to_timestamp()
delivery_date = resolve_delivery_date(
will_settings, now=now, built_locktime=built_locktime
)
return resolve_relative_check_alive_date(
will_settings["threshold"],
delivery_date,
creation_date=creation_date,
now=now,
)
def check_alive_expired(
is_basic_mode: bool, date_to_check: float, now: float | None = None
) -> bool:
"""True when the Check Alive guard should fire (``CheckAliveError``).
def resolve_guard_threshold(
is_basic_mode: bool,
will_settings: Any,
now: float | None = None,
creation_date: float | None = None,
) -> float | None:
"""Resolve the "locktime is lower than threshold" guard's reference.
Only ADVANCED mode can be "expired": in BASIC mode the Check Alive is inert
by construction, so a passed check-alive date must never force a postpone or
rewrite of the will.
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:
"""True when the Check Alive date is inside ``(now, delivery_date)``.
The single validity rule for the Check Alive reference: it must be AFTER
today and BEFORE the delivery. A ``None`` ``delivery_date`` skips the
upper bound (no delivery is known yet).
"""
if date_to_check is None:
return False
current = now if now is not None else datetime.now().timestamp()
return date_to_check < current
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:
return
if date_to_check is None:
return
current = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
if date_to_check < current:
raise CheckAliveError(date_to_check)
if delivery_date is not None and date_to_check >= delivery_date:
raise CheckAliveAfterDeliveryError(date_to_check, delivery_date)

View File

@@ -59,7 +59,7 @@ from electrum.util import (
write_json_file,
)
from .util import Util
from .util import Util, copy_structure
from .willexecutors import Willexecutors
if TYPE_CHECKING:
@@ -321,40 +321,14 @@ def get_change_output(wallet, in_amount, out_amount, fee):
return out
def _json_safe(value, _path="heirs", _depth=0):
"""Return a JSON-serializable deep copy of *value*.
def _json_safe(value, _path="heirs"):
"""Backward-compatible alias of :func:`bal.core.util.copy_structure`.
The wallet DB persists the heirs dict via ``json_db.put``, which calls
``copy.deepcopy`` on the value. If any nested element is a live runtime
object (e.g. one holding a ``threading.RLock``), deepcopy raises
``TypeError: cannot pickle '_thread.RLock' object`` and the whole
"Build will" task fails.
To make persistence robust we coerce the structure to plain
JSON-compatible types (dict / list / str / int / float / bool / None).
Anything else is converted to ``str(value)`` and logged with its path so
the offending field can be identified, instead of crashing the task.
Kept so call sites that imported ``_json_safe`` directly keep working; the
actual implementation (a JSON-safe, deepcopy-free clone) lives in
``bal.core.util`` so every copy path shares one code base.
"""
# Primitive JSON scalars are kept as-is.
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, dict):
return {
str(k): _json_safe(v, "{}[{!r}]".format(_path, k), _depth + 1)
for k, v in value.items()
}
if isinstance(value, (list, tuple)):
return [
_json_safe(v, "{}[{}]".format(_path, i), _depth + 1)
for i, v in enumerate(value)
]
# Unexpected runtime object: do not let it reach deepcopy. Log where it
# was found so the real source can be fixed, then store a safe string.
_logger.error(
"heirs.save: non-serializable value at {} (type={}); coercing to str. "
"value={!r}".format(_path, type(value).__name__, value)
)
return str(value)
return copy_structure(value, _path=_path)
class Heirs(dict, Logger):
@@ -363,6 +337,10 @@ class Heirs(dict, Logger):
Logger.__init__(self)
self.db = wallet.db
self.wallet = wallet
# Reason code explaining why the last buildTransactions() produced no
# transaction (None when the last build succeeded or never ran). See
# buildTransactions for the list of codes and why they exist.
self.last_build_error = None
d = self.db.get("heirs", {})
try:
self.update(d)
@@ -630,6 +608,20 @@ class Heirs(dict, Logger):
def buildTransactions(
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
):
# Reset the diagnostic reason at the start of every build attempt.
#
# WHY: when the build produced nothing, the GUI used to show a fixed
# list of three "possible reasons" (low balance / dust shares /
# check-alive after the delivery date). In practice the real cause is
# often NONE of those three - several code paths below simply return
# an empty result with no explanation at all, so the user was shown
# three guesses that were all wrong. Each such path now records WHY
# it gave up, and BalBuildWillDialog names the actual cause.
#
# Codes: NO_HEIRS, NO_UTXO, NO_WILLEXECUTOR_USABLE, NO_FUTURE_DATE,
# WILLEXECUTOR_FEE, WILLEXECUTOR_FEE_TOO_HIGH, TX_BUILD_FAILED,
# WILLEXECUTOR_TX_ERROR.
self.last_build_error = None
_before = list(self.keys())
Heirs._validate(self, persist=False)
_removed = [k for k in _before if k not in self]
@@ -644,6 +636,7 @@ class Heirs(dict, Logger):
", ".join(_removed),
)
if len(self) <= 0:
self.last_build_error = "NO_HEIRS"
_logger.info("while building transactions there was no heirs")
return
balance = 0.0
@@ -660,12 +653,18 @@ class Heirs(dict, Logger):
len_utxo_set += 1
available_utxos.append(utxo)
if len_utxo_set == 0:
self.last_build_error = "NO_UTXO"
_logger.info("no usable utxos")
return
j = -2
willexecutorsitems = list(willexecutors.items())
willexecutorslen = len(willexecutorsitems)
alltxs = {}
# Counts how many will-executors were actually PROCESSED (i.e. passed
# the is_selected/is_valid filter below and reached the build loop).
# If it stays 0 the loop silently skipped every single one, which is a
# distinct failure from "we tried and the build failed".
processed_willexecutors = 0
while True:
j += 1
if j >= willexecutorslen:
@@ -682,6 +681,7 @@ class Heirs(dict, Logger):
url = willexecutor = None
else:
break
processed_willexecutors += 1
fees = {}
i = 0
txs = {}
@@ -699,9 +699,11 @@ class Heirs(dict, Logger):
max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
)
except WillExecutorFeeException:
self.last_build_error = "WILLEXECUTOR_FEE"
i = 10
continue
except WillExecutorFeeTooHighException:
self.last_build_error = "WILLEXECUTOR_FEE_TOO_HIGH"
i = 10
continue
if locktimes:
@@ -710,19 +712,33 @@ class Heirs(dict, Logger):
locktimes, available_utxos[:], fees, wallet
)
if not txs:
self.last_build_error = "TX_BUILD_FAILED"
return {}
except Exception as e:
# An unexpected failure while assembling the
# transactions for THIS will-executor.
#
# WHY THIS CHANGED: the previous code read
# ``e.heirname`` here, in order to auto-deselect the
# will-executor blamed by the exception. NOTHING in
# the plugin sets that attribute any more (it is a
# leftover from an older exception design), so the
# lookup itself raised AttributeError, and the inner
# ``except Exception: raise`` re-raised THAT - aborting
# the whole build with a confusing secondary error
# instead of the real one. We now record the reason,
# log the actual exception together with the
# will-executor it happened on, and simply move on to
# the next one, which is what the original code was
# clearly trying to do.
self.last_build_error = "WILLEXECUTOR_TX_ERROR"
_logger.error(
f"build transactions: error preparing transactions: {e}"
"build transactions: error preparing transactions "
"for will-executor %s: %r",
(willexecutor or {}).get("url", "(none)"),
e,
)
try:
if "w!ll3x3c" in e.heirname:
Willexecutors.is_selected(
e.heirname[len("w!ll3x3c") :], False
)
break
except Exception:
raise
break
total_fees = 0
total_fees_real = 0
total_in = 0
@@ -746,12 +762,26 @@ class Heirs(dict, Logger):
if i >= 10:
break
else:
self.last_build_error = "NO_FUTURE_DATE"
_logger.info(
f"no locktimes for willexecutor {willexecutor} skipped"
)
break
alltxs.update(txs)
# Every will-executor was skipped by the is_selected/is_valid filter
# (or the list was empty) and no "no will-executor" build was allowed,
# so the loop above never even attempted a build. This path used to
# return silently with no log line at all, which is exactly the case
# the owner hit: the dialog then blamed balance/dust/check-alive, none
# of which was true.
if not alltxs and processed_willexecutors == 0:
self.last_build_error = "NO_WILLEXECUTOR_USABLE"
_logger.info(
"no usable will-executor: all %d skipped (not selected or not valid)",
willexecutorslen,
)
return alltxs
def get_transactions(

View File

@@ -107,7 +107,7 @@ def _checkbdy(s: str, pos: int, appendix: str) -> Tuple[int, str]:
def normalize_locktime_raw_text(
text: str, pos: int
text: str, pos: int, allow_negative: bool = False
) -> Tuple[str, bool, bool, int]:
"""Sanitise the RAW locktime field text.
@@ -117,6 +117,9 @@ def normalize_locktime_raw_text(
Args:
text: the raw field 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:
``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised
@@ -124,11 +127,17 @@ def normalize_locktime_raw_text(
"""
text = text.strip()
chars = "0123456789dy"
if allow_negative:
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])
isdays = 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, "y")
@@ -141,6 +150,10 @@ def normalize_locktime_raw_text(
s = replace_dy_suffixes(s) + "d"
if isyears:
s = replace_dy_suffixes(s) + "y"
if negative:
s = "-" + s
pos = max(pos, 1)
return s, isdays, isyears, pos

View File

@@ -24,12 +24,15 @@ This module performs **no** GUI work and imports nothing from PyQt / electrum.gu
import json
import os
import platform
from datetime import date, datetime, timedelta
from datetime import datetime, timedelta, timezone
from electrum import constants, json_db
from electrum.logging import get_logger
from electrum.plugin import BasePlugin
from electrum.transaction import tx_from_any
from electrum.util import classproperty
from ..i18n import N_, _
_logger = get_logger(__name__)
@@ -108,7 +111,9 @@ def get_will(x):
try:
# Electrum >= 4.8.0
from electrum.stored_dict import register_name as _electrum_register_name # pyright: ignore[reportMissingImports]
from electrum.stored_dict import (
register_name as _electrum_register_name, # pyright: ignore[reportMissingImports]
)
def _register_will_dict(name, method, _type=None):
"""Register a plugin dict in the wallet DB (Electrum >= 4.8.0 API)."""
@@ -131,12 +136,20 @@ class BalConfig:
Wraps ``config.get`` / ``config.set_key`` and supplies a default value
when the key is missing.
``translatable=True`` is for a default *text* (marked with ``N_()``) that
should follow the GUI language. The English default is what gets stored,
and :meth:`get` translates it when it is read, so the stored value never
depends on the GUI language. A text the user wrote is returned unchanged.
Only for texts that are shown: a text that is also used to recognise
stored data (e.g. ``HISTORY_LABEL``) must stay non-translatable.
"""
def __init__(self, config, name, default):
def __init__(self, config, name, default, translatable=False):
self.config = config
self.name = name
self.default = default
self.translatable = translatable
def get(self, default=None):
"""Return the stored value, falling back to ``default`` then ``self.default``."""
@@ -146,10 +159,20 @@ class BalConfig:
v = default
else:
v = self.default
if self.translatable and v == self.default:
return _(v)
return v
def localized_default(self):
"""Return the default value, translated if the setting is translatable."""
return _(self.default) if self.translatable else self.default
def set(self, value, save=True):
"""Persist ``value`` for this key."""
if self.translatable and value == self.localized_default():
# The translated default is stored as its English original, so
# it keeps following the GUI language (see get()).
value = self.default
self.config.set_key(self.name, value, save=save)
@@ -169,9 +192,12 @@ class BalPlugin(BasePlugin):
}
# Human-readable chain name ("bitcoin", "testnet", "regtest", ...).
chainname = (
constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin"
)
# Must be a classproperty (not a plain class attribute) because the class
# is defined before constants.net is set to the correct network — a plain
# attribute would capture "bitcoin" and never update.
@classproperty
def chainname(cls):
return constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin"
# Default geometry hint for some dialogs (kept from the original code).
SIZE = (159, 97)
@@ -237,6 +263,10 @@ class BalPlugin(BasePlugin):
# the wallet's local history. May contain the "{willexecutor}" token,
# which is replaced with the will-executor URL of each will item at
# save time.
# Deliberately NOT translatable: Util._label_matches_history() uses
# this text to recognise BAL's own local transactions (stale-history
# cleanup, spendable UTXOs). A label that changed with the GUI
# language would no longer match the transactions saved before.
self.HISTORY_LABEL = BalConfig(
config,
"bal_history_label",
@@ -251,12 +281,36 @@ class BalPlugin(BasePlugin):
# (handled by BalWindow.get_wallet_password). Default ON.
self.AUTO_SIGN = BalConfig(config, "bal_auto_sign", True)
# REBUILD_ON_CLOSE: when enabled (default), closing the wallet or
# quitting Electrum runs the "Build your will" wizard
# (BalBuildWillDialog) to rebuild and re-validate the will. When
# disabled, on_close() only persists the current in-memory willitems to
# the wallet DB: no rebuild dialog, no auto-sign/broadcast, no
# invalidation prompts at close. Default ON.
self.REBUILD_ON_CLOSE = BalConfig(config, "bal_rebuild_on_close", True)
# AUTO_REBUILD: when enabled, an incoming/outgoing wallet transaction
# automatically re-runs the same rebuild flow the wizard runs at
# wallet close (anticipate the delivery date by one day to orphan the
# previous will; build an on-chain invalidation tx ONLY when the
# anticipated locktime would fall before the check-alive threshold or
# the threshold is already in the past). When disabled (default) the
# will is only rebuilt when the user presses Check / Prepare or closes
# the wallet. Default OFF.
self.AUTO_REBUILD = BalConfig(config, "bal_auto_rebuild", False)
# EDITABLE_DATES (Group C / C2): when enabled, the delivery-time and
# check-alive date fields are editable everywhere (toolbar / Heirs tab),
# not only inside the "Build your will" wizard. Default OFF, so the dates
# stay display-only outside the wizard unless the user opts in.
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in characters,
# used per QR frame when exporting/importing a will through the QR
# channel. The export dialog seeds its own size selector from this
# value (default 150, small QR, low-resolution cameras).
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
# exported .ics calendar should contain. Each reminder becomes its own
# VEVENT (its own date in the calendar). The dates are spread uniformly
@@ -297,13 +351,20 @@ class BalPlugin(BasePlugin):
self.WELIST_SERVER = BalConfig(
config, "bal_welist_server", "https://welist.bitcoin-after.life/"
)
# Calendar (.ics) texts: only shown, never used to recognise data, so
# their defaults follow the GUI language (translatable=True). The
# $tokens must survive translation.
self.EVENT_DESCRIPTION = BalConfig(
config,
"bal_event_description",
"BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete",
N_("BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete"),
translatable=True,
)
self.EVENT_SUMMARY = BalConfig(
config, "bal_event_summary", "BAL -Will execution of $wallet_name"
config,
"bal_event_summary",
N_("BAL -Will execution of $wallet_name"),
translatable=True,
)
# Default will-executor servers, keyed by network. These addresses are
@@ -436,22 +497,35 @@ class BalPlugin(BasePlugin):
@staticmethod
def default_will_settings_absolute():
"""Convert the default relative dates into absolute timestamps (from today)."""
"""Convert the default relative dates into absolute timestamps.
The Check Alive anchor follows its sign (see ``bal.core.checkalive``):
a negative default counts back from the delivery, a positive one counts
forward from today (the inheritance creation date).
"""
relative_dates = BalPlugin.default_will_settings_relative()
today = date.today()
dt = datetime(today.year, today.month, today.day, 0, 0, 0)
threshold = (
dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days())
).timestamp()
locktime = (
dt + timedelta(days=BalTimestamp(relative_dates["locktime"]).duration_to_days())
).timestamp()
return {"threshold": threshold, "locktime": locktime}
today = datetime.now(tz=timezone.utc).date()
dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc)
locktime_dt = dt + timedelta(
days=BalTimestamp(relative_dates["locktime"]).duration_to_days()
)
threshold = BalTimestamp(relative_dates["threshold"])
if threshold.unit is not None and threshold.value < 0:
anchor_dt = locktime_dt
else:
anchor_dt = dt
threshold_dt = anchor_dt + timedelta(days=threshold.duration_to_days())
return {"threshold": threshold_dt.timestamp(), "locktime": locktime_dt.timestamp()}
@staticmethod
def default_will_settings_relative():
"""Default relative dates: 30 days threshold, 1 year locktime."""
return {"threshold": "30d", "locktime": "1y"}
"""Default relative dates: 30 days BEFORE the delivery, 1 year locktime.
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:
@@ -499,12 +573,12 @@ class BalTimestamp:
"""
int32_max = 2 ** 31 - 1
try:
return datetime.fromtimestamp(ts)
return datetime.fromtimestamp(ts, tz=timezone.utc)
except (OSError, OverflowError, ValueError):
try:
return datetime.fromtimestamp(min(int(ts), int32_max))
return datetime.fromtimestamp(min(int(ts), int32_max), tz=timezone.utc)
except (OSError, OverflowError, ValueError):
return datetime.fromtimestamp(int32_max)
return datetime.fromtimestamp(int32_max, tz=timezone.utc)
def to_date(self, from_date=None, reverse=False):
"""Resolve to a ``datetime``.
@@ -517,7 +591,7 @@ class BalTimestamp:
return self._safe_fromtimestamp(self.value)
else:
if from_date is None:
from_date = datetime.now()
from_date = datetime.now(tz=timezone.utc)
if isinstance(from_date, (int, float)):
from_date = self._safe_fromtimestamp(from_date)
reverse = 1 if not reverse else -1

80
bal/core/qrtransfer.py Normal file
View File

@@ -0,0 +1,80 @@
"""
bal.core.qrtransfer
===================
GUI-free helpers for moving BAL will data between devices via QR codes or
the Electrum ``audio_modem`` plugin (see ``PLAN_QR_TRANSFER.md``).
Scope
-----
* converts will transactions into a compact ``transfer_string``
(newline-joined serialized transactions, optionally zlib + base64
compressed). The transfer string is the payload carried by BBQR QR frames
(``bal.core.animated_qr``).
The audio-modem channel carries the whole transfer string in a single blob
(PLAN_QR_TRANSFER.md section 4.4), so callers only use
:func:`encode_transfer` / :func:`decode_transfer`.
This module never imports Qt or any Electrum GUI code (house rule).
"""
from __future__ import annotations
import base64
import zlib
# 4 standard presets (label, payload budget per QR). Ordered from
# low-resolution cameras to high-resolution cameras (owner decision D5).
CHUNK_PRESETS = (
("Small - ~150 bytes/QR (low-res cameras)", 150),
("Medium - ~400 bytes/QR", 400),
("Large - ~900 bytes/QR", 900),
("XL - ~1800 bytes/QR (high-res cameras)", 1800),
)
def encode_transfer(tx_strings, compress=False):
"""Join serialized transaction strings into a transfer string.
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
the whole bundle shrinks before being printed/scanned.
"""
return __compress("\n".join(tx_strings), enabled=compress)
def decode_transfer(transfer_string, compressed):
"""Inverse of :func:`encode_transfer`.
Returns the list of serialized transaction strings; empty frames are
dropped so a trailing newline (or an empty payload) cannot produce an
empty trailing element.
"""
text = __decompress(transfer_string, enabled=compressed)
return [part for part in text.split("\n") if part]
def preset_index_for_chunk_size(chunk_size):
"""Return the :data:`CHUNK_PRESETS` index whose budget best matches a size."""
best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1])
for index, (_label, budget) in enumerate(CHUNK_PRESETS):
diff = abs(chunk_size - budget)
if diff < best_diff:
best, best_diff = index, diff
return best
# --------------------------------------------------------------------------- #
# Internals
# --------------------------------------------------------------------------- #
def __compress(text, *, enabled):
if not enabled:
return text
return base64.b64encode(zlib.compress(text.encode("utf-8"))).decode("ascii")
def __decompress(text, *, enabled):
if not enabled:
return text
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")

View File

@@ -38,7 +38,7 @@ def compute_reminder_offsets(days, count):
count: requested number of reminders.
Returns:
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
A list of integer day-offsets (each ``>= 1``), e.g. ``[30, 16, 1]`` for
``days=30, count=3``. Empty if there is no room for any reminder.
"""
# No room for any reminder (deadline today or already passed).
@@ -147,6 +147,7 @@ def build_ics_reminders(
num_reminders: int = 3,
now: Optional[datetime] = None,
threshold: Optional[datetime] = None,
reminder_suffix: str = "(reminder {idx}/{total})",
) -> Optional[str]:
"""Build the ``.ics`` content with one VEVENT per reminder date.
@@ -169,6 +170,10 @@ def build_ics_reminders(
num_reminders: requested reminder count (ADVANCED mode only).
now: "today" reference; defaults to ``datetime.now()``.
threshold: check-alive date (ADVANCED mode only; required there).
reminder_suffix: text appended to each event summary, with the
``{idx}`` and ``{total}`` fields. This module stays free of
Electrum imports, so the GUI passes it already translated; the
English default keeps the CLI output unchanged.
Returns:
The ``.ics`` content string, or ``None`` when no reminder falls in the
@@ -210,7 +215,9 @@ def build_ics_reminders(
# The visible date of this event: "offset" days before the deadline.
event_dt = format_time(locktime - timedelta(days=offset))
# Suffix the summary so the N events are easy to tell apart.
event_summary = ical_escape(f"{summary_base} (reminder {idx}/{total})")
event_summary = ical_escape(
"{} {}".format(summary_base, reminder_suffix.format(idx=idx, total=total))
)
lines.extend([
"BEGIN:VEVENT",
# Offset in the UID keeps each event unique (no merging).

View File

@@ -18,11 +18,14 @@ original implementation.
"""
import bisect
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
from electrum.logging import get_logger
from electrum.transaction import PartialTxOutput
_logger = get_logger(__name__)
# Bitcoin consensus rule: an nLockTime value strictly below this threshold is
# interpreted as a *block height*, otherwise it is interpreted as a *UNIX
# timestamp*.
@@ -35,6 +38,41 @@ from electrum.transaction import PartialTxOutput
LOCKTIME_THRESHOLD = 500000000
def copy_structure(value, _path="copy"):
"""Return a JSON-serializable deep copy of *value*.
This is the ad-hoc, deepcopy-free stand-in used every time the plugin needs
an independent copy of a plain-data structure (heirs dicts, will-executor
dicts, status tables). It recursively clones dict / list / tuple values
while leaving JSON scalars (str / int / float / bool / None) as-is.
If any nested element is a live runtime object (e.g. one holding a
``threading.RLock``), ``copy.deepcopy`` would raise
``TypeError: cannot pickle '_thread.RLock' object``; instead we coerce the
offending value to ``str(value)`` and log it with its path so the source
field can be identified, without crashing the caller.
"""
# Primitive JSON scalars are kept as-is.
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, dict):
return {
str(k): copy_structure(v, "{}[{!r}]".format(_path, k))
for k, v in value.items()
}
if isinstance(value, (list, tuple)):
return [
copy_structure(v, "{}[{}]".format(_path, i)) for i, v in enumerate(value)
]
# Unexpected runtime object: do not let it reach deepcopy. Log where it
# was found so the real source can be fixed, then store a safe string.
_logger.error(
"copy_structure: non-serializable value at {} (type={}); coercing to "
"str. value={!r}".format(_path, type(value).__name__, value)
)
return str(value)
class Util:
"""Namespace of static helpers (kept as a class to preserve the original
``Util.method(...)`` call sites used throughout the plugin)."""
@@ -103,7 +141,7 @@ class Util:
except Exception:
pass
try:
now = datetime.now()
now = datetime.now(tz=timezone.utc)
if locktime[-1] == "y":
locktime = str(int(locktime[:-1]) * 365) + "d"
if locktime[-1] == "d":
@@ -189,7 +227,7 @@ class Util:
# moment, so fall back to the legacy forward-from-now resolution.
return Util.parse_locktime_string(current)
try:
base = datetime.fromtimestamp(int(tx_locktime)).replace(
base = datetime.fromtimestamp(int(tx_locktime), tz=timezone.utc).replace(
hour=0, minute=0, second=0, microsecond=0
)
build_moment = base - timedelta(days=built_days)
@@ -440,9 +478,9 @@ class Util:
# On Windows datetime.fromtimestamp raises OverflowError past 2038
# (e.g. NLOCKTIME_MAX); clamp to INT32_MAX (Electrum issue #6170).
try:
dt = datetime.fromtimestamp(locktime)
dt = datetime.fromtimestamp(locktime, tz=timezone.utc)
except (OverflowError, OSError, ValueError):
dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1))
dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1), tz=timezone.utc)
dt -= timedelta(seconds=seconds)
out = dt.timestamp()
@@ -450,34 +488,6 @@ class Util:
out = 1
return out
@staticmethod
def cmp_locktime(locktimea, locktimeb):
"""Compare two relative locktime strings sharing the same unit."""
if locktimea == locktimeb:
return 0
strlocktimea = str(locktimea)
strlocktimeb = str(locktimeb)
if locktimea[-1] in "ydb":
if locktimeb[-1] == locktimea[-1]:
return int(strlocktimea[-1]) - int(strlocktimeb[-1])
else:
return int(locktimea) - (locktimeb)
@staticmethod
def get_lowest_valid_tx(available_utxos, will):
"""Placeholder kept from the original code (sorts the will by locktime)."""
will = sorted(will.items(), key=lambda x: x[1]["tx"].locktime)
for _txid, _willitem in will.items():
pass
@staticmethod
def get_locktimes(will):
"""Return the distinct locktimes used by the transactions in ``will``."""
locktimes = {}
for _, willitem in will.items():
locktimes[willitem["tx"].locktime] = True
return locktimes.keys()
@staticmethod
def get_lowest_locktimes(locktimes):
"""Split a list of locktimes into (sorted_timestamps, sorted_blocks)."""
@@ -492,32 +502,6 @@ class Util:
return sorted(sorted_timestamp), sorted(sorted_block)
@staticmethod
def get_lowest_locktimes_from_will(will):
"""Convenience wrapper: lowest locktimes directly from a will dict."""
return Util.get_lowest_locktimes(Util.get_locktimes(will))
@staticmethod
def search_willtx_per_io(will, tx):
"""Find a will entry whose tx has the same inputs/outputs as ``tx``."""
for wid, w in will.items():
if Util.cmp_txs(w["tx"], tx["tx"]):
return wid, w
return None, None
@staticmethod
def invalidate_will(will):
raise Exception("not implemented")
@staticmethod
def get_will_spent_utxos(will):
"""Collect every input spent by any transaction in ``will``."""
utxos = []
for _, willitem in will.items():
utxos += willitem["tx"].inputs()
return utxos
# ------------------------------------------------------------------ #
# UTXO helpers
# ------------------------------------------------------------------ #

View File

@@ -26,10 +26,9 @@ The status flags themselves (the source of truth) stay here; only the mapping
"status -> colour" now lives in the GUI layer. No behaviour changed.
"""
import copy
from datetime import datetime, timezone
from electrum.i18n import _
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
from electrum.logging import Logger, get_logger
from electrum.transaction import (
PartialTransaction,
@@ -44,8 +43,9 @@ from electrum.util import (
bfh,
)
from ..i18n import N_, _
from .heirs import WillExecutorFeeTooHighException
from .util import Util
from .util import Util, copy_structure
from .willexecutors import Willexecutors
MIN_LOCKTIME = 1
@@ -74,11 +74,6 @@ class Will:
if not will[child[0]].father:
will[child[0]].father = willid
# return a list of will sorted by locktime
@staticmethod
def get_sorted_will(will):
return sorted(will.items(), key=lambda x: x[1]["tx"].locktime)
@staticmethod
def only_valid(will):
for k, v in will.items():
@@ -107,15 +102,6 @@ class Will:
and not w.get_status("CHECKED")
)
@staticmethod
def search_equal_tx(will, tx, wid):
for w in will:
if w != wid and not tx.to_json() != will[w]["tx"].to_json():
if will[w]["tx"].txid() != tx.txid():
if Util.cmp_txs(will[w]["tx"], tx):
return will[w]["tx"]
return False
@staticmethod
def get_tx_from_any(x):
try:
@@ -157,7 +143,7 @@ class Will:
willitems = {}
for wid in will:
Will.add_info_from_will(will, wid, wallet)
willitems[wid] = WillItem(will[wid])
willitems[wid] = WillItem(will[wid], wallet=wallet)
will = willitems
errors = {}
for wid in will:
@@ -179,7 +165,7 @@ class Will:
outputs = will[wid].tx.outputs()
ow = will[wid]
ow.normalize_locktime(others_input)
will[wid] = WillItem(ow.to_dict())
will[wid] = ow.copy()
for i in range(0, len(outputs)):
Will.change_input(
@@ -479,7 +465,7 @@ class Will:
continue
utxo_str = utxo.prevout.to_str()
if utxo_str in prevout_to_spend:
balance += inputs[utxo_str][0][2].value_sats()
balance += utxo.value_sats()
utxo_to_spend.append(utxo)
_logger.debug("utxo to spend: {}".format(utxo_to_spend))
if len(utxo_to_spend) > 0:
@@ -516,6 +502,7 @@ class Will:
for _wid, w in will.items():
if w.get_status("VALID") and not w.get_status("COMPLETE"):
return True
return False
@staticmethod
def search_rai(all_inputs, all_utxos, will, wallet):
@@ -861,6 +848,48 @@ class Will:
except Exception as e:
_logger.error(f"save_valid_transactions_to_history failed: {e}")
@staticmethod
def remove_stale_wallet_history(wallet, history_label):
"""Delete wallet-LOCAL will transactions saved under ``history_label``.
``save_valid_transactions_to_history`` stores the not-yet-signed
inheritance txs into the wallet's local history; those local
placeholders nominally spend the coins they reference. When the will is
REBUILT (prepare/build, auto-rebuild, on-close rebuild, CLI build) the
stale placeholders must be removed so the coins become available again
to the new build (see ``Util.get_available_utxos``). Only
wallet-local/future (non-broadcast) txs whose label matches the history
label template are removed; confirmed/broadcast history is never
touched. Returns the txids that were removed.
"""
if not wallet or not getattr(wallet, "adb", None):
return []
removed = []
for txid, label in Will._wallet_labels(wallet):
if not label or not Util._label_matches_history(label, history_label):
continue
try:
height = int(wallet.adb.get_tx_height(txid).height())
except Exception:
continue
if height not in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
continue
try:
wallet.adb.remove_transaction(txid)
removed.append(txid)
except Exception as e:
_logger.error(f"remove from history failed for {txid}: {e}")
continue
try:
wallet.set_label(txid, None)
except Exception as e:
_logger.error(f"set_label failed for {txid}: {e}")
try:
wallet.save_db()
except Exception as e:
_logger.error(f"save_db failed after history purge: {e}")
return removed
@staticmethod
def _add_transaction_to_history(wallet, tx, txid):
"""Store *tx* into the wallet's local history via ``adb``.
@@ -943,6 +972,25 @@ class Will:
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)
@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
@@ -1227,9 +1275,9 @@ class Will:
if Util.parse_locktime_string(heirs[h][2]) >= check_date:
count_heirs += 1
if h not in heirs_found:
_logger.debug(f"heir: {h} not found")
raise HeirNotFoundException(h)
if h not in heirs_found:
_logger.debug(f"heir: {h} not found")
raise HeirNotFoundException(h)
if not count_heirs:
raise NoHeirsException("there are not valid heirs")
if self_willexecutor and no_willexecutor == 0:
@@ -1244,6 +1292,34 @@ class Will:
def format_status_history(status: str) -> str:
"""Return the saved status history of a will item in the GUI language.
``WillItem.status`` is a dot-separated history such as
``".Signed.Pushed.NOT Valid"``. It is stored in English (see
``WillItem.set_status``) and translated only here, for display. A token
that is not a known English label is shown unchanged: this keeps working
the histories written by older versions, which stored the label already
translated (for example ``"Firmato"``).
"""
labels = {label for label, _flag in WillItem.STATUS_DEFAULT.values()}
labels.update(WillItem.LEGACY_STATUS_LABELS)
def translate(token):
# "ERROR!!!" is glued to the end of the history when an item has no
# id (see WillItem.__init__): keep it, translate what comes before.
suffix = ""
if token.endswith("ERROR!!!"):
token, suffix = token[: -len("ERROR!!!")], "ERROR!!!"
if token in labels:
token = _(token)
elif token.startswith("NOT ") and token[len("NOT "):] in labels:
token = _("NOT {}").format(_(token[len("NOT "):]))
return token + suffix
return ".".join(translate(token) for token in status.split("."))
class WillItem(Logger):
# Default status flags for an inheritance transaction.
# Each entry maps an internal status key to [human-readable label, default
@@ -1257,28 +1333,68 @@ class WillItem(Logger):
# * "UPDATED" was added: the transaction was spendable AND valid, and a new
# transaction replaces it while keeping the SAME locktime and SAME heirs.
# UPDATED keeps the VALID flag (see set_status).
#
# The labels are marked with N_() but stay English here: they are written
# into the saved status history (see set_status) and translated only when
# shown, by format_status_history().
STATUS_DEFAULT = {
"ANTICIPATED": ["Anticipated", False],
"BROADCASTED": ["Broadcasted", False],
"CHECKED": ["Checked", False],
"CHECK_FAIL": ["Check Failed", False],
"COMPLETE": ["Signed", False],
"CONFIRMED": ["Confirmed", False],
"ERROR": ["Error", False],
"EXPIRED": ["Expired", False],
"EXPORTED": ["Exported", False],
"IMPORTED": ["Imported", False],
"INVALIDATED": ["Invalidated", False],
"MEMPOOL": ["Mempool", False],
"PUSH_FAIL": ["Push failed", False],
"PUSHED": ["Pushed", False],
"PARTIALLY_SIGNED": ["Partially Signed", False],
"REPLACED": ["Replaced", False],
"RESTORED": ["Restored", False],
"UPDATED": ["Updated", False],
"VALID": ["Valid", True],
"ANTICIPATED": [N_("Anticipated"), False],
"BROADCASTED": [N_("Broadcasted"), False],
"CHECKED": [N_("Checked"), False],
"CHECK_FAIL": [N_("Check Failed"), False],
"COMPLETE": [N_("Signed"), False],
"CONFIRMED": [N_("Confirmed"), False],
"ERROR": [N_("Error"), False],
"EXPIRED": [N_("Expired"), False],
"EXPORTED": [N_("Exported"), False],
"IMPORTED": [N_("Imported"), False],
"INVALIDATED": [N_("Invalidated"), False],
"MEMPOOL": [N_("Mempool"), False],
"PUSH_FAIL": [N_("Push failed"), False],
"PUSHED": [N_("Pushed"), False],
"PARTIALLY_SIGNED": [N_("Partially Signed"), False],
"REPLACED": [N_("Replaced"), False],
"RESTORED": [N_("Restored"), False],
"UPDATED": [N_("Updated"), False],
"VALID": [N_("Valid"), True],
}
# Labels found in histories saved by older versions but no longer
# written: "New" opened every history (e.g. tests/samanta7).
# format_status_history() still translates them.
LEGACY_STATUS_LABELS = (N_("New"),)
# 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):
"""Set a status flag and apply the related side effects.
@@ -1312,7 +1428,11 @@ class WillItem(Logger):
if self.STATUS[status][1] == bool(value):
return None
self.status += "." + (("NOT " if not value else "") + _(self.STATUS[status][0]))
# The history is saved in the wallet, in exported files and in QR
# transfers, so it is always written in English: the saved data must
# not depend on the GUI language (older versions wrote the translated
# label). format_status_history() translates it when it is shown.
self.status += "." + (("NOT " if not value else "") + self.STATUS[status][0])
self.STATUS[status][1] = bool(value)
if value:
# NOTE: ANTICIPATED and UPDATED are intentionally NOT in this list,
@@ -1340,47 +1460,78 @@ class WillItem(Logger):
return self.STATUS[status][1]
def __init__(self, w, _id=None, wallet=None):
if isinstance(
w,
WillItem,
):
self.__dict__ = w.__dict__.copy()
else:
self.tx = Will.get_tx_from_any(w["tx"])
self.heirs = w.get("heirs", None)
self.we = w.get("willexecutor", None)
self.status = w.get("status", None)
self.description = w.get("description", None)
self.time = w.get("time", None)
self.change = w.get("change", None)
self.tx_fees = w.get("baltx_fees", 0)
self.sigs_required = int(w.get("sigs_required", 0))
self.sigs_have = int(w.get("sigs_have", 0))
self.father = w.get("Father", None)
self.children = w.get("Children", None)
self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
for s in self.STATUS:
self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1])
# Backward-compatibility migration (A2): the "PENDING" status was
# renamed to "MEMPOOL". Wills saved by older versions of the plugin
# store the flag under the legacy "PENDING" key, so if that key is
# present and set, carry it over to "MEMPOOL". This way no state is
# lost when loading an older will. The new key always wins if both
# happen to be present.
if "MEMPOOL" not in w and w.get("PENDING"):
self.STATUS["MEMPOOL"][1] = True
if isinstance(w, WillItem):
# Copy a WillItem WITHOUT deepcopy. Serialize it to its plain-dict
# form and deserialize from there: the tx is re-parsed into a fresh
# object, STATUS is rebuilt from the clones below and heirs /
# will-executors are cloned recursively, so the copy shares no
# mutable state with the source. See also copy().
data = w.to_dict()
data["heirs"] = copy_structure(w.heirs) if w.heirs is not None else None
data["willexecutor"] = (
copy_structure(w.we) if w.we is not None else None
)
if not _id:
self._id = self.tx.txid()
else:
self._id = _id
_id = w._id
w = data
self.tx = Will.get_tx_from_any(w["tx"])
self.heirs = w.get("heirs", None)
self.we = WillItem._without_transient_push_fields(
w.get("willexecutor", None)
)
self.status = WillItem._untranslate_new_status(w.get("status") or "")
self.description = w.get("description", None)
self.time = w.get("time", None)
self.change = w.get("change", None)
self.tx_fees = w.get("baltx_fees", 0)
self.sigs_required = int(w.get("sigs_required", 0))
self.sigs_have = int(w.get("sigs_have", 0))
self.father = w.get("Father", None)
self.children = w.get("Children", None)
self.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
for s in self.STATUS:
self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1])
# Backward-compatibility migration (A2): the "PENDING" status was
# renamed to "MEMPOOL". Wills saved by older versions of the plugin
# store the flag under the legacy "PENDING" key, so if that key is
# present and set, carry it over to "MEMPOOL". This way no state is
# lost when loading an older will. The new key always wins if both
# happen to be present.
if "MEMPOOL" not in w and w.get("PENDING"):
self.STATUS["MEMPOOL"][1] = True
if not _id:
self._id = self.tx.txid()
else:
self._id = _id
if not self._id:
self.status += "ERROR!!!"
self.valid = False
if not self._id:
self.status += "ERROR!!!"
self.valid = False
if wallet:
self.tx.add_info_from_wallet(wallet)
def copy(self, wallet=None):
"""Return an independent copy of this WillItem (no deepcopy).
The copy is produced by serializing this item and deserializing it:
the transaction is re-parsed, the STATUS table is rebuilt and
heirs / will-executors are cloned recursively, so the result shares no
mutable state with ``self``. Pass a ``wallet`` when the copy's tx
needs its address/value information restored
(``tx.add_info_from_wallet``).
"""
return WillItem(self, _id=self._id, wallet=wallet)
@staticmethod
def copy_status_table(status_table):
"""Clone a STATUS table (``{flag: [label, bool]}``) without deepcopy.
Both the outer dict and every inner ``[label, bool]`` list are new
objects, so mutating the returned table never affects the source.
"""
return {k: [label, value] for k, (label, value) in status_table.items()}
def to_dict(self):
out = {
"_id": self._id,
@@ -1394,6 +1545,8 @@ class WillItem(Logger):
"baltx_fees": self.tx_fees,
"sigs_required": self.sigs_required,
"sigs_have": self.sigs_have,
"Father": self.father,
"Children": self.children,
}
for key in self.STATUS:
try:

View File

@@ -22,10 +22,10 @@ from typing import Any
from aiohttp import ClientResponse
from electrum import bitcoin, constants
from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
from electrum.i18n import _
from electrum.logging import get_logger
from electrum.network import Network
from ..i18n import _
from .plugin_base import BalPlugin, get_version
# Per-request timeout (seconds) for interactive operations (ping / info /
@@ -112,8 +112,6 @@ def is_tor_active():
return False
chainname = BalPlugin.chainname
class Willexecutors:
@@ -146,9 +144,9 @@ class Willexecutors:
@staticmethod
def save(bal_plugin, willexecutors):
_logger.debug(f"save {willexecutors},{chainname}")
_logger.debug(f"save {willexecutors},{BalPlugin.chainname}")
aw = bal_plugin.WILLEXECUTORS.get()
aw[chainname] = willexecutors
aw[BalPlugin.chainname] = willexecutors
bal_plugin.WILLEXECUTORS.set(aw)
_logger.debug(f"saved: {aw}")
# bal_plugin.WILLEXECUTORS.set(willexecutors)
@@ -158,7 +156,7 @@ class Willexecutors:
bal_plugin, update=False, bal_window: Any = None, force=False, task=True
):
willexecutors = bal_plugin.WILLEXECUTORS.get()
willexecutors = willexecutors.get(chainname, {})
willexecutors = willexecutors.get(BalPlugin.chainname, {})
to_del = []
for w in willexecutors:
if not isinstance(willexecutors[w], dict):
@@ -172,7 +170,7 @@ class Willexecutors:
)
)
del willexecutors[w]
bal = bal_plugin.WILLEXECUTORS.default.get(chainname, {})
bal = bal_plugin.WILLEXECUTORS.default.get(BalPlugin.chainname, {})
for bal_url, bal_executor in bal.items():
if bal_url not in willexecutors:
_logger.debug(f"force add {bal_url} willexecutor")
@@ -233,6 +231,13 @@ class Willexecutors:
@staticmethod
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 = {}
for wid, willitem in will.items():
if willitem.get_status("VALID"):
@@ -242,10 +247,18 @@ class Willexecutors:
url = willexecutor["url"]
if willexecutor and Willexecutors.is_selected(willexecutor):
if url not in willexecutors:
willexecutor["txs"] = ""
willexecutor["txsids"] = []
willexecutor["broadcast_status"] = _("Waiting...")
willexecutors[url] = willexecutor
# Work on a COPY of the record: the item's
# own ``we`` dict is saved in the wallet, so
# the transient push fields below (raw txs,
# txids and the - translated - broadcast
# status shown in the push dialog) must
# never end up in it (stored data must not
# depend on the UI language, see bal/i18n.py).
record = dict(willexecutor)
record["txs"] = ""
record["txsids"] = []
record["broadcast_status"] = _("Waiting...")
willexecutors[url] = record
willexecutors[url]["txs"] += str(willitem.tx) + "\n"
willexecutors[url]["txsids"].append(wid)
@@ -368,7 +381,7 @@ class Willexecutors:
_logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}")
if w := Willexecutors.send_request(
"post",
willexecutor["url"] + "/" + chainname + "/pushtxs",
willexecutor["url"] + "/" + BalPlugin.chainname + "/pushtxs",
data=willexecutor["txs"].encode("ascii"),
timeout=timeout,
max_retries=max_retries,
@@ -408,7 +421,7 @@ class Willexecutors:
# single short timeout instead of retrying 10x with sleeps, which
# used to freeze the UI for minutes per unreachable server.
w = Willexecutors.send_request(
"get", url + "/" + chainname + "/info",
"get", url + "/" + BalPlugin.chainname + "/info",
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
)
if isinstance(w, dict):
@@ -788,7 +801,7 @@ class Willexecutors:
welist_server = welist_server if welist_server[-1] == '/' else welist_server+'/'
willexecutors = Willexecutors.send_request(
"get",
f"{welist_server}data/{chainname}?page=0&limit=100",
f"{welist_server}data/{BalPlugin.chainname}?page=0&limit=100",
)
if not isinstance(willexecutors, dict):
_logger.warning(

View File

@@ -13,12 +13,15 @@ The pure RFC-5545 logic (offsets, escaping, folding, the unified .ics builder,
the Qt button and the OS/subprocess glue.
"""
import os
import subprocess
from electrum.gui.qt.util import getSaveFileName
from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QToolButton
from PyQt6.QtWidgets import QInputDialog, QMenu, QToolButton
from ...core.reminders import write_temp_ics
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from .common import _, _logger
class BalCalendarButton(QToolButton):

View File

@@ -15,7 +15,6 @@ hosts a few GUI helpers that do not deserve a module of their own:
(:class:`CheckAliveError` now lives in ``bal.core.checkalive``.)
"""
import copy
import enum
import os
import subprocess
@@ -28,6 +27,7 @@ from functools import partial
from typing import Any, Callable, Mapping, Optional, Union
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
from electrum.gui.common_qt.util import draw_qr
from electrum.gui.qt.amountedit import BTCAmountEdit
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
from electrum.gui.qt.my_treeview import MyTreeView
@@ -42,6 +42,7 @@ from electrum.gui.qt.util import (
MessageBoxMixin,
OkButton,
TaskThread,
WaitingDialog,
WindowModalDialog,
char_width_in_lineedit,
getOpenFileName,
@@ -51,7 +52,6 @@ from electrum.gui.qt.util import (
read_QPixmap_from_bytes,
webopen,
)
from electrum.i18n import _
from electrum.logging import get_logger
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError
from electrum.payment_identifier import PaymentIdentifier
@@ -80,6 +80,7 @@ from PyQt6.QtWidgets import (
QAbstractItemView,
QAbstractSpinBox,
QApplication,
QButtonGroup,
QCheckBox,
QComboBox,
QDateTimeEdit,
@@ -92,6 +93,7 @@ from PyQt6.QtWidgets import (
QMenu,
QMenuBar,
QPushButton,
QRadioButton,
QScrollArea,
QSizePolicy,
QSpinBox,
@@ -108,6 +110,7 @@ from ...core.heirs import (
HEIR_DUST_AMOUNT,
HEIR_REAL_AMOUNT,
OP_RETURN_PREFIX,
BalanceTooLowException,
HeirAmountIsDustException,
Heirs,
WillExecutorFeeTooHighException,
@@ -118,7 +121,7 @@ from ...core.heirs import (
# --- Core (GUI-free) logic layer ---
from ...core.plugin_base import BalPlugin, BalTimestamp
from ...core.util import Util
from ...core.util import Util, copy_structure
from ...core.will import (
AmountException,
HeirChangeException,
@@ -133,6 +136,7 @@ from ...core.will import (
WillExpiredException,
WillItem,
WillPostponedException,
format_status_history,
)
from ...core.willexecutors import ( # noqa: F401
Willexecutors,
@@ -140,6 +144,9 @@ from ...core.willexecutors import ( # noqa: F401
is_tor_active,
)
# BAL's translator: Electrum's catalog first, then BAL's (see bal/i18n.py).
from ...i18n import N_, _
# --- Presentation helpers ---
from .theme import (
server_status_text,
@@ -157,6 +164,17 @@ from .window_utils import (
_logger = get_logger(__name__)
# Labels of bal.core.qrtransfer.CHUNK_PRESETS, marked for translation here
# because qrtransfer.py must stay free of Electrum imports (the Android reader
# ships a copy of it). The combos show them with _(label);
# tests/test_i18n.py checks that this list matches CHUNK_PRESETS.
QR_PRESET_LABELS = (
N_("Small - ~150 bytes/QR (low-res cameras)"),
N_("Medium - ~400 bytes/QR"),
N_("Large - ~900 bytes/QR"),
N_("XL - ~1800 bytes/QR (high-res cameras)"),
)
class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal naming)
_type = bool
@@ -174,13 +192,30 @@ class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal namin
def add_widget(grid, label, widget, row, help_):
grid.addWidget(QLabel(_(label)), row, 0)
"""Add a ``label | widget | help button`` row to ``grid``.
``label`` and ``help_`` must already be translated by the caller: a
variable passed to _() cannot be extracted into the catalog.
"""
grid.addWidget(QLabel(label), row, 0)
grid.addWidget(widget, row, 1)
grid.addWidget(HelpButton(help_), row, 2)
def translated_headers(headers):
"""Return a translated copy of a list's ``headers`` class attribute.
The column headers are class attributes marked with N_(): a class body
runs at import time, before the catalog is loaded, so they are
translated here, each time the headers are (re)built.
"""
return {column: _(text) for column, text in headers.items()}
def log_error(exec_info, window=None):
"""Log an error and optionally show it.
@@ -218,7 +253,7 @@ def export_meta_gui(electrum_window, title, exporter):
filter_ = "All files (*)"
filename = getSaveFileName(
parent=electrum_window,
title=_("Select file to save your {}".format(title)),
title=_("Select file to save your {}").format(title),
filename="BALplugin_{}_{}_{}".format(
BalPlugin.chainname, str(electrum_window.wallet), title
),
@@ -233,7 +268,7 @@ def export_meta_gui(electrum_window, title, exporter):
electrum_window.show_critical(str(e))
else:
electrum_window.show_message(
_("Your {0} were exported to '{1}'".format(title, str(filename)))
_("Your {0} were exported to '{1}'").format(title, str(filename))
)

File diff suppressed because it is too large Load Diff

View File

@@ -19,8 +19,62 @@ from typing import TYPE_CHECKING
from PyQt6.QtWidgets import QLineEdit as _QLineEdit
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from .common import (
N_,
OP_RETURN_PREFIX,
BalTimestamp,
Buttons,
CancelButton,
HelpButton,
MessageBoxMixin,
MyTreeView,
OkButton,
QAbstractItemView,
QApplication,
QColor,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMenu,
QModelIndex,
QPersistentModelIndex,
QPushButton,
QSize,
QSizePolicy,
QSpinBox,
QStandardItem,
QStandardItemModel,
Qt,
QToolButton,
QVBoxLayout,
QWidget,
TaskThread,
Util,
Will,
Willexecutors,
WillItem,
_,
_logger,
char_width_in_lineedit,
datetime,
enum,
export_meta_gui,
format_status_history,
getOpenFileName,
import_meta_gui,
is_op_return_address,
partial,
read_json_file,
read_QIcon_from_bytes,
server_status_text,
server_status_tooltip,
signature_suffix,
status_color,
translated_headers,
tx_from_any,
write_json_file,
)
from .dialogs import BalBuildWillDialog, BalDialog
from .widgets import BalCheckBox, WillSettingsWidget
@@ -77,9 +131,9 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
AMOUNT = enum.auto()
headers = {
Columns.NAME: _("Name"),
Columns.ADDRESS: _("Address"),
Columns.AMOUNT: _("Amount"),
Columns.NAME: N_("Name"),
Columns.ADDRESS: N_("Address"),
Columns.AMOUNT: N_("Amount"),
}
filter_columns = [Columns.NAME, Columns.ADDRESS]
@@ -194,7 +248,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
col=self.Columns.NAME, role=self.ROLE_HEIR_KEY
)
self.model().clear()
self.update_headers(self.__class__.headers)
self.update_headers(translated_headers(self.__class__.headers))
set_current = None
for key in sorted(self.bal_window.heirs.keys()):
heir = self.bal_window.heirs[key]
@@ -286,11 +340,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
SERVER = enum.auto()
headers = {
Columns.LOCKTIME: _("Locktime"),
Columns.TXID: _("Txid"),
Columns.WILLEXECUTOR: _("Will-Executor"),
Columns.STATUS: _("Status"),
Columns.SERVER: _("Server"),
Columns.LOCKTIME: N_("Locktime"),
Columns.TXID: N_("Txid"),
Columns.WILLEXECUTOR: N_("Will-Executor"),
Columns.STATUS: N_("Status"),
Columns.SERVER: N_("Server"),
}
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000
@@ -535,7 +589,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
if bal_tx.we:
we = bal_tx.we["url"]
labels[self.Columns.WILLEXECUTOR] = we
status = bal_tx.status + signature_suffix(bal_tx)
status = format_status_history(bal_tx.status) + signature_suffix(bal_tx)
if len(status) > 53:
status = "...{}".format(status[-50:])
labels[self.Columns.STATUS] = status
@@ -593,7 +647,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
col=self.Columns.TXID, role=self.ROLE_HEIR_KEY
)
self.model().clear()
self.update_headers(self.__class__.headers)
self.update_headers(translated_headers(self.__class__.headers))
set_current = None
for txid, bal_tx in self.will.items():
@@ -612,11 +666,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
menu.addAction(_("Prepare"), self.build_transactions)
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
export_menu = menu.addMenu(_("Export"))
export_menu.addAction(_("All"), self.export_will)
export_menu.addAction(_("Valid"), self.export_will_valid)
export_menu.addAction(_("Valid NC"), self.export_will_valid_incomplete)
menu.addAction(_("Import"), self.import_will_into_details)
# Export/Import open a single window that offers all transports
# (file / QR / audio). The Choose Filter / transport settings live
# inside that window.
menu.addAction(_("Export"), self.export_will)
menu.addAction(_("Import"), self.import_will)
menu.addAction(_("Merge"), self.merge_will)
menu.addAction(_("Broadcast"), self.broadcast)
menu.addAction(_("Check"), self.check)
@@ -625,7 +679,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
# The Wizard is the main entry point to create an inheritance, so make
# it stand out: show a bold label next to a slightly larger icon (the
# plain icon-only button was too easy to overlook).
wizard = QPushButton(" " + _("Build Your Will"))
wizard = QPushButton(" {}".format(_("Build Your Will")))
wizard.setIcon(
read_QIcon_from_bytes(
self.bal_window.bal_plugin.read_file("icons/wizard.png")
@@ -682,38 +736,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
if will:
self.update_will(will)
def export_json_file(self, path):
write_json_file(path, self.will)
def export_will(self):
self.bal_window.export_will()
self.update()
self.bal_window.export_will_dialog()
def export_will_valid(self):
"""Export only the will items that are valid."""
subset = {
wid: wi
for wid, wi in self.will.items()
if wi.get_status("VALID")
}
if not subset:
self.show_message(_("No valid will item to export"))
return
self.bal_window.export_will(will=subset)
self.update()
def export_will_valid_incomplete(self):
"""Export only the will items that are valid but not yet fully signed (V-NC)."""
subset = {
wid: wi
for wid, wi in self.will.items()
if wi.get_status("VALID") and not wi.get_status("COMPLETE")
}
if not subset:
self.show_message(_("No valid, incomplete will item to export"))
return
self.bal_window.export_will(will=subset)
self.update()
def import_will(self):
self.bal_window.import_will_dialog()
def import_will_into_details(self):
self.bal_window.import_will_into_details()
@@ -880,12 +907,12 @@ class WillExecutorListWidget(MyTreeView):
ADDRESS = enum.auto()
headers = {
Columns.SELECTED: _(""),
Columns.URL: _("Url"),
Columns.STATUS: _("S"),
Columns.BASE_FEE: _("Base fee"),
Columns.INFO: _("Info"),
Columns.ADDRESS: _("Default Address"),
Columns.SELECTED: "",
Columns.URL: N_("Url"),
Columns.STATUS: N_("S"),
Columns.BASE_FEE: N_("Base fee"),
Columns.INFO: N_("Info"),
Columns.ADDRESS: N_("Default Address"),
}
filter_columns = [Columns.URL]
@@ -1067,7 +1094,7 @@ class WillExecutorListWidget(MyTreeView):
col=self.Columns.URL, role=self.ROLE_HEIR_KEY
)
self.model().clear()
self.update_headers(self.__class__.headers)
self.update_headers(translated_headers(self.__class__.headers))
set_current = None
@@ -1212,13 +1239,14 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
buttonbox.addWidget(b)
def _menu_button(label):
# ``label`` must already be translated by the caller.
btn = QToolButton()
btn.setText(_(label))
btn.setText(label)
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
buttonbox.addWidget(btn)
return btn
export_btn = _menu_button("Export")
export_btn = _menu_button(_("Export"))
export_menu = QMenu(export_btn)
export_menu.addAction(_("Export all"), lambda: self.export_file())
export_menu.addAction(
@@ -1229,7 +1257,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
)
export_btn.setMenu(export_menu)
ping_btn = _menu_button("Ping All")
ping_btn = _menu_button(_("Ping All"))
ping_menu = QMenu(ping_btn)
ping_menu.addAction(_("Ping all"), lambda: self.update_willexecutors())
ping_menu.addAction(
@@ -1237,7 +1265,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
)
ping_btn.setMenu(ping_menu)
select_btn = _menu_button("Select All")
select_btn = _menu_button(_("Select All"))
select_menu = QMenu(select_btn)
select_menu.addAction(_("Select all"), lambda: self.set_select_all(True))
select_menu.addAction(
@@ -1271,7 +1299,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
url_edit = QLineEdit()
url_edit.setFixedWidth(32 * char_width_in_lineedit())
info_edit = QLineEdit("New Will Executor")
info_edit = QLineEdit(_("New Will Executor"))
info_edit.setFixedWidth(32 * char_width_in_lineedit())
base_fee_spin = QSpinBox()
base_fee_spin.setRange(0, 1000000)
@@ -1285,7 +1313,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
if executor:
url_edit.setText(edit_key)
info_edit.setText(str(executor.get("info", "New Will Executor")))
info_edit.setText(str(executor.get("info", _("New Will Executor"))))
base_fee_spin.setValue(int(executor.get("base_fee", 0)))
address_edit.setText(str(executor.get("address", "")))
@@ -1445,7 +1473,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
break
self._add_another = False
url_edit.clear()
info_edit.setText("New Will Executor")
info_edit.setText(_("New Will Executor"))
base_fee_spin.setValue(0)
address_edit.clear()

View File

@@ -15,13 +15,36 @@ and cached in ``self.bal_windows``.
"""
from electrum.gui.qt.main_window import StatusBarButton
from electrum.plugin import hook
from electrum.util import EventListener, event_listener
from PyQt6.QtWidgets import QLayout
from .common import *
from .common import ( # underscore names are not re-exported by "import *"
from ...i18n import init_from_config
from .common import (
BalPlugin,
Buttons,
EnterButton,
HelpButton,
PasswordDialog,
QComboBox,
QGridLayout,
QHBoxLayout,
QInputDialog,
QLabel,
QPushButton,
QTimer,
QVBoxLayout,
QWidget,
UserCancelled,
Willexecutors,
_,
_logger,
add_widget,
partial,
read_QIcon_from_bytes,
read_QPixmap_from_bytes,
show_modal,
webopen,
)
from .dialogs import BalDialog
from .widgets import BalCheckBox, BalLineEdit, BalSpinBox, BalTextEdit
@@ -40,15 +63,24 @@ def _window_key(window):
return id(window)
class Plugin(BalPlugin):
class Plugin(BalPlugin, EventListener):
def __init__(self, parent, config, name):
_logger.info("INIT BALPLUGIN")
BalPlugin.__init__(self, parent, config, name)
# Load BAL's catalog for the language Electrum's GUI is using, before
# any window or dialog builds its texts. It must come after
# BalPlugin.__init__: read_file() needs the plugin's parent and name.
# The CLI plugin does not call this, so the CLI stays in English.
init_from_config(self, config)
self.bal_windows = {}
# Status-bar buttons, keyed by id(sb.window()). Tracking them lets us
# remove a stale button before creating a fresh one when a wallet is
# switched / Electrum is restarted, so the icon is never duplicated.
self._statusbar_buttons = {}
# Register the on_event_* handlers with Electrum's callback manager so
# the plugin learns about new wallet transactions (used by the
# AUTO_REBUILD setting).
self.register_callbacks()
@hook
def init_qt(self, gui_object):
@@ -271,7 +303,7 @@ class Plugin(BalPlugin):
pass
b = StatusBarButton(
read_QIcon_from_bytes(self.read_file("icons/bal32x32.png")),
"Bal " + _("Bitcoin After Life"),
"Bal {}".format(_("Bitcoin After Life")),
lambda: self.settings_dialog(sb.window()),
sb.height(),
)
@@ -328,6 +360,44 @@ class Plugin(BalPlugin):
except Exception as e:
_logger.error("close_wallet: on_close failed: {}".format(e))
@event_listener
def on_event_new_transaction(self, wallet, tx):
"""Electrum event: a transaction was added to *wallet*."""
self._wallet_activity(wallet)
@event_listener
def on_event_wallet_updated(self, wallet):
"""Electrum event: *wallet* finished a sync pass."""
self._wallet_activity(wallet)
def _wallet_activity(self, wallet):
"""React to wallet activity (new transaction / sync update).
When the AUTO_REBUILD setting is enabled, any change to a wallet that
has a live BalWindow schedules the headless "auto rebuild" flow
(``BalWindow.schedule_auto_rebuild``): it re-runs the same check the
wizard runs at wallet close, anticipating the delivery date by one day
and building an on-chain invalidation tx only when the anticipated
locktime would fall before the check-alive threshold (or the threshold
is already in the past).
This handler runs on the asyncio callback thread, so it only touches
thread-safe state and defers all work to the BalWindow (which marshals
itself onto the GUI thread through QTimer).
"""
if not self.AUTO_REBUILD.get():
return
for win in list(self.bal_windows.values()):
try:
if (
getattr(win, "wallet", None) == wallet
and win.ok
and not win.disable_plugin
):
win.schedule_auto_rebuild()
except Exception as e:
_logger.debug("_wallet_activity failed: {}".format(e))
@hook
def init_keystore(self):
_logger.debug("init keystore")
@@ -377,7 +447,7 @@ class Plugin(BalPlugin):
def settings_dialog(self, window=None, wallet=None):
d = BalDialog(window, self, self.get_window_title("Settings"))
d = BalDialog(window, self, self.get_window_title(_("Settings")))
d.setMinimumSize(100, 200)
qicon = read_QPixmap_from_bytes(self.read_file("icons/bal16x16.png"))
lbl_logo = QLabel()
@@ -448,13 +518,24 @@ class Plugin(BalPlugin):
self.MAX_WILLEXECUTOR_FEE, minimum=0, maximum=10000000
)
# "No will-executor TX" checkbox. Bound to the persisted NO_WILLEXECUTOR
# config (default ON, see plugin_base.py), the SAME config used by the
# checkbox inside the "Build your will" wizard's will-executor download
# window, so the two stay in sync automatically. When enabled the plugin
# also builds a will that does not require a will-executor (e.g. it can
# be saved on a USB stick and a copy given to the heirs).
heir_no_willexecutor = BalCheckBox(self.NO_WILLEXECUTOR)
# "Rebuild will on wallet close" checkbox. Bound to the persisted
# REBUILD_ON_CLOSE config (default ON). When ticked, closing the wallet
# / quitting Electrum runs the "Build your will" wizard to rebuild and
# re-validate the will. When unticked, the will is only rebuilt when
# the user presses Check/Prepare. Visible to all users (BASIC and
# ADVANCED).
heir_rebuild_on_close = BalCheckBox(self.REBUILD_ON_CLOSE)
# "Rebuild automatically on new transactions" checkbox. Bound to the
# persisted AUTO_REBUILD config (default OFF). When ticked, an incoming
# or outgoing wallet transaction automatically re-runs the same rebuild
# flow the wizard runs at wallet close: the delivery date is
# anticipated by one day (so the new will replaces the previous one
# without an invalidation tx), and an on-chain invalidation is only
# built when the anticipated locktime would fall before the Check Alive
# threshold or the threshold is already in the past. Visible to all
# users (BASIC and ADVANCED).
heir_auto_rebuild = BalCheckBox(self.AUTO_REBUILD)
# USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo
# (not a free-text field) bound to the USER_TYPE config:
@@ -565,9 +646,9 @@ class Plugin(BalPlugin):
elif kind == "spin":
widget.setValue(int(cfg.default))
elif kind == "line":
widget.setText(cfg.default)
widget.setText(cfg.localized_default())
elif kind == "text":
widget.setPlainText(cfg.default)
widget.setPlainText(cfg.localized_default())
elif kind == "user_type":
widget.setCurrentIndex(
1 if str(cfg.default).lower() == "advanced" else 0
@@ -575,7 +656,7 @@ class Plugin(BalPlugin):
btn.clicked.connect(reset)
return btn
heir_repush = QPushButton("Rebroadcast transactions")
heir_repush = QPushButton(_("Rebroadcast transactions"))
heir_repush.clicked.connect(partial(self.broadcast_transactions, True))
bal_mode = QComboBox()
options = ["Easy", "Advanced", "Experimental"]
@@ -601,27 +682,27 @@ class Plugin(BalPlugin):
# advanced-only settings, so the user picks basic/advanced first.
add_widget(
grid,
"Hide Replaced",
_("Hide Replaced"),
heir_hide_replaced,
0,
"Hide replaced transactions from will detail and list",
_("Hide replaced transactions from will detail and list"),
)
grid.addWidget(_make_reset_btn(self.HIDE_REPLACED, heir_hide_replaced, "check"), 0, 3)
add_widget(
grid,
"Hide Invalidated",
_("Hide Invalidated"),
heir_hide_invalidated,
1,
"Hide invalidated transactions from will detail and list",
_("Hide invalidated transactions from will detail and list"),
)
grid.addWidget(_make_reset_btn(self.HIDE_INVALIDATED, heir_hide_invalidated, "check"), 1, 3)
lbl_auto_sign = QLabel(_("Auto-sign on Check"))
help_auto_sign = HelpButton(
help_auto_sign = HelpButton(_(
"When checking, automatically sign and broadcast the will "
"transactions to their will-executors.\n"
"The wallet password is requested only if the wallet is "
"encrypted."
)
))
grid.addWidget(_hide_if_basic(lbl_auto_sign), 2, 0)
grid.addWidget(_hide_if_basic(heir_auto_sign), 2, 1)
grid.addWidget(_hide_if_basic(help_auto_sign), 2, 2)
@@ -631,10 +712,10 @@ class Plugin(BalPlugin):
grid.addWidget(_hide_if_basic(reset_btn_auto_sign), 2, 3)
add_widget(
grid,
"Panel editable Date and Fee",
_("Panel editable Date and Fee"),
heir_editable_dates,
3,
(
_(
"When enabled, the delivery-time and check-alive date fields "
"can be edited everywhere (toolbar / Heirs tab), not only in "
"the will-building wizard.\n"
@@ -642,51 +723,29 @@ class Plugin(BalPlugin):
),
)
grid.addWidget(_make_reset_btn(self.EDITABLE_DATES, heir_editable_dates, "check"), 3, 3)
# "Add transaction without will-executor" setting (formerly labelled
# "No will-executor TX"). When ON the plugin ALSO builds the backup
# inheritance transaction that does NOT require a will-executor (the
# "celeste"/light-blue one shown in the will list): it can be saved on a
# USB stick and a copy handed to the heirs. When OFF only the
# transactions destined to the selected will-executors are built.
#
# Placed here (row 5, right below "Panel editable Date and Fee" and above
# "Number of reminders") at the user's request so related options sit
# together. The remaining grid rows below were renumbered accordingly.
add_widget(
grid,
"Add transaction without willexecutor",
heir_no_willexecutor,
4,
(
"Create a will that does not require a Will-executor; it can be "
"saved, for example, on a USB stick, and a copy can be given to "
"the heirs."
),
)
grid.addWidget(_make_reset_btn(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"), 4, 3)
# Max willexecutor fee: maximum fee (in satoshi) allowed for a single
# will-executor. Visible to all users (BASIC and ADVANCED).
add_widget(
grid,
"Max Will-Executor Fee (satoshi)",
_("Max Will-Executor Fee (satoshi)"),
heir_max_willexecutor_fee,
5,
(
4,
_(
"Maximum fee (in satoshi) allowed to be paid to a single "
"will-executor. If a will-executor charges more than this, "
"the will will not be built.\n"
"Default: 500,000 satoshi (0.005 BTC)."
),
)
grid.addWidget(_make_reset_btn(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"), 5, 3)
grid.addWidget(_make_reset_btn(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"), 4, 3)
# User Type selector placed BEFORE the advanced-only settings so the
# user chooses basic/advanced first, then sees the relevant options.
add_widget(
grid,
"User Type",
_("User Type"),
user_type_combo,
6,
(
5,
_(
"Choose how much detail the plugin shows.\n\n"
"BASIC: simplified interface, safe configuration for most "
"users.\n\n"
@@ -696,103 +755,103 @@ class Plugin(BalPlugin):
"editable."
),
)
grid.addWidget(_make_reset_btn(self.USER_TYPE, user_type_combo, "user_type"), 6, 3)
grid.addWidget(_make_reset_btn(self.USER_TYPE, user_type_combo, "user_type"), 5, 3)
# Number of reminders, event summary and event description are visible
# only in ADVANCED mode. In BASIC mode the factory defaults are always
# used and these settings are hidden.
lbl_num_reminders = QLabel(_("Number of reminders"))
help_num_reminders = HelpButton(
help_num_reminders = HelpButton(_(
"How many reminder alarms the exported calendar (.ics) event "
"contains. Range: 1 to 5 (default 3). Only used in ADVANCED mode."
)
grid.addWidget(_hide_if_basic(lbl_num_reminders), 7, 0)
grid.addWidget(_hide_if_basic(heir_num_reminders), 7, 1)
grid.addWidget(_hide_if_basic(help_num_reminders), 7, 2)
))
grid.addWidget(_hide_if_basic(lbl_num_reminders), 6, 0)
grid.addWidget(_hide_if_basic(heir_num_reminders), 6, 1)
grid.addWidget(_hide_if_basic(help_num_reminders), 6, 2)
reset_btn_6 = _make_reset_btn(self.NUM_REMINDERS, heir_num_reminders, "spin")
grid.addWidget(_hide_if_basic(reset_btn_6), 7, 3)
grid.addWidget(_hide_if_basic(reset_btn_6), 6, 3)
lbl_event_summary = QLabel(_("Event summary"))
help_event_summary = HelpButton(
help_event_summary = HelpButton(_(
"Default message to be used in event summary\n"
"Variables:\n"
" $wallet_name: name of wallet\n"
" $heirs_complete: list of heirs name,address,amount\n"
"Only used in ADVANCED mode."
)
grid.addWidget(_hide_if_basic(lbl_event_summary), 8, 0)
grid.addWidget(_hide_if_basic(edit_event_summary), 8, 1)
grid.addWidget(_hide_if_basic(help_event_summary), 8, 2)
))
grid.addWidget(_hide_if_basic(lbl_event_summary), 7, 0)
grid.addWidget(_hide_if_basic(edit_event_summary), 7, 1)
grid.addWidget(_hide_if_basic(help_event_summary), 7, 2)
reset_btn_7 = _make_reset_btn(self.EVENT_SUMMARY, edit_event_summary, "line")
grid.addWidget(_hide_if_basic(reset_btn_7), 8, 3)
grid.addWidget(_hide_if_basic(reset_btn_7), 7, 3)
lbl_event_description = QLabel(_("Event description"))
help_event_description = HelpButton(
help_event_description = HelpButton(_(
"Default message to be used in event description\n"
"Variables:\n"
" $wallet_name: name of wallet\n"
" $heirs_complete: list of heirs name,address,amount\n"
"Only used in ADVANCED mode."
)
grid.addWidget(_hide_if_basic(lbl_event_description), 9, 0)
grid.addWidget(_hide_if_basic(edit_event_description), 9, 1)
grid.addWidget(_hide_if_basic(help_event_description), 9, 2)
))
grid.addWidget(_hide_if_basic(lbl_event_description), 8, 0)
grid.addWidget(_hide_if_basic(edit_event_description), 8, 1)
grid.addWidget(_hide_if_basic(help_event_description), 8, 2)
reset_btn_8 = _make_reset_btn(self.EVENT_DESCRIPTION, edit_event_description, "text")
grid.addWidget(_hide_if_basic(reset_btn_8), 9, 3)
grid.addWidget(_hide_if_basic(reset_btn_8), 8, 3)
# Welist server URL: shown only in ADVANCED mode. In BASIC mode the
# factory default is always used and the setting is hidden.
lbl_welist_server = QLabel(_("Welist Server URL"))
help_welist_server = HelpButton(
help_welist_server = HelpButton(_(
"URL of the server that provides the will-executor list. "
"Only available in ADVANCED mode."
)
grid.addWidget(_hide_if_basic(lbl_welist_server), 10, 0)
grid.addWidget(_hide_if_basic(edit_welist_server), 10, 1)
grid.addWidget(_hide_if_basic(help_welist_server), 10, 2)
))
grid.addWidget(_hide_if_basic(lbl_welist_server), 9, 0)
grid.addWidget(_hide_if_basic(edit_welist_server), 9, 1)
grid.addWidget(_hide_if_basic(help_welist_server), 9, 2)
reset_btn_9 = _make_reset_btn(self.WELIST_SERVER, edit_welist_server, "line")
grid.addWidget(_hide_if_basic(reset_btn_9), 10, 3)
grid.addWidget(_hide_if_basic(reset_btn_9), 9, 3)
lbl_calendar_app = QLabel(_("Calendar app command"))
help_calendar_app = HelpButton(
help_calendar_app = HelpButton(_(
"Command used to open .ics calendar files.\n"
"Leave empty to use the system default (xdg-open/open/start).\n"
"Only used in ADVANCED mode."
)
grid.addWidget(_hide_if_basic(lbl_calendar_app), 11, 0)
grid.addWidget(_hide_if_basic(edit_calendar_app), 11, 1)
grid.addWidget(_hide_if_basic(help_calendar_app), 11, 2)
))
grid.addWidget(_hide_if_basic(lbl_calendar_app), 10, 0)
grid.addWidget(_hide_if_basic(edit_calendar_app), 10, 1)
grid.addWidget(_hide_if_basic(help_calendar_app), 10, 2)
reset_btn_10 = _make_reset_btn(self.CALENDAR_APP, edit_calendar_app, "line")
grid.addWidget(_hide_if_basic(reset_btn_10), 11, 3)
grid.addWidget(_hide_if_basic(reset_btn_10), 10, 3)
# Save-in-history toggle and history label: advanced-only rows. The
# label field is disabled while the checkbox is off (see
# on_save_history_change above).
lbl_save_history = QLabel(_("Save inheritance transactions in history"))
help_save_history = HelpButton(
help_save_history = HelpButton(_(
"After each check, save the valid will transactions into the "
"wallet's local history (the History tab), each with a label.\n"
"The label may contain the variable:\n"
" {willexecutor}: replaced with the will-executor URL of the item\n"
"Only used in ADVANCED mode."
)
grid.addWidget(_hide_if_basic(lbl_save_history), 12, 0)
grid.addWidget(_hide_if_basic(heir_save_history), 12, 1)
grid.addWidget(_hide_if_basic(help_save_history), 12, 2)
))
grid.addWidget(_hide_if_basic(lbl_save_history), 11, 0)
grid.addWidget(_hide_if_basic(heir_save_history), 11, 1)
grid.addWidget(_hide_if_basic(help_save_history), 11, 2)
reset_btn_11 = _make_reset_btn(self.SAVE_HISTORY, heir_save_history, "check")
grid.addWidget(_hide_if_basic(reset_btn_11), 12, 3)
grid.addWidget(_hide_if_basic(reset_btn_11), 11, 3)
lbl_history_label = QLabel(_("History label"))
help_history_label = HelpButton(
help_history_label = HelpButton(_(
"Label applied to the will transactions saved into the wallet's "
"local history.\n"
"Variables:\n"
" {willexecutor}: replaced with the will-executor URL of the item\n"
"Only used in ADVANCED mode."
)
grid.addWidget(_hide_if_basic(lbl_history_label), 13, 0)
grid.addWidget(_hide_if_basic(edit_history_label), 13, 1)
grid.addWidget(_hide_if_basic(help_history_label), 13, 2)
))
grid.addWidget(_hide_if_basic(lbl_history_label), 12, 0)
grid.addWidget(_hide_if_basic(edit_history_label), 12, 1)
grid.addWidget(_hide_if_basic(help_history_label), 12, 2)
reset_btn_12 = _make_reset_btn(self.HISTORY_LABEL, edit_history_label, "line")
grid.addWidget(_hide_if_basic(reset_btn_12), 13, 3)
grid.addWidget(_hide_if_basic(reset_btn_12), 12, 3)
# NOTE: the ADVANCED-only widgets above have ALREADY been given their
# correct initial visibility inline (via _hide_if_basic) BEFORE being
@@ -801,15 +860,57 @@ class Plugin(BalPlugin):
# the Windows relayout flicker. Do NOT reintroduce a post-hoc
# setVisible() loop here.
grid.addWidget(heir_repush, 14, 0)
grid.addWidget(heir_repush, 13, 0)
grid.addWidget(
HelpButton(
"Broadcast all transactions to willexecutors including those already pushed"
_("Broadcast all transactions to willexecutors including those already pushed")
),
14,
13,
2,
)
# "Rebuild will on wallet close" row (always visible, BASIC + ADVANCED).
# Placed below the rebroadcast button so the existing rows keep their
# numbers.
lbl_rebuild_on_close = QLabel(_("Rebuild will on wallet close"))
help_rebuild_on_close = HelpButton(_(
"Run the 'Build your will' wizard every time the wallet is closed "
"or Electrum is quit, so the will is rebuilt and re-validated.\n"
"When disabled, the will is only rebuilt when you press Check or "
"Prepare. The last built state is still saved to the wallet."
))
grid.addWidget(lbl_rebuild_on_close, 14, 0)
grid.addWidget(heir_rebuild_on_close, 14, 1)
grid.addWidget(help_rebuild_on_close, 14, 2)
reset_btn_rebuild_on_close = _make_reset_btn(
self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"
)
grid.addWidget(reset_btn_rebuild_on_close, 14, 3)
# "Rebuild automatically on new transactions" row (always visible,
# BASIC + ADVANCED), right below the "Rebuild will on wallet close"
# row.
lbl_auto_rebuild = QLabel(_("Rebuild automatically on new transactions"))
help_auto_rebuild = HelpButton(_(
"When a new transaction arrives for the wallet, automatically "
"rebuild the will the same way the wizard does at wallet close: "
"the delivery date is anticipated by one day so the new will "
"replaces the previous one, and the rebuilt transactions are "
"signed and sent to their will-executors.\n"
"An on-chain invalidation transaction is only built when the "
"anticipated delivery date would fall before the Check Alive "
"threshold, or when the threshold is already in the past.\n"
"When disabled (default), the will is only rebuilt on Check / "
"Prepare / wallet close."
))
grid.addWidget(lbl_auto_rebuild, 15, 0)
grid.addWidget(heir_auto_rebuild, 15, 1)
grid.addWidget(help_auto_rebuild, 15, 2)
reset_btn_auto_rebuild = _make_reset_btn(
self.AUTO_REBUILD, heir_auto_rebuild, "check"
)
grid.addWidget(reset_btn_auto_rebuild, 15, 3)
# ----------------------------------------------------------------- #
# Group C / C4b: "Reset" button that restores the dialog settings to #
# their factory defaults. It only resets the settings exposed by THIS #
@@ -834,7 +935,6 @@ class Plugin(BalPlugin):
(self.AUTO_SIGN, heir_auto_sign, "check"),
(self.EDITABLE_DATES, heir_editable_dates, "check"),
(self.NUM_REMINDERS, heir_num_reminders, "spin"),
(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"),
(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"),
(self.EVENT_SUMMARY, edit_event_summary, "line"),
(self.EVENT_DESCRIPTION, edit_event_description, "text"),
@@ -842,6 +942,8 @@ class Plugin(BalPlugin):
(self.CALENDAR_APP, edit_calendar_app, "line"),
(self.SAVE_HISTORY, heir_save_history, "check"),
(self.HISTORY_LABEL, edit_history_label, "line"),
(self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"),
(self.AUTO_REBUILD, heir_auto_rebuild, "check"),
]
for cfg, widget, kind in resets:
# Persist the default value back into the Electrum config.
@@ -854,9 +956,9 @@ class Plugin(BalPlugin):
elif kind == "spin":
widget.setValue(int(cfg.default))
elif kind == "line":
widget.setText(cfg.default)
widget.setText(cfg.localized_default())
elif kind == "text":
widget.setPlainText(cfg.default)
widget.setPlainText(cfg.localized_default())
elif kind == "user_type":
# Default is "basic" -> combo index 0; "advanced" -> index 1.
widget.setCurrentIndex(
@@ -891,7 +993,7 @@ class Plugin(BalPlugin):
bottom_row = QHBoxLayout()
bottom_row.addWidget(btn_reset)
bottom_row.addStretch(1)
bottom_row.addWidget(QLabel("<b>" + _("Support:") + "</b>"))
bottom_row.addWidget(QLabel("<b>{}</b>".format(_("Support:"))))
bottom_row.addWidget(lbl_support)
# Outer layout: warning (top) -> settings grid -> bottom button row.
@@ -943,6 +1045,11 @@ class Plugin(BalPlugin):
)
def get_window_title(self, title):
return _("BAL - ") + _(title)
"""Return the window title "BAL - <title>".
``title`` must already be translated by the caller: a variable passed
to _() cannot be extracted into the catalog.
"""
return _("BAL - {}").format(title)

View File

@@ -87,7 +87,7 @@ def server_status_text(will_item) -> str:
the user always knows whether each inheritance transaction is actually
stored on the will-executor servers, regardless of the row colour.
"""
from electrum.i18n import _
from ...i18n import _
if will_item.get_status("CHECK_FAIL") and not will_item.get_status("CHECKED"):
return _("Not on server")
@@ -105,7 +105,7 @@ def server_status_text(will_item) -> str:
def server_status_tooltip(will_item) -> str:
"""Return a detailed tooltip for the "Server" column, including the
will-executor URL (if any) and the current server state."""
from electrum.i18n import _
from ...i18n import _
url = None
we = getattr(will_item, "we", None)

View File

@@ -20,6 +20,8 @@ Contents:
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 (
LockTimeEditor,
normalize_locktime_raw_text,
@@ -28,8 +30,55 @@ from ...core.input_rules import (
)
from ...core.reminders import build_ics_reminders, write_temp_ics
from .calendar import BalCalendar, BalCalendarButton
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from .common import (
DECIMAL_POINT,
N_,
NLOCKTIME_BLOCKHEIGHT_MAX,
NLOCKTIME_MAX,
Any,
BalTimestamp,
BTCAmountEdit,
ColorScheme,
Decimal,
HelpButton,
Optional,
QAbstractSpinBox,
QCheckBox,
QColor,
QComboBox,
QDateTime,
QDateTimeEdit,
QHBoxLayout,
QLabel,
QLineEdit,
QPainter,
QPalette,
QPushButton,
QSizePolicy,
QSpinBox,
QStyle,
QStyleOptionFrame,
Qt,
QTextEdit,
QVBoxLayout,
QWidget,
Union,
Util,
Will,
_,
_logger,
char_width_in_lineedit,
datetime,
format_status_history,
getSaveFileName,
log_error,
os,
partial,
pyqtSignal,
read_QIcon_from_bytes,
signature_suffix,
status_color,
)
if TYPE_CHECKING:
from .window import BalWindow
@@ -161,14 +210,18 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
current_index = None
default_value = None
help_text = (
help_text = N_(
"if you choose Raw, you can insert various options based on suffix:\n"
+ " - d: number of days after current day(ex: 1d means tomorrow)\n"
+ " - y: number of years after currrent day(ex: 1y means one year from today)\n"
" - d: number of days after current day(ex: 1d means tomorrow)\n"
" - y: number of years after currrent day(ex: 1y means one year from today)\n"
)
label_text = None
tooltip_text = None
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):
super().__init__(parent)
@@ -179,7 +232,9 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
hbox.setContentsMargins(0, 0, 0, 0)
hbox.setSpacing(0)
self.setMinimumWidth(40 * char_width_in_lineedit())
self.locktime_raw_e = TimeRawEditWidget(self, time_edit=self)
self.locktime_raw_e = TimeRawEditWidget(
self, time_edit=self, allow_negative=self.allow_negative_raw
)
self.locktime_date_e = LockTimeDateEdit(self, time_edit=self)
self.editors = [self.locktime_raw_e, self.locktime_date_e]
self.combo = QComboBox()
@@ -230,7 +285,9 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
]
)
#hbox.addWidget(QLabel(self.label_text))
help_button=HelpButton(self.help_text)
# help_text and tooltip_text are class attributes marked with N_():
# translate them here, when shown.
help_button=HelpButton(_(self.help_text))
help_button.setText(self.label_text)
# Show a short label (e.g. "Delivery time" / "Check Alive") when the
# user hovers the icon, so the emoji button is self-explanatory.
@@ -432,9 +489,9 @@ class TimeRawEditWidget(QWidget):
def is_acceptable_locktime(self, value):
return True
def __init__(self, parent, time_edit=None):
def __init__(self, parent, time_edit=None, allow_negative=False):
super().__init__(parent)
self.editor = LockTimeRawEdit(parent, time_edit)
self.editor = LockTimeRawEdit(parent, time_edit, allow_negative=allow_negative)
self.label = QLabel("")
# 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
@@ -460,7 +517,7 @@ class TimeRawEditWidget(QWidget):
class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
def __init__(self, parent=None, time_edit=None):
def __init__(self, parent=None, time_edit=None, allow_negative=False):
QLineEdit.__init__(self, parent)
# 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"
@@ -470,16 +527,22 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
self.textChanged.connect(self.numbify)
self.isdays = 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
def numbify(self):
# Only digits plus the day ("d") and year ("y") suffixes are accepted.
# The block-height suffix ("b") was removed (A1): locktimes are always
# UNIX timestamps now, so block-relative input is no longer allowed.
# Only digits plus the day ("d") and year ("y") suffixes are accepted
# (and, for the Check Alive field, a single leading "-"). The
# block-height suffix ("b") was removed (A1): locktimes are always UNIX
# timestamps now, so block-relative input is no longer allowed.
# The sanitisation itself lives in bal.core.input_rules.
text = self.text().strip()
pos = self.cursorPosition()
s, isdays, isyears, pos = normalize_locktime_raw_text(text, pos)
s, isdays, isyears, pos = normalize_locktime_raw_text(
text, pos, allow_negative=self.allow_negative
)
self.isdays = isdays
self.isyears = isyears
self.blockSignals(True)
@@ -566,15 +629,20 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
# Use the overflow-safe converter: on Windows datetime.fromtimestamp
# raises OverflowError for timestamps past 2038 (e.g. NLOCKTIME_MAX).
_dt = BalTimestamp._safe_fromtimestamp(x)
#if self.alarm != dt:
self.setDateTime(_dt)
self.alarm = _dt
# Store the LOCAL wall-clock time, not the aware-UTC datetime:
# QDateTimeEdit keeps the given wall time with a LocalTime spec, so
# an aware-UTC datetime would make get_value() read back a timezone-
# shifted epoch. That broke the set_value -> get_value roundtrip and
# kept the valueEdited -> update_setting_widgets -> set_value cycle
# firing forever (infinite RecursionError on wizard "Next").
self.setDateTime(_dt.astimezone().replace(tzinfo=None))
class ThresholdTimeWidget(BalTimeEditWidget):
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
help_text = (
help_text = N_(
"<b>CHECK ALIVE</b><br><br>"
"In DATA mode:<br>"
"set the date for the \u201ccheck alive\u201d parameter.<br>"
@@ -588,13 +656,20 @@ class ThresholdTimeWidget(BalTimeEditWidget):
"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>"
" - 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 "
"<b>Local time</b> (not UTC time).<br>"
)
label_text = "🚨"
#label_text = "Check Alive"
tooltip_text = "Check Alive"
tooltip_text = N_("Check Alive")
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):
if init_value is None:
@@ -608,7 +683,7 @@ class ThresholdTimeWidget(BalTimeEditWidget):
class LockTimeWidget(BalTimeEditWidget):
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
help_text = (
help_text = N_(
"<b>DELIVERY TIME</b><br><br>"
"Set Locktime for transactions.<br>"
"Any time is needed transaction will be anticipated by 1day<br><br>"
@@ -626,7 +701,7 @@ class LockTimeWidget(BalTimeEditWidget):
#label_text = "Locktime"
# Hover tooltip for the delivery-time icon; mirrors the style of the fee
# icon tooltip ("..., click for more information") so the two are consistent.
tooltip_text = "Delivery Time, click for more information"
tooltip_text = N_("Delivery Time, click for more information")
base_field = "locktime"
def __init__(self, bal_window, parent, init_value=None):
@@ -1037,9 +1112,12 @@ class WillSettingsWidget(QWidget):
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
if basic_mode:
# BASIC mode: use factory defaults (the hidden settings are
# ignored) and the fixed 30/10/1 offsets.
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
# ignored) and the fixed 30/10/1 offsets. The defaults are in
# the GUI language (see BalConfig).
raw_description = (
self.bal_window.bal_plugin.EVENT_DESCRIPTION.localized_default()
)
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.localized_default()
threshold = None
num_reminders = 3
else:
@@ -1069,6 +1147,7 @@ class WillSettingsWidget(QWidget):
version=self.bal_window.bal_plugin.version,
num_reminders=num_reminders,
threshold=threshold,
reminder_suffix=_("(reminder {idx}/{total})"),
)
except Exception as e:
_logger.error(f"failed to generate .ics: {e}")
@@ -1076,18 +1155,23 @@ class WillSettingsWidget(QWidget):
def on_locktime_change(self):
locktime = self.widgets["locktime"].get_value()
threshold = self.widgets["threshold"].get_value()
threshold_raw = self.widgets["threshold"].get_value()
locktime = BalTimestamp(locktime)
threshold = BalTimestamp(threshold)
min_locktime = min(
Will.get_min_locktime(self.bal_window.willitems, NLOCKTIME_MAX),
locktime.to_timestamp(),
)
# Resolved Check Alive date. For relative values this counts BACKWARDS
# from the delivery time (reverse=True): "90d" means "90 days BEFORE the
# delivery", not "90 days from now".
td = threshold.to_date(min_locktime, True)
# Resolved Check Alive date. The SIGN of the RAW value selects the
# anchor: negative counts backwards from the delivery, positive counts
# forward from the inheritance creation date. The rule lives in
# ``bal.core.checkalive`` so this display matches every validity check.
resolved = resolve_relative_check_alive_date(
threshold_raw,
delivery_date=min_locktime,
creation_date=Will.get_creation_time(self.bal_window.willitems),
)
td = BalTimestamp._safe_fromtimestamp(resolved)
self.widgets["threshold"].alarm=td
self.bal_window.will_settings["real_threshold"]=td.timestamp()
try:
@@ -1148,7 +1232,7 @@ class PercAmountEdit(BTCAmountEdit):
painter.drawText(
text_rect,
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
self.base_unit() + " or perc value",
_("{} or perc value").format(self.base_unit()),
)
@@ -1266,11 +1350,12 @@ class WillWidget(QWidget):
creation = str(BalTimestamp(self.will[w].time))
def qlabel(title, value):
label = "<b>" + _(str(title)) + f":</b>\t{str(value)}"
return QLabel(label)
# ``title`` is shown as it is: callers pass a translated
# label, or data (an heir name, a will-executor URL).
return QLabel("<b>{}:</b>\t{}".format(title, value))
detaillayout.addWidget(qlabel("Locktime", locktime))
detaillayout.addWidget(qlabel("Creation Time", creation))
detaillayout.addWidget(qlabel(_("Locktime"), locktime))
detaillayout.addWidget(qlabel(_("Creation Time"), creation))
try:
total_fees = (
self.will[w].tx.input_value() - self.will[w].tx.output_value()
@@ -1280,25 +1365,46 @@ class WillWidget(QWidget):
decoded_fees = total_fees
fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3)
fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)"
detaillayout.addWidget(qlabel("Transaction fees:", fees_str))
detaillayout.addWidget(qlabel(_("Transaction fees"), fees_str))
# The saved status history is English: translate it for display.
detaillayout.addWidget(
qlabel("Status:", self.will[w].status + signature_suffix(self.will[w]))
qlabel(
_("Status"),
format_status_history(self.will[w].status)
+ signature_suffix(self.will[w]),
)
)
detaillayout.addWidget(QLabel(""))
detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
for heir in self.will[w].heirs:
if 'w!ll3x3c"' not in heir:
decoded_amount = Util.decode_amount(
self.will[w].heirs[heir][3], self._bal_parent.decimal_point
)
detaillayout.addWidget(QLabel("<b>{}</b>".format(_("Heirs:"))))
for heir_name in self.will[w].heirs:
if 'w!ll3x3c"' in heir_name:
continue
h = self.will[w].heirs[heir_name]
decoded_amount = Util.decode_amount(
h[3], self._bal_parent.decimal_point
)
if is_op_return_address(h[0]):
data_hex = get_op_return_hex(h[0]) or ""
try:
decoded = bytes.fromhex(data_hex).decode(
"utf-8", errors="replace"
)
except Exception:
decoded = h[0]
detaillayout.addWidget(qlabel(heir_name, "OP_RETURN: " + decoded))
else:
detaillayout.addWidget(
qlabel(
heir, f"{decoded_amount} {self._bal_parent.base_unit_name}"
heir_name,
f"{decoded_amount} {self._bal_parent.base_unit_name} "
f"[{h[0]}]",
)
)
if self.will[w].we:
detaillayout.addWidget(QLabel(""))
detaillayout.addWidget(QLabel(_("<b>Willexecutor:</b:")))
detaillayout.addWidget(
QLabel("<b>{}</b>".format(_("Willexecutor:")))
)
decoded_amount = Util.decode_amount(
self.will[w].we["base_fee"], self._bal_parent.decimal_point
)
@@ -1309,6 +1415,10 @@ class WillWidget(QWidget):
f"{decoded_amount} {self._bal_parent.base_unit_name}",
)
)
if self.will[w].we.get("address"):
detaillayout.addWidget(
qlabel(_("Address"), self.will[w].we["address"])
)
detaillayout.addStretch()
pal = QPalette()
pal.setColor(

File diff suppressed because it is too large Load Diff

198
bal/i18n.py Normal file
View File

@@ -0,0 +1,198 @@
"""
bal.i18n
========
Translation layer of the BAL plugin (gettext domain ``bal``).
Why BAL has its own catalog
---------------------------
Electrum translates its interface with gettext (domain ``electrum``). Its
translators only see Electrum's own source code, so the texts of an external
(zip) plugin such as BAL are not in Electrum's catalog, and Electrum offers no
way to translate external plugins. BAL therefore ships its own compiled
catalogs inside the plugin: ``locale/<lang>/LC_MESSAGES/bal.mo``.
Lookup order
------------
:func:`_` returns the first translation found in:
1. **Electrum's catalog.** When Electrum already translates the exact same
English text, its translation wins. BAL then reads like Electrum and
follows Electrum's own translation fixes; if Electrum ever ships BAL as an
internal plugin, Electrum's translations take over with no change here
(owner's decision D7 in ``PLAN_I18N.md``).
2. **BAL's catalog**, for the texts that only BAL has.
3. **The English source text.**
Language
--------
Electrum picks the GUI language once, at start-up (``run_electrum``), and a
change needs a restart. :func:`init_from_config` repeats Electrum's choice
once, when the Qt plugin is created. BAL never detects the language by
itself: otherwise part of a window could be in one language and part in
another. The CLI never calls it, so the CLI stays in English, like Electrum's.
Rules for translatable texts
----------------------------
* Wrap literal English text only: ``_("Sign the will")``.
* Put the variable parts in ``{}`` and fill them *after* translating:
``_("{} heirs").format(count)``. Never build the text with an f-string or
``%`` inside ``_()``: it would change at run time and never match a catalog
entry (Ruff rules INT001-INT003 catch this).
* Texts evaluated at import time (class attributes, tables, constants) are
marked with :func:`N_` and passed to ``_()`` when they are displayed. Every
class body runs before :func:`init_from_config`, so a ``_()`` there would
always return English.
* Never translate text that is stored or compared (wallet labels, the saved
status history, config values used to recognise BAL transactions): stored
data must not depend on the UI language.
"""
import gettext
import io
import string
from typing import Optional
from electrum.i18n import _ as _electrum_gettext
from electrum.logging import get_logger
_logger = get_logger(__name__)
#: gettext domain of the BAL catalogs.
DOMAIN = "bal"
# BAL catalog of the current language, or None (English, CLI, or no usable
# catalog). Set by set_language().
_catalog: Optional[gettext.GNUTranslations] = None
# Source texts whose BAL translation was rejected: the warning is logged once,
# not at every repaint of the widget that shows the text.
_rejected: set = set()
_formatter = string.Formatter()
def keeps_format_fields(msg: str, translation: str) -> bool:
"""Tell whether ``translation`` keeps the ``{}`` fields of ``msg``.
A translation that adds, drops or renames a replacement field would make
the ``.format()`` call that follows raise, or put a value in the wrong
place, so such a translation must not be used.
The rules are copied from Electrum's
``_ensure_translation_keeps_format_string_syntax_similar``
(``electrum/i18n.py``, MIT licence, Copyright (C) The Electrum developers)
rather than importing that private function, which may change without
notice. Keep them identical, so a BAL translation is accepted or rejected
exactly like an Electrum one. The only addition: a malformed source text
returns False here instead of raising.
"""
try:
# Tuples of (literal_text, field_name, format_spec, conversion).
parsed1 = list(_formatter.parse(msg))
parsed2 = list(_formatter.parse(translation))
except ValueError: # malformed format string
return False
if len(parsed1) != len(parsed2):
return False
# The set of field names must not change (re-ordering them is allowed).
return {t[1] for t in parsed1} == {t[1] for t in parsed2}
def _(msg: str) -> str:
"""Return ``msg`` translated into the GUI language.
See the module docstring for the lookup order. ``_("")`` returns ``""``:
for gettext the empty text is the key of the catalog header.
"""
if msg == "":
return ""
translation = _electrum_gettext(msg)
if translation != msg or _catalog is None:
# Electrum's translation wins (Electrum has already checked its {}
# fields); without a BAL catalog there is nothing else to look up.
return translation
translation = _catalog.gettext(msg)
if translation == msg or keeps_format_fields(msg, translation):
return translation
if msg not in _rejected:
_rejected.add(msg)
_logger.warning(
f"rejected BAL translation, replacement fields differ: "
f"{msg!r} -> {translation!r}"
)
return msg
def N_(msg: str) -> str: # noqa: N802 (the conventional gettext marker name)
"""Mark ``msg`` as translatable and return it unchanged.
Used where a text is defined at import time (class attributes, tables,
constants). Extraction tools (Babel, xgettext) collect ``N_("...")`` like
``_("...")``; the code passes the value to ``_()`` when it shows it.
"""
return msg
def set_language(plugin, lang: Optional[str]) -> None:
"""Load the BAL catalog of ``lang`` (for example ``"it_IT"``), or none.
``None``, ``""`` and English (``"en_*"``) load no catalog, so the English
source texts are shown, as Electrum does for ``en_*``.
The catalog is read with ``plugin.read_file()``, the Electrum API BAL uses
for its icons: it works both from the installed zip and from a development
checkout. ``gettext.translation(localedir=...)`` is not used because,
pointed inside a zip, it silently finds nothing.
If ``locale/<lang>/`` has no catalog, the plain language code is tried
(``locale/it/`` for ``it_IT``). A missing or unreadable catalog is logged
and BAL stays in English: a translation problem must never stop the
plugin.
"""
global _catalog
_catalog = None
_rejected.clear()
if not isinstance(lang, str) or not lang or lang.startswith("en_"):
return
codes = [lang]
if "_" in lang:
codes.append(lang.split("_", 1)[0])
for code in codes:
path = "locale/{}/LC_MESSAGES/{}.mo".format(code, DOMAIN)
try:
data = plugin.read_file(path)
except Exception:
# Not shipped for this code (a zip raises KeyError, a development
# folder raises OSError): try the next code.
continue
try:
_catalog = gettext.GNUTranslations(io.BytesIO(data))
except Exception as e:
_logger.info(f"unreadable BAL catalog {path}, using English: {e!r}")
return
_logger.info(f"BAL catalog loaded: {path}")
return
_logger.info(f"no BAL catalog for {lang!r}, using English")
def init_from_config(plugin, config) -> None:
"""Load the BAL catalog of the language Electrum's Qt GUI is using.
Repeats Electrum's own choice in ``run_electrum``: the ``language``
setting (Preferences > Language) or, when it is empty, the system language
if Electrum supports it, else English. Called once, from the Qt plugin's
``__init__``. Never raises.
"""
try:
lang = config.LOCALIZATION_LANGUAGE
if not lang:
from electrum.gui.default_lang import get_default_language
lang = get_default_language(gui_name="qt")
except Exception as e:
# Electrum also falls back to English when it cannot read the
# system language.
_logger.info(f"could not read the GUI language, using English: {e!r}")
lang = None
set_language(plugin, lang)

1819
bal/locale/bal.pot Normal file

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

@@ -1,12 +1,13 @@
{
"name": "bal",
"fullname": "Bitcoin After Life",
"version": "0.6.1",
"version": "0.7.0",
"description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.",
"author": "Svatantrya",
"licence": "MIT",
"available_for": [
"qt"
"qt",
"cmdline"
],
"icon": "icons/bal32x32.png"
}

View File

@@ -9,7 +9,12 @@ Electrum loads external plugins from a ``.zip`` using Python's ``zipimport``.
* uses standard DEFLATE compression (well supported by ``zipimport``);
* emits entries in a deterministic, sorted order so the archive is
reproducible (stable SHA-256);
* skips ``__pycache__`` directories and compiled ``*.pyc``/``*.pyo`` files.
* skips ``__pycache__`` directories and compiled ``*.pyc``/``*.pyo`` files;
* compiles each translation catalog ``bal/locale/<lang>/LC_MESSAGES/bal.po``
into ``bal.mo`` inside the archive (the ``.mo`` files are not kept in
git, and the ``.po``/``.pot`` sources are not shipped). This needs Babel
(``pip install babel``); without it the build stops, so a release can
never ship without its translations by mistake.
The archive keeps the top-level ``bal/`` directory so that the package is
importable as ``bal`` (and Electrum derives ``dirname='bal'`` from the path of
@@ -23,14 +28,36 @@ Prints the resulting size and SHA-256 so the download can be integrity-checked.
"""
import hashlib
import io
import os
import sys
import time
import zipfile
SRC_ROOT = "bal"
DEFAULT_OUT = "bal-electrum-plugin.zip"
def compile_catalog(po_path: str) -> bytes:
"""Return the compiled ``.mo`` bytes of the gettext catalog ``po_path``.
Fuzzy (unreviewed) entries are left out, as ``msgfmt`` does.
"""
try:
from babel.messages.mofile import write_mo
from babel.messages.pofile import read_po
except ImportError:
raise SystemExit(
"ERROR: Babel is needed to compile the translations "
"(pip install babel)"
) from None
with open(po_path, "rb") as f:
catalog = read_po(f)
buf = io.BytesIO()
write_mo(buf, catalog, use_fuzzy=False)
return buf.getvalue()
def build(out_path: str) -> None:
if os.path.exists(out_path):
os.remove(out_path)
@@ -40,17 +67,34 @@ def build(out_path: str) -> None:
# prune cache dirs in place so os.walk does not descend into them
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
for fn in filenames:
if fn.endswith((".pyc", ".pyo")):
# .mo files are rebuilt from the .po sources below; the .po/.pot
# sources themselves are not shipped.
if fn.endswith((".pyc", ".pyo", ".po", ".pot", ".mo")):
continue
files.append(os.path.join(dirpath, fn))
catalogs = {} # archive path of the .mo -> source .po
for dirpath, _dirnames, filenames in os.walk(os.path.join(SRC_ROOT, "locale")):
for fn in filenames:
if fn.endswith(".po"):
po = os.path.join(dirpath, fn)
catalogs[po[: -len(".po")] + ".mo"] = po
files.sort()
with zipfile.ZipFile(
out_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
) as z:
for f in files:
entries = [(f, None) for f in files] + [
(mo, po) for mo, po in catalogs.items()
]
for f, po in sorted(entries):
arc = f.replace(os.sep, "/") # forward slashes inside the archive
z.write(f, arc)
if po is None:
z.write(f, arc)
else:
# Same timestamp rule as z.write(): the source file's mtime.
info = zipfile.ZipInfo(arc, time.localtime(os.path.getmtime(po))[:6])
info.compress_type = zipfile.ZIP_DEFLATED
z.writestr(info, compile_catalog(po))
# Integrity + summary
with zipfile.ZipFile(out_path) as z:
@@ -63,7 +107,8 @@ def build(out_path: str) -> None:
data = open(out_path, "rb").read()
print(f"built : {out_path}")
print(f"files : {len(files)}")
print(f"files : {len(files) + len(catalogs)}")
print(f"langs : {', '.join(sorted(os.path.basename(os.path.dirname(os.path.dirname(m))) for m in catalogs)) or '-'}")
print(f"size : {len(data)} bytes")
print(f"sha256: {hashlib.sha256(data).hexdigest()}")

View File

@@ -308,7 +308,7 @@ executor that <em>should</em> hold your tx did not return it — re‑Broadcast
<li><strong>Mind the dust limit.</strong> A share below Bitcoin's dust limit is skipped; if <strong>every</strong> heir is dust the build is blocked with a clear message (§4.8) — raise the amounts or use fewer heirs.</li>
</ol>
<footer>This document reflects BAL plugin v0.4.7. Behaviour is derived directly from
<footer>This document reflects BAL plugin v0.7.0. Behaviour is derived directly from
<code>core/will.py</code>, <code>core/heirs.py</code> and <code>gui/qt/window.py</code>.</footer>
</div>

View File

@@ -32,6 +32,18 @@ you change anything in your will, BAL must decide between three outcomes:
The whole point of rule 3 is safety: **a will‑executor must never be able to
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)
@@ -164,6 +176,7 @@ exactly what the will‑executors hold — not the in‑memory copy.
| **Move date EARLIER, still in the future** (anticipate) | **any** (signed or not) | Plain **rebuild** with the new earlier locktime (`HeirNotFound` fall‑through) — **never** an on‑chain invalidation | **No** |
| **Move date EARLIER into the past** (new date already passed) | — | Will is genuinely **expired** → **invalidate** (`WillExpired`) | **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
> the *old* transaction with the *earlier* locktime. If you simply re‑signed a
@@ -357,5 +370,5 @@ that limit.
---
*This document reflects the current BAL plugin (v0.6.1). Behaviour is derived
*This document reflects the current BAL plugin (v0.7.0). Behaviour is derived
directly from `core/will.py`, `core/heirs.py` and `gui/qt/window.py`.*

View File

@@ -475,6 +475,27 @@ transactions can have in the WILL tab, on each will‑executor that is online.
> **NB:** When you close Electrum, the plugin automatically proceeds to execute
> **Prepare → Sign → Broadcast** (if they have not already been completed) to
> ensure the inheritance is correctly executed.
>
> Optionally, the **Rebuild on close** setting (available in **Tools → Plugins →
> BAL**, default OFF) skips the full wizard and runs a one-shot rebuild/sign/push
> flow when Electrum closes.
---
## Auto-rebuild on new transactions
> **NB:** this feature requires the **Auto-rebuild** setting to be enabled
> (available in **Tools → Plugins → BAL**, default OFF).
When the **Auto-rebuild** setting is enabled, the plugin automatically rebuilds
the will when new transactions are detected in the wallet (e.g. incoming
payments). The delivery date is anticipated by one day so the new will orphans
the old one on-chain without requiring a manual invalidation. An on-chain
invalidation is only needed when the anticipated locktime crosses the **Check
Alive** threshold (ADVANCED mode only).
This is useful for wallets that receive funds regularly: the inheritance stays
up-to-date without manual intervention.
---
@@ -527,6 +548,32 @@ value.
---
## Command-line / headless usage
BAL can also be used without the Qt GUI, via Electrum's daemon mode. This is
useful for scripting, automation, or running on a headless server.
**Prerequisites:** an Electrum daemon (`electrum daemon -d`) and a loaded wallet
(`electrum load_wallet`).
**Example:**
```bash
electrum daemon -d
electrum load_wallet
electrum bal_heirs_list
electrum bal_will_prepare
electrum bal_will_sign --password '...'
electrum bal_will_broadcast
electrum stop
```
All GUI operations (prepare, sign, broadcast, check, rebuild) are available as
`bal_*` commands. See the full command table in the
[README](../../README.md#command-line--headless-usage).
---
About installing a will‑executor server or collaboration, send your request to:
**info@bitcoin-after.life**

View File

@@ -5,7 +5,7 @@
# ./make-release.sh # read version from bal/manifest.json
# ./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
#
# Requires: git, gpg, curl, python3, sha256sum
# Requires: git, gpg, curl, python3, sha256sum, Babel for python3 (pip install babel)
# Optional: ruff (lint skipped if not installed)
# Credentials: ~/.git-credentials or GITEA_USER / GITEA_TOKEN env vars
@@ -33,6 +33,12 @@ else
NEW_VER=""
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 "Current manifest version: ${CURRENT_VER}"
[ -n "$NEW_VER" ] && echo "New version (will bump): ${NEW_VER}"
@@ -68,6 +74,8 @@ if QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.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
echo "All tests passed."
else

View File

@@ -3,12 +3,20 @@ line-length = 88
target-version = "py312"
[tool.ruff.lint]
select =["E", "W", "F", "I", "N", "B"]
select =["E", "W", "F", "I", "N", "B", "INT"] # INT: gettext rules (see bal/i18n.py)
ignore = ["E501"]
[tool.ruff.lint.isort]
# Classify the plugin package explicitly: otherwise ruff decides from where it
# is run / how the project root is detected, and the same import block is
# "sorted" on one machine and I001 on another.
known-first-party = ["bal"]
[tool.ruff.lint.pep8-naming]
classmethod-decorators = ["classmethod", "classproperty"] # electrum.util.classproperty uses cls
[tool.ruff.lint.per-file-ignores]
"bal/gui/qt/*.py" = ["F403", "F405"] # intentional `from .common import *` hub
"bal/gui/qt/common.py" = ["F401"] # re-exports consumed via `import *`
"bal/gui/qt/common.py" = ["F401"] # re-exports consumed via explicit imports
"bal/gui/qt/dialogs.py" = ["N802"] # Qt overrides: closeEvent/hideEvent/getText
"bal/gui/qt/lists.py" = ["N802"] # Qt overrides: createEditor/setEditorData/setModelData
"bal/gui/qt/widgets.py" = ["N802", "N815"] # Qt overrides + Qt signal attrs (valueChanged, ...)

18
tests/conftest.py Normal file
View File

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

11986
tests/karen7

File diff suppressed because one or more lines are too long

View File

@@ -21,12 +21,12 @@ Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
"""
import copy
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.util import copy_structure
from bal.core.will import (
HeirNotFoundException,
NoHeirsException,
@@ -58,7 +58,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
d = {
"tx": _VALID_TX_HEX,
"heirs": copy.deepcopy(heirs),
"heirs": copy_structure(heirs),
"willexecutor": None,
"status": "",
"description": "",
@@ -67,7 +67,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
"baltx_fees": TX_FEES,
}
item = WillItem(d, _id="willid_1")
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
# Force the locktime frozen "inside" the signed tx.
item.tx.locktime = tx_locktime
if status_complete:
@@ -118,7 +118,7 @@ def main():
# Scenario 0: nothing changed -> should be coherent.
heirs = {"alice": ["addr_alice", 5000, same_lt]}
_run("0. nothing changed",
will_heirs=heirs, current_heirs=copy.deepcopy(heirs),
will_heirs=heirs, current_heirs=copy_structure(heirs),
tx_locktime=base_lt, check_date=0)
# Scenario 1: delivery date moved forward (postpone), will NOT yet signed.

View File

@@ -27,7 +27,6 @@ Run:
tests/test_anticipate_manual_locktime.py -q
"""
import copy
import os
import sys
@@ -35,6 +34,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
import pytest # noqa: E402 # pyright: ignore[reportMissingImports]
from bal.core.util import copy_structure # noqa: E402
from bal.core.will import ( # noqa: E402
NotCompleteWillException,
Will,
@@ -70,7 +70,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
"""
d = {
"tx": _VALID_TX_HEX,
"heirs": copy.deepcopy(heirs),
"heirs": copy_structure(heirs),
"willexecutor": None,
"status": "",
"description": "",
@@ -79,7 +79,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
"baltx_fees": TX_FEES,
}
item = WillItem(d, _id="willid_1")
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
item.tx.locktime = tx_locktime
if status_complete:
item.set_status("COMPLETE", True)

View File

@@ -0,0 +1,570 @@
#!/usr/bin/env python3
"""Tests for the "Rebuild automatically on new transactions" (AUTO_REBUILD)
feature.
Covers:
* the persisted ``bal_auto_rebuild`` configuration key exists and defaults
to OFF (False), and can be enabled and read back;
* the event wiring: ``Plugin._wallet_activity`` schedules the rebuild only
for the matching wallet and only when the setting is enabled;
* ``BalWindow.schedule_auto_rebuild`` debounces through ``QTimer`` and the
re-entrancy / cooldown guards;
* ``BalWindow.maybe_auto_rebuild`` reproduces the wizard's close-time flow:
- no-op when the will is still valid;
- rebuild + sign + push when a new UTXO invalidates the will (no on-chain
invalidation, the rebuilt tx is anticipated to mine before the old);
- on-chain invalidation when the check-alive threshold is already in the
past (CheckAliveError);
- on-chain invalidation when the will is already expired;
- on-chain invalidation when the anticipated locktime would fall before
the check-alive threshold (and no sign/push in that case).
Run:
source "$BAL_HOME/electrum/env/bin/activate"
QT_QPA_PLATFORM=offscreen python3 tests/test_auto_rebuild_on_new_tx.py
"""
import os
import sys
import tempfile
import time
import unittest.mock as mock
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from electrum import bitcoin, crypto # noqa: E402
from electrum.descriptor import parse_descriptor # noqa: E402
from electrum.transaction import ( # noqa: E402
PartialTxInput,
PartialTxOutput,
TxOutpoint,
)
from electrum.util import bfh # noqa: E402
from PyQt6.QtWidgets import QApplication # noqa: E402
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import bal.gui.qt.window as window_mod # noqa: E402
from bal.core.heirs import Heirs # noqa: E402
from bal.core.plugin_base import BalConfig, BalPlugin # noqa: E402
from bal.core.util import Util # noqa: E402
from bal.core.will import Will # noqa: E402
from bal.core.willexecutors import Willexecutors # noqa: E402
from bal.gui.qt.plugin import Plugin # noqa: E402
from bal.gui.qt.window import BalWindow # noqa: E402
CONFIG_KEY = "bal_auto_rebuild"
# --------------------------------------------------------------------------- #
# Fixtures
# --------------------------------------------------------------------------- #
PRIVKEY = bytes(range(32))
PUBKEY = crypto.privkey_to_pubkey(PRIVKEY)
ADDRESS = bitcoin.public_key_to_p2wpkh(PUBKEY)
SCRIPT = bitcoin.address_to_script(ADDRESS)
FUNDING_SATOSHIS = 500000
def make_funding_input(prevout_hex="11" * 32):
"""Return a fake wallet UTXO spendable by the will."""
utxo = PartialTxInput(prevout=TxOutpoint(bfh(prevout_hex), 0))
utxo.witness_utxo = PartialTxOutput.from_address_and_value(
ADDRESS, FUNDING_SATOSHIS
)
utxo._trusted_value_sats = FUNDING_SATOSHIS
utxo._TxInput__scriptpubkey = SCRIPT
utxo._TxInput__address = ADDRESS
return utxo
class FakeDB:
def __init__(self):
self._data = {}
def get(self, key, default=None):
return self._data.get(key, default)
def put(self, key, value):
self._data[key] = value
def get_transaction(self, txid):
return None
def commit(self):
pass
class FakeWallet:
def __init__(self, utxos):
self.db = FakeDB()
self.adb = None
self.network = None
self._utxos = list(utxos)
self._dust = 546
self._change_addresses = [ADDRESS]
self.labels = {}
self.save_db_calls = 0
def save_db(self):
self.save_db_calls += 1
def dust_threshold(self):
return self._dust
def has_keystore_encryption(self):
return False
def set_label(self, txid, label):
self.labels[txid] = label
def get_all_labels(self):
return dict(self.labels)
def get_label_for_txid(self, txid):
return self.labels.get(txid, "")
def get_utxos(self):
return list(self._utxos)
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
return self._change_addresses
def add_input_info(self, txin, only_der_suffix=False):
pass
def add_output_info(self, txout, only_der_suffix=False):
pass
def get_tx_info(self, tx):
class _TxInfo:
def __init__(self):
class _MinedStatus:
def height(self):
return 0
self.tx_mined_status = _MinedStatus()
return _TxInfo()
def get_transaction(self, txid):
return None
def sign_transaction(self, tx, password=None, ignore_warnings=True):
descriptor = parse_descriptor(f"wpkh({PUBKEY.hex()})")
for txin in tx.inputs():
if txin.script_descriptor is None:
txin.script_descriptor = descriptor
if txin.value_sats() is None:
txin._trusted_value_sats = FUNDING_SATOSHIS
tx.sign({PUBKEY: PRIVKEY})
class FakeConfig:
def __init__(self):
self._data = {}
self._tmpdir = tempfile.mkdtemp(prefix="bal-test-")
def electrum_path(self):
return self._tmpdir
def user_dir(self):
return self._tmpdir
def get(self, key, default=None):
return self._data.get(key, default)
def set_key(self, key, value, save=True):
self._data[key] = value
class FakeWindow:
def __init__(self, wallet):
self.wallet = wallet
self.messages = []
self.warnings = []
self.errors = []
def get_decimal_point(self):
return 0
def show_message(self, text):
self.messages.append(str(text))
def show_warning(self, text, parent=None, title=None):
self.warnings.append(str(text))
def show_error(self, text):
self.errors.append(str(text))
def show_critical(self, text):
self.errors.append(str(text))
def update_status(self):
pass
def make_controller(utxos=None):
"""Build a fully-wired BalWindow without constructing the Qt tabs."""
utxos = [make_funding_input()] if utxos is None else utxos
config = FakeConfig()
wallet = FakeWallet(utxos)
window = FakeWindow(wallet)
plugin = BalPlugin(None, config, "bal")
plugin.get_window_title = lambda title: str(title)
plugin.get_decimal_point = window.get_decimal_point
plugin.NO_WILLEXECUTOR.set(True)
plugin.AUTO_REBUILD.set(True)
ctl = BalWindow.__new__(BalWindow)
ctl.bal_plugin = plugin
ctl.window = window
ctl.wallet = wallet
ctl.will = {}
ctl.willitems = {}
ctl.willexecutors = {}
ctl.will_settings = plugin.WILL_SETTINGS.get()
Util.fix_will_settings_tx_fees(ctl.will_settings)
ctl.heirs = Heirs(wallet)
ctl.heirs["alice"] = [ADDRESS, "100000", "1y"]
ctl.heirs["bob"] = [ADDRESS, "100%", "1y"]
ctl.no_willexecutor = True
ctl.disable_plugin = False
ctl.ok = True
ctl.update_all = lambda: None
ctl._schedule_history_refresh = lambda: None
ctl._auto_rebuild_running = False
ctl._auto_rebuild_cooldown_until = 0.0
return ctl
def _no_willexecutors():
"""Force an empty will-executor list (offline tests)."""
return mock.patch.object(
Willexecutors,
"get_willexecutors",
return_value={},
)
def _single(controller):
"""Return (txid, WillItem) for the controller's single will item."""
assert len(controller.willitems) == 1, controller.willitems
return next(iter(controller.willitems.items()))
def _item_spending(controller, *prevout_hexes):
"""Return the will item whose tx spends exactly the given prevouts."""
wanted = sorted(h for h in prevout_hexes)
items = [
item
for item in controller.willitems.values()
if sorted(i.prevout.txid.hex() for i in item.tx.inputs()) == wanted
]
assert len(items) == 1, controller.willitems
return items[0]
# --------------------------------------------------------------------------- #
# Config key
# --------------------------------------------------------------------------- #
def test_auto_rebuild_config_defaults_off():
"""bal_auto_rebuild must default to OFF (False) when not yet stored."""
cfg = FakeConfig()
rebuild = BalConfig(cfg, CONFIG_KEY, False)
assert rebuild.get() is False
def test_auto_rebuild_config_can_be_enabled():
"""Once enabled and persisted, bal_auto_rebuild reads back True."""
cfg = FakeConfig()
rebuild = BalConfig(cfg, CONFIG_KEY, False)
rebuild.set(True)
assert rebuild.get() is True
assert BalConfig(cfg, CONFIG_KEY, False).get() is True
# --------------------------------------------------------------------------- #
# Event wiring (Plugin._wallet_activity)
# --------------------------------------------------------------------------- #
def test_wallet_activity_schedules_only_matching_wallet():
plugin = Plugin.__new__(Plugin)
plugin.AUTO_REBUILD = BalConfig(FakeConfig(), CONFIG_KEY, True)
wallet_a = FakeWallet([make_funding_input()])
wallet_b = FakeWallet([make_funding_input()])
scheduled = []
class _Win:
wallet = wallet_a
ok = True
disable_plugin = False
def schedule_auto_rebuild(self):
scheduled.append(self)
win = _Win()
plugin.bal_windows = {"a": win}
plugin._wallet_activity(wallet_b)
assert scheduled == [], "a different wallet must not schedule a rebuild"
plugin._wallet_activity(wallet_a)
assert scheduled == [win], "the matching wallet must schedule a rebuild"
def test_wallet_activity_skips_when_disabled():
plugin = Plugin.__new__(Plugin)
plugin.AUTO_REBUILD = BalConfig(FakeConfig(), CONFIG_KEY, False)
wallet_obj = FakeWallet([make_funding_input()])
scheduled = []
class _Win:
wallet = wallet_obj
ok = True
disable_plugin = False
def schedule_auto_rebuild(self):
scheduled.append(self)
plugin.bal_windows = {"a": _Win()}
plugin._wallet_activity(wallet_obj)
assert scheduled == [], "AUTO_REBUILD off must not schedule anything"
# --------------------------------------------------------------------------- #
# Scheduling / guards
# --------------------------------------------------------------------------- #
def test_schedule_auto_rebuild_debounces():
ctl = make_controller()
with mock.patch.object(window_mod.QTimer, "singleShot") as single_shot:
ctl.schedule_auto_rebuild()
single_shot.assert_called_once_with(
ctl._AUTO_REBUILD_DEBOUNCE_MS, ctl._run_auto_rebuild
)
def test_auto_rebuild_guards():
with _no_willexecutors():
ctl = make_controller()
ctl.prepare_will()
assert ctl._auto_rebuild_allowed() is True
# Re-entrancy guard.
ctl._auto_rebuild_running = True
assert ctl._auto_rebuild_allowed() is False
ctl._auto_rebuild_running = False
# Cooldown guard.
ctl._auto_rebuild_cooldown_until = time.time() + 100
assert ctl._auto_rebuild_allowed() is False
ctl._auto_rebuild_cooldown_until = 0.0
assert ctl._auto_rebuild_allowed() is True
# Disabled / inactive guards.
ctl.disable_plugin = True
assert ctl._auto_rebuild_allowed() is False
ctl.disable_plugin = False
ctl.ok = False
assert ctl._auto_rebuild_allowed() is False
def test_run_auto_rebuild_spawns_worker_when_allowed():
with _no_willexecutors():
ctl = make_controller()
ctl.prepare_will()
started = []
class FakeThread:
def __init__(self, target, daemon=None):
self.target = target
def start(self):
started.append(self.target)
with mock.patch.object(window_mod.threading, "Thread", FakeThread):
ctl._run_auto_rebuild()
assert len(started) == 1, "the worker thread must be spawned"
# --------------------------------------------------------------------------- #
# maybe_auto_rebuild behaviour
# --------------------------------------------------------------------------- #
def test_auto_rebuild_noop_when_disabled():
with _no_willexecutors():
ctl = make_controller()
ctl.prepare_will()
ctl.bal_plugin.AUTO_REBUILD.set(False)
txid_before, _ = _single(ctl)
result = ctl.maybe_auto_rebuild()
assert result is False
txid_after, _ = _single(ctl)
assert txid_after == txid_before, "disabled flow must not touch the will"
def test_auto_rebuild_noop_without_will():
with _no_willexecutors():
ctl = make_controller()
assert not ctl.willitems
result = ctl.maybe_auto_rebuild()
assert result is False
def test_auto_rebuild_noop_when_will_valid():
with _no_willexecutors():
ctl = make_controller()
ctl.prepare_will()
txid_before, _ = _single(ctl)
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
ctl, "_auto_sign_save_push"
) as sign:
result = ctl.maybe_auto_rebuild()
assert result is False
txid_after, _ = _single(ctl)
assert txid_after == txid_before, "a valid will must not be rebuilt"
inv.assert_not_called()
sign.assert_not_called()
def test_auto_rebuild_rebuilds_and_pushes_on_new_utxo():
with _no_willexecutors():
ctl = make_controller()
# A relative delivery recipe keeps the will coherent after the rebuild
# anticipates the locktime by one day (an absolute recipe would read the
# anticipated tx as a postpone, see check_willexecutors_and_heirs).
ctl.will_settings["locktime"] = "1y"
ctl.prepare_will()
old_txid, old_item = _single(ctl)
old_locktime = int(old_item.tx.locktime)
# An incoming payment adds a second UTXO -> the will no longer covers
# the whole wallet (NotCompleteWillException).
ctl.wallet._utxos.append(make_funding_input("22" * 32))
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
ctl, "push_transactions_to_willexecutors"
) as push, mock.patch.object(ctl, "_save_will_to_history") as history:
result = ctl.maybe_auto_rebuild()
assert result is True, "a stale will must be rebuilt"
assert inv.call_count == 0, "a plain rebuild must not invalidate on-chain"
push.assert_called_once()
history.assert_called_once()
# The rebuilt will now spends BOTH wallet UTXOs (BAL keeps the previous
# single-input transaction alongside it in the will).
new_item = _item_spending(ctl, "11" * 32, "22" * 32)
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
# The new locktime must be at most the old one, so the new tx can be mined
# before the previous will.
assert int(new_item.tx.locktime) <= old_locktime
assert new_item.get_status("COMPLETE"), "passwordless rebuild must sign"
assert new_item.get_status("VALID")
# The rebuilt will is still valid now: no further work.
assert ctl.check_will() is True
def test_auto_rebuild_invalidates_when_threshold_passed():
with _no_willexecutors():
ctl = make_controller()
ctl.prepare_will()
# ADVANCED mode with a check-alive threshold already in the past.
ctl.bal_plugin.USER_TYPE.set("advanced")
ctl.will_settings["threshold"] = int(time.time()) - 3600
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
ctl, "_auto_sign_save_push"
) as sign:
result = ctl.maybe_auto_rebuild()
assert result is True
inv.assert_called_once()
sign.assert_not_called()
def test_auto_rebuild_invalidates_when_locktime_expired():
with _no_willexecutors():
ctl = make_controller()
ctl.prepare_will()
txid, item = _single(ctl)
# Move the frozen delivery date into the past: "too late to
# anticipate" -> the old will must be invalidated on-chain.
item.tx.locktime = int(time.time()) - 2 * 86400
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
ctl, "_auto_sign_save_push"
) as sign:
result = ctl.maybe_auto_rebuild()
assert result is True
inv.assert_called_once()
sign.assert_not_called()
def test_auto_rebuild_invalidates_when_anticipation_crosses_threshold():
with _no_willexecutors():
ctl = make_controller()
now = time.time()
delivery = int(now + 3 * 86400)
ctl.will_settings["locktime"] = delivery
# ADVANCED mode: the check-alive threshold sits 12h before delivery, so
# an anticipated (delivery - 1 day) locktime falls BEFORE it.
ctl.bal_plugin.USER_TYPE.set("advanced")
ctl.will_settings["threshold"] = delivery - 12 * 3600
ctl.prepare_will()
old_txid, _ = _single(ctl)
ctl.wallet._utxos.append(make_funding_input("22" * 32))
# The rebuild itself anticipates the delivery date by one day ONLY when
# the rebuilt transactions keep the same real amounts (Will.check_anticipate,
# same coins + same heirs). Real amounts are re-computed against the
# wallet balance, so a new UTXO normally changes them and the rebuilt
# will keeps the old locktime. Force the anticipating branch here to
# exercise the "anticipated locktime crosses the threshold" handling.
with mock.patch.object(
Will, "check_anticipate", return_value=delivery - 86400
):
with mock.patch.object(
ctl, "_auto_invalidate_will"
) as inv, mock.patch.object(ctl, "_auto_sign_save_push") as sign:
result = ctl.maybe_auto_rebuild()
assert result is True
inv.assert_called_once(), (
"an anticipated locktime below the threshold must invalidate on-chain"
)
sign.assert_not_called(), (
"after an invalidation the rebuilt will must NOT be signed/pushed "
"(the wizard stops and waits for the invalidation to confirm)"
)
new_item = _item_spending(ctl, "11" * 32, "22" * 32)
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
assert int(new_item.tx.locktime) == delivery - 86400, (
"the rebuilt locktime must be anticipated by one day"
)
def _run_all():
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
for fn in tests:
print(f"{fn.__name__} ... ", end="", flush=True)
fn()
print("OK")
print(f"\n{len(tests)} tests passed")
if __name__ == "__main__":
app = QApplication.instance() or QApplication([])
_run_all()

View File

@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""Tests for the headless auto-rebuild flow (``bal_will_autorebuild``).
The CLI equivalent of the GUI AUTO_REBUILD feature:
``BalController.auto_rebuild`` runs the wizard's close-time flow in a single
call. Everything is exercised offline against a fake signing wallet (the same
fixtures the GUI tests use), so no wallet, network or Qt is needed.
Covers:
* no-op when the will is still valid (``valid``);
* rebuild + sign + push when a new UTXO invalidates the will (``rebuilt``,
no on-chain invalidation: the rebuilt tx is anticipated to mine before
the old one);
* ``needs_signing`` when the wallet is encrypted;
* on-chain invalidation when the will is already expired (``expired``);
* on-chain invalidation when the anticipated locktime crosses the check-alive
threshold (``anticipation_crossed``) - and no sign/push in that case.
The ``no_heirs`` and ``threshold_passed`` paths live in
``test_cli_controller_offline.py``.
Run:
source "$BAL_HOME/electrum/env/bin/activate"
python3 tests/test_cli_autorebuild.py
"""
import os
import shutil
import sys
import tempfile
import time
import unittest.mock as mock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum import bitcoin, crypto
from electrum.descriptor import parse_descriptor
from electrum.simple_config import SimpleConfig
from electrum.transaction import PartialTxInput, PartialTxOutput, TxOutpoint
from electrum.util import bfh
from bal.cli.controller import BalController
from bal.core.will import Will
from bal.core.willexecutors import Willexecutors
PRIVKEY = bytes(range(32))
PUBKEY = crypto.privkey_to_pubkey(PRIVKEY)
ADDRESS = bitcoin.public_key_to_p2wpkh(PUBKEY)
SCRIPT = bitcoin.address_to_script(ADDRESS)
FUNDING_SATOSHIS = 500000
def make_funding_input(prevout_hex="11" * 32):
"""Return a fake wallet UTXO spendable by the will."""
utxo = PartialTxInput(prevout=TxOutpoint(bfh(prevout_hex), 0))
utxo.witness_utxo = PartialTxOutput.from_address_and_value(
ADDRESS, FUNDING_SATOSHIS
)
utxo._trusted_value_sats = FUNDING_SATOSHIS
utxo._TxInput__scriptpubkey = SCRIPT
utxo._TxInput__address = ADDRESS
return utxo
class FakeDB:
def __init__(self):
self._data = {}
def get(self, key, default=None):
return self._data.get(key, default)
def put(self, key, value):
self._data[key] = value
def get_dict(self, key):
return self._data.setdefault(key, {})
def get_transaction(self, txid):
return None
def add_transaction(self, tx, *args, **kwargs):
pass
class FakeWallet:
def __init__(self, utxos, encrypted=False):
self.db = FakeDB()
self.adb = None
self.network = None
self._utxos = list(utxos)
self._dust = 546
self._change_addresses = [ADDRESS]
self._encrypted = encrypted
self.labels = {}
def save_db(self):
pass
def dust_threshold(self):
return self._dust
def has_keystore_encryption(self):
return self._encrypted
def set_label(self, txid, label):
self.labels[txid] = label
def get_utxos(self):
return list(self._utxos)
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
return self._change_addresses
def add_input_info(self, txin, only_der_suffix=False):
pass
def add_output_info(self, txout, only_der_suffix=False):
pass
def get_tx_info(self, tx):
class _TxInfo:
def __init__(self):
class _MinedStatus:
def height(self):
return 0
self.tx_mined_status = _MinedStatus()
return _TxInfo()
def get_transaction(self, txid):
return None
def sign_transaction(self, tx, password=None, ignore_warnings=True):
descriptor = parse_descriptor(f"wpkh({PUBKEY.hex()})")
for txin in tx.inputs():
if txin.script_descriptor is None:
txin.script_descriptor = descriptor
if txin.value_sats() is None:
txin._trusted_value_sats = FUNDING_SATOSHIS
tx.sign({PUBKEY: PRIVKEY})
class _Plugin:
"""Real ``bal.cli.plugin.Plugin`` with an isolated config directory."""
def __init__(self):
self.tmpdir = tempfile.mkdtemp(prefix="bal_cli_autorebuild_")
from bal.cli.plugin import Plugin as RealPlugin
self.config = SimpleConfig(
{"electrum_path": self.tmpdir},
read_user_config_function=lambda path: {},
)
self.plugin = RealPlugin(None, self.config, "bal")
def __enter__(self):
return self.plugin
def __exit__(self, *exc):
shutil.rmtree(self.tmpdir, ignore_errors=True)
def _no_willexecutors():
"""Force an empty will-executor list (offline tests)."""
return mock.patch.object(
Willexecutors,
"get_willexecutors",
return_value={},
)
def _make_controller(plugin, wallet):
c = BalController(plugin, wallet)
c.will_settings["locktime"] = "1y"
c.heirs_add("alice", ADDRESS, "100000")
c.heirs_add("bob", ADDRESS, "100%")
return c
def _single(controller):
"""Return (txid, WillItem) for the controller's single will item."""
assert len(controller.willitems) == 1, controller.willitems
return next(iter(controller.willitems.items()))
def _item_spending(controller, *prevout_hexes):
"""Return the will item whose tx spends exactly the given prevouts."""
wanted = sorted(h for h in prevout_hexes)
items = [
item
for item in controller.willitems.values()
if sorted(i.prevout.txid.hex() for i in item.tx.inputs()) == wanted
]
assert len(items) == 1, controller.willitems
return items[0]
# --------------------------------------------------------------------------- #
# auto_rebuild behaviour
# --------------------------------------------------------------------------- #
def test_auto_rebuild_noop_when_will_valid():
with _no_willexecutors():
with _Plugin() as plugin:
plugin.NO_WILLEXECUTOR.set(True)
wallet = FakeWallet([make_funding_input()])
c = _make_controller(plugin, wallet)
c.prepare_will()
txid_before, _ = _single(c)
result = c.auto_rebuild()
assert result["result"] == "valid", result
assert next(iter(c.willitems)) == txid_before, (
"a valid will must not be rebuilt"
)
def test_auto_rebuild_rebuilds_and_pushes_on_new_utxo():
with _no_willexecutors():
with _Plugin() as plugin:
plugin.NO_WILLEXECUTOR.set(True)
wallet = FakeWallet([make_funding_input()])
c = _make_controller(plugin, wallet)
c.prepare_will()
old_txid, old_item = _single(c)
old_locktime = int(old_item.tx.locktime)
# An incoming payment adds a second UTXO -> the will no longer
# covers the whole wallet (NotCompleteWillException).
wallet._utxos.append(make_funding_input("22" * 32))
result = c.auto_rebuild()
assert result["result"] == "rebuilt", result
assert result["push"] == {}
new_item = _item_spending(c, "11" * 32, "22" * 32)
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
assert int(new_item.tx.locktime) <= old_locktime, (
"the rebuilt tx must be anticipatable before the old will"
)
assert new_item.get_status("COMPLETE"), "passwordless rebuild must sign"
assert new_item.get_status("VALID")
# The rebuilt will is still valid now: no further work.
assert c.check_will() is True
def test_auto_rebuild_encrypted_wallet_requires_manual_signing():
with _no_willexecutors():
with _Plugin() as plugin:
plugin.NO_WILLEXECUTOR.set(True)
wallet = FakeWallet([make_funding_input()], encrypted=True)
c = _make_controller(plugin, wallet)
c.prepare_will()
wallet._utxos.append(make_funding_input("22" * 32))
result = c.auto_rebuild()
assert result["result"] == "needs_signing", result
assert result["will"]["count"] == 2
assert not any(w.get_status("COMPLETE") for w in c.willitems.values()), (
"an encrypted wallet must never be signed without the password"
)
def test_auto_rebuild_invalidates_when_locktime_expired():
with _no_willexecutors():
with _Plugin() as plugin:
plugin.NO_WILLEXECUTOR.set(True)
wallet = FakeWallet([make_funding_input()])
c = _make_controller(plugin, wallet)
c.prepare_will()
_, item = _single(c)
# Move the frozen delivery date into the past: "too late to
# anticipate" -> the old will must be invalidated on-chain.
item.tx.locktime = int(time.time()) - 2 * 86400
result = c.auto_rebuild()
assert result["result"] == "invalidated", result
assert result["reason"] == "expired"
assert result["invalidation_tx"]["txid"] is not None
assert result["invalidation_tx"]["tx"]
assert not any(w.get_status("COMPLETE") for w in c.willitems.values())
def test_auto_rebuild_invalidates_when_anticipation_crosses_threshold():
with _no_willexecutors():
with _Plugin() as plugin:
now = time.time()
delivery = int(now + 3 * 86400)
# ADVANCED mode: the check-alive threshold sits 12h before delivery,
# so an anticipated (delivery - 1 day) locktime falls BEFORE it.
plugin.USER_TYPE.set("advanced")
wallet = FakeWallet([make_funding_input()])
plugin.NO_WILLEXECUTOR.set(True)
c = _make_controller(plugin, wallet)
c.will_settings["locktime"] = delivery
c.will_settings["threshold"] = delivery - 12 * 3600
c.prepare_will()
old_txid, _ = _single(c)
wallet._utxos.append(make_funding_input("22" * 32))
# Force the anticipating branch (see the GUI test for the rationale:
# with a new UTXO the real amounts change, so the natural rebuild
# keeps the old locktime).
with mock.patch.object(
Will, "check_anticipate", return_value=delivery - 86400
):
result = c.auto_rebuild()
assert result["result"] == "invalidated", result
assert result["reason"] == "anticipation_crossed"
assert not any(w.get_status("COMPLETE") for w in c.willitems.values()), (
"after an invalidation the rebuilt will must NOT be signed/pushed "
"(the wizard stops and waits for the invalidation to confirm)"
)
new_item = _item_spending(c, "11" * 32, "22" * 32)
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
assert int(new_item.tx.locktime) == delivery - 86400, (
"the rebuilt locktime must be anticipated by one day"
)
def _run_all():
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
for fn in tests:
print(f"{fn.__name__} ... ", end="", flush=True)
fn()
print("OK")
print(f"\n{len(tests)} tests passed")
if __name__ == "__main__":
_run_all()

View File

@@ -0,0 +1,150 @@
"""
Test: BAL plugin CLI commands are registered with Electrum.
Verifies that importing the plugin through Electrum's own plugin loader
(``Plugins(config, cmd_only=True)``, the exact code path ``run_electrum`` uses
to pre-parse the command line) registers every ``bal_*`` command with
``electrum.commands`` (``known_commands`` + the ``Commands`` class).
It also asserts the basic contract enforced by ``plugin_command``: each command
is a coroutine and carries the expected flags (all ``bal_*`` commands require a
daemon/network, i.e. the ``'n'`` flag; the wallet-bound ones the ``'w'`` flag;
signing also ``'p'``).
Run:
source "$BAL_HOME/electrum/env/bin/activate"
python3 tests/test_cli_commands_registered.py
"""
import inspect
import tempfile
from electrum import commands as electrum_commands
from electrum.plugin import Plugins
from electrum.simple_config import SimpleConfig
# The full command table lives in PLAN_CMDLINE_PLUGIN.md section 6; new commands
# added in later phases must be appended here so the registration test keeps
# proving the whole list is wired up.
EXPECTED_COMMANDS = {
# Settings (no wallet required)
"bal_settings_list": {
"requires_network": True,
"requires_wallet": False,
"requires_password": False,
},
"bal_settings_get": {
"requires_network": True,
"requires_wallet": False,
"requires_password": False,
},
"bal_settings_set": {
"requires_network": True,
"requires_wallet": False,
"requires_password": False,
},
"bal_settings_reset": {
"requires_network": True,
"requires_wallet": False,
"requires_password": False,
},
# Heirs
"bal_heirs_list": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_show": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_add": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_update": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_delete": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_import": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
# Will-Executors
"bal_willexecutors_list": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_show": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_add": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_update": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_select": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_delete": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_ping": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_download": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_import": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
# Will
"bal_will_status": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_check": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_prepare": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_autorebuild": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_sign": {"requires_network": True, "requires_wallet": True, "requires_password": True},
"bal_will_broadcast": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_import_merge": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_invalidate": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_check_executor": {"requires_network": True, "requires_wallet": True, "requires_password": False},
}
def _isolated_config(**overrides):
"""A throwaway SimpleConfig that never touches the real Electrum config.
A fresh ``electrum_path`` temp dir keeps every write isolated, so running
the tests cannot pollute the user's config files. The bal plugin is
enabled because ``Plugins(cmd_only=True)`` skips any plugin that is not
explicitly enabled (electrum.plugin.Plugins.find_directory_plugins).
"""
opts = {"electrum_path": tempfile.mkdtemp(prefix="bal_test_")}
opts.update(overrides)
cfg = SimpleConfig(opts)
cfg.enable_plugin("bal")
return cfg
def test_commands_registered():
cfg = _isolated_config()
Plugins(cfg, cmd_only=True)
for name, flags in EXPECTED_COMMANDS.items():
assert name in electrum_commands.known_commands, f"{name} not registered"
cmd = electrum_commands.known_commands[name]
assert cmd.name == name
assert cmd.requires_network is flags["requires_network"]
assert cmd.requires_wallet is flags["requires_wallet"]
assert cmd.requires_password is flags["requires_password"]
def test_commands_are_coroutines():
cfg = _isolated_config()
Plugins(cfg, cmd_only=True)
for name in EXPECTED_COMMANDS:
func = getattr(electrum_commands.Commands, name, None)
assert func is not None, f"{name} missing from Commands"
assert inspect.iscoroutinefunction(func), f"{name} is not a coroutine"
def test_no_duplicate_registration():
"""Loading the plugin twice must not raise "Command name bal_... already
exists" (the guard in bal/__init__._register_cli_commands)."""
cfg = _isolated_config()
plugins = Plugins(cfg, cmd_only=True)
plugins.maybe_load_plugin_init_method("bal") # already imported -> no-op
for name in EXPECTED_COMMANDS:
assert name in electrum_commands.known_commands
def test_command_docstrings_document_all_args():
"""Every parameter/option must carry an ``arg:TYPE:NAME:DESC`` line (the
CLI parser prints "undocumented argument ..." otherwise)."""
cfg = _isolated_config()
Plugins(cfg, cmd_only=True)
for name in EXPECTED_COMMANDS:
cmd = electrum_commands.known_commands[name]
for varname in list(cmd.params) + list(cmd.options):
if varname in ("wallet", "wallet_path", "plugin", "password"):
continue
assert varname in cmd.arg_descriptions, (
f"{name}: undocumented argument {varname}"
)
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All CLI registration tests passed")

View File

@@ -0,0 +1,288 @@
"""
Offline tests for the headless ``bal.cli.controller.BalController``.
These run without a wallet, a network or Qt: the controller is exercised
against a ``FakeWallet`` plus a real ``bal.cli.plugin.Plugin`` backed by an
isolated in-memory ``SimpleConfig``. Only the flows that never touch the
network (settings/heirs/willexecutors CRUD, status snapshots, error mapping)
are covered here; build/sign/push flows need a live wallet and network and are
exercised by the group tests instead.
Run:
source electrum/env/bin/activate
python3 tests/test_cli_controller_offline.py
"""
import os
import shutil
import sys
import tempfile
import time
import unittest.mock as mock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum.simple_config import SimpleConfig
from electrum.util import UserFacingException
from bal.cli.controller import BalController
from bal.core.heirs import Heirs
from bal.core.util import Util
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
class FakeDB:
def __init__(self):
self._data = {}
def get(self, key, default=None):
return self._data.get(key, default)
def put(self, key, value):
self._data[key] = value
def get_dict(self, key):
return self._data.setdefault(key, {})
def get_transaction(self, txid):
return None
def add_transaction(self, tx, *args, **kwargs):
pass
class FakeWallet:
def __init__(self):
self.db = FakeDB()
self.network = None
self.adb = None
self._dust = 500
def save_db(self):
pass
def dust_threshold(self):
return self._dust
def has_keystore_encryption(self):
return False
def set_label(self, txid, text):
pass
def get_utxos(self):
return []
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
return [VALID_ADDRESS]
class Plugin:
"""Real ``bal.cli.plugin.Plugin`` with an isolated config directory."""
def __init__(self):
self.tmpdir = tempfile.mkdtemp(prefix="bal_cli_test_")
from bal.cli.plugin import Plugin as RealPlugin
self.config = SimpleConfig(
{"electrum_path": self.tmpdir},
read_user_config_function=lambda path: {},
)
self.plugin = RealPlugin(None, self.config, "bal")
def __enter__(self):
return self.plugin
def __exit__(self, *exc):
shutil.rmtree(self.tmpdir, ignore_errors=True)
def _make_controller(plugin):
return BalController(plugin, FakeWallet())
def test_controller_init_empty():
with Plugin() as plugin:
c = _make_controller(plugin)
assert c.willitems == {}
assert c.will == {}
assert c.heirs == {}
assert isinstance(c.will_settings, dict)
assert "baltx_fees" in c.will_settings
# Fresh config: no stored will-executors. On mainnet the default
# WILLEXECUTORS table is keyed by "mainnet" while chainname is
# "bitcoin", so nothing is injected either.
assert c.willexecutors == {}
assert c.no_willexecutor is False
def test_settings_roundtrip():
with Plugin() as plugin:
c = _make_controller(plugin)
listing = c.settings_list()
assert "BAL_TX_FEES" in listing or "TX_FEES" in listing
tx_key = "BAL_TX_FEES" if "BAL_TX_FEES" in listing else "TX_FEES"
assert c.settings_get(tx_key)["value"] == 100
c.settings_set("bal_tx_fees", "150")
assert c.settings_get("bal_tx_fees")["value"] == 150
assert c.settings_get("TX_FEES")["value"] == 150
c.settings_set("bal_no_willexecutor", "true")
assert c.settings_get("bal_no_willexecutor")["value"] is True
c.settings_reset("bal_tx_fees")
assert c.settings_get("bal_tx_fees")["value"] == 100
def test_settings_unknown_key():
with Plugin() as plugin:
c = _make_controller(plugin)
try:
c.settings_get("bal_does_not_exist")
raise AssertionError("expected UserFacingException")
except UserFacingException as e:
assert "Unknown BAL setting" in str(e)
def test_heirs_crud():
with Plugin() as plugin:
c = _make_controller(plugin)
c.heirs_add("alice", VALID_ADDRESS, "100000")
assert c.heirs["alice"][0] == VALID_ADDRESS
assert c.heirs["alice"][1] == "100000"
c.heirs_update("alice", amount="200000")
assert c.heirs["alice"][1] == "200000"
assert c.heirs_show("alice")["value"][1] == "200000"
assert "alice" in c.heirs_list()
c.heirs_delete(["alice"])
assert "alice" not in c.heirs_list()
def test_heirs_add_op_return():
with Plugin() as plugin:
c = _make_controller(plugin)
c.heirs_add("note", "OP_RETURN:6a0242414c", "100000")
assert c.heirs["note"][1] == "0"
def test_willexecutors_crud():
with Plugin() as plugin:
c = _make_controller(plugin)
assert c.willexecutors == {}
new_url = "https://executor.example.invalid"
c.willexecutors_add(new_url, address="", base_fee=250)
assert c.willexecutors_show(new_url)["willexecutor"]["base_fee"] == 250
assert c.willexecutors_show(new_url)["willexecutor"]["selected"] is False
c.willexecutors_update(new_url, base_fee="300", info="Example executor")
assert c.willexecutors_show(new_url)["willexecutor"]["base_fee"] == 300
c.willexecutors_select([new_url], select=True)
assert c.willexecutors_show(new_url)["willexecutor"]["selected"] is True
renamed = "https://executor2.example.invalid"
c.willexecutors_update(new_url, rename_to=renamed)
assert renamed in c.willexecutors
assert new_url not in c.willexecutors
assert c.willexecutors_delete([renamed]) == {"deleted": [renamed]}
assert renamed not in c.willexecutors
def test_will_status_empty():
with Plugin() as plugin:
c = _make_controller(plugin)
status = c.will_status()
assert status["count"] == 0
assert status["items"] == []
def test_will_check_no_heirs_raises():
with Plugin() as plugin:
c = _make_controller(plugin)
try:
c.will_check()
raise AssertionError("expected UserFacingException")
except UserFacingException as e:
assert "heir" in str(e).lower()
def test_auto_rebuild_no_heirs():
with Plugin() as plugin:
c = _make_controller(plugin)
assert c.auto_rebuild() == {"result": "no_heirs"}
def test_auto_rebuild_threshold_passed_invalidates():
with Plugin() as plugin:
c = _make_controller(plugin)
c.heirs_add("alice", VALID_ADDRESS, "100000")
plugin.USER_TYPE.set("advanced")
c.will_settings["threshold"] = int(time.time()) - 3600
result = c.auto_rebuild()
assert result["result"] == "invalidated"
assert result["reason"] == "threshold_passed"
assert result["invalidation_tx"] == {"txid": None, "tx": None}
def test_build_will_reanchors_date_to_check_to_new_locktime():
"""CLI mirror of the GUI regression: ``build_will`` must re-anchor
``date_to_check`` to the CURRENT heirs' earliest delivery before building,
so an anticipated (shortened) rebuild is not blocked by the old built-will
anchor (which would yield NO_FUTURE_DATE in ``get_transactions``).
"""
with Plugin() as plugin:
plugin.USER_TYPE.set("advanced")
plugin.NO_WILLEXECUTOR.set(True)
plugin.ENABLE_MULTIVERSE.set(True)
plugin.WILL_SETTINGS.set({"threshold": "-150d", "locktime": "2y", "baltx_fees": 20})
c = _make_controller(plugin)
c.no_willexecutor = True
c.heirs["alice"] = [VALID_ADDRESS, "100%", "1y"]
# Simulate an old built will frozen at 2y: reload keeps its (stale)
# anchor, which would reject the anticipated "1y" delivery.
c.init_class_variables()
stale_anchor = Util.parse_locktime_string("2y") - 150 * 86400
c.date_to_check = stale_anchor
assert Util.parse_locktime_string("1y") < c.date_to_check
with mock.patch.object(Heirs, "get_transactions", return_value={}) as gt:
result = c.build_will()
assert result == {}
# build_will re-anchored date_to_check to the new 1y delivery...
expected = Util.parse_locktime_string("1y") - 150 * 86400
assert abs(c.date_to_check - expected) < 3600
# ...and used THAT anchor as the build filter, not the stale 2y one.
assert gt.call_args.args[-1] == c.date_to_check
# ------------------------------------------------------------------ #
# runner
# ------------------------------------------------------------------ #
def main():
failures = 0
for name, fn in sorted(globals().items()):
if not name.startswith("test_") or not callable(fn):
continue
print(f" {name}")
try:
fn()
except Exception as e:
failures += 1
print(f" [FAIL] {name}: {e!r}")
if failures:
print(f"[FAIL] {failures} test(s) failed")
sys.exit(1)
print("[OK] All offline controller tests passed")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,139 @@
"""
Tests for ``bal.core.animated_qr`` (BBQR interop).
Validates the self-contained BBQR codec against round trips, out-of-order
assembly, compression default/fallback, malformed-input rejection and the
format detection / parse_for_detection helpers.
Run:
source electrum/env/bin/activate
python3 tests/test_core_animated_qr.py
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
import random
from bal.core import animated_qr as aq
# --------------------------------------------------------------------------- #
# BBQR
# --------------------------------------------------------------------------- #
def test_bbqr_all_encodings_roundtrip():
payload = ("BBQR payload " * 8).encode()
for encoding in ("Z", "2", "H"):
frames = aq.bbqr_frames(payload, 90, encoding=encoding)
assert len(frames) >= 1
order = list(range(len(frames)))
random.Random(3).shuffle(order)
session = aq.AnimatedQrSession()
for i in order:
session.add_part(frames[i])
assert session.done
assert session.resolve()[0] == payload.decode()
def test_bbqr_compression_default_and_fallback():
payload = ("repetitive data " * 40).encode() # compresses well
frames_z = aq.bbqr_frames(payload, 90, encoding="Z")
# Highly compressible: Z yields one frame and a 'Z' flag.
assert all(f[2] == "Z" for f in frames_z)
assert len(frames_z) == 1
raw = os.urandom(600) # incompressible
frames_2 = aq.bbqr_frames(raw, 90, encoding="Z")
assert all(f[2] == "2" for f in frames_2) # Z loses, '2' is used
def test_bbqr_hex_uppercase():
payload = b"\xde\xad\xbe\xef"
frame = aq.bbqr_frames(payload, 50, encoding="H")[0]
assert "DEADBEEF" in frame
encoding, _type, total, index, frag = aq.bbqr_parse_part(frame)
assert (encoding, total, index) == ("H", 1, 0)
def test_bbqr_runt_last_part():
payload = os.urandom(33)
frames = aq.bbqr_frames(payload, 60, encoding="2")
parts = [aq.bbqr_parse_part(f)[4] for f in frames]
joined = aq._bbqr_decode(parts, "2")
assert joined == payload
assert len(parts[-1]) < len(parts[0]) # last part is a runt
def test_bbqr_zlib_bomb_rejected():
compressed = aq._bbqr_encode(b"\x00" * 1000000, "Z")[1]
try:
aq._bbqr_decode(["0" * len(compressed)], "2") # not zlib data
except aq.AnimatedQrError:
pass
# direct inflate bomb guard:
inflated = aq._bbqr_encode(b"\x00" * 1000000, "Z")
assert inflated[0] == "Z" # 1MB zeros compresses
bomb = aq._bbqr_encode(b"\x00" * (aq._MAX_MESSAGE_BYTES + 100), "Z")[1]
parts = [bomb[i : i + 90] for i in range(0, len(bomb), 90)]
try:
aq._bbqr_decode(parts, "Z")
except aq.AnimatedQrError:
pass
else:
raise AssertionError("expected AnimatedQrError for an oversized decompression")
def test_bbqr_part_number_limits():
try:
aq.bbqr_frames(os.urandom(30000), 40, encoding="2")
except aq.AnimatedQrError:
pass
else:
raise AssertionError("expected AnimatedQrError for too many BBQR parts")
# --------------------------------------------------------------------------- #
# Detection / parse_for_detection
# --------------------------------------------------------------------------- #
def test_detect_format_recognises_bbqr():
assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "bbqr"
def test_detect_format_rejects_garbage():
for text in ("", "hello world", "BALQR1|1|1||payload", "ur:", "ur:bytes/xyz"):
assert aq.detect_format(text) is None, text
def test_parse_for_detection_keys():
bb = aq.parse_for_detection(aq.bbqr_frames(b"x" * 50, 40)[0])
assert bb[0] == "bbqr" and bb[2] >= 1 and 0 <= bb[3] < bb[2]
def test_format_names_exist():
assert aq.format_name("bbqr")
assert aq.format_name("nope") == "nope"
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
import traceback
failures = 0
for _name, fn in sorted(globals().items()):
if _name.startswith("test_") and callable(fn):
try:
fn()
print("ok: {}".format(_name))
except Exception:
failures += 1
print("FAIL: {}".format(_name))
traceback.print_exc()
if failures:
print("{} test(s) failed".format(failures))
sys.exit(1)
print("all tests passed")

View File

@@ -1,8 +1,11 @@
"""
Tests for ``bal.core.checkalive`` (pure, GUI-free).
Covers the CheckAliveError exception and the BASIC/ADVANCED date_to_check
policy (``resolve_date_to_check`` / ``check_alive_expired``).
Covers the CheckAlive exceptions and the BASIC/ADVANCED date_to_check policy
(``resolve_date_to_check`` / ``resolve_relative_check_alive_date`` /
``is_check_alive_date_valid`` / ``ensure_check_alive_valid``), including the
sign rule: a NEGATIVE relative Check Alive counts back from the delivery, a
POSITIVE one counts forward from the inheritance creation date.
Run:
source "$BAL_HOME/electrum/env/bin/activate"
@@ -11,23 +14,41 @@ Run:
import sys
import time
from datetime import datetime, timezone
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.checkalive import ( # noqa: E402 (path insert above)
CheckAliveAfterDeliveryError,
CheckAliveError,
check_alive_expired,
ensure_check_alive_valid,
is_check_alive_date_valid,
resolve_date_to_check,
resolve_delivery_date,
resolve_guard_threshold,
resolve_relative_check_alive_date,
)
from bal.core.plugin_base import BalTimestamp # noqa: E402
from bal.core.will import Will # noqa: E402
def _midnight(ts):
return (
datetime.fromtimestamp(ts, tz=timezone.utc)
.replace(hour=0, minute=0, second=0, microsecond=0)
.timestamp()
)
# ------------------------------------------------------------------ #
# CheckAliveError
# CheckAliveError / CheckAliveAfterDeliveryError
# ------------------------------------------------------------------ #
def test_check_alive_error_default():
err = CheckAliveError(1000000)
assert err.timestamp_to_check == 1000000
assert err.reason is None
def test_check_alive_error_str():
@@ -37,10 +58,107 @@ def test_check_alive_error_str():
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():
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
# ------------------------------------------------------------------ #
@@ -58,69 +176,60 @@ def test_advanced_mode_uses_threshold_absolute():
assert abs(result - threshold) < 1
def test_advanced_mode_parses_relative_threshold():
# A relative threshold means "N days BEFORE the delivery": it resolves
# against the stored locktime (backwards), not forward from now.
from datetime import datetime, timedelta
def test_advanced_mode_negative_relative_anchored_to_delivery():
fake_now = 1_800_000_000.0
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)
# date_to_check = (locktime, midnight-normalised) - 30 days.
expected = (datetime.fromtimestamp(locktime)
.replace(hour=0, minute=0, second=0, microsecond=0)
- timedelta(days=30)).timestamp()
expected = _midnight(locktime) - 30 * 86400
assert abs(result - expected) < 1
# 90d delivery with a 30d window: the window starts 60 days after now.
assert result > fake_now
def test_advanced_mode_relative_threshold_anchored_to_locktime():
"""A relative threshold never drifts with the clock: re-resolving it a day
later, with the same fixed absolute locktime, yields the same date."""
def test_advanced_mode_positive_relative_anchored_to_creation():
fake_now = 1_800_000_000.0
creation = fake_now + 10 * 86400
locktime = fake_now + 365 * 86400
settings = {"threshold": "30d", "locktime": locktime}
result = resolve_date_to_check(
False, settings, now=fake_now, creation_date=creation
)
expected = _midnight(creation) + 30 * 86400
assert abs(result - expected) < 1
def test_advanced_mode_negative_relative_never_drifts():
"""A negative relative threshold anchored to a fixed absolute delivery does
not drift with the clock."""
fake_now = 1_800_000_000.0
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)
# Next day: same stored settings (the fixed delivery), a later clock.
second = resolve_date_to_check(False, settings, now=fake_now + 86400)
assert first == second
def test_advanced_mode_relative_threshold_with_relative_locktime():
"""A relative locktime is resolved against 'now' first, then the relative
threshold counts N days back from it (matches the settings widget)."""
from datetime import datetime
from bal.core.plugin_base import BalTimestamp
def test_advanced_mode_positive_relative_creation_never_drifts():
"""A positive relative threshold anchored to the frozen creation date does
not drift with the clock."""
fake_now = 1_800_000_000.0
settings = {"threshold": "30d", "locktime": "90d"}
result = resolve_date_to_check(False, settings, now=fake_now)
# Recompute the expected value with the same resolution rules:
# locktime = now + 90d (midnight-normalised), threshold = locktime - 30d.
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now))
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
assert abs(result - expected) < 1
assert result > fake_now
def test_advanced_mode_relative_threshold_no_locktime_falls_back():
# Without a locktime reference, fall back to the legacy forward resolution.
settings = {"threshold": "30d"}
result = resolve_date_to_check(False, settings)
assert result > time.time()
creation = fake_now + 2 * 86400
settings = {"threshold": "30d", "locktime": fake_now + 365 * 86400}
first = resolve_date_to_check(
False, settings, now=fake_now, creation_date=creation
)
second = resolve_date_to_check(
False, settings, now=fake_now + 5 * 86400, creation_date=creation
)
assert first == second
def test_advanced_mode_relative_locktime_anchored_to_built_tx():
"""A RELATIVE stored locktime is anchored to the built will's frozen
delivery date (built_locktime), not to "now": an unchanged will must not
read as expired as the clock advances (the karen7 daily-invalidate bug)."""
frozen = 1817438400 # frozen tx locktime (2027-08-05), built 2026-08-05
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.
frozen = 1817424000 # frozen tx locktime, built 2026-08-05
settings = {"threshold": "-30d", "locktime": "2y"}
first = resolve_date_to_check(
False, settings, now=1_800_000_000.0, built_locktime=frozen
)
@@ -129,41 +238,164 @@ def test_advanced_mode_relative_locktime_anchored_to_built_tx():
False, settings, now=1_800_000_000.0 + 10 * 86400, built_locktime=frozen
)
assert first == later
# The check window must start BEFORE the frozen delivery (never expired).
assert first < frozen
def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
"""Without a built will there is no anchor: keeps the legacy now-based
resolution (a moving target, used only before the first build)."""
from datetime import datetime
from bal.core.plugin_base import BalTimestamp
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)
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now))
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
locktime_dt = BalTimestamp("90d").to_date(fake_now)
expected = BalTimestamp("-30d").to_date(locktime_dt).timestamp()
assert abs(result - expected) < 1
# ------------------------------------------------------------------ #
# check_alive_expired
# resolve_guard_threshold
# ------------------------------------------------------------------ #
def test_guard_threshold_basic_mode_returns_none():
fake_now = 1_800_000_000.0
threshold = resolve_guard_threshold(True, {"threshold": "-30d"}, now=fake_now)
assert threshold is None
def _guard_locktime(settings, fake_now):
"""Reproduce the call-site locktime expression of the guard."""
return BalTimestamp(settings["locktime"]).to_timestamp(fake_now)
def test_guard_threshold_absolute():
fake_now = 1_800_000_000.0
locktime = fake_now + 90 * 86400
threshold = locktime - 30 * 86400
settings = {"locktime": locktime, "threshold": threshold}
assert resolve_guard_threshold(False, settings, now=fake_now) == threshold
def test_guard_threshold_negative_relative_fresh_anchor():
"""A negative relative threshold is anchored to the FRESH locktime so the
guard and the settings always share one reference frame."""
fake_now = 1_800_000_000.0
settings = {"locktime": "1y", "threshold": "-30d"}
locktime = _guard_locktime(settings, fake_now)
threshold = resolve_guard_threshold(False, settings, now=fake_now)
assert threshold is not None
assert locktime > threshold # internally consistent: no fire
assert threshold > fake_now
def test_guard_threshold_positive_relative_anchored_to_creation():
fake_now = 1_800_000_000.0
creation = fake_now + 4 * 86400
settings = {"locktime": "1y", "threshold": "30d"}
threshold = resolve_guard_threshold(
False, settings, now=fake_now, creation_date=creation
)
expected = _midnight(creation) + 30 * 86400
assert abs(threshold - expected) < 1
def test_guard_threshold_missing_returns_none():
fake_now = 1_800_000_000.0
assert resolve_guard_threshold(False, {"locktime": "1y"}, now=fake_now) is None
# ------------------------------------------------------------------ #
# is_check_alive_date_valid / ensure_check_alive_valid
# ------------------------------------------------------------------ #
def test_is_valid_inside_window():
now = 1_800_000_000.0
assert is_check_alive_date_valid(now + 100, now + 1000, now=now) is True
def test_is_valid_rejects_past():
now = 1_800_000_000.0
assert is_check_alive_date_valid(now - 100, now + 1000, now=now) is False
def test_is_valid_rejects_on_or_after_delivery():
now = 1_800_000_000.0
delivery = now + 1000
assert is_check_alive_date_valid(delivery, delivery, now=now) is False
assert is_check_alive_date_valid(delivery + 1, delivery, now=now) is False
def test_is_valid_without_delivery_skips_upper_bound():
now = 1_800_000_000.0
assert is_check_alive_date_valid(now + 100, None, now=now) is True
def test_is_valid_none_is_false():
assert is_check_alive_date_valid(None, 1000) is False
def test_basic_mode_never_expired():
assert check_alive_expired(True, 1_000_000_000.0) is False
assert check_alive_expired(True, time.time() - 10_000) is False
assert ensure_check_alive_valid(True, time.time() - 10_000) is None
assert ensure_check_alive_valid(True, 1_000_000_000.0) is None
def test_advanced_expired_when_past():
assert check_alive_expired(False, time.time() - 10_000) is True
def test_advanced_raises_when_past():
try:
ensure_check_alive_valid(False, time.time() - 10_000)
except CheckAliveError:
pass
else:
raise AssertionError("expected CheckAliveError")
def test_advanced_not_expired_when_future():
assert check_alive_expired(False, time.time() + 10_000) is False
def test_advanced_raises_when_after_delivery():
now = time.time()
try:
ensure_check_alive_valid(False, now + 100, delivery_date=now + 50)
except CheckAliveAfterDeliveryError:
pass
else:
raise AssertionError("expected CheckAliveAfterDeliveryError")
def test_advanced_valid_does_not_raise():
now = time.time()
assert ensure_check_alive_valid(False, now + 100, delivery_date=now + 1000) is None
# ------------------------------------------------------------------ #
# Will.get_creation_time
# ------------------------------------------------------------------ #
class _FakeWillItem:
def __init__(self, time_value, valid=True):
self.time = time_value
self._valid = valid
def get_status(self, status):
return self._valid if status == "VALID" else False
def test_get_creation_time_min_of_valid_items():
will = {
"a": _FakeWillItem(2000),
"b": _FakeWillItem(1000),
"c": _FakeWillItem(3000),
}
assert Will.get_creation_time(will) == 1000
def test_get_creation_time_ignores_invalid_and_none():
will = {
"a": _FakeWillItem(500, valid=False),
"b": _FakeWillItem(None),
"c": _FakeWillItem(2000),
}
assert Will.get_creation_time(will) == 2000
def test_get_creation_time_empty_returns_none():
assert Will.get_creation_time({}) is None
# ------------------------------------------------------------------ #

View File

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

View File

@@ -117,6 +117,30 @@ def test_normalize_locktime_raw_single_suffix():
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
# ------------------------------------------------------------------ #

View File

@@ -14,7 +14,7 @@ import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from datetime import date, datetime, timedelta
from datetime import date, datetime, timedelta, timezone
from bal.core.plugin_base import BalConfig, BalPlugin, BalTimestamp
@@ -74,7 +74,7 @@ def test_bt_to_date_absolute():
def test_bt_to_date_relative():
now = datetime.now()
now = datetime.now(timezone.utc)
# relative days from now
bt = BalTimestamp("7d")
@@ -86,8 +86,8 @@ def test_bt_to_date_relative():
d_rev = bt.to_date(reverse=True)
assert d_rev < now
# from explicit datetime
base = datetime(2025, 6, 1, 12, 0, 0)
# from explicit datetime (UTC, so the naive-timestamp roundtrip below is stable)
base = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
d = bt.to_date(from_date=base)
expected = (base + timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0)
assert d == expected
@@ -101,7 +101,7 @@ def test_bt_to_date_relative():
def test_bt_to_date_years():
bt = BalTimestamp("1y")
d = bt.to_date()
assert d > datetime.now()
assert d > datetime.now(timezone.utc)
def test_bt_to_date_overflow():
@@ -201,7 +201,7 @@ def test_balconfig_set():
def test_default_will_settings_relative():
rel = BalPlugin.default_will_settings_relative()
assert rel["threshold"] == "30d"
assert rel["threshold"] == "-30d"
assert rel["locktime"] == "1y"

View File

@@ -0,0 +1,81 @@
"""
Tests for ``bal.core.qrtransfer``.
Covers the transfer-string encoding used for will transfer via BBQR QR codes
/ audio modem: encoding, decoding and the preset list.
Run:
source electrum/env/bin/activate
python3 tests/test_core_qr_transfer.py
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.qrtransfer import (
CHUNK_PRESETS,
decode_transfer,
encode_transfer,
preset_index_for_chunk_size,
)
# --------------------------------------------------------------------------- #
# Round trips
# --------------------------------------------------------------------------- #
def test_encode_decode_plain():
tx_strings = ["00" * 32, "aa" * 40, "ff" * 50]
payload = encode_transfer(tx_strings, compress=False)
assert decode_transfer(payload, compressed=False) == tx_strings
def test_encode_decode_compressed():
tx_strings = ["00" * 32, "aa" * 40, "ff" * 50]
payload = encode_transfer(tx_strings, compress=True)
assert decode_transfer(payload, compressed=True) == tx_strings
def test_empty_list_roundtrip():
assert decode_transfer(encode_transfer([]), compressed=False) == []
# --------------------------------------------------------------------------- #
# Constants / presets
# --------------------------------------------------------------------------- #
def test_preset_count_and_order():
assert len(CHUNK_PRESETS) == 4
budgets = [budget for _label, budget in CHUNK_PRESETS]
assert budgets == sorted(budgets)
def test_preset_index_for_chunk_size():
for index, (_label, budget) in enumerate(CHUNK_PRESETS):
assert preset_index_for_chunk_size(budget) == index
assert preset_index_for_chunk_size(150) == 0
assert preset_index_for_chunk_size(1800) == 3
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
import traceback
failures = 0
for _name, fn in sorted(globals().items()):
if _name.startswith("test_") and callable(fn):
try:
fn()
print("ok: {}".format(_name))
except Exception:
failures += 1
print("FAIL: {}".format(_name))
traceback.print_exc()
if failures:
print("{} test(s) failed".format(failures))
sys.exit(1)
print("all tests passed")

View File

@@ -0,0 +1,136 @@
"""
Stored will data must not depend on the GUI language (rule in bal/i18n.py).
Covers the two findings of the i18n review:
* the status history of a new will item is saved as English "New" (not
``_("New")``), and histories saved by test builds as "Nuovo"/"Nuevo" are
mapped back to "New" on load;
* the push helper no longer writes transient fields (``txs``, ``txsids`` and
the translated ``broadcast_status``) into the will item's executor record,
and such fields saved by older versions are dropped on load.
Run (standalone or with pytest):
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 tests/test_core_status_language_neutral.py
"""
import os
import re
import sys
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum.transaction import Transaction
from bal.core.will import WillItem
from bal.core.willexecutors import Willexecutors
# WillItem parses a raw tx and calls add_info_from_wallet; no live wallet here.
_patcher = patch.object(Transaction, "add_info_from_wallet")
_patcher.start()
ROOT = Path(__file__).resolve().parent.parent
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
URL = "https://we.example"
def _item(status="", we=None):
return WillItem(
{"tx": _VALID_TX_HEX, "heirs": {}, "willexecutor": we, "status": status}
)
# ------------------------------------------------------------------ #
# Status history "New"
# ------------------------------------------------------------------ #
def test_builders_store_english_new():
"""window.py and the CLI controller must not translate the stored status."""
for rel in ("bal/gui/qt/window.py", "bal/cli/controller.py"):
src = (ROOT / rel).read_text(encoding="utf-8")
assert re.search(r'tx\["status"\]\s*=\s*"New"', src), rel
assert not re.search(r'tx\["status"\]\s*=\s*_\(', src), rel
def test_translated_new_is_mapped_back_on_load():
assert _item("Nuovo.Signed.Pushed").status == "New.Signed.Pushed"
assert _item("Nuevo").status == "New"
assert _item("New.Signed").status == "New.Signed"
def test_only_the_leading_new_is_touched():
assert _item(".Nuovo").status == ".Nuovo"
assert _item("Signed.Nuovo").status == "Signed.Nuovo"
def test_translated_new_labels_match_the_catalogs():
"""Every catalog's translation of "New" must be recognised on load."""
found = set()
for po in (ROOT / "bal" / "locale").glob("*/LC_MESSAGES/bal.po"):
text = po.read_text(encoding="utf-8")
m = re.search(r'^msgid "New"\nmsgstr "(.*)"$', text, re.MULTILINE)
if m and m.group(1):
found.add(m.group(1))
assert found, "no catalog translates 'New'"
assert found <= set(WillItem.TRANSLATED_NEW_LABELS), found
# ------------------------------------------------------------------ #
# Transient push fields
# ------------------------------------------------------------------ #
class FakeItem:
def __init__(self):
self.we = {"url": URL, "selected": True, "address": "a", "base_fee": 1}
self.tx = "rawtx"
def get_status(self, name):
return name in ("VALID", "COMPLETE")
def test_push_helper_does_not_modify_the_will_item():
item = FakeItem()
before = dict(item.we)
out = Willexecutors.get_willexecutor_transactions({"t1": item})
assert out[URL]["txsids"] == ["t1"]
assert "broadcast_status" in out[URL]
assert item.we == before
assert out[URL] is not item.we
def test_transient_fields_saved_by_older_versions_are_dropped_on_load():
we = {
"url": URL,
"selected": True,
"txs": "raw\n",
"txsids": ["t1"],
"broadcast_status": "Riuscito",
}
item = _item("New", we)
assert item.we == {"url": URL, "selected": True}
assert "broadcast_status" not in item.to_dict()["willexecutor"]
def test_clean_executor_record_is_kept_as_is():
we = {"url": URL, "selected": True}
assert _item("New", we).we is we
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All language-neutral status tests passed")

View File

@@ -97,7 +97,7 @@ def test_relative_days():
def test_resolve_locktime_against_tx_absolute():
"""An absolute current date is returned unchanged (compared vs the tx)."""
frozen = 1817438400
frozen = 1817424000
assert Util.resolve_locktime_against_tx(str(frozen), "1y", frozen) == frozen
assert Util.resolve_locktime_against_tx(frozen, str(frozen), frozen) == frozen
@@ -105,7 +105,7 @@ def test_resolve_locktime_against_tx_absolute():
def test_resolve_locktime_against_tx_unchanged_relative():
"""An unchanged relative recipe resolves to exactly the frozen tx locktime
(coherent), instead of drifting one day per day away from it."""
frozen = 1817438400 # 2027-08-05, i.e. a tx built 2026-08-05 with "1y"
frozen = 1817424000 # 2027-08-05 00:00 UTC, i.e. a tx built 2026-08-05 with "1y"
resolved = Util.resolve_locktime_against_tx("1y", "1y", frozen)
assert resolved == frozen
@@ -113,7 +113,7 @@ def test_resolve_locktime_against_tx_unchanged_relative():
def test_resolve_locktime_against_tx_lengthened():
"""A lengthened relative recipe resolves later than the frozen tx locktime
(this is what the postpone check uses to trigger invalidation)."""
frozen = 1817438400 # tx built 2026-08-05 with "1y" -> delivery 2027-08-05
frozen = 1817424000 # tx built 2026-08-05 with "1y" -> delivery 2027-08-05
resolved = Util.resolve_locktime_against_tx("2y", "1y", frozen)
assert resolved == frozen + 365 * 86400
@@ -121,7 +121,7 @@ def test_resolve_locktime_against_tx_lengthened():
def test_resolve_locktime_against_tx_shortened():
"""A shortened relative recipe resolves earlier than the frozen tx locktime
(this is what the anticipate/rebuild path uses)."""
frozen = 1817438400
frozen = 1817424000
resolved = Util.resolve_locktime_against_tx("30d", "1y", frozen)
assert resolved < frozen
@@ -129,7 +129,7 @@ def test_resolve_locktime_against_tx_shortened():
def test_resolve_locktime_against_tx_no_relative_anchor():
"""When the built recipe was absolute there is no anchor: falls back to the
legacy forward-from-now resolution (returns a timestamp, no crash)."""
frozen = 1817438400
frozen = 1817424000
result = Util.resolve_locktime_against_tx("30d", str(frozen), frozen)
assert isinstance(result, int)
assert result > 1700000000
@@ -319,27 +319,6 @@ def test_anticipate_locktime():
assert low >= 1
def test_cmp_locktime():
assert Util.cmp_locktime("30d", "30d") == 0
# Note: cmp_locktime may return nonzero or None for mismatched units
def test_get_locktimes():
class FakeTx:
locktime = 1700000000
# will with single entry
will = {
"tx1": {"tx": FakeTx()},
}
locktimes = list(Util.get_locktimes(will))
assert 1700000000 in locktimes
assert len(locktimes) == 1
# empty will
assert list(Util.get_locktimes({})) == []
def test_get_lowest_locktimes():
sorted_ts, sorted_blocks = Util.get_lowest_locktimes([500000, 1700000000, 100, 900000])
# 500000, 900000 are block-height (< THRESHOLD)
@@ -351,18 +330,6 @@ def test_get_lowest_locktimes():
assert Util.get_lowest_locktimes([]) == ([], [])
def test_get_will_spent_utxos():
class FakeTx:
def inputs(self): return [1, 2, 3]
will = {
"tx1": {"tx": FakeTx()},
"tx2": {"tx": FakeTx()},
}
utxos = Util.get_will_spent_utxos(will)
assert len(utxos) == 6 # 3 inputs * 2 txs
def test_utxo_to_str():
class FakeUtxo:
def to_str(self): return "txid:0"
@@ -518,10 +485,7 @@ if __name__ == "__main__":
test_get_value_amount()
test_chk_locktime()
test_anticipate_locktime()
test_cmp_locktime()
test_get_locktimes()
test_get_lowest_locktimes()
test_get_will_spent_utxos()
test_utxo_to_str()
test_cmp_utxo()
test_in_utxo()

View File

@@ -8,13 +8,19 @@ Run:
python3 tests/test_core_will.py
"""
import copy
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.will import Will, WillItem
from bal.core.checkalive import resolve_date_to_check
from bal.core.util import copy_structure
from bal.core.will import (
HeirNotFoundException,
NoHeirsException,
Will,
WillItem,
)
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
_VALID_TX_HEX = (
@@ -48,7 +54,7 @@ def _make_willitem_blank():
"""Create a fresh WillItem from scratch."""
item = WillItem(_make_minimal_willitem_dict())
# Reset STATUS to clean defaults
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
return item
@@ -126,22 +132,6 @@ def test_willitem_str_repr():
# Will static methods
# ------------------------------------------------------------------ #
def test_will_get_sorted_will():
# Use a simple dict structure that will[key]["tx"].locktime works
class FakeTx:
def __init__(self, locktime):
self.locktime = locktime
will = {
"b": {"tx": FakeTx(200)},
"a": {"tx": FakeTx(100)},
}
sorted_will = Will.get_sorted_will(will)
assert len(sorted_will) == 2
assert sorted_will[0][1]["tx"].locktime == 100
assert sorted_will[1][1]["tx"].locktime == 200
def test_will_only_valid():
item1 = _make_willitem_blank()
item2 = _make_willitem_blank()
@@ -167,8 +157,8 @@ def test_will_only_valid_list():
def _make_will_with_heirs(heirs, tx_locktime):
"""Build a single-item will whose stored heirs == ``heirs`` and whose
frozen tx.locktime == ``tx_locktime`` (what the will-executors hold)."""
item = WillItem(_make_minimal_willitem_dict(heirs=copy.deepcopy(heirs)))
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item = WillItem(_make_minimal_willitem_dict(heirs=copy_structure(heirs)))
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
item.tx.locktime = tx_locktime
return {"willid_1": item}
@@ -179,7 +169,7 @@ def test_check_heirs_unchanged_is_coherent():
heirs = {"alice": ["addr_alice", 5000, str(lt)]}
will = _make_will_with_heirs(heirs, lt)
result = Will.check_willexecutors_and_heirs(
will, copy.deepcopy(heirs), {}, False, 0, 100
will, copy_structure(heirs), {}, False, 0, 100
)
assert result is True
@@ -226,6 +216,59 @@ def test_check_heir_added_triggers_rebuild():
assert raised, "adding an heir must raise HeirNotFoundException"
def test_shortened_relative_recipe_on_signed_rebuilds_not_noheirs():
"""Regression (karen7): heirs shortened "2y"->"1y" on a signed will whose
ADVANCED check window is anchored to the frozen built delivery must trigger
a plain rebuild (HeirNotFoundException), NOT "No Heirs".
Earlier the count gate resolved each current relative recipe from *now*
while ``check_date`` was anchored to the (longer) frozen built locktime, so
every heir fell below the window and was silently excluded -> NoHeirs even
though the will simply needs rebuilding on the new, shorter schedule."""
lt = 2_100_000_000 # a far-future frozen delivery (a "2y" build)
will_heirs = {"alice": ["addr_alice", 5000, "2y"]}
current_heirs = {"alice": ["addr_alice", 5000, "1y"]}
will = _make_will_with_heirs(will_heirs, lt)
will["willid_1"].set_status("COMPLETE", True)
check_date = resolve_date_to_check(
False, {"locktime": "2y", "threshold": "-150d"}, built_locktime=lt
)
assert check_date < lt # the anchored window really precedes the delivery
raised = None
try:
Will.check_willexecutors_and_heirs(
will, copy_structure(current_heirs), {}, False, check_date, 100
)
except HeirNotFoundException:
raised = "rebuild"
except NoHeirsException:
raised = "noheirs"
assert raised == "rebuild", (
f"shortened recipe on a signed will must rebuild, got {raised!r}"
)
def test_all_heirs_past_check_date_still_noheirs():
"""The "no valid heirs" gate is preserved: when every heir is coherent with
the built will but its delivery lies before ``check_date``, the check still
reports NoHeirsException (there is literally nothing future to inherit)."""
lt = 1_900_000_000
will_heirs = {"alice": ["addr_alice", 5000, str(lt)]}
will = _make_will_with_heirs(will_heirs, lt)
raised = None
try:
Will.check_willexecutors_and_heirs(
will, copy_structure(will_heirs), {}, False, lt + 86400, 100
)
except HeirNotFoundException:
raised = "rebuild"
except NoHeirsException:
raised = "noheirs"
assert raised == "noheirs", (
f"a fully delivered will must report NoHeirs, got {raised!r}"
)
def test_needs_server_check():
"""Check button selection logic: only a VALID, PUSHED will with a
will-executor that is not yet CHECKED must be queried on the server.

View File

@@ -450,6 +450,12 @@ class FakeADB:
def remove_transaction(self, txid):
self.removed.append(txid)
# Simulate the real adb: dropping a stored tx frees the outputs it spent.
for utxos in self.outputs.values():
for utxo in utxos.values():
if getattr(utxo, "spent_txid", None) == txid:
utxo.spent_txid = None
utxo.spent_height = None
def get_spender(self, outpoint):
txid = self.spenders.get(outpoint)
@@ -837,6 +843,73 @@ def test_get_available_utxos_none_locktime_is_raw_view():
assert Util.get_available_utxos(None, _HISTORY_TEMPLATE, 1000) == []
# ------------------------------------------------------------------ #
# Will.remove_stale_wallet_history (pre-build history purge)
# ------------------------------------------------------------------ #
def test_remove_stale_wallet_history_frees_equal_locktime_spend():
# The stale placeholders (saved by a previous prepare) have the SAME
# locktime as the will being rebuilt, so get_available_utxos does NOT
# restore their coins (see test_...does_not_restore_not_later_locktime).
# The pre-build purge deletes them and the coins become available again.
wallet, utxo = _wallet_with_local_spend(locktime=1000)
spender = "ab" * 32
assert Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000) == []
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
assert removed == [spender]
assert wallet.adb.removed == [spender]
assert spender not in wallet.labels
assert [
u.prevout.to_str()
for u in Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
] == [utxo.prevout.to_str()]
def test_remove_stale_wallet_history_keeps_confirmed_spender():
# A broadcast (confirmed) BAL-labelled tx is never purged.
addr = "bcrt1qexample"
spender = "ab" * 32
utxo = _make_utxo(spent_txid=spender, spent_height=100)
wallet = FakeWallet(
stored_txs={spender: _make_multisig_ptx(0, locktime=2000)},
heights={spender: 100},
outputs={addr: {utxo.prevout.to_str(): utxo}},
addresses=[addr],
)
wallet.labels[spender] = _HISTORY_LABEL
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
assert removed == []
assert wallet.adb.removed == []
assert wallet.labels[spender] == _HISTORY_LABEL
def test_remove_stale_wallet_history_keeps_unlabeled_local_spender():
# Wallet-local BAL-status tx without a matching history label stays.
wallet, _ = _wallet_with_local_spend(locktime=1000)
spender = "ab" * 32
wallet.labels[spender] = "some other label"
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
assert removed == []
assert wallet.adb.removed == []
assert wallet.labels[spender] == "some other label"
def test_remove_stale_wallet_history_noop_without_wallet_or_adb():
assert Will.remove_stale_wallet_history(None, _HISTORY_TEMPLATE) == []
wallet = FakeWallet()
wallet.adb = None
assert Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) == []
def test_remove_stale_wallet_history_never_raises():
adb = MagicMock()
adb.get_tx_height.side_effect = RuntimeError("boom")
wallet = MagicMock()
wallet.adb = adb
wallet.get_all_labels.return_value = {"ab" * 32: _HISTORY_LABEL}
assert Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) == []
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #

View File

@@ -17,7 +17,6 @@ Run:
python3 -m pytest tests/test_core_will_invalidate.py -q
"""
import copy
import os
import sys
from unittest.mock import MagicMock, patch
@@ -75,9 +74,12 @@ def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None):
"change": "",
"baltx_fees": 100,
})
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
# Set the input value so the balance calculation works.
item.tx.inputs()[0]._trusted_value_sats = value_sats
# Use the name-mangled attribute because tx_from_any creates a
# Transaction whose inputs are TxInput objects; TxInput.value_sats()
# reads __value_sats, not _trusted_value_sats.
item.tx.inputs()[0]._TxInput__value_sats = value_sats
if not valid:
item.set_status("INVALIDATED", True)
return item
@@ -267,7 +269,7 @@ class TestInvalidateWill:
"""
item = _make_willitem(value_sats=100)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
wallet = _mock_wallet([_make_utxo(value_sats=100)])
result, mock_from_io, _ = _run_invalidate(will, wallet, fees_per_byte=100)

View File

@@ -21,7 +21,6 @@ Run:
python3 -m pytest tests/test_group_e_karen7_invalidate.py -q
"""
import copy
import json
import os
import sys
@@ -46,6 +45,7 @@ from electrum.transaction import (
from electrum.util import bfh
from bal.core.heirs import Heirs
from bal.core.util import copy_structure
from bal.core.will import Will, WillItem
# ------------------------------------------------------------------ #
@@ -219,7 +219,7 @@ def _txs_to_will(txs, heirs_data):
for txid, tx in txs.items():
item_dict = {
"tx": tx,
"heirs": copy.deepcopy(heirs_data),
"heirs": copy_structure(heirs_data),
"willexecutor": None,
"status": "",
"description": "",
@@ -281,10 +281,10 @@ class TestKaren7BuildAndInvalidate:
)
def test_built_tx_has_karen7_heirs(self):
"""The built will contains karen7's four heirs."""
assert len(self.heirs_model) == 4
"""The built will contains karen7's six heirs."""
assert len(self.heirs_model) == 6
assert list(self.heirs_model.keys()) == [
"aaaa", "lucia", "mario", "mario2"
"aaaa", "lucia", "mario", "mario2", "op_return", "op_return2"
]
def test_will_items_are_valid(self):

View File

@@ -22,7 +22,6 @@ Run:
python3 -m pytest tests/test_group_e_mock_karen7.py -q
"""
import copy
import json
import os
import sys
@@ -43,6 +42,7 @@ from bal.core.reminders import (
ical_escape,
write_temp_ics,
)
from bal.core.util import copy_structure
from bal.core.will import HeirNotFoundException, Will, WillItem
from bal.core.willexecutors import Willexecutors
@@ -136,7 +136,7 @@ def _make_willitem(**overrides):
}
d.update(overrides)
item = WillItem(d)
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
return item
@@ -343,7 +343,7 @@ def test_e2_heir_change_triggers_rebuild():
item = WillItem(
{
"tx": _VALID_TX_HEX,
"heirs": copy.deepcopy(will_heirs),
"heirs": copy_structure(will_heirs),
"willexecutor": None,
"status": "",
"description": "",
@@ -352,7 +352,7 @@ def test_e2_heir_change_triggers_rebuild():
"baltx_fees": 100,
}
)
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
item.tx.locktime = lt
will = {"willid_1": item}
@@ -570,8 +570,8 @@ def test_e5_build_with_real_wallet_heirs_and_utxos():
h = Heirs.__new__(Heirs)
h.update(heirs_data)
assert len(h) == 4, f"expected 4 heirs, got {len(h)}"
assert list(h.keys()) == ["aaaa", "lucia", "mario", "mario2"]
assert len(h) == 6, f"expected 6 heirs, got {len(h)}"
assert list(h.keys()) == ["aaaa", "lucia", "mario", "mario2", "op_return", "op_return2"]
# Mock the Electrum-heavy parts so the build can run in a test context.
wallet = MagicMock()

View File

@@ -15,7 +15,7 @@ pure policy in ``bal.core.checkalive``:
* ``resolve_date_to_check(is_basic_mode, will_settings)`` - the single
reference timestamp: "now" in BASIC, the stored threshold in ADVANCED;
* ``check_alive_expired(is_basic_mode, date_to_check)`` - whether the
* ``ensure_check_alive_valid(is_basic_mode, ...)`` - whether the
Check-Alive guard should fire (never in BASIC).
These tests exercise the exact code used at runtime (no GUI/Electrum wallet
@@ -33,7 +33,8 @@ sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.checkalive import ( # noqa: E402 (path insert above)
CheckAliveError,
check_alive_expired,
ensure_check_alive_valid,
is_check_alive_date_valid,
resolve_date_to_check,
)
@@ -72,23 +73,29 @@ def test_advanced_mode_parses_relative_threshold():
def test_basic_mode_never_expired():
"""BASIC mode can never be "expired": a passed check-alive date must never
force a postpone/rewrite of the will."""
assert check_alive_expired(True, time.time() - 10_000) is False
assert check_alive_expired(True, 1_000_000_000.0) is False
assert ensure_check_alive_valid(True, time.time() - 10_000) is None
assert ensure_check_alive_valid(True, 1_000_000_000.0) is None
def test_advanced_mode_expired_when_past():
assert check_alive_expired(False, time.time() - 10_000) is True
assert is_check_alive_date_valid(time.time() - 10_000, None) is False
try:
ensure_check_alive_valid(False, time.time() - 10_000)
except CheckAliveError:
pass
else:
raise AssertionError("expected CheckAliveError")
def test_advanced_mode_not_expired_when_future():
assert check_alive_expired(False, time.time() + 10_000) is False
assert is_check_alive_date_valid(time.time() + 10_000, None) is True
def test_basic_mode_never_raises_check_alive_error():
"""Regression guard for the reported bug: whatever the delivery date,
BASIC mode never raises CheckAliveError (the postpone/invalidate trigger)."""
date_to_check = resolve_date_to_check(True, {"threshold": "30d"})
assert not check_alive_expired(True, date_to_check)
date_to_check = resolve_date_to_check(True, {"threshold": "-30d"})
assert ensure_check_alive_valid(True, date_to_check) is None
# If it did fire, this is the exact exception that would be raised.
assert issubclass(CheckAliveError, Exception)

View File

@@ -0,0 +1,464 @@
"""
Tests for the filter-based unified export/import dialogs and the BalWindow
transport helpers (``bal.gui.qt.dialogs``, ``bal.gui.qt.window``).
Covers the shared export filters (All / Valid / Valid NC), the unified
``WillExportDialog`` file page (whole item vs tx-only content, empty-filter
abort), the audio export/import pages (KB/sec wiring, missing plugin guard,
receive flow) and the comma-separated tx-only file writer. The audio pages
run against a stub plugin so no sound hardware is exercised.
Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_export_dialogs.py
"""
import base64
import json
import sys
import zlib
from unittest.mock import patch
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtWidgets import QApplication, QMainWindow
import bal.gui.qt.dialogs as dialogs
from bal.core.qrtransfer import CHUNK_PRESETS
_app = QApplication.instance() or QApplication(sys.argv)
# A valid 1x1 transparent PNG, good enough for BalDialog's window icon.
_PNG_BYTES = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
# version 2); used for real-WillItem serialization tests.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
class _Cfg:
def __init__(self, value):
self._value = value
def get(self):
return self._value
def set(self, value, save=True):
self._value = value
class FakePlugin:
def __init__(self):
self.QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
def read_file(self, path):
return _PNG_BYTES
class FakeWindow(QMainWindow):
config = {}
def format_amount(self, amount):
return "{:.8f}".format(amount)
def format_amount_and_units(self, amount):
return "{:.8f} sat".format(amount)
class FakeAudioPlugin:
"""Duck-typed ``audio_modem`` plugin for the audio pages."""
def __init__(self):
self.modem_config = None
def is_available(self):
return True
class _FakeModemConfig:
def __init__(self, kbps):
self.modem_bps = kbps * 1000
class FakeBalWindow:
"""Duck-typed stand-in for BalWindow (dialog layer only)."""
def __init__(self, audio_plugin=None):
self.window = FakeWindow()
self.bal_plugin = FakePlugin()
self.willitems = {}
self.audio_plugin = audio_plugin
self.bitrate_set = None
self.audio_payloads = []
def get_audio_modem_plugin(self):
return self.audio_plugin
def set_audio_modem_bitrate(self, kbps):
self.bitrate_set = kbps
if self.audio_plugin is not None:
self.audio_plugin.modem_config = _FakeModemConfig(kbps)
def _audio_send_payload(self, payload):
self.audio_payloads.append(payload)
def export_json_file(self, path, will=None):
items = will if will is not None else self.willitems
with open(path, "w", encoding="utf-8") as f:
json.dump({wid: wi.to_dict() for wid, wi in items.items()}, f)
def export_tx_file(self, path, will=None):
items = will if will is not None else self.willitems
with open(path, "w", encoding="utf-8") as f:
f.write(",".join(str(wi.tx) for _, wi in items.items()))
class StubTx:
def __init__(self, payload):
self.payload = payload
def txid(self):
return "{:064x}".format(hash(self.payload) & 0xFFFFFFFFFFFFFFFF)
def __str__(self):
return self.payload
class StubWillItem:
def __init__(self, payload, statuses=None):
self.tx = StubTx(payload)
self.statuses = statuses or {}
def get_status(self, name):
return self.statuses.get(name, False)
def to_dict(self):
return {"tx": str(self.tx)}
def _make_willitems(n=3, payload_len=60, statuses=None):
return {
"item{}".format(i): StubWillItem(
"T{}".format(i) * payload_len, statuses=statuses
)
for i in range(n)
}
# ------------------------------------------------------------------ #
# Shared export filters
# ------------------------------------------------------------------ #
def test_export_filter_options():
opts = dialogs.export_filter_options()
assert [label for label, _fn in opts] == ["All", "Valid", "Valid NC"]
complete = StubWillItem("C*", statuses={"VALID": True, "COMPLETE": True})
valid = StubWillItem("V*", statuses={"VALID": True})
plain = StubWillItem("P*")
assert opts[0][1](complete) and opts[0][1](plain)
assert opts[1][1](complete) and opts[1][1](valid) and not opts[1][1](plain)
assert not opts[2][1](complete)
assert opts[2][1](valid) and not opts[2][1](plain)
def test_filter_willitems_by_index():
items = {
"a": StubWillItem("A*", statuses={"VALID": True, "COMPLETE": True}),
"b": StubWillItem("B*", statuses={"VALID": True}),
"c": StubWillItem("C*"),
}
opts = dialogs.export_filter_options()
assert set(dialogs.filter_willitems(items, opts, 0)) == {"a", "b", "c"}
assert set(dialogs.filter_willitems(items, opts, 1)) == {"a", "b"}
assert dialogs.filter_willitems(items, opts, 2) == {"b": items["b"]}
# ------------------------------------------------------------------ #
# WillExportDialog file page
# ------------------------------------------------------------------ #
def test_file_export_selects_by_filter():
bw = FakeBalWindow()
items = _make_willitems(3)
items["item0"].statuses = {"VALID": True, "COMPLETE": True}
items["item1"].statuses = {"VALID": True}
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
assert set(d._selected_items()) == set(items)
d._on_filter_change(1)
assert set(d._selected_items()) == {"item0", "item1"}
d._on_filter_change(2)
assert list(d._selected_items()) == ["item1"]
d.close()
def test_file_export_run_tx_only(tmpdir):
bw = FakeBalWindow()
items = _make_willitems(3)
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
d.content_check.setChecked(False)
captured = {}
def fake_gui(window, title, exporter):
captured["title"] = title
captured["exporter"] = exporter
with patch.object(dialogs, "export_meta_gui", side_effect=fake_gui):
d._export_file()
assert captured["title"] == "will_tx"
out = tmpdir.join("will_tx.txt").strpath
captured["exporter"](out)
expected = ",".join(str(wi.tx) for _, wi in items.items())
assert open(out, encoding="utf-8").read() == expected
d.close()
def test_file_export_run_willitem(tmpdir):
bw = FakeBalWindow()
items = _make_willitems(2)
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
assert d.content_check.isChecked()
captured = {}
def fake_gui(window, title, exporter):
captured["title"] = title
captured["exporter"] = exporter
with patch.object(dialogs, "export_meta_gui", side_effect=fake_gui):
d._export_file()
assert captured["title"] == "will"
out = tmpdir.join("will.json").strpath
captured["exporter"](out)
data = json.load(open(out, encoding="utf-8"))
assert set(data) == set(items)
assert data["item0"]["tx"] == str(items["item0"].tx)
d.close()
def test_file_export_empty_under_filter_aborts():
bw = FakeBalWindow()
items = {
"a": StubWillItem("A*", statuses={"VALID": True, "COMPLETE": True})
}
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
messages = []
d.show_message = lambda msg: messages.append(msg) # type: ignore[assignment]
d._filter_index = 2 # force an empty "Valid NC" selection
with patch.object(dialogs, "export_meta_gui") as gui:
d._export_file()
assert not gui.called
assert messages
d.close()
# ------------------------------------------------------------------ #
# BalWindow transport helpers
# ------------------------------------------------------------------ #
def test_bal_window_export_tx_file(tmpdir):
import bal.gui.qt.window as window
items = _make_willitems(3)
bw = object.__new__(window.BalWindow)
bw.willitems = items
out = tmpdir.join("will_tx.txt").strpath
bw.export_tx_file(out)
expected = ",".join(str(wi.tx) for _, wi in items.items())
assert open(out, encoding="utf-8").read() == expected
def test_bal_window_set_audio_modem_bitrate():
try:
import amodem.config
except ImportError:
return
import bal.gui.qt.window as window
class P:
def __init__(self):
self.modem_config = None
probe = P()
bw = object.__new__(window.BalWindow)
bw.get_audio_modem_plugin = lambda: probe
bw.set_audio_modem_bitrate(1)
assert probe.modem_config is amodem.config.bitrates[1]
# ------------------------------------------------------------------ #
# WillExportDialog audio page
# ------------------------------------------------------------------ #
def test_audio_export_page_send():
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
items = _make_willitems(3, payload_len=30)
bw.willitems = items
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
assert d.audio_send_btn.text() == dialogs._("Send")
assert d.audio_send_btn.isEnabled()
assert d.transport_audio.isEnabled()
assert d.kbps_combo.count() > 0
kbps = int(d.kbps_combo.currentText())
d._send_audio()
assert bw.bitrate_set == kbps
# Whole-will default: the audio payload is a single JSON document.
assert len(bw.audio_payloads) == 1
data = json.loads(bw.audio_payloads[0])
assert set(data) == set(items)
d.close()
def test_audio_export_page_send_tx_only():
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
items = _make_willitems(3, payload_len=30)
bw.willitems = items
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
d.content_check.setChecked(False)
kbps = int(d.kbps_combo.currentText())
d._send_audio()
assert bw.bitrate_set == kbps
expected = "\n".join(dialogs.serialize_tx_list(items))
assert bw.audio_payloads == [expected]
d.close()
def test_audio_export_page_plugin_missing():
# The window stays usable: only the audio option is disabled.
bw = FakeBalWindow(audio_plugin=None)
bw.willitems = _make_willitems(2)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
assert not d.audio_send_btn.isEnabled()
assert not d.transport_audio.isEnabled()
assert "not available" in d.audio_warn_label.text()
assert len(d.qr_page.frames) >= 1 # QR still usable
assert d.file_export_btn.isEnabled()
d.close()
# ------------------------------------------------------------------ #
# WillImportDialog audio page
# ------------------------------------------------------------------ #
def test_audio_import_page_build():
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
assert d.kbps_combo.count() > 0
assert d.receive_btn.text() == dialogs._("Receive by audio…")
assert d.receive_btn.isEnabled()
assert d.transport_audio.isEnabled()
d.close()
def test_audio_import_page_plugin_missing():
bw = FakeBalWindow(audio_plugin=None)
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
assert not d.receive_btn.isEnabled()
assert not d.transport_audio.isEnabled()
assert "not available" in d.audio_warn_label.text()
assert d.qr_page is not None # QR import still usable
d.close()
def test_audio_import_receive_wiring():
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
captured = {}
class FakeWaitingDialog:
def __init__(self, parent, msg, task, on_success=None, on_error=None):
captured["msg"] = msg
captured["task"] = task
captured["success"] = on_success
captured["error"] = on_error
imported = []
def fake_complete(bal_window, bal_plugin, payload, **kwargs):
imported.append(payload)
blob = zlib.compress(b"A" * 40 + b"\n" + b"B" * 40)
with patch.object(dialogs, "WaitingDialog", FakeWaitingDialog), patch.object(
dialogs, "_complete_import", side_effect=fake_complete
):
d._audio_receive()
kbps = int(d.kbps_combo.currentText())
assert bw.bitrate_set == kbps
assert captured["task"] is not None
captured["success"](blob)
# Payload is the raw decompressed text; autodetect handles the splitting.
assert imported == ["A" * 40 + "\n" + "B" * 40]
d.close()
# ------------------------------------------------------------------ #
# Whole-will JSON payload serializes a real Transaction (MyEncoder)
# ------------------------------------------------------------------ #
def test_qr_whole_will_json_serializes_transaction():
"""Regression: _whole_will_json must not raise
"Object of type Transaction is not JSON serializable".
Real WillItems keep a ``Transaction`` object in ``tx``; the whole-will
QR payload (default content scope) must serialize it via MyEncoder the
same way write_json_file does.
"""
from bal.core.will import WillItem
item = WillItem({
"tx": _VALID_TX_HEX,
"heirs": {},
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": 100,
})
bw = FakeBalWindow()
d = dialogs.WillExportDialog(
bw, will={"imp0": item}, bal_plugin=bw.bal_plugin, initial_mode="qr"
)
j = d._whole_will_json()
data = json.loads(j)
assert data["imp0"]["tx"] == _VALID_TX_HEX
d.close()
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
import inspect
import os
import tempfile
class _Path:
def __init__(self, d, name):
self.strpath = os.path.join(d, name)
class _Tmp:
def __init__(self):
self._d = tempfile.mkdtemp()
def join(self, name):
return _Path(self._d, name)
tmp = _Tmp()
for name in sorted(dir()):
if name.startswith("test_"):
fn = globals()[name]
fn(tmp) if inspect.signature(fn).parameters else fn()
print(" [OK] {}".format(name))
print("[OK] All export dialog GUI tests passed")

View File

@@ -179,6 +179,7 @@ def test_rebuild_path_schedules_full_refresh():
win.date_to_check = 1_800_000_000
win.will_settings = {"baltx_fees": 1, "locktime": "1 month"}
win.bal_plugin = _CfgBag(
is_basic_mode=lambda: False,
MAX_WILLEXECUTOR_FEE=_Cfg(1),
SAVE_HISTORY=_Cfg(True),
HISTORY_LABEL=_Cfg("LBL"),
@@ -204,6 +205,46 @@ def test_rebuild_path_schedules_full_refresh():
schedule_mock.assert_called_once_with()
def test_rebuild_purges_stale_wallet_history_before_building():
# The rebuild path must drop stale wallet-LOCAL will placeholders (saved by
# an earlier prepare) so their coins are available to the new build.
win = object.__new__(BalWindow)
win.disable_plugin = False
win.heirs = {"h": object()}
win.willexecutors = {}
win.no_willexecutor = True
win.willitems = {}
win.will = {}
win.date_to_check = 1_800_000_000
win.will_settings = {"baltx_fees": 1, "locktime": "1 month"}
win.bal_plugin = _CfgBag(
is_basic_mode=lambda: False,
MAX_WILLEXECUTOR_FEE=_Cfg(1),
SAVE_HISTORY=_Cfg(True),
HISTORY_LABEL=_Cfg("LBL"),
)
win.window = _FakeWindow()
win.window.wallet = _Wallet()
with (
patch.object(Util, "get_available_utxos", return_value=[]),
patch.object(Util, "parse_locktime_string", return_value=1_800_000_001),
patch.object(Will, "get_min_locktime", return_value=0),
patch.object(Will, "check_amounts"),
patch.object(BalWindow, "init_class_variables"),
patch.object(BalWindow, "build_will"),
patch.object(
BalWindow,
"check_will",
side_effect=[NotCompleteWillException(), None],
),
patch.object(BalWindow, "update_all"),
patch.object(BalWindow, "_schedule_history_refresh"),
patch.object(Will, "remove_stale_wallet_history") as purge_mock,
):
BalWindow.build_inheritance_transaction(win)
purge_mock.assert_called_once_with(win.window.wallet, "LBL")
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #

View File

@@ -0,0 +1,739 @@
"""
Tests for the QR / audio will-transfer dialogs (``bal.gui.qt.dialogs``).
Covers the unified ``WillExportDialog`` (transport radios, stacked pages,
QR build/navigation, chunk-size / autoplay, filter revert) and the unified
``WillImportDialog`` (QR frame capture, slot grid, complete-review enabling,
total mismatch reset, frame assembly -> decode). The QR transport now uses
only the BBQR wire format. The wizard and the camera/audio paths need a live
wallet/hardware and are exercised only through the shared frame-assembly
path here.
Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py
"""
import base64
import json
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from electrum.transaction import Transaction
from PyQt6.QtWidgets import QApplication, QMainWindow
import bal.gui.qt.dialogs as dialogs
from bal.core.animated_qr import bbqr_frames
from bal.core.qrtransfer import CHUNK_PRESETS, encode_transfer
from bal.core.will import WillItem
_app = QApplication.instance() or QApplication(sys.argv)
# A valid 1x1 transparent PNG, good enough for BalDialog's window icon.
_PNG_BYTES = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output),
# reused for the WillItem status regression test.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
def _rand_hex(n):
"""Incompressible lowercase hex string of ``n`` random bytes."""
return os.urandom(n).hex()
def _bbqr_frames(transfer, budget):
return bbqr_frames(transfer.encode("utf-8"), budget, encoding="Z")
class _Cfg:
def __init__(self, value):
self._value = value
def get(self):
return self._value
def set(self, value, save=True):
self._value = value
class FakePlugin:
# Smallest QR preset: long transfers produce several frames.
def __init__(self):
self.QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
def read_file(self, path):
return _PNG_BYTES
class FakeWindow(QMainWindow):
config = {}
def format_amount(self, amount):
return "{:.8f}".format(amount)
def format_amount_and_units(self, amount):
return "{:.8f} sat".format(amount)
class FakeBalWindow:
"""Duck-typed stand-in for BalWindow (dialog layer only)."""
def __init__(self):
self.window = FakeWindow()
self.bal_plugin = FakePlugin()
self.willitems = {}
def get_audio_modem_plugin(self):
return None
class StubTx:
def __init__(self, payload):
self.payload = payload
def txid(self):
return "{:064x}".format(hash(self.payload) & 0xFFFFFFFFFFFFFFFF)
def __str__(self):
return self.payload
class StubWillItem:
def __init__(self, payload, statuses=None):
self.tx = StubTx(payload)
self.statuses = statuses or {}
def get_status(self, name):
return self.statuses.get(name, False)
def to_dict(self):
return {"tx": str(self.tx), "status": self.statuses}
def _make_willitems(n=3, payload_len=120, payloads=None):
if payloads is None:
payloads = [_rand_hex(payload_len) for _ in range(n)]
return {
"item{}".format(i): StubWillItem(p)
for i, p in enumerate(payloads)
}
# ------------------------------------------------------------------ #
# WillExportDialog (QR transport via d.qr_page)
# ------------------------------------------------------------------ #
def test_export_dialog_builds():
bw = FakeBalWindow()
bw.willitems = _make_willitems()
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
assert d.transport_qr.isChecked()
assert page.tx_strings
assert page.frames
assert len(page.frames) >= 1
# Frame 1 is shown, and it is a BBQR frame.
assert page.qr_view.text == page.frames[0]
assert page.frames[0].startswith("B$")
assert "1" in page.progress_label.text()
d.close()
def test_export_dialog_unified_transports():
# One window hosts the three transports as stacked, radio-selected pages.
bw = FakeBalWindow()
bw.willitems = _make_willitems()
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
assert d.stacked.count() == 3
assert d.transport_file.isChecked()
assert d.stacked.currentWidget() is d.file_page
d._on_mode_clicked(d.MODE_QR)
assert d.stacked.currentWidget() is d.qr_page
d._on_mode_clicked(d.MODE_AUDIO)
assert d.stacked.currentWidget() is d.audio_page
d.close()
def test_import_dialog_qr_page_has_no_audio():
# Audio lives on the import dialog's own audio page, never in the QR page.
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
assert not hasattr(page, "_audio_receive")
texts = [b.text() for b in page.findChildren(dialogs.QPushButton)]
assert not any("Audio" in t for t in texts)
assert d.receive_btn is not None
d.close()
def test_export_dialog_empty_close():
# An empty will shows a modal message and no widgets are built; stub the
# message out for the test.
orig = dialogs.MessageBoxMixin.show_message
dialogs.MessageBoxMixin.show_message = lambda self, msg, icon=None: None
try:
bw = FakeBalWindow()
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
assert not d.isVisible()
assert not hasattr(d, "qr_page")
d.close()
finally:
dialogs.MessageBoxMixin.show_message = orig
def test_imported_item_status_not_none():
# Regression: a WillItem built from a bare {"tx": hex} had a None status,
# so set_status (e.g. IMPORTED / INVALIDATED from the import validity
# pass) crashed with "unsupported operand type(s) for +=: 'NoneType' and
# 'str'".
with patch.object(Transaction, "add_info_from_wallet"):
wi = WillItem({"tx": _VALID_TX_HEX}, wallet=None)
assert wi.status == ""
assert wi.set_status("IMPORTED", True) is True
assert wi.set_status("INVALIDATED", True) is True
assert "Imported" in wi.status and "Invalidated" in wi.status
def test_export_auto_scroll():
bw = FakeBalWindow()
bw.willitems = _make_willitems(n=6, payload_len=400)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
assert page.fps_spin is not None
assert len(page.frames) > 1
assert not page.auto_timer.isActive()
page.fps_spin.setValue(2)
page._toggle_auto()
assert page.auto_timer.isActive()
assert page.auto_btn.text() == dialogs._("Stop")
page._auto_step()
assert page.index == 1
page._toggle_auto()
assert not page.auto_timer.isActive()
assert page.auto_btn.text() == dialogs._("Auto")
# Advancing past the last frame stops the slideshow automatically.
page._toggle_auto()
page.index = len(page.frames) - 1
page._auto_step()
assert not page.auto_timer.isActive()
d.close()
def test_export_auto_scroll_loop():
bw = FakeBalWindow()
bw.willitems = _make_willitems(n=6, payload_len=400)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
assert page.loop_check is not None
assert len(page.frames) > 1
# Loop on: reaching the last code wraps back to the first and keeps going.
page.loop_check.setChecked(True)
page._toggle_auto()
assert page.auto_timer.isActive()
page.index = len(page.frames) - 1
page._auto_step()
assert page.index == 0
assert page.auto_timer.isActive()
page._toggle_auto()
# Loop off: reaching the last code stops the slideshow.
page.loop_check.setChecked(False)
page._toggle_auto()
page.index = len(page.frames) - 1
page._auto_step()
assert not page.auto_timer.isActive()
d.close()
def test_export_filter_valid_and_valid_nc():
bw = FakeBalWindow()
a = StubWillItem(_rand_hex(120), statuses={"VALID": True, "COMPLETE": True})
b = StubWillItem(_rand_hex(120), statuses={"VALID": True})
c = StubWillItem(_rand_hex(120))
bw.willitems = {"a": a, "b": b, "c": c}
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
assert d.filter_combo.count() == 3
# Whole-will default: a single JSON document carrying all selected items.
assert d.content_check.isChecked()
assert len(page.tx_strings) == 1
data = json.loads(page.tx_strings[0])
assert set(data) == {"a", "b", "c"}
# Switch to tx-only content, then exercise the filters.
d.content_check.setChecked(False)
assert len(page.tx_strings) == 3
# "Valid" filter -> only the valid items (a, b).
d._on_filter_change(1)
assert sorted(page.tx_strings) == sorted([str(a.tx), str(b.tx)])
# "Valid NC" filter -> only the valid, not-complete item (b).
d._on_filter_change(2)
assert sorted(page.tx_strings) == [str(b.tx)]
assert page.qr_view.text == page.frames[0]
d.close()
def test_export_filter_empty_reverts():
bw = FakeBalWindow()
# Only a COMPLETE valid item: "Valid NC" selects nothing -> revert.
bw.willitems = {
"a": StubWillItem(_rand_hex(120), statuses={"VALID": True, "COMPLETE": True})
}
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
messages = []
d.show_message = lambda msg: messages.append(msg) # type: ignore[assignment]
d._on_filter_change(2) # "Valid NC" -> empty subset
assert messages
assert d._filter_index == 0 # reverted to "All"
assert d.filter_combo.currentIndex() == 0
assert len(d.qr_page.tx_strings) == 1
d.close()
def test_export_navigation_and_chunk_change():
# Incompressible serialized transactions resist deflate, so the BBQR
# transfer still needs several frames at the default chunk and navigation
# across frames is exercised.
bw = FakeBalWindow()
bw.willitems = _make_willitems(n=6, payload_len=400)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
first_count = len(page.frames)
assert first_count > 1 # long transfer, small default chunk
assert not page.prev_btn.isEnabled()
page._next()
assert page.index == 1
assert page.qr_view.text == page.frames[1]
assert page.prev_btn.isEnabled()
page._prev()
assert page.index == 0
assert page.qr_view.text == page.frames[0]
# Switch to the largest preset: fewer, bigger frames.
page._on_chunk_change(len(dialogs.CHUNK_PRESETS) - 1)
assert len(page.frames) < first_count
assert page.index == 0
d.close()
def test_export_size_change_is_remembered():
# Changing the QR size inside the export dialog persists the choice so the
# next export starts from the last selected size.
bw = FakeBalWindow()
bw.willitems = _make_willitems(n=6, payload_len=400)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
assert bw.bal_plugin.QR_CHUNK_SIZE.get() == dialogs.CHUNK_PRESETS[0][1]
page._on_chunk_change(len(dialogs.CHUNK_PRESETS) - 1)
assert bw.bal_plugin.QR_CHUNK_SIZE.get() == dialogs.CHUNK_PRESETS[-1][1]
d.close()
# ------------------------------------------------------------------ #
# WillImportDialog (QR transport via d.qr_page)
# ------------------------------------------------------------------ #
def test_import_frame_flow():
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
assert not page.review_btn.isEnabled()
assert not page.slot_area.isVisible()
transfer = encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)])
frames = _bbqr_frames(transfer, 150)
assert len(frames) > 1
for frame in frames:
page._add_frame(frame)
assert page.total == len(frames)
assert page.review_btn.isEnabled()
# isVisible() needs a shown parent; assert the widget is not hidden instead.
assert not page.slot_area.isHidden()
assert len(page.slot_widgets) == page.total
assert "All" in page.status_label.text()
# Duplicate capture is harmless.
page._add_frame(frames[0])
assert len(page.frames) == page.total
d.close()
def test_import_assembles_and_decodes():
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
captured = {}
def fake_finish(payload):
captured["payload"] = payload
page._finish_import = fake_finish
tx_a = _rand_hex(130)
tx_b = _rand_hex(130)
transfer = encode_transfer([tx_a, tx_b])
for frame in _bbqr_frames(transfer, 150):
page._add_frame(frame)
page._review_and_sign()
# The payload is rejoined into an opaque string for autodetect.
assert captured["payload"].split("\n") == [tx_a, tx_b]
d.close()
def test_decode_will_payload_autodetect():
# Whole-will JSON is recognized as a will document.
payload = json.dumps({"item1": {"tx": "AAAA", "status": {"VALID": True}}})
kind, data = dialogs.decode_will_payload(payload)
assert kind == "will"
assert data["item1"]["tx"] == "AAAA"
# A singleton dict whose value is not an item dict falls back to txs.
kind, data = dialogs.decode_will_payload('{"foo": 1}')
assert kind == "txs"
# Comma and/or newline separated transactions.
kind, data = dialogs.decode_will_payload("AAAA,BBBB\nCCCC")
assert kind == "txs"
assert data == ["AAAA", "BBBB", "CCCC"]
# A single transaction with no separators.
kind, data = dialogs.decode_will_payload("HEXHEX")
assert kind == "txs"
assert data == ["HEXHEX"]
def test_whole_will_qr_roundtrip():
# "Whole will" produces a single JSON document that survives a full
# QR encode -> frame capture -> assemble -> decode cycle.
bw = FakeBalWindow()
items = _make_willitems(2)
bw.willitems = items
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
assert d.content_check.isChecked()
assert len(page.tx_strings) == 1
# Rebuild the transfer from the dialog's own strings, as the importer does.
transfer = encode_transfer(page.tx_strings)
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
import_page = impl.qr_page
for frame in _bbqr_frames(transfer, dialogs.CHUNK_PRESETS[0][1]):
import_page._add_frame(frame)
assert import_page.review_btn.isEnabled()
caught = {}
def fake_finish(payload):
caught["payload"] = payload
import_page._finish_import = fake_finish
import_page._review_and_sign()
kind, data = dialogs.decode_will_payload(caught["payload"])
assert kind == "will"
assert set(data) == {"item0", "item1"}
d.close()
impl.close()
def test_import_total_mismatch_resets():
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
warnings = []
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
frames_a = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
frames_b = _bbqr_frames(
encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)]), 150
)
for frame in frames_a:
page._add_frame(frame)
assert page.total == len(frames_a)
# A frame with a different total wipes the import; the first frame of
# the new transfer must be scanned afresh.
page._add_frame(frames_b[0])
assert warnings
assert page.total == 0
assert not page.frames
assert not page.review_btn.isEnabled()
d.close()
def test_import_manual_entry():
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
frames = _bbqr_frames(encode_transfer(["M" * 20]), 150)
page.manual_edit.setText(frames[0])
page._add_from_manual()
assert page.manual_edit.text() == ""
assert page.total == 1
assert page.review_btn.isEnabled()
d.close()
# ------------------------------------------------------------------ #
# Continuous camera scan (change/detection debounce + auto-finish)
# ------------------------------------------------------------------ #
def _fresh_debounce():
return {
"last_index": None,
"last_payload": None,
"pending_index": None,
"pending_payload": None,
"pending_count": 0,
}
def test_qr_import_debounce_pending_then_accept():
s = _fresh_debounce()
# First sighting of a new identity: pending, not yet stored.
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "PAYLOAD1") == "pending"
assert s["pending_count"] == 1
assert s["last_index"] is None
# A second stable read of the same identity: accepted.
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "PAYLOAD1") == "accept"
assert s["last_index"] == 0
assert s["last_payload"] == "PAYLOAD1"
assert s["pending_count"] == 0
def test_qr_import_debounce_re_reading_last_is_ignored():
s = _fresh_debounce()
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1")
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") # accepted
# The exporter is still showing frame 0: must be ignored, not accepted.
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") == "ignore"
assert s["last_index"] == 0
assert s["pending_count"] == 0
def test_qr_import_debounce_transition_pending_resets():
s = _fresh_debounce()
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1")
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") # accept frame 0
# A new identity interrupts the pending accumulation.
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "pending"
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "accept"
# Same-index duplicate with different payload is treated as new identity.
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "ignore"
def test_qr_import_debounce_total_mismatch_resets():
s = _fresh_debounce()
# In-range frame is accepted even though its declared total is ignored.
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:3", 3, 1, "P2") == "pending"
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:3", 3, 1, "P2") == "accept"
# A frame that belongs to a different transfer.
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:4", 4, 1, "P2B") == "reset"
# Once the policy is rebased on the new transfer, frames resume normally
# (the widget clears the debounce while wiping the import).
s["key"] = None
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:4", 4, 1, "P2B") == "pending"
def test_qr_import_handle_scanned_text_autofinish():
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
reviewed = []
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
frames = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
assert len(frames) > 1
# The camera session is running and every frame needs two stable reads.
page._scanning = True
for frame in frames:
for _rep in range(2):
page._handle_scanned_text(frame)
assert page.total == len(frames)
assert len(page.frames) == len(frames)
assert page.review_btn.isEnabled()
# With all frames stored, the loop auto-finishes exactly once.
_app.processEvents()
assert reviewed == [True]
# The camera loop was stopped before handing over to the review step.
assert not page._scanning
assert not page._scan_timer.isActive()
d.close()
def test_qr_import_handle_scanned_text_manual_does_not_autofinish():
# Without a camera session running, extra frames never auto-proceed.
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
reviewed = []
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
frames = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
assert len(frames) > 1
assert not page._scanning
for frame in frames:
for _rep in range(2):
page._handle_scanned_text(frame)
assert len(page.frames) == len(frames)
_app.processEvents()
assert reviewed == []
d.close()
# ------------------------------------------------------------------ #
# BBQR import/export round trips
# ------------------------------------------------------------------ #
def test_import_bbqr_roundtrip():
bw = FakeBalWindow()
bw.willitems = _make_willitems(2)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
frames = list(page.frames)
assert frames and all(f.startswith("B$") for f in frames)
d.close()
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
import_page = impl.qr_page
caught = {}
import_page._finish_import = lambda payload: caught.__setitem__("payload", payload)
for frame in frames:
import_page._add_frame(frame)
assert import_page.review_btn.isEnabled()
import_page._review_and_sign()
kind, data = dialogs.decode_will_payload(caught["payload"])
assert kind == "will"
assert set(data) == {"item0", "item1"}
impl.close()
def test_import_animated_scan_debounce_autofinish():
bw = FakeBalWindow()
bw.willitems = _make_willitems(2)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
frames = list(page.frames)
d.close()
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
import_page = impl.qr_page
reviewed = []
import_page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
import_page._scanning = True
for frame in frames:
for _rep in range(2):
import_page._handle_scanned_text(frame)
assert import_page.review_btn.isEnabled()
assert import_page.total == len(frames)
assert len(import_page.frames) >= import_page.total
_app.processEvents()
assert reviewed == [True]
assert not import_page._scanning
impl.close()
def test_import_garbage_scan_is_ignored():
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
for garbage in ("hello world", "12345", "B$ZZ", "ur:bytes/xyz", ""):
page._handle_scanned_text(garbage)
assert not page.frames
assert page.total == 0
assert not page.review_btn.isEnabled()
d.close()
def test_import_different_animated_transfer_resets():
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
warnings = []
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
frames_a = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
frames_b = _bbqr_frames(
encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)]), 150
)
for frame in frames_a:
page._add_frame(frame)
assert page.total == len(frames_a)
assert not warnings
page._add_frame(frames_b[0])
assert warnings
assert page.total == 0
assert not page.frames
assert not page.review_btn.isEnabled()
d.close()
def test_import_start_stop_scan_signal_wiring():
"""Regression: _start_scan/_stop_scan must use the QVideoSink signal
videoFrameChanged, not the videoFrame frame getter.
On PyQt6, ``QVideoSink.videoFrame`` is a method (the frame getter), so
``.videoFrame.connect(...)`` raises AttributeError. This test drives the
real sink life-cycle with a mocked camera and asserts the scan session
starts/ends cleanly with no error.
"""
from PyQt6.QtMultimedia import QCamera, QMediaCaptureSession, QMediaDevices
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
errors = []
page.show_error = lambda msg: errors.append(msg) # type: ignore[assignment]
fake_device = MagicMock()
fake_device.isNull.return_value = False
with (
patch.object(QMediaDevices, "defaultVideoInput", return_value=fake_device),
# Mock camera + capture session; the QVideoSink stays real so the
# videoFrameChanged connect/disconnect wiring is exercised for real.
patch.object(QCamera, "__new__", return_value=MagicMock()),
patch.object(QMediaCaptureSession, "__new__", return_value=MagicMock()),
):
page._start_scan()
assert page._scanning is True
assert not errors
page._stop_scan()
assert page._scanning is False
assert page._camera is None
assert page._video_sink is None
assert not errors
d.close()
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All QR transfer GUI tests passed")

View File

@@ -183,6 +183,22 @@ def test_locktime_raw_edit_get_set_value():
assert "d" in val
def test_locktime_date_edit_get_set_value_roundtrip():
"""set_value(x) must roundtrip to get_value() == x (same timezone).
Guards a timezone regression that made the Date editor return the stored
wall clock re-read as local time, i.e. ``x + utc_offset``. That broke the
set_value/get_value roundtrip and kept the valueEdited ->
update_setting_widgets -> set_value signal cycle alive forever, ending in a
RecursionError when opening the "Build your will" wizard (Next button).
"""
from bal.gui.qt.widgets import LockTimeDateEdit
edit = LockTimeDateEdit()
for ts in (1700000000, 1750000000, 2147483647):
edit.set_value(ts)
assert edit.get_value() == ts
# ------------------------------------------------------------------ #
# PercAmountEdit
# ------------------------------------------------------------------ #

View File

@@ -269,7 +269,9 @@ def test_prepare_will_builds_and_persists():
assert item.get_status("VALID"), "fresh items default to VALID"
assert txid == item.tx.txid()
assert isinstance(txid, str) and txid.startswith("2"), "raw tx id expected"
assert isinstance(txid, str) and len(txid) == 64 and all(
c in "0123456789abcdef" for c in txid
), "raw tx id expected (64-char hex, not a label/short id)"
assert not item.tx.is_complete(), "unsigned will must not be complete"
assert isinstance(item.tx.locktime, int) and item.tx.locktime > 0
@@ -388,6 +390,110 @@ def test_insufficient_funds_warns():
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():
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
for fn in tests:

View File

@@ -18,9 +18,10 @@ The two gates that produced the prompt are covered here:
never read as EXPIRED because the check window drifts past the frozen
tx locktime.
The karen7 regtest wallet fixture (``tests/karen7``) reproduces the exact
reported state: heirs with ``"1y"``, a signed/pushed/checked item whose frozen
tx.locktime is 2027-08-05 (built 2026-08-05), and will_settings
The reported state (reproduced hermetically here — the original live wallet
dump ``tests/karen7`` is gitignored and regenerated as the wallet evolves) is:
heirs with ``"1y"``, a signed/pushed/checked item whose frozen tx.locktime is
2027-08-05 (built 2026-08-05), and will_settings
``{"locktime": "2y", "threshold": "150d"}``.
Run:
@@ -28,18 +29,16 @@ Run:
python3 tests/test_heir_relative_anchor.py
"""
import copy
import json
import os
import sys
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)
constants.net = constants.BitcoinRegtest
from bal.core.checkalive import resolve_date_to_check # noqa: E402
from bal.core.util import copy_structure # noqa: E402
from bal.core.will import ( # noqa: E402
HeirNotFoundException,
NoHeirsException,
@@ -49,6 +48,16 @@ from bal.core.will import ( # noqa: E402
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;
# the tests override ``tx.locktime`` to simulate the frozen signed locktime.
_VALID_TX_HEX = (
@@ -61,9 +70,9 @@ _VALID_TX_HEX = (
"42146f11ef8414ae929feaafc388ac00000000"
)
# The frozen tx.locktime of karen7's valid item: delivery 2027-08-05, i.e. a
# will built 2026-08-05 with a "1y" recipe.
_FROZEN = 1817438400
# The frozen tx.locktime of karen7's valid item: delivery 2027-08-05 00:00 UTC,
# i.e. a will built 2026-08-05 with a "1y" recipe.
_FROZEN = 1817424000
def _make_will_item(heirs, tx_locktime, status_complete=False):
@@ -71,7 +80,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
d = {
"tx": _VALID_TX_HEX,
"heirs": copy.deepcopy(heirs),
"heirs": copy_structure(heirs),
"willexecutor": None,
"status": "",
"description": "",
@@ -80,7 +89,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
"baltx_fees": 1,
}
item = WillItem(d, _id="willid_1")
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
item.tx.locktime = tx_locktime
if status_complete:
item.set_status("COMPLETE", True)
@@ -111,7 +120,7 @@ def test_unchanged_relative_recipe_signed_is_coherent():
read as a postpone just because the clock has advanced past build day."""
heirs = {"alice": ["addr_alice", 5000, "1y"]}
outcome = _run_heir_check(
copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=True
copy_structure(heirs), copy_structure(heirs), _FROZEN, status_complete=True
)
assert outcome.startswith("coherent"), outcome
@@ -119,7 +128,7 @@ def test_unchanged_relative_recipe_signed_is_coherent():
def test_unchanged_relative_recipe_unsigned_is_coherent():
heirs = {"alice": ["addr_alice", 5000, "1y"]}
outcome = _run_heir_check(
copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=False
copy_structure(heirs), copy_structure(heirs), _FROZEN, status_complete=False
)
assert outcome.startswith("coherent"), outcome
@@ -145,7 +154,7 @@ def test_relative_recipe_shortened_on_signed_is_rebuild():
def test_unchanged_absolute_recipe_is_coherent():
built = {"alice": ["addr_alice", 5000, str(_FROZEN)]}
outcome = _run_heir_check(
copy.deepcopy(built), copy.deepcopy(built), _FROZEN, status_complete=True
copy_structure(built), copy_structure(built), _FROZEN, status_complete=True
)
assert outcome.startswith("coherent"), outcome
@@ -158,57 +167,57 @@ def test_absolute_postpone_on_signed_still_detected():
# ------------------------------------------------------------------ #
# karen7 wallet regression (real fixture)
# karen7 regression (hermetic, no live wallet fixture)
# ------------------------------------------------------------------ #
def _load_karen7():
path = os.path.join(os.path.dirname(__file__), "karen7")
with open(path) as f:
return json.load(f)
# 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
# relative "150d" delivery window and a "2y" promised locktime.
_WILL_SETTINGS = {"locktime": "2y", "threshold": "150d"}
def test_karen7_frozen_delivery_not_expired():
"""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."""
data = _load_karen7()
will_settings = data["will_settings"]
valid_wid = "def15833cf94c5795c6275076bdadebd809175455f6eb4d5f8db304f816433b8"
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
built_locktime = Will.get_min_locktime({valid_wid: wi})
assert built_locktime == _FROZEN
heirs = {"alice": ["addr_alice", 5000, "1y"]}
item = _make_will_item(copy_structure(heirs), _FROZEN, status_complete=True)
will = {"willid_1": item}
built_locktime = Will.get_min_locktime(will)
assert built_locktime is not None
assert built_locktime == int(item.tx.locktime)
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) < _FROZEN
assert int(date_to_check) < built_locktime
# Re-evaluated 10 days later the window is identical (no daily drift).
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,
)
assert date_to_check == later
def test_karen7_unchanged_heirs_are_coherent():
"""The karen7 heirs (unchanged relative "1y") are coherent with the frozen
signed tx: the plugin must NOT ask to invalidate the will."""
data = _load_karen7()
valid_wid = "def15833cf94c5795c6275076bdadebd809175455f6eb4d5f8db304f816433b8"
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
"""Unchanged relative "1y" heirs are coherent with the frozen signed tx:
the plugin must NOT ask to invalidate the will."""
heirs = {"alice": ["addr_alice", 5000, "1y"]}
# Use _FROZEN (a UTC-midnight value) so the check is compatible with
# the UTC anchoring code.
frozen_locktime = _FROZEN
date_to_check = resolve_date_to_check(
False, data["will_settings"],
False, _WILL_SETTINGS,
now=1_800_000_000.0,
built_locktime=int(wi.tx.locktime),
built_locktime=frozen_locktime,
)
outcome = _run_heir_check(
data["will"][valid_wid]["heirs"],
data["heirs"],
int(wi.tx.locktime),
copy_structure(heirs),
copy_structure(heirs),
frozen_locktime,
status_complete=True,
)
assert outcome.startswith("coherent"), outcome
assert int(date_to_check) < int(wi.tx.locktime)
assert int(date_to_check) < frozen_locktime
# ------------------------------------------------------------------ #
@@ -216,6 +225,7 @@ def test_karen7_unchanged_heirs_are_coherent():
# ------------------------------------------------------------------ #
if __name__ == "__main__":
constants.net = constants.BitcoinRegtest
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()

309
tests/test_i18n.py Normal file
View File

@@ -0,0 +1,309 @@
"""Tests for bal/i18n.py, BAL's translation layer (PLAN_I18N.md, phase 1).
The BAL catalogs are built in memory (see ``_mo_bytes``), so these tests need
no compiled .mo file and no Babel. Electrum's translator is replaced by a
small dictionary, so the tests do not depend on Electrum's own catalogs.
Run standalone (``python3 tests/test_i18n.py``) or with pytest.
"""
import array
import os
import struct
import sys
from contextlib import contextmanager
from types import SimpleNamespace
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal import i18n # noqa: E402
IT_MO = "locale/it_IT/LC_MESSAGES/bal.mo"
def _mo_bytes(messages):
"""Return a GNU .mo catalog (UTF-8) holding ``messages`` {msgid: msgstr}.
Same layout as CPython's Tools/i18n/msgfmt.py: header, the sorted msgid
and msgstr index tables, then the NUL-terminated strings.
"""
entries = {"": "Content-Type: text/plain; charset=UTF-8\n"}
entries.update(messages)
keys = sorted(entries)
ids = strs = b""
offsets = []
for key in keys:
msgid, msgstr = key.encode("utf-8"), entries[key].encode("utf-8")
offsets.append((len(ids), len(msgid), len(strs), len(msgstr)))
ids += msgid + b"\0"
strs += msgstr + b"\0"
keystart = 7 * 4 + 16 * len(keys)
valuestart = keystart + len(ids)
koffsets, voffsets = [], []
for o1, l1, o2, l2 in offsets:
koffsets += [l1, o1 + keystart]
voffsets += [l2, o2 + valuestart]
header = struct.pack(
"Iiiiiii", 0x950412DE, 0, len(keys), 7 * 4, 7 * 4 + len(keys) * 8, 0, 0
)
return header + array.array("i", koffsets + voffsets).tobytes() + ids + strs
class FakePlugin:
"""Stands in for Electrum's BasePlugin: serves read_file() from a dict."""
def __init__(self, files=None):
self.files = files or {}
self.requested = []
def read_file(self, filename):
self.requested.append(filename)
if filename not in self.files:
raise KeyError(filename) # what a missing entry in the zip raises
return self.files[filename]
@contextmanager
def _i18n_state(electrum=None, files=None, lang=None):
"""Fake Electrum's translations, load a fake BAL catalog, restore after.
Restoring matters: bal.i18n keeps module-level state, shared with every
other test of the same pytest run.
"""
saved = (i18n._electrum_gettext, i18n._catalog)
table = electrum or {}
i18n._electrum_gettext = lambda msg: table.get(msg, msg)
plugin = FakePlugin(files)
try:
i18n.set_language(plugin, lang)
yield plugin
finally:
i18n._electrum_gettext, i18n._catalog = saved
i18n._rejected.clear()
def test_empty_string_is_never_translated():
# For gettext "" is the key of the catalog header.
with _i18n_state(files={IT_MO: _mo_bytes({})}, lang="it_IT"):
assert i18n._catalog is not None
assert i18n._("") == ""
def test_electrum_translation_wins_over_bal_catalog():
catalog = _mo_bytes({"Cancel": "Cancella", "Heirs": "Eredi"})
with _i18n_state(
electrum={"Cancel": "Annulla"}, files={IT_MO: catalog}, lang="it_IT"
):
assert i18n._("Cancel") == "Annulla" # Electrum has it: Electrum wins
assert i18n._("Heirs") == "Eredi" # only BAL has it
def test_english_source_when_nobody_translates():
with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang="it_IT"):
assert i18n._("Not in any catalog") == "Not in any catalog"
def test_translation_with_different_fields_is_rejected():
catalog = _mo_bytes(
{
"{} heirs": "eredi", # field dropped: rejected
"Fee: {}": "Commissione: {}",
"{0} of {1}": "{1} di {0}", # re-ordered: allowed
"Hello {name}": "Ciao {nome}", # field renamed: rejected
}
)
with _i18n_state(files={IT_MO: catalog}, lang="it_IT"):
assert i18n._("{} heirs") == "{} heirs"
assert i18n._("Fee: {}") == "Commissione: {}"
assert i18n._("{0} of {1}") == "{1} di {0}"
assert i18n._("Hello {name}") == "Hello {name}"
def test_keeps_format_fields():
assert i18n.keeps_format_fields("Amount: {}", "Importo: {}")
assert not i18n.keeps_format_fields("Amount: {}", "Importo")
assert not i18n.keeps_format_fields("Amount: {}", "Importo: {} {}")
assert not i18n.keeps_format_fields("Amount: {}", "Importo: {") # malformed
assert i18n.keeps_format_fields("$wallet_name", "$wallet_name")
def test_english_loads_no_catalog():
with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang="en_UK") as p:
assert i18n._catalog is None
assert p.requested == []
assert i18n._("Heirs") == "Heirs"
def test_no_language_loads_no_catalog():
for lang in (None, ""):
with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang=lang):
assert i18n._catalog is None
def test_missing_catalog_falls_back_to_english():
with _i18n_state(files={}, lang="it_IT") as plugin:
assert i18n._catalog is None
assert plugin.requested == [IT_MO, "locale/it/LC_MESSAGES/bal.mo"]
assert i18n._("Heirs") == "Heirs"
def test_broken_catalog_falls_back_to_english():
with _i18n_state(files={IT_MO: b"this is not a catalog"}, lang="it_IT"):
assert i18n._catalog is None
assert i18n._("Heirs") == "Heirs"
def test_language_code_without_country_is_tried():
files = {"locale/it/LC_MESSAGES/bal.mo": _mo_bytes({"Heirs": "Eredi"})}
with _i18n_state(files=files, lang="it_IT"):
assert i18n._("Heirs") == "Eredi"
def test_n_marks_without_translating():
with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang="it_IT"):
assert i18n.N_("Heirs") == "Heirs"
assert i18n._(i18n.N_("Heirs")) == "Eredi"
def test_init_from_config_uses_electrum_language_setting():
config = SimpleNamespace(LOCALIZATION_LANGUAGE="it_IT")
plugin = FakePlugin({IT_MO: _mo_bytes({"Heirs": "Eredi"})})
with _i18n_state():
i18n.init_from_config(plugin, config)
assert i18n._("Heirs") == "Eredi"
def test_init_from_config_without_setting_uses_system_language():
import electrum.gui.default_lang as default_lang
saved = default_lang.get_default_language
default_lang.get_default_language = lambda gui_name=None: "it_IT"
config = SimpleNamespace(LOCALIZATION_LANGUAGE="")
plugin = FakePlugin({IT_MO: _mo_bytes({"Heirs": "Eredi"})})
try:
with _i18n_state():
i18n.init_from_config(plugin, config)
assert i18n._("Heirs") == "Eredi"
finally:
default_lang.get_default_language = saved
def test_init_from_config_never_raises():
plugin = FakePlugin({IT_MO: _mo_bytes({"Heirs": "Eredi"})})
with _i18n_state():
i18n.init_from_config(plugin, object()) # config without the setting
assert i18n._catalog is None
# --- Stored data stays language-neutral (PLAN_I18N.md, phase 2) -------------
def test_status_history_is_stored_in_english():
from bal.core.will import WillItem
catalog = _mo_bytes({"Signed": "Firmato", "Valid": "Valida"})
with _i18n_state(files={IT_MO: catalog}, lang="it_IT"):
# A bare item: set_status() only needs its status fields.
item = WillItem.__new__(WillItem)
item.status = ""
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
item.set_status("COMPLETE", True)
item.set_status("VALID", False)
assert item.status == ".Signed.NOT Valid"
def test_status_history_is_translated_for_display():
from bal.core.will import format_status_history
catalog = _mo_bytes(
{
"Signed": "Firmato",
"Valid": "Valida",
"NOT {}": "NON {}",
"New": "Nuovo",
"Replaced": "Sostituita",
}
)
with _i18n_state(files={IT_MO: catalog}, lang="it_IT"):
assert format_status_history(".Signed.NOT Valid") == ".Firmato.NON Valida"
# A real history saved by an older version (tests/samanta7), which
# stored some labels already translated: known English labels are
# translated, the other tokens are kept as they are.
assert (
format_status_history("New.Firmato.Pushed.Checked.Replaced")
== "Nuovo.Firmato.Pushed.Checked.Sostituita"
)
assert format_status_history(".SignedERROR!!!") == ".FirmatoERROR!!!"
with _i18n_state(): # English GUI: shown exactly as stored
assert format_status_history("New.Firmato.Pushed") == "New.Firmato.Pushed"
class _FakeConfig(dict):
"""Minimal stand-in for Electrum's SimpleConfig (get / set_key)."""
def get(self, key, default=None):
return dict.get(self, key, default)
def set_key(self, key, value, save=True):
self[key] = value
def test_translatable_config_default_follows_language():
from bal.core.plugin_base import BalConfig
config = _FakeConfig()
summary = BalConfig(config, "summary", "Will of $wallet_name", translatable=True)
plain = BalConfig(config, "label", "BAL label")
catalog = _mo_bytes(
{"Will of $wallet_name": "Testamento di $wallet_name", "BAL label": "X"}
)
with _i18n_state(files={IT_MO: catalog}, lang="it_IT"):
assert summary.get() == "Testamento di $wallet_name" # nothing stored
assert summary.localized_default() == "Testamento di $wallet_name"
summary.set("Testamento di $wallet_name") # e.g. the reset button
assert config["summary"] == "Will of $wallet_name" # stored in English
assert summary.get() == "Testamento di $wallet_name"
summary.set("My own text") # the user's text is kept as it is
assert summary.get() == "My own text"
assert plain.get() == "BAL label" # not translatable: never translated
with _i18n_state():
summary.set("Will of $wallet_name")
assert summary.get() == "Will of $wallet_name"
def test_calendar_reminder_suffix_is_translatable():
from datetime import datetime, timedelta
from bal.core.reminders import build_ics_reminders
now = datetime(2026, 1, 1)
kwargs = dict(
locktime=now + timedelta(days=400), basic_mode=True, description="d",
summary="s", wallet_name="w", heirs_details="", version="0", now=now,
)
assert "s (reminder 1/" in build_ics_reminders(**kwargs) # CLI default
ics = build_ics_reminders(reminder_suffix="(promemoria {idx}/{total})", **kwargs)
assert "s (promemoria 1/" in ics
def test_qr_preset_labels_match_chunk_presets():
# The labels are marked for translation in common.py because
# qrtransfer.py must stay free of Electrum imports (Android copy).
from bal.core.qrtransfer import CHUNK_PRESETS
from bal.gui.qt.common import QR_PRESET_LABELS
assert tuple(label for label, _budget in CHUNK_PRESETS) == QR_PRESET_LABELS
def test_real_translator_is_electrums():
# Outside the fakes above, BAL asks Electrum's real translator first.
from electrum.i18n import _ as electrum_gettext
assert i18n._electrum_gettext is electrum_gettext
if __name__ == "__main__":
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for test in tests:
test()
print(f"[OK] All {len(tests)} i18n tests passed")

View File

@@ -127,6 +127,11 @@ def test_sign_transactions_external_only():
wallet=FakeWallet(),
waiting_dialog=SimpleNamespace(update=lambda msg: None),
)
# sign_transactions dispatches to self._prepare_and_sign_tx; bind the real
# implementation onto the fake so the external-sign run actually executes.
fake._prepare_and_sign_tx = MethodType(
window_mod.BalWindow._prepare_and_sign_tx, fake
)
result = window_mod.BalWindow.sign_transactions(fake, None, will=imported)
@@ -166,6 +171,50 @@ def test_will_widget_explicit_will():
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
# ------------------------------------------------------------------ #

View File

@@ -27,7 +27,6 @@ Run::
python3 -m pytest tests/test_no_willexecutor_karen7.py -v -s
"""
import copy
import json
import logging
import os
@@ -49,6 +48,7 @@ from electrum.transaction import PartialTxInput, TxOutpoint
from electrum.util import bfh
from bal.core.heirs import Heirs
from bal.core.util import copy_structure
from bal.core.will import (
NotCompleteWillException,
NoWillExecutorNotPresent,
@@ -278,11 +278,11 @@ class FakeBalWindow:
tx["my_locktime"] = txs[txid].my_locktime
tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
tx["status"] = "New"
tx["baltx_fees"] = txs[txid].tx_fees
tx["time"] = creation_time
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
tx["heirs"] = copy_structure(txs[txid].heirs)
tx["txchildren"] = []
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
self.update_will(will)
@@ -392,7 +392,7 @@ class TestNoWillexecutorKaren7:
heirs_data = _KAREN7_DATA["heirs"]
h = Heirs.__new__(Heirs)
h.update(heirs_data)
assert len(h) == 4
assert len(h) == 6
self.heirs_obj = h
self.bal_plugin = _Karen7BalPlugin()

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Tests for the "Rebuild will on wallet close" (REBUILD_ON_CLOSE) setting.
Covers:
* the persisted ``bal_rebuild_on_close`` configuration key exists and
defaults to ON (True), and can be turned off and read back;
* ``BalWindow.on_close()`` runs the "Build your will" wizard
(``BalBuildWillDialog.build_will_task()``) when the setting is ON;
* ``BalWindow.on_close()`` SKIPS the wizard when the setting is OFF, but
still calls ``save_willitems()`` so the last built state is persisted.
The on_close tests drive the real ``BalWindow.on_close`` method with a
light-weight fake controller and a recording stub for ``BalBuildWillDialog``,
so no full wallet/GUI machinery is needed.
Run:
source "$BAL_HOME/electrum/env/bin/activate"
QT_QPA_PLATFORM=offscreen python3 tests/test_rebuild_on_close_setting.py
"""
import os
import sys
import types
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import bal.gui.qt.window as window_mod # noqa: E402
from bal.core.plugin_base import BalConfig # noqa: E402
CONFIG_KEY = "bal_rebuild_on_close"
# --------------------------------------------------------------------------- #
# Mocks
# --------------------------------------------------------------------------- #
class FakeConfig:
"""Minimal mock for Electrum's config object (key/value store)."""
def __init__(self):
self._store = {}
def get(self, key, default=None):
return self._store.get(key, default)
def set_key(self, key, value, save=True):
self._store[key] = value
class FakeBuildWillDialog:
"""Recording stub for BalBuildWillDialog, patched into window.py."""
instances = []
def __init__(self, bal_window):
self.bal_window = bal_window
FakeBuildWillDialog.instances.append(self)
def build_will_task(self):
self.bal_window._wizard_ran = True
class _Tabs:
def update(self):
pass
class _NoOp:
willexecutors_action = None
tabs = _Tabs()
def close(self):
pass
def toggle_tab(self, tab):
pass
def update(self):
pass
def removeAction(self, action):
pass
def _make_fake_window(rebuild_on_close):
"""Return a fake controller with the attributes on_close() touches."""
fake = types.SimpleNamespace()
fake.disable_plugin = False
fake.bal_plugin = types.SimpleNamespace(
REBUILD_ON_CLOSE=BalConfig(FakeConfig(), CONFIG_KEY, rebuild_on_close)
)
fake.willitems = {}
fake.will = {}
fake.saved = []
fake.save_willitems = lambda: fake.saved.append("save")
fake.heirs_tab = _NoOp()
fake.will_tab = _NoOp()
fake.tools_menu = _NoOp()
fake.window = _NoOp()
fake._menubar_initialized = True
return fake
def _call_on_close(fake):
original = window_mod.BalBuildWillDialog
FakeBuildWillDialog.instances = []
try:
window_mod.BalBuildWillDialog = FakeBuildWillDialog
window_mod.BalWindow.on_close(fake)
finally:
window_mod.BalBuildWillDialog = original
# --------------------------------------------------------------------------- #
# Config key
# --------------------------------------------------------------------------- #
def test_rebuild_on_close_config_defaults_on():
"""bal_rebuild_on_close must default to ON (True) when not yet stored."""
cfg = FakeConfig()
rebuild = BalConfig(cfg, CONFIG_KEY, True)
assert rebuild.get() is True
def test_rebuild_on_close_config_can_be_disabled():
"""Once turned off and persisted, bal_rebuild_on_close reads back False."""
cfg = FakeConfig()
rebuild = BalConfig(cfg, CONFIG_KEY, True)
rebuild.set(False)
assert rebuild.get() is False
# A fresh wrapper over the same config still sees the stored value.
assert BalConfig(cfg, CONFIG_KEY, True).get() is False
# --------------------------------------------------------------------------- #
# on_close() behaviour
# --------------------------------------------------------------------------- #
def test_on_close_runs_wizard_when_enabled():
"""With REBUILD_ON_CLOSE ON, on_close() builds the will and saves it."""
fake = _make_fake_window(True)
_call_on_close(fake)
assert len(FakeBuildWillDialog.instances) == 1, "wizard must be opened"
assert fake._wizard_ran is True, "wizard build_will_task must run"
assert fake.saved == ["save"], "save_willitems must run"
def test_on_close_skips_wizard_when_disabled():
"""With REBUILD_ON_CLOSE OFF, on_close() skips the wizard but saves."""
fake = _make_fake_window(False)
_call_on_close(fake)
assert len(FakeBuildWillDialog.instances) == 0, "wizard must NOT be opened"
assert not hasattr(fake, "_wizard_ran"), "wizard must not run"
assert fake.saved == ["save"], "save_willitems must still run"
if __name__ == "__main__":
test_rebuild_on_close_config_defaults_on()
test_rebuild_on_close_config_can_be_disabled()
test_on_close_runs_wizard_when_enabled()
test_on_close_skips_wizard_when_disabled()
print("OK: all tests passed")

View File

@@ -7,7 +7,6 @@ but without requiring a full Qt event loop.
"""
import contextlib
import copy
import json
import os
import sys
@@ -24,6 +23,7 @@ if os.path.isdir(ELECTRUM_DIR):
from bal.core.heirs import Heirs
from bal.core.plugin_base import BalPlugin, BalTimestamp
from bal.core.util import copy_structure
from bal.core.will import (
NoHeirsException,
NotCompleteWillException,
@@ -145,11 +145,11 @@ class FakeBalWindow:
tx["my_locktime"] = txs[txid].my_locktime
tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
tx["status"] = "New"
tx["baltx_fees"] = txs[txid].tx_fees
tx["time"] = creation_time
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
tx["heirs"] = copy_structure(txs[txid].heirs)
tx["txchildren"] = []
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
Will.update_will(self.willitems, will)
@@ -170,7 +170,7 @@ def test_simulate_task_phase1():
heirs_data = KAREN7_DATA["heirs"]
h = Heirs.__new__(Heirs)
h.update(heirs_data)
assert len(h) == 4
assert len(h) == 6
# 2. Build UTXOs
utxos = build_utxos(KAREN7_DATA)

View File

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

135
tests/test_translations.py Normal file
View File

@@ -0,0 +1,135 @@
"""Safety checks on BAL's translation catalogs (PLAN_I18N.md, section 4.4).
Runs on every ``bal/locale/<lang>/LC_MESSAGES/bal.po`` (the sources that
build_zip.py compiles into the zip). A translation is shown to the user as
if BAL wrote it, so a wrong or malicious one must fail here, before a zip is
built:
* the catalog must parse and compile;
* the ``{}`` replacement fields must match the English text (the same rule
bal.i18n applies at run time), and so must the ``$tokens`` of the calendar
texts (``$wallet_name``, ``$heirs_complete``), which Electrum does not check;
* no Bitcoin address, e-mail address, URL or long letters-and-digits word may
appear in a translation unless the English text has the same one: the
patterns are copied from Electrum's ``electrum-locale/update.py`` (MIT
licence, Copyright (C) The Electrum developers), which rejects translations
that try to slip in an address or a link.
Run standalone (``python3 tests/test_translations.py``, prints a per-language
summary) or with pytest. Needs Babel, like build_zip.py.
"""
import io
import os
import re
import sys
from pathlib import Path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from babel.messages.mofile import write_mo # noqa: E402
from babel.messages.pofile import read_po # noqa: E402
from bal.i18n import keeps_format_fields # noqa: E402
LOCALE_DIR = Path(__file__).resolve().parent.parent / "bal" / "locale"
# From electrum-locale/update.py (see the module docstring).
SUSPICIOUS = {
"Bitcoin address": re.compile("([13]|bc1)[a-zA-Z0-9]{30,}"),
"e-mail address": re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"),
"URL": re.compile(r"\S+\.\S*\w+\S*/\S+"),
"URL scheme": re.compile(r"http(s){0,1}://"),
"letters-and-digits word": re.compile(
r"(?a)(?=\w{16,})((\w*[a-zA-Z]\w*[0-9]\w*)|(\w*[0-9]\w*[a-zA-Z]\w*))"
),
}
TOKEN = re.compile(r"\$\w+")
def _catalogs():
return sorted(LOCALE_DIR.glob("*/LC_MESSAGES/bal.po"))
def _messages(po_path):
"""Yield the translated, non-fuzzy (msgid, msgstr) pairs of a catalog."""
with open(po_path, "rb") as f:
catalog = read_po(f)
for m in catalog:
if m.id and isinstance(m.id, str) and m.string and not m.fuzzy:
yield m.id, m.string
def check_catalog(po_path):
"""Return a list of problems found in one catalog (empty = fine)."""
problems = []
for msgid, msgstr in _messages(po_path):
if not keeps_format_fields(msgid, msgstr):
problems.append("{} fields differ: {!r}".format("{}", msgid))
if sorted(TOKEN.findall(msgid)) != sorted(TOKEN.findall(msgstr)):
problems.append("$tokens differ: {!r}".format(msgid))
for name, regex in SUSPICIOUS.items():
for match in regex.finditer(msgstr):
if match.group(0) not in msgid:
problems.append(
"{} {!r} not in the English text: {!r}".format(
name, match.group(0), msgid
)
)
return problems
def summary(po_path):
"""Return (translated, untranslated, fuzzy) counts of a catalog."""
with open(po_path, "rb") as f:
catalog = read_po(f)
msgs = [m for m in catalog if m.id]
fuzzy = sum(1 for m in msgs if m.fuzzy)
translated = sum(1 for m in msgs if m.string and not m.fuzzy)
return translated, len(msgs) - translated - fuzzy, fuzzy
def test_there_is_an_italian_catalog():
assert LOCALE_DIR / "it_IT" / "LC_MESSAGES" / "bal.po" in _catalogs()
def test_catalogs_compile():
for po in _catalogs():
with open(po, "rb") as f:
catalog = read_po(f)
buf = io.BytesIO()
write_mo(buf, catalog)
assert buf.getvalue(), po
def test_translations_are_safe():
problems = [
"{}: {}".format(po.parent.parent.name, p)
for po in _catalogs()
for p in check_catalog(po)
]
assert not problems, "\n".join(problems)
def test_suspicious_patterns_are_detected():
# A translation that adds an address or a link must be caught.
regexes = SUSPICIOUS.values()
assert any(r.search("invia a bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq") for r in regexes)
assert any(r.search("scarica da https://example.com") for r in regexes)
assert any(r.search("scrivi a truffa@example.com") for r in regexes)
assert not any(r.search("Firma il testamento") for r in regexes)
if __name__ == "__main__":
test_there_is_an_italian_catalog()
test_catalogs_compile()
test_suspicious_patterns_are_detected()
for po in _catalogs():
translated, untranslated, fuzzy = summary(po)
print(
"{}: {} translated, {} untranslated, {} fuzzy".format(
po.parent.parent.name, translated, untranslated, fuzzy
)
)
test_translations_are_safe()
print("[OK] translation catalogs are safe")