14 Commits

Author SHA1 Message Date
f3deccb626 gui+cli+core: anticipated-delivery warning, task #67 amount checks, CLI tools
GUI - "Building your will" row (owner request):
- when a rebuild anticipates the delivery date by one day, show
  "Anticipated - new delivery date: <date>" on the "Building your will" row
  instead of a plain "Ok";
- the detection compares the will's minimum locktime BEFORE and AFTER the
  rebuild, so it also covers relative recipes ("1y"/"30d") that the previous
  stored-vs-built comparison missed;
- the sign explanation stays on its own "Signing your will" row so it no
  longer overwrites the anticipation warning.

Task #67 - "Checking variables" stuck on "Wait":
- core: Will.check_amounts only requires a fixed amount to be funded when it
  is > 0, and only requires percentages to total 100% when the will actually
  has percentage heirs; fixed-only / empty-wallet wills no longer raise;
- gui: the amount warning is written on the "Checking variables" row itself
  (COLOR_WARNING) instead of being overwritten by the next row; the redundant
  "adjustment of the amounts" sentence was dropped.

CLI - will-executor helpers:
- willexecutors_update accepts promo_code;
- willexecutors_select supports bulk apply with only_valid;
- willexecutors_export supports selected/valid subsets;
- import overwrites existing records with sane defaults;
- raise NoServersForChainError with a clear user-facing message; map
  NotCompleteWill / HeirChange / WillexecutorChange in _user_facing.

Tests and docs:
- tests for the anticipation row, the CLI helpers and the amount checks;
- i18n catalogs refreshed; CHANGELOG section 67; agent task memory updated.
2026-10-15 21:55:03 -04:00
c9f3f77a15 i18n(es_ES): fix 'Ok' translation from 'Aceptar' to 'Hecho' in wizard status messages 2026-10-13 18:31:47 -04:00
3d9757fe2d core+gui+cli: network-fee invalidation, will-tree fixes, i18n refresh
- #64: the on-chain invalidation now pays the current network fee through a
  configurable fee-policy setting (default eta:2); baltx_fees is only the
  offline fallback. The change output is dust-guarded: coins are appended until
  the change clears the wallet dust threshold.
- #65: a will item whose father is missing/stale is handled defensively in
  check_invalidated and discarded by add_willtree instead of raising KeyError.
- #66: children are stored as a list (dict accepted for older data/tests).
- Refresh the translation catalogs and fix stale source line references.
- New tests for the invalidation fee policy, the dust guard and the father
  link handling.
2026-10-12 15:16:50 -04:00
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
79 changed files with 6120 additions and 7363 deletions

View File

@@ -485,8 +485,8 @@ ROW LABELS and, for each, a variable STATUS text.
|--------------------------------------------------------------------------------|--------|-------------| |--------------------------------------------------------------------------------|--------|-------------|
| Check Alive Threshold Passed: you have to Invalidate your old Will | RED | CheckAliveError + an invalidate tx exists (~559) | | Check Alive Threshold Passed: you have to Invalidate your old Will | RED | CheckAliveError + an invalidate tx exists (~559) |
| No Heirs | RED | NoHeirsException (~564) | | No Heirs | RED | NoHeirsException (~564) |
| Ok | GREEN | check_amounts passed (~579) | | Ok | GREEN | check_amounts passed |
| In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts | (warning popup) | AmountException (~583) | | In the inheritance process, the entire wallet will always be fully emptied. | ORANGE | AmountException (warning on the row itself, not a popup) |
### ROW 2 — "Checking your will" outcomes (this is where "Heir not found" / "New" appear) ### ROW 2 — "Checking your will" outcomes (this is where "Heir not found" / "New" appear)
| Status text shown | Color | When (code) | Triggers rebuild? | | Status text shown | Color | When (code) | Triggers rebuild? |

3
.gitignore vendored
View File

@@ -37,3 +37,6 @@ tests/karen7
# Compiled translations: build_zip.py rebuilds them from the .po sources # Compiled translations: build_zip.py rebuilds them from the .po sources
*.mo *.mo
# Android companion app: kept locally only, not part of the plugin repo
android/

View File

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

View File

