9 Commits

Author SHA1 Message Date
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
71 changed files with 4040 additions and 6989 deletions

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

@@ -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,167 @@ 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).

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
single part, seq_len=2 (12 frames) and seq_len=7 (3 sampled mixes),
validating CBOR framing, bytewords, alias+ary-threshold sampling,
xoshiro256** and the XOR mix.
- **Sessions:** UR v2 single-part (no seq header), out-of-order frames,
duplicate drops, solve with a missing pure fragment (a second redundant
mixed wave is emitted by `ur2_frames`), UR v1 single-part
(digest-less `ur:bytes/<bc32>` accepted) and multipart, BBQR full-frame
decode in any order for Z/2/H encodings.
- **Safety:** `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`,
`TransferConflictError` on a frame from a different transfer, `TransferConflictError` on a frame from a different transfer,
`SessionLimitError`, BBQR zlib-bomb guard, UTF-8 payloads only. `SessionLimitError`, BBQR zlib-bomb guard, UTF-8 payloads only.
- **GUI:** `BalQrExportWidget` gained a Format selector (BAL QR default, - **GUI:** `BalQrExportWidget` emits BBQR frames using the QR-size presets;
BC-UR v1, BC-UR v2, BBQR) reusing the QR-size presets; the importer now the importer routes every frame through `detect_format` +
routes every frame through `detect_format` +
`AnimatedQrSession.add_part` with the shared `AnimatedQrSession.add_part` with the shared
`qr_import_accept_frame(state, fmt, session_key, frame_total, index, `qr_import_accept_frame(state, fmt, session_key, frame_total, index,
payload, stable_reads=2)` debounce (reset on session-key change). payload, stable_reads=2)` debounce (reset on session-key change).
`_review_and_sign` resolves the session to the transfer text and decodes `_review_and_sign` resolves the session to the transfer text and decodes
parts uniformly across formats. parts uniformly.
- **Verification:** `tests/test_core_animated_qr.py` (32 tests incl. the - **Verification:** `tests/test_core_animated_qr.py` (BBQR round-trips,
C++-reference parity vectors and BC32 spec vectors) and the extended encoding fallback, zlib-bomb guard, detection) and the extended
`tests/test_gui_qr_transfer.py` pass; ruff clean on the new/changed `tests/test_gui_qr_transfer.py` pass.
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

@@ -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
@@ -106,6 +108,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(
_( _(
@@ -272,13 +280,21 @@ class BalController:
self.plugin.is_basic_mode(), self.plugin.is_basic_mode(),
self.will_settings, self.will_settings,
built_locktime=Will.get_min_locktime(self.willitems), built_locktime=Will.get_min_locktime(self.willitems),
creation_date=Will.get_creation_time(self.willitems),
) )
self.no_willexecutor = bool(self.plugin.NO_WILLEXECUTOR.get()) self.no_willexecutor = bool(self.plugin.NO_WILLEXECUTOR.get())
self.willexecutors = Willexecutors.get_willexecutors( self.willexecutors = Willexecutors.get_willexecutors(
self.plugin, update=True, task=False self.plugin, update=True, task=False
) )
if check_alive_expired(self.plugin.is_basic_mode(), self.date_to_check): delivery_date = resolve_delivery_date(
raise CheckAliveError(self.date_to_check) self.will_settings,
built_locktime=Will.get_min_locktime(self.willitems),
)
ensure_check_alive_valid(
self.plugin.is_basic_mode(),
self.date_to_check,
delivery_date=delivery_date,
)
self._init_heirs_to_locktime(self.plugin.ENABLE_MULTIVERSE.get()) self._init_heirs_to_locktime(self.plugin.ENABLE_MULTIVERSE.get())
def _init_heirs_to_locktime(self, multiverse=False): def _init_heirs_to_locktime(self, multiverse=False):
@@ -369,7 +385,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)
@@ -458,7 +476,9 @@ class BalController:
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 +603,11 @@ class BalController:
except will_mod.NoHeirsException as e: except will_mod.NoHeirsException as e:
_logger.info(f"auto rebuild: no heirs ({e})") _logger.info(f"auto rebuild: no heirs ({e})")
return {"result": "no_heirs"} return {"result": "no_heirs"}
except CheckAliveAfterDeliveryError:
_logger.info(
"auto rebuild: Check Alive not before the delivery, nothing to do"
)
return {"result": "check_alive_before_delivery"}
except CheckAliveError: except CheckAliveError:
_logger.info("auto rebuild: Check Alive already in the past") _logger.info("auto rebuild: Check Alive already in the past")
return self._auto_rebuild_invalidate("threshold_passed") return self._auto_rebuild_invalidate("threshold_passed")

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.
# Resolving it FORWARD from now (BalTimestamp.to_timestamp) turns
# date_to_check into a moving target that disagrees with the fixed
# locktime of the built transactions (and with the date shown in the UI),
# which can wrongly mark the will as expired/postponed. Resolve it
# against the delivery date instead.
if threshold.unit is not None:
locktime_raw = will_settings.get("locktime")
if locktime_raw is None:
# No delivery reference to anchor to: fall back to the legacy
# forward-from-now resolution.
return threshold.to_timestamp()
locktime_dt = BalTimestamp(locktime_raw).to_date(now)
# A RELATIVE stored locktime ("2y") is itself a moving target; when a
# will has already been built, its frozen delivery date (the tx
# locktime) is the authoritative anchor (see docstring).
if BalTimestamp(locktime_raw).unit is not None and built_locktime:
locktime_dt = BalTimestamp(int(built_locktime)).to_date(now)
return threshold.to_date(locktime_dt, reverse=True).timestamp()
return threshold.to_timestamp() return threshold.to_timestamp()
delivery_date = resolve_delivery_date(
will_settings, now=now, built_locktime=built_locktime
)
return resolve_relative_check_alive_date(
will_settings["threshold"],
delivery_date,
creation_date=creation_date,
now=now,
)
def 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

