2 Commits

Author SHA1 Message Date
a74b2d73fa gui: fix ruff I001 import sort order in dialogs.py
The i18n merge left BalanceTooLowException and BalTimestamp out of order
in the .common import block, so ruff check bal/ tests/ was no longer
clean. Swapped the two lines; no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-30 18:33:09 -04:00
80cff2377b Merge pull request 'i18n: Italian translation of the BAL plugin (BAL follows Electrum's language)' (#8) from feature/i18n into main
Reviewed-on: #8
2026-09-30 14:53:01 -04:00
76 changed files with 7007 additions and 4854 deletions

3
.gitignore vendored
View File

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

View File

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

View File

@@ -3448,337 +3448,3 @@ 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
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.
**Verification:** extended release test list (as above) plus the new test
files: 489 passed, 1 failed (pre-existing: `test_group_e_mock_karen7::test_e5`
needs `tests/karen7`, not in the repository; `main` 827a1d1 gives 456 passed,
same failure). `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 the owner's test of the ZIP and Truman's review).
---
## 64. Invalidation pays the current network fee (2 blocks) instead of `baltx_fees`
**Date:** 2026-09-26 (ported to `main` 827a1d1 on 2026-10-07)
**Goal (owner request):** the will's `baltx_fees` (default 20 sat/vbyte) is
deliberately high because the inheritance transactions may be broadcast by the
will-executors years from now, when network fees may be much higher. The
on-chain INVALIDATION, instead, is sent TODAY: it spends the will's coins back
to the wallet so the old pre-signed inheritance transactions become unusable.
It must therefore pay the current network fee, not the inheritance rate. After
the invalidation the plugin rebuilds the new inheritance with `baltx_fees` as
before (unchanged).
**What changed:**
- `bal/core/will.py`
- New constant `INVALIDATION_FEE_ETA_BLOCKS = 2` (owner decision: confirm
within 2 blocks, Electrum's own default target).
- New `Will.get_invalidation_fee_rate(wallet, fallback_fee_per_byte)`:
returns Electrum's live estimate for 2 blocks
(`network.fee_estimates.eta_target_to_fee`, sat/kvbyte → sat/vbyte), or
the fallback (the will's `baltx_fees`, i.e. the previous behaviour) when
there is no usable estimate: offline wallet, estimates not received yet,
network error or an invalid value. Never raises.
- `Will.invalidate_will`: uses that rate; the total fee is rounded UP to
whole satoshis (network rates can be fractional); logs the chosen rate and
its source (`network` / `fallback`). The `fees_per_byte` argument is now
only the fallback, so every caller (Tools → Invalidate, wizard
postpone/expired paths, automatic invalidation, CLI `bal_will_invalidate`)
gets the new behaviour without being changed. Docstrings added.
- `tests/test_core_invalidation_fee.py` (new): rate selection (2-block target,
network value used as is also above `baltx_fees`, fractional rates, fallback
when offline / no estimate / error / invalid values) and the fee actually
deducted by `invalidate_will` (network vs fallback, rounding up).
**Why no special case for an already-expired will:** discussed with the owner
and intentionally left out. Before its locktime the old inheritance tx cannot
enter a block or the mempool, so the invalidation has no competitor. RBF stays
enabled on the invalidation, so its fee can be raised from Electrum's History
("Increase fee") if it ever gets stuck.
**Verification:**
- `tests/test_core_invalidation_fee.py`: 12 passed (pytest and standalone).
- `make-release.sh` test list + `tests/test_cli_*.py`: 435 passed, 1 failed.
The failure is pre-existing and unrelated: `test_group_e_mock_karen7::test_e5`
needs `tests/karen7`, a wallet file that is not in the repository.
`tests/test_import_will_details.py`: 17 passed.
- `ruff check` clean on the touched files.
- Live, Electrum 4.8.2 testnet, offline daemon: `bal_will_invalidate` →
"invalidation fee: 20 sat/vbyte (fallback), total 2200 sat" (unchanged
behaviour when offline).
- Real Electrum wallet + real `FeeTimeEstimates` (1 input, 110 vbytes):
market 3 sat/vbyte → 330 sat; 1.5 → 165 sat; 0.4 → 110 sat (Electrum clamps
to its 1 sat/vbyte minimum relay fee); 45 → 4950 sat; no 2-block estimate →
fallback 20 sat/vbyte (2200 sat).
- API checked in Electrum 4.7.2, 4.8.0 and 4.8.2
(`FeeTimeEstimates.eta_target_to_fee`, `network.fee_estimates`).
**Notes:** on regtest Electrum returns a fixed 150 sat/vbyte when it has no
estimate, so regtest invalidations will pay that rate.
**Outcome:** DONE (pending the owner's test of the ZIP).
---
## 65. Check crashed with `KeyError: '<txid>'` (stale "father" link in the will tree)
**Date:** 2026-09-27
**Symptom (owner's mainnet wallet, 23 will items, Electrum 4.8.2):** the Check
wizard stopped at "Checking your will" with `Error: 'a65a682f…'`, and the
window refresh logged "error while updating window" with the same txid:
```
File "bal/core/will.py", line 657, in check_invalidated
or willtree[w.father].get_status("CONFIRMED")
KeyError: 'a65a682f2975f0dffd9eccce9392833f227badf617fc67815674736d594d4115'
```
**Root cause:** every will item stores its parent will tx in `father`, which is
persisted in the wallet (`"Father"` in `WillItem.to_dict`). `Will.add_willtree`
only ever SET it (`if not father: father = ...`) and never cleared it, so when
the parent tx was later removed from the will (rebuild, merge, replacement) the
child kept pointing to a txid that no longer exists. `check_invalidated` then
indexed the will with it. Reproduced on unmodified `main` (not related to #64).
**What changed:**
- `bal/core/will.py`
- `Will.add_willtree`: resets `father` on every item before rebuilding the
links, so the tree is always recomputed from the transactions themselves
(a stale value loaded from the wallet is discarded). Docstring added.
- `Will.check_invalidated`: defensive lookup - a father that is not (or no
longer) in the will is treated as "no father" instead of raising.
- `tests/test_core_willtree_father.py` (new): stale father discarded / replaced
by the real parent, no crash in `check_invalidated` (with and without the
tree rebuild), real parent/child link built, child of an unconfirmed parent
still skipped. 4 of the 6 tests fail on `main` and pass with the fix; the 2
"normal behaviour" tests pass on both.
**Verification:** make-release test list + `tests/test_cli_*.py` +
`tests/test_import_will_details.py`: 458 passed, 1 failed (pre-existing,
unrelated: `test_group_e_mock_karen7::test_e5` needs the missing `tests/karen7`
wallet file). `ruff check` clean on the touched files.
**Found while writing the tests, NOT fixed here (reported to the owner):**
`Will.set_invalidate` iterates `children.items()`, but `add_willtree` builds
`children` as a LIST, so invalidating an item that has child will txs raises
`AttributeError: 'list' object has no attribute 'items'` (reproduced on
`main`). The existing tests in `test_core_will_invalidate.py` set `children` to
dicts by hand, so they do not see it.
**Outcome:** DONE (pending the owner's test of the ZIP).
---
## 66. Invalidation cascade crashed on real will trees (`children` is a list)
**Date:** 2026-09-27
**Problem (found while fixing #65, reproduced on `main`):**
`Will.set_invalidate` iterated `children.items()`, but `Will.add_willtree`
builds `children` as a LIST of `[child_id, input_idx, prevout_idx]`, so
invalidating a will tx that has child will txs raised
`AttributeError: 'list' object has no attribute 'items'`. The existing tests
set `children` to dicts by hand, so they never saw it.
**What changed:** `bal/core/will.py` - `set_invalidate` accepts both shapes
(list from `add_willtree`, dict from older data/tests) and only recurses into
children that are still in the will. `tests/test_core_willtree_father.py`: new
test `test_set_invalidate_cascades_to_children_built_by_add_willtree` (fails
on `main`, passes with the fix).
**Verification:** 459 passed, 1 failed (pre-existing missing `tests/karen7`);
`ruff check` clean.
**Outcome:** DONE (included in the owner's test ZIP of 2026-09-27).
---
## 67. Deselected will-executor: its tx can be deleted and is no longer pushed
**Date:** 2026-09-27
**Problem (owner, mainnet wallet):** the will had a signed tx for
`we.divinaly.support` whose push had failed ("Push failed", "Not on server").
After deselecting that executor, Check still reported the will as valid, the
row stayed, and "delete" in the context menu was disabled (valid + signed
txs were never deletable). The broadcast also still targeted the deselected
executor, because each will item keeps its own build-time copy of the
executor record (`selected` = True).
**What changed (owner chose option "B"):**
- `bal/gui/qt/lists.py` - `_can_delete`: a valid, signed tx is now deletable
when its will-executor NEVER received it (not PUSHED and not CHECKED). It
stays protected when the executor holds it (deleting it locally would not
cancel it on the server) and when it has no executor (backup tx).
- `bal/core/willexecutors.py` - `get_willexecutor_transactions(...,
current_willexecutors=None)`: when the current executor list is given, an
executor that is no longer selected (or no longer listed) is skipped.
- `bal/gui/qt/window.py` (manual broadcast) and `bal/cli/controller.py`
(`bal_will_broadcast`) pass the current list read from the config. The
wizard already filtered by the current selection.
- Tests: `tests/test_gui_will_menu.py` (+3: deletable when never received,
protected when pushed/checked, protected backup tx) and new
`tests/test_core_deselected_executor.py` (4).
**Not changed (proposal for Truman, option "A"):** Check still does not treat
a tx of a deselected executor as "not coherent" (no automatic rebuild).
**Verification:** 483 passed, 1 failed (pre-existing missing `tests/karen7`);
`ruff check` clean on the touched files. Live (Electrum 4.8.2 testnet, CLI):
will built for 2 executors, one deselected → `bal_will_broadcast` targets only
the selected one (before the fix it targeted both).
**Outcome:** DONE (pending the owner's test of the ZIP).
**Porting note (entries 64-67):** first written on `main` 4a33116 as
58-61 (numbers then taken by the i18n and Check Alive work), re-applied on
`main` 827a1d1 on 2026-10-07 together with #63: extended release test list
489 passed, 1 pre-existing failure (missing `tests/karen7`); `ruff` no new
errors.

View File

@@ -25,15 +25,22 @@ and 4.8.0).
## QR wire-format compatibility
BAL exports/imports wills as QR codes using **BBQR**:
BAL exports/imports wills as QR codes. **BAL QR** (the default) is the plugin's
own frame format and is only understood by BAL itself. The export page also
supports **BC-UR v1**, **BC-UR v2** and **BBQR**:
| Format | Wire appearance | Interop target |
|-----------|----------------------------|------------------------------------------------------|
| 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 |
Import auto-detects the format of each scanned code; out-of-order and duplicate
BBQR frames are handled. Interop has not yet been cross-verified against
third-party libraries (`bbqr`, Coldcard firmwares).
Import auto-detects the format of each scanned code; out-of-order, duplicate
and (for UR v2) partially-lost fountain frames are handled. Interop is
validation-tested against the reference C++ bc-ur encoder output and the
BCR-2020-004/005 BC32 test vectors; it has not yet been cross-verified against
third-party libraries (`ur`, `bbqr`, Coldcard firmwares).
## Reporting compatibility issues

View File

@@ -68,8 +68,7 @@ bal/ <- the plugin package (this is what ships in the ZI
willexecutors.py <- remote will-executor services handling (is_selected / is_valid,
parallel push/check).
util.py <- locktime parsing/most helpers (timestamps only).
checkalive.py <- resolve_date_to_check, resolve_relative_check_alive_date,
ensure_check_alive_valid (GUI-free; signed relative rule).
checkalive.py <- resolve_date_to_check, check_alive_expired (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).
cli/ <- headless command-line layer (no Qt)
@@ -421,29 +420,50 @@ See Section 5 for details.
the audio path — buttons only appear when the `audio_modem` plugin +
`amodem` are installed; see the prerequisites below).
### Animated-QR interop: BBQR only
### In progress: animated-QR interop (BC-UR v1/v2, BBQR)
- `bal/core/animated_qr.py` implements a stdlib-only codec for **BBQR**
- `bal/core/animated_qr.py` implements stdlib-only codecs for **BC-UR v1**
(BC32 + SHA-256 digest; the bech32_bis checksum variant per
BCR-2020-004/005), **BC-UR v2** (CBOR part structure, bytewords-minimal,
CRC-32, xoshiro256-based fountain with alias-sampled mixing) and **BBQR**
(Coinkite `B$…` base32/hex/zlib frames), plus one shared
`AnimatedQrSession` with `detect_format` auto-detection and
`parse_for_detection` frame identity for the GUI debounce. The previously
supported **BAL QR**, **BC-UR v1** and **BC-UR v2** wire formats were
removed; only BBQR remains.
- **Sessions:** BBQR full-frame decode in any order for Z/2/H encodings,
duplicate drops.
- **Safety:** `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`,
`TransferConflictError` on a frame from a different transfer,
`SessionLimitError`, BBQR zlib-bomb guard, UTF-8 payloads only.
- **GUI:** `BalQrExportWidget` emits BBQR frames using the QR-size presets;
the importer routes every frame through `detect_format` +
`AnimatedQrSession.add_part` with the shared
`qr_import_accept_frame(state, fmt, session_key, frame_total, index,
payload, stable_reads=2)` debounce (reset on session-key change).
`_review_and_sign` resolves the session to the transfer text and decodes
parts uniformly.
- **Verification:** `tests/test_core_animated_qr.py` (BBQR round-trips,
encoding fallback, zlib-bomb guard, detection) and the extended
`tests/test_gui_qr_transfer.py` pass.
`parse_for_detection` frame identity for the GUI debounce.
- Current status as of this session: reference parity, GUI, and tests done;
not yet committed.
- **BC32/bytewords/codec parity:** BC32 reproduces the BCR-2020-004/005
test vectors (`Hello, world`, `Hello world`, the long seed vector);
bytewords-minimal round-trips with CRC rejection; UR v2 part encode +
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,
`SessionLimitError`, BBQR zlib-bomb guard, UTF-8 payloads only.
- **GUI:** `BalQrExportWidget` gained a Format selector (BAL QR default,
BC-UR v1, BC-UR v2, BBQR) reusing the QR-size presets; the importer now
routes every frame through `detect_format` +
`AnimatedQrSession.add_part` with the shared
`qr_import_accept_frame(state, fmt, session_key, frame_total, index,
payload, stable_reads=2)` debounce (reset on session-key change).
`_review_and_sign` resolves the session to the transfer text and decodes
parts uniformly across formats.
- **Verification:** `tests/test_core_animated_qr.py` (32 tests incl. the
C++-reference parity vectors and BC32 spec vectors) and the extended
`tests/test_gui_qr_transfer.py` pass; ruff clean on the new/changed
files; pyright 0 errors; smoke test, `build_zip.py` and
`external_zip_test.py` green. Only the pre-existing failures remain
(`test_bt_to_date_*`, fee-exceeds-balance, karen7 pollution).
- **Any remaining work:** manual on-device walkthrough of the QR path with
the new formats; optionally validate against third-party libraries
(`ur`, `bbqr`) once available; add the docstrings/branch notes already
captured in `ag1.md`/`ag2.md` context where needed.
**Dev-box audio prerequisites (audio_modem channel):**
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`.
- `will.py` — `Will` (statiche) e `WillItem` (stato per-tx: `VALID/COMPLETE/PUSHED/CHECKED/...`), `is_will_valid`, `check_will`, `check_willexecutors_and_heirs`, `invalidate_will`, `normalize_will`, `get_min_locktime`, `get_tx_from_any`, `set_check_willexecutor`, `save_valid_transactions_to_history`.
- `plugin_base.py` — `BalPlugin` (tutte le `BalConfig`: chiavi `bal_*`), `BalTimestamp`, `get_version`, registrazione dei dict `heirs`/`will`/`will_settings` nel wallet DB.
- `checkalive.py` — `resolve_date_to_check`, `resolve_relative_check_alive_date`, `ensure_check_alive_valid` (riferimento temporale unico per ogni check; relativo negativo = prima della delivery, positivo = dopo la creazione).
- `checkalive.py` — `resolve_date_to_check`, `check_alive_expired` (riferimento temporale unico per ogni check).
- `util.py` — `Util` (locktime, quantità, confronto tx/heirs, `get_available_utxos`, `fix_will_settings_tx_fees`).
- `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),
@@ -173,7 +173,7 @@ Metodi principali (replicano le funzioni Qt, senza dialoghi):
|---|---|---|
| `load_willitems()` | 120 | Costruisce i `WillItem` dal dict `will` del wallet DB. |
| `save_willitems()` | 129 | `to_dict()` con `tx` serializzato a stringa, `json.dumps` di prova, scrittura su `wallet.db` + `wallet.save_db()`. |
| `init_class_variables()` | 618 | `date_to_check`, `no_willexecutor`, `willexecutors`, check `ensure_check_alive_valid`. |
| `init_class_variables()` | 618 | `date_to_check`, `no_willexecutor`, `willexecutors`, check `check_alive_expired`. |
| `check_will()` | 473 | `Will.is_will_valid(...)`; le eccezioni di dominio vengono propagate al comando. |
| `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`. |

View File

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

View File

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

10
android/.gitignore vendored Normal file
View File

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

153
android/README.md Normal file
View File

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

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

5
android/app/proguard-rules.pro vendored Normal file
View File

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

View File

@@ -0,0 +1,31 @@
<?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

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

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

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

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

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

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

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

View File

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

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

@@ -0,0 +1,34 @@
<?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

@@ -0,0 +1,59 @@
<?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

@@ -0,0 +1,67 @@
<?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

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

View File

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

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

7
android/build.gradle.kts Normal file
View File

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

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

@@ -0,0 +1,7 @@
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 Executable file
View File

@@ -0,0 +1,251 @@
#!/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 Normal file
View File

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

161
android/scripts/build_apk.py Executable file
View File

@@ -0,0 +1,161 @@
#!/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

@@ -0,0 +1,99 @@
#!/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

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

@@ -0,0 +1,314 @@
#!/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())

202
android/tools/emitter.py Normal file
View File

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

@@ -0,0 +1,26 @@
{
"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,11 +40,9 @@ from ..core import checkalive
from ..core import heirs as heirs_mod
from ..core import will as will_mod
from ..core.checkalive import (
CheckAliveAfterDeliveryError,
CheckAliveError,
ensure_check_alive_valid,
check_alive_expired,
resolve_date_to_check,
resolve_delivery_date,
resolve_guard_threshold,
)
from ..core.heirs import Heirs, is_op_return_address
@@ -108,12 +106,6 @@ def _user_facing(e):
return UserFacingException(str(e))
if isinstance(e, heirs_mod.LocktimeNotValid):
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):
return UserFacingException(
_(
@@ -280,21 +272,13 @@ class BalController:
self.plugin.is_basic_mode(),
self.will_settings,
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.willexecutors = Willexecutors.get_willexecutors(
self.plugin, update=True, task=False
)
delivery_date = resolve_delivery_date(
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,
)
if check_alive_expired(self.plugin.is_basic_mode(), self.date_to_check):
raise CheckAliveError(self.date_to_check)
self._init_heirs_to_locktime(self.plugin.ENABLE_MULTIVERSE.get())
def _init_heirs_to_locktime(self, multiverse=False):
@@ -385,9 +369,7 @@ class BalController:
tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
# Stored history: always English (translated only for display by
# format_status_history); never _() here.
tx["status"] = "New"
tx["status"] = _("New")
tx["baltx_fees"] = txs[txid].tx_fees
tx["time"] = creation_time
tx["heirs"] = copy_structure(txs[txid].heirs)
@@ -476,9 +458,7 @@ class BalController:
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
threshold_ts = resolve_guard_threshold(
self.plugin.is_basic_mode(),
self.will_settings,
creation_date=Will.get_creation_time(self.willitems),
self.plugin.is_basic_mode(), self.will_settings
)
if threshold_ts is not None:
if locktime < threshold_ts:
@@ -603,11 +583,6 @@ class BalController:
except will_mod.NoHeirsException as e:
_logger.info(f"auto rebuild: no heirs ({e})")
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:
_logger.info("auto rebuild: Check Alive already in the past")
return self._auto_rebuild_invalidate("threshold_passed")
@@ -767,11 +742,7 @@ class BalController:
if not willitems:
raise UserFacingException(_("No transaction matches the given txids"))
# Skip executors the user deselected after building the will (the
# controller's list is read from the config for every command).
willexecutors = Willexecutors.get_willexecutor_transactions(
willitems, force=force, current_willexecutors=self.willexecutors
)
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
if not willexecutors:
return {}

View File

@@ -2,18 +2,33 @@
bal.core.animated_qr
====================
GUI-free implementation of the BBQR animated-QR transfer format used to
move BAL will data between devices.
GUI-free implementation of the interoperable animated-QR transfer formats
used to move BAL will data between devices.
Supported wire format (self-describing and order-independent on receive):
Supported wire formats (each self-describing and order-independent on
receive):
* **BALQR** (native): ``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)::
B$<encoding><type><2 base36 total><2 base36 index><payload>
Equal-length text frames; the payload is uppercase hex, RFC-4648
base32, or raw-deflate (``wbits=-10``) zlib plus base32.
Everything is implemented from scratch on top of the Python standard library
only (``zlib``, ``base64``), so the shipped plugin zip stays a
only (``zlib``, ``hashlib``, ``base64``), so the shipped plugin zip stays a
self-contained bundle with no third-party dependencies (house rule).
This module never imports Qt or any Electrum GUI code (house rule).
@@ -22,8 +37,9 @@ This module never imports Qt or any Electrum GUI code (house rule).
from __future__ import annotations
import base64
import hashlib
import zlib
from typing import Dict, List, Optional, Sequence, Tuple
from typing import Dict, FrozenSet, List, Optional, Sequence, Set, Tuple
# --------------------------------------------------------------------------- #
# Errors & safety caps
@@ -46,10 +62,604 @@ class SessionLimitError(AnimatedQrError):
"""A receive session exceeded its safety caps."""
class ChecksumError(AnimatedQrError):
"""A part failed its checksum / digest validation."""
# Safety caps for untrusted scanner input.
_MAX_SESSION_PARTS = 20000
_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)
# --------------------------------------------------------------------------- #
@@ -164,6 +774,9 @@ def _bbqr_decode(encoded_parts: Sequence[str], encoding: str) -> bytes:
# --------------------------------------------------------------------------- #
FORMAT_LABELS = {
"balqr": "BAL QR",
"ur1": "BC-UR v1",
"ur2": "BC-UR v2",
"bbqr": "BBQR",
}
@@ -178,8 +791,34 @@ def detect_format(text: str) -> Optional[str]:
text = text.strip()
if not text:
return None
lowered = text.lower()
if lowered.startswith(("balqr", "bal1")):
return "balqr"
if text.startswith(_BBQR_PREFIX):
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
@@ -191,16 +830,253 @@ def parse_for_detection(text: str) -> Tuple[str, str, int, int]:
:class:`AnimatedQrError` when the text cannot be parsed.
"""
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":
encoding, type_code, total, index, _payload = bbqr_parse_part(text)
return "bbqr", "bbqr:{}{}:{}".format(encoding, type_code, total), total, index
raise FormatNotDetectedError("Not a supported QR transfer format")
def _parse_balqr(text: str) -> Tuple[int, int, bool, str]:
from bal.core.qrtransfer import parse_frame
return parse_frame(text)
# --------------------------------------------------------------------------- #
# Receive session (order-independent assembly)
# Receive sessions (order-independent assembly per format)
# --------------------------------------------------------------------------- #
class _BalQrSession:
def __init__(self):
self._frames: Dict[int, str] = {}
self._total = 0
self._compressed = False
@property
def total(self) -> int:
return self._total
@property
def received(self) -> int:
return len(self._frames)
@property
def done(self) -> bool:
return bool(self._total) and len(self._frames) >= self._total
def add(self, text: str) -> str:
total, index, compressed, payload = _parse_balqr(text)
if self._total and total != self._total:
raise TransferConflictError("BAL QR transfer total changed")
if len(self._frames) >= _MAX_SESSION_PARTS:
raise SessionLimitError("too many BAL QR frames")
if not self._total:
self._total = total
self._compressed = compressed
if index in self._frames:
return "dup"
self._frames[index] = payload
return "ok"
def resolve(self) -> Tuple[str, bool]:
from bal.core.qrtransfer import assemble
text = assemble(self._frames, self._total)
return text, self._compressed
class _Ur1Session:
def __init__(self):
self._total = 0
self._digest = ""
self._fragments: Dict[int, str] = {}
@property
def total(self) -> int:
return self._total
@property
def received(self) -> int:
return len(self._fragments)
@property
def done(self) -> bool:
return bool(self._total) and len(self._fragments) >= self._total
def add(self, text: str) -> str:
index, total, digest, fragment = ur1_parse_part(text)
if self._total:
if total != self._total or digest != self._digest:
raise TransferConflictError("BC-UR v1 transfer digest changed")
else:
self._total = total
self._digest = digest
if total > _MAX_SESSION_PARTS:
raise SessionLimitError("BC-UR v1 demands too many parts")
if index in self._fragments:
return "dup"
self._fragments[index] = fragment
return "ok"
def resolve(self) -> Tuple[str, bool]:
full = "".join(self._fragments[i] for i in range(1, self._total + 1))
try:
message = bc32_decode(full)
except AnimatedQrError:
raise ChecksumError("BC-UR v1 checksum mismatch") from None
if self._digest and _ur1_digest(message) != self._digest:
raise ChecksumError("BC-UR v1 digest mismatch")
return _transfer_text(unwrap_ur_cbor(message)), False
class _Ur2Session:
"""Fountain decoder mirroring the reference (C++/python) semantics."""
def __init__(self):
self._seq_len = 0
self._message_len = 0
self._checksum = 0
self._fragment_len = 0
self._received: Set[int] = set()
self._simple: Dict[FrozenSet[int], bytes] = {}
self._mixed: Dict[FrozenSet[int], bytes] = {}
self._queue: List[Tuple[FrozenSet[int], bytes]] = []
self._processed = 0
self._result: Optional[bytes] = None
self._bad = False
@property
def total(self) -> int:
return self._seq_len
@property
def received(self) -> int:
return self._processed
@property
def done(self) -> bool:
return self._result is not None
def add(self, text: str) -> str:
seq, seq_len, message_len, checksum, data = ur2_parse_part(text)
if self._seq_len:
if not self._validate(seq_len, message_len, checksum, len(data)):
raise TransferConflictError("BC-UR v2 transfer header changed")
else:
self._seq_len = seq_len
self._message_len = message_len
self._checksum = checksum
self._fragment_len = len(data)
if seq_len > _MAX_SESSION_PARTS or message_len > _MAX_MESSAGE_BYTES:
raise SessionLimitError("BC-UR v2 session exceeds safety caps")
indexes = frozenset(choose_fragments(seq, self._seq_len, self._checksum))
self._receive(indexes, bytes(data))
return "ok"
def _validate(self, seq_len: int, message_len: int, checksum: int, data_len: int) -> bool:
return (
seq_len == self._seq_len
and message_len == self._message_len
and checksum == self._checksum
and data_len == self._fragment_len
)
def _receive(self, indexes: FrozenSet[int], data: bytes) -> None:
if self._result is not None or self._bad:
return
self._queue.append((indexes, data))
while self._result is None and not self._bad and self._queue:
self._process(self._queue.pop(0))
self._processed += 1
def _process(self, item: Tuple[FrozenSet[int], bytes]) -> None:
indexes, data = item
if len(indexes) == 1:
self._process_simple(indexes, data)
else:
self._process_mixed(indexes, data)
def _process_simple(self, indexes: FrozenSet[int], data: bytes) -> None:
fragment_index = next(iter(indexes))
if fragment_index in self._received:
return
self._simple[indexes] = data
self._received.add(fragment_index)
if self._received == set(range(self._seq_len)):
self._finish()
return
self._reduce_mixed_by(indexes, data)
def _reduce_mixed_by(self, indexes: FrozenSet[int], data: bytes) -> None:
new_mixed: Dict[FrozenSet[int], bytes] = {}
for other_indexes, other_data in self._mixed.items():
reduced = self._reduce_part(other_indexes, other_data, indexes, data)
if len(reduced[0]) == 1:
self._queue.append(reduced)
else:
new_mixed[reduced[0]] = reduced[1]
self._mixed = new_mixed
def _process_mixed(self, indexes: FrozenSet[int], data: bytes) -> None:
if indexes in self._mixed:
return
reduced_indexes, reduced_data = indexes, data
for simple_indexes, simple_data in self._simple.items():
reduced_indexes, reduced_data = self._reduce_part(
reduced_indexes, reduced_data, simple_indexes, simple_data
)
for other_indexes, other_data in list(self._mixed.items()):
reduced_indexes, reduced_data = self._reduce_part(
reduced_indexes, reduced_data, other_indexes, other_data
)
if len(reduced_indexes) == 1:
self._queue.append((reduced_indexes, reduced_data))
else:
self._reduce_mixed_by(reduced_indexes, reduced_data)
if reduced_indexes not in self._mixed:
self._mixed[reduced_indexes] = reduced_data
@staticmethod
def _reduce_part(
a_indexes: FrozenSet[int], a_data: bytes, b_indexes: FrozenSet[int], b_data: bytes
) -> Tuple[FrozenSet[int], bytes]:
if b_indexes == a_indexes or not b_indexes.issubset(a_indexes):
return a_indexes, a_data
new_indexes = a_indexes - b_indexes
new_data = bytes(x ^ y for x, y in zip(a_data, b_data, strict=True))
return new_indexes, new_data
def _finish(self) -> None:
fragments = []
for index in range(self._seq_len):
key = frozenset([index])
if key not in self._simple:
self._bad = True
return
fragments.append(self._simple[key])
message = b"".join(fragments)[: self._message_len]
if crc32_int(message) != self._checksum:
self._bad = True
return
self._result = message
def resolve(self) -> Tuple[str, bool]:
if self._bad:
raise ChecksumError("BC-UR v2 message checksum mismatch")
if self._result is None:
raise AnimatedQrError("BC-UR v2 session is not complete")
return _transfer_text(unwrap_ur_cbor(self._result)), False
class _BbqrSession:
def __init__(self):
self._total = 0
@@ -250,6 +1126,8 @@ def _transfer_text(raw: bytes) -> str:
class AnimatedQrSession:
"""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):
self._inner: Optional[object] = None
self._fmt: Optional[str] = None
@@ -274,6 +1152,12 @@ class AnimatedQrSession:
@staticmethod
def _make(fmt: str) -> object:
if fmt == "balqr":
return _BalQrSession()
if fmt == "ur1":
return _Ur1Session()
if fmt == "ur2":
return _Ur2Session()
if fmt == "bbqr":
return _BbqrSession()
raise AssertionError("unknown animated-QR format {}".format(fmt))

View File

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

View File

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

View File

@@ -305,10 +305,11 @@ class BalPlugin(BasePlugin):
# stay display-only outside the wizard unless the user opts in.
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in characters,
# used per QR frame when exporting/importing a will through the QR
# channel. The export dialog seeds its own size selector from this
# value (default 150, small QR, low-resolution cameras).
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in bytes, used
# per QR frame when exporting/importing a will through the QR channel.
# The settings dialog offers the 4 standard presets of
# bal.core.qrtransfer.CHUNK_PRESETS; this stores the selected budget.
# Default 150 (small QR, low-resolution cameras).
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
@@ -497,35 +498,22 @@ class BalPlugin(BasePlugin):
@staticmethod
def default_will_settings_absolute():
"""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).
"""
"""Convert the default relative dates into absolute timestamps (from today)."""
relative_dates = BalPlugin.default_will_settings_relative()
today = datetime.now(tz=timezone.utc).date()
dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc)
locktime_dt = dt + timedelta(
days=BalTimestamp(relative_dates["locktime"]).duration_to_days()
)
threshold = BalTimestamp(relative_dates["threshold"])
if threshold.unit is not None and threshold.value < 0:
anchor_dt = locktime_dt
else:
anchor_dt = dt
threshold_dt = anchor_dt + timedelta(days=threshold.duration_to_days())
return {"threshold": threshold_dt.timestamp(), "locktime": locktime_dt.timestamp()}
threshold = (
dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days())
).timestamp()
locktime = (
dt + timedelta(days=BalTimestamp(relative_dates["locktime"]).duration_to_days())
).timestamp()
return {"threshold": threshold, "locktime": locktime}
@staticmethod
def default_will_settings_relative():
"""Default relative dates: 30 days BEFORE the delivery, 1 year locktime.
The Check Alive default is NEGATIVE on purpose: it keeps counting back
from the delivery date, preserving the historical "check alive 30 days
before delivery" behaviour.
"""
return {"threshold": "-30d", "locktime": "1y"}
"""Default relative dates: 30 days threshold, 1 year locktime."""
return {"threshold": "30d", "locktime": "1y"}
class BalTimestamp:

View File

@@ -9,11 +9,31 @@ Scope
-----
* converts will transactions into a compact ``transfer_string``
(newline-joined serialized transactions, optionally zlib + base64
compressed). The transfer string is the payload carried by BBQR QR frames
(``bal.core.animated_qr``).
compressed);
* splits that string into fixed-size ``BAL1<TTT><iii><flag>`` frames for
multi-QR export, and reassembles/validates them on import.
The audio-modem channel carries the whole transfer string in a single blob
(PLAN_QR_TRANSFER.md section 4.4), so callers only use
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).
@@ -24,7 +44,12 @@ from __future__ import annotations
import base64
import zlib
# 4 standard presets (label, payload budget per QR). Ordered from
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),
@@ -33,16 +58,61 @@ CHUNK_PRESETS = (
("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 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`.
@@ -54,6 +124,73 @@ def decode_transfer(transfer_string, 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])
@@ -78,3 +215,90 @@ 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