@@ -117,7 +117,10 @@ python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha25
``` ```
- `build_zip.py` needs **Babel** (`pip install babel`): it compiles the - `build_zip.py` needs **Babel** (`pip install babel`): it compiles the
translation catalogs into the zip and stops if Babel is missing. translation catalogs into the zip and stops if Babel is missing. The i18n
tests need it too. Install it in the **runtime** env used for releases
(`source "$BAL_HOME/electrum/env/bin/activate" && pip install babel`);
`make-release.sh` checks it before running anything.
- `make-release.sh` requires gpg and Gitea credentials (`~/.git-credentials` - `make-release.sh` requires gpg and Gitea credentials (`~/.git-credentials`
or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the
version there, never invent a new source of truth. version there, never invent a new source of truth.

View File

@@ -3448,3 +3448,375 @@ only `bal/locale/it_IT/LC_MESSAGES/bal.mo` for the translations and
**Outcome:** DONE, pending the owner's review of the Italian texts and test **Outcome:** DONE, pending the owner's review of the Italian texts and test
of the ZIP. of the ZIP.
## 61. Signed relative Check Alive (negative = before delivery, positive = after creation)
**Date:** 2026-10-01
**Goal:** Change the meaning of a RELATIVE Check Alive value so that its SIGN
selects the anchor date:
- NEGATIVE (`-30d` / `-1y`): counted BACKWARDS from the delivery date
(`delivery + N days`; `-30d` = 30 days BEFORE the delivery). This preserves
the historical meaning of a relative threshold.
- POSITIVE (`30d` / `1y`): counted FORWARD from the inheritance creation date
(`min(WillItem.time)` over the VALID will items, frozen in the wallet DB).
- When the anchor is unknown (no built will yet, or imported items without a
`time`), the reference falls back to *now*.
No migration: existing stored positive values switch to the new meaning. The
Check Alive must always be strictly AFTER today and strictly BEFORE the
delivery; when it is not, the plugin raises an explicit error (see below).
**What changed:**
- `bal/core/checkalive.py` (centralised, GUI-free):
- new `resolve_delivery_date(will_settings, now, built_locktime)` — the single
source of the delivery reference (absolute locktime as-is; relative locktime
resolved forward from *now* unless a built will freezes it).
- new `resolve_relative_check_alive_date(threshold_raw, delivery_date,
creation_date, now)` — the SINGLE calculation rule (sign selects the anchor;
`BalTimestamp.to_date` is used forward on both anchors, no more
`reverse=True`).
- new `is_check_alive_date_valid(date_to_check, delivery_date, now)` and
`ensure_check_alive_valid(is_basic_mode, date_to_check, delivery_date, now)`
— the SINGLE validity gate: `now < date_to_check < delivery`. BASIC mode is
a no-op (Check Alive is inert).
- `resolve_date_to_check` / `resolve_guard_threshold`: gain a `creation_date`
parameter and delegate to the shared rule; `resolve_guard_threshold` still
resolves the delivery from the stored settings alone (no built anchor).
- `check_alive_expired` removed (superseded by `ensure_check_alive_valid`).
- new `CheckAliveAfterDeliveryError` (a SIBLING of `CheckAliveError`, not a
subclass): raised when the Check Alive is on/after the delivery. It must not
reuse the `except CheckAliveError` handlers, which invalidate the will
on-chain; this is a configuration error that only aborts the action.
`CheckAliveError` gained an optional `reason`.
- `bal/core/will.py`: new `Will.get_creation_time(will)` — the oldest
`WillItem.time` among VALID items (`None` when unknown).
- `bal/core/input_rules.py`: `normalize_locktime_raw_text(..., allow_negative)`
keeps a single leading `-` (Check Alive field only).
- `bal/gui/qt/widgets.py`: the Check Alive RAW editor accepts `-`
(`ThresholdTimeWidget.allow_negative_raw`); `on_locktime_change` uses the
shared resolver for `real_threshold`; updated help text.
- `bal/gui/qt/window.py`, `bal/gui/qt/dialogs.py`, `bal/cli/controller.py`: pass
`creation_date=Will.get_creation_time(...)`, call
`ensure_check_alive_valid`, and handle `CheckAliveAfterDeliveryError` by
aborting (never invalidating). The wizard .ics provider and the CLI guard use
the shared resolver too.
- `bal/core/plugin_base.py`: the default relative Check Alive is now `"-30d"`
(30 days BEFORE the 1-year delivery), preserving the historical default; the
absolute default threshold follows the same sign rule.
**Tests:**
- `tests/test_core_checkalive.py` rewritten (both signs, delivery/creation
resolution, validity gate, new exceptions, `Will.get_creation_time`).
- Updated `tests/test_group_i_basic_checkalive.py`, `tests/test_core_will.py`,
`tests/test_gui_will_flows.py`, `tests/test_cli_controller_offline.py`,
`tests/test_core_plugin_base.py`, `tests/test_core_input_rules.py`.
**Outcome:** DONE.
## 62. Remove BALQR and BC-UR v1/v2 — keep only BBQR
**Date:** 2026-10-03
**Goal:** Drop the BALQR, BC-UR v1 and BC-UR v2 QR wire formats and keep only
**BBQR** for will transfer.
**What changed:**
- `bal/core/animated_qr.py`: removed the BC32, bytewords-minimal, CRC-32,
CBOR, xoshiro256/fountain codecs and the BALQR / UR v1 / UR v2 receive
sessions; the module now implements only the BBQR codec plus the
`AnimatedQrSession` facade and `detect_format` / `parse_for_detection`
(BBQR-only).
- `bal/core/qrtransfer.py`: removed the BALQR framing helpers (`split_frames`,
`parse_frame`, `assemble`, `encode_transfer_best`, the frame errors);
kept the transfer-string codec (`encode_transfer` / `decode_transfer`) and
the QR-size presets.
- `bal/gui/qt/dialogs.py`: the export widget no longer shows a Format selector
and always emits BBQR; the import widget treats BBQR indexes as 0-based.
- Removed the "QR Code Size" setting from the plugin settings dialog; the
QR-size selector now lives only in the export dialog, and its chosen value
is remembered (persisted to `QR_CHUNK_SIZE`) for the next export.
- Android reader: `bal/core/*` copies re-synced, `bal/encoder.py` and the
Kotlin bridges (`BalDecoder`, `QrEncoder`, `QrSendActivity`, `MainActivity`,
`strings.xml`) reduced to BBQR; `tools/emitter.py` and
`test_chain/verify_chain.py` updated.
- Tests: `test_core_animated_qr.py`, `test_core_qr_transfer.py` and
`test_gui_qr_transfer.py` rewritten for BBQR only.
- Docs: README, COMPATIBILITY, HANDOFF and android/README updated; translation
catalogs refreshed (removed format strings marked obsolete).
**Outcome:** DONE.
---
## 63. i18n review fixes (Truman's review of the translation PR)
**Date:** 2026-10-07
**What changed:**
- `make-release.sh`
- Step 3 now also runs `tests/test_i18n.py`, `tests/test_translations.py`,
`tests/test_cli_*.py` and `tests/test_import_will_details.py` (the i18n
tests were never run by the release gate; the CLI and will-import tests
were not either).
- Checks up front that Babel is importable by `python3` and stops with
"pip install babel" otherwise: the i18n tests (step 3) and `build_zip.py`
(step 5) both need it, so a release no longer stops half-way.
- `AGENTS.md`: Babel must be installed in the runtime env used for releases.
- `pyproject.toml`: `[tool.ruff.lint.isort] known-first-party = ["bal"]`.
WHY: the reported I001 in `tests/test_translations.py` is not reproducible
from the repository root (ruff 0.15.11 and 0.6.9: clean), but it appears as
soon as ruff classifies `bal` as third-party (then 20 files report I001).
Removing the blank line would break the check where `bal` is first-party;
declaring it makes the result the same on every machine.
- `bal/core/willexecutors.py` - `get_willexecutor_transactions` returns COPIES
of the executor records. It used to write `txs`, `txsids` and the
translated `broadcast_status` into the will item's own `we` dict, which is
saved in the wallet (stored data depended on the UI language).
- `bal/core/will.py` - `WillItem` drops those transient fields when loading a
will saved by an older version, and maps a translated leading "New"
("Nuovo", "Nuevo" - saved by i18n test builds) back to "New".
- `bal/gui/qt/window.py`, `bal/cli/controller.py`: the status history of a new
will item is saved as the literal "New" (was `_("New")`).
- `tests/test_core_status_language_neutral.py` (new, 7 tests; 5 fail on
`main`): English "New" stored, translated "New" mapped back, the mapping
covers every catalog's translation of "New", the push helper does not
modify the will item, old transient fields dropped on load.
**Scope:** this entry contains ONLY the i18n review fixes. The other fixes
prepared in the same session (invalidation fee, Check `KeyError`, cascade,
deselected executor) are not part of it and will be proposed separately.
**Verification (on `main` 827a1d1):**
- New test file `tests/test_core_status_language_neutral.py`: 5 of 7 fail on
`main`, 7 of 7 pass with this change.
- Extended release test list (as above) plus the new test file: 446 passed,
1 failed (`main`: 439 passed, same failure). The failure is pre-existing:
`test_group_e_mock_karen7::test_e5` needs `tests/karen7`, a wallet file that
is not in the repository.
- Whole `tests/` directory: 609 passed, 26 failed, 3 collection errors
(`main`: 602 passed, and the SAME 26 failures and 3 errors, test by test).
All pre-existing and unrelated: 9 need `tests/karen7`; 21 in
`test_auto_rebuild_on_new_tx.py`, `test_cli_autorebuild.py` and
`test_cli_controller_offline.py` fail only when the whole directory runs
together (they pass when each file runs alone); 3 audio tests need an audio
device. `test_gui_export_dialogs.py::test_audio_import_receive_wiring` hangs
forever, also on `main`, so it was deselected.
- `ruff check bal/ tests/`: the same 3 pre-existing I001 as on `main`
(`dialogs.py`, `test_core_animated_qr.py`, `test_core_qr_transfer.py`),
none new.
**Outcome:** DONE (pending Truman's review).
---
## 64. Invalidation pays the current network fee, configurable via a fee-policy setting
**Date:** 2026-10-10
**Goal (owner request):** the will's `baltx_fees` (default 20 sat/vbyte) is
deliberately high because the inheritance transactions may be broadcast by the
will-executors years from now, when network fees may be much higher. The
on-chain INVALIDATION, instead, is sent TODAY: it spends the will's coins back
to the wallet so the old pre-signed inheritance transactions become unusable.
It must therefore pay the current network fee, not the inheritance rate. After
the invalidation the plugin rebuilds the new inheritance with `baltx_fees` as
before (unchanged). The fee policy is exposed as an ADVANCED-only setting.
**What changed:**
- `bal/core/will.py`
- New constants `INVALIDATION_FEE_ETA_BLOCKS = 2` and
`INVALIDATION_FEE_POLICY_DEFAULT = "eta:2"` (owner decision: confirm within
2 blocks, Electrum's own default target).
- New `Will.get_invalidation_fee_rate(wallet, fallback_fee_per_byte,
fee_policy_descriptor=None)`: builds an Electrum `FeePolicy` from the
descriptor (Feerate / ETA / Mempool) and returns its rate in sat/vbyte,
together with the `FeeMethod` name (`"feerate"` / `"eta"` / `"mempool"`) or
`"fallback"`. The fallback (the will's `baltx_fees`, i.e. the previous
behaviour) is used when no usable rate is available: offline wallet,
estimates not received yet, network error or an invalid rate value. A
malformed stored descriptor is handled by Electrum's `FeePolicy`, which
falls back to the default `eta:2` policy (network rate), not to
`baltx_fees`. Never raises.
- `Will.invalidate_will` gained a `fee_policy_descriptor` argument and uses
that rate; the total fee is rounded UP to whole satoshis (network rates can
be fractional); it logs the chosen rate and its source. `fees_per_byte` is
now only the fallback, so every caller keeps working. The change output must
stay at or above the wallet dust threshold: coins are appended until the
change clears dust, and if it never does the invalidation returns `None`
(instead of an unrelayable sub-dust output). Docstrings added.
- `bal/core/plugin_base.py`: new `INVALIDATION_FEE_POLICY` BalConfig
(`bal_invalidation_fee_policy`, default `"eta:2"`).
- `bal/gui/qt/plugin.py`: new ADVANCED-only row 16 in the settings dialog,
reusing Electrum's standard fee widget (`FeeSlider` + `FeeComboBox`) for the
Feerate / ETA / Mempool choice, with a Help button and a per-row reset. The
chosen descriptor is persisted; the dialog-wide "Reset to Default Setting"
also restores it.
- `bal/gui/qt/window.py`, `bal/gui/qt/dialogs.py`, `bal/cli/controller.py`:
every `Will.invalidate_will` call site passes
`fee_policy_descriptor=...INVALIDATION_FEE_POLICY.get()`, so the Tools →
Invalidate action, the wizard postpone/expired paths, the automatic
invalidation and the CLI (`bal_will_invalidate`) all get the new behaviour.
- `bal/locale/bal.pot` and the `es_ES` / `it_IT` catalogs refreshed
(`pybabel extract` / `update`, Babel 2.10.3 to match the existing template) so
the new settings-dialog strings are translatable.
**Why no special case for an already-expired will:** discussed with the owner
and intentionally left out. Before its locktime the old inheritance tx cannot
enter a block or the mempool, so the invalidation has no competitor. RBF stays
enabled on the invalidation, so its fee can be raised from Electrum's History
("Increase fee") if it ever gets stuck.
**Verification:**
- `tests/test_core_invalidation_fee.py` (new, 17 tests): rate selection
(2-block target, network value used as is also above `baltx_fees`, fractional
rates, fallback when offline / no estimate / error / invalid values / invalid
descriptor / no wallet) and the fee actually deducted by `invalidate_will`
(network vs fallback, fixed feerate offline, rounding up).
- `tests/test_settings_invalidation_fee.py` (new, 7 tests): the row is hidden
in BASIC and visible in ADVANCED; choosing a Feerate / ETA policy persists
the descriptor; "Reset to Default Setting" restores `"eta:2"`.
- `tests/test_core_will_invalidate.py`: its mock wallet now has `network=None`
so the pure fee arithmetic (`fee = size * fees_per_byte`) still exercises the
fallback path; the policy-based selection is covered by the new file. Four new
tests cover the dust guard: sub-dust change returns `None`, change exactly at
the threshold is accepted, the fallback stops when the change is still dust,
and it succeeds once the change clears dust.
- Selected suite (core + GUI + settings + translations): 423 passed.
- `ruff check` on the touched files: no new violations (only the pre-existing
I001 in `dialogs.py`).
**Outcome:** DONE (pending the owner's test of the release ZIP).
---
## 65. Check crashed with `KeyError: '<txid>'` (stale "father" link in the will tree)
**Date:** 2026-10-10
**Symptom (owner's mainnet wallet, Electrum 4.8.x):** the Check wizard stopped
at "Checking your will" with `Error: 'a65a682f…'`, and the window refresh
logged "error while updating window" with the same txid:
```
File "bal/core/will.py", line 657, in check_invalidated
or willtree[w.father].get_status("CONFIRMED")
KeyError: 'a65a682f2975f0dffd9eccce9392833f227badf617fc67815674736d594d4115'
```
**Root cause:** `father` is persisted in the wallet (the `"Father"` field of
`WillItem.to_dict`) and was only ever set, never cleared. When the father tx was
later removed from the will (rebuild, merge, replacement) the child kept
pointing to a txid that no longer exists, and `check_invalidated` crashed with a
`KeyError` on that txid (the Check wizard and the window refresh both failed).
**What changed:**
- `bal/core/will.py`
- `Will.add_willtree`: clears `father` on every item before rebuilding the
links, so the tree is always recomputed from scratch from the transactions
themselves. Docstring added explaining WHY.
- `Will.check_invalidated`: a father that is not (or no longer) part of the
will is treated as "no father" instead of raising `KeyError`.
- `tests/test_core_willtree_father.py` (new, 7 tests): stale father discarded /
replaced by the real parent, no crash in `check_invalidated` (with and
without the tree rebuild), real parent/child link built, child of an
unconfirmed parent still skipped, and the `set_invalidate` cascade (see #66).
**Verification:** the new test file plus the core/CLI suites pass; `ruff check`
clean on the touched files.
**Outcome:** DONE (pending the owner's test of the ZIP).
---
## 66. Invalidation cascade crashed on real will trees (`children` is a list)
**Date:** 2026-10-10
**Problem (found while fixing #65, reproduced on real data):**
`Will.set_invalidate` iterated `children.items()`, but `Will.add_willtree`
builds `children` as a LIST of `[child_id, input_idx, prevout_idx]`, so
invalidating a will tx that has child will txs raised
`AttributeError: 'list' object has no attribute 'items'`. The existing tests in
`test_core_will_invalidate.py` set `children` to dicts by hand, so they never
saw it.
**What changed:** `bal/core/will.py` - `set_invalidate` accepts both shapes
(the list from `add_willtree`, a dict from older data/tests) and only recurses
into children that are still present in the will.
`tests/test_core_willtree_father.py`: new test
`test_set_invalidate_cascades_to_children_built_by_add_willtree`.
**Verification:** `tests/test_core_willtree_father.py` passes; `ruff check`
clean on the touched files.
**Outcome:** DONE.
---
## 67. "Checking variables" stuck on "Wait" + redundant amount-adjustment wording
**Date:** 2026-10-14
**Symptom (owner report):** in the "Building Will" dialog the "Checking
variables" row always stayed on "Wait" and never became "Ok", even though the
will itself was fine ("Checking your will" showed "Ok"). It should show its
result before the "Checking your will" row is written.
**Root cause (two bugs):**
1. UI - when `Will.check_amounts` raised `AmountException` /
`WillExecutorFeeTooHighException`, `BalBuildWillDialog.task_phase1` wrote the
warning to the **"Checking your will"** row (`msg_set_checking(...)`) and the
very next `self.msg_set_checking()` call immediately overwrote it. So on an
amount warning the warning was lost AND the "Checking variables" row kept its
initial "Wait" forever (the success path set "Ok" only when the amount check
passed).
2. Core - `Will.check_amounts` had two false positives that made perfectly valid
wills raise:
- `fixed_amount >= wallet_balance` also fired when `fixed_amount == 0`
(all-percentage or empty/fully-committed wallet: `0 >= 0`);
- `perc_amount != 100` also fired when the will has NO percentage heirs at
all (a fixed-only will reports `perc_amount == 0`).
**What changed:**
- `bal/gui/qt/dialogs.py` (`task_phase1`, AmountException branch): the warning is
now written on the **"Checking variables"** row itself with `COLOR_WARNING`, so
the row always reaches a real outcome and the warning is never lost. The
wording was also reduced to the first sentence only - "In the inheritance
process, the entire wallet will always be fully emptied." - dropping
"Your settings require an adjustment of the amounts" (owner request).
- `bal/gui/qt/window.py` (`build_inheritance_transaction`, AmountException
branch): same message reduction; the appended exception detail (`.format(e)`)
was removed with the sentence it belonged to.
- `bal/core/will.py` (`check_amounts`): the fixed-amount checks now only apply
when `fixed_amount > 0`, and the 100% check only when the will actually has
percentage heirs (`perc_heirs`). Valid fixed-only / empty-wallet wills no
longer raise.
- i18n catalogs refreshed (`pybabel extract` + `update`); the new unified msgid
"In the inheritance process, the entire wallet will always be fully emptied."
is translated in `it_IT` and `es_ES`; the old
"...adjustment of the amounts.{}" entry is now obsolete.
**Verification:**
- `Will.check_amounts` matrix (fixed-only/percentage/empty wallet) behaves as
expected; core/offline suites plus `test_gui_prepare_will_history.py`,
`test_sync_locktime_built_txs.py`, `test_rebuild_on_close_setting.py`,
`test_translations.py` and the smoke test pass.
- Offscreen repro of `task_phase1`: "Checking variables" shows green "Ok" on
success and the (shortened) warning on the row on `AmountException` - never
"Wait".
- `py_compile` clean; `ruff check` adds no new violations (only the pre-existing
`I001` in `dialogs.py`).
**Outcome:** DONE (pending the owner's test of the ZIP).

View File

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

View File

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

View File

@@ -49,7 +49,7 @@ Ho verificato sul codice reale (`electrum/commands.py`, `electrum/plugin.py`,
- `willexecutors.py` — `Willexecutors` (config `bal_willexecutors`, chiave per `chainname`), `get_willexecutors`, `save`, `initialize_willexecutor`, `is_selected`, `is_valid`, `ping_servers_parallel`, `push_transactions_parallel`, `check_transactions_parallel`, `check_transaction`, `download_list`, `get_willexecutors_list_from_json`. - `willexecutors.py` — `Willexecutors` (config `bal_willexecutors`, chiave per `chainname`), `get_willexecutors`, `save`, `initialize_willexecutor`, `is_selected`, `is_valid`, `ping_servers_parallel`, `push_transactions_parallel`, `check_transactions_parallel`, `check_transaction`, `download_list`, `get_willexecutors_list_from_json`.
- `will.py` — `Will` (statiche) e `WillItem` (stato per-tx: `VALID/COMPLETE/PUSHED/CHECKED/...`), `is_will_valid`, `check_will`, `check_willexecutors_and_heirs`, `invalidate_will`, `normalize_will`, `get_min_locktime`, `get_tx_from_any`, `set_check_willexecutor`, `save_valid_transactions_to_history`. - `will.py` — `Will` (statiche) e `WillItem` (stato per-tx: `VALID/COMPLETE/PUSHED/CHECKED/...`), `is_will_valid`, `check_will`, `check_willexecutors_and_heirs`, `invalidate_will`, `normalize_will`, `get_min_locktime`, `get_tx_from_any`, `set_check_willexecutor`, `save_valid_transactions_to_history`.
- `plugin_base.py` — `BalPlugin` (tutte le `BalConfig`: chiavi `bal_*`), `BalTimestamp`, `get_version`, registrazione dei dict `heirs`/`will`/`will_settings` nel wallet DB. - `plugin_base.py` — `BalPlugin` (tutte le `BalConfig`: chiavi `bal_*`), `BalTimestamp`, `get_version`, registrazione dei dict `heirs`/`will`/`will_settings` nel wallet DB.
- `checkalive.py` — `resolve_date_to_check`, `check_alive_expired` (riferimento temporale unico per ogni check). - `checkalive.py` — `resolve_date_to_check`, `resolve_relative_check_alive_date`, `ensure_check_alive_valid` (riferimento temporale unico per ogni check; relativo negativo = prima della delivery, positivo = dopo la creazione).
- `util.py` — `Util` (locktime, quantità, confronto tx/heirs, `get_available_utxos`, `fix_will_settings_tx_fees`). - `util.py` — `Util` (locktime, quantità, confronto tx/heirs, `get_available_utxos`, `fix_will_settings_tx_fees`).
- `bal/gui/qt/window.py` — `BalWindow` contiene i flussi da **replicare in headless** (non riusabile direttamente perché legato a Qt): - `bal/gui/qt/window.py` — `BalWindow` contiene i flussi da **replicare in headless** (non riusabile direttamente perché legato a Qt):
- `init_will` (riga 151), `load_willitems`/`save_willitems` (120/129), - `init_will` (riga 151), `load_willitems`/`save_willitems` (120/129),
@@ -173,7 +173,7 @@ Metodi principali (replicano le funzioni Qt, senza dialoghi):
|---|---|---| |---|---|---|
| `load_willitems()` | 120 | Costruisce i `WillItem` dal dict `will` del wallet DB. | | `load_willitems()` | 120 | Costruisce i `WillItem` dal dict `will` del wallet DB. |
| `save_willitems()` | 129 | `to_dict()` con `tx` serializzato a stringa, `json.dumps` di prova, scrittura su `wallet.db` + `wallet.save_db()`. | | `save_willitems()` | 129 | `to_dict()` con `tx` serializzato a stringa, `json.dumps` di prova, scrittura su `wallet.db` + `wallet.save_db()`. |
| `init_class_variables()` | 618 | `date_to_check`, `no_willexecutor`, `willexecutors`, check `check_alive_expired`. | | `init_class_variables()` | 618 | `date_to_check`, `no_willexecutor`, `willexecutors`, check `ensure_check_alive_valid`. |
| `check_will()` | 473 | `Will.is_will_valid(...)`; le eccezioni di dominio vengono propagate al comando. | | `check_will()` | 473 | `Will.is_will_valid(...)`; le eccezioni di dominio vengono propagate al comando. |
| `build_inheritance_transaction()` | 678 | Flusso 1/7→2/7 replicato: `Will.check_amounts`, guardie locktime/willexecutor, `check_will()` e rebuild su `NotCompleteWillException`. Le `show_message/show_error` diventano raise (`UserFacingException` con testo chiaro) oppure ritorni `{"status": "postponed", "invalidation": tx}`. | | `build_inheritance_transaction()` | 678 | Flusso 1/7→2/7 replicato: `Will.check_amounts`, guardie locktime/willexecutor, `check_will()` e rebuild su `NotCompleteWillException`. Le `show_message/show_error` diventano raise (`UserFacingException` con testo chiaro) oppure ritorni `{"status": "postponed", "invalidation": tx}`. |
| `sign_transactions(password)` | 952 | Firma i `VALID` non completi: fixup input dai willitems padre, `wallet.sign_transaction(tx, password, ignore_warnings=True)`, `set_status("COMPLETE")`, `check_signatures`. | | `sign_transactions(password)` | 952 | Firma i `VALID` non completi: fixup input dai willitems padre, `wallet.sign_transaction(tx, password, ignore_warnings=True)`, `set_status("COMPLETE")`, `check_signatures`. |

View File

@@ -17,7 +17,7 @@
**In scope** **In scope**
- The Qt GUI of the plugin (`bal/gui/qt/`) and the GUI-free strings it displays (`bal/core/`). - The Qt GUI of the plugin (`bal/gui/qt/`) and the GUI-free strings it displays (`bal/core/`).
- One language for now: Italian (`it_IT`). - Languages: Italian (`it_IT`) and Spanish (`es_ES`).
**Out of scope** (owner's decisions, or by design) **Out of scope** (owner's decisions, or by design)
- **BAL Easy Heirs**: excluded by the owner. - **BAL Easy Heirs**: excluded by the owner.

View File

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

10
android/.gitignore vendored
View File

@@ -1,10 +0,0 @@
.gradle/
build/
local.properties
.idea/
*.apk
*.aab
captures/
.externalNativeBuild/
.cxx/
*.hprof

View File

@@ -1,153 +0,0 @@
# BAL Reader (Android)
A minimal Android app that reads a Bitcoin will exported by the
[BAL Electrum plugin](https://bitcoin-after.life) directly from your screen,
then lets you view, copy, share, or save the recovered data. **Reader only** —
it never signs or broadcasts.
It decodes **all four** transfer formats the plugin can export, auto-detecting
the format from the first frame:
- **BAL QR** (the plugin's default), single- and multi-frame, plain and
zlib-compressed, compact `BAL1` header (legacy `BALQR1` frames are still
accepted on import);
- **BC-UR v1** (single-part and `NofM` multipart);
- **BC-UR v2** (single-part and XOR-fountain multipart — it tolerates dropped,
repeated, and out-of-order frames);
- **BBQR** (`Z`/`H`/`2` encodings).
Both payload kinds are handled: the **whole-will JSON** and the plain
**transaction-hex list**.
## How decoding works
The app does not reimplement the QR formats. It bundles the plugin's own
codec modules — `bal/core/__init__.py`, `bal/core/animated_qr.py`,
`bal/core/qrtransfer.py` — and runs them verbatim through **Chaquopy** (CPython
on Android). Kotlin is only camera glue and UI:
```
Camera (CameraX) → ML Kit QR detection (on-device, no API key)
→ BalDecoder.add(text) → bal.core.animated_qr.AnimatedQrSession
→ when done → BalDecoder.finish()
= session.resolve() → qrtransfer.decode_transfer()
→ balreader.payload.decode_will_payload()
→ ResultActivity: view / copy / share / save
```
The decode tail mirrors the plugin's import dialog function-for-function, and
`android/test_chain/verify_chain.py` proves the bundled code decodes every
format the way the desktop import does (including scrambled, duplicated, and
missing frames).
## Repository layout
```
android/
├── app/src/main/
│ ├── AndroidManifest.xml
│ ├── java/life/after/bitcoin/
│ │ ├── BalDecoder.kt Chaquopy bridge over the bundled codecs
│ │ ├── MainActivity.kt camera + ML Kit scan loop + progress
│ │ └── ResultActivity.kt viewer (copy / share / save)
│ └── python/ bundled Python (regenerate, do not hand-edit)
│ ├── bal/core/ SYNCED COPY of the plugin codecs
│ └── balreader/payload.py verbatim copy of dialogs.decode_will_payload
├── scripts/
│ ├── sync_codecs.py re-copy + verify the bundled codecs
│ └── build_apk.py resync codecs, run Gradle, print APK + sha256
└── test_chain/verify_chain.py decode-chain simulation for all formats
```
## Build
You need Android Studio (Jellyfish or newer), JDK 17, an Android SDK with
platform 35, and a network connection for the first Gradle sync.
1. Open this `android/` folder in Android Studio and let it sync (it will
fetch the Gradle wrapper 8.14, AGP 8.10.0, Kotlin 2.0.21, Chaquopy 17.0.0,
CameraX 1.3.4, and ML Kit).
2. Connect a phone (API 24+) or start an emulator and press **Run**.
3. Grant the camera permission when asked.
Alternatively, from the command line (from the repository root):
```bash
python3 android/scripts/build_apk.py # debug APK + sha256
python3 android/scripts/build_apk.py --release # (unsigned) release APK
```
The script re-synchronises the bundled codec modules first (so the APK always
carries the current `bal/core` sources), runs `./gradlew`, and prints the APK
path, size and sha256. Flags: `--no-sync` (skip the re-sync), `--offline`
(Gradle without downloads), `--clean`, `--verbose`.
Equivalent raw Gradle call:
```bash
cd android
./gradlew assembleDebug # APK: android/app/build/outputs/apk/debug/app-debug.apk
```
### If the Gradle wrapper jar is missing
`gradle/wrapper/gradle-wrapper.jar` is committed so `./gradlew` works out of
the box. If it is ever absent, Android Studio regenerates it on the first
sync; no manual steps needed.
## Use
1. In Electrum + BAL, open the will's **export** dialog.
2. Pick a format — start with the default **BAL QR**, then try **BC-UR v1**,
**BC-UR v2**, and **BBQR**.
3. Make sure the wording toggle shows a payload (business logic), then display
the animated QR and keep it on screen.
4. Point the phone at the screen. The header shows the detected format and
`received / total`; scanning stops automatically when the transfer is
complete.
5. On the result screen: **Copy** the raw transfer, **Share** it, **Save** it
as `will.json` (whole will) or `will_tx.txt` (transaction list), or press
**Scan another**.
Notes:
- Keep the phone still and the whole QR inside the frame (the codec dedups
repeated frames, so a slow capture is fine).
- If the camera glares off the screen, reduce brightness or tilt slightly.
- If scanning jumps between exports, the app detects the format switch,
resets, and asks you to let it re-scan.
## Keeping the bundled code in sync with the plugin
The codecs under `app/src/main/python/bal/core/` are **committed copies** for
deterministic builds, but they must stay identical to the plugin. Re-run this
after changing `bal/core/animated_qr.py` or `bal/core/qrtransfer.py` (and
after any change to `decode_will_payload` in `bal/gui/qt/dialogs.py`, which
mirrors `balreader/payload.py`):
```bash
python3 android/scripts/sync_codecs.py # copy
python3 android/scripts/sync_codecs.py --check # verify only (CI-friendly)
python3 android/test_chain/verify_chain.py # full decode-chain regression
```
`verify_chain.py` fails if the app's `balreader/payload.py` ever drifts from
the plugin's `decode_will_payload` (AST identity + result parity).
## Version pins (see `PLAN_ANDROID_READER.md`)
| Item | Version |
|---|---|
| AGP | 8.10.0 |
| Gradle | 8.14 (wrapper) |
| Kotlin | 2.0.21 |
| Chaquopy | 17.0.0 (Python 3.12) |
| compile / target / min SDK | 35 / 35 / 24 |
| CameraX | 1.3.4 |
| ML Kit barcode-scanning | 17.3.0 |
| JDK | 17 |
## License
MIT. The bundled Python codec files inherit the plugin's MIT license
(`bal/LICENSE`); see `app/src/main/python/bal/` for attribution.

View File

@@ -1,67 +0,0 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.chaquo.python")
}
android {
namespace = "life.after.bitcoin"
compileSdk = 35
defaultConfig {
applicationId = "life.after.bitcoin"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
// Chaquopy requires explicit ABI filters. Python 3.12 ships only for
// 64-bit ABIs: phones (arm64-v8a) + the common emulator image (x86_64).
ndk {
abiFilters += listOf("arm64-v8a", "x86_64")
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
viewBinding = true
}
}
chaquopy {
defaultConfig {
version = "3.12"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.activity:activity-ktx:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
// CameraX
implementation("androidx.camera:camera-core:1.3.4")
implementation("androidx.camera:camera-camera2:1.3.4")
implementation("androidx.camera:camera-lifecycle:1.3.4")
implementation("androidx.camera:camera-view:1.3.4")
// ML Kit on-device barcode scanning (QR only, no API key)
implementation("com.google.mlkit:barcode-scanning:17.3.0")
}

View File

@@ -1,5 +0,0 @@
# Chaquopy Python runtime.
-keep class com.chaquo.python.** { *; }
# ML Kit barcode scanning.
-keep class com.google.mlkit.** { *; }

View File

@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.camera"
android:required="true" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.BalReader">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ResultActivity"
android:exported="false"
android:parentActivityName=".MainActivity" />
</application>
</manifest>

View File

@@ -1,129 +0,0 @@
package life.after.bitcoin
import android.content.Context
import com.chaquo.python.PyObject
import com.chaquo.python.PyException
import com.chaquo.python.Python
import com.chaquo.python.android.AndroidPlatform
import org.json.JSONException
import org.json.JSONObject
/**
* Chaquopy bridge over the plugin's animated-QR codecs
* (``bal.core.animated_qr``, ``bal.core.qrtransfer``, bundled verbatim under
* ``app/src/main/python``).
*
* The decode tail mirrors the plugin's import dialog exactly, and runs
* entirely inside Python (``balreader.bridge.finish``) so no container
* conversion happens across the bridge:
*
* session.resolve() -> qrtransfer.decode_transfer() -> decode_will_payload()
*
* Kotlin only feeds frames, reads progress, and renders the JSON the bridge
* returns.
*/
class BalDecoder(private val context: Context) {
/** Outcome of feeding one scanned frame to the session. */
enum class AddResult {
/** A new frame was accepted. */
OK,
/** The frame was already present (duplicate); ignore. */
DUP,
/** The frame was not a supported QR transfer; ignore. */
GARBAGE,
/** The QR switched to a different transfer; caller should rescan. */
CONFLICT,
}
/** Fully decoded transfer, mirroring the plugin's import tail. */
data class DecodedResult(
val kind: String, // "will", "txs" or "error"
val payload: String, // raw transfer text (JSON or joined tx hexes)
val parts: List<String> // [whole-will JSON] or [tx hex strings]
)
private val python: Python by lazy {
if (!Python.isStarted()) {
Python.start(AndroidPlatform(context))
}
Python.getInstance()
}
private val animatedQr by lazy { python.getModule("bal.core.animated_qr") }
private val bridge by lazy { python.getModule("balreader.bridge") }
private var session: PyObject? = null
/** Start a fresh receive session (clears any accumulated frames). */
fun reset() {
session = null
}
private fun sessionOrCreate(): PyObject {
val current = session
if (current != null) {
return current
}
return animatedQr.callAttr("AnimatedQrSession").also { session = it }
}
/** Feed one scanned frame string; see [AddResult] for semantics. */
fun add(text: String): AddResult {
return try {
when (sessionOrCreate().callAttr("add_part", text).toString()) {
"dup" -> AddResult.DUP
else -> AddResult.OK
}
} catch (e: PyException) {
val msg = e.message ?: ""
// TransferConflictError: "Switched QR format mid-import (.. -> ..)".
if (msg.contains("Switched QR format")) {
AddResult.CONFLICT
} else {
AddResult.GARBAGE
}
}
}
/** The detected wire format ("balqr"/"ur1"/"ur2"/"bbqr"), or null. */
val format: String?
get() = runCatching {
session?.get("format")?.toString()?.takeIf { it != "None" }
}.getOrNull()
/** Number of distinct frames accepted. */
val received: Int
get() = session?.get("received")?.toInt() ?: 0
/** Total frames expected for the current transfer (0 until known). */
val total: Int
get() = session?.get("total")?.toInt() ?: 0
/** True once the whole transfer has been captured. */
val done: Boolean
get() = session?.get("done")?.toBoolean() ?: false
/**
* Resolve the completed session into a [DecodedResult]. The decoding runs
* in Python (``balreader.bridge.finish``) using the exact same three steps
* as the plugin's import dialog.
*/
fun finish(): DecodedResult {
val jsonText = bridge.callAttr("finish", sessionOrCreate()).toString()
return try {
val obj = JSONObject(jsonText)
val partsArray = obj.getJSONArray("parts")
val parts = (0 until partsArray.length()).map { partsArray.getString(it) }
DecodedResult(
kind = obj.getString("kind"),
payload = obj.getString("payload"),
parts = parts,
)
} catch (e: JSONException) {
DecodedResult(kind = "error", payload = jsonText, parts = emptyList())
}
}
}

View File

@@ -1,70 +0,0 @@
package life.after.bitcoin
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.View
import androidx.core.content.ContextCompat
/**
* Horizontal progress bar showing how many QR frames of the current transfer
* have been captured (`received / total`), with a filled mint segment
* proportional to the fraction. A thin decorative strip over the camera
* preview; the exact count stays in the header's "n / N" label.
*/
class FrameProgressBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : View(context, attrs) {
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.frame_fill)
}
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.frame_track)
}
private val trackRect = RectF()
private val fillRect = RectF()
private val cornerRadius = dp(3f)
private var fraction = 0f
/** Reset to an empty bar. */
fun reset() {
fraction = 0f
invalidate()
}
/**
* Update the fill to [received] out of [total] frames captured.
* A zero/unknown total clears the bar.
*/
fun set(total: Int, received: Int) {
fraction = if (total > 0) {
received.toFloat() / total.toFloat()
} else {
0f
}
invalidate()
}
private fun dp(value: Float): Float =
resources.displayMetrics.density * value
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (width <= 0 || height <= 0) {
return
}
trackRect.set(0f, 0f, width.toFloat(), height.toFloat())
canvas.drawRoundRect(trackRect, cornerRadius, cornerRadius, trackPaint)
if (fraction <= 0f) {
return
}
val fillWidth = width * fraction.coerceIn(0f, 1f)
fillRect.set(0f, 0f, fillWidth, height.toFloat())
canvas.drawRoundRect(fillRect, cornerRadius, cornerRadius, fillPaint)
}
}

View File

@@ -1,209 +0,0 @@
package life.after.bitcoin
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.content.ContextCompat
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.BarcodeScanner
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import life.after.bitcoin.BalDecoder.AddResult
import life.after.bitcoin.databinding.ActivityMainBinding
import java.util.concurrent.Executors
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var decoder: BalDecoder
private lateinit var barcodeScanner: BarcodeScanner
private val analyzerExecutor = Executors.newSingleThreadExecutor()
private var finished = false
private var cameraBound = false
private var lastAnalysisMs = 0L
private val formatLabels: Map<String, String> by lazy {
mapOf(
"balqr" to getString(R.string.format_balqr),
"ur1" to getString(R.string.format_ur1),
"ur2" to getString(R.string.format_ur2),
"bbqr" to getString(R.string.format_bbqr),
)
}
private val requestCameraPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
startCamera()
} else {
Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_LONG).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
decoder = BalDecoder(applicationContext)
barcodeScanner = BarcodeScanning.getClient(
BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build()
)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED
) {
startCamera()
} else {
requestCameraPermission.launch(Manifest.permission.CAMERA)
}
}
override fun onResume() {
super.onResume()
// Returning from the result screen starts a new scan.
if (finished) {
finished = false
decoder.reset()
binding.tvFormat.text = getString(R.string.format_placeholder)
binding.tvProgress.text = "0 / 0"
binding.tvStatus.setText(R.string.status_waiting)
binding.frameBar.reset()
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED
) {
startCamera()
}
}
override fun onDestroy() {
cameraProvider?.unbindAll()
barcodeScanner.close()
analyzerExecutor.shutdown()
super.onDestroy()
}
private var cameraProvider: ProcessCameraProvider? = null
private fun startCamera() {
if (cameraBound) {
return
}
val providerFuture = ProcessCameraProvider.getInstance(this)
providerFuture.addListener({
val provider = providerFuture.get()
cameraProvider = provider
val preview = Preview.Builder().build()
preview.setSurfaceProvider(binding.previewView.surfaceProvider)
val analysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
analysis.setAnalyzer(analyzerExecutor) { proxy -> analyze(proxy) }
try {
provider.unbindAll()
provider.bindToLifecycle(
this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
)
cameraBound = true
} catch (e: Exception) {
Log.e(TAG, "Failed to bind camera", e)
}
}, ContextCompat.getMainExecutor(this))
}
private fun analyze(proxy: ImageProxy) {
val now = System.currentTimeMillis()
if (finished || now - lastAnalysisMs < 100) {
proxy.close()
return
}
lastAnalysisMs = now
val image = proxy.image
if (image == null) {
proxy.close()
return
}
try {
val input = InputImage.fromMediaImage(image, proxy.imageInfo.rotationDegrees)
barcodeScanner.process(input)
.addOnSuccessListener { barcodes ->
for (barcode in barcodes) {
val value = barcode.rawValue
if (!value.isNullOrEmpty()) {
handleFrame(value)
break
}
}
}
.addOnCompleteListener { proxy.close() }
} catch (e: Exception) {
Log.w(TAG, "Frame analysis failure", e)
proxy.close()
}
}
private fun handleFrame(value: String) {
if (finished) {
return
}
when (decoder.add(value)) {
AddResult.OK -> {
Log.i(TAG, "frame ok fmt=${decoder.format} rcvd=${decoder.received}/${decoder.total} done=${decoder.done}")
binding.tvFormat.text = decoder.format?.let { formatLabels[it] }
?: getString(R.string.format_placeholder)
binding.tvProgress.text =
getString(R.string.progress_fmt, decoder.received, decoder.total)
binding.frameBar.set(decoder.total, decoder.received)
if (decoder.done) {
finishScan()
}
}
AddResult.DUP -> Log.i(TAG, "frame dup")
AddResult.GARBAGE -> Log.w(TAG, "frame garbage")
AddResult.CONFLICT -> {
Log.w(TAG, "format conflict - resetting")
decoder.reset()
binding.tvFormat.text = getString(R.string.format_placeholder)
binding.tvProgress.text = "0 / 0"
binding.tvStatus.setText(R.string.conflict_message)
binding.frameBar.reset()
}
}
}
private fun finishScan() {
if (finished) {
return
}
finished = true
val result = decoder.finish()
Log.i(TAG, "FINISH kind=${result.kind} payload=${result.payload.length}B parts=${result.parts.size}")
val intent = Intent(this, ResultActivity::class.java).apply {
putExtra(ResultActivity.EXTRA_KIND, result.kind)
putExtra(ResultActivity.EXTRA_PAYLOAD, result.payload)
putStringArrayListExtra(ResultActivity.EXTRA_PARTS, ArrayList(result.parts))
}
startActivity(intent)
}
companion object {
private const val TAG = "BalReader"
}
}

View File

@@ -1,100 +0,0 @@
package life.after.bitcoin
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import life.after.bitcoin.databinding.ActivityResultBinding
import org.json.JSONException
import org.json.JSONObject
class ResultActivity : AppCompatActivity() {
private lateinit var binding: ActivityResultBinding
private var kind = "txs"
private var payload = ""
private var parts: List<String> = emptyList()
private val saveWillPicker =
registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri: Uri? ->
saveTo(uri)
}
private val saveTxsPicker =
registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri: Uri? ->
saveTo(uri)
}
private fun saveTo(uri: Uri?) {
if (uri != null) {
contentResolver.openOutputStream(uri)?.use { it.write(payload.toByteArray()) }
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityResultBinding.inflate(layoutInflater)
setContentView(binding.root)
kind = intent.getStringExtra(EXTRA_KIND) ?: "txs"
payload = intent.getStringExtra(EXTRA_PAYLOAD) ?: ""
parts = intent.getStringArrayListExtra(EXTRA_PARTS) ?: emptyList()
binding.tvKind.text =
getString(if (kind == "will") R.string.result_kind_will else R.string.result_kind_txs)
binding.tvContent.text = pretty(payload)
binding.btnCopy.setOnClickListener {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("BAL transfer", payload))
Toast.makeText(this, R.string.copied_toast, Toast.LENGTH_SHORT).show()
}
binding.btnShare.setOnClickListener {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, payload)
}
startActivity(Intent.createChooser(send, null))
}
binding.btnSave.setOnClickListener { saveFile() }
binding.btnScanAnother.setOnClickListener { finish() }
}
private fun pretty(json: String): String {
if (kind == "will") {
try {
return JSONObject(json).toString(2)
} catch (_: JSONException) {
return json
}
}
// Transaction list: one numbered line per tx.
if (parts.isNotEmpty()) {
return parts.mapIndexed { i, tx -> "%d. %s".format(i + 1, tx) }
.joinToString("\n")
}
return json
}
private fun saveFile() {
val name = if (kind == "will") {
getString(R.string.save_file_will)
} else {
getString(R.string.save_file_txs)
}
// ActivityResultContracts.CreateDocument takes the suggested file name;
// it maps it to ACTION_CREATE_DOCUMENT + EXTRA_TITLE internally.
val picker = if (kind == "will") saveWillPicker else saveTxsPicker
picker.launch(name)
}
companion object {
const val EXTRA_KIND = "kind"
const val EXTRA_PAYLOAD = "payload"
const val EXTRA_PARTS = "parts"
}
}

View File

@@ -1,21 +0,0 @@
"""
bal.core
========
Pure business-logic layer of the Bitcoin After Life (BAL) Electrum plugin.
Everything in this sub-package MUST stay completely free of any GUI / Qt
imports. The rule of thumb is:
* ``bal.core`` -> "what the plugin does" (inheritance rules, building
and validating transactions, talking to
will-executor servers, persistence helpers).
* ``bal.gui`` -> "how it looks" (Qt widgets, dialogs, list views).
Keeping the two apart is the main motivation behind this rewrite: the original
code mixed transaction-building logic and presentation inside a single
4000-line ``qt.py`` module, which made the delicate Bitcoin logic hard to audit.
No behaviour is changed with respect to the original plugin; the code has only
been reorganised and documented.
"""

File diff suppressed because it is too large Load Diff

View File

@@ -1,304 +0,0 @@
"""
bal.core.qrtransfer
===================
GUI-free helpers for moving BAL will data between devices via QR codes or
the Electrum ``audio_modem`` plugin (see ``PLAN_QR_TRANSFER.md``).
Scope
-----
* converts will transactions into a compact ``transfer_string``
(newline-joined serialized transactions, optionally zlib + base64
compressed);
* splits that string into fixed-size ``BAL1<TTT><iii><flag>`` frames for
multi-QR export, and reassembles/validates them on import.
Wire format (v2, compact)
-------------------------
A frame is::
BAL1<TTT><iii><flag><payload>
* ``BAL1`` - magic + format era (4 chars).
* ``TTT`` - frame total as exactly 3 base36 digits (1-based, cap 46655).
* ``iii`` - frame index as exactly 3 base36 digits (1-based).
* ``flag`` - one char: ``Z`` (zlib + base64) or ``0`` (plain ASCII).
* ``payload`` - every other character of the frame; the payloads of all
frames, concatenated in index order, rebuild the transfer string.
The fixed 11-char header replaces the legacy ``BALQR1|N|i|flags|`` form
(same 5 pieces of information) without any pipe separator, so the whole
frame is scan-friendly and the overhead no longer grows with the frame
count. Legacy ``BALQR1|…`` frames are still accepted on import.
The audio-modem channel deliberately bypasses the framing helpers here
(PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and
carries the whole transfer string in a single blob, so callers only use
:func:`encode_transfer` / :func:`decode_transfer`.
This module never imports Qt or any Electrum GUI code (house rule).
"""
from __future__ import annotations
import base64
import zlib
MAGIC = "BALQR"
VERSION = 1
FLAG_COMPRESSED = "Z"
FLAG_PLAIN = "0"
# 4 standard presets (label, payload budget in bytes per QR). Ordered from
# low-resolution cameras to high-resolution cameras (owner decision D5).
CHUNK_PRESETS = (
("Small - ~150 bytes/QR (low-res cameras)", 150),
("Medium - ~400 bytes/QR", 400),
("Large - ~900 bytes/QR", 900),
("XL - ~1800 bytes/QR (high-res cameras)", 1800),
)
# Smallest allowed payload budget per frame, below which the frame header
# could consume the whole budget.
MIN_CHUNK_SIZE = 40
# Legacy wire format (still imported); the exporter emits the v2 form below.
_FRAME_MAGIC_V1 = MAGIC + str(VERSION)
# Compact v2 wire format: fixed-width base36 count fields, no separators.
_FRAME_MAGIC_V2 = "BAL1"
_BASE36_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
_BASE36_WIDTH = 3
_HEADER_V2_LEN = len(_FRAME_MAGIC_V2) + 2 * _BASE36_WIDTH + 1
_MAX_TOTAL = 36 ** _BASE36_WIDTH - 1
class QrTransferError(ValueError):
"""Base error for will QR / audio transfer processing."""
class MissingFramesError(QrTransferError):
"""Some frame indices of a multi-QR transfer are missing."""
def __init__(self, missing):
self.missing = list(missing)
super().__init__("Missing QR frames: {}".format(self.missing))
class InconsistentTotalError(QrTransferError):
"""Frames disagree about the advertised frame total."""
def encode_transfer(tx_strings, compress=False):
"""Join serialized transaction strings into a transfer string.
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
the whole bundle shrinks before being printed/scanned. The optional flag
of the frame header lets the importer reverse this automatically.
"""
return __compress("\n".join(tx_strings), enabled=compress)
def encode_transfer_best(tx_strings):
"""Encode ``tx_strings`` with the smaller of plain vs compressed form.
Returns ``(transfer_string, compressed: bool)``. Compressed wins only
when zlib + base64 really is shorter (best-of, never larger).
"""
joined = "\n".join(tx_strings)
plain = joined
compressed = __compress(joined, enabled=True)
if len(compressed) < len(plain):
return compressed, True
return plain, False
def decode_transfer(transfer_string, compressed):
"""Inverse of :func:`encode_transfer`.
Returns the list of serialized transaction strings; empty frames are
dropped so a trailing newline (or an empty payload) cannot produce an
empty trailing element.
"""
text = __decompress(transfer_string, enabled=compressed)
return [part for part in text.split("\n") if part]
def split_frames(transfer_string, chunk_size, compressed=False):
"""Split ``transfer_string`` into full compact ``BAL1`` frames.
Every returned frame has the fixed 11-char v2 header followed by its
share of the payload, so each frame is at most ``chunk_size`` characters
long. ``compressed`` stamps the ``Z`` flag into every frame so the
importer knows how to reverse the encoding.
Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold
the header plus any payload, or when the transfer needs more than
:data:`_MAX_TOTAL` frames.
"""
flag = FLAG_COMPRESSED if compressed else FLAG_PLAIN
total = __compute_total(len(transfer_string), chunk_size)
budget = chunk_size - _HEADER_V2_LEN
frames = []
pos = 0
length = len(transfer_string)
for index in range(1, total + 1):
end = min(pos + budget, length)
frames.append(
_FRAME_MAGIC_V2
+ _base36(total)
+ _base36(index)
+ flag
+ transfer_string[pos:end]
)
pos = end
if pos < length:
# __compute_total guarantees this cannot happen; keep a safety net.
raise QrTransferError("internal error: frames did not cover the transfer string")
return frames
def parse_frame(frame):
"""Parse a single frame.
Accepts both the legacy ``BALQR1|total|index|flags|payload`` form and
the compact ``BAL1<total><index><flag><payload>`` v2 form.
Returns ``(total, index, compressed: bool, payload: str)``. Raises
:class:`QrTransferError` on malformed input (bad magic/version, wrong
arity, non-integer or out-of-range frame numbers, unknown flags).
"""
if frame.startswith(_FRAME_MAGIC_V2):
return _parse_v2(frame)
return _parse_v1(frame)
def assemble(frames, total):
"""Concatenate frame payloads back into a transfer string.
``frames`` maps 1-based index -> payload. Every index ``1..total`` must
be present (else :class:`MissingFramesError`) and no index may exceed
``total`` (else :class:`InconsistentTotalError`).
"""
if total < 1:
raise QrTransferError("invalid frame total")
missing = [index for index in range(1, total + 1) if index not in frames]
if missing:
raise MissingFramesError(missing)
extra = [index for index in frames if index > total]
if extra:
raise InconsistentTotalError()
return "".join(frames[index] for index in range(1, total + 1))
def preset_index_for_chunk_size(chunk_size):
"""Return the :data:`CHUNK_PRESETS` index whose budget best matches a size."""
best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1])
for index, (_label, budget) in enumerate(CHUNK_PRESETS):
diff = abs(chunk_size - budget)
if diff < best_diff:
best, best_diff = index, diff
return best
# --------------------------------------------------------------------------- #
# Internals
# --------------------------------------------------------------------------- #
def __compress(text, *, enabled):
if not enabled:
return text
return base64.b64encode(zlib.compress(text.encode("utf-8"))).decode("ascii")
def __decompress(text, *, enabled):
if not enabled:
return text
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
def _base36(n):
"""Zero-padded :data:`_BASE36_WIDTH` base36 render of ``n``."""
if not 0 <= n <= _MAX_TOTAL:
raise QrTransferError("BAL QR part number out of range: {}".format(n))
chars = []
for _ in range(_BASE36_WIDTH):
chars.append(_BASE36_DIGITS[n % 36])
n //= 36
return "".join(reversed(chars))
def _base36_decode(text):
"""Inverse of :func:`_base36`; raises ``ValueError`` on bad input."""
if len(text) != _BASE36_WIDTH or any(c not in _BASE36_DIGITS for c in text):
raise ValueError(text)
n = 0
for c in text:
n = n * 36 + _BASE36_DIGITS.index(c)
return n
def _parse_v1(frame):
parts = frame.split("|", maxsplit=4)
if len(parts) != 5:
raise QrTransferError("Not a BAL will QR (bad frame structure)")
magic_seen, total_s, index_s, flags, payload = parts
if magic_seen != _FRAME_MAGIC_V1:
raise QrTransferError("Not a BAL will QR (unknown magic/version)")
try:
total = int(total_s)
index = int(index_s)
except ValueError as e:
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e
if total < 1 or not 1 <= index <= total:
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
if flags not in ("", FLAG_COMPRESSED):
raise QrTransferError("Not a BAL will QR (unknown flags)")
return total, index, flags == FLAG_COMPRESSED, payload
def _parse_v2(frame):
if len(frame) < _HEADER_V2_LEN:
raise QrTransferError("Not a BAL will QR (bad frame structure)")
# Magic is length _FRAME_MAGIC_V2; the two base36 fields and the flag
# make up the rest of the fixed header.
offset = len(_FRAME_MAGIC_V2)
total_s = frame[offset : offset + _BASE36_WIDTH]
index_s = frame[offset + _BASE36_WIDTH : offset + 2 * _BASE36_WIDTH]
flag = frame[offset + 2 * _BASE36_WIDTH]
try:
total = _base36_decode(total_s)
index = _base36_decode(index_s)
except ValueError:
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from None
if total < 1 or not 1 <= index <= total:
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
if flag not in (FLAG_PLAIN, FLAG_COMPRESSED):
raise QrTransferError("Not a BAL will QR (unknown flags)")
payload = frame[_HEADER_V2_LEN:]
return total, index, flag == FLAG_COMPRESSED, payload
def __compute_total(transfer_len, chunk_size):
"""Smallest frame count whose budget covers the whole transfer string.
The v2 header is fixed-width, so the budget is constant and the count is
a plain ceiling division, capped at :data:`_MAX_TOTAL`.
"""
if chunk_size < MIN_CHUNK_SIZE:
raise QrTransferError(
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
)
budget = chunk_size - _HEADER_V2_LEN
if budget <= 0:
raise QrTransferError(
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
)
total = -(-transfer_len // budget)
if total < 1:
total = 1
if total > _MAX_TOTAL:
raise QrTransferError(
"BAL QR transfer demands too many frames: {}".format(total)
)
return total

View File

@@ -1 +0,0 @@
"""Android reader helpers built on the bundled plugin codecs."""

View File

@@ -1,33 +0,0 @@
"""Kotlin-facing helper: runs the plugin's exact import tail and returns JSON.
A serializable JSON contract keeps the Chaquopy bridge tiny on the Kotlin side
and avoids exposing ``PyObject`` tuple/container indexing to it. The steps are
the same three calls the plugin's import dialog performs:
session.resolve() -> (transfer_text, compressed)
qrtransfer.decode_transfer -> parts
balreader.payload.decode_will_payload -> ("will"|"txs", data)
"""
import json
from bal.core import qrtransfer as _qrtransfer
from balreader import payload as _payload
def finish(session):
"""Run the import tail on a live ``AnimatedQrSession``.
Returns a JSON string ``{"kind": ..., "payload": ..., "parts": [...]}``
with ``kind`` either ``"will"`` or ``"txs"``. On any failure it returns
``{"kind": "error", "payload": <message>, "parts": []}`` so a misbehaving
session can never crash the UI thread.
"""
try:
transfer, compressed = session.resolve()
parts = list(_qrtransfer.decode_transfer(transfer, compressed))
payload = "\n".join(parts)
kind, _data = _payload.decode_will_payload(payload)
return json.dumps({"kind": kind, "payload": payload, "parts": parts})
except Exception as exc: # noqa: BLE001 - defensive bridge boundary
return json.dumps({"kind": "error", "payload": str(exc), "parts": []})

View File

@@ -1,33 +0,0 @@
"""Will-payload autodetection for the BAL Reader app.
This file is a verbatim copy of ``decode_will_payload`` from
``bal/gui/qt/dialogs.py``. ``android/test_chain/verify_chain.py`` compares the
two functions result-for-result so they can never drift apart.
Keep the function body identical to the plugin source.
"""
import json
import re
from typing import Any
def decode_will_payload(text) -> tuple[Any, Any]:
"""Autodetect: whole-will JSON or transaction list?
Returns ``("will", dict_of_willitems_data)`` when ``text`` is a JSON
object whose values are dicts containing a ``"tx"`` key (the whole-will
format produced by :meth:`BalWindow.export_json_file` and friends).
Otherwise returns ``("txs", [tx_strings])`` where the transaction
strings were split on commas and/or newlines.
"""
text = text.strip()
try:
data = json.loads(text)
except (json.JSONDecodeError, ValueError):
data = None
if isinstance(data, dict) and data:
if all(isinstance(v, dict) and "tx" in v for v in data.values()):
return ("will", data)
parts = [p for p in re.split(r"[,\r\n]+", text) if p.strip()]
return ("txs", parts)

View File

@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0B3D2E"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M14,14h22v22h-22z" />
<path
android:fillColor="#0B3D2E"
android:pathData="M19,19h12v12h-12z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M72,14h22v22h-22z" />
<path
android:fillColor="#0B3D2E"
android:pathData="M77,19h12v12h-12z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M14,72h22v22h-22z" />
<path
android:fillColor="#0B3D2E"
android:pathData="M19,77h12v12h-12z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M14,42h4v4h-4z M22,42h4v4h-4z M14,50h4v4h-4z M22,50h4v4h-4z M14,58h4v4h-4z M30,42h4v4h-4z M30,50h4v4h-4z M14,66h4v4h-4z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M52,14h4v4h-4z M60,14h4v4h-4z M52,22h4v4h-4z M68,14h4v4h-4z M52,30h4v4h-4z M56,42h4v4h-4z M64,42h4v4h-4z M56,50h4v4h-4z M72,42h4v4h-4z M56,58h4v4h-4z M64,58h4v4h-4z M56,66h4v4h-4z M64,66h4v4h-4z M72,58h4v4h-4z M64,74h4v4h-4z M72,66h4v4h-4z" />
</vector>

View File

@@ -1,59 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@android:color/black">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp"
android:background="#1A1A1A">
<TextView
android:id="@+id/tv_format"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/format_placeholder"
android:textColor="#9BE8C0"
android:textStyle="bold"
android:textSize="14sp" />
<TextView
android:id="@+id/tv_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0 / 0"
android:textColor="#FFFFFF"
android:textSize="14sp" />
</LinearLayout>
<life.after.bitcoin.FrameProgressBar
android:id="@+id/frame_bar"
android:layout_width="match_parent"
android:layout_height="6dp"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp" />
<androidx.camera.view.PreviewView
android:id="@+id/preview_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/tv_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dp"
android:gravity="center"
android:text="@string/status_waiting"
android:textColor="#CFCFCF"
android:textSize="14sp" />
</LinearLayout>

View File

@@ -1,67 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/tv_kind"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="?android:attr/textColorPrimary"
android:paddingBottom="8dp" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="?android:attr/colorBackground">
<TextView
android:id="@+id/tv_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:textIsSelectable="true"
android:textSize="12sp"
android:textColor="?android:attr/textColorPrimary"
android:padding="8dp" />
</ScrollView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="12dp">
<Button
android:id="@+id/btn_copy"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/action_copy" />
<Button
android:id="@+id/btn_share"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/action_share" />
<Button
android:id="@+id/btn_save"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/action_save" />
</LinearLayout>
<Button
android:id="@+id/btn_scan_another"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/action_scan_another" />
</LinearLayout>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="frame_fill">#9BE8C0</color>
<color name="frame_track">#3A3A3A</color>
</resources>

View File

@@ -1,24 +0,0 @@
<resources>
<string name="app_name">BAL Reader</string>
<string name="status_waiting">Point the camera at the QR screen</string>
<string name="format_placeholder">—</string>
<string name="progress_fmt">%1$d / %2$d</string>
<string name="format_balqr">BAL QR</string>
<string name="format_ur1">BC-UR v1</string>
<string name="format_ur2">BC-UR v2</string>
<string name="format_bbqr">BBQR</string>
<string name="result_kind_will">Whole will (JSON)</string>
<string name="result_kind_txs">Transaction list</string>
<string name="action_copy">Copy</string>
<string name="action_share">Share</string>
<string name="action_save">Save</string>
<string name="action_scan_another">Scan another</string>
<string name="copied_toast">Transfer copied to clipboard</string>
<string name="save_file_will">will.json</string>
<string name="save_file_txs">will_tx.txt</string>
<string name="permission_denied">Camera permission is required to scan QR codes.</string>
<string name="conflict_message">The QR switched to a different transfer. Let it rescan.</string>
</resources>

View File

@@ -1,3 +0,0 @@
<resources>
<style name="Theme.BalReader" parent="Theme.AppCompat.DayNight.NoActionBar" />
</resources>

View File

@@ -1,7 +0,0 @@
// Top-level build file: plugin versions only. See the docs for the full
// compatibility matrix (Chaquopy 17 requires AGP 7.3-9.2 and minSdk 24).
plugins {
id("com.android.application") version "8.10.0" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("com.chaquo.python") version "17.0.0" apply false
}

View File

@@ -1,5 +0,0 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

Binary file not shown.

View File

@@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
android/gradlew vendored
View File

@@ -1,251 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored
View File

@@ -1,94 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -1,161 +0,0 @@
#!/usr/bin/env python3
"""Build the BAL Reader Android APK via Gradle.
Re-synchronises the bundled codec modules into the app (so the APK always
carries the current ``bal/core`` sources), then invokes the Gradle wrapper to
produce the APK, and finally prints the artifact path, size and sha256.
Run from the repository root (any Python 3.8+, needs JDK 17 and an Android
SDK; the first build also needs a network connection for Gradle downloads):
python3 android/scripts/build_apk.py # debug APK
python3 android/scripts/build_apk.py --release # (unsigned) release APK
python3 android/scripts/build_apk.py --no-sync # skip codec re-sync
python3 android/scripts/build_apk.py --offline # gradle --offline
The APK is written under ``android/app/build/outputs/apk/``.
"""
import argparse
import hashlib
import os
import re
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
ANDROID_DIR = REPO_ROOT / "android"
GRADLEW = ANDROID_DIR / "gradlew"
LOCAL_PROPERTIES = ANDROID_DIR / "local.properties"
WRAPPER_JAR = ANDROID_DIR / "gradle" / "wrapper" / "gradle-wrapper.jar"
VARIANTS = {
"debug": "assembleDebug",
"release": "assembleRelease",
}
APK_REL = {
"debug": Path("app") / "build" / "outputs" / "apk" / "debug" / "app-debug.apk",
"release": Path("app") / "build" / "outputs" / "apk" / "release" / "app-release-unsigned.apk",
}
SYNC_SCRIPT = ANDROID_DIR / "scripts" / "sync_codecs.py"
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def run(cmd, cwd, verbose: bool) -> int:
if verbose:
print("+", " ".join(str(c) for c in cmd))
result = subprocess.run(
cmd, cwd=str(cwd), capture_output=not verbose, text=True
)
if not verbose:
sys.stdout.write(result.stdout)
sys.stderr.write(result.stderr)
return result.returncode
def check_prerequisites() -> None:
if not (GRADLEW.exists() and WRAPPER_JAR.exists()):
sys.exit(
"error: gradle wrapper is incomplete ({} missing).\n"
"hint: run `gradle wrapper` in android/ once, or re-clone.".format(
WRAPPER_JAR if not WRAPPER_JAR.exists() else GRADLEW
)
)
java_ok = None
try:
out = subprocess.run(
["java", "-version"], capture_output=True, text=True, check=False
).stderr
match = re.search(r'version "(?:1\.)?(\d+)', out)
java_ok = int(match.group(1)) if match else None
except FileNotFoundError:
java_ok = None
if java_ok is None:
sys.exit("error: no JDK found on PATH (need JDK 17 for AGP 8.10).")
if java_ok < 17:
sys.exit("error: JDK {} on PATH, but the Android build needs JDK 17.".format(java_ok))
sdk = None
if LOCAL_PROPERTIES.exists():
for line in LOCAL_PROPERTIES.read_text().splitlines():
if line.startswith("sdk.dir="):
sdk = line.split("=", 1)[1]
sdk = sdk or os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")
if not sdk or not Path(sdk).exists():
sys.exit(
"error: Android SDK not found.\n"
"hint: set sdk.dir in android/local.properties or ANDROID_HOME."
)
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--release",
action="store_true",
help="build the (unsigned) release APK instead of the debug APK",
)
parser.add_argument(
"--no-sync",
action="store_true",
help="skip re-synchronising the bundled codec modules",
)
parser.add_argument(
"--offline",
action="store_true",
help="pass --offline to Gradle (no dependency downloads)",
)
parser.add_argument(
"--clean",
action="store_true",
help="run the Gradle clean task before building",
)
parser.add_argument(
"--verbose", action="store_true", help="stream Gradle output"
)
args = parser.parse_args(argv)
check_prerequisites()
variant = "release" if args.release else "debug"
if not args.no_sync:
sync = subprocess.run(
[sys.executable, str(SYNC_SCRIPT)], cwd=str(REPO_ROOT), check=False
)
if sync.returncode != 0:
print("error: codec re-sync failed; refusing to build a stale APK.")
return sync.returncode
tasks = []
if args.clean:
tasks.append("clean")
tasks.append(VARIANTS[variant])
cmd = [str(GRADLEW)]
if args.offline:
cmd.append("--offline")
cmd.extend(tasks)
rc = run(cmd, cwd=ANDROID_DIR, verbose=args.verbose)
if rc != 0:
print("error: Gradle {} failed (exit {}).".format(" ".join(tasks), rc))
return rc
apk = ANDROID_DIR / APK_REL[variant]
if not apk.exists():
print("error: expected APK not found at {}".format(apk))
return 1
data = apk.read_bytes()
print("APK : {}".format(apk.relative_to(REPO_ROOT)))
print("size : {} bytes".format(len(data)))
print("sha256: {}".format(sha256(data)))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View File

@@ -1,99 +0,0 @@
#!/usr/bin/env python3
"""Synchronise the BAL QR codec modules into the Android app.
Copies ``bal/core/{__init__,animated_qr,qrtransfer}.py`` from the plugin repo
into ``android/app/src/main/python/bal/core/`` so Chaquopy ships exactly the
same code the desktop plugin runs. Afterwards the copies are imported
standalone and used for one quick encode/decode round trip.
Run from the repository root (any Python 3.8+, no dependencies):
python3 android/scripts/sync_codecs.py
python3 android/scripts/sync_codecs.py --check # no writes
Re-run whenever ``bal/core/animated_qr.py`` or ``bal/core/qrtransfer.py``
changes; the bundled copies are committed for deterministic builds.
"""
import argparse
import hashlib
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
CORE_SRC = REPO_ROOT / "bal" / "core"
PYTHON_DEST = REPO_ROOT / "android" / "app" / "src" / "main" / "python"
BAL_CORE_DEST = PYTHON_DEST / "bal" / "core"
FILES = ("__init__.py", "animated_qr.py", "qrtransfer.py")
ROUNDTRIP = (
"import sys; "
"sys.path.insert(0, {dest!r}); "
"from bal.core.animated_qr import AnimatedQrSession, ur2_frames; "
"import bal.core.qrtransfer as qtf; "
"frames = ur2_frames(b'roundtrip-check', 400); "
"assert frames, 'no frames produced'; "
"s = AnimatedQrSession(); "
"assert all(s.add_part(f) == 'ok' for f in frames); "
"transfer, compressed = s.resolve(); "
"assert not compressed and transfer == 'roundtrip-check', 'roundtrip failed'; "
"print('standalone import + roundtrip OK'); "
)
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def check_up_to_date() -> int:
outdated = []
for name in FILES:
src = (CORE_SRC / name).read_bytes()
dst = BAL_CORE_DEST / name
if not dst.exists() or dst.read_bytes() != src:
outdated.append(name)
if outdated:
print("OUT OF DATE: {}".format(", ".join(outdated)))
print("run: python3 android/scripts/sync_codecs.py")
return 1
print("codec bundles are up to date")
return 0
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--check",
action="store_true",
help="verify the bundled copies are up to date without writing",
)
args = parser.parse_args(argv)
if args.check:
return check_up_to_date()
BAL_CORE_DEST.mkdir(parents=True, exist_ok=True)
for name in FILES:
src = (CORE_SRC / name).read_bytes()
dst = BAL_CORE_DEST / name
dst.write_bytes(src)
print("synced {:16s} sha256={}".format(name, sha256(src)[:16]))
run = subprocess.run(
[sys.executable, "-c", ROUNDTRIP.format(dest=str(PYTHON_DEST))],
cwd=REPO_ROOT,
capture_output=True,
text=True,
)
if run.returncode != 0:
print(run.stdout, end="")
print(run.stderr, end="")
return 1
print(run.stdout.strip())
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View File