@@ -305,11 +305,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 +497,35 @@ class BalPlugin(BasePlugin):
@staticmethod @staticmethod
def default_will_settings_absolute(): def default_will_settings_absolute():
"""Convert the default relative dates into absolute timestamps (from today).""" """Convert the default relative dates into absolute timestamps.
The Check Alive anchor follows its sign (see ``bal.core.checkalive``):
a negative default counts back from the delivery, a positive one counts
forward from today (the inheritance creation date).
"""
relative_dates = BalPlugin.default_will_settings_relative() relative_dates = BalPlugin.default_will_settings_relative()
today = datetime.now(tz=timezone.utc).date() today = datetime.now(tz=timezone.utc).date()
dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc) dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc)
threshold = ( locktime_dt = dt + timedelta(
dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days()) days=BalTimestamp(relative_dates["locktime"]).duration_to_days()
).timestamp() )
locktime = ( threshold = BalTimestamp(relative_dates["threshold"])
dt + timedelta(days=BalTimestamp(relative_dates["locktime"]).duration_to_days()) if threshold.unit is not None and threshold.value < 0:
).timestamp() anchor_dt = locktime_dt
return {"threshold": threshold, "locktime": locktime} else:
anchor_dt = dt
threshold_dt = anchor_dt + timedelta(days=threshold.duration_to_days())
return {"threshold": threshold_dt.timestamp(), "locktime": locktime_dt.timestamp()}
@staticmethod @staticmethod
def default_will_settings_relative(): def default_will_settings_relative():
"""Default relative dates: 30 days threshold, 1 year locktime.""" """Default relative dates: 30 days BEFORE the delivery, 1 year locktime.
return {"threshold": "30d", "locktime": "1y"}
The Check Alive default is NEGATIVE on purpose: it keeps counting back
from the delivery date, preserving the historical "check alive 30 days
before delivery" behaviour.
"""
return {"threshold": "-30d", "locktime": "1y"}
class BalTimestamp: class BalTimestamp:

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