@@ -26,7 +26,6 @@ The status flags themselves (the source of truth) stay here; only the mapping
"status -> colour" now lives in the GUI layer. No behaviour changed.
"""
import math
from datetime import datetime, timezone
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
@@ -51,13 +50,6 @@ from .willexecutors import Willexecutors
MIN_LOCKTIME = 1
MIN_BLOCK = 1
# Confirmation target (in blocks) for the on-chain INVALIDATION transaction.
# WHY: the invalidation is sent TODAY (it spends the will's coins so the old
# pre-signed inheritance txs become unusable), so it must pay the CURRENT
# network fee for a quick confirmation - not ``baltx_fees``, which is a
# deliberately high rate reserved for inheritance txs that may be broadcast
# years from now. 2 blocks is Electrum's own default target (owner decision).
INVALIDATION_FEE_ETA_BLOCKS = 2
_logger = get_logger(__name__)
@@ -76,22 +68,6 @@ class Will:
# build a tree with parent transactions
@staticmethod
def add_willtree(will):
"""(Re)build the parent/child links between the will transactions.
Every item gets ``children`` (the will txs spending its outputs) and,
when it spends the output of another will tx, a ``father``.
WHY the reset below: ``father`` is persisted in the wallet (the
``"Father"`` field of ``WillItem.to_dict``) and was only ever set,
never cleared. When the father tx was later removed from the will
(rebuild, merge, replacement) the child kept pointing to a txid that
no longer exists, and ``check_invalidated`` crashed with a
``KeyError`` on that txid (reported on a real wallet: the Check
wizard and the window refresh both failed). The tree is therefore
always recomputed from scratch from the transactions themselves.
"""
for willid in will:
will[willid].father = None
for willid in will:
will[willid].children = Will.get_children(will, willid)
for child in will[willid].children:
@@ -462,68 +438,9 @@ class Will:
out[inp.prevout.to_str()] = inp
return out
@staticmethod
def get_invalidation_fee_rate(wallet, fallback_fee_per_byte):
"""Return ``(fee_rate, source)`` for the on-chain invalidation tx.
``fee_rate`` is in sat/vbyte. ``source`` is ``"network"`` when the rate
comes from Electrum's live fee estimate, ``"fallback"`` otherwise.
WHY: the invalidation is broadcast NOW, so it pays the current network
fee for a confirmation within ``INVALIDATION_FEE_ETA_BLOCKS`` blocks.
The will's ``baltx_fees`` is kept only as a fallback, used when no
estimate is available (offline wallet, estimates not received yet,
server error), which is exactly the behaviour of previous versions.
The estimate is read from ``network.fee_estimates.eta_target_to_fee``
(Electrum 4.7.2 / 4.8.x), which returns sat/kvbyte and already clamps
the value between Electrum's conservative minimum relay fee (1
sat/vbyte) and its maximum dynamic fee. This method never raises.
Args:
wallet: The Electrum wallet (its ``network`` may be ``None``).
fallback_fee_per_byte: Rate (sat/vbyte) used when there is no
usable estimate - normally the will's ``baltx_fees``.
"""
network = getattr(wallet, "network", None) if wallet else None
fee_per_kb = None
if network is not None:
try:
fee_per_kb = network.fee_estimates.eta_target_to_fee(
INVALIDATION_FEE_ETA_BLOCKS
)
except Exception as e:
_logger.warning(f"invalidation fee: no network estimate ({e!r})")
fee_per_kb = None
# Accept only a real positive integer (Electrum's type for sat/kvbyte);
# ``bool`` is excluded explicitly because it is a subclass of ``int``.
if (
isinstance(fee_per_kb, int)
and not isinstance(fee_per_kb, bool)
and fee_per_kb > 0
):
return fee_per_kb / 1000, "network"
return fallback_fee_per_byte, "fallback"
@staticmethod
def invalidate_will(will, wallet, fees_per_byte, history_label=None,
will_locktime=None):
"""Build the (unsigned) on-chain invalidation transaction.
The transaction spends every coin used by the valid will items back to
one of the wallet's own change addresses, so the pre-signed
inheritance transactions held by the will-executors become unusable.
Fee: the CURRENT network fee for a confirmation within
``INVALIDATION_FEE_ETA_BLOCKS`` blocks (see
:meth:`get_invalidation_fee_rate`). ``fees_per_byte`` (the will's
``baltx_fees``) is only the FALLBACK used when no network estimate is
available. RBF stays enabled, so the fee can still be raised from
Electrum's History ("Increase fee") if the transaction gets stuck.
Returns the ``PartialTransaction``, or ``None`` when there is nothing
to invalidate or the balance does not cover the fee.
"""
_logger.debug("invalidate tx in will module")
will_only_valid = Will.only_valid_list(will)
inputs = Will.get_all_inputs(will_only_valid)
@@ -560,17 +477,7 @@ class Will:
utxo_to_spend, [out], locktime=locktime, version=2
)
tx.set_rbf(True)
fee_rate, fee_source = Will.get_invalidation_fee_rate(
wallet, fees_per_byte
)
# The network rate can be fractional (e.g. 1.5 sat/vbyte): round
# the total fee UP to whole satoshis so the output value is an
# integer and the rate is never below the one requested.
fee = int(math.ceil(tx.estimated_size() * fee_rate))
_logger.info(
f"invalidation fee: {fee_rate} sat/vbyte ({fee_source}), "
f"total {fee} sat"
)
fee = tx.estimated_size() * fees_per_byte
if balance - fee > 0:
out = PartialTxOutput.from_address_and_value(
change_addresses[0], balance - fee
@@ -655,15 +562,9 @@ class Will:
def set_invalidate(wid, will=None):
will = will if will is not None else {}
will[wid].set_status("INVALIDATED", True)
children = will[wid].children or []
# add_willtree builds a LIST of [child_id, input_idx, prevout_idx];
# a dict keyed by child id is still accepted (older data / tests).
child_ids = children.keys() if isinstance(children, dict) else [
c[0] for c in children
]
for child_id in child_ids:
if child_id in will:
Will.set_invalidate(child_id, will)
if will[wid].children:
for c in will[wid].children.items():
Will.set_invalidate(c[0], will)
@staticmethod
def check_tx_height(tx, wallet):
@@ -674,15 +575,10 @@ class Will:
@staticmethod
def check_invalidated(willtree, utxos_list, wallet):
for wid, w in willtree.items():
# Defensive: a father that is not (or no longer) part of the will
# is treated as "no father" instead of raising KeyError, so the
# item's inputs are checked against the wallet UTXOs like any
# root transaction. See add_willtree for the root cause.
father = willtree.get(w.father) if w.father else None
if (
father is None
or father.get_status("CONFIRMED")
or father.get_status("MEMPOOL")
not w.father
or willtree[w.father].get_status("CONFIRMED")
or willtree[w.father].get_status("MEMPOOL")
):
for inp in w.tx.inputs():
inp_str = Util.utxo_to_str(inp)
@@ -1076,25 +972,6 @@ class Will:
def get_min_locktime(will,default_value=None):
return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value)
@staticmethod
def get_creation_time(will):
"""Return the inheritance creation timestamp (oldest VALID will item).
A positive relative Check Alive value counts forward from this date.
The per-item ``time`` is stamped when the will is built and persisted
in the wallet DB, so the anchor does not drift with the clock. Only
VALID items are considered; imported items without a ``time`` are
ignored. Returns ``None`` when no VALID item carries a timestamp.
"""
return min(
(
v.time
for v in will.values()
if v.get_status('VALID') and v.time is not None
),
default=None,
)
@staticmethod
@@ -1468,37 +1345,6 @@ class WillItem(Logger):
# format_status_history() still translates them.
LEGACY_STATUS_LABELS = (N_("New"),)
# Translations of "New" that test builds of the i18n work saved INTO the
# status history (window.py / the CLI used ``_("New")`` when building).
# They are mapped back to "New" on load so the history is language
# neutral again. Keep in sync with the "New" msgstr of every catalog in
# bal/locale (tests/test_core_status_language_neutral.py checks it).
TRANSLATED_NEW_LABELS = ("Nuovo", "Nuevo")
# Fields that Willexecutors.get_willexecutor_transactions /
# push_transactions_to_willexecutor used to write into the item's own
# executor record (older versions); they are transient push data.
TRANSIENT_PUSH_FIELDS = ("txs", "txsids", "broadcast_status")
@staticmethod
def _untranslate_new_status(status):
"""Map a translated leading "New" of a saved history back to "New"."""
head, sep, rest = status.partition(".")
if head in WillItem.TRANSLATED_NEW_LABELS:
return "New" + sep + rest
return status
@staticmethod
def _without_transient_push_fields(we):
"""Return ``we`` without the transient push fields (a copy if needed)."""
if not isinstance(we, dict) or not any(
k in we for k in WillItem.TRANSIENT_PUSH_FIELDS
):
return we
return {
k: v for k, v in we.items() if k not in WillItem.TRANSIENT_PUSH_FIELDS
}
def set_status(self, status, value=True):
"""Set a status flag and apply the related side effects.
@@ -1580,10 +1426,8 @@ class WillItem(Logger):
w = data
self.tx = Will.get_tx_from_any(w["tx"])
self.heirs = w.get("heirs", None)
self.we = WillItem._without_transient_push_fields(
w.get("willexecutor", None)
)
self.status = WillItem._untranslate_new_status(w.get("status") or "")
self.we = w.get("willexecutor", None)
self.status = w.get("status") or ""
self.description = w.get("description", None)
self.time = w.get("time", None)
self.change = w.get("change", None)