@@ -1,23 +0,0 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "BALReader"
include(":app")

View File

@@ -1,314 +0,0 @@
#!/usr/bin/env python3
"""Verify the Android app can decode every frame format the plugin exports.
Simulates the exact runtime path of the APK on the development machine:
* imports ``bal.core`` and ``balreader.payload`` from the *bundled* copies in
``android/app/src/main/python`` (the code Chaquopy actually ships);
* generates frames exactly as the plugin's export page does
(``split_frames`` for BAL QR, ``encode_animated_frames`` semantics for
UR v1 / UR v2 / BBQR);
* drives an :class:`~bal.core.animated_qr.AnimatedQrSession` the way
``BalDecoder.add`` does (scrambled input, duplicates, dropped frames);
* runs the app's ``finish()`` chain (``resolve()`` -> ``decode_transfer()``
-> ``decode_will_payload()``) and checks the result;
* cross-checks the app's ``decode_will_payload`` copy result-for-result (and
AST-for-AST) against the plugin's original in ``bal/gui/qt/dialogs.py``.
Run from the repository root (any Python 3.8+, no dependencies):
python3 android/test_chain/verify_chain.py
"""
import ast
import json
import random
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
APP_PYTHON = REPO_ROOT / "android" / "app" / "src" / "main" / "python"
DIALOGS = REPO_ROOT / "bal" / "gui" / "qt" / "dialogs.py"
sys.path.insert(0, str(APP_PYTHON))
from bal.core import animated_qr as aq # noqa: E402
from bal.core import qrtransfer as qtf # noqa: E402
from balreader import bridge as bridge_codec # noqa: E402
from balreader import payload as payload_codec # noqa: E402
PASSED = 0
def ok(condition, label):
global PASSED
if not condition:
raise AssertionError("FAILED: " + label)
PASSED += 1
def check_imported_bundle():
for module in (aq, qtf, payload_codec):
assert module.__file__ is not None
path = str(Path(module.__file__).resolve())
assert path.startswith(str(APP_PYTHON)), path
ok(True, "all modules imported from the bundled android/ copies")
def find_function(tree, name):
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == name:
return node
raise RuntimeError("{} not found".format(name))
# --------------------------------------------------------------------------- #
# Fixtures
# --------------------------------------------------------------------------- #
TXS = ["{:064x}".format(i) for i in range(1, 8)]
WILL_ITEMS = {
"imp{}".format(i): {
"tx": "{:064x}".format(i + 1),
"addr": "bc1qdeadbeef{:x}".format(i),
"amount": 100000 + i,
"tag": "heiress-{}".format(i),
"metadata": {},
"notify": "mail-{}@example.invalid".format(i),
}
for i in range(3)
}
WILL_JSON_COMPACT = json.dumps(WILL_ITEMS, separators=(",", ":"))
WILL_JSON_PRETTY = json.dumps(WILL_ITEMS, indent=2)
# --------------------------------------------------------------------------- #
# Parity: app's decode_will_payload vs the plugin's dialogs.py original
# --------------------------------------------------------------------------- #
def build_extracted_and_app_function():
dialogs_source = DIALOGS.read_text()
app_source = Path(payload_codec.__file__).read_text()
dialogs_node = find_function(ast.parse(dialogs_source), "decode_will_payload")
app_node = find_function(ast.parse(app_source), "decode_will_payload")
ok(
ast.dump(app_node) == ast.dump(dialogs_node),
"decode_will_payload AST identical between app copy and plugin",
)
namespace = {}
exec("import json\nimport re\nfrom typing import Any", namespace)
exec(compile(ast.Module(body=[dialogs_node], type_ignores=[]), "dialogs.py", "exec"), namespace)
return namespace["decode_will_payload"]
def run_payload_parity_cases():
plugin_decode = build_extracted_and_app_function()
samples = {
"will-compact": WILL_JSON_COMPACT,
"will-pretty": WILL_JSON_PRETTY,
"txs-newlines": "\n".join(TXS),
"txs-comma-crlf": ",\r\n".join(TXS[:3]),
"single-tx": TXS[0],
"json-array": json.dumps(TXS),
"not-json-dict": "hello world",
"empty": "",
"whitespace": " \n\t ",
}
for label, text in samples.items():
app_result = payload_codec.decode_will_payload(text)
plugin_result = plugin_decode(text)
ok(
app_result == plugin_result,
"payload parity for {!r}".format(label),
)
# --------------------------------------------------------------------------- #
# Full decode chain (the app's BalDecoder.finish())
# --------------------------------------------------------------------------- #
def app_chain(transfer_text, compressed):
parts = qtf.decode_transfer(transfer_text, compressed)
payload = "\n".join(parts)
kind, data = payload_codec.decode_will_payload(payload)
return parts, payload, kind, data
def feed_frame_set(session, frames, *, drop=None, order=None):
indexes = list(range(len(frames)))
if drop:
indexes = [i for i in indexes if i not in drop]
if order is not None:
indexes = list(order)
for i in indexes:
session.add_part(frames[i])
def make_frames(transfer_text, fmt, budget_chars, *, compressed=False):
payload = transfer_text.encode("utf-8")
if fmt == "balqr":
return qtf.split_frames(transfer_text, budget_chars, compressed=compressed)
if fmt == "ur1":
return aq.ur1_frames(payload, budget_chars)
if fmt == "ur2":
return aq.ur2_frames(payload, budget_chars)
if fmt == "bbqr":
return aq.bbqr_frames(payload, budget_chars, encoding="Z")
raise AssertionError("unknown format " + fmt)
def run_transport_case(transport, budget_chars, transfer_text, compressed=False):
frames = make_frames(transfer_text, transport, budget_chars, compressed=compressed)
session = aq.AnimatedQrSession()
rng = random.Random(len(transfer_text) + len(transport.encode()))
order = [i for i in range(len(frames))]
rng.shuffle(order)
feed_frame_set(session, frames, order=order)
ok(session.done, "{} (.{} chars) reaches done in scrambled order".format(transport, budget_chars))
if transport == "ur2":
# Fountain indexes can range wider than seq_len, so received may
# exceed (or fall short of) total; only progress and completion matter.
ok(session.total >= 1 and session.received >= 1,
"{} reports positive progress".format(transport))
else:
ok(
session.received == session.total,
"{} received matches total".format(transport),
)
ok(
session.received == len(frames) and session.total == len(frames),
"{} received/total equals frame count".format(transport),
)
transfer, compressed_flag = session.resolve()
ok(transfer == transfer_text, "{} restores exact transfer text".format(transport))
parts, payload, kind, data = app_chain(transfer, compressed_flag)
bridge_json = json.loads(bridge_codec.finish(session))
ok(
bridge_json == {"kind": kind, "payload": payload, "parts": parts},
"{} bridge.finish JSON matches the app chain".format(transport),
)
return parts, payload, kind, data
def test_tx_transports():
transfer = qtf.encode_transfer(TXS, compress=False)
for transport in ("balqr", "ur1", "ur2", "bbqr"):
parts, payload, kind, data = run_transport_case(transport, 400, transfer)
ok(kind == "txs", "{} classifies as txs".format(transport))
ok(parts == TXS and payload == "\n".join(TXS), "{} yields the tx list".format(transport))
def test_compressed_bal_transport():
transfer = qtf.encode_transfer(TXS, compress=True)
frames = make_frames(transfer, "balqr", 400, compressed=True)
session = aq.AnimatedQrSession()
feed_frame_set(session, frames)
ok(session.done, "compressed BAL QR done")
parts, payload, kind, data = app_chain(*session.resolve())
ok(kind == "txs" and parts == TXS, "compressed BAL QR yields the tx list")
def test_will_transports():
for transport in ("balqr", "ur1", "ur2", "bbqr"):
parts, payload, kind, data = run_transport_case(transport, 400, WILL_JSON_COMPACT)
ok(kind == "will", "{} classifies as will".format(transport))
ok(data == WILL_ITEMS, "{} restores the whole-will dict".format(transport))
# Pretty JSON works too (blank lines are whitespace for json.loads).
parts, payload, kind, data = run_transport_case("ur2", 400, WILL_JSON_PRETTY)
ok(kind == "will" and data == WILL_ITEMS, "pretty JSON transport restores the will dict")
def test_duplicates_are_ignored():
# UR v1 (multi-fragment) dedups by fragment index; UR v2 fountains never
# report "dup" (they dedup internally), matching the plugin's behaviour.
frames = make_frames(WILL_JSON_COMPACT, "ur1", 200)
ok(len(frames) >= 2, "UR v1 yields multiple fragments (got {})".format(len(frames)))
session = aq.AnimatedQrSession()
for i in range(len(frames)):
first = session.add_part(frames[i])
dup = session.add_part(frames[i])
ok(first == "ok", "fresh UR v1 frame reported as ok")
ok(dup == "dup", "duplicate UR v1 frame reported as dup")
ok(session.received == len(frames), "duplicates do not inflate received")
ok(session.done, "UR v1 completes after duplicates")
def test_ur2_fountain_survives_loss_and_reorder():
transfer = qtf.encode_transfer(TXS, compress=False)
frames = make_frames(transfer, "ur2", 120)
ok(len(frames) >= 6, "fountain yields multiple frames (got {})".format(len(frames)))
drop = (0, 2, len(frames) - 1)
session = aq.AnimatedQrSession()
rng = random.Random(99)
order = [i for i in range(len(frames)) if i not in drop]
rng.shuffle(order)
feed_frame_set(session, frames, order=order, drop=drop)
ok(session.done, "fountain completes after dropped + reordered frames")
transfer, compressed_flag = session.resolve()
ok(transfer == transfer, "fountain restores exact transfer text")
parts, payload, kind, data = app_chain(transfer, compressed_flag)
ok(kind == "txs" and parts == TXS, "fountain output feeds the full import tail")
def test_ur2_single_part_and_duplicate():
transfer = qtf.encode_transfer(TXS, compress=False)
frames = make_frames(transfer, "ur2", 2000)
ok(len(frames) == 1, "large budget yields a single-part UR v2 frame")
session = aq.AnimatedQrSession()
session.add_part(frames[0])
ok(session.done and session.received == 1, "single-part UR v2 completes")
def test_bbqr_all_three_encodings():
for encoding in ("Z", "H", "2"):
frames = aq.bbqr_frames(WILL_JSON_COMPACT.encode(), 120, encoding=encoding)
session = aq.AnimatedQrSession()
feed_frame_set(session, frames)
ok(session.done, "BBQR {} done".format(encoding))
transfer, compressed = session.resolve()
parts, payload, kind, data = app_chain(transfer, compressed)
ok(kind == "will" and data == WILL_ITEMS, "BBQR {} restores the will".format(encoding))
def test_mid_transfer_conflict():
ur1 = aq.ur1_frames(b"transfer-one", 400)
ur2 = aq.ur2_frames(b"transfer-two", 400)
session = aq.AnimatedQrSession()
session.add_part(ur1[0])
try:
session.add_part(ur2[0])
except aq.TransferConflictError:
ok(True, "format switch raises TransferConflictError")
else:
ok(False, "format switch raised TransferConflictError")
def test_garbage_and_single_line():
session = aq.AnimatedQrSession()
try:
session.add_part("this is not a QR transfer")
except aq.FormatNotDetectedError:
ok(True, "garbage raises FormatNotDetectedError")
else:
ok(False, "garbage raised FormatNotDetectedError")
def main():
check_imported_bundle()
run_payload_parity_cases()
test_tx_transports()
test_compressed_bal_transport()
test_will_transports()
test_duplicates_are_ignored()
test_ur2_fountain_survives_loss_and_reorder()
test_ur2_single_part_and_duplicate()
test_bbqr_all_three_encodings()
test_mid_transfer_conflict()
test_garbage_and_single_line()
print("verify_chain: {} checks passed".format(PASSED))
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,202 +0,0 @@
"""Standalone QR emitter for testing the Android reader on a real camera.
Replicates exactly what the plugin's QR export page puts on screen
(``BalQrExportWidget``): the same ``encode_transfer`` + per-format frame
encoders from ``bal.core``, rendered one QR at a time with ``qrcode``.
Run from the repo root with the runtime venv (has ``bal``, ``qrcode``,
PyQt6):
source "$BAL_HOME/electrum/env/bin/activate"
QT_QPA_PLATFORM=xcb python3 android/tools/emitter.py --format ur2 --loop
Controls:
Left/Right previous / next frame
Space toggle autoplay
L toggle loop (default off)
Q / Esc quit
"""
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
import qrcode # noqa: E402
from PyQt6.QtCore import Qt, QTimer # noqa: E402
from PyQt6.QtGui import QColor, QImage, QPainter, QPixmap # noqa: E402
from PyQt6.QtWidgets import QLabel, QMainWindow, QWidget # noqa: E402
from bal.core import animated_qr as aq # noqa: E402
from bal.core import qrtransfer as qtf # noqa: E402
def build_frames(tx_strings, fmt, budget):
"""Frames exactly as BalQrExportWidget._refresh_frames produces them."""
transfer = qtf.encode_transfer(tx_strings, compress=False)
if fmt == "balqr":
return qtf.split_frames(transfer, budget, compressed=False)
payload = transfer.encode("utf-8")
if fmt == "ur1":
return aq.ur1_frames(payload, budget)
if fmt == "ur2":
return aq.ur2_frames(payload, budget)
if fmt == "bbqr":
return aq.bbqr_frames(payload, budget, encoding="Z")
raise SystemExit("unknown format: {}".format(fmt))
def load_payload(args):
"""Return ``(tx_strings, description)`` mirroring ``_payload_strings``."""
if args.will is not None:
will = json.loads(Path(args.will).read_text())
return (
[json.dumps(will, ensure_ascii=False)],
"whole-will JSON ({})".format(Path(args.will).name),
)
if args.txs is not None:
raw = Path(args.txs).read_text()
return [line.strip() for line in raw.split() if line.strip()], "txs"
raise SystemExit("give --will FILE or --txs FILE")
def qr_pixmap(text, size):
"""Render ``text`` as a QR code fitted to a ``size``x``size`` image."""
qr = qrcode.QRCode(border=2, error_correction=qrcode.constants.ERROR_CORRECT_M)
qr.add_data(text)
qr.make(fit=True)
matrix = qr.modules
n = len(matrix)
border = qr.border
scale = max(1, size // (n + 2 * border))
dim = (n + 2 * border) * scale
image = QImage(dim, dim, QImage.Format.Format_RGB32)
image.fill(Qt.GlobalColor.white)
painter = QPainter(image)
painter.fillRect(0, 0, dim, dim, QColor("white"))
painter.setBrush(QColor("black"))
painter.setPen(Qt.PenStyle.NoPen)
for y, row in enumerate(matrix):
for x, on in enumerate(row):
if on:
painter.fillRect(
(x + border) * scale, (y + border) * scale, scale, scale,
QColor("black"),
)
painter.end()
return QPixmap.fromImage(image).scaled(
size, size, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
class EmitterWindow(QMainWindow):
def __init__(self, frames, description, fmt, fps, loop):
super().__init__()
self.frames = frames
self.fps = fps
self.loop = loop
self.index = 0
self.autoplay = True
central = QWidget(self)
self.setCentralWidget(central)
self.pix = QLabel(central)
self.pix.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.caption = QLabel(central)
self.caption.setAlignment(Qt.AlignmentFlag.AlignCenter)
import PyQt6.QtWidgets as qt # noqa: N813 - local import for clarity
v = qt.QVBoxLayout(central)
v.addWidget(self.pix, 1)
v.addWidget(self.caption)
self.setWindowTitle("BAL Reader emitter — {}".format(fmt))
self.resize(900, 1000)
self.timer = QTimer(self)
self.timer.timeout.connect(self._step)
self.timer.start(int(1000 / self.fps))
self._render()
self.caption.setText(
"{desc} | {fmt} | frame {i}/{n} | autoplay={auto} loop={loop}".format(
desc=description, fmt=fmt, i=self.index + 1, n=len(self.frames),
auto="on" if self.autoplay else "off", loop="on" if loop else "off",
)
)
self.show()
def _render(self):
self.pix.setPixmap(qr_pixmap(self.frames[self.index], min(self.width() - 60, 900)))
def _update_caption(self):
auto = "on" if self.autoplay else "off"
loop = "on" if self.loop else "off"
self.caption.setText(
"frame {i}/{n} ({fmt}) | autoplay={auto} loop={loop}".format(
i=self.index + 1, n=len(self.frames), fmt="", auto=auto, loop=loop)
)
def _step(self):
if self.index + 1 < len(self.frames):
self.index += 1
elif self.loop:
self.index = 0
else:
self.autoplay = False
self.timer.stop()
self._render()
self._update_caption()
def keyPressEvent(self, event): # noqa: N802 - Qt override name
key = event.key()
if key == Qt.Key.Key_Right:
self.autoplay = False
self.index = min(self.index + 1, len(self.frames) - 1)
self._render()
elif key == Qt.Key.Key_Left:
self.autoplay = False
self.index = max(self.index - 1, 0)
self._render()
elif key == Qt.Key.Key_Space:
self.autoplay = not self.autoplay
if self.autoplay:
self.timer.start(int(1000 / self.fps))
else:
self.timer.stop()
elif key == Qt.Key.Key_L:
self.loop = not self.loop
elif key in (Qt.Key.Key_Q, Qt.Key.Key_Escape):
self.close()
self._update_caption()
def main(argv):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--format", choices=["balqr", "ur1", "ur2", "bbqr"],
default="balqr")
parser.add_argument("--budget", type=int, default=400)
parser.add_argument("--fps", type=float, default=1.0)
parser.add_argument("--loop", action="store_true")
parser.add_argument("--will", help="whole-will JSON file")
parser.add_argument("--txs", help="file with serialized tx strings")
args = parser.parse_args(argv)
tx_strings, description = load_payload(args)
frames = build_frames(tx_strings, args.format, args.budget)
if len(frames) == 1:
print("single-frame transfer ready ({} bytes)".format(len(frames[0])), file=sys.stderr)
else:
print("{} frames ready".format(len(frames)), file=sys.stderr)
app = __import__("PyQt6.QtWidgets", fromlist=["QApplication"]).QApplication([])
EmitterWindow(frames, description, args.format, args.fps, args.loop)
app.exec()
if __name__ == "__main__":
main(sys.argv[1:])

View File

@@ -1,26 +0,0 @@
{
"imp-heiress-1": {
"tx": "0f1e2d3c4b5a69788796a5b4c3d2e1f0112233445566778899aabbccddeeff00",
"addr": "bc1qdeadbeef0",
"amount": 100000,
"tag": "heiress-1",
"metadata": {},
"notify": "heir1@example.invalid"
},
"imp-heiress-2": {
"tx": "112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011",
"addr": "bc1qdeadbeef1",
"amount": 200000,
"tag": "heiress-2",
"metadata": {},
"notify": "heir2@example.invalid"
},
"imp-heiress-3": {
"tx": "a1b2c3d4e5f60718293a4b5c6d7e8f901a2b3c4d5e6f708192a3b4c5d6e7f809",
"addr": "bc1qdeadbeef2",
"amount": 300000,
"tag": "heiress-3",
"metadata": {},
"notify": "heir3@example.invalid"
}
}

View File

@@ -238,24 +238,26 @@ async def willexecutors_update(
wallet, wallet,
"willexecutors_update", "willexecutors_update",
url, url,
address, address=address,
base_fee, base_fee=base_fee,
info, info=info,
promo_code, promo_code=promo_code,
rename_to, rename_to=rename_to,
) )
@plugin_command("nw", plugin_name) @plugin_command("nw", plugin_name)
async def willexecutors_select( async def willexecutors_select(
self, url, value=True, wallet=None, plugin=None self, url=None, value=True, only_valid=False, wallet=None, plugin=None
): ):
"""Select (or deselect) a will-executor. """Select (or deselect) a will-executor, or bulk-apply a selection.
arg:str:url:The will-executor URL. arg:str:url:The will-executor URL; omit to act on every executor.
arg:bool:value:True to select, False to deselect. arg:bool:value:True to select, False to deselect.
arg:bool:only_valid:When selecting/deselecting all, only change the valid executors (the invalid ones are deselected/left alone).
""" """
return _call(plugin, wallet, "willexecutors_select", [url], value) urls = [url] if url is not None else None
return _call(plugin, wallet, "willexecutors_select", urls, value, only_valid)
@plugin_command("nw", plugin_name) @plugin_command("nw", plugin_name)
@@ -298,12 +300,13 @@ async def willexecutors_import(self, path, wallet=None, plugin=None):
@plugin_command("nw", plugin_name) @plugin_command("nw", plugin_name)
async def willexecutors_export(self, path, wallet=None, plugin=None): async def willexecutors_export(self, path, subset=None, wallet=None, plugin=None):
"""Export the will-executors to a JSON file. """Export the will-executors to a JSON file.
arg:str:path:Destination file path. arg:str:path:Destination file path.
arg:str:subset:Optional filter: ``selected`` (only selected executors) or ``valid`` (only valid ones); omit to export all.
""" """
return _call(plugin, wallet, "willexecutors_export", path) return _call(plugin, wallet, "willexecutors_export", path, subset)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #

View File