@@ -972,6 +972,25 @@ class Will:
def get_min_locktime(will,default_value=None): def get_min_locktime(will,default_value=None):
return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value) return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value)
@staticmethod
def get_creation_time(will):
"""Return the inheritance creation timestamp (oldest VALID will item).
A positive relative Check Alive value counts forward from this date.
The per-item ``time`` is stamped when the will is built and persisted
in the wallet DB, so the anchor does not drift with the clock. Only
VALID items are considered; imported items without a ``time`` are
ignored. Returns ``None`` when no VALID item carries a timestamp.
"""
return min(
(
v.time
for v in will.values()
if v.get_status('VALID') and v.time is not None
),
default=None,
)
@staticmethod @staticmethod
@@ -1345,6 +1364,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 +1476,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
@@ -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"):
@@ -754,6 +730,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(
@@ -1201,10 +1184,11 @@ class BalBuildWillDialog(BalDialog):
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 +1537,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:
@@ -1844,9 +1840,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 +2735,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 +2804,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 +2818,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,25 +2861,6 @@ class BalQrExportWidget(QWidget):
self._next() self._next()
def _update_intro(self): def _update_intro(self):
self.format_hint.setText(
{
"balqr": _(
"Proprietary format; every other BAL wallet understands it."
),
"ur1": _("Legacy BC-UR v1 (ur:bytes), compatible with older "
"Blockchain Commons tools."),
"ur2": _("Standard BC-UR v2 fountain codes (ur:bytes)."),
"bbqr": _("Coinkite BBQR (B$ frames) for BitKit & friends."),
}[self.format]
)
if self.format == "balqr":
self.intro_label.setText(
_(
"Scan the QR codes below, in order, with the will-opening "
"device.\nFrame 1 of {} carries the total number of codes."
).format(self.total_frames)
)
else:
self.intro_label.setText( self.intro_label.setText(
_( _(
"Scan the QR codes below with the will-opening device.\n" "Scan the QR codes below with the will-opening device.\n"
@@ -2964,6 +2909,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 +2963,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 +2976,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 +3023,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 +3052,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 +3231,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 +3262,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 +3503,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 +3691,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 +3948,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

@@ -19,7 +19,6 @@ 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 +537,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)
@@ -670,10 +653,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,25 +911,6 @@ class Plugin(BalPlugin, EventListener):
) )
grid.addWidget(reset_btn_auto_rebuild, 15, 3) grid.addWidget(reset_btn_auto_rebuild, 15, 3)
# "QR Code Size" row (always visible, BASIC + ADVANCED). Default QR
# size used when exporting a will via QR codes; changeable per export
# inside the export dialog itself.
lbl_qr_size = QLabel(_("QR Code Size"))
help_qr_size = HelpButton(_(
"Payload size of a single QR code when exporting a will via QR.\n\n"
"Larger QR codes hold more data (fewer shots) but are easier to "
"scan with a high-resolution camera; smaller QR codes scan fine "
"even with low-resolution cameras but require more shots.\n"
"The same selector is available inside the export dialog."
))
grid.addWidget(lbl_qr_size, 16, 0)
grid.addWidget(qr_size_combo, 16, 1)
grid.addWidget(help_qr_size, 16, 2)
reset_btn_qr_size = _make_reset_btn(
self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"
)
grid.addWidget(reset_btn_qr_size, 16, 3)
# ----------------------------------------------------------------- # # ----------------------------------------------------------------- #
# Group C / C4b: "Reset" button that restores the dialog settings to # # Group C / C4b: "Reset" button that restores the dialog settings to #
# their factory defaults. It only resets the settings exposed by THIS # # their factory defaults. It only resets the settings exposed by THIS #
@@ -984,7 +944,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,10 +964,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))
)
# 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.

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
@@ -837,6 +846,13 @@ class BalWindow:
_("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(
_( _(
@@ -1298,6 +1314,12 @@ class BalWindow:
# means the old will must be invalidated on-chain. # means the old will must be invalidated on-chain.
try: try:
self.init_class_variables() self.init_class_variables()
except CheckAliveAfterDeliveryError:
_logger.info(
"auto rebuild: check-alive not before the delivery, "
"nothing to rebuild/invalidate"
)
return False
except CheckAliveError: except CheckAliveError:
_logger.info("auto rebuild: check-alive threshold passed -> invalidate") _logger.info("auto rebuild: check-alive threshold passed -> invalidate")
self._auto_invalidate_will() self._auto_invalidate_will()

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

@@ -240,7 +240,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

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

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

@@ -412,7 +412,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 +446,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"