View File

@@ -230,21 +230,7 @@ class Willexecutors:
return True
@staticmethod
def get_willexecutor_transactions(will, force=False, current_willexecutors=None):
"""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``.
``current_willexecutors`` is the CURRENT executor list from the config
(``{url: record}``). When given, an executor that is no longer in it or
no longer selected is skipped. WHY: every will item keeps its own copy
of the executor record taken at build time (``selected`` = True), so
without this check a deselected executor would still receive the will.
``None`` keeps the previous behaviour (only the item's own copy).
The returned records are COPIES: the will items are never modified.
"""
def get_willexecutor_transactions(will, force=False):
willexecutors = {}
for wid, willitem in will.items():
if willitem.get_status("VALID"):
@@ -252,26 +238,12 @@ class Willexecutors:
if not willitem.get_status("PUSHED") or force:
if willexecutor := willitem.we:
url = willexecutor["url"]
if current_willexecutors is not None and not (
Willexecutors.is_selected(
current_willexecutors.get(url)
)
):
continue
if willexecutor and Willexecutors.is_selected(willexecutor):
if url not in willexecutors:
# Work on a COPY of the record: the item's
# own ``we`` dict is saved in the wallet, so
# the transient push fields below (raw txs,
# txids and the - translated - broadcast
# status shown in the push dialog) must
# never end up in it (stored data must not
# depend on the UI language, see bal/i18n.py).
record = dict(willexecutor)
record["txs"] = ""
record["txsids"] = []
record["broadcast_status"] = _("Waiting...")
willexecutors[url] = record
willexecutor["txs"] = ""
willexecutor["txsids"] = []
willexecutor["broadcast_status"] = _("Waiting...")
willexecutors[url] = willexecutor
willexecutors[url]["txs"] += str(willitem.tx) + "\n"
willexecutors[url]["txsids"].append(wid)