@@ -40,9 +40,11 @@ from ..core import checkalive
from ..core import heirs as heirs_mod from ..core import heirs as heirs_mod
from ..core import will as will_mod from ..core import will as will_mod
from ..core.checkalive import ( from ..core.checkalive import (
CheckAliveAfterDeliveryError,
CheckAliveError, CheckAliveError,
check_alive_expired, ensure_check_alive_valid,
resolve_date_to_check, resolve_date_to_check,
resolve_delivery_date,
resolve_guard_threshold, resolve_guard_threshold,
) )
from ..core.heirs import Heirs, is_op_return_address from ..core.heirs import Heirs, is_op_return_address
@@ -86,6 +88,27 @@ def _user_facing(e):
return UserFacingException(_("Will-Executor not present")) return UserFacingException(_("Will-Executor not present"))
if isinstance(e, will_mod.NoWillExecutorNotPresent): if isinstance(e, will_mod.NoWillExecutorNotPresent):
return UserFacingException(_("No backup transaction or will-executor selected")) return UserFacingException(_("No backup transaction or will-executor selected"))
if isinstance(e, will_mod.HeirChangeException):
return UserFacingException(_("The heirs changed: a new will must be prepared."))
if isinstance(e, will_mod.WillexecutorChangeException):
return UserFacingException(
_("A will-executor changed: a new will must be prepared.")
)
if isinstance(e, will_mod.NotCompleteWillException):
# ``WillException.__init__`` stores the detail in ``self.msg`` but calls
# ``Exception.__init__(self)`` without it, so ``e.args`` is always empty;
# the default sentinel message is "WillException".
detail = getattr(e, "msg", None)
if detail and detail != "WillException":
return UserFacingException(
_(
"The wallet contains funds that the current will does not "
"cover yet: a new will must be prepared."
)
)
return UserFacingException(
_("The will contains no valid transaction: a new will must be prepared.")
)
if isinstance(e, will_mod.AmountException): if isinstance(e, will_mod.AmountException):
return UserFacingException( return UserFacingException(
_( _(
@@ -106,6 +129,12 @@ def _user_facing(e):
return UserFacingException(str(e)) return UserFacingException(str(e))
if isinstance(e, heirs_mod.LocktimeNotValid): if isinstance(e, heirs_mod.LocktimeNotValid):
return UserFacingException(str(e)) return UserFacingException(str(e))
if isinstance(e, checkalive.CheckAliveAfterDeliveryError):
return UserFacingException(
_(
"Check Alive must be after today and before the delivery date, please update it"
)
)
if isinstance(e, checkalive.CheckAliveError): if isinstance(e, checkalive.CheckAliveError):
return UserFacingException( return UserFacingException(
_( _(
@@ -113,6 +142,21 @@ def _user_facing(e):
"but less than the locktime" "but less than the locktime"
) )
) )
if isinstance(e, Willexecutors.NoServersForChainError):
if e.url:
return UserFacingException(
_(
"Could not reach the configured welist server.\n\n"
"Server: {url}\n"
"Error: {reason}\n\n"
"Please verify the welist server URL in the plugin settings."
).format(url=e.url, reason=e.reason or "empty response")
)
return UserFacingException(
_("No active will-executor found for the {chain} network.").format(
chain=e.chain
)
)
return e return e
@@ -272,13 +316,21 @@ class BalController:
self.plugin.is_basic_mode(), self.plugin.is_basic_mode(),
self.will_settings, self.will_settings,
built_locktime=Will.get_min_locktime(self.willitems), built_locktime=Will.get_min_locktime(self.willitems),
creation_date=Will.get_creation_time(self.willitems),
) )
self.no_willexecutor = bool(self.plugin.NO_WILLEXECUTOR.get()) self.no_willexecutor = bool(self.plugin.NO_WILLEXECUTOR.get())
self.willexecutors = Willexecutors.get_willexecutors( self.willexecutors = Willexecutors.get_willexecutors(
self.plugin, update=True, task=False self.plugin, update=True, task=False
) )
if check_alive_expired(self.plugin.is_basic_mode(), self.date_to_check): delivery_date = resolve_delivery_date(
raise CheckAliveError(self.date_to_check) self.will_settings,
built_locktime=Will.get_min_locktime(self.willitems),
)
ensure_check_alive_valid(
self.plugin.is_basic_mode(),
self.date_to_check,
delivery_date=delivery_date,
)
self._init_heirs_to_locktime(self.plugin.ENABLE_MULTIVERSE.get()) self._init_heirs_to_locktime(self.plugin.ENABLE_MULTIVERSE.get())
def _init_heirs_to_locktime(self, multiverse=False): def _init_heirs_to_locktime(self, multiverse=False):
@@ -336,6 +388,7 @@ class BalController:
self.date_to_check = resolve_date_to_check( self.date_to_check = resolve_date_to_check(
self.plugin.is_basic_mode(), self.will_settings, self.plugin.is_basic_mode(), self.will_settings,
built_locktime=_new_locktime, built_locktime=_new_locktime,
creation_date=Will.get_creation_time(self.willitems),
) )
self.willexecutors = Willexecutors.get_willexecutors( self.willexecutors = Willexecutors.get_willexecutors(
self.plugin, update=False, task=False self.plugin, update=False, task=False
@@ -369,7 +422,9 @@ class BalController:
tx["heirsvalue"] = txs[txid].heirsvalue tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description tx["description"] = txs[txid].description
tx["willexecutor"] = copy_structure(txs[txid].willexecutor) tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
tx["status"] = _("New") # Stored history: always English (translated only for display by
# format_status_history); never _() here.
tx["status"] = "New"
tx["baltx_fees"] = txs[txid].tx_fees tx["baltx_fees"] = txs[txid].tx_fees
tx["time"] = creation_time tx["time"] = creation_time
tx["heirs"] = copy_structure(txs[txid].heirs) tx["heirs"] = copy_structure(txs[txid].heirs)
@@ -399,6 +454,7 @@ class BalController:
will_locktime=Will.get_min_locktime( will_locktime=Will.get_min_locktime(
willitems, default_value=self.date_to_check willitems, default_value=self.date_to_check
), ),
fee_policy_descriptor=self.plugin.INVALIDATION_FEE_POLICY.get(),
) )
if tx is not None: if tx is not None:
try: try:
@@ -453,12 +509,19 @@ class BalController:
self.wallet.dust_threshold(), self.wallet.dust_threshold(),
max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(), max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(),
) )
except will_mod.AmountException as e:
# Mirror the GUI (BalWindow.build_inheritance_transaction): an
# amount mismatch is only a WARNING here, the flow continues (the
# rebuild/normalisation below reconciles the shares).
_logger.warning(f"prepare will: check_amounts: {e}")
except Exception as e: except Exception as e:
raise _user_facing(e) from e raise _user_facing(e) from e
locktime = Util.parse_locktime_string(self.will_settings["locktime"]) locktime = Util.parse_locktime_string(self.will_settings["locktime"])
threshold_ts = resolve_guard_threshold( threshold_ts = resolve_guard_threshold(
self.plugin.is_basic_mode(), self.will_settings self.plugin.is_basic_mode(),
self.will_settings,
creation_date=Will.get_creation_time(self.willitems),
) )
if threshold_ts is not None: if threshold_ts is not None:
if locktime < threshold_ts: if locktime < threshold_ts:
@@ -583,6 +646,11 @@ class BalController:
except will_mod.NoHeirsException as e: except will_mod.NoHeirsException as e:
_logger.info(f"auto rebuild: no heirs ({e})") _logger.info(f"auto rebuild: no heirs ({e})")
return {"result": "no_heirs"} return {"result": "no_heirs"}
except CheckAliveAfterDeliveryError:
_logger.info(
"auto rebuild: Check Alive not before the delivery, nothing to do"
)
return {"result": "check_alive_before_delivery"}
except CheckAliveError: except CheckAliveError:
_logger.info("auto rebuild: Check Alive already in the past") _logger.info("auto rebuild: Check Alive already in the past")
return self._auto_rebuild_invalidate("threshold_passed") return self._auto_rebuild_invalidate("threshold_passed")
@@ -923,6 +991,8 @@ class BalController:
heir.append(locktime) heir.append(locktime)
else: else:
heir.append(self.will_settings["locktime"]) heir.append(self.will_settings["locktime"])
if not self.plugin.ENABLE_MULTIVERSE.get():
heir[3] = self.will_settings["locktime"]
if is_op_return_address(address): if is_op_return_address(address):
heir[2] = "0" heir[2] = "0"
return Heirs.validate_heir(heir[0], heir[1:]) return Heirs.validate_heir(heir[0], heir[1:])
@@ -1008,7 +1078,7 @@ class BalController:
return self.willexecutors_show(url) return self.willexecutors_show(url)
def willexecutors_update(self, url, address=None, base_fee=None, info=None, def willexecutors_update(self, url, address=None, base_fee=None, info=None,
rename_to=None): promo_code=None, rename_to=None):
if url not in self.willexecutors: if url not in self.willexecutors:
raise UserFacingException(_("Will-Executor not found: {}").format(url)) raise UserFacingException(_("Will-Executor not found: {}").format(url))
we = self.willexecutors[url] we = self.willexecutors[url]
@@ -1019,6 +1089,8 @@ class BalController:
we["base_fee"] = int(base_fee) we["base_fee"] = int(base_fee)
if info is not None: if info is not None:
we["info"] = info.strip() or "New Will Executor" we["info"] = info.strip() or "New Will Executor"
if promo_code is not None:
we["promo_code"] = promo_code
if rename_to and rename_to != url: if rename_to and rename_to != url:
if rename_to in self.willexecutors: if rename_to in self.willexecutors:
raise UserFacingException( raise UserFacingException(
@@ -1039,17 +1111,38 @@ class BalController:
Willexecutors.save(self.plugin, self.willexecutors) Willexecutors.save(self.plugin, self.willexecutors)
return {"deleted": deleted} return {"deleted": deleted}
def willexecutors_select(self, urls=None, select=True): def willexecutors_select(self, urls=None, select=True, only_valid=False):
"""Select/deselect one, many or all will-executors.""" """Select or deselect will-executors.
selected = {}
targets = urls if urls is not None else list(self.willexecutors) Mirrors the GUI's single-toggle and ``set_select_all(select,
only_valid)`` bulk actions. ``urls`` is the set of records to act on
(all of them when omitted); with ``only_valid`` the selection is
restricted by validity: selecting sets the valid ones to True and the
invalid ones to False, while deselecting only clears the invalid ones.
"""
targets = (
list(self.willexecutors)
if urls is None
else [url for url in urls if url in self.willexecutors]
)
for url in targets: for url in targets:
if url not in self.willexecutors: we = self.willexecutors[url]
continue if only_valid:
self.willexecutors[url]["selected"] = bool(select) is_valid = Willexecutors.is_valid(
selected[url] = bool(select) we,
max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(),
dust=self.wallet.dust_threshold(),
)
if select:
we["selected"] = bool(is_valid)
elif not is_valid:
we["selected"] = False
else:
we["selected"] = bool(select)
Willexecutors.save(self.plugin, self.willexecutors) Willexecutors.save(self.plugin, self.willexecutors)
return selected return {
url: bool(self.willexecutors[url].get("selected")) for url in targets
}
def willexecutors_ping(self, urls=None): def willexecutors_ping(self, urls=None):
"""Ping the (selected) will-executor servers and refresh their info. """Ping the (selected) will-executor servers and refresh their info.
@@ -1099,10 +1192,12 @@ class BalController:
result = {} result = {}
last_error = None last_error = None
any_server_reached = False
try: try:
resp = Willexecutors.send_request( resp = Willexecutors.send_request(
"get", url, timeout=10, max_retries=1, retry_sleep=1 "get", url, timeout=10, max_retries=1, retry_sleep=1
) )
any_server_reached = True
if not isinstance(resp, dict): if not isinstance(resp, dict):
last_error = "invalid response format" last_error = "invalid response format"
_logger.warning( _logger.warning(
@@ -1128,12 +1223,17 @@ class BalController:
last_error = str(e) last_error = str(e)
_logger.error(f"fetch_will_executors_list: {url} -> {type(e).__name__}: {e}") _logger.error(f"fetch_will_executors_list: {url} -> {type(e).__name__}: {e}")
if not result and not basic: if not result:
raise UserFacingException( if not basic:
_("Could not reach the configured welist server.\nServer: {}\nError: {}").format( # Advanced mode: always raise with full details.
url, last_error or "empty response" raise Willexecutors.NoServersForChainError(
chainname,
url=url,
reason=last_error or "empty response",
) )
) if any_server_reached:
# Basic mode: the server answered but has no data for this chain.
raise Willexecutors.NoServersForChainError(chainname)
return result return result
def willexecutors_download(self): def willexecutors_download(self):
@@ -1153,18 +1253,41 @@ class BalController:
raise UserFacingException( raise UserFacingException(
_("Invalid entry {} in will-executors file").format(url) _("Invalid entry {} in will-executors file").format(url)
) )
if url not in self.willexecutors: # Mirror the GUI import (``willexecutors_list.update(data)``):
we = dict(we) # existing URLs are overwritten with the imported record, missing
we.setdefault("selected", False) # fields get sane defaults.
we.setdefault("status", "New") we = dict(we)
we.setdefault("promo_code", None) we.setdefault("selected", False)
self.willexecutors[url] = we we.setdefault("status", "New")
we.setdefault("promo_code", None)
self.willexecutors[url] = we
Willexecutors.save(self.plugin, self.willexecutors) Willexecutors.save(self.plugin, self.willexecutors)
return {"imported": len(data), "total": len(self.willexecutors)} return {"imported": len(data), "total": len(self.willexecutors)}
def willexecutors_export(self, path): def willexecutors_export(self, path, subset=None):
write_json_file(path, self.willexecutors) """Export the will-executors to ``path``.
return {"exported": len(self.willexecutors), "path": path}
``subset`` mirrors the GUI export filter: ``"selected"`` writes only
the selected records, ``"valid"`` only the valid ones, anything else
(or ``None``) writes every record.
"""
data = self.willexecutors
if subset == "selected":
data = {
url: we for url, we in data.items() if Willexecutors.is_selected(we)
}
elif subset == "valid":
data = {
url: we
for url, we in data.items()
if Willexecutors.is_valid(
we,
max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(),
dust=self.wallet.dust_threshold(),
)
}
write_json_file(path, data)
return {"exported": len(data), "path": path}
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Settings # Settings
@@ -1264,11 +1387,3 @@ class BalController:
) )
return value return value
return str(raw) return str(raw)
def get_decimal_point(plugin):
"""Standalone helper returning Electrum's configured BTC decimal point."""
try:
return plugin.config.BTC_AMOUNTS_DECIMAL_POINT
except AttributeError:
return 8

View File

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

View File

@@ -6,8 +6,21 @@ The "Check Alive" policy: the single reference timestamp (``date_to_check``)
against which every will-validity check is evaluated, and the BASIC/ADVANCED against which every will-validity check is evaluated, and the BASIC/ADVANCED
mode rules that decide it. mode rules that decide it.
Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the A RELATIVE Check Alive value is anchored according to its SIGN:
postpone/invalidate flow; the decision that it *should* be raised lives here.
* NEGATIVE (``"-30d"`` / ``"-1y"``): counted BACKWARDS from the delivery date,
i.e. ``delivery + N days`` (``-30d`` = 30 days BEFORE the delivery). This is
the historical meaning of a relative threshold.
* POSITIVE (``"30d"`` / ``"1y"``): counted FORWARD from the inheritance
creation date (``min(WillItem.time)`` of the valid will items), i.e.
``creation + N days``. The creation date is frozen in the wallet DB, so the
reference never drifts with the clock.
* When the anchor is unknown (no built will yet, or imported items without a
``time``). The reference falls back to *now*.
Pure, GUI-free. The GUI raises :class:`CheckAliveError` /
:class:`CheckAliveAfterDeliveryError` to trigger the postpone/invalidate flow;
the decision that they *should* be raised lives here.
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -19,13 +32,114 @@ from .plugin_base import BalTimestamp
class CheckAliveError(Exception): class CheckAliveError(Exception):
"""Raised when the "check alive" date is in the past.""" """Raised when the "check alive" date is in the past."""
def __init__(self, timestamp_to_check): def __init__(self, timestamp_to_check, reason: str | None = None):
self.timestamp_to_check = timestamp_to_check self.timestamp_to_check = timestamp_to_check
self.reason = reason
def __str__(self): def __str__(self):
return "Check alive expired please update it: {}".format( message = "Check alive expired please update it: {}".format(
datetime.fromtimestamp(self.timestamp_to_check, tz=timezone.utc).isoformat() datetime.fromtimestamp(self.timestamp_to_check, tz=timezone.utc).isoformat()
) )
if self.reason:
message += f" ({self.reason})"
return message
class CheckAliveAfterDeliveryError(Exception):
"""Raised when the "check alive" date is not before the delivery date.
Deliberately NOT a subclass of :class:`CheckAliveError`: the existing
``except CheckAliveError`` handlers invalidate the inheritance on-chain,
while a Check Alive that lands on/after the delivery is a configuration
error that must only abort the current action.
"""
def __init__(self, timestamp_to_check, delivery_timestamp=None):
self.timestamp_to_check = timestamp_to_check
self.delivery_timestamp = delivery_timestamp
def __str__(self):
check_date = datetime.fromtimestamp(
self.timestamp_to_check, tz=timezone.utc
).isoformat()
if self.delivery_timestamp is None:
return (
"Check alive date must be after today and before the delivery "
f"date: {check_date}"
)
delivery_date = datetime.fromtimestamp(
self.delivery_timestamp, tz=timezone.utc
).isoformat()
return (
"Check alive date must be after today and before the delivery "
f"date: {check_date} >= {delivery_date}"
)
def resolve_delivery_date(
will_settings: Any,
now: float | None = None,
built_locktime: float | int | None = None,
) -> float | None:
"""Return the delivery date every Check Alive calculation is anchored to.
This is the single source of the delivery reference, shared by
:func:`resolve_date_to_check` and :func:`resolve_guard_threshold`:
* an ABSOLUTE stored locktime is used as-is;
* a RELATIVE stored locktime (``"2y"``) is resolved forward from *now*,
UNLESS a will has already been built: its frozen delivery date
(``built_locktime``, the locktime inside the signed tx) is then
authoritative, so an unchanged will never drifts as the clock advances.
Returns ``None`` when no locktime is stored.
"""
locktime_raw = will_settings.get("locktime")
if locktime_raw is None:
return None
if BalTimestamp(locktime_raw).unit is not None and built_locktime:
return BalTimestamp(int(built_locktime)).to_timestamp(now)
return BalTimestamp(locktime_raw).to_timestamp(now)
def resolve_relative_check_alive_date(
threshold_raw: Any,
delivery_date: float | None,
creation_date: float | None = None,
now: float | None = None,
) -> float:
"""Resolve a RELATIVE Check Alive value to an absolute timestamp.
The SIGN of the value selects the anchor (the single calculation rule):
* negative (``"-30d"``): ``delivery_date + N days`` (N < 0 -> BEFORE the
delivery);
* positive (``"30d"``): ``creation_date + N days`` (the inheritance
creation date);
* when the selected anchor is unknown, *now* is used as a fallback.
Args:
threshold_raw: the stored ``"<n>d"`` / ``"<n>y"`` value (may be
negative).
delivery_date: the resolved delivery timestamp (see
:func:`resolve_delivery_date`), or ``None``.
creation_date: the inheritance creation timestamp
(``Will.get_creation_time``), or ``None``.
now: overridable clock for tests.
Returns:
The absolute reference timestamp (float, UNIX seconds).
"""
threshold = BalTimestamp(threshold_raw)
if threshold.unit is None:
return threshold.to_timestamp()
if threshold.value < 0:
anchor = delivery_date
else:
anchor = creation_date
if anchor is None:
anchor = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
return threshold.to_date(anchor).timestamp()
def resolve_date_to_check( def resolve_date_to_check(
@@ -33,6 +147,7 @@ def resolve_date_to_check(
will_settings: Any, will_settings: Any,
now: float | None = None, now: float | None = None,
built_locktime: float | int | None = None, built_locktime: float | int | None = None,
creation_date: float | None = None,
) -> float: ) -> float:
"""Return the reference timestamp for every will-validity check. """Return the reference timestamp for every will-validity check.
@@ -41,22 +156,11 @@ def resolve_date_to_check(
``check_amounts`` and the locktime-vs-threshold guard. ``check_amounts`` and the locktime-vs-threshold guard.
* BASIC mode: the Check Alive is hidden and NOT editable, so it must never * BASIC mode: the Check Alive is hidden and NOT editable, so it must never
govern those checks. ``date_to_check`` is set to *now*: every check is govern those checks. ``date_to_check`` is set to *now*.
evaluated against the current moment (the Check Alive effectively does not * ADVANCED mode: an ABSOLUTE stored threshold is returned unchanged; a
exist) while the delivery locktime is still fully enforced. RELATIVE one is resolved by :func:`resolve_relative_check_alive_date`
* ADVANCED mode: the user-controlled stored threshold is used as-is. An (negative -> anchored to the delivery, positive -> anchored to the
ABSOLUTE threshold is returned unchanged; a RELATIVE one (``"30d"``/``"1y"``) inheritance creation date).
means "N days BEFORE the delivery date" and is resolved against the stored
locktime (matching the date the settings widget displays), so it stays in
lockstep with the built transactions instead of drifting with the clock.
A RELATIVE stored locktime is resolved against the frozen delivery date of
the built will (``built_locktime``, the locktime inside the signed tx) when
one exists: the will's real delivery date is authoritative, and resolving
the relative locktime from *now* would drift ``date_to_check`` past the
frozen tx locktime so an unchanged will wrongly reads as expired (asking to
invalidate) every day. Without a built will the legacy forward-from-now
resolution is kept.
Args: Args:
is_basic_mode: ``True`` for the SIMPLE / BASIC user type. is_basic_mode: ``True`` for the SIMPLE / BASIC user type.
@@ -65,6 +169,8 @@ def resolve_date_to_check(
now: overridable clock for tests; defaults to ``datetime.now()``. now: overridable clock for tests; defaults to ``datetime.now()``.
built_locktime: the absolute locktime frozen inside the built will's built_locktime: the absolute locktime frozen inside the built will's
transactions (``None`` when there is no built will yet). transactions (``None`` when there is no built will yet).
creation_date: the inheritance creation timestamp
(``Will.get_creation_time``); ``None`` falls back to *now*.
Returns: Returns:
The reference timestamp (float, UNIX seconds). The reference timestamp (float, UNIX seconds).
@@ -73,33 +179,24 @@ def resolve_date_to_check(
return (now if now is not None else datetime.now(tz=timezone.utc).timestamp()) return (now if now is not None else datetime.now(tz=timezone.utc).timestamp())
threshold = BalTimestamp(will_settings["threshold"]) threshold = BalTimestamp(will_settings["threshold"])
# A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery": if threshold.unit is None:
# the settings widget resolves it as real_threshold = locktime - N days. return threshold.to_timestamp()
# Resolving it FORWARD from now (BalTimestamp.to_timestamp) turns delivery_date = resolve_delivery_date(
# date_to_check into a moving target that disagrees with the fixed will_settings, now=now, built_locktime=built_locktime
# locktime of the built transactions (and with the date shown in the UI), )
# which can wrongly mark the will as expired/postponed. Resolve it return resolve_relative_check_alive_date(
# against the delivery date instead. will_settings["threshold"],
if threshold.unit is not None: delivery_date,
locktime_raw = will_settings.get("locktime") creation_date=creation_date,
if locktime_raw is None: now=now,
# No delivery reference to anchor to: fall back to the legacy )
# forward-from-now resolution.
return threshold.to_timestamp()
locktime_dt = BalTimestamp(locktime_raw).to_date(now)
# A RELATIVE stored locktime ("2y") is itself a moving target; when a
# will has already been built, its frozen delivery date (the tx
# locktime) is the authoritative anchor (see docstring).
if BalTimestamp(locktime_raw).unit is not None and built_locktime:
locktime_dt = BalTimestamp(int(built_locktime)).to_date(now)
return threshold.to_date(locktime_dt, reverse=True).timestamp()
return threshold.to_timestamp()
def resolve_guard_threshold( def resolve_guard_threshold(
is_basic_mode: bool, is_basic_mode: bool,
will_settings: Any, will_settings: Any,
now: float | None = None, now: float | None = None,
creation_date: float | None = None,
) -> float | None: ) -> float | None:
"""Resolve the "locktime is lower than threshold" guard's reference. """Resolve the "locktime is lower than threshold" guard's reference.
@@ -109,22 +206,22 @@ def resolve_guard_threshold(
Unlike :func:`resolve_date_to_check` -- which may be *anchored* to the 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 built will's frozen tx locktime so that an unchanged will never reads as
expired -- this helper resolves the threshold from the **stored settings expired -- this helper resolves the threshold from the **stored settings
alone**. Otherwise, when the stored relative locktime is shorter than the alone** (no ``built_locktime``). Otherwise, when the stored relative
frozen locktime of an old (still valid) built will (e.g. the delivery was locktime is shorter than the frozen locktime of an old (still valid) built
shortened from ``"2y"`` to ``"1y"``), the guard would compare the fresh will (e.g. the delivery was shortened from ``"2y"`` to ``"1y"``), the guard
"1y" locktime against the old will's anchored threshold and wrongly fire, would compare the fresh "1y" locktime against the old will's anchored
even though locktime > threshold by the settings themselves. threshold and wrongly fire, even though locktime > threshold by the
settings themselves.
* BASIC mode: no threshold exists. Returns ``None`` and the caller falls * BASIC mode: no threshold exists. Returns ``None`` and the caller falls
back to comparing the locktime against ``date_to_check`` (= now), so its back to comparing the locktime against ``date_to_check`` (= now), so its
behaviour is unchanged. behaviour is unchanged.
* ADVANCED mode with an ABSOLUTE threshold: returns the stored threshold * ADVANCED mode with an ABSOLUTE threshold: returns the stored threshold
as-is. as-is.
* ADVANCED mode with a RELATIVE threshold (``"30d"``/``"1y"``, meaning * ADVANCED mode with a RELATIVE threshold: resolved by
"N days BEFORE the delivery"): the threshold is anchored to the locktime :func:`resolve_relative_check_alive_date` (negative -> delivery anchor,
resolved forward from *now* (the settings' own delivery reading, never a positive -> inheritance creation date), using *now* for the delivery
built tx), keeping both sides of the comparison in the same reference reading and never a built tx.
frame, as the settings widget displays it.
Returns ``None`` when there is no threshold to enforce (BASIC mode or a Returns ``None`` when there is no threshold to enforce (BASIC mode or a
missing stored value). missing stored value).
@@ -137,23 +234,58 @@ def resolve_guard_threshold(
threshold = BalTimestamp(threshold_raw) threshold = BalTimestamp(threshold_raw)
if threshold.unit is None: if threshold.unit is None:
return threshold.to_timestamp() return threshold.to_timestamp()
now_dt = ( delivery_date = resolve_delivery_date(will_settings, now=now, built_locktime=None)
datetime.fromtimestamp(now, tz=timezone.utc) if now is not None else None return resolve_relative_check_alive_date(
threshold_raw,
delivery_date,
creation_date=creation_date,
now=now,
) )
locktime_dt = BalTimestamp(will_settings["locktime"]).to_date(now_dt)
return threshold.to_date(locktime_dt, reverse=True).timestamp()
def check_alive_expired( def is_check_alive_date_valid(
is_basic_mode: bool, date_to_check: float, now: float | None = None date_to_check: float | None,
delivery_date: float | None,
now: float | None = None,
) -> bool: ) -> bool:
"""True when the Check Alive guard should fire (``CheckAliveError``). """True when the Check Alive date is inside ``(now, delivery_date)``.
Only ADVANCED mode can be "expired": in BASIC mode the Check Alive is inert The single validity rule for the Check Alive reference: it must be AFTER
by construction, so a passed check-alive date must never force a postpone or today and BEFORE the delivery. A ``None`` ``delivery_date`` skips the
rewrite of the will. upper bound (no delivery is known yet).
""" """
if is_basic_mode: if date_to_check is None:
return False return False
current = now if now is not None else datetime.now(tz=timezone.utc).timestamp() current = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
return date_to_check < current if date_to_check < current:
return False
if delivery_date is not None and date_to_check >= delivery_date:
return False
return True
def ensure_check_alive_valid(
is_basic_mode: bool,
date_to_check: float | None,
delivery_date: float | None = None,
now: float | None = None,
) -> None:
"""Raise when the Check Alive date is not valid.
Only ADVANCED mode can be invalid: in BASIC mode the Check Alive is inert
by construction, so a passed check-alive date must never force a postpone
or rewrite of the will.
Raises:
CheckAliveError: the date is in the past.
CheckAliveAfterDeliveryError: the date is on/after the delivery.
"""
if is_basic_mode:
return
if date_to_check is None:
return
current = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
if date_to_check < current:
raise CheckAliveError(date_to_check)
if delivery_date is not None and date_to_check >= delivery_date:
raise CheckAliveAfterDeliveryError(date_to_check, delivery_date)

View File

@@ -107,7 +107,7 @@ def _checkbdy(s: str, pos: int, appendix: str) -> Tuple[int, str]:
def normalize_locktime_raw_text( def normalize_locktime_raw_text(
text: str, pos: int text: str, pos: int, allow_negative: bool = False
) -> Tuple[str, bool, bool, int]: ) -> Tuple[str, bool, bool, int]:
"""Sanitise the RAW locktime field text. """Sanitise the RAW locktime field text.
@@ -117,6 +117,9 @@ def normalize_locktime_raw_text(
Args: Args:
text: the raw field text. text: the raw field text.
pos: the cursor position within ``text``. pos: the cursor position within ``text``.
allow_negative: when ``True`` a single leading ``-`` is kept (used by
the Check Alive field, whose sign selects the anchor date). The
delivery (locktime) field never accepts a sign.
Returns: Returns:
``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised ``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised
@@ -124,11 +127,17 @@ def normalize_locktime_raw_text(
""" """
text = text.strip() text = text.strip()
chars = "0123456789dy" chars = "0123456789dy"
if allow_negative:
chars += "-"
pos = len("".join([i for i in text[:pos] if i in chars])) pos = len("".join([i for i in text[:pos] if i in chars]))
s = "".join([i for i in text if i in chars]) s = "".join([i for i in text if i in chars])
isdays = False isdays = False
isyears = False isyears = False
negative = allow_negative and "-" in s
if negative:
s = s.replace("-", "")
pos, s = _checkbdy(s, pos, "d") pos, s = _checkbdy(s, pos, "d")
pos, s = _checkbdy(s, pos, "y") pos, s = _checkbdy(s, pos, "y")
@@ -141,6 +150,10 @@ def normalize_locktime_raw_text(
s = replace_dy_suffixes(s) + "d" s = replace_dy_suffixes(s) + "d"
if isyears: if isyears:
s = replace_dy_suffixes(s) + "y" s = replace_dy_suffixes(s) + "y"
if negative:
s = "-" + s
pos = max(pos, 1)
return s, isdays, isyears, pos return s, isdays, isyears, pos

View File

@@ -251,6 +251,24 @@ class BalPlugin(BasePlugin):
self.TX_FEES = BalConfig(config, "bal_tx_fees", 100) self.TX_FEES = BalConfig(config, "bal_tx_fees", 100)
self.INVALIDATE = BalConfig(config, "bal_invalidate", True) self.INVALIDATE = BalConfig(config, "bal_invalidate", True)
self.ASK_INVALIDATE = BalConfig(config, "bal_ask_invalidate", True) self.ASK_INVALIDATE = BalConfig(config, "bal_ask_invalidate", True)
# INVALIDATION_FEE_POLICY (advanced): the fee rate the on-chain
# INVALIDATION transaction pays. It is expressed as an Electrum
# ``FeePolicy`` descriptor "<method>:<value>", the same format Electrum
# uses for its own ``FEE_POLICY`` setting, so the standard fee slider
# (Feerate / ETA / Mempool) can edit it directly:
# * "eta:2" - fee estimated for confirmation within 2 blocks
# (the default: Electrum's own default target);
# * "mempool:N" - fee targeting a depth of N vbytes in the mempool;
# * "feerate:N" - a fixed rate of N sat/kvbyte (works offline).
# WHY it is separate from the will's ``baltx_fees``: the will is built
# with a deliberately high rate because the will-executors may broadcast
# the inheritance years from now, while the invalidation is sent TODAY
# and must pay the current network fee. ``baltx_fees`` remains only the
# fallback used when no estimate is available.
self.INVALIDATION_FEE_POLICY = BalConfig(
config, "bal_invalidation_fee_policy", "eta:2"
)
self.PREVIEW = BalConfig(config, "bal_preview", True) self.PREVIEW = BalConfig(config, "bal_preview", True)
self.SAVE_TXS = BalConfig(config, "bal_save_txs", True) self.SAVE_TXS = BalConfig(config, "bal_save_txs", True)
@@ -305,11 +323,10 @@ class BalPlugin(BasePlugin):
# stay display-only outside the wizard unless the user opts in. # stay display-only outside the wizard unless the user opts in.
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False) self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in bytes, used # QR_CHUNK_SIZE (will transfer via QR): payload budget, in characters,
# per QR frame when exporting/importing a will through the QR channel. # used per QR frame when exporting/importing a will through the QR
# The settings dialog offers the 4 standard presets of # channel. The export dialog seeds its own size selector from this
# bal.core.qrtransfer.CHUNK_PRESETS; this stores the selected budget. # value (default 150, small QR, low-resolution cameras).
# Default 150 (small QR, low-resolution cameras).
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150) self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the # NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
@@ -498,22 +515,35 @@ class BalPlugin(BasePlugin):
@staticmethod @staticmethod
def default_will_settings_absolute(): def default_will_settings_absolute():
"""Convert the default relative dates into absolute timestamps (from today).""" """Convert the default relative dates into absolute timestamps.
The Check Alive anchor follows its sign (see ``bal.core.checkalive``):
a negative default counts back from the delivery, a positive one counts
forward from today (the inheritance creation date).
"""
relative_dates = BalPlugin.default_will_settings_relative() relative_dates = BalPlugin.default_will_settings_relative()
today = datetime.now(tz=timezone.utc).date() today = datetime.now(tz=timezone.utc).date()
dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc) dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc)
threshold = ( locktime_dt = dt + timedelta(
dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days()) days=BalTimestamp(relative_dates["locktime"]).duration_to_days()
).timestamp() )
locktime = ( threshold = BalTimestamp(relative_dates["threshold"])
dt + timedelta(days=BalTimestamp(relative_dates["locktime"]).duration_to_days()) if threshold.unit is not None and threshold.value < 0:
).timestamp() anchor_dt = locktime_dt
return {"threshold": threshold, "locktime": locktime} else:
anchor_dt = dt
threshold_dt = anchor_dt + timedelta(days=threshold.duration_to_days())
return {"threshold": threshold_dt.timestamp(), "locktime": locktime_dt.timestamp()}
@staticmethod @staticmethod
def default_will_settings_relative(): def default_will_settings_relative():
"""Default relative dates: 30 days threshold, 1 year locktime.""" """Default relative dates: 30 days BEFORE the delivery, 1 year locktime.
return {"threshold": "30d", "locktime": "1y"}
The Check Alive default is NEGATIVE on purpose: it keeps counting back
from the delivery date, preserving the historical "check alive 30 days
before delivery" behaviour.
"""
return {"threshold": "-30d", "locktime": "1y"}
class BalTimestamp: class BalTimestamp:

View File

@@ -9,31 +9,11 @@ Scope
----- -----
* converts will transactions into a compact ``transfer_string`` * converts will transactions into a compact ``transfer_string``
(newline-joined serialized transactions, optionally zlib + base64 (newline-joined serialized transactions, optionally zlib + base64
compressed); compressed). The transfer string is the payload carried by BBQR QR frames
* splits that string into fixed-size ``BAL1<TTT><iii><flag>`` frames for (``bal.core.animated_qr``).
multi-QR export, and reassembles/validates them on import.
Wire format (v2, compact) The audio-modem channel carries the whole transfer string in a single blob
------------------------- (PLAN_QR_TRANSFER.md section 4.4), so callers only use
A frame is::
BAL1<TTT><iii><flag><payload>
* ``BAL1`` - magic + format era (4 chars).
* ``TTT`` - frame total as exactly 3 base36 digits (1-based, cap 46655).
* ``iii`` - frame index as exactly 3 base36 digits (1-based).
* ``flag`` - one char: ``Z`` (zlib + base64) or ``0`` (plain ASCII).
* ``payload`` - every other character of the frame; the payloads of all
frames, concatenated in index order, rebuild the transfer string.
The fixed 11-char header replaces the legacy ``BALQR1|N|i|flags|`` form
(same 5 pieces of information) without any pipe separator, so the whole
frame is scan-friendly and the overhead no longer grows with the frame
count. Legacy ``BALQR1|…`` frames are still accepted on import.
The audio-modem channel deliberately bypasses the framing helpers here
(PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and
carries the whole transfer string in a single blob, so callers only use
:func:`encode_transfer` / :func:`decode_transfer`. :func:`encode_transfer` / :func:`decode_transfer`.
This module never imports Qt or any Electrum GUI code (house rule). This module never imports Qt or any Electrum GUI code (house rule).
@@ -44,12 +24,7 @@ from __future__ import annotations
import base64 import base64
import zlib import zlib
MAGIC = "BALQR" # 4 standard presets (label, payload budget per QR). Ordered from
VERSION = 1
FLAG_COMPRESSED = "Z"
FLAG_PLAIN = "0"
# 4 standard presets (label, payload budget in bytes per QR). Ordered from
# low-resolution cameras to high-resolution cameras (owner decision D5). # low-resolution cameras to high-resolution cameras (owner decision D5).
CHUNK_PRESETS = ( CHUNK_PRESETS = (
("Small - ~150 bytes/QR (low-res cameras)", 150), ("Small - ~150 bytes/QR (low-res cameras)", 150),
@@ -58,61 +33,16 @@ CHUNK_PRESETS = (
("XL - ~1800 bytes/QR (high-res cameras)", 1800), ("XL - ~1800 bytes/QR (high-res cameras)", 1800),
) )
# Smallest allowed payload budget per frame, below which the frame header
# could consume the whole budget.
MIN_CHUNK_SIZE = 40
# Legacy wire format (still imported); the exporter emits the v2 form below.
_FRAME_MAGIC_V1 = MAGIC + str(VERSION)
# Compact v2 wire format: fixed-width base36 count fields, no separators.
_FRAME_MAGIC_V2 = "BAL1"
_BASE36_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
_BASE36_WIDTH = 3
_HEADER_V2_LEN = len(_FRAME_MAGIC_V2) + 2 * _BASE36_WIDTH + 1
_MAX_TOTAL = 36 ** _BASE36_WIDTH - 1
class QrTransferError(ValueError):
"""Base error for will QR / audio transfer processing."""
class MissingFramesError(QrTransferError):
"""Some frame indices of a multi-QR transfer are missing."""
def __init__(self, missing):
self.missing = list(missing)
super().__init__("Missing QR frames: {}".format(self.missing))
class InconsistentTotalError(QrTransferError):
"""Frames disagree about the advertised frame total."""
def encode_transfer(tx_strings, compress=False): def encode_transfer(tx_strings, compress=False):
"""Join serialized transaction strings into a transfer string. """Join serialized transaction strings into a transfer string.
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so ``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
the whole bundle shrinks before being printed/scanned. The optional flag the whole bundle shrinks before being printed/scanned.
of the frame header lets the importer reverse this automatically.
""" """
return __compress("\n".join(tx_strings), enabled=compress) return __compress("\n".join(tx_strings), enabled=compress)
def encode_transfer_best(tx_strings):
"""Encode ``tx_strings`` with the smaller of plain vs compressed form.
Returns ``(transfer_string, compressed: bool)``. Compressed wins only
when zlib + base64 really is shorter (best-of, never larger).
"""
joined = "\n".join(tx_strings)
plain = joined
compressed = __compress(joined, enabled=True)
if len(compressed) < len(plain):
return compressed, True
return plain, False
def decode_transfer(transfer_string, compressed): def decode_transfer(transfer_string, compressed):
"""Inverse of :func:`encode_transfer`. """Inverse of :func:`encode_transfer`.
@@ -124,73 +54,6 @@ def decode_transfer(transfer_string, compressed):
return [part for part in text.split("\n") if part] return [part for part in text.split("\n") if part]
def split_frames(transfer_string, chunk_size, compressed=False):
"""Split ``transfer_string`` into full compact ``BAL1`` frames.
Every returned frame has the fixed 11-char v2 header followed by its
share of the payload, so each frame is at most ``chunk_size`` characters
long. ``compressed`` stamps the ``Z`` flag into every frame so the
importer knows how to reverse the encoding.
Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold
the header plus any payload, or when the transfer needs more than
:data:`_MAX_TOTAL` frames.
"""
flag = FLAG_COMPRESSED if compressed else FLAG_PLAIN
total = __compute_total(len(transfer_string), chunk_size)
budget = chunk_size - _HEADER_V2_LEN
frames = []
pos = 0
length = len(transfer_string)
for index in range(1, total + 1):
end = min(pos + budget, length)
frames.append(
_FRAME_MAGIC_V2
+ _base36(total)
+ _base36(index)
+ flag
+ transfer_string[pos:end]
)
pos = end
if pos < length:
# __compute_total guarantees this cannot happen; keep a safety net.
raise QrTransferError("internal error: frames did not cover the transfer string")
return frames
def parse_frame(frame):
"""Parse a single frame.
Accepts both the legacy ``BALQR1|total|index|flags|payload`` form and
the compact ``BAL1<total><index><flag><payload>`` v2 form.
Returns ``(total, index, compressed: bool, payload: str)``. Raises
:class:`QrTransferError` on malformed input (bad magic/version, wrong
arity, non-integer or out-of-range frame numbers, unknown flags).
"""
if frame.startswith(_FRAME_MAGIC_V2):
return _parse_v2(frame)
return _parse_v1(frame)
def assemble(frames, total):
"""Concatenate frame payloads back into a transfer string.
``frames`` maps 1-based index -> payload. Every index ``1..total`` must
be present (else :class:`MissingFramesError`) and no index may exceed
``total`` (else :class:`InconsistentTotalError`).
"""
if total < 1:
raise QrTransferError("invalid frame total")
missing = [index for index in range(1, total + 1) if index not in frames]
if missing:
raise MissingFramesError(missing)
extra = [index for index in frames if index > total]
if extra:
raise InconsistentTotalError()
return "".join(frames[index] for index in range(1, total + 1))
def preset_index_for_chunk_size(chunk_size): def preset_index_for_chunk_size(chunk_size):
"""Return the :data:`CHUNK_PRESETS` index whose budget best matches a size.""" """Return the :data:`CHUNK_PRESETS` index whose budget best matches a size."""
best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1]) best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1])
@@ -215,90 +78,3 @@ def __decompress(text, *, enabled):
if not enabled: if not enabled:
return text return text
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8") return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
def _base36(n):
"""Zero-padded :data:`_BASE36_WIDTH` base36 render of ``n``."""
if not 0 <= n <= _MAX_TOTAL:
raise QrTransferError("BAL QR part number out of range: {}".format(n))
chars = []
for _ in range(_BASE36_WIDTH):
chars.append(_BASE36_DIGITS[n % 36])
n //= 36
return "".join(reversed(chars))
def _base36_decode(text):
"""Inverse of :func:`_base36`; raises ``ValueError`` on bad input."""
if len(text) != _BASE36_WIDTH or any(c not in _BASE36_DIGITS for c in text):
raise ValueError(text)
n = 0
for c in text:
n = n * 36 + _BASE36_DIGITS.index(c)
return n
def _parse_v1(frame):
parts = frame.split("|", maxsplit=4)
if len(parts) != 5:
raise QrTransferError("Not a BAL will QR (bad frame structure)")
magic_seen, total_s, index_s, flags, payload = parts
if magic_seen != _FRAME_MAGIC_V1:
raise QrTransferError("Not a BAL will QR (unknown magic/version)")
try:
total = int(total_s)
index = int(index_s)
except ValueError as e:
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e
if total < 1 or not 1 <= index <= total:
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
if flags not in ("", FLAG_COMPRESSED):
raise QrTransferError("Not a BAL will QR (unknown flags)")
return total, index, flags == FLAG_COMPRESSED, payload
def _parse_v2(frame):
if len(frame) < _HEADER_V2_LEN:
raise QrTransferError("Not a BAL will QR (bad frame structure)")
# Magic is length _FRAME_MAGIC_V2; the two base36 fields and the flag
# make up the rest of the fixed header.
offset = len(_FRAME_MAGIC_V2)
total_s = frame[offset : offset + _BASE36_WIDTH]
index_s = frame[offset + _BASE36_WIDTH : offset + 2 * _BASE36_WIDTH]
flag = frame[offset + 2 * _BASE36_WIDTH]
try:
total = _base36_decode(total_s)
index = _base36_decode(index_s)
except ValueError:
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from None
if total < 1 or not 1 <= index <= total:
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
if flag not in (FLAG_PLAIN, FLAG_COMPRESSED):
raise QrTransferError("Not a BAL will QR (unknown flags)")
payload = frame[_HEADER_V2_LEN:]
return total, index, flag == FLAG_COMPRESSED, payload
def __compute_total(transfer_len, chunk_size):
"""Smallest frame count whose budget covers the whole transfer string.
The v2 header is fixed-width, so the budget is constant and the count is
a plain ceiling division, capped at :data:`_MAX_TOTAL`.
"""
if chunk_size < MIN_CHUNK_SIZE:
raise QrTransferError(
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
)
budget = chunk_size - _HEADER_V2_LEN
if budget <= 0:
raise QrTransferError(
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
)
total = -(-transfer_len // budget)
if total < 1:
total = 1
if total > _MAX_TOTAL:
raise QrTransferError(
"BAL QR transfer demands too many frames: {}".format(total)
)
return total

View File

@@ -26,9 +26,11 @@ The status flags themselves (the source of truth) stay here; only the mapping
"status -> colour" now lives in the GUI layer. No behaviour changed. "status -> colour" now lives in the GUI layer. No behaviour changed.
""" """
import math
from datetime import datetime, timezone from datetime import datetime, timezone
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
from electrum.fee_policy import FeePolicy
from electrum.logging import Logger, get_logger from electrum.logging import Logger, get_logger
from electrum.transaction import ( from electrum.transaction import (
PartialTransaction, PartialTransaction,
@@ -50,6 +52,18 @@ from .willexecutors import Willexecutors
MIN_LOCKTIME = 1 MIN_LOCKTIME = 1
MIN_BLOCK = 1 MIN_BLOCK = 1
# Confirmation target (in blocks) for the on-chain INVALIDATION transaction.
# WHY: the invalidation is sent TODAY (it spends the will's coins so the old
# pre-signed inheritance txs become unusable), so it must pay the CURRENT
# network fee for a quick confirmation - not ``baltx_fees``, which is a
# deliberately high rate reserved for inheritance txs that may be broadcast
# years from now. 2 blocks is Electrum's own default target (owner decision).
INVALIDATION_FEE_ETA_BLOCKS = 2
# Default fee policy for the invalidation tx, as an Electrum ``FeePolicy``
# descriptor ("<method>:<value>"). The user can change it from the plugin's
# graphical settings, which uses Electrum's standard fee slider (Feerate / ETA /
# Mempool). "eta:2" = fee estimated for confirmation within 2 blocks.
INVALIDATION_FEE_POLICY_DEFAULT = f"eta:{INVALIDATION_FEE_ETA_BLOCKS}"
_logger = get_logger(__name__) _logger = get_logger(__name__)
@@ -68,6 +82,22 @@ class Will:
# build a tree with parent transactions # build a tree with parent transactions
@staticmethod @staticmethod
def add_willtree(will): def add_willtree(will):
"""(Re)build the parent/child links between the will transactions.
Every item gets ``children`` (the will txs spending its outputs) and,
when it spends the output of another will tx, a ``father``.
WHY the reset below: ``father`` is persisted in the wallet (the
``"Father"`` field of ``WillItem.to_dict``) and was only ever set,
never cleared. When the father tx was later removed from the will
(rebuild, merge, replacement) the child kept pointing to a txid that
no longer exists, and ``check_invalidated`` crashed with a
``KeyError`` on that txid (reported on a real wallet: the Check
wizard and the window refresh both failed). The tree is therefore
always recomputed from scratch from the transactions themselves.
"""
for willid in will:
will[willid].father = None
for willid in will: for willid in will:
will[willid].children = Will.get_children(will, willid) will[willid].children = Will.get_children(will, willid)
for child in will[willid].children: for child in will[willid].children:
@@ -438,61 +468,220 @@ class Will:
out[inp.prevout.to_str()] = inp out[inp.prevout.to_str()] = inp
return out return out
@staticmethod
def get_invalidation_fee_rate(
wallet, fallback_fee_per_byte, fee_policy_descriptor=None
):
"""Return ``(fee_rate, source)`` for the on-chain invalidation tx.
``fee_rate`` is in sat/vbyte. ``source`` names where the rate came from:
the ``FeeMethod`` used by the policy (``"feerate"``, ``"eta"``,
``"mempool"``) or ``"fallback"`` when no usable rate was available.
WHY: the invalidation is broadcast NOW, so it pays the current network
fee according to the user's chosen ``FeePolicy`` (default ``"eta:2"``,
i.e. a confirmation within 2 blocks). The will's ``baltx_fees`` is kept
only as a fallback, used when no estimate is available (offline wallet,
estimates not received yet, server error), which is exactly the
behaviour of previous versions.
The rate is computed by Electrum's own ``FeePolicy`` (the module behind
the standard fee slider), so the graphical settings can offer Feerate /
ETA / Mempool. It returns sat/kvbyte and already clamps dynamic
estimates between Electrum's conservative minimum relay fee and its
maximum dynamic fee; a fixed "feerate" policy needs no network at all.
This method never raises.
Args:
wallet: The Electrum wallet (its ``network`` may be ``None``).
fallback_fee_per_byte: Rate (sat/vbyte) used when there is no
usable rate - normally the will's ``baltx_fees``.
fee_policy_descriptor: Electrum fee policy descriptor (e.g.
``"eta:2"``). ``None`` uses ``INVALIDATION_FEE_POLICY_DEFAULT``.
"""
descriptor = fee_policy_descriptor or INVALIDATION_FEE_POLICY_DEFAULT
# FeePolicy.parse handles malformed descriptors internally (it logs a
# warning and falls back to "eta:2"), so this never raises.
policy = FeePolicy(descriptor)
network = getattr(wallet, "network", None) if wallet else None
fee_per_kb = None
try:
fee_per_kb = policy.fee_per_kb(network)
except Exception as e:
_logger.warning(f"invalidation fee: no estimate ({e!r})")
fee_per_kb = None
# Accept only a real positive integer (Electrum's type for sat/kvbyte);
# ``bool`` is excluded explicitly because it is a subclass of ``int``.
if (
isinstance(fee_per_kb, int)
and not isinstance(fee_per_kb, bool)
and fee_per_kb > 0
):
return fee_per_kb / 1000, policy.method.name.lower()
return fallback_fee_per_byte, "fallback"
@staticmethod
def _invalidation_age_key(utxo):
"""Sort key for invalidation coins: smaller = in the wallet longest.
"Oldest" is approximated by the funding transaction's confirmation
height (confirmed coins with the lowest height were received first).
Unconfirmed, local or unknown-height coins have no mined position and
are treated as the newest. Deterministic tie-breakers: position within
the block, then the prevout string.
"""
height = getattr(utxo, "block_height", None)
if isinstance(height, int) and height > 0:
txpos = getattr(utxo, "block_txpos", 0) or 0
return (0, height, txpos, utxo.prevout.to_str())
return (1, 0, 0, utxo.prevout.to_str())
@staticmethod @staticmethod
def invalidate_will(will, wallet, fees_per_byte, history_label=None, def invalidate_will(will, wallet, fees_per_byte, history_label=None,
will_locktime=None): will_locktime=None, fee_policy_descriptor=None):
"""Build the (unsigned) on-chain invalidation transaction.
The transaction spends, per will transaction, only the OLDEST funding
coin (the one that has been in the wallet longest): spending a single
input already makes the pre-signed transaction unspendable, while the
newer coins stay available for the rebuilt (postponed) will. Both VALID
and REPLACED will items are considered, because a REPLACED item's
pre-signed transaction may have been pushed to the will-executors and
may spend different coins. The outputs go back to one of the wallet's
own change addresses. When the chosen coins would not cover the fee
while leaving a change output at or above the dust threshold, additional
(newer) candidate coins are added as fallback inputs; if the change
still cannot clear dust once the coins are exhausted, no invalidation
transaction is returned.
Fee: the CURRENT network fee according to the user's fee policy
(``fee_policy_descriptor``, default ``"eta:2"``; see
:meth:`get_invalidation_fee_rate`). ``fees_per_byte`` (the will's
``baltx_fees``) is only the FALLBACK used when no usable rate is
available. RBF stays enabled, so the fee can still be raised from
Electrum's History ("Increase fee") if the transaction gets stuck.
Returns the ``PartialTransaction``, or ``None`` when there is nothing
to invalidate or the balance does not cover the fee.
"""
_logger.debug("invalidate tx in will module") _logger.debug("invalidate tx in will module")
will_only_valid = Will.only_valid_list(will) # Invalidating only the VALID items is not enough. A will rebuilt after
inputs = Will.get_all_inputs(will_only_valid) # an heir change (or a rebuild) supersedes the previously signed
# transaction: the old item is kept but marked REPLACED, which CLEARS
# VALID (see set_status). That old transaction may have been pushed to
# the will-executors and may spend DIFFERENT coins than the current
# valid one, so its funding UTXOs must be spent too, otherwise a
# will-executor could still broadcast it from a UTXO left unspent.
# REPLACED items therefore join the VALID ones; inputs that are already
# spent are naturally dropped by the wallet-UTXO filter below.
will_to_invalidate = {
wid: wi
for wid, wi in will.items()
if wi.get_status("VALID") or wi.get_status("REPLACED")
}
if history_label is not None and will_locktime is not None: if history_label is not None and will_locktime is not None:
utxos = Util.get_available_utxos(wallet, history_label, will_locktime) utxos = Util.get_available_utxos(wallet, history_label, will_locktime)
else: else:
utxos = wallet.get_utxos() utxos = wallet.get_utxos()
filtered_inputs = []
prevout_to_spend = []
current_height = Util.get_current_height(wallet.network) current_height = Util.get_current_height(wallet.network)
for prevout_str, ws in inputs.items(): utxo_by_prevout = {}
for w in ws:
if w[0] not in filtered_inputs:
filtered_inputs.append(w[0])
if prevout_str not in prevout_to_spend:
prevout_to_spend.append(prevout_str)
balance = 0
utxo_to_spend = []
for utxo in utxos: for utxo in utxos:
if utxo.is_coinbase_output() and utxo.block_height < current_height+100: if utxo.is_coinbase_output() and utxo.block_height < current_height+100:
_logger.debug("is not mature coinbase output") _logger.debug("is not mature coinbase output")
continue continue
utxo_str = utxo.prevout.to_str() utxo_by_prevout[utxo.prevout.to_str()] = utxo
if utxo_str in prevout_to_spend: # Candidate coins: the wallet UTXOs that fund one of the will txs.
balance += utxo.value_sats() candidates = []
utxo_to_spend.append(utxo) seen = set()
for wi in will_to_invalidate.values():
for txin in wi.tx.inputs():
prevout_str = txin.prevout.to_str()
if prevout_str in seen or prevout_str not in utxo_by_prevout:
continue
seen.add(prevout_str)
candidates.append(utxo_by_prevout[prevout_str])
# Spend, per will transaction, only its OLDEST funding coin (the one
# that has been in the wallet longest): spending a single input is
# enough to make the pre-signed transaction unspendable, and the newer
# coins stay available for the rebuilt (postponed) will.
chosen = {} # prevout_str -> UTXO, one (the oldest) per will transaction
for wi in will_to_invalidate.values():
item_candidates = [
utxo_by_prevout[txin.prevout.to_str()]
for txin in wi.tx.inputs()
if txin.prevout.to_str() in utxo_by_prevout
]
if item_candidates:
oldest = min(item_candidates, key=Will._invalidation_age_key)
chosen[oldest.prevout.to_str()] = oldest
utxo_to_spend = list(chosen.values())
balance = sum(u.value_sats() for u in utxo_to_spend)
_logger.debug("utxo to spend: {}".format(utxo_to_spend)) _logger.debug("utxo to spend: {}".format(utxo_to_spend))
if len(utxo_to_spend) > 0: if len(utxo_to_spend) > 0:
change_addresses = wallet.get_change_addresses_for_new_transaction() change_addresses = wallet.get_change_addresses_for_new_transaction()
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
out.is_change = True
locktime = current_height locktime = current_height
tx = PartialTransaction.from_io( fee_rate, fee_source = Will.get_invalidation_fee_rate(
utxo_to_spend, [out], locktime=locktime, version=2 wallet, fees_per_byte, fee_policy_descriptor
) )
tx.set_rbf(True) _logger.info(
fee = tx.estimated_size() * fees_per_byte f"invalidation fee: {fee_rate} sat/vbyte ({fee_source})"
if balance - fee > 0: )
out = PartialTxOutput.from_address_and_value( # The change output must stay at or above the dust threshold,
change_addresses[0], balance - fee # otherwise the invalidation transaction would be non-standard and
) # could not be relayed.
try:
dust = int(wallet.dust_threshold())
except (TypeError, ValueError):
dust = 0
# Remaining candidate coins, oldest first, for the fee fallback.
remaining = sorted(
(c for c in candidates if c.prevout.to_str() not in chosen),
key=Will._invalidation_age_key,
)
while True:
# The network rate can be fractional (e.g. 1.5 sat/vbyte): round
# the total fee UP to whole satoshis so the output value is an
# integer and the rate is never below the one requested.
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
out.is_change = True
tx = PartialTransaction.from_io( tx = PartialTransaction.from_io(
utxo_to_spend, [out], locktime=locktime, version=2 utxo_to_spend, [out], locktime=locktime, version=2
) )
tx.set_rbf(True) tx.set_rbf(True)
fee = int(math.ceil(tx.estimated_size() * fee_rate))
if balance - fee >= dust:
out = PartialTxOutput.from_address_and_value(
change_addresses[0], balance - fee
)
tx = PartialTransaction.from_io(
utxo_to_spend, [out], locktime=locktime, version=2
)
tx.set_rbf(True)
_logger.debug(f"invalidation tx: {tx}") _logger.debug(f"invalidation tx: {tx}")
return tx return tx
else: # The selected coins cannot cover the fee while leaving a
_logger.debug(f"balance({balance}) - fee({fee}) <=0") # spendable (non-dust) change: keep spending the next-oldest
pass # will coin until the invalidation is economically viable or no
# coin is left.
if not remaining:
_logger.debug(
"balance({})-fee({})<dust({}), no more will coins".format(
balance, fee, dust
)
)
break
extra = remaining.pop(0)
_logger.debug(
"balance({})-fee({})<dust({}): adding {}".format(
balance, fee, dust, extra.prevout.to_str()
)
)
chosen[extra.prevout.to_str()] = extra
utxo_to_spend = list(chosen.values())
balance += extra.value_sats()
else: else:
_logger.debug("len utxo_to_spend <=0") _logger.debug("len utxo_to_spend <=0")
pass pass
@@ -562,9 +751,15 @@ class Will:
def set_invalidate(wid, will=None): def set_invalidate(wid, will=None):
will = will if will is not None else {} will = will if will is not None else {}
will[wid].set_status("INVALIDATED", True) will[wid].set_status("INVALIDATED", True)
if will[wid].children: children = will[wid].children or []
for c in will[wid].children.items(): # add_willtree builds a LIST of [child_id, input_idx, prevout_idx];
Will.set_invalidate(c[0], will) # a dict keyed by child id is still accepted (older data / tests).
child_ids = children.keys() if isinstance(children, dict) else [
c[0] for c in children
]
for child_id in child_ids:
if child_id in will:
Will.set_invalidate(child_id, will)
@staticmethod @staticmethod
def check_tx_height(tx, wallet): def check_tx_height(tx, wallet):
@@ -575,10 +770,15 @@ class Will:
@staticmethod @staticmethod
def check_invalidated(willtree, utxos_list, wallet): def check_invalidated(willtree, utxos_list, wallet):
for wid, w in willtree.items(): for wid, w in willtree.items():
# Defensive: a father that is not (or no longer) part of the will
# is treated as "no father" instead of raising KeyError, so the
# item's inputs are checked against the wallet UTXOs like any
# root transaction. See add_willtree for the root cause.
father = willtree.get(w.father) if w.father else None
if ( if (
not w.father father is None
or willtree[w.father].get_status("CONFIRMED") or father.get_status("CONFIRMED")
or willtree[w.father].get_status("MEMPOOL") or father.get_status("MEMPOOL")
): ):
for inp in w.tx.inputs(): for inp in w.tx.inputs():
inp_str = Util.utxo_to_str(inp) inp_str = Util.utxo_to_str(inp)
@@ -623,11 +823,19 @@ class Will:
for utxo in all_utxos: for utxo in all_utxos:
wallet_balance += utxo.value_sats() wallet_balance += utxo.value_sats()
if fixed_amount >= wallet_balance: if fixed_amount > 0 and fixed_amount >= wallet_balance:
# Only a REAL fixed amount needs to be funded: with no fixed heirs
# (all percentages) fixed_amount is 0 and ``0 >= 0`` would wrongly
# flag an empty/fully-committed wallet as an amount error.
raise FixedAmountException( raise FixedAmountException(
f"Fixed amount({fixed_amount}) >= {wallet_balance}" f"Fixed amount({fixed_amount}) >= {wallet_balance}"
) )
if perc_amount != 100: if perc_heirs and perc_amount != 100:
# Percentages only have to add up to 100% when the will actually
# uses percentage heirs. A will made ONLY of fixed-amount heirs has
# no percentages, so perc_amount is 0 - which used to raise here and
# left "Checking variables" stuck on "Wait" for every fixed-only
# will (the build normalises the fixed shares anyway).
raise PercAmountException(f"Perc amount({perc_amount}) =! 100%") raise PercAmountException(f"Perc amount({perc_amount}) =! 100%")
for url, wex in willexecutors.items(): for url, wex in willexecutors.items():
@@ -635,7 +843,7 @@ class Will:
if max_fee is not None and int(wex["base_fee"]) > max_fee: if max_fee is not None and int(wex["base_fee"]) > max_fee:
raise WillExecutorFeeTooHighException(wex, max_fee) raise WillExecutorFeeTooHighException(wex, max_fee)
temp_balance = wallet_balance - int(wex["base_fee"]) temp_balance = wallet_balance - int(wex["base_fee"])
if fixed_amount >= temp_balance: if fixed_amount > 0 and fixed_amount >= temp_balance:
raise FixedAmountException( raise FixedAmountException(
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}" f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
) )
@@ -972,6 +1180,25 @@ class Will:
def get_min_locktime(will,default_value=None): def get_min_locktime(will,default_value=None):
return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value) return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value)
@staticmethod
def get_creation_time(will):
"""Return the inheritance creation timestamp (oldest VALID will item).
A positive relative Check Alive value counts forward from this date.
The per-item ``time`` is stamped when the will is built and persisted
in the wallet DB, so the anchor does not drift with the clock. Only
VALID items are considered; imported items without a ``time`` are
ignored. Returns ``None`` when no VALID item carries a timestamp.
"""
return min(
(
v.time
for v in will.values()
if v.get_status('VALID') and v.time is not None
),
default=None,
)
@staticmethod @staticmethod
@@ -1345,6 +1572,37 @@ class WillItem(Logger):
# format_status_history() still translates them. # format_status_history() still translates them.
LEGACY_STATUS_LABELS = (N_("New"),) LEGACY_STATUS_LABELS = (N_("New"),)
# Translations of "New" that test builds of the i18n work saved INTO the
# status history (window.py / the CLI used ``_("New")`` when building).
# They are mapped back to "New" on load so the history is language
# neutral again. Keep in sync with the "New" msgstr of every catalog in
# bal/locale (tests/test_core_status_language_neutral.py checks it).
TRANSLATED_NEW_LABELS = ("Nuovo", "Nuevo")
# Fields that Willexecutors.get_willexecutor_transactions /
# push_transactions_to_willexecutor used to write into the item's own
# executor record (older versions); they are transient push data.
TRANSIENT_PUSH_FIELDS = ("txs", "txsids", "broadcast_status")
@staticmethod
def _untranslate_new_status(status):
"""Map a translated leading "New" of a saved history back to "New"."""
head, sep, rest = status.partition(".")
if head in WillItem.TRANSLATED_NEW_LABELS:
return "New" + sep + rest
return status
@staticmethod
def _without_transient_push_fields(we):
"""Return ``we`` without the transient push fields (a copy if needed)."""
if not isinstance(we, dict) or not any(
k in we for k in WillItem.TRANSIENT_PUSH_FIELDS
):
return we
return {
k: v for k, v in we.items() if k not in WillItem.TRANSIENT_PUSH_FIELDS
}
def set_status(self, status, value=True): def set_status(self, status, value=True):
"""Set a status flag and apply the related side effects. """Set a status flag and apply the related side effects.
@@ -1426,8 +1684,10 @@ class WillItem(Logger):
w = data w = data
self.tx = Will.get_tx_from_any(w["tx"]) self.tx = Will.get_tx_from_any(w["tx"])
self.heirs = w.get("heirs", None) self.heirs = w.get("heirs", None)
self.we = w.get("willexecutor", None) self.we = WillItem._without_transient_push_fields(
self.status = w.get("status") or "" w.get("willexecutor", None)
)
self.status = WillItem._untranslate_new_status(w.get("status") or "")
self.description = w.get("description", None) self.description = w.get("description", None)
self.time = w.get("time", None) self.time = w.get("time", None)
self.change = w.get("change", None) self.change = w.get("change", None)

View File

@@ -231,6 +231,13 @@ class Willexecutors:
@staticmethod @staticmethod
def get_willexecutor_transactions(will, force=False): def get_willexecutor_transactions(will, force=False):
"""Group the valid, signed, not-yet-pushed will txs by will-executor.
Returns ``{url: executor_record}`` where each record gets ``txs`` (the
raw txs, one per line) and ``txsids``.
The returned records are COPIES: the will items are never modified.
"""
willexecutors = {} willexecutors = {}
for wid, willitem in will.items(): for wid, willitem in will.items():
if willitem.get_status("VALID"): if willitem.get_status("VALID"):
@@ -240,10 +247,18 @@ class Willexecutors:
url = willexecutor["url"] url = willexecutor["url"]
if willexecutor and Willexecutors.is_selected(willexecutor): if willexecutor and Willexecutors.is_selected(willexecutor):
if url not in willexecutors: if url not in willexecutors:
willexecutor["txs"] = "" # Work on a COPY of the record: the item's
willexecutor["txsids"] = [] # own ``we`` dict is saved in the wallet, so
willexecutor["broadcast_status"] = _("Waiting...") # the transient push fields below (raw txs,
willexecutors[url] = willexecutor # txids and the - translated - broadcast
# status shown in the push dialog) must
# never end up in it (stored data must not
# depend on the UI language, see bal/i18n.py).
record = dict(willexecutor)
record["txs"] = ""
record["txsids"] = []
record["broadcast_status"] = _("Waiting...")
willexecutors[url] = record
willexecutors[url]["txs"] += str(willitem.tx) + "\n" willexecutors[url]["txs"] += str(willitem.tx) + "\n"
willexecutors[url]["txsids"].append(wid) willexecutors[url]["txsids"].append(wid)

View File

@@ -43,19 +43,18 @@ from ...core.animated_qr import (
bbqr_frames, bbqr_frames,
format_name, format_name,
parse_for_detection, parse_for_detection,
ur1_frames,
ur2_frames,
) )
from ...core.checkalive import CheckAliveError, resolve_date_to_check from ...core.checkalive import (
CheckAliveAfterDeliveryError,
CheckAliveError,
resolve_date_to_check,
resolve_relative_check_alive_date,
)
from ...core.qrtransfer import ( from ...core.qrtransfer import (
CHUNK_PRESETS, CHUNK_PRESETS,
MissingFramesError,
QrTransferError,
decode_transfer, decode_transfer,
encode_transfer, encode_transfer,
encode_transfer_best,
preset_index_for_chunk_size, preset_index_for_chunk_size,
split_frames,
) )
from ...core.reminders import build_ics_reminders from ...core.reminders import build_ics_reminders
from .calendar import BalCalendarButton from .calendar import BalCalendarButton
@@ -65,8 +64,8 @@ from .common import (
N_, N_,
AmountException, AmountException,
Any, Any,
BalanceTooLowException,
BalTimestamp, BalTimestamp,
BalanceTooLowException,
BestEffortRequestFailed, BestEffortRequestFailed,
Buttons, Buttons,
Callable, Callable,
@@ -142,29 +141,6 @@ if TYPE_CHECKING:
# imported lazily where needed to avoid a dialogs<->lists import cycle. # imported lazily where needed to avoid a dialogs<->lists import cycle.
# Animated-QR formats beyond the legacy BAL QR. Combined with the format
# selector in :class:`BalQrExportWidget` they give the export page
# interoperable BC-UR v1/v2 and BBQR output while keeping BAL QR as the
# default (and the only format understood by older plugin versions).
ANIMATED_QR_FORMATS = ("ur1", "ur2", "bbqr")
def encode_animated_frames(transfer, fmt, budget_chars):
"""Encode the transfer text into the given animated-QR format.
``transfer`` is the BAL QR transfer text (str). ``budget_chars`` is the
maximum length of one frame (the exporter's "QR code size" preset).
"""
payload = transfer.encode("utf-8")
if fmt == "ur1":
return ur1_frames(payload, budget_chars)
if fmt == "ur2":
return ur2_frames(payload, budget_chars)
if fmt == "bbqr":
return bbqr_frames(payload, budget_chars, encoding="Z")
raise QrTransferError("unknown animated QR format: {}".format(fmt))
class BalDialog(QDialog,MessageBoxMixin): class BalDialog(QDialog,MessageBoxMixin):
_stopping = False _stopping = False
def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"): def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"):
@@ -382,7 +358,8 @@ class BalWizardWidget(QWidget):
class BalWizardHeirsWidget(BalWizardWidget): class BalWizardHeirsWidget(BalWizardWidget):
title = N_("Bitcoin After Life Heirs") title = N_("Bitcoin After Life Heirs")
message = N_( message = N_(
"Please add your heirs\n remember that 100% of wallet balance will be spent" "Please add your heirs\n"
" remember that the wallet balance will be spent at 100%"
) )
def get_content(self): def get_content(self):
@@ -714,11 +691,22 @@ class BalBuildWillDialog(BalDialog):
# Manual next-steps hint (Sign / Broadcast) shown to the user after the # Manual next-steps hint (Sign / Broadcast) shown to the user after the
# dialog finishes; None when nothing is left to do. # dialog finishes; None when nothing is left to do.
self._next_steps_hint = None self._next_steps_hint = None
# Set to True by _sync_locktime_to_built_txs when the delivery date was # Set to True by _build_success_report when the rebuild moved the
# automatically anticipated during a rebuild. Used to explain to the # delivery date one day earlier than the previous will. Used both to
# user WHY signing is being requested (otherwise the sign prompt appears # show the "Anticipated" warning on the "Building your will" row and to
# without any reason, as the owner reported). # explain WHY signing is being requested (otherwise the sign prompt
# appears without any reason, as the owner reported).
self._date_was_anticipated = False self._date_was_anticipated = False
# The anticipated locktime (UNIX timestamp) captured by
# _build_success_report, formatted into the "Anticipated - new
# delivery date: ..." warning. None when the last build did not
# anticipate the delivery.
self._anticipated_locktime = None
# The will's minimum locktime captured BEFORE the rebuild (see
# task_phase1). _build_success_report compares it with the rebuilt one
# to tell whether the delivery was anticipated by one day. None when no
# build ran yet.
self._previous_min_locktime = None
# Set to True right after we broadcast an automatic invalidation # Set to True right after we broadcast an automatic invalidation
# transaction (the "postpone" path). On the very next phase-1 re-check # transaction (the "postpone" path). On the very next phase-1 re-check
# Electrum may not have seen the invalidation tx yet, so it would still # Electrum may not have seen the invalidation tx yet, so it would still
@@ -754,6 +742,13 @@ class BalBuildWillDialog(BalDialog):
varrow = self.msg_set_status(_("Checking variables")) varrow = self.msg_set_status(_("Checking variables"))
try: try:
self.bal_window.init_class_variables() self.bal_window.init_class_variables()
except CheckAliveAfterDeliveryError:
self.msg_set_status(
"Checking variables", varrow,
"Check Alive must be after today and before the delivery date",
self.COLOR_ERROR,
)
return "check_alive_before_delivery", None
except CheckAliveError as cae: except CheckAliveError as cae:
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1) fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1)
tx = Will.invalidate_will( tx = Will.invalidate_will(
@@ -763,6 +758,7 @@ class BalBuildWillDialog(BalDialog):
self.bal_window.willitems, self.bal_window.willitems,
default_value=self.bal_window.date_to_check, default_value=self.bal_window.date_to_check,
), ),
fee_policy_descriptor=self.bal_window.bal_plugin.INVALIDATION_FEE_POLICY.get(),
) )
if tx: if tx:
_logger.debug( _logger.debug(
@@ -804,18 +800,32 @@ class BalBuildWillDialog(BalDialog):
_logger.debug("variables ok") _logger.debug("variables ok")
self.msg_set_status(_("Checking variables"), varrow, _("Ok"), self.COLOR_OK) self.msg_set_status(_("Checking variables"), varrow, _("Ok"), self.COLOR_OK)
except AmountException: except AmountException:
self.msg_set_checking( # The amount check is a WARNING, not a hard failure: the build can
self.msg_warning( # still normalise the shares (see BalWindow.build_inheritance_
_( # transaction, which shows the same warning and continues). Report
"In the inheritance process, " # it on the "Checking variables" row itself, with the warning colour.
"the entire wallet will always be fully emptied. \n" # Previously this was routed to msg_set_checking(), i.e. the
"Your settings require an adjustment of the amounts" # "Checking your will" row, and then immediately overwritten by the
) # msg_set_checking() call below - so the warning was lost AND the
) # "Checking variables" row stayed stuck on its initial "Wait".
self.msg_set_status(
_("Checking variables"),
varrow,
_(
"In the inheritance process, "
"the entire wallet will always be fully emptied."
),
self.COLOR_WARNING,
) )
except WillExecutorFeeTooHighException as e: except WillExecutorFeeTooHighException as e:
self.msg_set_checking( # Same reasoning as the AmountException branch above: show the
self.msg_warning(_("Will-executor fee too high: {}").format(e)) # warning on the "Checking variables" row so it is neither lost nor
# left stuck on "Wait".
self.msg_set_status(
_("Checking variables"),
varrow,
_("Will-executor fee too high: {}").format(e),
self.COLOR_WARNING,
) )
self.msg_set_checking() self.msg_set_checking()
@@ -858,6 +868,7 @@ class BalBuildWillDialog(BalDialog):
self.bal_window.willitems, self.bal_window.willitems,
default_value=self.bal_window.date_to_check, default_value=self.bal_window.date_to_check,
), ),
fee_policy_descriptor=self.bal_window.bal_plugin.INVALIDATION_FEE_POLICY.get(),
) )
except NoHeirsException: except NoHeirsException:
_logger.debug("no heirs") _logger.debug("no heirs")
@@ -911,6 +922,18 @@ class BalBuildWillDialog(BalDialog):
if have_to_build: if have_to_build:
self.msg_set_building() self.msg_set_building()
# Capture the delivery date of the CURRENT (previous) will BEFORE
# the rebuild overwrites it, so _build_success_report can tell
# whether the rebuild ANTICIPATED the delivery by one day. The core
# anticipates by moving the rebuilt txs one day EARLIER than the
# previous will that spends the same coins (Will.check_anticipate /
# Will.search_anticipate), so comparing the two is the only reliable
# signal: a relative recipe ("1y"/"30d") is anchored to the built tx,
# which is why comparing the built locktime with the STORED value
# (see _sync_locktime_to_built_txs) misses the anticipation.
self._previous_min_locktime = Will.get_min_locktime(
self.bal_window.willitems
)
try: try:
txs = self.bal_window.build_will() txs = self.bal_window.build_will()
if not txs: if not txs:
@@ -1099,7 +1122,40 @@ class BalBuildWillDialog(BalDialog):
# auto-anticipated) transactions, otherwise the next Check would wrongly # auto-anticipated) transactions, otherwise the next Check would wrongly
# ask to invalidate. See _sync_locktime_to_built_txs for the full why. # ask to invalidate. See _sync_locktime_to_built_txs for the full why.
self._sync_locktime_to_built_txs() self._sync_locktime_to_built_txs()
self.msg_set_building(self.msg_ok()) # Detect an automatic anticipation by comparing the will's delivery date
# BEFORE the rebuild (captured in task_phase1) with the one AFTER it:
# the core anticipates by moving the rebuilt txs one day earlier than
# the previous will that spends the same coins (Will.check_anticipate).
# This is format-agnostic: it works whether the stored recipe is an
# absolute timestamp or a relative "1y"/"30d" recipe (anchoring makes a
# relative recipe unusable for a stored-vs-built comparison).
new_min_locktime = Will.get_min_locktime(self.bal_window.willitems)
previous_min_locktime = self._previous_min_locktime
self._date_was_anticipated = (
previous_min_locktime is not None
and new_min_locktime is not None
and int(new_min_locktime) < int(previous_min_locktime)
)
self._anticipated_locktime = (
int(new_min_locktime) if self._date_was_anticipated else None
)
# Owner request: when the rebuild anticipated the delivery, do NOT show
# a plain "Ok" on the "Building your will" row - show a warning that
# names the anticipation and the NEW delivery date, so the user is told
# the inheritance will be delivered one day earlier. The sign step then
# explains WHY signing is requested on its OWN row ("Signing your
# will"), leaving this warning visible.
if self._anticipated_locktime is not None:
new_date = BalTimestamp._safe_fromtimestamp(
self._anticipated_locktime
).strftime("%Y-%m-%d")
self.msg_set_building(
self.msg_alert(
_("Anticipated - new delivery date: {}").format(new_date)
)
)
else:
self.msg_set_building(self.msg_ok())
# List EACH heir on its OWN line, green + bold (owner request: revert the # List EACH heir on its OWN line, green + bold (owner request: revert the
# one-line "Heirs: a, b, c" form of v0.4.6). Heir names can be long, and # one-line "Heirs: a, b, c" form of v0.4.6). Heir names can be long, and
# now that the report area scrolls (allegato1) there is no need to cram # now that the report area scrolls (allegato1) there is no need to cram
@@ -1191,20 +1247,21 @@ class BalBuildWillDialog(BalDialog):
f"sync delivery date to built tx locktime: " f"sync delivery date to built tx locktime: "
f"{current} -> {min_locktime}" f"{current} -> {min_locktime}"
) )
# Remember that we anticipated the date, so the later sign # NOTE: this only SYNCS the stored date. Whether the rebuild
# prompt can explain WHY signing is needed # ANTICIPATED the delivery is decided in _build_success_report
# (see on_success_phase1). # by comparing the previous and the rebuilt locktimes (the only
self._date_was_anticipated = True # format-agnostic signal, see there).
# update_setting_widgets stores the value, persists it and # update_setting_widgets stores the value, persists it and
# refreshes the date widgets in all panels/wizard (the .ics # refreshes the date widgets in all panels/wizard (the .ics
# calendar too). # calendar too).
self.bal_window.update_setting_widgets( self.bal_window.update_setting_widgets(
min_locktime, "locktime", update_all=True min_locktime, "locktime", update_all=True
) )
# A relative "Check Alive" threshold ("N days BEFORE the delivery") is # A relative "Check Alive" threshold is also PRESERVED: it is anchored
# also PRESERVED: it is anchored on every check by # on every check by ``resolve_date_to_check`` / ``resolve_guard_threshold``
# ``resolve_date_to_check`` / ``resolve_guard_threshold``, so it does # (negative counts back from the delivery, positive forward from the
# not need to be frozen to an absolute date here. # inheritance creation date), so it never needs freezing to an absolute
# date here.
def on_accept(self): def on_accept(self):
try: try:
@@ -1553,6 +1610,18 @@ class BalBuildWillDialog(BalDialog):
self._add_no_heirs_buttons() self._add_no_heirs_buttons()
return return
if self.have_to_sign == "check_alive_before_delivery":
# The Check Alive date is on/after the delivery (a configuration
# error, not an expired will): do NOT invalidate, just tell the
# user and let them close the wizard.
self.bal_window.show_error(
_(
"Check Alive must be after today and before the delivery date, please update it"
)
)
self._add_close_button()
return
_logger.debug("have to sign {}".format(self.have_to_sign)) _logger.debug("have to sign {}".format(self.have_to_sign))
password = None password = None
if self.have_to_sign is None: if self.have_to_sign is None:
@@ -1625,19 +1694,18 @@ class BalBuildWillDialog(BalDialog):
# If the will was rebuilt with an automatically anticipated # If the will was rebuilt with an automatically anticipated
# delivery date, explain WHY we are now asking to sign: # delivery date, explain WHY we are now asking to sign:
# otherwise the sign prompt appears with no reason (owner # otherwise the sign prompt appears with no reason (owner
# feedback). The note is shown on the "Building your will" # feedback). This note goes on its OWN "Signing your will" row
# row (orange warning) before the modal password prompt # so it does NOT overwrite the "Anticipated - new delivery
# opens, so the user can read it. # date" warning already shown on the "Building your will" row,
# and it is readable before the modal password prompt opens.
if self._date_was_anticipated: if self._date_was_anticipated:
self.msg_set_building( self.msg_set_signing(
"<b>{}</b>".format( _(
_( "The delivery date was automatically moved "
"The delivery date was automatically moved " "one day earlier so the updated will can "
"one day earlier so the updated will can " "correctly replace the previous one.\n"
"correctly replace the previous one.\n" "Please sign (and broadcast) to confirm "
"Please sign (and broadcast) to confirm " "the change."
"the change."
)
) )
) )
password = self.bal_window.get_wallet_password( password = self.bal_window.get_wallet_password(
@@ -1844,9 +1912,14 @@ class BalBuildWillDialog(BalDialog):
threshold = None threshold = None
num_reminders = 3 num_reminders = 3
else: else:
threshold_ts = BalTimestamp( # The Check Alive reference shares the same sign rule as the
self.bal_window.will_settings["threshold"] # core: negative counts back from the delivery, positive counts
).to_timestamp() # forward from the inheritance creation date.
threshold_ts = resolve_relative_check_alive_date(
self.bal_window.will_settings["threshold"],
delivery_date=locktime_ts,
creation_date=Will.get_creation_time(self.bal_window.willitems),
)
threshold = datetime.fromtimestamp(threshold_ts) threshold = datetime.fromtimestamp(threshold_ts)
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get() raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get() raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
@@ -2734,10 +2807,10 @@ class BalQrExportWidget(QWidget):
The page (re)displays itself on :meth:`set_tx_strings`. The page (re)displays itself on :meth:`set_tx_strings`.
""" """
def __init__(self, chunk_size=CHUNK_PRESETS[0][1], parent=None): def __init__(self, chunk_size=CHUNK_PRESETS[0][1], parent=None, on_size_change=None):
QWidget.__init__(self, parent) QWidget.__init__(self, parent)
self.chunk_size = chunk_size self.chunk_size = chunk_size
self.format = "balqr" self._on_size_change = on_size_change
self.tx_strings = [] self.tx_strings = []
self.transfer = "" self.transfer = ""
self.frames = [] self.frames = []
@@ -2803,36 +2876,11 @@ class BalQrExportWidget(QWidget):
size_row.addStretch(1) size_row.addStretch(1)
vbox.addLayout(size_row) vbox.addLayout(size_row)
format_row = QHBoxLayout()
format_row.addWidget(QLabel(_("Format:")))
self.format_combo = QComboBox()
self._format_options = ["balqr"] + list(ANIMATED_QR_FORMATS)
self.format_combo.addItems(
[
format_name(fmt)
+ (" {}".format(_("(default)")) if fmt == "balqr" else "")
for fmt in self._format_options
]
)
self.format_combo.setCurrentIndex(0)
self.format_combo.currentIndexChanged.connect(self._on_format_change)
format_row.addWidget(self.format_combo)
self.format_hint = QLabel()
self.format_hint.setWordWrap(True)
format_row.addWidget(self.format_hint, 1)
vbox.addLayout(format_row)
def set_tx_strings(self, tx_strings): def set_tx_strings(self, tx_strings):
"""Rebuild the transfer frames from the given serialized transactions.""" """Rebuild the transfer frames from the given serialized transactions."""
self.tx_strings = list(tx_strings) self.tx_strings = list(tx_strings)
self._stop_auto() self._stop_auto()
self.transfer = encode_transfer(self.tx_strings, compress=False) self.transfer = encode_transfer(self.tx_strings, compress=False)
# BAL QR now ships compact (best-of) compressed by default: smaller
# frames, and the importer reverses it via the per-frame flag. The
# other formats keep the raw transfer (they compress internally).
self._balqr_transfer, self._balqr_compressed = encode_transfer_best(
self.tx_strings
)
self._refresh_frames() self._refresh_frames()
self._update_intro() self._update_intro()
self._render() self._render()
@@ -2842,30 +2890,18 @@ class BalQrExportWidget(QWidget):
return len(self.frames) return len(self.frames)
def _refresh_frames(self): def _refresh_frames(self):
if self.format == "balqr": self.frames = bbqr_frames(
self.frames = split_frames( self.transfer.encode("utf-8"), self.chunk_size, encoding="Z"
self._balqr_transfer, )
self.chunk_size,
compressed=self._balqr_compressed,
)
else:
self.frames = encode_animated_frames(
self.transfer, self.format, self.chunk_size
)
self.index = 0 self.index = 0
def _on_format_change(self, index):
self._stop_auto()
self.format = self._format_options[index]
self._refresh_frames()
self._render()
self._update_intro()
def _on_chunk_change(self, index): def _on_chunk_change(self, index):
self._stop_auto() self._stop_auto()
self.chunk_size = CHUNK_PRESETS[index][1] self.chunk_size = CHUNK_PRESETS[index][1]
self._refresh_frames() self._refresh_frames()
self._render() self._render()
if self._on_size_change is not None:
self._on_size_change(self.chunk_size)
def _toggle_auto(self): def _toggle_auto(self):
"""Start/stop the automatic QR slideshow.""" """Start/stop the automatic QR slideshow."""
@@ -2897,32 +2933,13 @@ class BalQrExportWidget(QWidget):
self._next() self._next()
def _update_intro(self): def _update_intro(self):
self.format_hint.setText( self.intro_label.setText(
{ _(
"balqr": _( "Scan the QR codes below with the will-opening device.\n"
"Proprietary format; every other BAL wallet understands it." "{} codes carry the whole transfer (any order works, "
), "duplicates are ignored)."
"ur1": _("Legacy BC-UR v1 (ur:bytes), compatible with older " ).format(self.total_frames)
"Blockchain Commons tools."),
"ur2": _("Standard BC-UR v2 fountain codes (ur:bytes)."),
"bbqr": _("Coinkite BBQR (B$ frames) for BitKit & friends."),
}[self.format]
) )
if self.format == "balqr":
self.intro_label.setText(
_(
"Scan the QR codes below, in order, with the will-opening "
"device.\nFrame 1 of {} carries the total number of codes."
).format(self.total_frames)
)
else:
self.intro_label.setText(
_(
"Scan the QR codes below with the will-opening device.\n"
"{} codes carry the whole transfer (any order works, "
"duplicates are ignored)."
).format(self.total_frames)
)
def _prev(self): def _prev(self):
if self.index > 0: if self.index > 0:
@@ -2964,6 +2981,7 @@ class WillExportDialog(BalDialog):
def __init__(self, bal_window, will=None, bal_plugin=None, initial_mode="file"): def __init__(self, bal_window, will=None, bal_plugin=None, initial_mode="file"):
BalDialog.__init__(self, bal_window.window, bal_plugin, _("Export will")) BalDialog.__init__(self, bal_window.window, bal_plugin, _("Export will"))
self.bal_window = bal_window self.bal_window = bal_window
self.bal_plugin = bal_plugin
self._source = will if will is not None else bal_window.willitems self._source = will if will is not None else bal_window.willitems
self._filters = export_filter_options() self._filters = export_filter_options()
self._filter_index = 0 self._filter_index = 0
@@ -3017,7 +3035,10 @@ class WillExportDialog(BalDialog):
self.stacked = QStackedWidget() self.stacked = QStackedWidget()
self.file_page = self._build_file_page() self.file_page = self._build_file_page()
self.stacked.addWidget(self.file_page) self.stacked.addWidget(self.file_page)
self.qr_page = BalQrExportWidget(chunk_size=chunk) self.qr_page = BalQrExportWidget(
chunk_size=chunk,
on_size_change=self._remember_qr_size,
)
self.stacked.addWidget(self.qr_page) self.stacked.addWidget(self.qr_page)
self.audio_page = self._build_audio_page() self.audio_page = self._build_audio_page()
self.stacked.addWidget(self.audio_page) self.stacked.addWidget(self.audio_page)
@@ -3027,12 +3048,19 @@ class WillExportDialog(BalDialog):
close_btn.clicked.connect(self.close) close_btn.clicked.connect(self.close)
vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight) vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
# The audio transport is only offered when the audio_modem plugin is
# available; otherwise its radio button is disabled (file and QR stay
# usable).
self.transport_audio.setEnabled(self._audio_available)
mode_ids = { mode_ids = {
"file": self.MODE_FILE, "file": self.MODE_FILE,
"qr": self.MODE_QR, "qr": self.MODE_QR,
"audio": self.MODE_AUDIO, "audio": self.MODE_AUDIO,
} }
mode = mode_ids.get(initial_mode, self.MODE_FILE) mode = mode_ids.get(initial_mode, self.MODE_FILE)
if mode == self.MODE_AUDIO and not self._audio_available:
mode = self.MODE_FILE
(self.transport_file, self.transport_qr, self.transport_audio)[ (self.transport_file, self.transport_qr, self.transport_audio)[
mode mode
].setChecked(True) ].setChecked(True)
@@ -3067,6 +3095,7 @@ class WillExportDialog(BalDialog):
self._audio_plugin = self.bal_window.get_audio_modem_plugin() self._audio_plugin = self.bal_window.get_audio_modem_plugin()
self.bitrates = audio_bitrates() self.bitrates = audio_bitrates()
available = self._audio_plugin is not None available = self._audio_plugin is not None
self._audio_available = available
if available: if available:
self.audio_warn_label.hide() self.audio_warn_label.hide()
else: else:
@@ -3095,6 +3124,14 @@ class WillExportDialog(BalDialog):
def _selected_items(self): def _selected_items(self):
return filter_willitems(self._source, self._filters, self._filter_index) return filter_willitems(self._source, self._filters, self._filter_index)
def _remember_qr_size(self, size):
"""Persist the QR size chosen in the export page for the next export."""
if self.bal_plugin is not None:
try:
self.bal_plugin.QR_CHUNK_SIZE.set(size)
except Exception:
pass
def _payload_strings(self): def _payload_strings(self):
"""The data handed to the QR page, honouring the content scope. """The data handed to the QR page, honouring the content scope.
@@ -3266,6 +3303,10 @@ class WillImportDialog(BalDialog):
close_btn.clicked.connect(self.close) close_btn.clicked.connect(self.close)
vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight) vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
# The audio transport is only offered when the audio_modem plugin is
# available; otherwise its radio button is disabled.
self.transport_audio.setEnabled(self._audio_available)
self.transport_file.setChecked(True) self.transport_file.setChecked(True)
self._on_mode_clicked(self.MODE_FILE) self._on_mode_clicked(self.MODE_FILE)
@@ -3293,6 +3334,7 @@ class WillImportDialog(BalDialog):
self._audio_plugin = self.bal_window.get_audio_modem_plugin() self._audio_plugin = self.bal_window.get_audio_modem_plugin()
self.bitrates = audio_bitrates() self.bitrates = audio_bitrates()
available = self._audio_plugin is not None available = self._audio_plugin is not None
self._audio_available = available
if available: if available:
self.audio_warn_label.hide() self.audio_warn_label.hide()
else: else:
@@ -3533,9 +3575,9 @@ def qr_import_accept_frame(
seen ``stable_reads`` times in a row -- this mirrors the export-side seen ``stable_reads`` times in a row -- this mirrors the export-side
slideshow pacing and swallows transition artifacts; slideshow pacing and swallows transition artifacts;
* re-reading the currently accepted frame is ``ignore``d; * re-reading the currently accepted frame is ``ignore``d;
* a frame whose ``session_key`` (transfer identity, e.g. ``"balqr:3"`` or * a frame whose ``session_key`` (transfer identity, e.g. ``"bbqr:Z:B:3"``)
``"ur2:2-31-3804692811"``) contradicts the transfer already being built contradicts the transfer already being built is a ``reset`` (a
is a ``reset`` (a different transfer was presented). different transfer was presented).
``state`` is a mutable mapping with keys ``last_index``, ``last_payload``, ``state`` is a mutable mapping with keys ``last_index``, ``last_payload``,
``pending_index``, ``pending_payload``, ``pending_count`` and ``key``. ``pending_index``, ``pending_payload``, ``pending_count`` and ``key``.
@@ -3721,8 +3763,8 @@ class BalQrImportWidget(QWidget):
self._key = key self._key = key
self._init_slots() self._init_slots()
if status == "ok": if status == "ok":
# Slot grid is always 1-based even for 0-based wire formats. # Slot grid is always 1-based even for the 0-based BBQR indexes.
slot = index + 1 if fmt == "bbqr" else index slot = index + 1
self.frames[slot] = frame_text self.frames[slot] = frame_text
self._update_slots() self._update_slots()
self._update_status() self._update_status()
@@ -3978,7 +4020,7 @@ class BalQrImportWidget(QWidget):
try: try:
transfer, compressed = session.resolve() transfer, compressed = session.resolve()
parts = decode_transfer(transfer, compressed) parts = decode_transfer(transfer, compressed)
except (MissingFramesError, QrTransferError, AnimatedQrError) as e: except AnimatedQrError as e:
self.show_error(str(e)) self.show_error(str(e))
self._reset_all() self._reset_all()
return return

View File

@@ -14,12 +14,13 @@ One :class:`bal.gui.qt.window.BalWindow` is created per top-level wallet window
and cached in ``self.bal_windows``. and cached in ``self.bal_windows``.
""" """
from electrum.fee_policy import FeeMethod, FeePolicy
from electrum.gui.qt.fee_slider import FeeComboBox, FeeSlider
from electrum.gui.qt.main_window import StatusBarButton from electrum.gui.qt.main_window import StatusBarButton
from electrum.plugin import hook from electrum.plugin import hook
from electrum.util import EventListener, event_listener from electrum.util import EventListener, event_listener
from PyQt6.QtWidgets import QLayout from PyQt6.QtWidgets import QLayout
from ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size
from ...i18n import init_from_config from ...i18n import init_from_config
from .common import ( from .common import (
BalPlugin, BalPlugin,
@@ -538,22 +539,6 @@ class Plugin(BalPlugin, EventListener):
# users (BASIC and ADVANCED). # users (BASIC and ADVANCED).
heir_auto_rebuild = BalCheckBox(self.AUTO_REBUILD) heir_auto_rebuild = BalCheckBox(self.AUTO_REBUILD)
# QR Code Size selector (will transfer via QR). A 4-standard-size combo
# bound to the QR_CHUNK_SIZE config (payload budget in bytes per frame).
# Ordered low -> high so the user picks the resolution matching their
# camera. Visible to all users (BASIC and ADVANCED).
qr_size_combo = QComboBox()
# Labels marked for translation in common.QR_PRESET_LABELS.
qr_size_combo.addItems([_(label) for label, _budget in CHUNK_PRESETS])
qr_size_combo.setCurrentIndex(
preset_index_for_chunk_size(int(self.QR_CHUNK_SIZE.get()))
)
def on_qr_size_change(index):
self.QR_CHUNK_SIZE.set(CHUNK_PRESETS[index][1])
qr_size_combo.currentIndexChanged.connect(on_qr_size_change)
# USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo # USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo
# (not a free-text field) bound to the USER_TYPE config: # (not a free-text field) bound to the USER_TYPE config:
# index 0 -> "BASIC" -> stored value "basic" (DEFAULT) # index 0 -> "BASIC" -> stored value "basic" (DEFAULT)
@@ -611,7 +596,9 @@ class Plugin(BalPlugin, EventListener):
lbl_save_history, heir_save_history, help_save_history, lbl_save_history, heir_save_history, help_save_history,
lbl_history_label, edit_history_label, help_history_label, lbl_history_label, edit_history_label, help_history_label,
reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10, reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10,
reset_btn_11, reset_btn_12, reset_btn_auto_sign): reset_btn_11, reset_btn_12, reset_btn_auto_sign,
lbl_invalidation_fee, invalidation_fee_box,
help_invalidation_fee, btn_reset_invalidation_fee):
w.setVisible(not basic) w.setVisible(not basic)
# Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on # Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on
# a real USER TYPE change (not inside update_all/CHECK), so pressing # a real USER TYPE change (not inside update_all/CHECK), so pressing
@@ -670,10 +657,6 @@ class Plugin(BalPlugin, EventListener):
widget.setCurrentIndex( widget.setCurrentIndex(
1 if str(cfg.default).lower() == "advanced" else 0 1 if str(cfg.default).lower() == "advanced" else 0
) )
elif kind == "qr_size":
widget.setCurrentIndex(
preset_index_for_chunk_size(int(cfg.default))
)
btn.clicked.connect(reset) btn.clicked.connect(reset)
return btn return btn
@@ -932,24 +915,77 @@ class Plugin(BalPlugin, EventListener):
) )
grid.addWidget(reset_btn_auto_rebuild, 15, 3) grid.addWidget(reset_btn_auto_rebuild, 15, 3)
# "QR Code Size" row (always visible, BASIC + ADVANCED). Default QR # ----------------------------------------------------------------- #
# size used when exporting a will via QR codes; changeable per export # Invalidation fee policy (ADVANCED only): which fee rate the on-chain #
# inside the export dialog itself. # invalidation transaction pays. It reuses Electrum's standard fee #
lbl_qr_size = QLabel(_("QR Code Size")) # widget (the same Feerate / ETA / Mempool slider used in the send #
help_qr_size = HelpButton(_( # tab), so the user picks a fixed rate, a block target or a mempool #
"Payload size of a single QR code when exporting a will via QR.\n\n" # depth. The chosen policy is persisted as an Electrum "FeePolicy" #
"Larger QR codes hold more data (fewer shots) but are easier to " # descriptor ("<method>:<value>"). #
"scan with a high-resolution camera; smaller QR codes scan fine " # ----------------------------------------------------------------- #
"even with low-resolution cameras but require more shots.\n" invalidation_fee_policy = FeePolicy(self.INVALIDATION_FEE_POLICY.get())
"The same selector is available inside the export dialog." settings_network = (
)) getattr(window, "network", None) if window is not None else None
grid.addWidget(lbl_qr_size, 16, 0)
grid.addWidget(qr_size_combo, 16, 1)
grid.addWidget(help_qr_size, 16, 2)
reset_btn_qr_size = _make_reset_btn(
self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"
) )
grid.addWidget(reset_btn_qr_size, 16, 3)
def on_invalidation_fee_change(_fee_rate):
"""Persist the fee policy descriptor chosen with the slider."""
self.INVALIDATION_FEE_POLICY.set(
invalidation_fee_policy.get_descriptor()
)
invalidation_fee_slider = FeeSlider(
parent=d,
network=settings_network,
fee_policy=invalidation_fee_policy,
callback=on_invalidation_fee_change,
)
invalidation_fee_combo = FeeComboBox(invalidation_fee_slider)
# Do NOT call invalidation_fee_slider.update() here: the slider and combo
# already initialise themselves before their signals are connected, and
# update() fires moved() -> on_invalidation_fee_change, which would
# persist (and snap) the descriptor just for opening the dialog.
invalidation_fee_box = QWidget()
invalidation_fee_box_layout = QHBoxLayout(invalidation_fee_box)
invalidation_fee_box_layout.setContentsMargins(0, 0, 0, 0)
invalidation_fee_box_layout.addWidget(invalidation_fee_slider, 1)
invalidation_fee_box_layout.addWidget(invalidation_fee_combo)
def reset_invalidation_fee():
"""Restore the fee policy to its default ("eta:2").
The live ``FeePolicy`` object is mutated in place (method + value)
so the slider keeps working with the new defaults; the stored
descriptor and the combo are then re-synced.
"""
default_policy = FeePolicy(self.INVALIDATION_FEE_POLICY.default)
invalidation_fee_policy.method = default_policy.method
invalidation_fee_policy.value = default_policy.value
self.INVALIDATION_FEE_POLICY.set(self.INVALIDATION_FEE_POLICY.default)
invalidation_fee_combo.setCurrentIndex(
FeeMethod.slider_index_of_method(default_policy.method)
)
invalidation_fee_slider.update()
lbl_invalidation_fee = QLabel(_("Invalidation fee policy"))
help_invalidation_fee = HelpButton(_(
"Fee rate the on-chain invalidation transaction pays.\n"
"The invalidation is sent TODAY (it spends the will's coins so the "
"old inheritance transactions become unusable), so it should pay "
"the current network fee - not the will's mining fee rate.\n\n"
"Feerate: a fixed rate (sat/vByte).\n"
"ETA: fee based on the estimated confirmation time (blocks).\n"
"Mempool: fee targeting a depth in the memory pool.\n"
"Only used in ADVANCED mode."
))
btn_reset_invalidation_fee = QPushButton("\u21ba")
btn_reset_invalidation_fee.setFixedWidth(24)
btn_reset_invalidation_fee.setToolTip(_("Reset to default"))
btn_reset_invalidation_fee.clicked.connect(reset_invalidation_fee)
grid.addWidget(_hide_if_basic(lbl_invalidation_fee), 16, 0)
grid.addWidget(_hide_if_basic(invalidation_fee_box), 16, 1)
grid.addWidget(_hide_if_basic(help_invalidation_fee), 16, 2)
grid.addWidget(_hide_if_basic(btn_reset_invalidation_fee), 16, 3)
# ----------------------------------------------------------------- # # ----------------------------------------------------------------- #
# Group C / C4b: "Reset" button that restores the dialog settings to # # Group C / C4b: "Reset" button that restores the dialog settings to #
@@ -984,7 +1020,6 @@ class Plugin(BalPlugin, EventListener):
(self.HISTORY_LABEL, edit_history_label, "line"), (self.HISTORY_LABEL, edit_history_label, "line"),
(self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"), (self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"),
(self.AUTO_REBUILD, heir_auto_rebuild, "check"), (self.AUTO_REBUILD, heir_auto_rebuild, "check"),
(self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"),
] ]
for cfg, widget, kind in resets: for cfg, widget, kind in resets:
# Persist the default value back into the Electrum config. # Persist the default value back into the Electrum config.
@@ -1005,14 +1040,12 @@ class Plugin(BalPlugin, EventListener):
widget.setCurrentIndex( widget.setCurrentIndex(
1 if str(cfg.default).lower() == "advanced" else 0 1 if str(cfg.default).lower() == "advanced" else 0
) )
elif kind == "qr_size":
widget.setCurrentIndex(
preset_index_for_chunk_size(int(cfg.default))
)
# Re-sync the history-label field's enabled state after a reset: the # Re-sync the history-label field's enabled state after a reset: the
# reset restores SAVE_HISTORY to its default, so the field must # reset restores SAVE_HISTORY to its default, so the field must
# follow the (default) checkbox state again. # follow the (default) checkbox state again.
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get())) edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
# Reset the invalidation fee policy to its default ("eta:2") too.
reset_invalidation_fee()
# Refresh the open BAL windows so any dependent view (e.g. the # Refresh the open BAL windows so any dependent view (e.g. the
# editable-dates state is not in this list, but hide filters are) # editable-dates state is not in this list, but hide filters are)
# reflects the reset values. # reflects the reset values.