View File

@@ -43,18 +43,19 @@ from ...core.animated_qr import (
bbqr_frames,
format_name,
parse_for_detection,
ur1_frames,
ur2_frames,
)
from ...core.checkalive import (
CheckAliveAfterDeliveryError,
CheckAliveError,
resolve_date_to_check,
resolve_relative_check_alive_date,
)
from ...core.checkalive import CheckAliveError, resolve_date_to_check
from ...core.qrtransfer import (
CHUNK_PRESETS,
MissingFramesError,
QrTransferError,
decode_transfer,
encode_transfer,
encode_transfer_best,
preset_index_for_chunk_size,
split_frames,
)
from ...core.reminders import build_ics_reminders
from .calendar import BalCalendarButton
@@ -64,8 +65,8 @@ from .common import (
N_,
AmountException,
Any,
BalTimestamp,
BalanceTooLowException,
BalTimestamp,
BestEffortRequestFailed,
Buttons,
Callable,
@@ -141,6 +142,29 @@ if TYPE_CHECKING:
# 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):
_stopping = False
def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"):
@@ -730,13 +754,6 @@ class BalBuildWillDialog(BalDialog):
varrow = self.msg_set_status(_("Checking variables"))
try:
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:
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1)
tx = Will.invalidate_will(
@@ -1184,11 +1201,10 @@ class BalBuildWillDialog(BalDialog):
self.bal_window.update_setting_widgets(
min_locktime, "locktime", update_all=True
)
# A relative "Check Alive" threshold is also PRESERVED: it is anchored
# on every check by ``resolve_date_to_check`` / ``resolve_guard_threshold``
# (negative counts back from the delivery, positive forward from the
# inheritance creation date), so it never needs freezing to an absolute
# date here.
# A relative "Check Alive" threshold ("N days BEFORE the delivery") is
# also PRESERVED: it is anchored on every check by
# ``resolve_date_to_check`` / ``resolve_guard_threshold``, so it does
# not need to be frozen to an absolute date here.
def on_accept(self):
try:
@@ -1537,18 +1553,6 @@ class BalBuildWillDialog(BalDialog):
self._add_no_heirs_buttons()
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))
password = None
if self.have_to_sign is None:
@@ -1840,14 +1844,9 @@ class BalBuildWillDialog(BalDialog):
threshold = None
num_reminders = 3
else:
# The Check Alive reference shares the same sign rule as the
# core: negative counts back from the delivery, positive counts
# 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_ts = BalTimestamp(
self.bal_window.will_settings["threshold"]
).to_timestamp()
threshold = datetime.fromtimestamp(threshold_ts)
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
@@ -2735,10 +2734,10 @@ class BalQrExportWidget(QWidget):
The page (re)displays itself on :meth:`set_tx_strings`.
"""
def __init__(self, chunk_size=CHUNK_PRESETS[0][1], parent=None, on_size_change=None):
def __init__(self, chunk_size=CHUNK_PRESETS[0][1], parent=None):
QWidget.__init__(self, parent)
self.chunk_size = chunk_size
self._on_size_change = on_size_change
self.format = "balqr"
self.tx_strings = []
self.transfer = ""
self.frames = []
@@ -2804,11 +2803,36 @@ class BalQrExportWidget(QWidget):
size_row.addStretch(1)
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):
"""Rebuild the transfer frames from the given serialized transactions."""
self.tx_strings = list(tx_strings)
self._stop_auto()
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._update_intro()
self._render()
@@ -2818,18 +2842,30 @@ class BalQrExportWidget(QWidget):
return len(self.frames)
def _refresh_frames(self):
self.frames = bbqr_frames(
self.transfer.encode("utf-8"), self.chunk_size, encoding="Z"
)
if self.format == "balqr":
self.frames = split_frames(
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
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):
self._stop_auto()
self.chunk_size = CHUNK_PRESETS[index][1]
self._refresh_frames()
self._render()
if self._on_size_change is not None:
self._on_size_change(self.chunk_size)
def _toggle_auto(self):
"""Start/stop the automatic QR slideshow."""
@@ -2861,13 +2897,32 @@ class BalQrExportWidget(QWidget):
self._next()
def _update_intro(self):
self.intro_label.setText(
_(
"Scan the QR codes below with the will-opening device.\n"
"{} codes carry the whole transfer (any order works, "
"duplicates are ignored)."
).format(self.total_frames)
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(
_(
"Scan the QR codes below with the will-opening device.\n"
"{} codes carry the whole transfer (any order works, "
"duplicates are ignored)."
).format(self.total_frames)
)
def _prev(self):
if self.index > 0:
@@ -2909,7 +2964,6 @@ class WillExportDialog(BalDialog):
def __init__(self, bal_window, will=None, bal_plugin=None, initial_mode="file"):
BalDialog.__init__(self, bal_window.window, bal_plugin, _("Export will"))
self.bal_window = bal_window
self.bal_plugin = bal_plugin
self._source = will if will is not None else bal_window.willitems
self._filters = export_filter_options()
self._filter_index = 0
@@ -2963,10 +3017,7 @@ class WillExportDialog(BalDialog):
self.stacked = QStackedWidget()
self.file_page = self._build_file_page()
self.stacked.addWidget(self.file_page)
self.qr_page = BalQrExportWidget(
chunk_size=chunk,
on_size_change=self._remember_qr_size,
)
self.qr_page = BalQrExportWidget(chunk_size=chunk)
self.stacked.addWidget(self.qr_page)
self.audio_page = self._build_audio_page()
self.stacked.addWidget(self.audio_page)
@@ -2976,19 +3027,12 @@ class WillExportDialog(BalDialog):
close_btn.clicked.connect(self.close)
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 = {
"file": self.MODE_FILE,
"qr": self.MODE_QR,
"audio": self.MODE_AUDIO,
}
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)[
mode
].setChecked(True)
@@ -3023,7 +3067,6 @@ class WillExportDialog(BalDialog):
self._audio_plugin = self.bal_window.get_audio_modem_plugin()
self.bitrates = audio_bitrates()
available = self._audio_plugin is not None
self._audio_available = available
if available:
self.audio_warn_label.hide()
else:
@@ -3052,14 +3095,6 @@ class WillExportDialog(BalDialog):
def _selected_items(self):
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):
"""The data handed to the QR page, honouring the content scope.
@@ -3231,10 +3266,6 @@ class WillImportDialog(BalDialog):
close_btn.clicked.connect(self.close)
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._on_mode_clicked(self.MODE_FILE)
@@ -3262,7 +3293,6 @@ class WillImportDialog(BalDialog):
self._audio_plugin = self.bal_window.get_audio_modem_plugin()
self.bitrates = audio_bitrates()
available = self._audio_plugin is not None
self._audio_available = available
if available:
self.audio_warn_label.hide()
else:
@@ -3503,9 +3533,9 @@ def qr_import_accept_frame(
seen ``stable_reads`` times in a row -- this mirrors the export-side
slideshow pacing and swallows transition artifacts;
* re-reading the currently accepted frame is ``ignore``d;
* a frame whose ``session_key`` (transfer identity, e.g. ``"bbqr:Z:B:3"``)
contradicts the transfer already being built is a ``reset`` (a
different transfer was presented).
* a frame whose ``session_key`` (transfer identity, e.g. ``"balqr:3"`` or
``"ur2:2-31-3804692811"``) contradicts the transfer already being built
is a ``reset`` (a different transfer was presented).
``state`` is a mutable mapping with keys ``last_index``, ``last_payload``,
``pending_index``, ``pending_payload``, ``pending_count`` and ``key``.
@@ -3691,8 +3721,8 @@ class BalQrImportWidget(QWidget):
self._key = key
self._init_slots()
if status == "ok":
# Slot grid is always 1-based even for the 0-based BBQR indexes.
slot = index + 1
# Slot grid is always 1-based even for 0-based wire formats.
slot = index + 1 if fmt == "bbqr" else index
self.frames[slot] = frame_text
self._update_slots()
self._update_status()
@@ -3948,7 +3978,7 @@ class BalQrImportWidget(QWidget):
try:
transfer, compressed = session.resolve()
parts = decode_transfer(transfer, compressed)
except AnimatedQrError as e:
except (MissingFramesError, QrTransferError, AnimatedQrError) as e:
self.show_error(str(e))
self._reset_all()
return

View File

@@ -93,27 +93,13 @@ def _can_broadcast(will_item):
def _can_delete(will_item):
"""True if a will transaction may be deleted.
"""True if a will transaction may be deleted (invalid or unsigned).
Deletable:
* invalid or not fully signed transactions (unchanged rule);
* valid AND fully signed transactions that their will-executor NEVER
received: not PUSHED and not CHECKED (e.g. "Push failed" /
"Not on server", or never sent).
Not deletable: a valid, signed transaction that the will-executor holds
(PUSHED or CHECKED). WHY: deleting it only on this PC would not remove it
from the executor's server, so the user would wrongly believe that
inheritance was cancelled; that case needs an on-chain invalidation.
Signed transactions without a will-executor (backup txs) also stay
protected, as before.
Valid AND fully-signed transactions are never deletable: removing them
would silently drop a committed inheritance.
"""
if not will_item:
return False
if not will_item.get_status("VALID") or not will_item.get_status("COMPLETE"):
return True
return bool(getattr(will_item, "we", None)) and not (
will_item.get_status("PUSHED") or will_item.get_status("CHECKED")
return bool(will_item) and (
not will_item.get_status("VALID") or not will_item.get_status("COMPLETE")
)

View File

@@ -19,6 +19,7 @@ from electrum.plugin import hook
from electrum.util import EventListener, event_listener
from PyQt6.QtWidgets import QLayout
from ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size
from ...i18n import init_from_config
from .common import (
BalPlugin,
@@ -537,6 +538,22 @@ class Plugin(BalPlugin, EventListener):
# users (BASIC and ADVANCED).
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
# (not a free-text field) bound to the USER_TYPE config:
# index 0 -> "BASIC" -> stored value "basic" (DEFAULT)
@@ -653,6 +670,10 @@ class Plugin(BalPlugin, EventListener):
widget.setCurrentIndex(
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)
return btn
@@ -911,6 +932,25 @@ class Plugin(BalPlugin, EventListener):
)
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 #
# their factory defaults. It only resets the settings exposed by THIS #
@@ -944,6 +984,7 @@ class Plugin(BalPlugin, EventListener):
(self.HISTORY_LABEL, edit_history_label, "line"),
(self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"),
(self.AUTO_REBUILD, heir_auto_rebuild, "check"),
(self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"),
]
for cfg, widget, kind in resets:
# Persist the default value back into the Electrum config.
@@ -964,6 +1005,10 @@ class Plugin(BalPlugin, EventListener):
widget.setCurrentIndex(
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
# reset restores SAVE_HISTORY to its default, so the field must
# follow the (default) checkbox state again.

View File

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

View File

@@ -20,11 +20,9 @@ import threading
from electrum.util import MyEncoder
from ...core.checkalive import (
CheckAliveAfterDeliveryError,
CheckAliveError,
ensure_check_alive_valid,
check_alive_expired,
resolve_date_to_check,
resolve_delivery_date,
resolve_guard_threshold,
)
from .common import (
@@ -494,7 +492,6 @@ class BalWindow:
self.bal_plugin.is_basic_mode(),
self.will_settings,
built_locktime=_new_locktime,
creation_date=Will.get_creation_time(self.willitems),
)
will = {}
# willtodelete = []
@@ -551,9 +548,7 @@ class BalWindow:
tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
# Stored history: always English (translated only for display by
# format_status_history); never _() here.
tx["status"] = "New"
tx["status"] = _("New")
tx["baltx_fees"] = txs[txid].tx_fees
tx["time"] = creation_time
tx["heirs"] = copy_structure(txs[txid].heirs)
@@ -745,7 +740,6 @@ class BalWindow:
self.bal_plugin.is_basic_mode(),
self.will_settings,
built_locktime=Will.get_min_locktime(self.willitems),
creation_date=Will.get_creation_time(self.willitems),
)
# found = False
# NOTE: block-height tracking removed (A1) - locktimes are always
@@ -756,20 +750,19 @@ class BalWindow:
self.willexecutors = Willexecutors.get_willexecutors(
self.bal_plugin, update=True, bal_window=self, task=False
)
# The Check Alive must be strictly after today and strictly before
# the delivery. In BASIC mode the single validity gate below is a
# no-op (see ``bal.core.checkalive.ensure_check_alive_valid``), so a
# passed check-alive date never forces a postpone/rewrite; the
# delivery time (locktime) stays fully enforced.
delivery_date = resolve_delivery_date(
self.will_settings,
built_locktime=Will.get_min_locktime(self.willitems),
)
ensure_check_alive_valid(
self.bal_plugin.is_basic_mode(),
self.date_to_check,
delivery_date=delivery_date,
)
# SIMPLE / ADVANCED: in BASIC mode the "Check Alive" parameter must
# behave AS IF IT DID NOT EXIST. The check-alive threshold drives
# the "you are alive -> postpone the inheritance" prompt; raising
# CheckAliveError here is what triggers that postpone/invalidate
# flow. In BASIC we therefore SKIP this check entirely, so a passed
# check-alive date never forces a postpone/rewrite of the will. The
# delivery time (locktime) is unaffected and still fully enforced.
# The BASIC/ADVANCED rule lives in
# ``bal.core.checkalive.check_alive_expired``.
if check_alive_expired(
self.bal_plugin.is_basic_mode(), self.date_to_check
):
raise CheckAliveError(self.date_to_check)
self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get())
@@ -794,9 +787,7 @@ class BalWindow:
"""
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
threshold_ts = resolve_guard_threshold(
self.bal_plugin.is_basic_mode(),
self.will_settings,
creation_date=Will.get_creation_time(self.willitems),
self.bal_plugin.is_basic_mode(), self.will_settings
)
if threshold_ts is not None:
return locktime < threshold_ts
@@ -846,13 +837,6 @@ class BalWindow:
_("Will-executor fee too high: {}").format(e)
)
return
except CheckAliveAfterDeliveryError:
self.show_error(
_(
"Check Alive must be after today and before the delivery date, please update it"
)
)
return
except CheckAliveError:
self.show_error(
_(
@@ -1314,12 +1298,6 @@ class BalWindow:
# means the old will must be invalidated on-chain.
try:
self.init_class_variables()
except CheckAliveAfterDeliveryError:
_logger.info(
"auto rebuild: check-alive not before the delivery, "
"nothing to rebuild/invalidate"
)
return False
except CheckAliveError:
_logger.info("auto rebuild: check-alive threshold passed -> invalidate")
self._auto_invalidate_will()
@@ -1632,15 +1610,7 @@ class BalWindow:
willitems = {
t: willitems[t] for t in txids if t in willitems
}
# Read the CURRENT executor list from the config, so an executor the
# user deselected after building the will is not contacted anymore.
willexecutors = Willexecutors.get_willexecutor_transactions(
willitems,
force=force,
current_willexecutors=Willexecutors.get_willexecutors(
self.bal_plugin, update=False, task=False
),
)
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
def getMsg(willexecutors):
msg = _("Broadcasting Transactions to Will-Executors:") + "\n"

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,18 +32,6 @@ you change anything in your will, BAL must decide between three outcomes:
The whole point of rule 3 is safety: **a will‑executor must never be able to
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)
@@ -176,7 +164,6 @@ exactly what the will‑executors hold — not the in‑memory copy.
| **Move date EARLIER, still in the future** (anticipate) | **any** (signed or not) | Plain **rebuild** with the new earlier locktime (`HeirNotFound` fall‑through) — **never** an on‑chain invalidation | **No** |
| **Move date EARLIER into the past** (new date already passed) | — | Will is genuinely **expired** → **invalidate** (`WillExpired`) | **Yes** |
| Check‑Alive threshold already passed | — | **Invalidate** (`CheckAliveError`) | **Yes** |
| Check‑Alive date not before the delivery (config error) | — | **Abort**, no rebuild/invalidate (`CheckAliveAfterDeliveryError`) | **No** |
> **Why postpone needs an on‑chain invalidation:** the will‑executor still holds
> the *old* transaction with the *earlier* locktime. If you simply re‑signed a

View File

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

View File

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

View File

@@ -240,7 +240,7 @@ def test_build_will_reanchors_date_to_check_to_new_locktime():
plugin.USER_TYPE.set("advanced")
plugin.NO_WILLEXECUTOR.set(True)
plugin.ENABLE_MULTIVERSE.set(True)
plugin.WILL_SETTINGS.set({"threshold": "-150d", "locktime": "2y", "baltx_fees": 20})
plugin.WILL_SETTINGS.set({"threshold": "150d", "locktime": "2y", "baltx_fees": 20})
c = _make_controller(plugin)
c.no_willexecutor = True
c.heirs["alice"] = [VALID_ADDRESS, "100%", "1y"]

View File

@@ -1,9 +1,11 @@
"""
Tests for ``bal.core.animated_qr`` (BBQR interop).
Tests for ``bal.core.animated_qr`` (BC-UR v1, BC-UR v2, BBQR interop).
Validates the self-contained BBQR codec against round trips, out-of-order
assembly, compression default/fallback, malformed-input rejection and the
format detection / parse_for_detection helpers.
Validates the self-contained codecs against the published spec vectors
(BCR-2020-004/005 BC32, BCR-2020-012 bytewords) and against byte-exact
output captured from the reference C++ bc-ur encoder (fountain/xoshiro/
alias-sampler parity), plus round trips, out-of-order assembly, missing-part
fountain solving and malformed-input rejection for all four formats.
Run:
source electrum/env/bin/activate
@@ -20,6 +22,328 @@ import random
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
# --------------------------------------------------------------------------- #
@@ -100,22 +424,41 @@ def test_bbqr_part_number_limits():
# --------------------------------------------------------------------------- #
def test_detect_format_recognises_bbqr():
def test_detect_format_recognises_all_formats():
assert aq.detect_format("BALQR1|1|1||payload") == "balqr"
assert aq.detect_format("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(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():
for text in ("", "hello world", "BALQR1|1|1||payload", "ur:", "ur:bytes/xyz"):
for text in ("", "hello world", "BALQ|1|1||a", "ur:", "ur:txn/xyz"):
assert aq.detect_format(text) is None, text
# 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():
bb = aq.parse_for_detection(aq.bbqr_frames(b"x" * 50, 40)[0])
bal = aq.parse_for_detection("BALQR1|3|2||payload")
assert bal == ("balqr", "balqr:3", 3, 2)
# 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]
def test_format_names_exist():
assert aq.format_name("bbqr")
for fmt in ("balqr", "ur1", "ur2", "bbqr"):
assert aq.format_name(fmt)
assert aq.format_name("nope") == "nope"

View File

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

View File

@@ -1,82 +0,0 @@
"""
Tests for ``Willexecutors.get_willexecutor_transactions`` with the CURRENT
executor selection (``current_willexecutors``).
Every will item keeps its own copy of the executor record taken at build time
(``selected`` = True). Without the current selection, an executor the user
deselected after building the will would still receive the will.
Run (standalone or with pytest):
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 tests/test_core_deselected_executor.py
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.willexecutors import Willexecutors
class FakeItem:
"""Minimal will item: statuses, a tx and the build-time executor copy."""
def __init__(self, url, **flags):
self._status = {"VALID": True, "COMPLETE": True}
self._status.update(flags)
self.we = {"url": url, "selected": True}
self.tx = f"rawtx-{url}"
def get_status(self, name):
return self._status.get(name, False)
A = "https://we.a.example"
B = "https://we.b.example"
def _will():
return {"tx_a": FakeItem(A), "tx_b": FakeItem(B)}
def test_without_current_list_previous_behaviour():
assert set(Willexecutors.get_willexecutor_transactions(_will())) == {A, B}
def test_deselected_executor_is_skipped():
current = {A: {"selected": True}, B: {"selected": False}}
out = Willexecutors.get_willexecutor_transactions(
_will(), current_willexecutors=current
)
assert set(out) == {A}
assert out[A]["txsids"] == ["tx_a"]
def test_executor_removed_from_list_is_skipped():
current = {A: {"selected": True}}
out = Willexecutors.get_willexecutor_transactions(
_will(), current_willexecutors=current
)
assert set(out) == {A}
def test_already_pushed_still_excluded_unless_forced():
will = {"tx_a": FakeItem(A, PUSHED=True)}
current = {A: {"selected": True}}
assert Willexecutors.get_willexecutor_transactions(
will, current_willexecutors=current
) == {}
assert set(
Willexecutors.get_willexecutor_transactions(
will, force=True, current_willexecutors=current
)
) == {A}
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All deselected-executor tests passed")

View File

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

View File

@@ -1,231 +0,0 @@
"""
Tests for the fee of the on-chain INVALIDATION transaction.
The invalidation is broadcast TODAY (it spends the will's coins so the old
pre-signed inheritance transactions become unusable), therefore it must pay the
CURRENT network fee for a confirmation within
``INVALIDATION_FEE_ETA_BLOCKS`` (2) blocks. The will's ``baltx_fees`` (a
deliberately high rate meant for inheritance transactions broadcast years from
now) is only the FALLBACK used when no network estimate is available.
Covers:
* Will.get_invalidation_fee_rate() - rate selection and fallback rules
* Will.invalidate_will() - the chosen rate is really used for the fee
Run (standalone or with pytest):
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 tests/test_core_invalidation_fee.py
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_core_invalidation_fee.py -q
"""
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum.transaction import Transaction
from bal.core.will import INVALIDATION_FEE_ETA_BLOCKS, Will, WillItem
# WillItem parses a raw tx and calls add_info_from_wallet; no live wallet here.
_patcher = patch.object(Transaction, "add_info_from_wallet")
_patcher.start()
# A valid serialized transaction (1 input, 1 output) reused by the core tests.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
# The outpoint spent by _VALID_TX_HEX (input 0).
_PREVOUT_STR = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0"
_CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
_BALANCE = 1000000
# The will's baltx_fees used as the fallback rate in every test (sat/vbyte).
_BALTX_FEES = 20
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _wallet_with_estimate(fee_per_kb):
"""Wallet whose network returns ``fee_per_kb`` (sat/kvbyte) for any ETA.
``fee_per_kb`` may also be an Exception instance: the estimate call then
raises it, to simulate a broken network object.
"""
wallet = MagicMock()
wallet.get_change_addresses_for_new_transaction.return_value = [_CHANGE_ADDR]
network = MagicMock()
if isinstance(fee_per_kb, Exception):
network.fee_estimates.eta_target_to_fee.side_effect = fee_per_kb
else:
network.fee_estimates.eta_target_to_fee.return_value = fee_per_kb
wallet.network = network
return wallet
def _make_will():
"""One VALID will item spending _PREVOUT_STR with a known input value."""
item = WillItem({
"tx": _VALID_TX_HEX,
"heirs": {"alice": ["addr_alice", 5000, "30d"]},
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": _BALTX_FEES,
})
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
item.tx.inputs()[0]._TxInput__value_sats = _BALANCE
return {"willtxid1": item}
def _make_utxo():
"""Wallet-side UTXO matching the will input."""
utxo = MagicMock()
utxo.prevout.to_str.return_value = _PREVOUT_STR
utxo.is_coinbase_output.return_value = False
utxo.block_height = 1
utxo.value_sats.return_value = _BALANCE
return utxo
def _invalidation_output_value(wallet, estimated_size=200):
"""Run Will.invalidate_will and return the value of the final output.
Electrum's tx building is mocked (as in test_core_will_invalidate.py):
``estimated_size`` is the vsize the fee is computed from.
"""
wallet.get_utxos.return_value = [_make_utxo()]
mock_output = MagicMock()
mock_tx = MagicMock()
mock_tx.estimated_size.return_value = estimated_size
with patch("bal.core.will.Util.get_current_height", return_value=800000), \
patch("electrum.transaction.PartialTxOutput.from_address_and_value",
return_value=mock_output) as mock_out, \
patch("electrum.transaction.PartialTransaction.from_io",
return_value=mock_tx):
result = Will.invalidate_will(_make_will(), wallet, _BALTX_FEES)
assert result is mock_tx
# 1st call: full balance (size estimation); 2nd call: balance - fee.
return mock_out.call_args_list[1][0][1]
# ------------------------------------------------------------------ #
# Will.get_invalidation_fee_rate
# ------------------------------------------------------------------ #
def test_target_is_two_blocks():
"""The owner's decision: confirm within 2 blocks."""
assert INVALIDATION_FEE_ETA_BLOCKS == 2
wallet = _wallet_with_estimate(3000)
Will.get_invalidation_fee_rate(wallet, _BALTX_FEES)
wallet.network.fee_estimates.eta_target_to_fee.assert_called_once_with(2)
def test_network_estimate_is_used():
"""A live estimate (sat/kvbyte) becomes the rate in sat/vbyte."""
wallet = _wallet_with_estimate(3000)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (3.0, "network")
def test_network_estimate_higher_than_baltx_fees_is_used_as_is():
"""No cap: if the market is above baltx_fees, the market rate is used."""
wallet = _wallet_with_estimate(45000)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (45.0, "network")
def test_fractional_estimate_is_kept():
"""Electrum estimates may be fractional (e.g. 1.5 sat/vbyte)."""
wallet = _wallet_with_estimate(1500)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (1.5, "network")
def test_offline_wallet_falls_back_to_baltx_fees():
"""No network object (offline): previous behaviour, baltx_fees."""
wallet = MagicMock()
wallet.network = None
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
)
def test_missing_estimate_falls_back():
"""Estimates not received yet: Electrum returns None."""
wallet = _wallet_with_estimate(None)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
)
def test_estimate_error_falls_back_and_does_not_raise():
"""A broken network object must never break the invalidation."""
wallet = _wallet_with_estimate(RuntimeError("server gone"))
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
)
def test_non_integer_or_invalid_estimates_fall_back():
"""Only a positive int (Electrum's sat/kvbyte type) is trusted."""
for bad in (0, -1000, True, "3000", 3.5, MagicMock()):
wallet = _wallet_with_estimate(bad)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
), bad
def test_no_wallet_falls_back():
assert Will.get_invalidation_fee_rate(None, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
)
# ------------------------------------------------------------------ #
# Will.invalidate_will uses the chosen rate
# ------------------------------------------------------------------ #
def test_invalidation_pays_network_fee_not_baltx_fees():
"""3 sat/vbyte on 200 vbytes = 600 sat (not 200 * 20 = 4000 sat)."""
wallet = _wallet_with_estimate(3000)
assert _invalidation_output_value(wallet) == _BALANCE - 600
def test_invalidation_offline_pays_baltx_fees():
"""Offline: unchanged behaviour, 200 * 20 = 4000 sat."""
wallet = _wallet_with_estimate(None)
assert _invalidation_output_value(wallet) == _BALANCE - 4000
def test_fractional_fee_is_rounded_up_to_whole_satoshis():
"""1.5 sat/vbyte on 201 vbytes = 301.5 -> 302 sat (never below the rate)."""
wallet = _wallet_with_estimate(1500)
value = _invalidation_output_value(wallet, estimated_size=201)
assert value == _BALANCE - 302
assert isinstance(value, int)
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All invalidation fee tests passed")