View File

@@ -20,6 +20,7 @@ Contents:
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ...core.checkalive import resolve_relative_check_alive_date
from ...core.heirs import get_op_return_hex, is_op_return_address from ...core.heirs import get_op_return_hex, is_op_return_address
from ...core.input_rules import ( from ...core.input_rules import (
LockTimeEditor, LockTimeEditor,
@@ -217,6 +218,10 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
label_text = None label_text = None
tooltip_text = None tooltip_text = None
base_field = None base_field = None
# A leading "-" is accepted by the RAW editor only for the Check Alive
# field (its sign selects the anchor date); the delivery field never
# accepts a sign. Subclasses override this.
allow_negative_raw = False
def __init__(self, bal_window, parent, default_locktime=None): def __init__(self, bal_window, parent, default_locktime=None):
super().__init__(parent) super().__init__(parent)
@@ -227,7 +232,9 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
hbox.setContentsMargins(0, 0, 0, 0) hbox.setContentsMargins(0, 0, 0, 0)
hbox.setSpacing(0) hbox.setSpacing(0)
self.setMinimumWidth(40 * char_width_in_lineedit()) self.setMinimumWidth(40 * char_width_in_lineedit())
self.locktime_raw_e = TimeRawEditWidget(self, time_edit=self) self.locktime_raw_e = TimeRawEditWidget(
self, time_edit=self, allow_negative=self.allow_negative_raw
)
self.locktime_date_e = LockTimeDateEdit(self, time_edit=self) self.locktime_date_e = LockTimeDateEdit(self, time_edit=self)
self.editors = [self.locktime_raw_e, self.locktime_date_e] self.editors = [self.locktime_raw_e, self.locktime_date_e]
self.combo = QComboBox() self.combo = QComboBox()
@@ -482,9 +489,9 @@ class TimeRawEditWidget(QWidget):
def is_acceptable_locktime(self, value): def is_acceptable_locktime(self, value):
return True return True
def __init__(self, parent, time_edit=None): def __init__(self, parent, time_edit=None, allow_negative=False):
super().__init__(parent) super().__init__(parent)
self.editor = LockTimeRawEdit(parent, time_edit) self.editor = LockTimeRawEdit(parent, time_edit, allow_negative=allow_negative)
self.label = QLabel("") self.label = QLabel("")
# Group C / C3: this trailing label shows the ABSOLUTE date computed # Group C / C3: this trailing label shows the ABSOLUTE date computed
# from the RAW value (e.g. "30d" -> "2027-06-23"), so it needs room for a # from the RAW value (e.g. "30d" -> "2027-06-23"), so it needs room for a
@@ -510,7 +517,7 @@ class TimeRawEditWidget(QWidget):
class LockTimeRawEdit(QLineEdit, _LockTimeEditor): class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
def __init__(self, parent=None, time_edit=None): def __init__(self, parent=None, time_edit=None, allow_negative=False):
QLineEdit.__init__(self, parent) QLineEdit.__init__(self, parent)
# Group C / C3: narrow the RAW input to roughly a third of its former # Group C / C3: narrow the RAW input to roughly a third of its former
# width. The accepted values are short relative durations such as "30d" # width. The accepted values are short relative durations such as "30d"
@@ -520,16 +527,22 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
self.textChanged.connect(self.numbify) self.textChanged.connect(self.numbify)
self.isdays = False self.isdays = False
self.isyears = False self.isyears = False
# The Check Alive field accepts a leading "-" (the sign selects the
# anchor date); the delivery field does not.
self.allow_negative = allow_negative
self.time_edit = time_edit self.time_edit = time_edit
def numbify(self): def numbify(self):
# Only digits plus the day ("d") and year ("y") suffixes are accepted. # Only digits plus the day ("d") and year ("y") suffixes are accepted
# The block-height suffix ("b") was removed (A1): locktimes are always # (and, for the Check Alive field, a single leading "-"). The
# UNIX timestamps now, so block-relative input is no longer allowed. # block-height suffix ("b") was removed (A1): locktimes are always UNIX
# timestamps now, so block-relative input is no longer allowed.
# The sanitisation itself lives in bal.core.input_rules. # The sanitisation itself lives in bal.core.input_rules.
text = self.text().strip() text = self.text().strip()
pos = self.cursorPosition() pos = self.cursorPosition()
s, isdays, isyears, pos = normalize_locktime_raw_text(text, pos) s, isdays, isyears, pos = normalize_locktime_raw_text(
text, pos, allow_negative=self.allow_negative
)
self.isdays = isdays self.isdays = isdays
self.isyears = isyears self.isyears = isyears
self.blockSignals(True) self.blockSignals(True)
@@ -643,6 +656,11 @@ class ThresholdTimeWidget(BalTimeEditWidget):
"if you choose Raw, you can insert various options based on suffix:<br>" "if you choose Raw, you can insert various options based on suffix:<br>"
" - d: number of days after current day(ex: 1d means tomorrow)<br>" " - d: number of days after current day(ex: 1d means tomorrow)<br>"
" - y: number of years after current day(ex: 1y means one year from today)<br>" " - y: number of years after current day(ex: 1y means one year from today)<br>"
"The sign selects the reference date:<br>"
" - a NEGATIVE value (ex: -30d) counts backwards from the "
"<b>delivery</b> date (30 days before the delivery);<br>"
" - a POSITIVE value (ex: 30d) counts forward from the "
"<b>inheritance creation</b> date (30 days after the will was created).<br>"
"<br><b>Note:</b> the date/time is expressed in your computer's " "<br><b>Note:</b> the date/time is expressed in your computer's "
"<b>Local time</b> (not UTC time).<br>" "<b>Local time</b> (not UTC time).<br>"
) )
@@ -650,6 +668,8 @@ class ThresholdTimeWidget(BalTimeEditWidget):
#label_text = "Check Alive" #label_text = "Check Alive"
tooltip_text = N_("Check Alive") tooltip_text = N_("Check Alive")
base_field = "threshold" base_field = "threshold"
# The Check Alive field accepts a signed value; the delivery field does not.
allow_negative_raw = True
def __init__(self, bal_window, parent, init_value=None): def __init__(self, bal_window, parent, init_value=None):
if init_value is None: if init_value is None:
@@ -1135,18 +1155,23 @@ class WillSettingsWidget(QWidget):
def on_locktime_change(self): def on_locktime_change(self):
locktime = self.widgets["locktime"].get_value() locktime = self.widgets["locktime"].get_value()
threshold = self.widgets["threshold"].get_value() threshold_raw = self.widgets["threshold"].get_value()
locktime = BalTimestamp(locktime) locktime = BalTimestamp(locktime)
threshold = BalTimestamp(threshold)
min_locktime = min( min_locktime = min(
Will.get_min_locktime(self.bal_window.willitems, NLOCKTIME_MAX), Will.get_min_locktime(self.bal_window.willitems, NLOCKTIME_MAX),
locktime.to_timestamp(), locktime.to_timestamp(),
) )
# Resolved Check Alive date. For relative values this counts BACKWARDS # Resolved Check Alive date. The SIGN of the RAW value selects the
# from the delivery time (reverse=True): "90d" means "90 days BEFORE the # anchor: negative counts backwards from the delivery, positive counts
# delivery", not "90 days from now". # forward from the inheritance creation date. The rule lives in
td = threshold.to_date(min_locktime, True) # ``bal.core.checkalive`` so this display matches every validity check.
resolved = resolve_relative_check_alive_date(
threshold_raw,
delivery_date=min_locktime,
creation_date=Will.get_creation_time(self.bal_window.willitems),
)
td = BalTimestamp._safe_fromtimestamp(resolved)
self.widgets["threshold"].alarm=td self.widgets["threshold"].alarm=td
self.bal_window.will_settings["real_threshold"]=td.timestamp() self.bal_window.will_settings["real_threshold"]=td.timestamp()
try: try:

View File

@@ -20,9 +20,11 @@ import threading
from electrum.util import MyEncoder from electrum.util import MyEncoder
from ...core.checkalive import ( from ...core.checkalive import (
CheckAliveAfterDeliveryError,
CheckAliveError, CheckAliveError,
check_alive_expired, ensure_check_alive_valid,
resolve_date_to_check, resolve_date_to_check,
resolve_delivery_date,
resolve_guard_threshold, resolve_guard_threshold,
) )
from .common import ( from .common import (
@@ -492,6 +494,7 @@ class BalWindow:
self.bal_plugin.is_basic_mode(), self.bal_plugin.is_basic_mode(),
self.will_settings, self.will_settings,
built_locktime=_new_locktime, built_locktime=_new_locktime,
creation_date=Will.get_creation_time(self.willitems),
) )
will = {} will = {}
# willtodelete = [] # willtodelete = []
@@ -548,7 +551,9 @@ class BalWindow:
tx["heirsvalue"] = txs[txid].heirsvalue tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description tx["description"] = txs[txid].description
tx["willexecutor"] = copy_structure(txs[txid].willexecutor) tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
tx["status"] = _("New") # Stored history: always English (translated only for display by
# format_status_history); never _() here.
tx["status"] = "New"
tx["baltx_fees"] = txs[txid].tx_fees tx["baltx_fees"] = txs[txid].tx_fees
tx["time"] = creation_time tx["time"] = creation_time
tx["heirs"] = copy_structure(txs[txid].heirs) tx["heirs"] = copy_structure(txs[txid].heirs)
@@ -740,6 +745,7 @@ class BalWindow:
self.bal_plugin.is_basic_mode(), self.bal_plugin.is_basic_mode(),
self.will_settings, self.will_settings,
built_locktime=Will.get_min_locktime(self.willitems), built_locktime=Will.get_min_locktime(self.willitems),
creation_date=Will.get_creation_time(self.willitems),
) )
# found = False # found = False
# NOTE: block-height tracking removed (A1) - locktimes are always # NOTE: block-height tracking removed (A1) - locktimes are always
@@ -750,19 +756,20 @@ class BalWindow:
self.willexecutors = Willexecutors.get_willexecutors( self.willexecutors = Willexecutors.get_willexecutors(
self.bal_plugin, update=True, bal_window=self, task=False self.bal_plugin, update=True, bal_window=self, task=False
) )
# SIMPLE / ADVANCED: in BASIC mode the "Check Alive" parameter must # The Check Alive must be strictly after today and strictly before
# behave AS IF IT DID NOT EXIST. The check-alive threshold drives # the delivery. In BASIC mode the single validity gate below is a
# the "you are alive -> postpone the inheritance" prompt; raising # no-op (see ``bal.core.checkalive.ensure_check_alive_valid``), so a
# CheckAliveError here is what triggers that postpone/invalidate # passed check-alive date never forces a postpone/rewrite; the
# flow. In BASIC we therefore SKIP this check entirely, so a passed # delivery time (locktime) stays fully enforced.
# check-alive date never forces a postpone/rewrite of the will. The delivery_date = resolve_delivery_date(
# delivery time (locktime) is unaffected and still fully enforced. self.will_settings,
# The BASIC/ADVANCED rule lives in built_locktime=Will.get_min_locktime(self.willitems),
# ``bal.core.checkalive.check_alive_expired``. )
if check_alive_expired( ensure_check_alive_valid(
self.bal_plugin.is_basic_mode(), self.date_to_check self.bal_plugin.is_basic_mode(),
): self.date_to_check,
raise CheckAliveError(self.date_to_check) delivery_date=delivery_date,
)
self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get()) self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get())
@@ -787,7 +794,9 @@ class BalWindow:
""" """
locktime = Util.parse_locktime_string(self.will_settings["locktime"]) locktime = Util.parse_locktime_string(self.will_settings["locktime"])
threshold_ts = resolve_guard_threshold( threshold_ts = resolve_guard_threshold(
self.bal_plugin.is_basic_mode(), self.will_settings self.bal_plugin.is_basic_mode(),
self.will_settings,
creation_date=Will.get_creation_time(self.willitems),
) )
if threshold_ts is not None: if threshold_ts is not None:
return locktime < threshold_ts return locktime < threshold_ts
@@ -826,17 +835,24 @@ class BalWindow:
self.window.wallet.dust_threshold(), self.window.wallet.dust_threshold(),
max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(), max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
) )
except AmountException as e: except AmountException:
self.show_warning( self.show_warning(
_( _(
"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{}" "In the inheritance process, the entire wallet will always be fully emptied."
).format(e) )
) )
except WillExecutorFeeTooHighException as e: except WillExecutorFeeTooHighException as e:
self.show_error( self.show_error(
_("Will-executor fee too high: {}").format(e) _("Will-executor fee too high: {}").format(e)
) )
return return
except CheckAliveAfterDeliveryError:
self.show_error(
_(
"Check Alive must be after today and before the delivery date, please update it"
)
)
return
except CheckAliveError: except CheckAliveError:
self.show_error( self.show_error(
_( _(
@@ -1073,6 +1089,7 @@ class BalWindow:
will_locktime=Will.get_min_locktime( will_locktime=Will.get_min_locktime(
willitems, default_value=self.date_to_check willitems, default_value=self.date_to_check
), ),
fee_policy_descriptor=self.bal_plugin.INVALIDATION_FEE_POLICY.get(),
) )
msg = _("Calculating Transactions") msg = _("Calculating Transactions")
self.waiting_dialog = BalWaitingDialog( self.waiting_dialog = BalWaitingDialog(
@@ -1298,6 +1315,12 @@ class BalWindow:
# means the old will must be invalidated on-chain. # means the old will must be invalidated on-chain.
try: try:
self.init_class_variables() self.init_class_variables()
except CheckAliveAfterDeliveryError:
_logger.info(
"auto rebuild: check-alive not before the delivery, "
"nothing to rebuild/invalidate"
)
return False
except CheckAliveError: except CheckAliveError:
_logger.info("auto rebuild: check-alive threshold passed -> invalidate") _logger.info("auto rebuild: check-alive threshold passed -> invalidate")
self._auto_invalidate_will() self._auto_invalidate_will()
@@ -1402,6 +1425,7 @@ class BalWindow:
willitems, willitems,
default_value=getattr(self, "date_to_check", None), default_value=getattr(self, "date_to_check", None),
), ),
fee_policy_descriptor=self.bal_plugin.INVALIDATION_FEE_POLICY.get(),
) )
except Exception as e: except Exception as e:
_logger.error("auto invalidate: could not build tx: {}".format(e)) _logger.error("auto invalidate: could not build tx: {}".format(e))

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

@@ -32,6 +32,18 @@ you change anything in your will, BAL must decide between three outcomes:
The whole point of rule 3 is safety: **a will‑executor must never be able to The whole point of rule 3 is safety: **a will‑executor must never be able to
broadcast an old transaction that would execute your inheritance too early.** broadcast an old transaction that would execute your inheritance too early.**
**Check‑Alive relative values are signed.** A RELATIVE Check‑Alive
(``-30d`` / ``30d``) is anchored according to its sign:
- **NEGATIVE** (``-30d``) counts **backwards from the delivery date** — the
classic "check alive 30 days before the delivery".
- **POSITIVE** (``30d``) counts **forward from the inheritance creation date**
(the date the will was built), so it stays fixed while the will is unchanged.
The Check‑Alive date must always be **after today** and **before the delivery**;
when it is not, BAL raises `CheckAliveAfterDeliveryError` (a configuration error
that aborts the action and never invalidates the will).
--- ---
## 2. Transaction states (status flags) ## 2. Transaction states (status flags)
@@ -164,6 +176,7 @@ exactly what the will‑executors hold — not the in‑memory copy.
| **Move date EARLIER, still in the future** (anticipate) | **any** (signed or not) | Plain **rebuild** with the new earlier locktime (`HeirNotFound` fall‑through) — **never** an on‑chain invalidation | **No** | | **Move date EARLIER, still in the future** (anticipate) | **any** (signed or not) | Plain **rebuild** with the new earlier locktime (`HeirNotFound` fall‑through) — **never** an on‑chain invalidation | **No** |
| **Move date EARLIER into the past** (new date already passed) | — | Will is genuinely **expired** → **invalidate** (`WillExpired`) | **Yes** | | **Move date EARLIER into the past** (new date already passed) | — | Will is genuinely **expired** → **invalidate** (`WillExpired`) | **Yes** |
| Check‑Alive threshold already passed | — | **Invalidate** (`CheckAliveError`) | **Yes** | | Check‑Alive threshold already passed | — | **Invalidate** (`CheckAliveError`) | **Yes** |
| Check‑Alive date not before the delivery (config error) | — | **Abort**, no rebuild/invalidate (`CheckAliveAfterDeliveryError`) | **No** |
> **Why postpone needs an on‑chain invalidation:** the will‑executor still holds > **Why postpone needs an on‑chain invalidation:** the will‑executor still holds
> the *old* transaction with the *earlier* locktime. If you simply re‑signed a > the *old* transaction with the *earlier* locktime. If you simply re‑signed a

View File

@@ -5,7 +5,7 @@
# ./make-release.sh # read version from bal/manifest.json # ./make-release.sh # read version from bal/manifest.json
# ./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release # ./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
# #
# Requires: git, gpg, curl, python3, sha256sum # Requires: git, gpg, curl, python3, sha256sum, Babel for python3 (pip install babel)
# Optional: ruff (lint skipped if not installed) # Optional: ruff (lint skipped if not installed)
# Credentials: ~/.git-credentials or GITEA_USER / GITEA_TOKEN env vars # Credentials: ~/.git-credentials or GITEA_USER / GITEA_TOKEN env vars
@@ -33,6 +33,12 @@ else
NEW_VER="" NEW_VER=""
fi fi
# Babel is needed by the i18n tests (step 3) and by build_zip.py, which
# compiles the translation catalogs into the zip (step 5). Check it up front
# so a release never stops half-way.
python3 -c "import babel" 2>/dev/null \
|| die "Babel is missing for $(command -v python3): pip install babel"
echo "=== Release ${TAG} ===" echo "=== Release ${TAG} ==="
echo "Current manifest version: ${CURRENT_VER}" echo "Current manifest version: ${CURRENT_VER}"
[ -n "$NEW_VER" ] && echo "New version (will bump): ${NEW_VER}" [ -n "$NEW_VER" ] && echo "New version (will bump): ${NEW_VER}"
@@ -68,6 +74,8 @@ if QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \ tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \ tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
tests/test_group_h_v048.py \ tests/test_group_h_v048.py \
tests/test_i18n.py tests/test_translations.py \
tests/test_cli_*.py tests/test_import_will_details.py \
-q 2>&1; then -q 2>&1; then
echo "All tests passed." echo "All tests passed."
else else

View File

@@ -6,6 +6,12 @@ target-version = "py312"
select =["E", "W", "F", "I", "N", "B", "INT"] # INT: gettext rules (see bal/i18n.py) select =["E", "W", "F", "I", "N", "B", "INT"] # INT: gettext rules (see bal/i18n.py)
ignore = ["E501"] ignore = ["E501"]
[tool.ruff.lint.isort]
# Classify the plugin package explicitly: otherwise ruff decides from where it
# is run / how the project root is detected, and the same import block is
# "sorted" on one machine and I001 on another.
known-first-party = ["bal"]
[tool.ruff.lint.pep8-naming] [tool.ruff.lint.pep8-naming]
classmethod-decorators = ["classmethod", "classproperty"] # electrum.util.classproperty uses cls classmethod-decorators = ["classmethod", "classproperty"] # electrum.util.classproperty uses cls

View File

@@ -25,9 +25,14 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum.simple_config import SimpleConfig from electrum.simple_config import SimpleConfig
from electrum.util import UserFacingException from electrum.util import UserFacingException
from bal.cli.controller import BalController from bal.cli.controller import BalController, _user_facing
from bal.core.heirs import Heirs from bal.core.heirs import Heirs
from bal.core.util import Util from bal.core.util import Util
from bal.core.will import (
HeirChangeException,
NotCompleteWillException,
WillexecutorChangeException,
)
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf" VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
@@ -102,6 +107,13 @@ def _make_controller(plugin):
return BalController(plugin, FakeWallet()) return BalController(plugin, FakeWallet())
def _tmp_path():
"""Return the path of a fresh temporary JSON file (caller removes it)."""
fd, path = tempfile.mkstemp(prefix="bal_cli_", suffix=".json")
os.close(fd)
return path
def test_controller_init_empty(): def test_controller_init_empty():
with Plugin() as plugin: with Plugin() as plugin:
c = _make_controller(plugin) c = _make_controller(plugin)
@@ -194,6 +206,179 @@ def test_willexecutors_crud():
assert renamed not in c.willexecutors assert renamed not in c.willexecutors
def test_willexecutors_update_accepts_promo_code():
"""Regression: the command transport used to forward six positional args to
a five-parameter controller method, raising TypeError. ``promo_code`` must
also be applied to the record instead of being silently dropped.
"""
with Plugin() as plugin:
c = _make_controller(plugin)
url = "https://executor-promo.example.invalid"
c.willexecutors_add(url, address="", base_fee=250)
# Positional call mirroring the transport layer: (url, address,
# base_fee, info, promo_code, rename_to).
result = c.willexecutors_update(
url, "", "300", "Promo executor", "PROMO10", None
)
assert result["willexecutor"]["base_fee"] == 300
assert result["willexecutor"]["info"] == "Promo executor"
assert c.willexecutors[url]["promo_code"] == "PROMO10"
def test_heirs_locktime_forced_when_multiverse_disabled():
"""When multiverse is disabled the heir locktime follows the will locktime,
exactly like the GUI's ``set_heir`` (which forces ``heir[3]``)."""
with Plugin() as plugin:
plugin.WILL_SETTINGS.set(
{"threshold": "-150d", "locktime": "2y", "baltx_fees": 20}
)
plugin.ENABLE_MULTIVERSE.set(False)
c = _make_controller(plugin)
c.heirs_add("bob", VALID_ADDRESS, "100000", locktime="30d")
assert c.heirs["bob"][2] == "2y"
def test_heirs_locktime_kept_when_multiverse_enabled():
with Plugin() as plugin:
plugin.WILL_SETTINGS.set({"threshold": "-150d", "locktime": "2y", "baltx_fees": 20})
plugin.ENABLE_MULTIVERSE.set(True)
c = _make_controller(plugin)
c.heirs_add("carol", VALID_ADDRESS, "100000", locktime="30d")
assert c.heirs["carol"][2] == "30d"
def test_user_facing_maps_not_complete_will():
"""The check flow must translate every NotCompleteWillException subtype to a
user-facing message instead of leaking the raw internal exception.
"""
assert "heirs changed" in str(_user_facing(HeirChangeException())).lower()
assert "will-executor changed" in str(
_user_facing(WillexecutorChangeException())
).lower()
assert "no valid transaction" in str(
_user_facing(NotCompleteWillException())
).lower()
assert "does not cover" in str(
_user_facing(NotCompleteWillException("utxo"))
).lower()
def test_willexecutors_delete_missing_is_noop():
with Plugin() as plugin:
c = _make_controller(plugin)
assert c.willexecutors_delete(["https://missing.example.invalid"]) == {
"deleted": []
}
def test_willexecutors_select_only_valid():
with Plugin() as plugin:
c = _make_controller(plugin)
valid_url = "https://valid.example.invalid"
invalid_url = "https://invalid.example.invalid"
# Valid: real address, base_fee within [dust, max_fee].
c.willexecutors_add(valid_url, address=VALID_ADDRESS, base_fee=1000)
# Invalid: no address.
c.willexecutors_add(invalid_url, address="", base_fee=1000)
c.willexecutors_select(None, select=True, only_valid=True)
assert c.willexecutors[valid_url]["selected"] is True
assert c.willexecutors[invalid_url]["selected"] is False
# Select everything, then deselect only-valid: the valid one keeps its
# state, only the invalid one is cleared.
c.willexecutors_select(None, select=True)
c.willexecutors_select(None, select=False, only_valid=True)
assert c.willexecutors[valid_url]["selected"] is True
assert c.willexecutors[invalid_url]["selected"] is False
def test_willexecutors_import_overwrites_existing():
import json
with Plugin() as plugin:
c = _make_controller(plugin)
url = "https://existing.example.invalid"
c.willexecutors_add(url, address="", base_fee=100, info="Old")
path = _tmp_path()
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(
{
url: {
"address": "",
"base_fee": 999,
"info": "New",
"selected": True,
}
},
f,
)
result = c.willexecutors_import(path)
assert result["imported"] == 1
# The imported record replaces the existing one (GUI behaviour).
assert c.willexecutors[url]["base_fee"] == 999
assert c.willexecutors[url]["info"] == "New"
assert c.willexecutors[url]["selected"] is True
finally:
os.remove(path)
def test_willexecutors_export_subsets():
import json
with Plugin() as plugin:
c = _make_controller(plugin)
selected_url = "https://selected.example.invalid"
other_url = "https://other.example.invalid"
invalid_url = "https://invalid.example.invalid"
c.willexecutors_add(selected_url, address=VALID_ADDRESS, base_fee=1000)
c.willexecutors_add(other_url, address=VALID_ADDRESS, base_fee=1000)
c.willexecutors_add(invalid_url, address="", base_fee=1000)
c.willexecutors_select([selected_url], select=True)
def exported(subset):
path = _tmp_path()
try:
c.willexecutors_export(path, subset)
with open(path, encoding="utf-8") as f:
return json.load(f)
finally:
os.remove(path)
assert set(exported(None)) == {selected_url, other_url, invalid_url}
assert set(exported("selected")) == {selected_url}
# "valid" keeps both real-address records and drops the address-less one.
assert set(exported("valid")) == {selected_url, other_url}
def test_user_facing_maps_no_servers_for_chain():
from bal.core.willexecutors import Willexecutors
advanced = Willexecutors.NoServersForChainError(
"bitcoin", url="https://welist.example/", reason="boom"
)
assert "https://welist.example/" in str(_user_facing(advanced))
basic = Willexecutors.NoServersForChainError("bitcoin")
assert "bitcoin" in str(_user_facing(basic))
def test_prepare_will_warns_on_amount_exception():
from bal.core import will as will_mod
with Plugin() as plugin:
plugin.NO_WILLEXECUTOR.set(True)
c = _make_controller(plugin)
c.heirs["alice"] = [VALID_ADDRESS, "100%", "2y"]
with mock.patch.object(
will_mod.Will,
"check_amounts",
side_effect=will_mod.FixedAmountException("too much"),
), mock.patch.object(will_mod.Will, "is_will_valid", return_value=True):
result = c.prepare_will()
# The amount mismatch is a warning, not an abort: the flow completes.
assert result["result"] == "coherent"
def test_will_status_empty(): def test_will_status_empty():
with Plugin() as plugin: with Plugin() as plugin:
c = _make_controller(plugin) c = _make_controller(plugin)
@@ -240,7 +425,7 @@ def test_build_will_reanchors_date_to_check_to_new_locktime():
plugin.USER_TYPE.set("advanced") plugin.USER_TYPE.set("advanced")
plugin.NO_WILLEXECUTOR.set(True) plugin.NO_WILLEXECUTOR.set(True)
plugin.ENABLE_MULTIVERSE.set(True) plugin.ENABLE_MULTIVERSE.set(True)
plugin.WILL_SETTINGS.set({"threshold": "150d", "locktime": "2y", "baltx_fees": 20}) plugin.WILL_SETTINGS.set({"threshold": "-150d", "locktime": "2y", "baltx_fees": 20})
c = _make_controller(plugin) c = _make_controller(plugin)
c.no_willexecutor = True c.no_willexecutor = True
c.heirs["alice"] = [VALID_ADDRESS, "100%", "1y"] c.heirs["alice"] = [VALID_ADDRESS, "100%", "1y"]