View File

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

View File

@@ -1,8 +1,10 @@
"""
Tests for ``bal.core.qrtransfer``.
Covers the transfer-string encoding used for will transfer via BBQR QR codes
/ audio modem: encoding, decoding and the preset list.
Covers the BALQR frame encoding used for will transfer via QR codes /
audio modem: encoding, framing, reassembly, malformed input and the preset
list (optionally cross-checked against the ``qrcode`` library's EC-M
capacity when it is installed).
Run:
source electrum/env/bin/activate
@@ -16,12 +18,34 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.qrtransfer import (
CHUNK_PRESETS,
MIN_CHUNK_SIZE,
InconsistentTotalError,
MissingFramesError,
QrTransferError,
assemble,
decode_transfer,
encode_transfer,
parse_frame,
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
# --------------------------------------------------------------------------- #
@@ -43,6 +67,300 @@ def test_empty_list_roundtrip():
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
# --------------------------------------------------------------------------- #
@@ -61,6 +379,46 @@ def test_preset_index_for_chunk_size():
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__":
import traceback

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -92,31 +92,6 @@ def test_can_delete_none_false():
assert _can_delete(None) is False
def _signed_valid(we="https://we.example", **flags):
"""A VALID + COMPLETE (signed) item, optionally with a will-executor."""
item = FakeWillItem(VALID=True, COMPLETE=True, **flags)
item.we = {"url": we, "selected": True} if we else None
return item
def test_can_delete_signed_but_never_received_by_executor():
"""Push failed / "Not on server": the executor does not hold it."""
assert _can_delete(_signed_valid(PUSH_FAIL=True, CHECK_FAIL=True)) is True
assert _can_delete(_signed_valid()) is True # signed, never sent
def test_can_delete_signed_and_pushed_false():
"""The executor holds it: deleting locally would not cancel it."""
assert _can_delete(_signed_valid(PUSHED=True)) is False
assert _can_delete(_signed_valid(PUSHED=True, CHECKED=True)) is False
assert _can_delete(_signed_valid(CHECKED=True)) is False
def test_can_delete_signed_backup_tx_without_executor_false():
"""Signed txs without a will-executor (backup) stay protected."""
assert _can_delete(_signed_valid(we=None)) is False
# ------------------------------------------------------------------ #
# _apply_select_all
# ------------------------------------------------------------------ #