View File

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

View File

@@ -1,8 +1,11 @@
""" """
Tests for ``bal.core.checkalive`` (pure, GUI-free). Tests for ``bal.core.checkalive`` (pure, GUI-free).
Covers the CheckAliveError exception and the BASIC/ADVANCED date_to_check Covers the CheckAlive exceptions and the BASIC/ADVANCED date_to_check policy
policy (``resolve_date_to_check`` / ``check_alive_expired``). (``resolve_date_to_check`` / ``resolve_relative_check_alive_date`` /
``is_check_alive_date_valid`` / ``ensure_check_alive_valid``), including the
sign rule: a NEGATIVE relative Check Alive counts back from the delivery, a
POSITIVE one counts forward from the inheritance creation date.
Run: Run:
source "$BAL_HOME/electrum/env/bin/activate" source "$BAL_HOME/electrum/env/bin/activate"
@@ -11,24 +14,41 @@ Run:
import sys import sys
import time import time
from datetime import datetime, timezone
sys.path.insert(0, __file__.rsplit("/", 2)[0]) sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.checkalive import ( # noqa: E402 (path insert above) from bal.core.checkalive import ( # noqa: E402 (path insert above)
CheckAliveAfterDeliveryError,
CheckAliveError, CheckAliveError,
check_alive_expired, ensure_check_alive_valid,
is_check_alive_date_valid,
resolve_date_to_check, resolve_date_to_check,
resolve_delivery_date,
resolve_guard_threshold, resolve_guard_threshold,
resolve_relative_check_alive_date,
) )
from bal.core.plugin_base import BalTimestamp # noqa: E402
from bal.core.will import Will # noqa: E402
def _midnight(ts):
return (
datetime.fromtimestamp(ts, tz=timezone.utc)
.replace(hour=0, minute=0, second=0, microsecond=0)
.timestamp()
)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# CheckAliveError # CheckAliveError / CheckAliveAfterDeliveryError
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_check_alive_error_default(): def test_check_alive_error_default():
err = CheckAliveError(1000000) err = CheckAliveError(1000000)
assert err.timestamp_to_check == 1000000 assert err.timestamp_to_check == 1000000
assert err.reason is None
def test_check_alive_error_str(): def test_check_alive_error_str():
@@ -38,10 +58,107 @@ def test_check_alive_error_str():
assert "1970" in s assert "1970" in s
def test_check_alive_error_str_with_reason():
err = CheckAliveError(1000000, reason="too early")
assert "too early" in str(err)
def test_check_alive_error_subclass(): def test_check_alive_error_subclass():
assert issubclass(CheckAliveError, Exception) assert issubclass(CheckAliveError, Exception)
def test_check_alive_after_delivery_error_is_sibling():
# NOT a subclass of CheckAliveError: the existing ``except CheckAliveError``
# handlers must not invalidate the will for this configuration error.
assert not issubclass(CheckAliveAfterDeliveryError, CheckAliveError)
err = CheckAliveAfterDeliveryError(2000, 1000)
assert err.timestamp_to_check == 2000
assert err.delivery_timestamp == 1000
assert "delivery" in str(err)
# ------------------------------------------------------------------ #
# resolve_delivery_date
# ------------------------------------------------------------------ #
def test_delivery_absolute_locktime():
settings = {"locktime": 1_800_000_000}
assert resolve_delivery_date(settings) == 1_800_000_000
def test_delivery_relative_locktime_resolved_from_now():
fake_now = 1_800_000_000.0
settings = {"locktime": "90d"}
result = resolve_delivery_date(settings, now=fake_now)
expected = _midnight(fake_now) + 90 * 86400
assert abs(result - expected) < 1
def test_delivery_relative_locktime_anchored_to_built_tx():
frozen = 1817424000
settings = {"locktime": "2y"}
result = resolve_delivery_date(
settings, now=1_800_000_000.0, built_locktime=frozen
)
assert result == frozen
def test_delivery_missing_locktime_returns_none():
assert resolve_delivery_date({}) is None
# ------------------------------------------------------------------ #
# resolve_relative_check_alive_date (the single calculation rule)
# ------------------------------------------------------------------ #
def test_relative_negative_anchors_to_delivery():
fake_now = 1_800_000_000.0
delivery = fake_now + 90 * 86400
result = resolve_relative_check_alive_date(
"-30d", delivery_date=delivery, creation_date=fake_now, now=fake_now
)
expected = _midnight(delivery) - 30 * 86400
assert abs(result - expected) < 1
assert result < delivery
def test_relative_positive_anchors_to_creation():
fake_now = 1_800_000_000.0
creation = fake_now + 5 * 86400
delivery = fake_now + 365 * 86400
result = resolve_relative_check_alive_date(
"30d", delivery_date=delivery, creation_date=creation, now=fake_now
)
expected = _midnight(creation) + 30 * 86400
assert abs(result - expected) < 1
# The delivery anchor is irrelevant for a positive value.
assert result < delivery
def test_relative_positive_without_creation_falls_back_to_now():
fake_now = 1_800_000_000.0
result = resolve_relative_check_alive_date(
"30d", delivery_date=fake_now + 365 * 86400, creation_date=None, now=fake_now
)
expected = _midnight(fake_now) + 30 * 86400
assert abs(result - expected) < 1
def test_relative_negative_without_delivery_falls_back_to_now():
fake_now = 1_800_000_000.0
result = resolve_relative_check_alive_date(
"-30d", delivery_date=None, creation_date=fake_now, now=fake_now
)
expected = _midnight(fake_now) - 30 * 86400
assert abs(result - expected) < 1
def test_relative_absolute_value_returned_unchanged():
assert resolve_relative_check_alive_date(1_700_000_000, None, None) == 1_700_000_000
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# resolve_date_to_check # resolve_date_to_check
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -59,69 +176,60 @@ def test_advanced_mode_uses_threshold_absolute():
assert abs(result - threshold) < 1 assert abs(result - threshold) < 1
def test_advanced_mode_parses_relative_threshold(): def test_advanced_mode_negative_relative_anchored_to_delivery():
# A relative threshold means "N days BEFORE the delivery": it resolves
# against the stored locktime (backwards), not forward from now.
from datetime import datetime, timedelta, timezone
fake_now = 1_800_000_000.0 fake_now = 1_800_000_000.0
locktime = fake_now + 90 * 86400 locktime = fake_now + 90 * 86400
settings = {"threshold": "30d", "locktime": locktime} settings = {"threshold": "-30d", "locktime": locktime}
result = resolve_date_to_check(False, settings, now=fake_now) result = resolve_date_to_check(False, settings, now=fake_now)
# date_to_check = (locktime, midnight-normalised) - 30 days. expected = _midnight(locktime) - 30 * 86400
expected = (datetime.fromtimestamp(locktime, tz=timezone.utc)
.replace(hour=0, minute=0, second=0, microsecond=0)
- timedelta(days=30)).timestamp()
assert abs(result - expected) < 1 assert abs(result - expected) < 1
# 90d delivery with a 30d window: the window starts 60 days after now.
assert result > fake_now assert result > fake_now
def test_advanced_mode_relative_threshold_anchored_to_locktime(): def test_advanced_mode_positive_relative_anchored_to_creation():
"""A relative threshold never drifts with the clock: re-resolving it a day fake_now = 1_800_000_000.0
later, with the same fixed absolute locktime, yields the same date.""" creation = fake_now + 10 * 86400
locktime = fake_now + 365 * 86400
settings = {"threshold": "30d", "locktime": locktime}
result = resolve_date_to_check(
False, settings, now=fake_now, creation_date=creation
)
expected = _midnight(creation) + 30 * 86400
assert abs(result - expected) < 1
def test_advanced_mode_negative_relative_never_drifts():
"""A negative relative threshold anchored to a fixed absolute delivery does
not drift with the clock."""
fake_now = 1_800_000_000.0 fake_now = 1_800_000_000.0
locktime = fake_now + 90 * 86400 locktime = fake_now + 90 * 86400
settings = {"threshold": "30d", "locktime": locktime} settings = {"threshold": "-30d", "locktime": locktime}
first = resolve_date_to_check(False, settings, now=fake_now) first = resolve_date_to_check(False, settings, now=fake_now)
# Next day: same stored settings (the fixed delivery), a later clock.
second = resolve_date_to_check(False, settings, now=fake_now + 86400) second = resolve_date_to_check(False, settings, now=fake_now + 86400)
assert first == second assert first == second
def test_advanced_mode_relative_threshold_with_relative_locktime(): def test_advanced_mode_positive_relative_creation_never_drifts():
"""A relative locktime is resolved against 'now' first, then the relative """A positive relative threshold anchored to the frozen creation date does
threshold counts N days back from it (matches the settings widget).""" not drift with the clock."""
from datetime import datetime, timezone
from bal.core.plugin_base import BalTimestamp
fake_now = 1_800_000_000.0 fake_now = 1_800_000_000.0
settings = {"threshold": "30d", "locktime": "90d"} creation = fake_now + 2 * 86400
result = resolve_date_to_check(False, settings, now=fake_now) settings = {"threshold": "30d", "locktime": fake_now + 365 * 86400}
# Recompute the expected value with the same resolution rules: first = resolve_date_to_check(
# locktime = now + 90d (midnight-normalised), threshold = locktime - 30d. False, settings, now=fake_now, creation_date=creation
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc)) )
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp() second = resolve_date_to_check(
assert abs(result - expected) < 1 False, settings, now=fake_now + 5 * 86400, creation_date=creation
assert result > fake_now )
assert first == second
def test_advanced_mode_relative_threshold_no_locktime_falls_back():
# Without a locktime reference, fall back to the legacy forward resolution.
settings = {"threshold": "30d"}
result = resolve_date_to_check(False, settings)
assert result > time.time()
def test_advanced_mode_relative_locktime_anchored_to_built_tx(): def test_advanced_mode_relative_locktime_anchored_to_built_tx():
"""A RELATIVE stored locktime is anchored to the built will's frozen """A RELATIVE stored locktime is anchored to the built will's frozen
delivery date (built_locktime), not to "now": an unchanged will must not delivery date (built_locktime), not to "now": an unchanged will must not
read as expired as the clock advances (the karen7 daily-invalidate bug).""" read as expired as the clock advances (the karen7 daily-invalidate bug)."""
frozen = 1817424000 # frozen tx locktime (2027-08-05 00:00 UTC), built 2026-08-05 frozen = 1817424000 # frozen tx locktime, built 2026-08-05
settings = {"threshold": "30d", "locktime": "2y"} settings = {"threshold": "-30d", "locktime": "2y"}
# On build day the frozen delivery is authoritative: date_to_check is
# frozen - 30d and NEVER drifts, however much later the clock gets.
first = resolve_date_to_check( first = resolve_date_to_check(
False, settings, now=1_800_000_000.0, built_locktime=frozen False, settings, now=1_800_000_000.0, built_locktime=frozen
) )
@@ -130,22 +238,15 @@ def test_advanced_mode_relative_locktime_anchored_to_built_tx():
False, settings, now=1_800_000_000.0 + 10 * 86400, built_locktime=frozen False, settings, now=1_800_000_000.0 + 10 * 86400, built_locktime=frozen
) )
assert first == later assert first == later
# The check window must start BEFORE the frozen delivery (never expired).
assert first < frozen assert first < frozen
def test_advanced_mode_relative_locktime_without_built_tx_falls_back(): def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
"""Without a built will there is no anchor: keeps the legacy now-based
resolution (a moving target, used only before the first build)."""
from datetime import datetime, timezone
from bal.core.plugin_base import BalTimestamp
fake_now = 1_800_000_000.0 fake_now = 1_800_000_000.0
settings = {"threshold": "30d", "locktime": "90d"} settings = {"threshold": "-30d", "locktime": "90d"}
result = resolve_date_to_check(False, settings, now=fake_now) result = resolve_date_to_check(False, settings, now=fake_now)
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc)) locktime_dt = BalTimestamp("90d").to_date(fake_now)
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp() expected = BalTimestamp("-30d").to_date(locktime_dt).timestamp()
assert abs(result - expected) < 1 assert abs(result - expected) < 1
@@ -156,14 +257,12 @@ def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
def test_guard_threshold_basic_mode_returns_none(): def test_guard_threshold_basic_mode_returns_none():
fake_now = 1_800_000_000.0 fake_now = 1_800_000_000.0
threshold = resolve_guard_threshold(True, {"threshold": "30d"}, now=fake_now) threshold = resolve_guard_threshold(True, {"threshold": "-30d"}, now=fake_now)
assert threshold is None assert threshold is None
def _guard_locktime(settings, fake_now): def _guard_locktime(settings, fake_now):
"""Reproduce the call-site locktime expression of the guard.""" """Reproduce the call-site locktime expression of the guard."""
from bal.core.plugin_base import BalTimestamp
return BalTimestamp(settings["locktime"]).to_timestamp(fake_now) return BalTimestamp(settings["locktime"]).to_timestamp(fake_now)
@@ -175,43 +274,27 @@ def test_guard_threshold_absolute():
assert resolve_guard_threshold(False, settings, now=fake_now) == threshold assert resolve_guard_threshold(False, settings, now=fake_now) == threshold
def test_guard_threshold_relative_fresh_anchor(): def test_guard_threshold_negative_relative_fresh_anchor():
"""A relative threshold must be anchored to the FRESH locktime so the """A negative relative threshold is anchored to the FRESH locktime so the
guard and the settings always share one reference frame. guard and the settings always share one reference frame."""
Regression for the false positive where a still-valid built will frozen at
a LONGER delivery ("2y") anchored ``date_to_check`` beyond the currently
stored shorter delivery ("1y"): the old guard compared the fresh "1y"
locktime against that anchored threshold and wrongly fired "locktime is
lower than threshold", even though the settings themselves are consistent
(locktime is 30d AFTER the threshold).
"""
fake_now = 1_800_000_000.0 fake_now = 1_800_000_000.0
settings = {"locktime": "1y", "threshold": "30d"} settings = {"locktime": "1y", "threshold": "-30d"}
locktime = _guard_locktime(settings, fake_now) locktime = _guard_locktime(settings, fake_now)
threshold = resolve_guard_threshold(False, settings, now=fake_now) threshold = resolve_guard_threshold(False, settings, now=fake_now)
assert threshold is not None assert threshold is not None
assert locktime > threshold # internally consistent: no fire assert locktime > threshold # internally consistent: no fire
assert threshold > fake_now assert threshold > fake_now
# The helper takes no built anchor: a frozen "2y" built will must NOT
# contaminate the result, although resolve_date_to_check (the expiry
# reference) legitimately keeps using it.
frozen_two_years = locktime + 365 * 86400
anchored = resolve_date_to_check(
False, settings, now=fake_now, built_locktime=frozen_two_years
)
assert anchored > threshold # built anchor pushes date_to_check forward...
assert locktime < anchored # ...which is exactly what used to fire the bug
def test_guard_threshold_relative_locktime_absolute_threshold(): def test_guard_threshold_positive_relative_anchored_to_creation():
fake_now = 1_800_000_000.0 fake_now = 1_800_000_000.0
threshold = fake_now + 200 * 86400 creation = fake_now + 4 * 86400
settings = {"locktime": "1y", "threshold": threshold} settings = {"locktime": "1y", "threshold": "30d"}
assert resolve_guard_threshold(False, settings, now=fake_now) == threshold threshold = resolve_guard_threshold(
# "1y" from now is later than the stored absolute threshold: allowed. False, settings, now=fake_now, creation_date=creation
locktime = _guard_locktime(settings, fake_now) )
assert locktime > threshold expected = _midnight(creation) + 30 * 86400
assert abs(threshold - expected) < 1
def test_guard_threshold_missing_returns_none(): def test_guard_threshold_missing_returns_none():
@@ -220,21 +303,99 @@ def test_guard_threshold_missing_returns_none():
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# check_alive_expired # is_check_alive_date_valid / ensure_check_alive_valid
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_is_valid_inside_window():
now = 1_800_000_000.0
assert is_check_alive_date_valid(now + 100, now + 1000, now=now) is True
def test_is_valid_rejects_past():
now = 1_800_000_000.0
assert is_check_alive_date_valid(now - 100, now + 1000, now=now) is False
def test_is_valid_rejects_on_or_after_delivery():
now = 1_800_000_000.0
delivery = now + 1000
assert is_check_alive_date_valid(delivery, delivery, now=now) is False
assert is_check_alive_date_valid(delivery + 1, delivery, now=now) is False
def test_is_valid_without_delivery_skips_upper_bound():
now = 1_800_000_000.0
assert is_check_alive_date_valid(now + 100, None, now=now) is True
def test_is_valid_none_is_false():
assert is_check_alive_date_valid(None, 1000) is False
def test_basic_mode_never_expired(): def test_basic_mode_never_expired():
assert check_alive_expired(True, 1_000_000_000.0) is False assert ensure_check_alive_valid(True, time.time() - 10_000) is None
assert check_alive_expired(True, time.time() - 10_000) is False assert ensure_check_alive_valid(True, 1_000_000_000.0) is None
def test_advanced_expired_when_past(): def test_advanced_raises_when_past():
assert check_alive_expired(False, time.time() - 10_000) is True try:
ensure_check_alive_valid(False, time.time() - 10_000)
except CheckAliveError:
pass
else:
raise AssertionError("expected CheckAliveError")
def test_advanced_not_expired_when_future(): def test_advanced_raises_when_after_delivery():
assert check_alive_expired(False, time.time() + 10_000) is False now = time.time()
try:
ensure_check_alive_valid(False, now + 100, delivery_date=now + 50)
except CheckAliveAfterDeliveryError:
pass
else:
raise AssertionError("expected CheckAliveAfterDeliveryError")
def test_advanced_valid_does_not_raise():
now = time.time()
assert ensure_check_alive_valid(False, now + 100, delivery_date=now + 1000) is None
# ------------------------------------------------------------------ #
# Will.get_creation_time
# ------------------------------------------------------------------ #
class _FakeWillItem:
def __init__(self, time_value, valid=True):
self.time = time_value
self._valid = valid
def get_status(self, status):
return self._valid if status == "VALID" else False
def test_get_creation_time_min_of_valid_items():
will = {
"a": _FakeWillItem(2000),
"b": _FakeWillItem(1000),
"c": _FakeWillItem(3000),
}
assert Will.get_creation_time(will) == 1000
def test_get_creation_time_ignores_invalid_and_none():
will = {
"a": _FakeWillItem(500, valid=False),
"b": _FakeWillItem(None),
"c": _FakeWillItem(2000),
}
assert Will.get_creation_time(will) == 2000
def test_get_creation_time_empty_returns_none():
assert Will.get_creation_time({}) is None
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -117,6 +117,30 @@ def test_normalize_locktime_raw_single_suffix():
assert s == "130d" assert s == "130d"
def test_normalize_locktime_raw_negative_disallowed_by_default():
# The delivery field must never accept a sign.
s, isdays, isyears, pos = normalize_locktime_raw_text("-30d", 4)
assert s == "30d"
assert isdays is True
def test_normalize_locktime_raw_negative_allowed_keeps_single_sign():
s, isdays, isyears, pos = normalize_locktime_raw_text(
"-30d", 4, allow_negative=True
)
assert s == "-30d"
assert isdays is True
assert pos >= 1
def test_normalize_locktime_raw_negative_only_leading():
# Extra signs are dropped; a single leading sign survives.
s, _isdays, _isyears, _pos = normalize_locktime_raw_text(
"--1y", 4, allow_negative=True
)
assert s == "-1y"
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Percentage-or-amount normalisation # Percentage-or-amount normalisation
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -0,0 +1,296 @@
"""
Tests for the fee of the on-chain INVALIDATION transaction.
The invalidation is broadcast TODAY (it spends the will's coins so the old
pre-signed inheritance transactions become unusable), therefore it must pay the
CURRENT network fee chosen with an Electrum ``FeePolicy`` (Feerate / ETA /
Mempool; default ``"eta:2"``, i.e. confirm within 2 blocks). The will's
``baltx_fees`` (a deliberately high rate meant for inheritance transactions
broadcast years from now) is only the FALLBACK used when no usable rate is
available.
Covers:
* Will.get_invalidation_fee_rate() - rate selection and fallback rules
* Will.invalidate_will() - the chosen rate is really used for the fee
Run (standalone or with pytest):
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 tests/test_core_invalidation_fee.py
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_core_invalidation_fee.py -q
"""
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum.fee_policy import FeePolicy, FeeTimeEstimates
from electrum.transaction import Transaction
from bal.core.will import (
INVALIDATION_FEE_ETA_BLOCKS,
INVALIDATION_FEE_POLICY_DEFAULT,
Will,
WillItem,
)
# 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()
# A valid serialized transaction (1 input, 1 output) reused by the core tests.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
# The outpoint spent by _VALID_TX_HEX (input 0).
_PREVOUT_STR = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0"
_CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
_BALANCE = 1000000
# The will's baltx_fees used as the fallback rate in every test (sat/vbyte).
_BALTX_FEES = 20
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _wallet_with_eta_estimate(fee_per_kb):
"""Wallet whose ETA fee lookup returns ``fee_per_kb`` (sat/kvbyte).
Electrum's ``FeePolicy`` (method ``eta``) resolves the target through
``network.fee_estimates.eta_to_fee``. ``fee_per_kb`` may also be an
Exception instance: the call then raises it (broken network object), or a
non-int-convertible object to exercise the invalid-value path.
"""
wallet = MagicMock()
wallet.get_change_addresses_for_new_transaction.return_value = [_CHANGE_ADDR]
network = MagicMock()
if isinstance(fee_per_kb, Exception):
network.fee_estimates.eta_to_fee.side_effect = fee_per_kb
else:
network.fee_estimates.eta_to_fee.return_value = fee_per_kb
wallet.network = network
return wallet
def _make_will():
"""One VALID will item spending _PREVOUT_STR with a known input value."""
item = WillItem({
"tx": _VALID_TX_HEX,
"heirs": {"alice": ["addr_alice", 5000, "30d"]},
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": _BALTX_FEES,
})
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
item.tx.inputs()[0]._TxInput__value_sats = _BALANCE
return {"willtxid1": item}
def _make_utxo():
"""Wallet-side UTXO matching the will input."""
utxo = MagicMock()
utxo.prevout.to_str.return_value = _PREVOUT_STR
utxo.is_coinbase_output.return_value = False
utxo.block_height = 1
utxo.value_sats.return_value = _BALANCE
return utxo
def _invalidation_output_value(wallet, estimated_size=200, descriptor=None):
"""Run Will.invalidate_will and return the value of the final output.
Electrum's tx building is mocked (as in test_core_will_invalidate.py):
``estimated_size`` is the vsize the fee is computed from.
"""
wallet.get_utxos.return_value = [_make_utxo()]
mock_output = MagicMock()
mock_tx = MagicMock()
mock_tx.estimated_size.return_value = estimated_size
with patch("bal.core.will.Util.get_current_height", return_value=800000), \
patch("electrum.transaction.PartialTxOutput.from_address_and_value",
return_value=mock_output) as mock_out, \
patch("electrum.transaction.PartialTransaction.from_io",
return_value=mock_tx):
result = Will.invalidate_will(
_make_will(), wallet, _BALTX_FEES,
fee_policy_descriptor=descriptor,
)
assert result is mock_tx
# 1st call: full balance (size estimation); 2nd call: balance - fee.
return mock_out.call_args_list[1][0][1]
# ------------------------------------------------------------------ #
# Will.get_invalidation_fee_rate - policy selection
# ------------------------------------------------------------------ #
def test_default_policy_is_two_block_eta():
"""The owner's decision: default fee policy confirms within 2 blocks.
Uses a REAL ``FeeTimeEstimates`` holding data only for the 2-block target,
proving the default policy really asks Electrum for target 2.
"""
assert INVALIDATION_FEE_ETA_BLOCKS == 2
assert INVALIDATION_FEE_POLICY_DEFAULT == "eta:2"
network = MagicMock()
estimates = FeeTimeEstimates()
estimates.set_data(2, 3000)
network.fee_estimates = estimates
wallet = MagicMock()
wallet.network = network
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (3.0, "eta")
def test_default_policy_descriptor_is_parsed_by_electrum_fee_policy():
"""The setting is a real Electrum FeePolicy descriptor."""
policy = FeePolicy(INVALIDATION_FEE_POLICY_DEFAULT)
assert policy.method.name.lower() == "eta"
assert policy.value == 2
def test_network_estimate_is_used():
"""A live estimate (sat/kvbyte) becomes the rate in sat/vbyte."""
wallet = _wallet_with_eta_estimate(3000)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (3.0, "eta")
def test_network_estimate_higher_than_baltx_fees_is_used_as_is():
"""No cap: if the market is above baltx_fees, the market rate is used."""
wallet = _wallet_with_eta_estimate(45000)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (45.0, "eta")
def test_fractional_estimate_is_kept():
"""Electrum estimates may be fractional (e.g. 1.5 sat/vbyte)."""
wallet = _wallet_with_eta_estimate(1500)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (1.5, "eta")
def test_fixed_feerate_policy_works_offline():
"""A fixed 'feerate' policy needs no network at all."""
wallet = MagicMock()
wallet.network = None
assert Will.get_invalidation_fee_rate(
wallet, _BALTX_FEES, "feerate:5000"
) == (5.0, "feerate")
def test_mempool_policy_uses_depth_estimate():
"""A 'mempool' policy asks Electrum for a depth-based estimate."""
wallet = MagicMock()
network = MagicMock()
network.mempool_fees.depth_to_fee.return_value = 3000
wallet.network = network
assert Will.get_invalidation_fee_rate(
wallet, _BALTX_FEES, "mempool:1000000"
) == (3.0, "mempool")
def test_offline_wallet_falls_back_to_baltx_fees():
"""No network object (offline) with a dynamic policy: baltx_fees."""
wallet = MagicMock()
wallet.network = None
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
)
def test_missing_estimate_falls_back():
"""Estimates not received yet: Electrum returns None."""
wallet = _wallet_with_eta_estimate(None)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
)
def test_estimate_error_falls_back_and_does_not_raise():
"""A broken network object must never break the invalidation."""
wallet = _wallet_with_eta_estimate(RuntimeError("server gone"))
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
)
def test_non_positive_or_unconvertible_estimates_fall_back():
"""Only a positive integer rate is trusted."""
for bad in (0, -1000, object()):
wallet = _wallet_with_eta_estimate(bad)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
), bad
def test_invalid_policy_descriptor_uses_the_default():
"""A corrupt stored descriptor must not break anything: default is used."""
wallet = _wallet_with_eta_estimate(3000)
assert Will.get_invalidation_fee_rate(
wallet, _BALTX_FEES, "not-a-policy"
) == (3.0, "eta")
def test_no_wallet_falls_back():
assert Will.get_invalidation_fee_rate(None, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
)
# ------------------------------------------------------------------ #
# Will.invalidate_will uses the chosen rate
# ------------------------------------------------------------------ #
def test_invalidation_pays_network_fee_not_baltx_fees():
"""3 sat/vbyte on 200 vbytes = 600 sat (not 200 * 20 = 4000 sat)."""
wallet = _wallet_with_eta_estimate(3000)
assert _invalidation_output_value(wallet) == _BALANCE - 600
def test_invalidation_offline_pays_baltx_fees():
"""Offline: unchanged behaviour, 200 * 20 = 4000 sat."""
wallet = _wallet_with_eta_estimate(None)
assert _invalidation_output_value(wallet) == _BALANCE - 4000
def test_invalidation_uses_fixed_feerate_policy_offline():
"""A fixed feerate pays exactly that rate even without a network."""
wallet = MagicMock()
wallet.network = None
assert _invalidation_output_value(
wallet, descriptor="feerate:5000"
) == _BALANCE - 1000 # 200 vbytes * 5 sat/vbyte
def test_fractional_fee_is_rounded_up_to_whole_satoshis():
"""1.5 sat/vbyte on 201 vbytes = 301.5 -> 302 sat (never below the rate)."""
wallet = _wallet_with_eta_estimate(1500)
value = _invalidation_output_value(wallet, estimated_size=201)
assert value == _BALANCE - 302
assert isinstance(value, int)
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All invalidation fee tests passed")

View File

@@ -201,7 +201,7 @@ def test_balconfig_set():
def test_default_will_settings_relative(): def test_default_will_settings_relative():
rel = BalPlugin.default_will_settings_relative() rel = BalPlugin.default_will_settings_relative()
assert rel["threshold"] == "30d" assert rel["threshold"] == "-30d"
assert rel["locktime"] == "1y" assert rel["locktime"] == "1y"

View File

@@ -1,10 +1,8 @@
""" """
Tests for ``bal.core.qrtransfer``. Tests for ``bal.core.qrtransfer``.
Covers the BALQR frame encoding used for will transfer via QR codes / Covers the transfer-string encoding used for will transfer via BBQR QR codes
audio modem: encoding, framing, reassembly, malformed input and the preset / audio modem: encoding, decoding and the preset list.
list (optionally cross-checked against the ``qrcode`` library's EC-M
capacity when it is installed).
Run: Run:
source electrum/env/bin/activate source electrum/env/bin/activate
@@ -18,34 +16,12 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.qrtransfer import ( from bal.core.qrtransfer import (
CHUNK_PRESETS, CHUNK_PRESETS,
MIN_CHUNK_SIZE,
InconsistentTotalError,
MissingFramesError,
QrTransferError,
assemble,
decode_transfer, decode_transfer,
encode_transfer, encode_transfer,
parse_frame,
preset_index_for_chunk_size, preset_index_for_chunk_size,
split_frames,
) )
def _frames(tx_strings, chunk_size, compress=False):
"""Split a payload and return (payload, total, {index: payload})."""
payload = encode_transfer(tx_strings, compress=compress)
parsed = {}
total = None
for frame in split_frames(payload, chunk_size, compressed=compress):
t, index, _compressed, p = parse_frame(frame)
if total is not None:
assert total == t
total = t
parsed[index] = p
assert total is not None
return payload, total, parsed
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Round trips # Round trips
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -67,300 +43,6 @@ def test_empty_list_roundtrip():
assert decode_transfer(encode_transfer([]), compressed=False) == [] assert decode_transfer(encode_transfer([]), compressed=False) == []
# --------------------------------------------------------------------------- #
# Framing
# --------------------------------------------------------------------------- #
def test_single_frame():
tx_strings = ["11" * 10]
payload = encode_transfer(tx_strings)
frames = split_frames(payload, 150)
assert len(frames) == 1
total, index, compressed, p = parse_frame(frames[0])
assert (total, index, compressed) == (1, 1, False)
assert p == payload
def test_multiple_frames_reassemble():
tx_strings = ["ab" * 100, "cd" * 100] # 600 chars -> multiple frames
_payload, total, parsed = _frames(tx_strings, CHUNK_PRESETS[0][1])
assert total > 1
decoded = decode_transfer(assemble(parsed, total), compressed=False)
assert decoded == tx_strings
def test_size_greater_than_payload():
tx_strings = ["12" * 5]
payload = encode_transfer(tx_strings)
frames = split_frames(payload, 1800)
assert len(frames) == 1
_t, _i, _c, p = parse_frame(frames[0])
assert p == payload
def test_exact_single_frame_boundary():
# A 139-byte payload exactly fills the 150-byte preset budget (the 11-char
# compact header plus payload), so the encoded frame is exactly 150.
tx_strings = ["a" * 139]
payload = encode_transfer(tx_strings)
frames = split_frames(payload, 150)
assert len(frames) == 1
assert len(frames[0]) == 150
_t, _i, _c, p = parse_frame(frames[0])
assert p == payload
def test_frames_fit_chunk_size():
tx_strings = ["".join("{:02x}".format(i) * 2) for i in range(300)]
payload = encode_transfer(tx_strings)
for _label, size in CHUNK_PRESETS:
for frame in split_frames(payload, size):
assert len(frame) <= size, (size, len(frame))
def test_single_tx_larger_than_chunk():
# A huge serialized tx must be split over several frames and reassemble
# exactly (positional slicing is safe for hex/base64 text).
tx_strings = ["7b" * 1000] # 2000 chars
payload = encode_transfer(tx_strings)
frames = split_frames(payload, 150)
assert len(frames) > 1
parsed = {parse_frame(f)[1]: parse_frame(f)[3] for f in frames}
total = parse_frame(frames[0])[0]
assert assemble(parsed, total) == payload
def test_compressed_frames_carry_flag():
tx_strings = ["ab" * 40]
frames = split_frames(encode_transfer(tx_strings, compress=True), 150, compressed=True)
for frame in frames:
_t, _i, compressed, _p = parse_frame(frame)
assert compressed is True
# Plain frames do not.
frames_plain = split_frames(encode_transfer(tx_strings), 150)
_t, _i, compressed, _p = parse_frame(frames_plain[0])
assert compressed is False
def test_compressed_roundtrip_through_frames():
tx_strings = ["ab" * 50, "cd" * 50, "12" * 60]
payload = encode_transfer(tx_strings, compress=True)
parsed = {}
total = None
for frame in split_frames(payload, 400, compressed=True):
t, index, _c, p = parse_frame(frame)
total = t
parsed[index] = p
assert total is not None
decoded = decode_transfer(assemble(parsed, total), compressed=True)
assert decoded == tx_strings
# --------------------------------------------------------------------------- #
# Compact v2 wire format ("BAL1")
# --------------------------------------------------------------------------- #
def test_v2_frame_header_structure():
frames = split_frames(encode_transfer(["11" * 10]), 150)
assert len(frames) == 1
frame = frames[0]
assert frame.startswith("BAL1")
# Fixed 11-char header: magic + 3-char total + 3-char index + 1 flag.
assert len(frame) > 11
magic, total_s, index_s, flag, payload = (
frame[:4],
frame[4:7],
frame[7:10],
frame[10],
frame[11:],
)
assert magic == "BAL1"
assert total_s == "001"
assert index_s == "001"
assert flag == "0"
assert payload == "11" * 10
total, index, compressed, p = parse_frame(frame)
assert (total, index, compressed) == (1, 1, False)
assert p == payload
def test_v2_compressed_flag_is_z():
frames = split_frames(
encode_transfer(["11" * 10], compress=True), 150, compressed=True
)
assert frames[0][10] == "Z"
_t, _i, compressed, _p = parse_frame(frames[0])
assert compressed is True
def test_v2_header_fixed_width_high_counts():
# A long transfer needs multi-digit counts; the v2 header stays exactly
# 11 chars no matter how many frames (3-char base36 zero-padded counts).
tx_strings = ["ab" * 300] # 600 chars -> several frames at 150
frames = split_frames(encode_transfer(tx_strings), 150)
assert len(frames) > 1
for frame in frames:
# magic(4) + total(3) + index(3) + flag(1) = 11 chars, then payload.
assert len(frame) - len(frame[11:]) == 11
def test_v2_max_frame_count():
# A transfer needing more than 46655 frames must be rejected (3-char
# base36 count fields cannot represent larger totals).
from bal.core.qrtransfer import _MAX_TOTAL
oversized = "A" * (_MAX_TOTAL * (150 - 11) + 1)
try:
split_frames(oversized, 150)
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError above the frame cap")
def test_v2_boundary_at_max_count():
from bal.core.qrtransfer import _MAX_TOTAL
# Exactly at the cap: must still produce (bounded) frames with 3-char
# counts "VVV" (46655) for the highest serialised part.
payload = "B" * (_MAX_TOTAL * (150 - 11))
frames = split_frames(payload, 150)
assert len(frames) == _MAX_TOTAL
total, index, _c, _p = parse_frame(frames[-1])
assert total == _MAX_TOTAL
assert index == _MAX_TOTAL
assert frames[-1][:10] == "BAL1" + "ZZZ" + "ZZZ"
def test_encode_transfer_best():
from bal.core.qrtransfer import encode_transfer_best
# Redundant JSON-ish text compresses -> compressed (and longer source
# must round-trip unchanged).
txs = ['{"a": "%s"}' % ("x" * 300), '{"b": "%s"}' % ("y" * 300)]
transfer, compressed = encode_transfer_best(txs)
assert compressed is True
assert decode_transfer(transfer, compressed) == txs
# Already-compact input stays plain (never larger than the source).
txs_small = ["ab", "cd"]
transfer, compressed = encode_transfer_best(txs_small)
assert compressed is False
assert decode_transfer(transfer, compressed) == txs_small
def test_v2_malformed_frames():
bad = (
"BAL1", # header only, no fields
"BAL1" + "001", # truncated
"BAL1" + "G-1" + "001" + "Z" + "p", # non-base36 total
"BAL1" + "001" + "G-1" + "Z" + "p", # non-base36 index
"BAL1" + "000" + "001" + "Z" + "p", # total 0
"BAL1" + "001" + "000" + "Z" + "p", # index 0
"BAL1" + "001" + "002" + "Z" + "p", # index beyond total
"BAL1" + "001" + "001" + "Q" + "p", # unknown flag
)
for frame in bad:
try:
parse_frame(frame)
except QrTransferError:
continue
raise AssertionError("expected QrTransferError for: {!r}".format(frame))
# --------------------------------------------------------------------------- #
# Malformed input
# --------------------------------------------------------------------------- #
def test_parse_bad_magic_and_version():
for frame in (
"BALQR|1|1||a", # missing version
"BALQR2|1|1||a", # unknown version
"XXXXX1|1|1||a", # unknown magic
):
try:
parse_frame(frame)
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError for: {}".format(frame))
def test_parse_bad_arity():
for frame in ("BALQR1", "BALQR1|1|1|"):
try:
parse_frame(frame)
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError for: {}".format(frame))
def test_parse_pipe_in_payload_is_folded():
# maxsplit keeps the tail (including any inner '|') in the payload part.
frame = "BALQR1|1|1||a|b|c"
total, index, compressed, payload = parse_frame(frame)
assert (total, index, compressed) == (1, 1, False)
assert payload == "a|b|c"
def test_parse_bad_numbers():
for frame in (
"BALQR1|x|1||a",
"BALQR1|1|y||a",
"BALQR1|0|1||a",
"BALQR1|1|0||a",
"BALQR1|1|2||a", # index beyond total
"BALQR1|-1|1||a",
):
try:
parse_frame(frame)
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError for: {}".format(frame))
def test_parse_bad_flags():
try:
parse_frame("BALQR1|1|1|Q|payload")
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError for unknown flags")
def test_assemble_missing_frames():
try:
assemble({1: "a", 3: "c"}, total=3)
except MissingFramesError as e:
assert e.missing == [2]
else:
raise AssertionError("expected MissingFramesError")
def test_assemble_index_beyond_total():
try:
assemble({1: "a", 2: "b"}, total=1)
except InconsistentTotalError:
pass
else:
raise AssertionError("expected InconsistentTotalError")
def test_assemble_order_and_total_validation():
assert assemble({1: "a", 2: "b"}, total=2) == "ab"
try:
assemble({}, total=0)
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError")
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Constants / presets # Constants / presets
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -379,46 +61,6 @@ def test_preset_index_for_chunk_size():
assert preset_index_for_chunk_size(1800) == 3 assert preset_index_for_chunk_size(1800) == 3
def test_min_chunk_size_guard():
try:
split_frames("x" * 10, MIN_CHUNK_SIZE - 1)
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError for tiny chunk size")
def test_split_frame_headers_consistent():
tx_strings = ["ab" * 80]
payload = encode_transfer(tx_strings)
frames = split_frames(payload, 150)
totals = {parse_frame(frame)[0] for frame in frames}
assert len(totals) == 1
assert totals.pop() == len(frames)
# --------------------------------------------------------------------------- #
# Optional: cross-check presets against the qrcode library (EC level M)
# --------------------------------------------------------------------------- #
def test_presets_fit_qrcode_ec_m():
"""Every preset budget must render inside a QR at EC level M."""
try:
import qrcode
from qrcode.constants import ERROR_CORRECT_M
except ImportError:
print("qrcode not installed - skipping capacity check")
return
for _label, size in CHUNK_PRESETS:
# Worst-case frame: header with the largest plausible total/index plus
# a full payload of the preset budget.
frame = "BALQR1|9999|9999|Z|" + "a" * (size - 14)
qr = qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2)
qr.add_data(frame)
qr.get_matrix() # raises DataOverflowError if it does not fit
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
if __name__ == "__main__": if __name__ == "__main__":
import traceback import traceback

View File

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

@@ -231,7 +231,7 @@ def test_shortened_relative_recipe_on_signed_rebuilds_not_noheirs():
will = _make_will_with_heirs(will_heirs, lt) will = _make_will_with_heirs(will_heirs, lt)
will["willid_1"].set_status("COMPLETE", True) will["willid_1"].set_status("COMPLETE", True)
check_date = resolve_date_to_check( check_date = resolve_date_to_check(
False, {"locktime": "2y", "threshold": "150d"}, built_locktime=lt False, {"locktime": "2y", "threshold": "-150d"}, built_locktime=lt
) )
assert check_date < lt # the anchored window really precedes the delivery assert check_date < lt # the anchored window really precedes the delivery
raised = None raised = None

View File

@@ -47,6 +47,24 @@ _VALID_TX_HEX = (
# The prevout string that _VALID_TX_HEX spends (input 0). # The prevout string that _VALID_TX_HEX spends (input 0).
_PREVOUT_STR = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0" _PREVOUT_STR = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0"
# A second transaction identical to _VALID_TX_HEX but spending a DIFFERENT
# prevout. It stands for a superseded (REPLACED) will transaction that commits
# different coins than the current valid one.
_REPLACED_TX_HEX = "0100000001ff" + _VALID_TX_HEX[len("01000000012a"):]
_REPLACED_PREVOUT_STR = _PREVOUT_STR[:-4] + "ff:0"
# A 2-input transaction: version 2 with two parallel inputs. Built from
# _VALID_TX_HEX by duplicating its (298-hex) input with a DIFFERENT prevout
# (first byte "ff", so the second input spends _REPLACED_PREVOUT_STR) and
# keeping the unchanged outputs.
_INPUT_LEN = 298
_TWO_INPUT_TX_HEX = (
"0100000002"
+ _VALID_TX_HEX[10:10 + _INPUT_LEN]
+ "ff" + _VALID_TX_HEX[10 + 2:10 + _INPUT_LEN]
+ _VALID_TX_HEX[10 + _INPUT_LEN:]
)
# Change address for the invalidation output. # Change address for the invalidation output.
_CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs" _CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
@@ -55,8 +73,9 @@ _CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
# Helpers # Helpers
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None): def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None,
"""Create a WillItem from _VALID_TX_HEX with a known input value. tx_hex=_VALID_TX_HEX):
"""Create a WillItem from ``tx_hex`` with a known input value.
The input's ``_trusted_value_sats`` is set so that The input's ``_trusted_value_sats`` is set so that
``invalidate_will`` can read the balance from it. ``invalidate_will`` can read the balance from it.
@@ -65,7 +84,7 @@ def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None):
if extra_heirs: if extra_heirs:
heirs.update(extra_heirs) heirs.update(extra_heirs)
item = WillItem({ item = WillItem({
"tx": _VALID_TX_HEX, "tx": tx_hex,
"heirs": heirs, "heirs": heirs,
"willexecutor": None, "willexecutor": None,
"status": "", "status": "",
@@ -93,16 +112,24 @@ def _make_utxo(prevout_str=None, value_sats=1000000, is_coinbase=False):
utxo.prevout.to_str.return_value = prevout_str utxo.prevout.to_str.return_value = prevout_str
utxo.is_coinbase_output.return_value = is_coinbase utxo.is_coinbase_output.return_value = is_coinbase
utxo.block_height = 1 utxo.block_height = 1
utxo.block_txpos = 0
utxo.value_sats.return_value = value_sats utxo.value_sats.return_value = value_sats
return utxo return utxo
def _mock_wallet(utxos, change_addr=_CHANGE_ADDR): def _mock_wallet(utxos, change_addr=_CHANGE_ADDR):
"""Create a mock wallet with the given UTXOs and change address.""" """Create a mock wallet with the given UTXOs and change address.
``network`` is left ``None`` so ``Will.invalidate_will`` uses the
``fees_per_byte`` FALLBACK instead of Electrum's live estimate: these tests
exercise the pure fee arithmetic (``fee = size * fees_per_byte``). The
policy-based rate selection has its own tests in
``tests/test_core_invalidation_fee.py``.
"""
wallet = MagicMock() wallet = MagicMock()
wallet.get_utxos.return_value = utxos wallet.get_utxos.return_value = utxos
wallet.get_change_addresses_for_new_transaction.return_value = [change_addr] wallet.get_change_addresses_for_new_transaction.return_value = [change_addr]
wallet.network = MagicMock() wallet.network = None
return wallet return wallet
@@ -292,6 +319,77 @@ class TestInvalidateWill:
second_call_value = mock_out.call_args_list[1][0][1] second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 998000 assert second_call_value == 998000
def test_replaced_item_inputs_are_invalidated(self):
"""A REPLACED (superseded) item's funding UTXO is also spent.
Regression: a will rebuilt after an heir change supersedes the old
signed transaction, which is kept but marked REPLACED (VALID cleared).
The old transaction may have been pushed to the will-executors and may
spend DIFFERENT coins than the current valid one, so its UTXO must be
included in the invalidation. Otherwise it would stay unspent and a
will-executor could still broadcast the superseded transaction.
"""
valid_item = _make_willitem(value_sats=1000000)
replaced_item = _make_willitem(
value_sats=2000000, tx_hex=_REPLACED_TX_HEX
)
replaced_item.set_status("REPLACED", True)
assert replaced_item.get_status("VALID") is False
will = {"valid": valid_item, "replaced": replaced_item}
wallet = _mock_wallet([
_make_utxo(),
_make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000),
])
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert _PREVOUT_STR in spent
assert _REPLACED_PREVOUT_STR in spent
def test_replaced_item_contributes_balance(self):
"""The balance of a REPLACED item is included in the invalidation."""
valid_item = _make_willitem(value_sats=1000000)
replaced_item = _make_willitem(
value_sats=2000000, tx_hex=_REPLACED_TX_HEX
)
replaced_item.set_status("REPLACED", True)
will = {"valid": valid_item, "replaced": replaced_item}
wallet = _mock_wallet([
_make_utxo(),
_make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000),
])
_, _, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
# Balance = 1000000 + 2000000 = 3000000, fee = 200 * 10 = 2000
second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 2998000
def test_invalidated_item_inputs_are_not_invalidated(self):
"""A purely INVALIDATED item (already neutralised) is still excluded."""
valid_item = _make_willitem(value_sats=1000000)
invalid_item = _make_willitem(
value_sats=2000000, tx_hex=_REPLACED_TX_HEX, valid=False
)
assert invalid_item.get_status("INVALIDATED") is True
will = {"valid": valid_item, "invalid": invalid_item}
wallet = _mock_wallet([
_make_utxo(),
_make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000),
])
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert _PREVOUT_STR in spent
assert _REPLACED_PREVOUT_STR not in spent
def test_first_from_io_uses_full_balance(self): def test_first_from_io_uses_full_balance(self):
"""The first from_io call uses the full balance (before fee deduction) """The first from_io call uses the full balance (before fee deduction)
to estimate the fee.""" to estimate the fee."""
@@ -353,6 +451,152 @@ class TestInvalidateWill:
second_call_value = mock_out.call_args_list[1][0][1] second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 1000000 assert second_call_value == 1000000
def test_invalidation_spends_oldest_input_per_tx(self):
"""For each will tx, only its OLDEST funding coin is spent.
A will item whose transaction has two inputs (old and new coin) must
spend only the coin that has been in the wallet longest, leaving the
newer coin unspent for the rebuilt (postponed) will.
"""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
old_utxo = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000000)
old_utxo.block_height = 1
new_utxo = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000)
new_utxo.block_height = 500 # newer than the old coin
wallet = _mock_wallet([old_utxo, new_utxo])
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert spent == [_PREVOUT_STR]
def test_invalidation_fallback_appends_next_oldest(self):
"""If the oldest coin cannot cover the fee, the next-oldest is appended.
The invalidation must not be dropped just because the single oldest
coin is small: it keeps adding will coins, oldest first, until the
balance covers the estimated fee.
"""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
old_utxo = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000)
old_utxo.block_height = 1
new_utxo = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=1000000)
new_utxo.block_height = 500
wallet = _mock_wallet([old_utxo, new_utxo])
# estimated_size (200) * fees_per_byte (10) = 2000 > balance (1000)
result, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert _PREVOUT_STR in spent
assert _REPLACED_PREVOUT_STR in spent
# balance = 1000 + 1000000, fee = 2000
final_value = mock_out.call_args[0][1]
assert final_value == 999000
def test_invalidation_treats_unconfirmed_as_newest(self):
"""An unconfirmed (unknown-height) coin is never chosen as the oldest."""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
confirmed = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000000)
confirmed.block_height = 10
unconfirmed = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000)
unconfirmed.block_height = None
wallet = _mock_wallet([unconfirmed, confirmed])
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert spent == [_PREVOUT_STR]
def test_invalidation_same_height_order_is_deterministic(self):
"""Coins at the same height are ordered by block position, not list order."""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
later = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000000)
later.block_height = 10
later.block_txpos = 5
earlier = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000)
earlier.block_height = 10
earlier.block_txpos = 1
wallet = _mock_wallet([later, earlier])
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert spent == [_REPLACED_PREVOUT_STR] # block_txpos 1 < 5 => older
def test_sub_dust_change_returns_none(self):
"""A change output below the dust limit is never created.
Regression: previously any positive change (balance - fee > 0) was
accepted, which could produce a non-standard (unrelayable) output and
leave the will un-invalidated.
"""
item = _make_willitem(value_sats=2200)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo(value_sats=2200)])
wallet.dust_threshold.return_value = 546
# fee = estimated_size (200) * fees_per_byte (10) = 2000;
# balance - fee = 200 < dust (546) -> no transaction.
result, _, _ = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is None
def test_change_equal_to_dust_is_allowed(self):
"""Change exactly at the dust threshold is accepted (>= comparison)."""
item = _make_willitem(value_sats=2546)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo(value_sats=2546)])
wallet.dust_threshold.return_value = 546
# balance - fee = 2546 - 2000 = 546 == dust -> transaction.
result, _, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is not None
assert mock_out.call_args_list[1][0][1] == 546
def test_fallback_stops_when_change_still_dust(self):
"""Coins are appended until the change clears dust; else returns None."""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
old_utxo = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000)
old_utxo.block_height = 1
new_utxo = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=1200)
new_utxo.block_height = 500
wallet = _mock_wallet([old_utxo, new_utxo])
wallet.dust_threshold.return_value = 546
# fee = 2000; total balance = 1000 + 1200 = 2200;
# change = 200 < dust (546) and no coin is left -> None.
result, _, _ = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is None
def test_fallback_succeeds_once_change_clears_dust(self):
"""Appending the next coin yields a change above dust -> transaction."""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
old_utxo = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000)
old_utxo.block_height = 1
new_utxo = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2546)
new_utxo.block_height = 500
wallet = _mock_wallet([old_utxo, new_utxo])
wallet.dust_threshold.return_value = 546
# fee = 2000; total balance = 3546; change = 1546 >= dust -> transaction.
result, _, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is not None
assert mock_out.call_args_list[-1][0][1] == 1546
# ================================================================== # # ================================================================== #
# Will.set_invalidate - status flag cascade # Will.set_invalidate - status flag cascade

View File

@@ -0,0 +1,145 @@
"""
Tests for the parent/child tree of the will transactions (``father`` links).
Bug reproduced from a real wallet (Electrum log, Check wizard):
File "bal/core/will.py", in check_invalidated
or willtree[w.father].get_status("CONFIRMED")
KeyError: 'a65a682f…'
``father`` is persisted in the wallet (``"Father"``) and was never cleared, so
after the father tx was removed from the will the child pointed to a txid that
no longer exists. ``Will.add_willtree`` now rebuilds the links from scratch and
``Will.check_invalidated`` treats a missing father as "no father".
Run (standalone or with pytest):
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 tests/test_core_willtree_father.py
"""
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum.transaction import Transaction
from bal.core.will import Will, WillItem
# 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()
# A valid serialized transaction (1 input, 1 output) reused by the core tests.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
# The input of _VALID_TX_HEX spends this outpoint (little-endian in the hex).
_FUNDING_ID = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a"
# PARENT: _VALID_TX_HEX itself. CHILD: the same tx with its input re-pointed to
# the parent's txid, so the child spends the parent's output 0.
_PARENT_ID = Transaction(_VALID_TX_HEX).txid()
_CHILD_TX_HEX = _VALID_TX_HEX.replace(
bytes.fromhex(_FUNDING_ID)[::-1].hex(), bytes.fromhex(_PARENT_ID)[::-1].hex()
)
# A txid that is not in the will (the "removed father" of the bug report).
_MISSING_ID = "a65a682f2975f0dffd9eccce9392833f227badf617fc67815674736d594d4115"
def _item(tx_hex=_VALID_TX_HEX, father=None):
"""WillItem built from ``tx_hex``, optionally with a stored father."""
data = {"tx": tx_hex, "heirs": {}, "willexecutor": None}
if father is not None:
data["Father"] = father
return WillItem(data)
def _wallet_without_adb():
"""Wallet whose check_invalidated path skips on-chain lookups."""
wallet = MagicMock()
wallet.adb = None
return wallet
def test_stale_father_is_discarded_by_add_willtree():
child = _item(father=_MISSING_ID)
will = {child._id: child}
Will.add_willtree(will)
assert child.father is None
def test_check_invalidated_does_not_crash_on_stale_father():
"""The exact failure of the bug report, now without KeyError."""
child = _item(father=_MISSING_ID)
will = {child._id: child}
Will.add_willtree(will)
Will.check_invalidated(will, [], _wallet_without_adb())
def test_check_invalidated_tolerates_missing_father_without_add_willtree():
"""Defensive path: even if the tree was not rebuilt, no KeyError."""
child = _item(father=_MISSING_ID)
will = {child._id: child}
Will.check_invalidated(will, [], _wallet_without_adb())
assert child.father == _MISSING_ID
def test_real_parent_child_link_is_built():
parent = _item()
child = _item(_CHILD_TX_HEX)
will = {_PARENT_ID: parent, child._id: child}
Will.add_willtree(will)
assert child.father == _PARENT_ID
assert [c[0] for c in parent.children] == [child._id]
assert parent.father is None
def test_stale_father_is_replaced_by_the_real_parent():
parent = _item()
child = _item(_CHILD_TX_HEX, father=_MISSING_ID)
will = {_PARENT_ID: parent, child._id: child}
Will.add_willtree(will)
assert child.father == _PARENT_ID
def test_child_of_unconfirmed_parent_is_not_checked_against_utxos():
"""Existing rule kept: a child of a VALID (not mined) parent is skipped."""
parent = _item()
child = _item(_CHILD_TX_HEX)
will = {_PARENT_ID: parent, child._id: child}
Will.add_willtree(will)
wallet = MagicMock()
# Funding not spent on-chain: nothing gets invalidated, we only observe
# which items had their inputs checked.
with patch.object(Will, "check_tx_height", return_value=-2) as height, \
patch.object(Will, "_funding_really_spent", return_value=False):
Will.check_invalidated({child._id: child, _PARENT_ID: parent}, [], wallet)
# Only the parent (a root) looked up its height; the child was skipped.
checked = [call.args[0] for call in height.call_args_list]
assert checked == [parent.tx]
def test_set_invalidate_cascades_to_children_built_by_add_willtree():
"""add_willtree builds children as a LIST: invalidation must cascade."""
parent = _item()
child = _item(_CHILD_TX_HEX)
will = {parent._id: parent, child._id: child}
Will.add_willtree(will)
Will.set_invalidate(parent._id, will)
assert parent.get_status("INVALIDATED") is True
assert child.get_status("INVALIDATED") is True
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All will-tree father tests passed")

View File

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

View File

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

View File

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

View File

@@ -386,7 +386,9 @@ def test_insufficient_funds_warns():
assert not will, "no wallet funds must not build a will" assert not will, "no wallet funds must not build a will"
assert ctl.window.warnings, "user must be warned about the balance" assert ctl.window.warnings, "user must be warned about the balance"
assert any("adjustment" in w.lower() for w in ctl.window.warnings) # The wording was shortened (task #67): the "adjustment of the amounts"
# sentence was dropped, the "fully emptied" note stays.
assert any("fully emptied" in w.lower() for w in ctl.window.warnings)
assert not ctl.willitems assert not ctl.willitems
@@ -412,7 +414,7 @@ def test_guard_not_blocked_by_old_built_will():
# Freeze the built (VALID) will at a delivery one year longer than the # Freeze the built (VALID) will at a delivery one year longer than the
# now-shortened settings: the pre-fix guard would reject the rebuild. # now-shortened settings: the pre-fix guard would reject the rebuild.
item.tx.locktime = item.tx.locktime + 365 * 86400 item.tx.locktime = item.tx.locktime + 365 * 86400
ctl.will_settings = {"locktime": "1y", "threshold": "30d"} ctl.will_settings = {"locktime": "1y", "threshold": "-30d"}
Util.fix_will_settings_tx_fees(ctl.will_settings) Util.fix_will_settings_tx_fees(ctl.will_settings)
ctl.init_class_variables() ctl.init_class_variables()
@@ -446,7 +448,7 @@ def test_anticipated_rebuild_reanchors_date_to_check():
# Per-heir deliveries require multiverse mode (the only way heirs can # Per-heir deliveries require multiverse mode (the only way heirs can
# carry a different recipe than the settings locktime). # carry a different recipe than the settings locktime).
ctl.bal_plugin.ENABLE_MULTIVERSE.set(True) ctl.bal_plugin.ENABLE_MULTIVERSE.set(True)
ctl.will_settings = {"locktime": "2y", "threshold": "150d", "baltx_fees": 20} ctl.will_settings = {"locktime": "2y", "threshold": "-150d", "baltx_fees": 20}
Util.fix_will_settings_tx_fees(ctl.will_settings) Util.fix_will_settings_tx_fees(ctl.will_settings)
ctl.heirs["alice"][2] = "2y" ctl.heirs["alice"][2] = "2y"
ctl.heirs["bob"][2] = "2y" ctl.heirs["bob"][2] = "2y"

View File

@@ -0,0 +1,208 @@
"""
Tests for the "Invalidation fee policy" settings-dialog row (ADVANCED only).
Covers:
- The row is hidden in BASIC mode and visible in ADVANCED mode (like the
other advanced-only rows).
- The Electrum fee widget (``FeeSlider`` + ``FeeComboBox``) is bound to the
``INVALIDATION_FEE_POLICY`` config: choosing a Feerate / ETA / Mempool
policy persists its descriptor (``"<method>:<value>"``).
- "Reset to Default Setting" restores the policy to its default ("eta:2").
Run:
source "$BAL_HOME/electrum/env/bin/activate"
QT_QPA_PLATFORM=offscreen python3 tests/test_settings_invalidation_fee.py
"""
import sys
import tempfile
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from unittest.mock import patch
from PyQt6.QtWidgets import QApplication, QComboBox, QLabel, QPushButton
_app = QApplication.instance() or QApplication(sys.argv)
from electrum.fee_policy import FeeMethod, FeePolicy
from electrum.gui.qt.fee_slider import FeeComboBox, FeeSlider
from electrum.simple_config import SimpleConfig
import bal.gui.qt.plugin as plugin_mod
from bal.gui.qt.plugin import Plugin
LABEL_TEXT = "Invalidation fee policy"
def _isolated_config(**overrides):
"""An in-memory SimpleConfig that never touches the real Electrum config."""
opts = {"electrum_path": tempfile.mkdtemp(prefix="bal_test_")}
opts.update(overrides)
return SimpleConfig(opts)
def _build_dialog(user_type, config=None, **config_overrides):
"""Build the plugin settings dialog for *user_type* and return it.
*config* (optional) lets a test pass a pre-populated SimpleConfig; overrides
are used to build one otherwise. Note that overrides become command-line
options, which ``set()`` refuses to modify - use *config* when the test needs
a mutable stored value.
"""
cfg = config if config is not None else _isolated_config(**config_overrides)
plugin = Plugin(None, cfg, "bal")
plugin.get_window_title = lambda s: s
plugin.read_file = lambda *a: b""
plugin.broadcast_transactions = lambda *a, **k: None
plugin.update_all = lambda *a, **k: None
plugin.USER_TYPE.set(user_type)
captured = {}
with patch.object(
plugin_mod, "show_modal", lambda d: captured.setdefault("d", d) or True
):
plugin.settings_dialog(None)
return plugin, captured["d"]
def _find_label(dialog):
"""Return the "Invalidation fee policy" QLabel (wrapped by _hide_if_basic)."""
for lbl in dialog.findChildren(QLabel):
if lbl.text() == LABEL_TEXT:
return lbl
raise AssertionError("invalidation fee policy label not found")
# ------------------------------------------------------------------ #
# Visibility
# ------------------------------------------------------------------ #
def test_row_hidden_in_basic_and_visible_in_advanced():
_plugin, basic = _build_dialog("basic")
assert _find_label(basic).isHidden() is True
basic.close()
_plugin, advanced = _build_dialog("advanced")
assert _find_label(advanced).isHidden() is False
advanced.close()
def _user_type_combo(dialog):
"""Return the BASIC/ADVANCED selector combo of the settings dialog."""
for combo in dialog.findChildren(QComboBox):
if combo.itemText(0) == "BASIC" and combo.itemText(1) == "ADVANCED":
return combo
raise AssertionError("user type combo not found")
def test_row_follows_live_mode_switch():
"""The row must show/hide when the mode is toggled at runtime (not only
the initial creation-time visibility)."""
# BASIC -> ADVANCED (with the confirmation phrase).
plugin, dialog = _build_dialog("basic")
combo = _user_type_combo(dialog)
with patch(
"PyQt6.QtWidgets.QInputDialog.getText",
return_value=("at My Risk", True),
):
combo.setCurrentIndex(1)
assert plugin.USER_TYPE.get() == "advanced"
assert _find_label(dialog).isHidden() is False
dialog.close()
# ADVANCED -> BASIC (no confirmation needed).
plugin, dialog = _build_dialog("advanced")
combo = _user_type_combo(dialog)
combo.setCurrentIndex(0)
assert plugin.USER_TYPE.get() == "basic"
assert _find_label(dialog).isHidden() is True
dialog.close()
# ------------------------------------------------------------------ #
# Persistence
# ------------------------------------------------------------------ #
def test_default_policy_is_eta_two():
plugin, dialog = _build_dialog("advanced")
assert plugin.INVALIDATION_FEE_POLICY.default == "eta:2"
assert plugin.INVALIDATION_FEE_POLICY.get() == "eta:2"
dialog.close()
def test_hand_edited_policy_is_not_rewritten_on_open():
"""Opening the dialog must not normalize (snap/persist) a hand-edited
descriptor; only an explicit user change should do that."""
# "eta:3" is not a slider target (FEE_ETA_TARGETS = [1008, 144, 25, 10,
# 5, 2, 1]), so a naive slider.update() would snap it to "eta:2". The value
# is written to the isolated config (not passed as an override, which would
# become a non-modifiable command-line option and hide the bug).
config = _isolated_config()
config.set_key("bal_invalidation_fee_policy", "eta:3")
plugin, dialog = _build_dialog("advanced", config=config)
assert plugin.INVALIDATION_FEE_POLICY.get() == "eta:3"
dialog.close()
def test_choosing_a_fixed_feerate_is_persisted():
plugin, dialog = _build_dialog("advanced")
combo = dialog.findChildren(FeeComboBox)[0]
slider = dialog.findChildren(FeeSlider)[0]
combo.setCurrentIndex(FeeMethod.slider_index_of_method(FeeMethod.FEERATE))
slider.fee_policy.value = 5000 # sat/kvB
slider.update()
assert plugin.INVALIDATION_FEE_POLICY.get() == "feerate:5000"
dialog.close()
def test_choosing_an_eta_target_is_persisted():
plugin, dialog = _build_dialog("advanced")
combo = dialog.findChildren(FeeComboBox)[0]
slider = dialog.findChildren(FeeSlider)[0]
combo.setCurrentIndex(FeeMethod.slider_index_of_method(FeeMethod.ETA))
slider.fee_policy.value = 10 # blocks (valid FixedTargets entry)
slider.update()
assert plugin.INVALIDATION_FEE_POLICY.get() == "eta:10"
dialog.close()
# ------------------------------------------------------------------ #
# Reset to default
# ------------------------------------------------------------------ #
def test_reset_to_default_restores_eta_two():
plugin, dialog = _build_dialog("advanced")
combo = dialog.findChildren(FeeComboBox)[0]
slider = dialog.findChildren(FeeSlider)[0]
combo.setCurrentIndex(FeeMethod.slider_index_of_method(FeeMethod.FEERATE))
slider.fee_policy.value = 5000
slider.update()
assert plugin.INVALIDATION_FEE_POLICY.get() == "feerate:5000"
reset_btn = [
b for b in dialog.findChildren(QPushButton)
if b.text() == "Reset to Default Setting"
][0]
reset_btn.click()
assert plugin.INVALIDATION_FEE_POLICY.get() == "eta:2"
assert slider.fee_policy.get_descriptor() == "eta:2"
assert FeePolicy("eta:2").method == FeeMethod.ETA
dialog.close()
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All invalidation fee settings tests passed")

View File

@@ -1,10 +1,11 @@
""" """
Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs``. Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs`` and the
"Building your will" row of ``BalBuildWillDialog._build_success_report``.
This is the post-build sync that keeps the plugin's stored delivery date ``_sync_locktime_to_built_txs`` is the post-build sync that keeps the plugin's
(WILL_SETTINGS["locktime"]) in lockstep with the BUILT transactions' fixed stored delivery date (WILL_SETTINGS["locktime"]) in lockstep with the BUILT
locktime when the core AUTOMATICALLY anticipates it (one day earlier than transactions' fixed locktime when the core AUTOMATICALLY anticipates it (one
stored). day earlier than stored).
RELATIVE recipes ("90d" / "1y") are now PRESERVED: the daily-drift problem 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 that once forced freezing them to absolute timestamps is solved at the root by
@@ -14,8 +15,14 @@ anchoring every relative recipe against the built transactions
timestamp). Only a genuine automatic anticipation on an ABSOLUTE stored date timestamp). Only a genuine automatic anticipation on an ABSOLUTE stored date
moves the stored value. moves the stored value.
The method is exercised with a lightweight fake ``self`` (no Qt event loop, no WHY THE FLAG IS NOT SET HERE ANY MORE: because a relative recipe is anchored to
Electrum wallet) by calling it as an unbound method. the built tx, a stored-vs-built comparison cannot detect the anticipation for
it. ``_build_success_report`` therefore detects it by comparing the will's
minimum locktime BEFORE the rebuild with the one AFTER it (see there), which
works for both absolute and relative recipes.
The methods are exercised with a lightweight fake ``self`` (no Qt event loop,
no Electrum wallet) by calling them as unbound methods.
Run: Run:
source "$BAL_HOME/electrum/env/bin/activate" source "$BAL_HOME/electrum/env/bin/activate"
@@ -35,10 +42,12 @@ from bal.gui.qt.dialogs import BalBuildWillDialog # noqa: E402 (path insert ab
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _fake_willitem(tx_locktime): def _fake_willitem(tx_locktime):
"""Minimal stand-in for a WillItem: enough for Will.get_min_locktime.""" """Minimal stand-in for a WillItem: enough for Will.get_min_locktime and
the heir listing in _build_success_report."""
return SimpleNamespace( return SimpleNamespace(
tx=SimpleNamespace(locktime=tx_locktime), tx=SimpleNamespace(locktime=tx_locktime),
get_status=lambda name: True, get_status=lambda name: True,
heirs={},
) )
@@ -57,7 +66,6 @@ def _make_dialog(will_settings, tx_locktimes, recorded):
will_settings=will_settings, will_settings=will_settings,
update_setting_widgets=update_setting_widgets, update_setting_widgets=update_setting_widgets,
), ),
_date_was_anticipated=False,
) )
@@ -83,7 +91,6 @@ def test_relative_locktime_preserved():
assert fake.bal_window.will_settings["locktime"] == "90d" assert fake.bal_window.will_settings["locktime"] == "90d"
assert fake.bal_window.will_settings["threshold"] == "30d" assert fake.bal_window.will_settings["threshold"] == "30d"
assert recorded == [], "a relative recipe must never be rewritten" assert recorded == [], "a relative recipe must never be rewritten"
assert fake._date_was_anticipated is False
def test_relative_threshold_preserved(): def test_relative_threshold_preserved():
@@ -108,12 +115,13 @@ def test_absolute_locktime_unchanged_on_equal():
recorded, recorded,
) )
assert fake.bal_window.will_settings["locktime"] == tx_locktime assert fake.bal_window.will_settings["locktime"] == tx_locktime
assert fake._date_was_anticipated is False assert fake.bal_window.will_settings["threshold"] == 1_700_000_000
def test_anticipation_sets_flag_and_moves_earlier(): def test_anticipation_moves_stored_date_earlier():
"""A real automatic anticipation of an ABSOLUTE stored date (built earlier """A real automatic anticipation of an ABSOLUTE stored date (built earlier
than stored) still moves the date earlier and flags the sign prompt.""" than stored) moves the stored date earlier so the next Check does not
mistake it for a postpone."""
tx_locktime = 1_700_000_000 tx_locktime = 1_700_000_000
recorded = [] recorded = []
fake = _call_sync( fake = _call_sync(
@@ -122,7 +130,6 @@ def test_anticipation_sets_flag_and_moves_earlier():
recorded, recorded,
) )
assert fake.bal_window.will_settings["locktime"] == tx_locktime assert fake.bal_window.will_settings["locktime"] == tx_locktime
assert fake._date_was_anticipated is True
def test_stored_earlier_than_built_never_moved_later(): def test_stored_earlier_than_built_never_moved_later():
@@ -137,7 +144,6 @@ def test_stored_earlier_than_built_never_moved_later():
recorded, recorded,
) )
assert fake.bal_window.will_settings["locktime"] == stored assert fake.bal_window.will_settings["locktime"] == stored
assert fake._date_was_anticipated is False
def test_multiple_txs_uses_minimum_locktime(): def test_multiple_txs_uses_minimum_locktime():
@@ -182,6 +188,76 @@ def test_relative_locktime_stays_coherent_via_anchor():
assert drifted > tx_locktime assert drifted > tx_locktime
# ------------------------------------------------------------------ #
# _build_success_report: the "Building your will" row
# ------------------------------------------------------------------ #
def _make_report_dialog(new_locktimes, previous_min_locktime):
"""Fake ``self`` for _build_success_report (no Qt event loop).
``_sync_locktime_to_built_txs`` is stubbed (it is exercised separately), so
the test drives the AFTER-build locktimes and the captured BEFORE-build
locktime directly; the message helpers capture what would be written on the
"Building your will" row.
"""
captured = {}
def _set_building(status=None, row=None, color=None):
captured["build_status"] = status
fake = SimpleNamespace(
bal_window=SimpleNamespace(
willitems={
f"tx{i}": _fake_willitem(lt)
for i, lt in enumerate(new_locktimes)
},
wallet=SimpleNamespace(set_label=lambda *a, **k: None),
),
_previous_min_locktime=previous_min_locktime,
_sync_locktime_to_built_txs=lambda: None,
msg_set_building=_set_building,
msg_set_status=lambda *a, **k: None,
msg_alert=lambda e: "ALERT:{}".format(e),
msg_ok=lambda e=None: "OK",
)
return fake, captured
def test_build_report_shows_anticipated_warning():
"""When the rebuild moved the delivery one day earlier than the previous
will, the "Building your will" row shows a warning naming the NEW delivery
date instead of a plain "Ok"."""
previous = 1_800_000_000 # 2027-01-15 (UTC)
rebuilt = previous - 86_400 # anticipated one day earlier
fake, captured = _make_report_dialog([rebuilt], previous)
BalBuildWillDialog._build_success_report(fake)
assert fake._date_was_anticipated is True
assert fake._anticipated_locktime == rebuilt
assert captured["build_status"] == (
"ALERT:Anticipated - new delivery date: 2027-01-14"
)
def test_build_report_shows_ok_when_not_anticipated():
"""Without anticipation the row keeps the plain "Ok" and the state stays
clean."""
previous = 1_800_000_000
fake, captured = _make_report_dialog([previous], previous)
BalBuildWillDialog._build_success_report(fake)
assert fake._date_was_anticipated is False
assert fake._anticipated_locktime is None
assert captured["build_status"] == "OK"
def test_build_report_first_build_is_not_anticipated():
"""A first build (no previous will) is never flagged as anticipated."""
fake, captured = _make_report_dialog([1_800_000_000], None)
BalBuildWillDialog._build_success_report(fake)
assert fake._date_was_anticipated is False
assert fake._anticipated_locktime is None
assert captured["build_status"] == "OK"
if __name__ == "__main__": if __name__ == "__main__":
for name in sorted(dir()): for name in sorted(dir()):
if name.startswith("test_"): if name.startswith("test_"):