Compare commits
11 Commits
4a33116b66
...
fix/i18n-r
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bb9fe812c | |||
|
827a1d1e54
|
|||
|
d6fd20ad9c
|
|||
|
5ec2b79d03
|
|||
|
47b120f526
|
|||
|
1b601609f0
|
|||
|
b1f78700d1
|
|||
|
beab22ce23
|
|||
| 70196fc3cd | |||
| 9c654bf2bf | |||
| e8db76338d |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -34,3 +34,9 @@ tmp*
|
||||
# Release artifacts
|
||||
bal_v*.zip.*
|
||||
tests/karen7
|
||||
|
||||
# Compiled translations: build_zip.py rebuilds them from the .po sources
|
||||
*.mo
|
||||
|
||||
# Android companion app: kept locally only, not part of the plugin repo
|
||||
android/
|
||||
|
||||
31
AGENTS.md
31
AGENTS.md
@@ -83,6 +83,32 @@ QT_QPA_PLATFORM=offscreen python3 -m pytest tests/test_core_*.py -q
|
||||
registration API differs between them (`json_db.register_dict` vs
|
||||
`stored_dict.register_name`).
|
||||
|
||||
## Translations (i18n)
|
||||
|
||||
How it works: `bal/i18n.py` (Electrum's catalog first, then BAL's, then
|
||||
English). Plan and decisions: `PLAN_I18N.md`.
|
||||
|
||||
- Sources in git: `babel.cfg`, `bal/locale/bal.pot` (template) and
|
||||
`bal/locale/<lang>/LC_MESSAGES/bal.po` (one per language). The compiled
|
||||
`.mo` files are NOT in git: `build_zip.py` builds them into the zip.
|
||||
- Writing texts: `_("... {}").format(x)`, never f-strings or `%` inside
|
||||
`_()` (Ruff INT); `N_()` for class attributes/constants, translated with
|
||||
`_()` when shown. Never translate stored or compared text (wallet labels,
|
||||
`HISTORY_LABEL`, the status history).
|
||||
- After changing texts, refresh the catalogs (then translate the new
|
||||
entries, e.g. with Poedit):
|
||||
```bash
|
||||
pybabel extract -F babel.cfg --no-wrap -o bal/locale/bal.pot .
|
||||
pybabel update -i bal/locale/bal.pot -d bal/locale -D bal --no-wrap
|
||||
```
|
||||
- New language: `pybabel init -i bal/locale/bal.pot -d bal/locale -D bal -l <lang>`
|
||||
(use Electrum's language code, e.g. `de_DE`).
|
||||
- Dev install (symlink, not the zip): compile once to see the translations,
|
||||
`pybabel compile -d bal/locale -D bal`.
|
||||
- `tests/test_translations.py` checks every catalog ({} fields, `$tokens`,
|
||||
no addresses/links/e-mails added by a translation) and prints a summary
|
||||
when run standalone.
|
||||
|
||||
## Build / release
|
||||
|
||||
```bash
|
||||
@@ -90,6 +116,11 @@ python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha25
|
||||
./make-release.sh [v0.x.y] # bump manifest version, tag, sign, push Gitea release
|
||||
```
|
||||
|
||||
- `build_zip.py` needs **Babel** (`pip install babel`): it compiles the
|
||||
translation catalogs into the zip and stops if Babel is missing. The i18n
|
||||
tests need it too. Install it in the **runtime** env used for releases
|
||||
(`source "$BAL_HOME/electrum/env/bin/activate" && pip install babel`);
|
||||
`make-release.sh` checks it before running anything.
|
||||
- `make-release.sh` requires gpg and Gitea credentials (`~/.git-credentials`
|
||||
or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the
|
||||
version there, never invent a new source of truth.
|
||||
|
||||
455
CHANGELOG.md
455
CHANGELOG.md
@@ -3327,3 +3327,458 @@ densest frames possible.
|
||||
the base36 count fields are 3 chars regardless of how many frames exist.
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
## 58. i18n phase 1: BAL translation layer (Electrum first, then BAL)
|
||||
|
||||
**Date:** 2026-09-26 (branch `feature/i18n`, see `PLAN_I18N.md`)
|
||||
|
||||
**Goal (owner request):** make the whole plugin translatable, starting with
|
||||
Italian, so that BAL follows the language chosen in Electrum. This phase adds
|
||||
the mechanism only: nothing visible changes yet.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- New `bal/i18n.py`, BAL's own gettext layer (domain `bal`, catalogs in
|
||||
`bal/locale/<lang>/LC_MESSAGES/bal.mo`, read with `plugin.read_file()` so it
|
||||
works from the zip and from a development checkout):
|
||||
- `_()` looks a text up in Electrum's catalog first, then in BAL's, then
|
||||
returns the English source (owner's decision D7: BAL reads like Electrum,
|
||||
and Electrum's translations would take over if BAL ever became an internal
|
||||
plugin). BAL translations whose `{}` fields differ from the source are
|
||||
rejected with the same rules as Electrum's.
|
||||
- `N_()` marks texts defined at import time, translated when shown.
|
||||
- `init_from_config()` repeats Electrum's own language choice (Preferences >
|
||||
Language, else the supported system language, else English) once, in the
|
||||
Qt `Plugin.__init__`. A missing or broken catalog never stops the plugin.
|
||||
- `common.py`, `theme.py`, `core/will.py`, `core/willexecutors.py` now import
|
||||
`_` from `bal.i18n`. The CLI keeps Electrum's translator and stays English,
|
||||
like Electrum's own CLI.
|
||||
|
||||
**Verification:** new `tests/test_i18n.py`; results in entry 59.
|
||||
|
||||
**Outcome:** DONE (delivered in one test ZIP together with entry 59).
|
||||
|
||||
## 59. i18n phase 2: translatable texts and language-neutral stored data
|
||||
|
||||
**Date:** 2026-09-26 (branch `feature/i18n`, see `PLAN_I18N.md`)
|
||||
|
||||
**Goal:** make every user-visible text of the Qt GUI extractable and
|
||||
translatable, without changing the English UI, and make sure no stored data
|
||||
depends on the GUI language.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- Texts built with f-strings or `.format()` inside `_()`, or glued together
|
||||
from pieces, are now whole sentences with `{}` fields (Ruff INT clean).
|
||||
- Class-level texts (list headers, wizard titles/messages, help texts,
|
||||
download error messages, status labels) are marked with `N_()` and
|
||||
translated when shown: class bodies run before the catalog is loaded.
|
||||
- Helpers no longer translate their argument (`add_widget`, `get_window_title`,
|
||||
`msg_set_status`, the will-detail `qlabel`): callers pass translated text.
|
||||
This also stops heir names and URLs from going through the translator.
|
||||
- Newly translatable: the settings help texts, the wizard download options,
|
||||
the build report rows, the Ping/Select menu buttons, the QR size presets
|
||||
(marked in `common.QR_PRESET_LABELS`: `core/qrtransfer.py` stays free of
|
||||
Electrum imports because the Android reader ships a copy of it), and the
|
||||
"(reminder i/n)" suffix of the calendar events (`build_ics_reminders` gained
|
||||
`reminder_suffix`, English by default for the CLI).
|
||||
- **Stored status history:** `WillItem.set_status` now always writes the
|
||||
English label; the new `format_status_history()` translates it for display
|
||||
(lists and will detail) and keeps unknown tokens as they are, so histories
|
||||
saved by older versions (e.g. `New.Firmato.Pushed`) still display fine.
|
||||
- **Calendar defaults:** `EVENT_SUMMARY` / `EVENT_DESCRIPTION` defaults follow
|
||||
the GUI language (`BalConfig(..., translatable=True)`), while the English
|
||||
original is what gets stored; a text written by the user is kept as it is.
|
||||
- **Not translated on purpose:** `HISTORY_LABEL` (used by
|
||||
`Util._label_matches_history` to recognise BAL's own local transactions),
|
||||
the "BAL Invalidate/Inheritance transaction" wallet labels,
|
||||
`heirs.TRANSACTION_LABEL` and the will-executor "info" fallback stored in the
|
||||
config.
|
||||
- Two small visible fixes: the will detail showed "Transaction fees::" and
|
||||
"Status::" with a double colon.
|
||||
- `pyproject.toml`: Ruff now also checks the `INT` (gettext) rules.
|
||||
|
||||
**Verification (Electrum 4.8.2, Python 3.14, offscreen):**
|
||||
|
||||
- `tests/test_i18n.py`: 20 passed (lookup order, rejected `{}` fields,
|
||||
missing/broken catalogs, language fallback, English status storage and its
|
||||
display translation, translatable config defaults, reminder suffix, QR
|
||||
preset labels).
|
||||
- Release-gate batch (as `make-release.sh`): 403 passed, 1 failed; the other
|
||||
offline test files: 213 passed. Identical to the baseline taken before the
|
||||
change; the only failures are pre-existing (`test_e5` and
|
||||
`test_reproduce_none_type` need the local `tests/karen7` wallet,
|
||||
`test_import_start_stop_scan_signal_wiring` fails on `main` too,
|
||||
`test_gui_export_dialogs.py` hangs on `main` too).
|
||||
- `smoke_test.py`, `build_zip.py` and `external_zip_test.py`: OK.
|
||||
- Ruff: INT clean; no new errors (the only one left is the pre-existing
|
||||
`dialogs.py` I001).
|
||||
- Babel extraction (GUI + core): 424 strings (about 3,000 words), was 323.
|
||||
|
||||
**Outcome:** DONE, pending the owner's test of the ZIP.
|
||||
|
||||
## 60. i18n phase 3: Italian catalog
|
||||
|
||||
**Date:** 2026-09-26 (branch `feature/i18n`, see `PLAN_I18N.md`)
|
||||
|
||||
**Goal (owner request):** with Electrum set to Italian, BAL shows in Italian.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `babel.cfg` (extraction mapping, CLI and wallet_util excluded),
|
||||
`bal/locale/bal.pot` (template, 424 texts) and the Italian catalog
|
||||
`bal/locale/it_IT/LC_MESSAGES/bal.po`: 43 entries copied from Electrum's
|
||||
own it_IT catalog (same English text), the others drafted following the
|
||||
owner's glossary (D1). Draft to be reviewed by the owner (D2).
|
||||
- `build_zip.py` compiles every `bal.po` into `bal.mo` inside the zip
|
||||
(Babel required, the build stops without it); `.po`/`.pot` are not
|
||||
shipped and `*.mo` is ignored by git (D4).
|
||||
- New `tests/test_translations.py`: every catalog compiles; `{}` fields and
|
||||
`$tokens` match the English text; no Bitcoin address, e-mail, URL or long
|
||||
letters-and-digits word that the English text does not have (patterns from
|
||||
Electrum's `electrum-locale/update.py`).
|
||||
- `AGENTS.md`: how to update the catalogs, add a language, compile for the
|
||||
development install.
|
||||
|
||||
**Verification (Electrum 4.8.2, offscreen):** `tests/test_translations.py`
|
||||
OK (it_IT: 424 translated, 0 untranslated, 0 fuzzy); all offline tests 640
|
||||
passed, only the pre-existing failures of entry 59; the built zip contains
|
||||
only `bal/locale/it_IT/LC_MESSAGES/bal.mo` for the translations and
|
||||
`external_zip_test.py` loads it; Ruff: no new errors.
|
||||
|
||||
**Outcome:** DONE, pending the owner's review of the Italian texts and test
|
||||
of the ZIP.
|
||||
## 61. Signed relative Check Alive (negative = before delivery, positive = after creation)
|
||||
|
||||
**Date:** 2026-10-01
|
||||
|
||||
**Goal:** Change the meaning of a RELATIVE Check Alive value so that its SIGN
|
||||
selects the anchor date:
|
||||
|
||||
- NEGATIVE (`-30d` / `-1y`): counted BACKWARDS from the delivery date
|
||||
(`delivery + N days`; `-30d` = 30 days BEFORE the delivery). This preserves
|
||||
the historical meaning of a relative threshold.
|
||||
- POSITIVE (`30d` / `1y`): counted FORWARD from the inheritance creation date
|
||||
(`min(WillItem.time)` over the VALID will items, frozen in the wallet DB).
|
||||
- When the anchor is unknown (no built will yet, or imported items without a
|
||||
`time`), the reference falls back to *now*.
|
||||
|
||||
No migration: existing stored positive values switch to the new meaning. The
|
||||
Check Alive must always be strictly AFTER today and strictly BEFORE the
|
||||
delivery; when it is not, the plugin raises an explicit error (see below).
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/checkalive.py` (centralised, GUI-free):
|
||||
- new `resolve_delivery_date(will_settings, now, built_locktime)` — the single
|
||||
source of the delivery reference (absolute locktime as-is; relative locktime
|
||||
resolved forward from *now* unless a built will freezes it).
|
||||
- new `resolve_relative_check_alive_date(threshold_raw, delivery_date,
|
||||
creation_date, now)` — the SINGLE calculation rule (sign selects the anchor;
|
||||
`BalTimestamp.to_date` is used forward on both anchors, no more
|
||||
`reverse=True`).
|
||||
- new `is_check_alive_date_valid(date_to_check, delivery_date, now)` and
|
||||
`ensure_check_alive_valid(is_basic_mode, date_to_check, delivery_date, now)`
|
||||
— the SINGLE validity gate: `now < date_to_check < delivery`. BASIC mode is
|
||||
a no-op (Check Alive is inert).
|
||||
- `resolve_date_to_check` / `resolve_guard_threshold`: gain a `creation_date`
|
||||
parameter and delegate to the shared rule; `resolve_guard_threshold` still
|
||||
resolves the delivery from the stored settings alone (no built anchor).
|
||||
- `check_alive_expired` removed (superseded by `ensure_check_alive_valid`).
|
||||
- new `CheckAliveAfterDeliveryError` (a SIBLING of `CheckAliveError`, not a
|
||||
subclass): raised when the Check Alive is on/after the delivery. It must not
|
||||
reuse the `except CheckAliveError` handlers, which invalidate the will
|
||||
on-chain; this is a configuration error that only aborts the action.
|
||||
`CheckAliveError` gained an optional `reason`.
|
||||
- `bal/core/will.py`: new `Will.get_creation_time(will)` — the oldest
|
||||
`WillItem.time` among VALID items (`None` when unknown).
|
||||
- `bal/core/input_rules.py`: `normalize_locktime_raw_text(..., allow_negative)`
|
||||
keeps a single leading `-` (Check Alive field only).
|
||||
- `bal/gui/qt/widgets.py`: the Check Alive RAW editor accepts `-`
|
||||
(`ThresholdTimeWidget.allow_negative_raw`); `on_locktime_change` uses the
|
||||
shared resolver for `real_threshold`; updated help text.
|
||||
- `bal/gui/qt/window.py`, `bal/gui/qt/dialogs.py`, `bal/cli/controller.py`: pass
|
||||
`creation_date=Will.get_creation_time(...)`, call
|
||||
`ensure_check_alive_valid`, and handle `CheckAliveAfterDeliveryError` by
|
||||
aborting (never invalidating). The wizard .ics provider and the CLI guard use
|
||||
the shared resolver too.
|
||||
- `bal/core/plugin_base.py`: the default relative Check Alive is now `"-30d"`
|
||||
(30 days BEFORE the 1-year delivery), preserving the historical default; the
|
||||
absolute default threshold follows the same sign rule.
|
||||
|
||||
**Tests:**
|
||||
|
||||
- `tests/test_core_checkalive.py` rewritten (both signs, delivery/creation
|
||||
resolution, validity gate, new exceptions, `Will.get_creation_time`).
|
||||
- Updated `tests/test_group_i_basic_checkalive.py`, `tests/test_core_will.py`,
|
||||
`tests/test_gui_will_flows.py`, `tests/test_cli_controller_offline.py`,
|
||||
`tests/test_core_plugin_base.py`, `tests/test_core_input_rules.py`.
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
## 62. Remove BALQR and BC-UR v1/v2 — keep only BBQR
|
||||
|
||||
**Date:** 2026-10-03
|
||||
|
||||
**Goal:** Drop the BALQR, BC-UR v1 and BC-UR v2 QR wire formats and keep only
|
||||
**BBQR** for will transfer.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/animated_qr.py`: removed the BC32, bytewords-minimal, CRC-32,
|
||||
CBOR, xoshiro256/fountain codecs and the BALQR / UR v1 / UR v2 receive
|
||||
sessions; the module now implements only the BBQR codec plus the
|
||||
`AnimatedQrSession` facade and `detect_format` / `parse_for_detection`
|
||||
(BBQR-only).
|
||||
- `bal/core/qrtransfer.py`: removed the BALQR framing helpers (`split_frames`,
|
||||
`parse_frame`, `assemble`, `encode_transfer_best`, the frame errors);
|
||||
kept the transfer-string codec (`encode_transfer` / `decode_transfer`) and
|
||||
the QR-size presets.
|
||||
- `bal/gui/qt/dialogs.py`: the export widget no longer shows a Format selector
|
||||
and always emits BBQR; the import widget treats BBQR indexes as 0-based.
|
||||
- Removed the "QR Code Size" setting from the plugin settings dialog; the
|
||||
QR-size selector now lives only in the export dialog, and its chosen value
|
||||
is remembered (persisted to `QR_CHUNK_SIZE`) for the next export.
|
||||
- Android reader: `bal/core/*` copies re-synced, `bal/encoder.py` and the
|
||||
Kotlin bridges (`BalDecoder`, `QrEncoder`, `QrSendActivity`, `MainActivity`,
|
||||
`strings.xml`) reduced to BBQR; `tools/emitter.py` and
|
||||
`test_chain/verify_chain.py` updated.
|
||||
- Tests: `test_core_animated_qr.py`, `test_core_qr_transfer.py` and
|
||||
`test_gui_qr_transfer.py` rewritten for BBQR only.
|
||||
- Docs: README, COMPATIBILITY, HANDOFF and android/README updated; translation
|
||||
catalogs refreshed (removed format strings marked obsolete).
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## 63. i18n review fixes (Truman's review of the translation PR)
|
||||
|
||||
**Date:** 2026-10-07
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `make-release.sh`
|
||||
- Step 3 now also runs `tests/test_i18n.py`, `tests/test_translations.py`,
|
||||
`tests/test_cli_*.py` and `tests/test_import_will_details.py` (the i18n
|
||||
tests were never run by the release gate; the CLI and will-import tests
|
||||
were not either).
|
||||
- Checks up front that Babel is importable by `python3` and stops with
|
||||
"pip install babel" otherwise: the i18n tests (step 3) and `build_zip.py`
|
||||
(step 5) both need it, so a release no longer stops half-way.
|
||||
- `AGENTS.md`: Babel must be installed in the runtime env used for releases.
|
||||
- `pyproject.toml`: `[tool.ruff.lint.isort] known-first-party = ["bal"]`.
|
||||
WHY: the reported I001 in `tests/test_translations.py` is not reproducible
|
||||
from the repository root (ruff 0.15.11 and 0.6.9: clean), but it appears as
|
||||
soon as ruff classifies `bal` as third-party (then 20 files report I001).
|
||||
Removing the blank line would break the check where `bal` is first-party;
|
||||
declaring it makes the result the same on every machine.
|
||||
- `bal/core/willexecutors.py` - `get_willexecutor_transactions` returns COPIES
|
||||
of the executor records. It used to write `txs`, `txsids` and the
|
||||
translated `broadcast_status` into the will item's own `we` dict, which is
|
||||
saved in the wallet (stored data depended on the UI language).
|
||||
- `bal/core/will.py` - `WillItem` drops those transient fields when loading a
|
||||
will saved by an older version, and maps a translated leading "New"
|
||||
("Nuovo", "Nuevo" - saved by i18n test builds) back to "New".
|
||||
- `bal/gui/qt/window.py`, `bal/cli/controller.py`: the status history of a new
|
||||
will item is saved as the literal "New" (was `_("New")`).
|
||||
- `tests/test_core_status_language_neutral.py` (new, 7 tests; 5 fail on
|
||||
`main`): English "New" stored, translated "New" mapped back, the mapping
|
||||
covers every catalog's translation of "New", the push helper does not
|
||||
modify the will item, old transient fields dropped on load.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -25,22 +25,15 @@ and 4.8.0).
|
||||
|
||||
## QR wire-format compatibility
|
||||
|
||||
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**:
|
||||
BAL exports/imports wills as QR codes using **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, 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).
|
||||
Import auto-detects the format of each scanned code; out-of-order and duplicate
|
||||
BBQR frames are handled. Interop has not yet been cross-verified against
|
||||
third-party libraries (`bbqr`, Coldcard firmwares).
|
||||
|
||||
## Reporting compatibility issues
|
||||
|
||||
|
||||
64
HANDOFF.md
64
HANDOFF.md
@@ -68,7 +68,8 @@ bal/ <- the plugin package (this is what ships in the ZI
|
||||
willexecutors.py <- remote will-executor services handling (is_selected / is_valid,
|
||||
parallel push/check).
|
||||
util.py <- locktime parsing/most helpers (timestamps only).
|
||||
checkalive.py <- resolve_date_to_check, check_alive_expired (GUI-free).
|
||||
checkalive.py <- resolve_date_to_check, resolve_relative_check_alive_date,
|
||||
ensure_check_alive_valid (GUI-free; signed relative rule).
|
||||
reminders.py <- compute_reminder_offsets, BALCalendar .ics generation (GUI-free).
|
||||
input_rules.py <- locktime/threshold data models, Raw/Date selector logic (GUI-free).
|
||||
cli/ <- headless command-line layer (no Qt)
|
||||
@@ -420,50 +421,29 @@ See Section 5 for details.
|
||||
the audio path — buttons only appear when the `audio_modem` plugin +
|
||||
`amodem` are installed; see the prerequisites below).
|
||||
|
||||
### In progress: animated-QR interop (BC-UR v1/v2, BBQR)
|
||||
### Animated-QR interop: BBQR only
|
||||
|
||||
- `bal/core/animated_qr.py` implements stdlib-only codecs for **BC-UR v1**
|
||||
(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**
|
||||
- `bal/core/animated_qr.py` implements a stdlib-only codec for **BBQR**
|
||||
(Coinkite `B$…` base32/hex/zlib frames), plus one shared
|
||||
`AnimatedQrSession` with `detect_format` auto-detection and
|
||||
`parse_for_detection` frame identity for the GUI debounce.
|
||||
- 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.
|
||||
`parse_for_detection` frame identity for the GUI debounce. The previously
|
||||
supported **BAL QR**, **BC-UR v1** and **BC-UR v2** wire formats were
|
||||
removed; only BBQR remains.
|
||||
- **Sessions:** BBQR full-frame decode in any order for Z/2/H encodings,
|
||||
duplicate drops.
|
||||
- **Safety:** `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`,
|
||||
`TransferConflictError` on a frame from a different transfer,
|
||||
`SessionLimitError`, BBQR zlib-bomb guard, UTF-8 payloads only.
|
||||
- **GUI:** `BalQrExportWidget` emits BBQR frames using the QR-size presets;
|
||||
the importer routes every frame through `detect_format` +
|
||||
`AnimatedQrSession.add_part` with the shared
|
||||
`qr_import_accept_frame(state, fmt, session_key, frame_total, index,
|
||||
payload, stable_reads=2)` debounce (reset on session-key change).
|
||||
`_review_and_sign` resolves the session to the transfer text and decodes
|
||||
parts uniformly.
|
||||
- **Verification:** `tests/test_core_animated_qr.py` (BBQR round-trips,
|
||||
encoding fallback, zlib-bomb guard, detection) and the extended
|
||||
`tests/test_gui_qr_transfer.py` pass.
|
||||
|
||||
**Dev-box audio prerequisites (audio_modem channel):**
|
||||
See `AUDIO_MODEM_DEBIAN.md` — the full Debian setup + verification, with the
|
||||
|
||||
@@ -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`, `check_alive_expired` (riferimento temporale unico per ogni check).
|
||||
- `checkalive.py` — `resolve_date_to_check`, `resolve_relative_check_alive_date`, `ensure_check_alive_valid` (riferimento temporale unico per ogni check; relativo negativo = prima della delivery, positivo = dopo la creazione).
|
||||
- `util.py` — `Util` (locktime, quantità, confronto tx/heirs, `get_available_utxos`, `fix_will_settings_tx_fees`).
|
||||
- `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 `check_alive_expired`. |
|
||||
| `init_class_variables()` | 618 | `date_to_check`, `no_willexecutor`, `willexecutors`, check `ensure_check_alive_valid`. |
|
||||
| `check_will()` | 473 | `Will.is_will_valid(...)`; le eccezioni di dominio vengono propagate al comando. |
|
||||
| `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`. |
|
||||
|
||||
453
PLAN_I18N.md
Normal file
453
PLAN_I18N.md
Normal file
@@ -0,0 +1,453 @@
|
||||
# PLAN_I18N — Italian translation of the BAL Electrum plugin (i18n)
|
||||
|
||||
> **Status:** DRAFT. Every phase still needs the owner's explicit "OK" before any code is written (HANDOFF.md, rule R4).
|
||||
> **Prepared:** 2026-09-25, from a read-only analysis (no code was changed). Owner's decisions recorded on 2026-09-26 (section 6).
|
||||
> **Code base analysed:** `bal-electrum-plugin` `main` @ `4a33116` (2026-09-19, manifest `0.7.0`) and Electrum `4.8.0`.
|
||||
> **Line numbers** refer to that commit. After merging a newer `main`, find the code by content.
|
||||
|
||||
## 0. Read this first
|
||||
|
||||
1. Read `HANDOFF.md` (rules R1–R4, ZIP-FIRST, CREDIT-SAVING) and `AGENTS.md` (venvs, tests). They still apply.
|
||||
2. **One exception to HANDOFF.md.** Section 5 ("Git / delivery workflow") and section 7, step 7, say: work directly on `main`, push to `origin/main`, run `make-release.sh`. **That does NOT apply to this task.** See section 3 (dedicated branch).
|
||||
3. The owner's decisions (D1–D7) are recorded in section 6. For anything not covered there, ask the owner (rule R3).
|
||||
|
||||
## 1. Goal and scope
|
||||
|
||||
**Goal:** when Electrum is set to Italian, the whole BAL interface appears in Italian. The mechanism must allow adding more languages later just by adding one `.po` file.
|
||||
|
||||
**In scope**
|
||||
- The Qt GUI of the plugin (`bal/gui/qt/`) and the GUI-free strings it displays (`bal/core/`).
|
||||
- Languages: Italian (`it_IT`) and Spanish (`es_ES`).
|
||||
|
||||
**Out of scope** (owner's decisions, or by design)
|
||||
- **BAL Easy Heirs**: excluded by the owner.
|
||||
- **CLI output** (`bal/cli/`): stays English, exactly like Electrum (`run_electrum`: "the CLI is intentionally always non-localized").
|
||||
- `bal/wallet_util/` (standalone helper scripts), the Android reader (`android/`, which uses Android resources), the manual, the website and the docs.
|
||||
- Other languages: later. The infrastructure will support them.
|
||||
|
||||
## 2. Background (verified)
|
||||
|
||||
### 2.1 How Electrum 4.8.0 does it
|
||||
|
||||
- **gettext with one domain, `electrum`.** Catalogs live in `electrum/locale/locale/<lang>/LC_MESSAGES/electrum.mo` (`electrum/i18n.py`, `LOCALE_DIR`). Every string goes through `electrum.i18n._()`.
|
||||
- **The language is set once, at startup, for the GUI only** (`run_electrum` L433–451):
|
||||
- it uses `config.LOCALIZATION_LANGUAGE` (config key `language`);
|
||||
- if that is empty, it uses `electrum.gui.default_lang.get_default_language(gui_name)`, i.e. `QLocale.system().name()`, **but only if** that value is in `electrum.i18n.languages`; otherwise `en_UK`;
|
||||
- `en_*` means no translation (the English source strings are shown);
|
||||
- changing the language requires a restart.
|
||||
- **Strings evaluated too early stay English.** Anything evaluated before `set_language()` (for example at import time) is not translated.
|
||||
- **Safety check inside `_()`.** The decorator `_ensure_translation_keeps_format_string_syntax_similar` rejects a translation whose `{}` fields differ from the source. `_("")` returns `""`. There is an optional `context=` argument (pgettext).
|
||||
- **Coding rules** (comments in `i18n.py`): no f-strings inside `_()`, no `%` formatting; write `_("… {}").format(x)`. There are no plural forms (`ngettext` is not used anywhere).
|
||||
- **Pipeline:**
|
||||
1. `contrib/locale/push_locale.py` runs xgettext over `electrum/**/*.py`. This includes *internal* plugins.
|
||||
2. The strings go to Crowdin, where volunteers translate them.
|
||||
3. The `.po` files are stored in the `electrum-locale` repo.
|
||||
4. `contrib/locale/build_locale.sh` compiles them with msgfmt into `.mo` files and writes `stats.json`.
|
||||
- **Translation security:**
|
||||
- `electrum-locale/update.py` refuses any translation whose `msgstr` contains a Bitcoin address, an e-mail address, a URL, or a long word mixing letters and digits;
|
||||
- `llm_proofreader/` checks every translation PR for vandalism in CI.
|
||||
- **External plugins:**
|
||||
- they are always ZIP files, imported with `zipimport` as `electrum_external_plugins.<name>`;
|
||||
- `BasePlugin.read_file(filename) -> bytes` reads a file from inside the ZIP, or from the internal plugin folder;
|
||||
- **Electrum provides nothing for translating external plugins.** Their strings are not in `electrum.mo`.
|
||||
- `i18n.py`, `gui/default_lang.py` and `read_file()` are identical in 4.7.2, 4.8.0 and 4.8.2.
|
||||
- **Swiss locales** (`it_CH`, `de_CH`, `fr_CH`) are not in Electrum's list, so Electrum starts in English until the user picks a language. BAL must simply follow Electrum and not detect the language itself. Otherwise the UI would be half in one language and half in another.
|
||||
|
||||
### 2.2 BAL today (`main` @ `4a33116`)
|
||||
|
||||
- Source strings are in English. Most are wrapped in `_`, imported from `electrum.i18n` in these places:
|
||||
- `gui/qt/common.py:55`, which re-exports it to every `gui/qt` module;
|
||||
- `gui/qt/theme.py:90,108`;
|
||||
- `core/will.py:32` and `core/willexecutors.py:25`;
|
||||
- `cli/controller.py:29`.
|
||||
- The package has 483 `_()` calls in 11 files. xgettext finds 365 distinct strings (≈ 2,230 words).
|
||||
- **With Electrum set to Italian, only 39 of the 365 strings (≈ 11 %) appear in Italian.** They are the generic words that Electrum already translates (Cancel, Save, Address…). Everything specific to BAL stays in English.
|
||||
- **Issues to fix before translating** (exact locations are in the Appendix):
|
||||
- 14 f-strings or `.format()` calls inside `_()`: these strings can never be translated (Ruff INT001/INT002);
|
||||
- 15 `_()` calls with a non-literal argument: extraction tools cannot find the text;
|
||||
- 14 concatenations around `_()`: the word order breaks in other languages;
|
||||
- 18 `_()` calls evaluated at import time (in class bodies): they would stay English (see 4.2);
|
||||
- 2 × `_("")`: plain gettext returns the catalog header instead of an empty string;
|
||||
- 12 settings help texts (`HelpButton("…")`) and the help texts of 5 `add_widget()` calls are never translated (≈ 500 words in total);
|
||||
- other text shown to the user without `_()`.
|
||||
- **Some stored data depends on the UI language.**
|
||||
- `WillItem.set_status()` (`core/will.py:1344`) appends the *translated* status label to `WillItem.status`.
|
||||
- That field is saved via `to_dict()` in the wallet DB (`wallet.db.get_dict("will")`, `gui/qt/window.py:232`), in exported files and in QR transfers.
|
||||
- Real example from `tests/samanta7`: `"New.Firmato.Pushed.Checked.Replaced"`. Electrum's Italian catalog already translates *Signed*, *Error* and *Expired*, so the saved history mixes languages today.
|
||||
- **Default texts in `core/plugin_base.py` exist only in English:**
|
||||
- `HISTORY_LABEL` (around L246, token `{willexecutor}`);
|
||||
- `EVENT_DESCRIPTION` and `EVENT_SUMMARY` (around L331–337, tokens `$wallet_name` and `$heirs_complete`).
|
||||
|
||||
## 3. Branch and workflow for this task
|
||||
|
||||
The owner decided, on Truman's suggestion, to do this work on a **separate branch on Gitea**, not on `main`.
|
||||
|
||||
- **Branch:** `feature/i18n` (proposed name, see D6), created from `main`.
|
||||
- **Never** commit or push to `main` during this task.
|
||||
- **Never** run `make-release.sh` from the branch: it tags and publishes a Gitea release. Build test ZIPs with `python3 build_zip.py` only.
|
||||
- **At the start of every phase:**
|
||||
1. merge `origin/main` into `feature/i18n`;
|
||||
2. resolve any conflicts;
|
||||
3. run the tests again.
|
||||
- **Where conflicts are likely:**
|
||||
- `gui/qt/dialogs.py`, `window.py`, `lists.py`, `plugin.py`, `widgets.py` and `core/will.py`: all were changed on `main` on 2026-09-04, 09-14 and 09-19, and commit `fb88d75` alone changed ≈ 1,800 lines of `dialogs.py`;
|
||||
- `CHANGELOG.md`: both sides append at the end, so keep both entries and renumber.
|
||||
- **ZIP-FIRST for every phase:**
|
||||
1. build the ZIP;
|
||||
2. the owner (and Truman) test it;
|
||||
3. only after the owner's explicit OK, commit on `feature/i18n` and push to `origin/feature/i18n` (who does this: see D3).
|
||||
- Add one numbered `CHANGELOG.md` entry per phase, in English.
|
||||
- Run ruff and the tests before every ZIP (HANDOFF.md). The known pre-existing test failures are listed in HANDOFF.md, section 7, step 3.
|
||||
- **At the end:** open a Pull Request `feature/i18n` → `main` on Gitea (there is a precedent: `feature/bal-qr-transfer`). Release from `main` only after the merge.
|
||||
|
||||
## 4. Design (decided)
|
||||
|
||||
**Chosen:** a gettext catalog owned by BAL, domain `bal`, shipped inside the plugin ZIP, that follows Electrum's language and is consulted **after** Electrum's own catalog (D7).
|
||||
|
||||
**Rejected:**
|
||||
- keep using `electrum.i18n._`: 89 % of the text stays English;
|
||||
- Python dictionaries or JSON files: a home-made format with no tooling;
|
||||
- Qt Linguist (`.ts` / `.qm`): Electrum desktop does not use it, and `core/` has no Qt.
|
||||
|
||||
### 4.1 New module `bal/i18n.py`
|
||||
|
||||
| Function | Behaviour |
|
||||
|---|---|
|
||||
| `_(msg)` | 1. `""` returns `""`. 2. **Electrum first (D7):** if `electrum.i18n._(msg)` differs from `msg`, return it (Electrum has already applied its own `{}` check). 3. Otherwise, if a BAL catalog is loaded and translates `msg`, apply the `{}` safety check and return the translation. The check uses the same logic as Electrum's decorator: **copy it** with an attribution comment (MIT); do not import Electrum's private function. 4. Otherwise return `msg` (English). The CLI and English behave exactly as in Electrum, because no BAL catalog is loaded there. |
|
||||
| `N_(msg)` | Returns `msg` unchanged. It marks strings defined in tables, constants and class attributes, which are translated later with `_()` when displayed. Babel extracts `N_` by default. |
|
||||
| `set_language(plugin, lang)` | `None`, `""` or `en_*`: no catalog. Otherwise read `locale/<lang>/LC_MESSAGES/bal.mo` with `plugin.read_file()`, falling back to `locale/<lang[:2]>/…`, and build `gettext.GNUTranslations(io.BytesIO(data))`. If the catalog is missing or broken, log at info level and continue in English. Never raise. |
|
||||
| `init_from_config(plugin, config)` | `lang = config.LOCALIZATION_LANGUAGE`. If it is empty, use `electrum.gui.default_lang.get_default_language(gui_name="qt")`, guarding against `ImportError` as `run_electrum` does. Then call `set_language()`. |
|
||||
|
||||
- **Why Electrum first (D7):** BAL follows Electrum's wording wherever Electrum has it. If Electrum ever ships BAL as an internal plugin, Electrum's own translations (Crowdin) would be used automatically and the BAL catalog would only fill the gaps.
|
||||
- **Trade-off of D7:** for a text that Electrum already translates, the BAL catalog cannot override Electrum's wording, not even with the D1 glossary. If one such case reads badly in BAL, handle that single case with the owner.
|
||||
- A text that Electrum "translates" into the identical English text counts as not found and falls through to the BAL catalog. This is harmless: the BAL catalog is prefilled with Electrum's translations (3.3).
|
||||
- **Do not use `gettext.translation(localedir=…)`.** When pointed inside a ZIP it silently returns `NullTranslations` (verified).
|
||||
- `plugin.read_file()` is the same API BAL already uses for its icons. It works both for the ZIP install and for the dev symlink install (`AGENTS.md`).
|
||||
- The state is module-level and set once. Changing the language requires restarting Electrum, as in Electrum itself.
|
||||
|
||||
### 4.2 Where the language is set
|
||||
|
||||
- In `bal/gui/qt/plugin.py`, `Plugin.__init__` (L67): call `init_from_config(self, config)` first.
|
||||
- The CLI entry point (`bal/cli/plugin.py`) does not call it, so the CLI stays English.
|
||||
- **Consequence:** `gui/qt/plugin.py` imports `dialogs`, `widgets` and `window` at module level (L49–51), and those import `lists`. So every class body is evaluated *before* `Plugin.__init__` runs, and any `_()` evaluated at import time would stay English. The cases in Appendix D must be converted in Phase 2.
|
||||
|
||||
### 4.3 Files and tools
|
||||
|
||||
```
|
||||
babel.cfg # extraction mapping (repo root)
|
||||
bal/locale/bal.pot # template, generated
|
||||
bal/locale/it_IT/LC_MESSAGES/bal.po # Italian translations (source of truth)
|
||||
bal/locale/it_IT/LC_MESSAGES/bal.mo # compiled (see D4)
|
||||
```
|
||||
|
||||
The tool is **Babel** (`pip install babel`). It is pure Python and works on Windows without GNU gettext. These commands were tested on this code base:
|
||||
|
||||
```ini
|
||||
# babel.cfg
|
||||
[ignore: bal/cli/**]
|
||||
[ignore: bal/wallet_util/**]
|
||||
[python: bal/**.py]
|
||||
```
|
||||
|
||||
```bash
|
||||
mkdir -p bal/locale # extract does not create it
|
||||
pybabel extract -F babel.cfg --no-wrap -o bal/locale/bal.pot .
|
||||
pybabel init -i bal/locale/bal.pot -d bal/locale -D bal -l it_IT # once
|
||||
pybabel update -i bal/locale/bal.pot -d bal/locale -D bal --no-wrap # after each extract
|
||||
pybabel compile -d bal/locale -D bal --statistics
|
||||
```
|
||||
|
||||
- Today this extracts 323 strings (≈ 1,900 words) from the GUI and core.
|
||||
- After Phase 2, expect about 360 strings (≈ 2,500 words).
|
||||
- Poedit (free) can be used to translate or review the `.po` file by hand.
|
||||
|
||||
### 4.4 Translation safety (as in Electrum)
|
||||
|
||||
- Add a check script (for example `scripts/check_translations.py`) and run it before every ZIP. It must check that:
|
||||
- every `.po` file compiles;
|
||||
- the `{}` fields are identical to the source (the same rule as the runtime check);
|
||||
- the `$tokens` are identical (`$wallet_name`, `$heirs_complete`, …): Electrum does not check these;
|
||||
- no `msgstr` contains a Bitcoin address, an e-mail address, a URL or a long letter-and-digit word (use the regexes from `electrum-locale/update.py`), unless the same text is in the `msgid`.
|
||||
|
||||
It must also print the counts of translated, untranslated and fuzzy strings.
|
||||
- Never put addresses or URLs inside translatable strings; pass them with `{}`.
|
||||
- The owner reviews every Italian string, especially the warnings about invalidation, locktime, fees and signing.
|
||||
|
||||
## 5. Phases
|
||||
|
||||
Each phase follows HANDOFF.md: DISCOVER → PLAN (owner's "OK") → EXECUTE → VERIFY → ZIP → test by the owner and Truman → commit on `feature/i18n`.
|
||||
Phases 1 and 2 change nothing visible, so they may be delivered as a single ZIP/test cycle (see D5).
|
||||
|
||||
### Phase 1 — Infrastructure (no visible change)
|
||||
|
||||
- NEW `bal/i18n.py` (section 4.1), with docstrings explaining *why* (rule R2).
|
||||
- `bal/gui/qt/common.py:55`: replace `from electrum.i18n import _` with an import of `_` and `N_` from `bal.i18n`. Use a relative import, zip-safe like the existing `from ...core…` imports.
|
||||
- `bal/gui/qt/theme.py:90,108`: change the local imports to `bal.i18n`.
|
||||
- `bal/core/will.py:32` and `bal/core/willexecutors.py:25`: import from `bal.i18n`.
|
||||
- `bal/cli/controller.py:29`: **unchanged** (the CLI stays English by design).
|
||||
- `bal/gui/qt/plugin.py`, `Plugin.__init__`: call `init_from_config(self, config)`.
|
||||
- NEW `tests/test_i18n.py`, covering:
|
||||
- the empty string;
|
||||
- Electrum's translation wins; the BAL catalog is used only when Electrum has none (D7);
|
||||
- rejection of a `{}` mismatch;
|
||||
- `en_UK` means no catalog;
|
||||
- a missing `.mo` does not crash;
|
||||
- a catalog loaded from bytes (use a small test catalog).
|
||||
- **Verify:**
|
||||
- the existing tests (baseline as in HANDOFF.md, section 7);
|
||||
- `tests/smoke_test.py`;
|
||||
- `build_zip.py` followed by `tests/external_zip_test.py`;
|
||||
- manually on Windows 11 with Electrum 4.8.0, once in Italian and once in English: the UI must look exactly as before.
|
||||
|
||||
### Phase 2 — String clean-up and language-neutral stored data (no visible change)
|
||||
|
||||
- **2.0** Merge `origin/main` first.
|
||||
- **2.1** Appendix A: turn `_(f"…")` and `_("…".format(x))` into `_("… {}").format(x)`. Include `cli/controller.py`, for lint consistency only.
|
||||
- **2.2** Appendix C: turn each concatenation into one string with `{}`.
|
||||
- Keep HTML outside the strings where possible, e.g. `"<b>{}</b>".format(_("Support:"))`.
|
||||
- Fix the malformed `_("<b>Willexecutor:</b:")` (`widgets.py:1366`).
|
||||
- **2.3** Appendix B: mark the literal with `N_()` where it is defined, or translate it at the call site.
|
||||
- The helpers `add_widget()` (`common.py:180`), `get_window_title()` (`plugin.py:1085`) and `qlabel()` (`widgets.py:1319`) need ONE rule: callers pass text that is already translated, and the helper does not call `_()` again.
|
||||
- Today `window.py:290` translates twice.
|
||||
- **2.4** Appendix D: replace class-level `_()` with `N_()` and translate when the text is displayed.
|
||||
- In `lists.py`, `update_headers(self.__class__.headers)` (L248, 647, 1094) must receive a translated copy.
|
||||
- For the wizard `title` / `message` (`dialogs.py` L380, 419–420, 527–528, 547–548), use `_(self.title)` and `_(self.message)` at L325 and L327.
|
||||
- **2.5** Appendix E: replace `_("")` with `""`.
|
||||
- **2.6** Appendix F and G: make the settings help texts and labels in `plugin.py` translatable.
|
||||
- **2.7** Appendix H, plus a full sweep for any other text shown to the user.
|
||||
- **2.8** **Stored status** (`core/will.py:1344`):
|
||||
- store the **English** label (mark the `STATUS_DEFAULT` labels with `N_()`); no `_()` at write time;
|
||||
- add a helper `format_status_history(status) -> str` that splits on `.`, translates the tokens that are known English labels (with an optional `NOT ` prefix), and leaves unknown or legacy tokens unchanged (`New`, `Firmato`, …);
|
||||
- use it wherever the status is shown (`lists.py:589`, `widgets.py:1336`);
|
||||
- this stays backward compatible: existing wallets keep displaying correctly, and older BAL versions still read plain English;
|
||||
- test it with the real legacy strings in `tests/samanta7`.
|
||||
- **2.9** **Default texts** (`core/plugin_base.py`: `HISTORY_LABEL`, `EVENT_DESCRIPTION`, `EVENT_SUMMARY`):
|
||||
- mark the English defaults with `N_()`;
|
||||
- when the stored value is missing **or equal to the English default**, use the translated default at the moment the text is used (calendar export, history label);
|
||||
- the tokens must survive translation.
|
||||
- **Changed during Phase 2 (safety):** `HISTORY_LABEL` stays English and is *not* translatable. `Util._label_matches_history()` uses it to recognise BAL's own local transactions (stale-history cleanup, spendable UTXOs), so a label that changed with the GUI language would no longer match the transactions saved before. Only `EVENT_SUMMARY` and `EVENT_DESCRIPTION` follow the language (`BalConfig(..., translatable=True)`). The same rule applies to every stored or compared text (wallet labels, `heirs.TRANSACTION_LABEL`, the status history).
|
||||
- **2.10** `pyproject.toml`: add `"INT"` to `[tool.ruff.lint] select`.
|
||||
- Ruff's hint suggests `%` formatting: **do not follow it**. Use `.format()`, which is Electrum's rule.
|
||||
- `make-release.sh` treats Ruff errors as blocking, so INT must end up clean.
|
||||
- **Verify:**
|
||||
- Ruff INT is clean;
|
||||
- the extraction count is about 360 (compare with 4.3);
|
||||
- the tests pass, plus new tests for 2.8 and 2.9;
|
||||
- manually in English: no visible text changed.
|
||||
|
||||
### Phase 3 — Italian catalog (visible change)
|
||||
|
||||
- **3.0** Merge `origin/main` first.
|
||||
- **3.1** Add `babel.cfg` and the commands from 4.3, documented in `README.md` or `AGENTS.md`.
|
||||
- **3.2** Generate `bal/locale/bal.pot` and create `it_IT/LC_MESSAGES/bal.po`.
|
||||
- **3.3** Draft the translation using the glossary (D1); the owner reviews it (D2). Where Electrum's `it_IT` catalog already translates the exact same English text, copy Electrum's translation, so the two UIs match.
|
||||
- **3.4** `build_zip.py`:
|
||||
- compile `.po` into `.mo`, according to D4;
|
||||
- put only the `.mo` files in the ZIP (no `.po` / `.pot`);
|
||||
- run the check script (4.4) before zipping.
|
||||
- **Verify:**
|
||||
- Electrum in Italian shows BAL in Italian;
|
||||
- Electrum in English shows BAL in English;
|
||||
- missing entries fall back to English;
|
||||
- `external_zip_test.py` passes;
|
||||
- it works on Windows 11 with Electrum 4.8.0 (and 4.7.2);
|
||||
- manual walkthrough of: wizard, settings, heirs list, will list and detail, will-executors, check / sign / broadcast, invalidate, export / import, QR transfer, calendar export.
|
||||
|
||||
### Phase 4 — Final test and merge
|
||||
|
||||
- The owner and Truman test the final branch ZIP.
|
||||
- Merge `origin/main` into the branch one last time and test again.
|
||||
- Open the Pull Request `feature/i18n` → `main` on Gitea. After the merge, follow the normal release flow from `main`.
|
||||
|
||||
## 6. Decisions
|
||||
|
||||
Recorded on 2026-09-26 from the owner's answers.
|
||||
|
||||
- **D1 — Italian glossary.** Rule: follow Electrum's `it_IT` wording wherever Electrum already has the term or the sentence. Grammatical variants (nouns, past participles, plurals, capitals in headers and buttons) are adapted in the draft and checked by the owner during the review (D2).
|
||||
|
||||
| English | Italian |
|
||||
|---|---|
|
||||
| will | testamento |
|
||||
| heir / heirs | erede / eredi |
|
||||
| will-executor | Will-Executor (kept in English) |
|
||||
| check-alive | verifica se sei vivo |
|
||||
| invalidate | invalida il piano |
|
||||
| invalidation (noun) | invalidazione del piano |
|
||||
| anticipate / postpone | anticipa / posticipa |
|
||||
| locktime | locktime (kept in English; owner, 2026-09-26) |
|
||||
| fee | commissione (as Electrum) |
|
||||
| broadcast | trasmetti il piano |
|
||||
| sign / signed | firma / firmato (as Electrum) |
|
||||
| wallet | portafogli (as Electrum) |
|
||||
|
||||
- **D2 — Who translates.** The developer drafts `it_IT/bal.po`; the owner reviews and corrects it (for example in Poedit). For later languages: Weblate (supports Gitea and `.po`) or Crowdin, as Electrum does.
|
||||
- **D3 — Branch and commits.** The developer creates `feature/i18n`, and commits and pushes on it, each time only after the owner's explicit OK (ZIP-FIRST).
|
||||
- **D4 — `.mo` files.** `build_zip.py` compiles `.po` into `.mo` at build time with Babel; `.mo` files stay out of git (add `*.mo` to `.gitignore`). If Babel is missing, the build stops with a clear error, so an English-only ZIP is never shipped by mistake. Reasons: the `.po` is the only source (no stale `.mo`), every change to a translation is readable in review, and it is how Electrum works.
|
||||
- **D5 — Cycles.** Phases 1 and 2 are delivered in one ZIP/test cycle. Phase 1 is not merged into `main` early.
|
||||
- **D6 — Branch name:** `feature/i18n`.
|
||||
- **D7 — Lookup order.** Electrum's catalog first, then BAL's, then English (see 4.1). Proposed by the owner so that BAL and Electrum read as one, also if Electrum ever integrates BAL.
|
||||
|
||||
## 7. Reminders for the developer
|
||||
|
||||
- Talk to the owner in **Italian**, with simple words and no jargon (the owner is not a programmer). Code, comments, docstrings, UI source strings, docs and commits are in **English**.
|
||||
- UI source strings stay in English; Italian lives only in the `.po` file.
|
||||
- Never invent. When in doubt, stop and ask.
|
||||
- Before writing any code, present the phase PLAN and wait for "OK".
|
||||
- ZIP-FIRST; run ruff and the tests before every ZIP; one CHANGELOG entry per phase; keep reports brief (CREDIT-SAVING).
|
||||
- Wallet data compatibility comes first: apart from 2.8 (status labels), do not change what is stored.
|
||||
|
||||
## Appendix — Inventory at `main` @ `4a33116`
|
||||
|
||||
Generated by a static scan (AST, Ruff `INT` and xgettext). The line numbers will move after merging a newer `main`: re-run the scan or search by content. These lists cover the known cases; Phase 2 still needs a full sweep.
|
||||
|
||||
#### A. f-string / `.format()` inside `_()` — Ruff INT001/INT002 (14)
|
||||
|
||||
| Location | Rule |
|
||||
|---|---|
|
||||
| `bal/cli/controller.py:92` | INT001 |
|
||||
| `bal/cli/controller.py:98` | INT001 |
|
||||
| `bal/cli/controller.py:104` | INT001 |
|
||||
| `bal/gui/qt/common.py:225` | INT002 |
|
||||
| `bal/gui/qt/common.py:240` | INT002 |
|
||||
| `bal/gui/qt/dialogs.py:1226` | INT001 |
|
||||
| `bal/gui/qt/dialogs.py:2491` | INT001 |
|
||||
| `bal/gui/qt/window.py:831` | INT001 |
|
||||
| `bal/gui/qt/window.py:836` | INT001 |
|
||||
| `bal/gui/qt/window.py:1090` | INT001 |
|
||||
| `bal/gui/qt/window.py:1091` | INT001 |
|
||||
| `bal/gui/qt/window.py:2228` | INT001 |
|
||||
| `bal/gui/qt/window.py:2236` | INT001 |
|
||||
| `bal/gui/qt/window.py:2246` | INT001 |
|
||||
|
||||
#### B. `_()` with a non-literal argument (not extractable) (15)
|
||||
|
||||
| Location | Code |
|
||||
|---|---|
|
||||
| `bal/core/will.py:1344` | `_(self.STATUS[status][0])` |
|
||||
| `bal/gui/qt/common.py:181` | `_(label)` |
|
||||
| `bal/gui/qt/dialogs.py:327` | `_(self.message)` |
|
||||
| `bal/gui/qt/dialogs.py:2288` | `_(msg)` |
|
||||
| `bal/gui/qt/dialogs.py:2291` | `_(msg)` |
|
||||
| `bal/gui/qt/lists.py:1240` | `_(label)` |
|
||||
| `bal/gui/qt/plugin.py:1086` | `_(title)` |
|
||||
| `bal/gui/qt/widgets.py:284` | `_(self.tooltip_text)` |
|
||||
| `bal/gui/qt/widgets.py:1320` | `_(str(title))` |
|
||||
| `bal/gui/qt/window.py:290` | `_(title)` |
|
||||
| `bal/gui/qt/window.py:942` | `_(message)` |
|
||||
| `bal/gui/qt/window.py:2217` | `_(self.DOWNLOAD_FAILED_TOR_MESSAGE)` |
|
||||
| `bal/gui/qt/window.py:2219` | `_(self.DOWNLOAD_FAILED_MESSAGE)` |
|
||||
| `bal/gui/qt/window.py:2253` | `_(self.DOWNLOAD_FAILED_TOR_MESSAGE)` |
|
||||
| `bal/gui/qt/window.py:2255` | `_(self.DOWNLOAD_FAILED_MESSAGE)` |
|
||||
|
||||
#### C. Concatenations around `_()` (14)
|
||||
|
||||
| Location | Expression |
|
||||
|---|---|
|
||||
| `bal/core/will.py:1344` | `'.' + (('NOT ' if not value else '') + _(self.STATUS[status][0]))` |
|
||||
| `bal/gui/qt/dialogs.py:1010` | `_('Wallet balance is too low: {} satoshi available, but the miner and…` |
|
||||
| `bal/gui/qt/dialogs.py:2206` | `messages[reason] + '\n\n' + _('Skipped')` |
|
||||
| `bal/gui/qt/dialogs.py:2209` | `_("Could not build the will, and the exact cause could not be determi…` |
|
||||
| `bal/gui/qt/dialogs.py:2419` | `_('Expiration date: ') + str(BalTimestamp(self.threshold))` |
|
||||
| `bal/gui/qt/dialogs.py:2425` | `_('Valid Txs:') + str(len(Will.only_valid_list(self.will)))` |
|
||||
| `bal/gui/qt/dialogs.py:2427` | `_('Total Txs:') + str(len(self.will))` |
|
||||
| `bal/gui/qt/lists.py:679` | `' ' + _('Build Your Will')` |
|
||||
| `bal/gui/qt/plugin.py:301` | `'Bal ' + _('Bitcoin After Life')` |
|
||||
| `bal/gui/qt/plugin.py:1034` | `'<b>' + _('Support:') + '</b>'` |
|
||||
| `bal/gui/qt/plugin.py:1086` | `_('BAL - ') + _(title)` |
|
||||
| `bal/gui/qt/widgets.py:1320` | `'<b>' + _(str(title)) + f':</b>\t{str(value)}'` |
|
||||
| `bal/gui/qt/window.py:1022` | `_('Electrum was unable to deserialize the transaction:') + '\n' + str…` |
|
||||
| `bal/gui/qt/window.py:1091` | `msg + _(f'signing: {tosign}')` |
|
||||
|
||||
#### D. `_()` evaluated at import time (class body) (18)
|
||||
|
||||
| Location | Scope | Call |
|
||||
|---|---|---|
|
||||
| `bal/gui/qt/dialogs.py:419` | class body | `_('Bitcoin After Life Will-Executors')` |
|
||||
| `bal/gui/qt/dialogs.py:420` | class body | `_('Choose willexecutors download method')` |
|
||||
| `bal/gui/qt/dialogs.py:528` | class body | `_('Configure and select your willexecutors')` |
|
||||
| `bal/gui/qt/dialogs.py:548` | class body | `_('')` |
|
||||
| `bal/gui/qt/lists.py:131` | class body | `_('Name')` |
|
||||
| `bal/gui/qt/lists.py:132` | class body | `_('Address')` |
|
||||
| `bal/gui/qt/lists.py:133` | class body | `_('Amount')` |
|
||||
| `bal/gui/qt/lists.py:340` | class body | `_('Locktime')` |
|
||||
| `bal/gui/qt/lists.py:341` | class body | `_('Txid')` |
|
||||
| `bal/gui/qt/lists.py:342` | class body | `_('Will-Executor')` |
|
||||
| `bal/gui/qt/lists.py:343` | class body | `_('Status')` |
|
||||
| `bal/gui/qt/lists.py:344` | class body | `_('Server')` |
|
||||
| `bal/gui/qt/lists.py:907` | class body | `_('')` |
|
||||
| `bal/gui/qt/lists.py:908` | class body | `_('Url')` |
|
||||
| `bal/gui/qt/lists.py:909` | class body | `_('S')` |
|
||||
| `bal/gui/qt/lists.py:910` | class body | `_('Base fee')` |
|
||||
| `bal/gui/qt/lists.py:911` | class body | `_('Info')` |
|
||||
| `bal/gui/qt/lists.py:912` | class body | `_('Default Address')` |
|
||||
|
||||
#### E. `_("")` (2)
|
||||
|
||||
| Location | Call |
|
||||
|---|---|
|
||||
| `bal/gui/qt/dialogs.py:548` | `_('')` |
|
||||
| `bal/gui/qt/lists.py:907` | `_('')` |
|
||||
|
||||
#### F. Settings help texts never passed to `_()` (12)
|
||||
|
||||
| Location | Widget | Text |
|
||||
|---|---|---|
|
||||
| `bal/gui/qt/plugin.py:715` | HelpButton | When checking, automatically sign and broadcast the will transactions… |
|
||||
| `bal/gui/qt/plugin.py:778` | HelpButton | `How many reminder alarms the exported calendar (.ics) event contains.…` |
|
||||
| `bal/gui/qt/plugin.py:789` | HelpButton | `Default message to be used in event summary\nVariables:\n $wallet_na…` |
|
||||
| `bal/gui/qt/plugin.py:803` | HelpButton | `Default message to be used in event description\nVariables:\n $walle…` |
|
||||
| `bal/gui/qt/plugin.py:818` | HelpButton | URL of the server that provides the will-executor list. Only availab… |
|
||||
| `bal/gui/qt/plugin.py:829` | HelpButton | `Command used to open .ics calendar files.\nLeave empty to use the sys…` |
|
||||
| `bal/gui/qt/plugin.py:844` | HelpButton | `After each check, save the valid will transactions into the wallet's …` |
|
||||
| `bal/gui/qt/plugin.py:858` | HelpButton | `Label applied to the will transactions saved into the wallet's local …` |
|
||||
| `bal/gui/qt/plugin.py:880` | HelpButton | Broadcast all transactions to willexecutors including those already p… |
|
||||
| `bal/gui/qt/plugin.py:891` | HelpButton | `Run the 'Build your will' wizard every time the wallet is closed or E…` |
|
||||
| `bal/gui/qt/plugin.py:909` | HelpButton | When a new transaction arrives for the wallet, automatically rebuild … |
|
||||
| `bal/gui/qt/plugin.py:933` | HelpButton | `Payload size of a single QR code when exporting a will via QR.\n\nLar…` |
|
||||
|
||||
#### G. `add_widget()` calls (label translated at runtime, help text never) (5)
|
||||
|
||||
| Location | Label | Help text |
|
||||
|---|---|---|
|
||||
| `bal/gui/qt/plugin.py:697` | Hide Replaced | Hide replaced transactions from will detail and l… |
|
||||
| `bal/gui/qt/plugin.py:705` | Hide Invalidated | Hide invalidated transactions from will detail an… |
|
||||
| `bal/gui/qt/plugin.py:727` | Panel editable Date and Fee | When enabled, the delivery-time and check-alive d… |
|
||||
| `bal/gui/qt/plugin.py:742` | `Max Will-Executor Fee (satoshi)` | `Maximum fee (in satoshi) allowed to be paid to a …` |
|
||||
| `bal/gui/qt/plugin.py:757` | User Type | `Choose how much detail the plugin shows.\n\nBASIC…` |
|
||||
|
||||
#### H. Other user-visible text not (correctly) translatable — known cases, not exhaustive
|
||||
|
||||
| Location | Code | Note |
|
||||
|---|---|---|
|
||||
| `bal/gui/qt/dialogs.py:380` | `title = "Bitcoin After Life Heirs"` | Wizard title, shown untranslated at `dialogs.py:325` |
|
||||
| `bal/gui/qt/dialogs.py:527` | `title = "Bitcoin After Life Will-Executors"` | Same |
|
||||
| `bal/gui/qt/dialogs.py:547` | `title = "Bitcoin After Life Will Settings"` | Same |
|
||||
| `bal/gui/qt/lists.py:1472` | `setText("New Will Executor")` | Not wrapped |
|
||||
| `bal/gui/qt/plugin.py:673` | `QPushButton("Rebroadcast transactions")` | Not wrapped |
|
||||
| `bal/gui/qt/widgets.py:1323` | `qlabel("Locktime", …)` | Translated inside `qlabel()` but not extractable |
|
||||
| `bal/gui/qt/widgets.py:1324` | `qlabel("Creation Time", …)` | Same |
|
||||
| `bal/gui/qt/widgets.py:1334` | `qlabel("Transaction fees:", …)` | Same; `qlabel()` adds another `:` |
|
||||
| `bal/gui/qt/widgets.py:1336` | `qlabel("Status:", …)` | Same; also shows the stored status (see 2.8) |
|
||||
| `bal/gui/qt/widgets.py:1339` | `QLabel("<b>Heirs:</b>")` | Not wrapped |
|
||||
| `bal/gui/qt/widgets.py:1366` | `_("<b>Willexecutor:</b:")` | Malformed HTML inside the string |
|
||||
| `bal/gui/qt/window.py:1613` | `msg = "Broadcasting Transactions to Will-Executors:\n"` | Not wrapped |
|
||||
|
||||
Not to translate: `widgets.py:122` (icon glyph), `plugin.py:1020` (link to bitcoin-after.life).
|
||||
|
||||
#### Numbers (`main` @ `4a33116`)
|
||||
|
||||
| Measure | Value |
|
||||
|---|---|
|
||||
| `_()` calls | 483 in 11 files |
|
||||
| Distinct strings, whole package (xgettext) | 365 (≈ 2,230 words) |
|
||||
| …of which Electrum `it_IT` already translates | 39 (≈ 11 %) |
|
||||
| Strings used only by the CLI | 36 (≈ 315 words), out of scope |
|
||||
| Babel extraction today, GUI + core | 323 (≈ 1,900 words) |
|
||||
| Expected after Phase 2 | ≈ 360 (≈ 2,500 words) |
|
||||
|
||||
## Sources
|
||||
|
||||
- Electrum 4.8.0: `electrum/i18n.py`, `run_electrum` (L433–451), `electrum/gui/default_lang.py`, `electrum/plugin.py` (`maybe_load_plugin_init_method`, `read_file`), `contrib/locale/` — https://github.com/spesmilo/electrum/tree/4.8.0
|
||||
- `electrum-locale` at the 4.8.0 submodule commit `3d7594c`: `update.py`, `llm_proofreader/` — https://github.com/spesmilo/electrum-locale
|
||||
- Gitea "Update branch" button on pull requests — https://github.com/go-gitea/gitea/pull/9784
|
||||
- Weblate: Gitea and gettext PO support — https://docs.weblate.org/en/latest/vcs.html
|
||||
25
README.md
25
README.md
@@ -26,8 +26,8 @@ bal/ the installable Electrum plugin package
|
||||
│ ├── willexecutors.py
|
||||
│ ├── checkalive.py
|
||||
│ ├── reminders.py
|
||||
│ ├── qrtransfer.py BAL QR will-transfer wire format / chunk scheduler
|
||||
│ ├── animated_qr.py BC-UR v1/v2 + BBQR codecs (stdlib-only)
|
||||
│ ├── qrtransfer.py transfer-string codec / QR size presets
|
||||
│ ├── animated_qr.py BBQR codec (stdlib-only)
|
||||
│ └── input_rules.py
|
||||
├── cli/ headless command-line layer (no Qt)
|
||||
│ ├── commands.py bal_* daemon commands (@plugin_command)
|
||||
@@ -94,25 +94,20 @@ exists, then enable it from **Tools → Plugins**.
|
||||
From the will list (**Export → QR Codes**) a will can be exported as a
|
||||
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 default **BAL QR** format already
|
||||
compressed whenever that is smaller (best-of zlib, flag per frame); the import
|
||||
(150–1800 bytes/frame) and ships the **BBQR** format; the import
|
||||
flow reviews and sign each transaction
|
||||
one at a time, then proposes exporting the signed transactions. When
|
||||
Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` +
|
||||
PortAudio) Send/Receive audio buttons complement the QR channel. See
|
||||
[`PLAN_QR_TRANSFER.md`](PLAN_QR_TRANSFER.md) for the BAL QR wire-format spec.
|
||||
[`PLAN_QR_TRANSFER.md`](PLAN_QR_TRANSFER.md) for the transfer wire-format spec.
|
||||
|
||||
### Animated-QR formats (interop)
|
||||
### Animated-QR format (BBQR)
|
||||
|
||||
BAL QR is the default export format, but the export page's **Format** selector
|
||||
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.
|
||||
The export page emits **BBQR** (`B$…`, Coinkite, used by BitKit and Coldcard)
|
||||
animated-QR sequences. The importer auto-detects each code it sees, so a BAL
|
||||
export can be imported by any tool that understands BBQR. BBQR frames may
|
||||
arrive in any order, and duplicates are ignored. The 32 MB message limit
|
||||
(zlib-bomb guard) bounds untrusted scanner input.
|
||||
|
||||
## Command-line / headless usage
|
||||
|
||||
|
||||
10
android/.gitignore
vendored
10
android/.gitignore
vendored
@@ -1,10 +0,0 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
*.apk
|
||||
*.aab
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.hprof
|
||||
@@ -1,153 +0,0 @@
|
||||
# BAL Reader (Android)
|
||||
|
||||
A minimal Android app that reads a Bitcoin will exported by the
|
||||
[BAL Electrum plugin](https://bitcoin-after.life) directly from your screen,
|
||||
then lets you view, copy, share, or save the recovered data. **Reader only** —
|
||||
it never signs or broadcasts.
|
||||
|
||||
It decodes **all four** transfer formats the plugin can export, auto-detecting
|
||||
the format from the first frame:
|
||||
|
||||
- **BAL QR** (the plugin's default), single- and multi-frame, plain and
|
||||
zlib-compressed, compact `BAL1` header (legacy `BALQR1` frames are still
|
||||
accepted on import);
|
||||
- **BC-UR v1** (single-part and `NofM` multipart);
|
||||
- **BC-UR v2** (single-part and XOR-fountain multipart — it tolerates dropped,
|
||||
repeated, and out-of-order frames);
|
||||
- **BBQR** (`Z`/`H`/`2` encodings).
|
||||
|
||||
Both payload kinds are handled: the **whole-will JSON** and the plain
|
||||
**transaction-hex list**.
|
||||
|
||||
## How decoding works
|
||||
|
||||
The app does not reimplement the QR formats. It bundles the plugin's own
|
||||
codec modules — `bal/core/__init__.py`, `bal/core/animated_qr.py`,
|
||||
`bal/core/qrtransfer.py` — and runs them verbatim through **Chaquopy** (CPython
|
||||
on Android). Kotlin is only camera glue and UI:
|
||||
|
||||
```
|
||||
Camera (CameraX) → ML Kit QR detection (on-device, no API key)
|
||||
→ BalDecoder.add(text) → bal.core.animated_qr.AnimatedQrSession
|
||||
→ when done → BalDecoder.finish()
|
||||
= session.resolve() → qrtransfer.decode_transfer()
|
||||
→ balreader.payload.decode_will_payload()
|
||||
→ ResultActivity: view / copy / share / save
|
||||
```
|
||||
|
||||
The decode tail mirrors the plugin's import dialog function-for-function, and
|
||||
`android/test_chain/verify_chain.py` proves the bundled code decodes every
|
||||
format the way the desktop import does (including scrambled, duplicated, and
|
||||
missing frames).
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
android/
|
||||
├── app/src/main/
|
||||
│ ├── AndroidManifest.xml
|
||||
│ ├── java/life/after/bitcoin/
|
||||
│ │ ├── BalDecoder.kt Chaquopy bridge over the bundled codecs
|
||||
│ │ ├── MainActivity.kt camera + ML Kit scan loop + progress
|
||||
│ │ └── ResultActivity.kt viewer (copy / share / save)
|
||||
│ └── python/ bundled Python (regenerate, do not hand-edit)
|
||||
│ ├── bal/core/ SYNCED COPY of the plugin codecs
|
||||
│ └── balreader/payload.py verbatim copy of dialogs.decode_will_payload
|
||||
├── scripts/
|
||||
│ ├── sync_codecs.py re-copy + verify the bundled codecs
|
||||
│ └── build_apk.py resync codecs, run Gradle, print APK + sha256
|
||||
└── test_chain/verify_chain.py decode-chain simulation for all formats
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
You need Android Studio (Jellyfish or newer), JDK 17, an Android SDK with
|
||||
platform 35, and a network connection for the first Gradle sync.
|
||||
|
||||
1. Open this `android/` folder in Android Studio and let it sync (it will
|
||||
fetch the Gradle wrapper 8.14, AGP 8.10.0, Kotlin 2.0.21, Chaquopy 17.0.0,
|
||||
CameraX 1.3.4, and ML Kit).
|
||||
2. Connect a phone (API 24+) or start an emulator and press **Run**.
|
||||
3. Grant the camera permission when asked.
|
||||
|
||||
Alternatively, from the command line (from the repository root):
|
||||
|
||||
```bash
|
||||
python3 android/scripts/build_apk.py # debug APK + sha256
|
||||
python3 android/scripts/build_apk.py --release # (unsigned) release APK
|
||||
```
|
||||
|
||||
The script re-synchronises the bundled codec modules first (so the APK always
|
||||
carries the current `bal/core` sources), runs `./gradlew`, and prints the APK
|
||||
path, size and sha256. Flags: `--no-sync` (skip the re-sync), `--offline`
|
||||
(Gradle without downloads), `--clean`, `--verbose`.
|
||||
|
||||
Equivalent raw Gradle call:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleDebug # APK: android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
### If the Gradle wrapper jar is missing
|
||||
|
||||
`gradle/wrapper/gradle-wrapper.jar` is committed so `./gradlew` works out of
|
||||
the box. If it is ever absent, Android Studio regenerates it on the first
|
||||
sync; no manual steps needed.
|
||||
|
||||
## Use
|
||||
|
||||
1. In Electrum + BAL, open the will's **export** dialog.
|
||||
2. Pick a format — start with the default **BAL QR**, then try **BC-UR v1**,
|
||||
**BC-UR v2**, and **BBQR**.
|
||||
3. Make sure the wording toggle shows a payload (business logic), then display
|
||||
the animated QR and keep it on screen.
|
||||
4. Point the phone at the screen. The header shows the detected format and
|
||||
`received / total`; scanning stops automatically when the transfer is
|
||||
complete.
|
||||
5. On the result screen: **Copy** the raw transfer, **Share** it, **Save** it
|
||||
as `will.json` (whole will) or `will_tx.txt` (transaction list), or press
|
||||
**Scan another**.
|
||||
|
||||
Notes:
|
||||
|
||||
- Keep the phone still and the whole QR inside the frame (the codec dedups
|
||||
repeated frames, so a slow capture is fine).
|
||||
- If the camera glares off the screen, reduce brightness or tilt slightly.
|
||||
- If scanning jumps between exports, the app detects the format switch,
|
||||
resets, and asks you to let it re-scan.
|
||||
|
||||
## Keeping the bundled code in sync with the plugin
|
||||
|
||||
The codecs under `app/src/main/python/bal/core/` are **committed copies** for
|
||||
deterministic builds, but they must stay identical to the plugin. Re-run this
|
||||
after changing `bal/core/animated_qr.py` or `bal/core/qrtransfer.py` (and
|
||||
after any change to `decode_will_payload` in `bal/gui/qt/dialogs.py`, which
|
||||
mirrors `balreader/payload.py`):
|
||||
|
||||
```bash
|
||||
python3 android/scripts/sync_codecs.py # copy
|
||||
python3 android/scripts/sync_codecs.py --check # verify only (CI-friendly)
|
||||
python3 android/test_chain/verify_chain.py # full decode-chain regression
|
||||
```
|
||||
|
||||
`verify_chain.py` fails if the app's `balreader/payload.py` ever drifts from
|
||||
the plugin's `decode_will_payload` (AST identity + result parity).
|
||||
|
||||
## Version pins (see `PLAN_ANDROID_READER.md`)
|
||||
|
||||
| Item | Version |
|
||||
|---|---|
|
||||
| AGP | 8.10.0 |
|
||||
| Gradle | 8.14 (wrapper) |
|
||||
| Kotlin | 2.0.21 |
|
||||
| Chaquopy | 17.0.0 (Python 3.12) |
|
||||
| compile / target / min SDK | 35 / 35 / 24 |
|
||||
| CameraX | 1.3.4 |
|
||||
| ML Kit barcode-scanning | 17.3.0 |
|
||||
| JDK | 17 |
|
||||
|
||||
## License
|
||||
|
||||
MIT. The bundled Python codec files inherit the plugin's MIT license
|
||||
(`bal/LICENSE`); see `app/src/main/python/bal/` for attribution.
|
||||
@@ -1,67 +0,0 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.chaquo.python")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "life.after.bitcoin"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "life.after.bitcoin"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
// Chaquopy requires explicit ABI filters. Python 3.12 ships only for
|
||||
// 64-bit ABIs: phones (arm64-v8a) + the common emulator image (x86_64).
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
}
|
||||
|
||||
chaquopy {
|
||||
defaultConfig {
|
||||
version = "3.12"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.activity:activity-ktx:1.9.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
|
||||
// CameraX
|
||||
implementation("androidx.camera:camera-core:1.3.4")
|
||||
implementation("androidx.camera:camera-camera2:1.3.4")
|
||||
implementation("androidx.camera:camera-lifecycle:1.3.4")
|
||||
implementation("androidx.camera:camera-view:1.3.4")
|
||||
|
||||
// ML Kit on-device barcode scanning (QR only, no API key)
|
||||
implementation("com.google.mlkit:barcode-scanning:17.3.0")
|
||||
}
|
||||
5
android/app/proguard-rules.pro
vendored
5
android/app/proguard-rules.pro
vendored
@@ -1,5 +0,0 @@
|
||||
# Chaquopy Python runtime.
|
||||
-keep class com.chaquo.python.** { *; }
|
||||
|
||||
# ML Kit barcode scanning.
|
||||
-keep class com.google.mlkit.** { *; }
|
||||
@@ -1,31 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="true" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.BalReader">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ResultActivity"
|
||||
android:exported="false"
|
||||
android:parentActivityName=".MainActivity" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,129 +0,0 @@
|
||||
package life.after.bitcoin
|
||||
|
||||
import android.content.Context
|
||||
import com.chaquo.python.PyObject
|
||||
import com.chaquo.python.PyException
|
||||
import com.chaquo.python.Python
|
||||
import com.chaquo.python.android.AndroidPlatform
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Chaquopy bridge over the plugin's animated-QR codecs
|
||||
* (``bal.core.animated_qr``, ``bal.core.qrtransfer``, bundled verbatim under
|
||||
* ``app/src/main/python``).
|
||||
*
|
||||
* The decode tail mirrors the plugin's import dialog exactly, and runs
|
||||
* entirely inside Python (``balreader.bridge.finish``) so no container
|
||||
* conversion happens across the bridge:
|
||||
*
|
||||
* session.resolve() -> qrtransfer.decode_transfer() -> decode_will_payload()
|
||||
*
|
||||
* Kotlin only feeds frames, reads progress, and renders the JSON the bridge
|
||||
* returns.
|
||||
*/
|
||||
class BalDecoder(private val context: Context) {
|
||||
|
||||
/** Outcome of feeding one scanned frame to the session. */
|
||||
enum class AddResult {
|
||||
/** A new frame was accepted. */
|
||||
OK,
|
||||
|
||||
/** The frame was already present (duplicate); ignore. */
|
||||
DUP,
|
||||
|
||||
/** The frame was not a supported QR transfer; ignore. */
|
||||
GARBAGE,
|
||||
|
||||
/** The QR switched to a different transfer; caller should rescan. */
|
||||
CONFLICT,
|
||||
}
|
||||
|
||||
/** Fully decoded transfer, mirroring the plugin's import tail. */
|
||||
data class DecodedResult(
|
||||
val kind: String, // "will", "txs" or "error"
|
||||
val payload: String, // raw transfer text (JSON or joined tx hexes)
|
||||
val parts: List<String> // [whole-will JSON] or [tx hex strings]
|
||||
)
|
||||
|
||||
private val python: Python by lazy {
|
||||
if (!Python.isStarted()) {
|
||||
Python.start(AndroidPlatform(context))
|
||||
}
|
||||
Python.getInstance()
|
||||
}
|
||||
private val animatedQr by lazy { python.getModule("bal.core.animated_qr") }
|
||||
private val bridge by lazy { python.getModule("balreader.bridge") }
|
||||
|
||||
private var session: PyObject? = null
|
||||
|
||||
/** Start a fresh receive session (clears any accumulated frames). */
|
||||
fun reset() {
|
||||
session = null
|
||||
}
|
||||
|
||||
private fun sessionOrCreate(): PyObject {
|
||||
val current = session
|
||||
if (current != null) {
|
||||
return current
|
||||
}
|
||||
return animatedQr.callAttr("AnimatedQrSession").also { session = it }
|
||||
}
|
||||
|
||||
/** Feed one scanned frame string; see [AddResult] for semantics. */
|
||||
fun add(text: String): AddResult {
|
||||
return try {
|
||||
when (sessionOrCreate().callAttr("add_part", text).toString()) {
|
||||
"dup" -> AddResult.DUP
|
||||
else -> AddResult.OK
|
||||
}
|
||||
} catch (e: PyException) {
|
||||
val msg = e.message ?: ""
|
||||
// TransferConflictError: "Switched QR format mid-import (.. -> ..)".
|
||||
if (msg.contains("Switched QR format")) {
|
||||
AddResult.CONFLICT
|
||||
} else {
|
||||
AddResult.GARBAGE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The detected wire format ("balqr"/"ur1"/"ur2"/"bbqr"), or null. */
|
||||
val format: String?
|
||||
get() = runCatching {
|
||||
session?.get("format")?.toString()?.takeIf { it != "None" }
|
||||
}.getOrNull()
|
||||
|
||||
/** Number of distinct frames accepted. */
|
||||
val received: Int
|
||||
get() = session?.get("received")?.toInt() ?: 0
|
||||
|
||||
/** Total frames expected for the current transfer (0 until known). */
|
||||
val total: Int
|
||||
get() = session?.get("total")?.toInt() ?: 0
|
||||
|
||||
/** True once the whole transfer has been captured. */
|
||||
val done: Boolean
|
||||
get() = session?.get("done")?.toBoolean() ?: false
|
||||
|
||||
/**
|
||||
* Resolve the completed session into a [DecodedResult]. The decoding runs
|
||||
* in Python (``balreader.bridge.finish``) using the exact same three steps
|
||||
* as the plugin's import dialog.
|
||||
*/
|
||||
fun finish(): DecodedResult {
|
||||
val jsonText = bridge.callAttr("finish", sessionOrCreate()).toString()
|
||||
return try {
|
||||
val obj = JSONObject(jsonText)
|
||||
val partsArray = obj.getJSONArray("parts")
|
||||
val parts = (0 until partsArray.length()).map { partsArray.getString(it) }
|
||||
DecodedResult(
|
||||
kind = obj.getString("kind"),
|
||||
payload = obj.getString("payload"),
|
||||
parts = parts,
|
||||
)
|
||||
} catch (e: JSONException) {
|
||||
DecodedResult(kind = "error", payload = jsonText, parts = emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package life.after.bitcoin
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* Horizontal progress bar showing how many QR frames of the current transfer
|
||||
* have been captured (`received / total`), with a filled mint segment
|
||||
* proportional to the fraction. A thin decorative strip over the camera
|
||||
* preview; the exact count stays in the header's "n / N" label.
|
||||
*/
|
||||
class FrameProgressBar @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : View(context, attrs) {
|
||||
|
||||
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = ContextCompat.getColor(context, R.color.frame_fill)
|
||||
}
|
||||
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = ContextCompat.getColor(context, R.color.frame_track)
|
||||
}
|
||||
private val trackRect = RectF()
|
||||
private val fillRect = RectF()
|
||||
private val cornerRadius = dp(3f)
|
||||
|
||||
private var fraction = 0f
|
||||
|
||||
/** Reset to an empty bar. */
|
||||
fun reset() {
|
||||
fraction = 0f
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the fill to [received] out of [total] frames captured.
|
||||
* A zero/unknown total clears the bar.
|
||||
*/
|
||||
fun set(total: Int, received: Int) {
|
||||
fraction = if (total > 0) {
|
||||
received.toFloat() / total.toFloat()
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun dp(value: Float): Float =
|
||||
resources.displayMetrics.density * value
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (width <= 0 || height <= 0) {
|
||||
return
|
||||
}
|
||||
trackRect.set(0f, 0f, width.toFloat(), height.toFloat())
|
||||
canvas.drawRoundRect(trackRect, cornerRadius, cornerRadius, trackPaint)
|
||||
if (fraction <= 0f) {
|
||||
return
|
||||
}
|
||||
val fillWidth = width * fraction.coerceIn(0f, 1f)
|
||||
fillRect.set(0f, 0f, fillWidth, height.toFloat())
|
||||
canvas.drawRoundRect(fillRect, cornerRadius, cornerRadius, fillPaint)
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
package life.after.bitcoin
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanner
|
||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import life.after.bitcoin.BalDecoder.AddResult
|
||||
import life.after.bitcoin.databinding.ActivityMainBinding
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var decoder: BalDecoder
|
||||
private lateinit var barcodeScanner: BarcodeScanner
|
||||
|
||||
private val analyzerExecutor = Executors.newSingleThreadExecutor()
|
||||
private var finished = false
|
||||
private var cameraBound = false
|
||||
private var lastAnalysisMs = 0L
|
||||
|
||||
private val formatLabels: Map<String, String> by lazy {
|
||||
mapOf(
|
||||
"balqr" to getString(R.string.format_balqr),
|
||||
"ur1" to getString(R.string.format_ur1),
|
||||
"ur2" to getString(R.string.format_ur2),
|
||||
"bbqr" to getString(R.string.format_bbqr),
|
||||
)
|
||||
}
|
||||
|
||||
private val requestCameraPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) {
|
||||
startCamera()
|
||||
} else {
|
||||
Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
decoder = BalDecoder(applicationContext)
|
||||
barcodeScanner = BarcodeScanning.getClient(
|
||||
BarcodeScannerOptions.Builder()
|
||||
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
||||
.build()
|
||||
)
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
startCamera()
|
||||
} else {
|
||||
requestCameraPermission.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Returning from the result screen starts a new scan.
|
||||
if (finished) {
|
||||
finished = false
|
||||
decoder.reset()
|
||||
binding.tvFormat.text = getString(R.string.format_placeholder)
|
||||
binding.tvProgress.text = "0 / 0"
|
||||
binding.tvStatus.setText(R.string.status_waiting)
|
||||
binding.frameBar.reset()
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
startCamera()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
cameraProvider?.unbindAll()
|
||||
barcodeScanner.close()
|
||||
analyzerExecutor.shutdown()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
|
||||
private fun startCamera() {
|
||||
if (cameraBound) {
|
||||
return
|
||||
}
|
||||
val providerFuture = ProcessCameraProvider.getInstance(this)
|
||||
providerFuture.addListener({
|
||||
val provider = providerFuture.get()
|
||||
cameraProvider = provider
|
||||
|
||||
val preview = Preview.Builder().build()
|
||||
preview.setSurfaceProvider(binding.previewView.surfaceProvider)
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
analysis.setAnalyzer(analyzerExecutor) { proxy -> analyze(proxy) }
|
||||
|
||||
try {
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(
|
||||
this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
|
||||
)
|
||||
cameraBound = true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to bind camera", e)
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(this))
|
||||
}
|
||||
|
||||
private fun analyze(proxy: ImageProxy) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (finished || now - lastAnalysisMs < 100) {
|
||||
proxy.close()
|
||||
return
|
||||
}
|
||||
lastAnalysisMs = now
|
||||
val image = proxy.image
|
||||
if (image == null) {
|
||||
proxy.close()
|
||||
return
|
||||
}
|
||||
try {
|
||||
val input = InputImage.fromMediaImage(image, proxy.imageInfo.rotationDegrees)
|
||||
barcodeScanner.process(input)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
for (barcode in barcodes) {
|
||||
val value = barcode.rawValue
|
||||
if (!value.isNullOrEmpty()) {
|
||||
handleFrame(value)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
.addOnCompleteListener { proxy.close() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Frame analysis failure", e)
|
||||
proxy.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFrame(value: String) {
|
||||
if (finished) {
|
||||
return
|
||||
}
|
||||
when (decoder.add(value)) {
|
||||
AddResult.OK -> {
|
||||
Log.i(TAG, "frame ok fmt=${decoder.format} rcvd=${decoder.received}/${decoder.total} done=${decoder.done}")
|
||||
binding.tvFormat.text = decoder.format?.let { formatLabels[it] }
|
||||
?: getString(R.string.format_placeholder)
|
||||
binding.tvProgress.text =
|
||||
getString(R.string.progress_fmt, decoder.received, decoder.total)
|
||||
binding.frameBar.set(decoder.total, decoder.received)
|
||||
if (decoder.done) {
|
||||
finishScan()
|
||||
}
|
||||
}
|
||||
AddResult.DUP -> Log.i(TAG, "frame dup")
|
||||
AddResult.GARBAGE -> Log.w(TAG, "frame garbage")
|
||||
AddResult.CONFLICT -> {
|
||||
Log.w(TAG, "format conflict - resetting")
|
||||
decoder.reset()
|
||||
binding.tvFormat.text = getString(R.string.format_placeholder)
|
||||
binding.tvProgress.text = "0 / 0"
|
||||
binding.tvStatus.setText(R.string.conflict_message)
|
||||
binding.frameBar.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishScan() {
|
||||
if (finished) {
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
val result = decoder.finish()
|
||||
Log.i(TAG, "FINISH kind=${result.kind} payload=${result.payload.length}B parts=${result.parts.size}")
|
||||
val intent = Intent(this, ResultActivity::class.java).apply {
|
||||
putExtra(ResultActivity.EXTRA_KIND, result.kind)
|
||||
putExtra(ResultActivity.EXTRA_PAYLOAD, result.payload)
|
||||
putStringArrayListExtra(ResultActivity.EXTRA_PARTS, ArrayList(result.parts))
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BalReader"
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package life.after.bitcoin
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import life.after.bitcoin.databinding.ActivityResultBinding
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
class ResultActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityResultBinding
|
||||
private var kind = "txs"
|
||||
private var payload = ""
|
||||
private var parts: List<String> = emptyList()
|
||||
|
||||
private val saveWillPicker =
|
||||
registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri: Uri? ->
|
||||
saveTo(uri)
|
||||
}
|
||||
private val saveTxsPicker =
|
||||
registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri: Uri? ->
|
||||
saveTo(uri)
|
||||
}
|
||||
|
||||
private fun saveTo(uri: Uri?) {
|
||||
if (uri != null) {
|
||||
contentResolver.openOutputStream(uri)?.use { it.write(payload.toByteArray()) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityResultBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
kind = intent.getStringExtra(EXTRA_KIND) ?: "txs"
|
||||
payload = intent.getStringExtra(EXTRA_PAYLOAD) ?: ""
|
||||
parts = intent.getStringArrayListExtra(EXTRA_PARTS) ?: emptyList()
|
||||
|
||||
binding.tvKind.text =
|
||||
getString(if (kind == "will") R.string.result_kind_will else R.string.result_kind_txs)
|
||||
binding.tvContent.text = pretty(payload)
|
||||
|
||||
binding.btnCopy.setOnClickListener {
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("BAL transfer", payload))
|
||||
Toast.makeText(this, R.string.copied_toast, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
binding.btnShare.setOnClickListener {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, payload)
|
||||
}
|
||||
startActivity(Intent.createChooser(send, null))
|
||||
}
|
||||
binding.btnSave.setOnClickListener { saveFile() }
|
||||
binding.btnScanAnother.setOnClickListener { finish() }
|
||||
}
|
||||
|
||||
private fun pretty(json: String): String {
|
||||
if (kind == "will") {
|
||||
try {
|
||||
return JSONObject(json).toString(2)
|
||||
} catch (_: JSONException) {
|
||||
return json
|
||||
}
|
||||
}
|
||||
// Transaction list: one numbered line per tx.
|
||||
if (parts.isNotEmpty()) {
|
||||
return parts.mapIndexed { i, tx -> "%d. %s".format(i + 1, tx) }
|
||||
.joinToString("\n")
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
private fun saveFile() {
|
||||
val name = if (kind == "will") {
|
||||
getString(R.string.save_file_will)
|
||||
} else {
|
||||
getString(R.string.save_file_txs)
|
||||
}
|
||||
// ActivityResultContracts.CreateDocument takes the suggested file name;
|
||||
// it maps it to ACTION_CREATE_DOCUMENT + EXTRA_TITLE internally.
|
||||
val picker = if (kind == "will") saveWillPicker else saveTxsPicker
|
||||
picker.launch(name)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_KIND = "kind"
|
||||
const val EXTRA_PAYLOAD = "payload"
|
||||
const val EXTRA_PARTS = "parts"
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
"""
|
||||
bal.core
|
||||
========
|
||||
|
||||
Pure business-logic layer of the Bitcoin After Life (BAL) Electrum plugin.
|
||||
|
||||
Everything in this sub-package MUST stay completely free of any GUI / Qt
|
||||
imports. The rule of thumb is:
|
||||
|
||||
* ``bal.core`` -> "what the plugin does" (inheritance rules, building
|
||||
and validating transactions, talking to
|
||||
will-executor servers, persistence helpers).
|
||||
* ``bal.gui`` -> "how it looks" (Qt widgets, dialogs, list views).
|
||||
|
||||
Keeping the two apart is the main motivation behind this rewrite: the original
|
||||
code mixed transaction-building logic and presentation inside a single
|
||||
4000-line ``qt.py`` module, which made the delicate Bitcoin logic hard to audit.
|
||||
|
||||
No behaviour is changed with respect to the original plugin; the code has only
|
||||
been reorganised and documented.
|
||||
"""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,304 +0,0 @@
|
||||
"""
|
||||
bal.core.qrtransfer
|
||||
===================
|
||||
|
||||
GUI-free helpers for moving BAL will data between devices via QR codes or
|
||||
the Electrum ``audio_modem`` plugin (see ``PLAN_QR_TRANSFER.md``).
|
||||
|
||||
Scope
|
||||
-----
|
||||
* converts will transactions into a compact ``transfer_string``
|
||||
(newline-joined serialized transactions, optionally zlib + base64
|
||||
compressed);
|
||||
* splits that string into fixed-size ``BAL1<TTT><iii><flag>`` frames for
|
||||
multi-QR export, and reassembles/validates them on import.
|
||||
|
||||
Wire format (v2, compact)
|
||||
-------------------------
|
||||
A frame is::
|
||||
|
||||
BAL1<TTT><iii><flag><payload>
|
||||
|
||||
* ``BAL1`` - magic + format era (4 chars).
|
||||
* ``TTT`` - frame total as exactly 3 base36 digits (1-based, cap 46655).
|
||||
* ``iii`` - frame index as exactly 3 base36 digits (1-based).
|
||||
* ``flag`` - one char: ``Z`` (zlib + base64) or ``0`` (plain ASCII).
|
||||
* ``payload`` - every other character of the frame; the payloads of all
|
||||
frames, concatenated in index order, rebuild the transfer string.
|
||||
|
||||
The fixed 11-char header replaces the legacy ``BALQR1|N|i|flags|`` form
|
||||
(same 5 pieces of information) without any pipe separator, so the whole
|
||||
frame is scan-friendly and the overhead no longer grows with the frame
|
||||
count. Legacy ``BALQR1|…`` frames are still accepted on import.
|
||||
|
||||
The audio-modem channel deliberately bypasses the framing helpers here
|
||||
(PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and
|
||||
carries the whole transfer string in a single blob, so callers only use
|
||||
:func:`encode_transfer` / :func:`decode_transfer`.
|
||||
|
||||
This module never imports Qt or any Electrum GUI code (house rule).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import zlib
|
||||
|
||||
MAGIC = "BALQR"
|
||||
VERSION = 1
|
||||
FLAG_COMPRESSED = "Z"
|
||||
FLAG_PLAIN = "0"
|
||||
|
||||
# 4 standard presets (label, payload budget in bytes per QR). Ordered from
|
||||
# low-resolution cameras to high-resolution cameras (owner decision D5).
|
||||
CHUNK_PRESETS = (
|
||||
("Small - ~150 bytes/QR (low-res cameras)", 150),
|
||||
("Medium - ~400 bytes/QR", 400),
|
||||
("Large - ~900 bytes/QR", 900),
|
||||
("XL - ~1800 bytes/QR (high-res cameras)", 1800),
|
||||
)
|
||||
|
||||
# Smallest allowed payload budget per frame, below which the frame header
|
||||
# could consume the whole budget.
|
||||
MIN_CHUNK_SIZE = 40
|
||||
|
||||
# Legacy wire format (still imported); the exporter emits the v2 form below.
|
||||
_FRAME_MAGIC_V1 = MAGIC + str(VERSION)
|
||||
|
||||
# Compact v2 wire format: fixed-width base36 count fields, no separators.
|
||||
_FRAME_MAGIC_V2 = "BAL1"
|
||||
_BASE36_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
_BASE36_WIDTH = 3
|
||||
_HEADER_V2_LEN = len(_FRAME_MAGIC_V2) + 2 * _BASE36_WIDTH + 1
|
||||
_MAX_TOTAL = 36 ** _BASE36_WIDTH - 1
|
||||
|
||||
|
||||
class QrTransferError(ValueError):
|
||||
"""Base error for will QR / audio transfer processing."""
|
||||
|
||||
|
||||
class MissingFramesError(QrTransferError):
|
||||
"""Some frame indices of a multi-QR transfer are missing."""
|
||||
|
||||
def __init__(self, missing):
|
||||
self.missing = list(missing)
|
||||
super().__init__("Missing QR frames: {}".format(self.missing))
|
||||
|
||||
|
||||
class InconsistentTotalError(QrTransferError):
|
||||
"""Frames disagree about the advertised frame total."""
|
||||
|
||||
|
||||
def encode_transfer(tx_strings, compress=False):
|
||||
"""Join serialized transaction strings into a transfer string.
|
||||
|
||||
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
|
||||
the whole bundle shrinks before being printed/scanned. The optional flag
|
||||
of the frame header lets the importer reverse this automatically.
|
||||
"""
|
||||
return __compress("\n".join(tx_strings), enabled=compress)
|
||||
|
||||
|
||||
def encode_transfer_best(tx_strings):
|
||||
"""Encode ``tx_strings`` with the smaller of plain vs compressed form.
|
||||
|
||||
Returns ``(transfer_string, compressed: bool)``. Compressed wins only
|
||||
when zlib + base64 really is shorter (best-of, never larger).
|
||||
"""
|
||||
joined = "\n".join(tx_strings)
|
||||
plain = joined
|
||||
compressed = __compress(joined, enabled=True)
|
||||
if len(compressed) < len(plain):
|
||||
return compressed, True
|
||||
return plain, False
|
||||
|
||||
|
||||
def decode_transfer(transfer_string, compressed):
|
||||
"""Inverse of :func:`encode_transfer`.
|
||||
|
||||
Returns the list of serialized transaction strings; empty frames are
|
||||
dropped so a trailing newline (or an empty payload) cannot produce an
|
||||
empty trailing element.
|
||||
"""
|
||||
text = __decompress(transfer_string, enabled=compressed)
|
||||
return [part for part in text.split("\n") if part]
|
||||
|
||||
|
||||
def split_frames(transfer_string, chunk_size, compressed=False):
|
||||
"""Split ``transfer_string`` into full compact ``BAL1`` frames.
|
||||
|
||||
Every returned frame has the fixed 11-char v2 header followed by its
|
||||
share of the payload, so each frame is at most ``chunk_size`` characters
|
||||
long. ``compressed`` stamps the ``Z`` flag into every frame so the
|
||||
importer knows how to reverse the encoding.
|
||||
|
||||
Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold
|
||||
the header plus any payload, or when the transfer needs more than
|
||||
:data:`_MAX_TOTAL` frames.
|
||||
"""
|
||||
flag = FLAG_COMPRESSED if compressed else FLAG_PLAIN
|
||||
total = __compute_total(len(transfer_string), chunk_size)
|
||||
budget = chunk_size - _HEADER_V2_LEN
|
||||
frames = []
|
||||
pos = 0
|
||||
length = len(transfer_string)
|
||||
for index in range(1, total + 1):
|
||||
end = min(pos + budget, length)
|
||||
frames.append(
|
||||
_FRAME_MAGIC_V2
|
||||
+ _base36(total)
|
||||
+ _base36(index)
|
||||
+ flag
|
||||
+ transfer_string[pos:end]
|
||||
)
|
||||
pos = end
|
||||
if pos < length:
|
||||
# __compute_total guarantees this cannot happen; keep a safety net.
|
||||
raise QrTransferError("internal error: frames did not cover the transfer string")
|
||||
return frames
|
||||
|
||||
|
||||
def parse_frame(frame):
|
||||
"""Parse a single frame.
|
||||
|
||||
Accepts both the legacy ``BALQR1|total|index|flags|payload`` form and
|
||||
the compact ``BAL1<total><index><flag><payload>`` v2 form.
|
||||
|
||||
Returns ``(total, index, compressed: bool, payload: str)``. Raises
|
||||
:class:`QrTransferError` on malformed input (bad magic/version, wrong
|
||||
arity, non-integer or out-of-range frame numbers, unknown flags).
|
||||
"""
|
||||
if frame.startswith(_FRAME_MAGIC_V2):
|
||||
return _parse_v2(frame)
|
||||
return _parse_v1(frame)
|
||||
|
||||
|
||||
def assemble(frames, total):
|
||||
"""Concatenate frame payloads back into a transfer string.
|
||||
|
||||
``frames`` maps 1-based index -> payload. Every index ``1..total`` must
|
||||
be present (else :class:`MissingFramesError`) and no index may exceed
|
||||
``total`` (else :class:`InconsistentTotalError`).
|
||||
"""
|
||||
if total < 1:
|
||||
raise QrTransferError("invalid frame total")
|
||||
missing = [index for index in range(1, total + 1) if index not in frames]
|
||||
if missing:
|
||||
raise MissingFramesError(missing)
|
||||
extra = [index for index in frames if index > total]
|
||||
if extra:
|
||||
raise InconsistentTotalError()
|
||||
return "".join(frames[index] for index in range(1, total + 1))
|
||||
|
||||
|
||||
def preset_index_for_chunk_size(chunk_size):
|
||||
"""Return the :data:`CHUNK_PRESETS` index whose budget best matches a size."""
|
||||
best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1])
|
||||
for index, (_label, budget) in enumerate(CHUNK_PRESETS):
|
||||
diff = abs(chunk_size - budget)
|
||||
if diff < best_diff:
|
||||
best, best_diff = index, diff
|
||||
return best
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Internals
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def __compress(text, *, enabled):
|
||||
if not enabled:
|
||||
return text
|
||||
return base64.b64encode(zlib.compress(text.encode("utf-8"))).decode("ascii")
|
||||
|
||||
|
||||
def __decompress(text, *, enabled):
|
||||
if not enabled:
|
||||
return text
|
||||
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
|
||||
|
||||
|
||||
def _base36(n):
|
||||
"""Zero-padded :data:`_BASE36_WIDTH` base36 render of ``n``."""
|
||||
if not 0 <= n <= _MAX_TOTAL:
|
||||
raise QrTransferError("BAL QR part number out of range: {}".format(n))
|
||||
chars = []
|
||||
for _ in range(_BASE36_WIDTH):
|
||||
chars.append(_BASE36_DIGITS[n % 36])
|
||||
n //= 36
|
||||
return "".join(reversed(chars))
|
||||
|
||||
|
||||
def _base36_decode(text):
|
||||
"""Inverse of :func:`_base36`; raises ``ValueError`` on bad input."""
|
||||
if len(text) != _BASE36_WIDTH or any(c not in _BASE36_DIGITS for c in text):
|
||||
raise ValueError(text)
|
||||
n = 0
|
||||
for c in text:
|
||||
n = n * 36 + _BASE36_DIGITS.index(c)
|
||||
return n
|
||||
|
||||
|
||||
def _parse_v1(frame):
|
||||
parts = frame.split("|", maxsplit=4)
|
||||
if len(parts) != 5:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
||||
magic_seen, total_s, index_s, flags, payload = parts
|
||||
if magic_seen != _FRAME_MAGIC_V1:
|
||||
raise QrTransferError("Not a BAL will QR (unknown magic/version)")
|
||||
try:
|
||||
total = int(total_s)
|
||||
index = int(index_s)
|
||||
except ValueError as e:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e
|
||||
if total < 1 or not 1 <= index <= total:
|
||||
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
||||
if flags not in ("", FLAG_COMPRESSED):
|
||||
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
||||
return total, index, flags == FLAG_COMPRESSED, payload
|
||||
|
||||
|
||||
def _parse_v2(frame):
|
||||
if len(frame) < _HEADER_V2_LEN:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
||||
# Magic is length _FRAME_MAGIC_V2; the two base36 fields and the flag
|
||||
# make up the rest of the fixed header.
|
||||
offset = len(_FRAME_MAGIC_V2)
|
||||
total_s = frame[offset : offset + _BASE36_WIDTH]
|
||||
index_s = frame[offset + _BASE36_WIDTH : offset + 2 * _BASE36_WIDTH]
|
||||
flag = frame[offset + 2 * _BASE36_WIDTH]
|
||||
try:
|
||||
total = _base36_decode(total_s)
|
||||
index = _base36_decode(index_s)
|
||||
except ValueError:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from None
|
||||
if total < 1 or not 1 <= index <= total:
|
||||
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
||||
if flag not in (FLAG_PLAIN, FLAG_COMPRESSED):
|
||||
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
||||
payload = frame[_HEADER_V2_LEN:]
|
||||
return total, index, flag == FLAG_COMPRESSED, payload
|
||||
|
||||
|
||||
def __compute_total(transfer_len, chunk_size):
|
||||
"""Smallest frame count whose budget covers the whole transfer string.
|
||||
|
||||
The v2 header is fixed-width, so the budget is constant and the count is
|
||||
a plain ceiling division, capped at :data:`_MAX_TOTAL`.
|
||||
"""
|
||||
if chunk_size < MIN_CHUNK_SIZE:
|
||||
raise QrTransferError(
|
||||
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
|
||||
)
|
||||
budget = chunk_size - _HEADER_V2_LEN
|
||||
if budget <= 0:
|
||||
raise QrTransferError(
|
||||
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
|
||||
)
|
||||
total = -(-transfer_len // budget)
|
||||
if total < 1:
|
||||
total = 1
|
||||
if total > _MAX_TOTAL:
|
||||
raise QrTransferError(
|
||||
"BAL QR transfer demands too many frames: {}".format(total)
|
||||
)
|
||||
return total
|
||||
@@ -1 +0,0 @@
|
||||
"""Android reader helpers built on the bundled plugin codecs."""
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Kotlin-facing helper: runs the plugin's exact import tail and returns JSON.
|
||||
|
||||
A serializable JSON contract keeps the Chaquopy bridge tiny on the Kotlin side
|
||||
and avoids exposing ``PyObject`` tuple/container indexing to it. The steps are
|
||||
the same three calls the plugin's import dialog performs:
|
||||
|
||||
session.resolve() -> (transfer_text, compressed)
|
||||
qrtransfer.decode_transfer -> parts
|
||||
balreader.payload.decode_will_payload -> ("will"|"txs", data)
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from bal.core import qrtransfer as _qrtransfer
|
||||
from balreader import payload as _payload
|
||||
|
||||
|
||||
def finish(session):
|
||||
"""Run the import tail on a live ``AnimatedQrSession``.
|
||||
|
||||
Returns a JSON string ``{"kind": ..., "payload": ..., "parts": [...]}``
|
||||
with ``kind`` either ``"will"`` or ``"txs"``. On any failure it returns
|
||||
``{"kind": "error", "payload": <message>, "parts": []}`` so a misbehaving
|
||||
session can never crash the UI thread.
|
||||
"""
|
||||
try:
|
||||
transfer, compressed = session.resolve()
|
||||
parts = list(_qrtransfer.decode_transfer(transfer, compressed))
|
||||
payload = "\n".join(parts)
|
||||
kind, _data = _payload.decode_will_payload(payload)
|
||||
return json.dumps({"kind": kind, "payload": payload, "parts": parts})
|
||||
except Exception as exc: # noqa: BLE001 - defensive bridge boundary
|
||||
return json.dumps({"kind": "error", "payload": str(exc), "parts": []})
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Will-payload autodetection for the BAL Reader app.
|
||||
|
||||
This file is a verbatim copy of ``decode_will_payload`` from
|
||||
``bal/gui/qt/dialogs.py``. ``android/test_chain/verify_chain.py`` compares the
|
||||
two functions result-for-result so they can never drift apart.
|
||||
|
||||
Keep the function body identical to the plugin source.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
def decode_will_payload(text) -> tuple[Any, Any]:
|
||||
"""Autodetect: whole-will JSON or transaction list?
|
||||
|
||||
Returns ``("will", dict_of_willitems_data)`` when ``text`` is a JSON
|
||||
object whose values are dicts containing a ``"tx"`` key (the whole-will
|
||||
format produced by :meth:`BalWindow.export_json_file` and friends).
|
||||
Otherwise returns ``("txs", [tx_strings])`` where the transaction
|
||||
strings were split on commas and/or newlines.
|
||||
"""
|
||||
text = text.strip()
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
data = None
|
||||
if isinstance(data, dict) and data:
|
||||
if all(isinstance(v, dict) and "tx" in v for v in data.values()):
|
||||
return ("will", data)
|
||||
parts = [p for p in re.split(r"[,\r\n]+", text) if p.strip()]
|
||||
return ("txs", parts)
|
||||
@@ -1,34 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#0B3D2E"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M14,14h22v22h-22z" />
|
||||
<path
|
||||
android:fillColor="#0B3D2E"
|
||||
android:pathData="M19,19h12v12h-12z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M72,14h22v22h-22z" />
|
||||
<path
|
||||
android:fillColor="#0B3D2E"
|
||||
android:pathData="M77,19h12v12h-12z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M14,72h22v22h-22z" />
|
||||
<path
|
||||
android:fillColor="#0B3D2E"
|
||||
android:pathData="M19,77h12v12h-12z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M14,42h4v4h-4z M22,42h4v4h-4z M14,50h4v4h-4z M22,50h4v4h-4z M14,58h4v4h-4z M30,42h4v4h-4z M30,50h4v4h-4z M14,66h4v4h-4z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M52,14h4v4h-4z M60,14h4v4h-4z M52,22h4v4h-4z M68,14h4v4h-4z M52,30h4v4h-4z M56,42h4v4h-4z M64,42h4v4h-4z M56,50h4v4h-4z M72,42h4v4h-4z M56,58h4v4h-4z M64,58h4v4h-4z M56,66h4v4h-4z M64,66h4v4h-4z M72,58h4v4h-4z M64,74h4v4h-4z M72,66h4v4h-4z" />
|
||||
</vector>
|
||||
@@ -1,59 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:background="@android:color/black">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="8dp"
|
||||
android:background="#1A1A1A">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_format"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/format_placeholder"
|
||||
android:textColor="#9BE8C0"
|
||||
android:textStyle="bold"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0 / 0"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<life.after.bitcoin.FrameProgressBar
|
||||
android:id="@+id/frame_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="6dp"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<androidx.camera.view.PreviewView
|
||||
android:id="@+id/preview_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="10dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/status_waiting"
|
||||
android:textColor="#CFCFCF"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
@@ -1,67 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_kind"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
android:paddingBottom="8dp" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:background="?android:attr/colorBackground">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:textIsSelectable="true"
|
||||
android:textSize="12sp"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
android:padding="8dp" />
|
||||
</ScrollView>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_copy"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/action_copy" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_share"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/action_share" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_save"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/action_save" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_scan_another"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/action_scan_another" />
|
||||
</LinearLayout>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="frame_fill">#9BE8C0</color>
|
||||
<color name="frame_track">#3A3A3A</color>
|
||||
</resources>
|
||||
@@ -1,24 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">BAL Reader</string>
|
||||
|
||||
<string name="status_waiting">Point the camera at the QR screen</string>
|
||||
<string name="format_placeholder">—</string>
|
||||
<string name="progress_fmt">%1$d / %2$d</string>
|
||||
|
||||
<string name="format_balqr">BAL QR</string>
|
||||
<string name="format_ur1">BC-UR v1</string>
|
||||
<string name="format_ur2">BC-UR v2</string>
|
||||
<string name="format_bbqr">BBQR</string>
|
||||
|
||||
<string name="result_kind_will">Whole will (JSON)</string>
|
||||
<string name="result_kind_txs">Transaction list</string>
|
||||
<string name="action_copy">Copy</string>
|
||||
<string name="action_share">Share</string>
|
||||
<string name="action_save">Save</string>
|
||||
<string name="action_scan_another">Scan another</string>
|
||||
<string name="copied_toast">Transfer copied to clipboard</string>
|
||||
<string name="save_file_will">will.json</string>
|
||||
<string name="save_file_txs">will_tx.txt</string>
|
||||
<string name="permission_denied">Camera permission is required to scan QR codes.</string>
|
||||
<string name="conflict_message">The QR switched to a different transfer. Let it rescan.</string>
|
||||
</resources>
|
||||
@@ -1,3 +0,0 @@
|
||||
<resources>
|
||||
<style name="Theme.BalReader" parent="Theme.AppCompat.DayNight.NoActionBar" />
|
||||
</resources>
|
||||
@@ -1,7 +0,0 @@
|
||||
// Top-level build file: plugin versions only. See the docs for the full
|
||||
// compatibility matrix (Chaquopy 17 requires AGP 7.3-9.2 and minSdk 24).
|
||||
plugins {
|
||||
id("com.android.application") version "8.10.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||
id("com.chaquo.python") version "17.0.0" apply false
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=true
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@@ -1,7 +0,0 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
android/gradlew
vendored
251
android/gradlew
vendored
@@ -1,251 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
android/gradlew.bat
vendored
94
android/gradlew.bat
vendored
@@ -1,94 +0,0 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -1,161 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the BAL Reader Android APK via Gradle.
|
||||
|
||||
Re-synchronises the bundled codec modules into the app (so the APK always
|
||||
carries the current ``bal/core`` sources), then invokes the Gradle wrapper to
|
||||
produce the APK, and finally prints the artifact path, size and sha256.
|
||||
|
||||
Run from the repository root (any Python 3.8+, needs JDK 17 and an Android
|
||||
SDK; the first build also needs a network connection for Gradle downloads):
|
||||
|
||||
python3 android/scripts/build_apk.py # debug APK
|
||||
python3 android/scripts/build_apk.py --release # (unsigned) release APK
|
||||
python3 android/scripts/build_apk.py --no-sync # skip codec re-sync
|
||||
python3 android/scripts/build_apk.py --offline # gradle --offline
|
||||
|
||||
The APK is written under ``android/app/build/outputs/apk/``.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
ANDROID_DIR = REPO_ROOT / "android"
|
||||
GRADLEW = ANDROID_DIR / "gradlew"
|
||||
LOCAL_PROPERTIES = ANDROID_DIR / "local.properties"
|
||||
WRAPPER_JAR = ANDROID_DIR / "gradle" / "wrapper" / "gradle-wrapper.jar"
|
||||
|
||||
VARIANTS = {
|
||||
"debug": "assembleDebug",
|
||||
"release": "assembleRelease",
|
||||
}
|
||||
APK_REL = {
|
||||
"debug": Path("app") / "build" / "outputs" / "apk" / "debug" / "app-debug.apk",
|
||||
"release": Path("app") / "build" / "outputs" / "apk" / "release" / "app-release-unsigned.apk",
|
||||
}
|
||||
|
||||
SYNC_SCRIPT = ANDROID_DIR / "scripts" / "sync_codecs.py"
|
||||
|
||||
|
||||
def sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def run(cmd, cwd, verbose: bool) -> int:
|
||||
if verbose:
|
||||
print("+", " ".join(str(c) for c in cmd))
|
||||
result = subprocess.run(
|
||||
cmd, cwd=str(cwd), capture_output=not verbose, text=True
|
||||
)
|
||||
if not verbose:
|
||||
sys.stdout.write(result.stdout)
|
||||
sys.stderr.write(result.stderr)
|
||||
return result.returncode
|
||||
|
||||
|
||||
def check_prerequisites() -> None:
|
||||
if not (GRADLEW.exists() and WRAPPER_JAR.exists()):
|
||||
sys.exit(
|
||||
"error: gradle wrapper is incomplete ({} missing).\n"
|
||||
"hint: run `gradle wrapper` in android/ once, or re-clone.".format(
|
||||
WRAPPER_JAR if not WRAPPER_JAR.exists() else GRADLEW
|
||||
)
|
||||
)
|
||||
|
||||
java_ok = None
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["java", "-version"], capture_output=True, text=True, check=False
|
||||
).stderr
|
||||
match = re.search(r'version "(?:1\.)?(\d+)', out)
|
||||
java_ok = int(match.group(1)) if match else None
|
||||
except FileNotFoundError:
|
||||
java_ok = None
|
||||
if java_ok is None:
|
||||
sys.exit("error: no JDK found on PATH (need JDK 17 for AGP 8.10).")
|
||||
if java_ok < 17:
|
||||
sys.exit("error: JDK {} on PATH, but the Android build needs JDK 17.".format(java_ok))
|
||||
|
||||
sdk = None
|
||||
if LOCAL_PROPERTIES.exists():
|
||||
for line in LOCAL_PROPERTIES.read_text().splitlines():
|
||||
if line.startswith("sdk.dir="):
|
||||
sdk = line.split("=", 1)[1]
|
||||
sdk = sdk or os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")
|
||||
if not sdk or not Path(sdk).exists():
|
||||
sys.exit(
|
||||
"error: Android SDK not found.\n"
|
||||
"hint: set sdk.dir in android/local.properties or ANDROID_HOME."
|
||||
)
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--release",
|
||||
action="store_true",
|
||||
help="build the (unsigned) release APK instead of the debug APK",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-sync",
|
||||
action="store_true",
|
||||
help="skip re-synchronising the bundled codec modules",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--offline",
|
||||
action="store_true",
|
||||
help="pass --offline to Gradle (no dependency downloads)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--clean",
|
||||
action="store_true",
|
||||
help="run the Gradle clean task before building",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", action="store_true", help="stream Gradle output"
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
check_prerequisites()
|
||||
variant = "release" if args.release else "debug"
|
||||
|
||||
if not args.no_sync:
|
||||
sync = subprocess.run(
|
||||
[sys.executable, str(SYNC_SCRIPT)], cwd=str(REPO_ROOT), check=False
|
||||
)
|
||||
if sync.returncode != 0:
|
||||
print("error: codec re-sync failed; refusing to build a stale APK.")
|
||||
return sync.returncode
|
||||
|
||||
tasks = []
|
||||
if args.clean:
|
||||
tasks.append("clean")
|
||||
tasks.append(VARIANTS[variant])
|
||||
cmd = [str(GRADLEW)]
|
||||
if args.offline:
|
||||
cmd.append("--offline")
|
||||
cmd.extend(tasks)
|
||||
|
||||
rc = run(cmd, cwd=ANDROID_DIR, verbose=args.verbose)
|
||||
if rc != 0:
|
||||
print("error: Gradle {} failed (exit {}).".format(" ".join(tasks), rc))
|
||||
return rc
|
||||
|
||||
apk = ANDROID_DIR / APK_REL[variant]
|
||||
if not apk.exists():
|
||||
print("error: expected APK not found at {}".format(apk))
|
||||
return 1
|
||||
data = apk.read_bytes()
|
||||
print("APK : {}".format(apk.relative_to(REPO_ROOT)))
|
||||
print("size : {} bytes".format(len(data)))
|
||||
print("sha256: {}".format(sha256(data)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Synchronise the BAL QR codec modules into the Android app.
|
||||
|
||||
Copies ``bal/core/{__init__,animated_qr,qrtransfer}.py`` from the plugin repo
|
||||
into ``android/app/src/main/python/bal/core/`` so Chaquopy ships exactly the
|
||||
same code the desktop plugin runs. Afterwards the copies are imported
|
||||
standalone and used for one quick encode/decode round trip.
|
||||
|
||||
Run from the repository root (any Python 3.8+, no dependencies):
|
||||
|
||||
python3 android/scripts/sync_codecs.py
|
||||
python3 android/scripts/sync_codecs.py --check # no writes
|
||||
|
||||
Re-run whenever ``bal/core/animated_qr.py`` or ``bal/core/qrtransfer.py``
|
||||
changes; the bundled copies are committed for deterministic builds.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
CORE_SRC = REPO_ROOT / "bal" / "core"
|
||||
PYTHON_DEST = REPO_ROOT / "android" / "app" / "src" / "main" / "python"
|
||||
BAL_CORE_DEST = PYTHON_DEST / "bal" / "core"
|
||||
|
||||
FILES = ("__init__.py", "animated_qr.py", "qrtransfer.py")
|
||||
|
||||
ROUNDTRIP = (
|
||||
"import sys; "
|
||||
"sys.path.insert(0, {dest!r}); "
|
||||
"from bal.core.animated_qr import AnimatedQrSession, ur2_frames; "
|
||||
"import bal.core.qrtransfer as qtf; "
|
||||
"frames = ur2_frames(b'roundtrip-check', 400); "
|
||||
"assert frames, 'no frames produced'; "
|
||||
"s = AnimatedQrSession(); "
|
||||
"assert all(s.add_part(f) == 'ok' for f in frames); "
|
||||
"transfer, compressed = s.resolve(); "
|
||||
"assert not compressed and transfer == 'roundtrip-check', 'roundtrip failed'; "
|
||||
"print('standalone import + roundtrip OK'); "
|
||||
)
|
||||
|
||||
|
||||
def sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def check_up_to_date() -> int:
|
||||
outdated = []
|
||||
for name in FILES:
|
||||
src = (CORE_SRC / name).read_bytes()
|
||||
dst = BAL_CORE_DEST / name
|
||||
if not dst.exists() or dst.read_bytes() != src:
|
||||
outdated.append(name)
|
||||
if outdated:
|
||||
print("OUT OF DATE: {}".format(", ".join(outdated)))
|
||||
print("run: python3 android/scripts/sync_codecs.py")
|
||||
return 1
|
||||
print("codec bundles are up to date")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="verify the bundled copies are up to date without writing",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.check:
|
||||
return check_up_to_date()
|
||||
|
||||
BAL_CORE_DEST.mkdir(parents=True, exist_ok=True)
|
||||
for name in FILES:
|
||||
src = (CORE_SRC / name).read_bytes()
|
||||
dst = BAL_CORE_DEST / name
|
||||
dst.write_bytes(src)
|
||||
print("synced {:16s} sha256={}".format(name, sha256(src)[:16]))
|
||||
|
||||
run = subprocess.run(
|
||||
[sys.executable, "-c", ROUNDTRIP.format(dest=str(PYTHON_DEST))],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if run.returncode != 0:
|
||||
print(run.stdout, end="")
|
||||
print(run.stderr, end="")
|
||||
return 1
|
||||
print(run.stdout.strip())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -1,23 +0,0 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "BALReader"
|
||||
include(":app")
|
||||
@@ -1,314 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify the Android app can decode every frame format the plugin exports.
|
||||
|
||||
Simulates the exact runtime path of the APK on the development machine:
|
||||
|
||||
* imports ``bal.core`` and ``balreader.payload`` from the *bundled* copies in
|
||||
``android/app/src/main/python`` (the code Chaquopy actually ships);
|
||||
* generates frames exactly as the plugin's export page does
|
||||
(``split_frames`` for BAL QR, ``encode_animated_frames`` semantics for
|
||||
UR v1 / UR v2 / BBQR);
|
||||
* drives an :class:`~bal.core.animated_qr.AnimatedQrSession` the way
|
||||
``BalDecoder.add`` does (scrambled input, duplicates, dropped frames);
|
||||
* runs the app's ``finish()`` chain (``resolve()`` -> ``decode_transfer()``
|
||||
-> ``decode_will_payload()``) and checks the result;
|
||||
* cross-checks the app's ``decode_will_payload`` copy result-for-result (and
|
||||
AST-for-AST) against the plugin's original in ``bal/gui/qt/dialogs.py``.
|
||||
|
||||
Run from the repository root (any Python 3.8+, no dependencies):
|
||||
|
||||
python3 android/test_chain/verify_chain.py
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
APP_PYTHON = REPO_ROOT / "android" / "app" / "src" / "main" / "python"
|
||||
DIALOGS = REPO_ROOT / "bal" / "gui" / "qt" / "dialogs.py"
|
||||
|
||||
sys.path.insert(0, str(APP_PYTHON))
|
||||
|
||||
from bal.core import animated_qr as aq # noqa: E402
|
||||
from bal.core import qrtransfer as qtf # noqa: E402
|
||||
|
||||
from balreader import bridge as bridge_codec # noqa: E402
|
||||
from balreader import payload as payload_codec # noqa: E402
|
||||
|
||||
PASSED = 0
|
||||
|
||||
|
||||
def ok(condition, label):
|
||||
global PASSED
|
||||
if not condition:
|
||||
raise AssertionError("FAILED: " + label)
|
||||
PASSED += 1
|
||||
|
||||
|
||||
def check_imported_bundle():
|
||||
for module in (aq, qtf, payload_codec):
|
||||
assert module.__file__ is not None
|
||||
path = str(Path(module.__file__).resolve())
|
||||
assert path.startswith(str(APP_PYTHON)), path
|
||||
ok(True, "all modules imported from the bundled android/ copies")
|
||||
|
||||
|
||||
def find_function(tree, name):
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == name:
|
||||
return node
|
||||
raise RuntimeError("{} not found".format(name))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
TXS = ["{:064x}".format(i) for i in range(1, 8)]
|
||||
WILL_ITEMS = {
|
||||
"imp{}".format(i): {
|
||||
"tx": "{:064x}".format(i + 1),
|
||||
"addr": "bc1qdeadbeef{:x}".format(i),
|
||||
"amount": 100000 + i,
|
||||
"tag": "heiress-{}".format(i),
|
||||
"metadata": {},
|
||||
"notify": "mail-{}@example.invalid".format(i),
|
||||
}
|
||||
for i in range(3)
|
||||
}
|
||||
WILL_JSON_COMPACT = json.dumps(WILL_ITEMS, separators=(",", ":"))
|
||||
WILL_JSON_PRETTY = json.dumps(WILL_ITEMS, indent=2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parity: app's decode_will_payload vs the plugin's dialogs.py original
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def build_extracted_and_app_function():
|
||||
dialogs_source = DIALOGS.read_text()
|
||||
app_source = Path(payload_codec.__file__).read_text()
|
||||
dialogs_node = find_function(ast.parse(dialogs_source), "decode_will_payload")
|
||||
app_node = find_function(ast.parse(app_source), "decode_will_payload")
|
||||
ok(
|
||||
ast.dump(app_node) == ast.dump(dialogs_node),
|
||||
"decode_will_payload AST identical between app copy and plugin",
|
||||
)
|
||||
namespace = {}
|
||||
exec("import json\nimport re\nfrom typing import Any", namespace)
|
||||
exec(compile(ast.Module(body=[dialogs_node], type_ignores=[]), "dialogs.py", "exec"), namespace)
|
||||
return namespace["decode_will_payload"]
|
||||
|
||||
|
||||
def run_payload_parity_cases():
|
||||
plugin_decode = build_extracted_and_app_function()
|
||||
samples = {
|
||||
"will-compact": WILL_JSON_COMPACT,
|
||||
"will-pretty": WILL_JSON_PRETTY,
|
||||
"txs-newlines": "\n".join(TXS),
|
||||
"txs-comma-crlf": ",\r\n".join(TXS[:3]),
|
||||
"single-tx": TXS[0],
|
||||
"json-array": json.dumps(TXS),
|
||||
"not-json-dict": "hello world",
|
||||
"empty": "",
|
||||
"whitespace": " \n\t ",
|
||||
}
|
||||
for label, text in samples.items():
|
||||
app_result = payload_codec.decode_will_payload(text)
|
||||
plugin_result = plugin_decode(text)
|
||||
ok(
|
||||
app_result == plugin_result,
|
||||
"payload parity for {!r}".format(label),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Full decode chain (the app's BalDecoder.finish())
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def app_chain(transfer_text, compressed):
|
||||
parts = qtf.decode_transfer(transfer_text, compressed)
|
||||
payload = "\n".join(parts)
|
||||
kind, data = payload_codec.decode_will_payload(payload)
|
||||
return parts, payload, kind, data
|
||||
|
||||
|
||||
def feed_frame_set(session, frames, *, drop=None, order=None):
|
||||
indexes = list(range(len(frames)))
|
||||
if drop:
|
||||
indexes = [i for i in indexes if i not in drop]
|
||||
if order is not None:
|
||||
indexes = list(order)
|
||||
for i in indexes:
|
||||
session.add_part(frames[i])
|
||||
|
||||
|
||||
def make_frames(transfer_text, fmt, budget_chars, *, compressed=False):
|
||||
payload = transfer_text.encode("utf-8")
|
||||
if fmt == "balqr":
|
||||
return qtf.split_frames(transfer_text, budget_chars, compressed=compressed)
|
||||
if fmt == "ur1":
|
||||
return aq.ur1_frames(payload, budget_chars)
|
||||
if fmt == "ur2":
|
||||
return aq.ur2_frames(payload, budget_chars)
|
||||
if fmt == "bbqr":
|
||||
return aq.bbqr_frames(payload, budget_chars, encoding="Z")
|
||||
raise AssertionError("unknown format " + fmt)
|
||||
|
||||
|
||||
def run_transport_case(transport, budget_chars, transfer_text, compressed=False):
|
||||
frames = make_frames(transfer_text, transport, budget_chars, compressed=compressed)
|
||||
session = aq.AnimatedQrSession()
|
||||
rng = random.Random(len(transfer_text) + len(transport.encode()))
|
||||
order = [i for i in range(len(frames))]
|
||||
rng.shuffle(order)
|
||||
feed_frame_set(session, frames, order=order)
|
||||
ok(session.done, "{} (.{} chars) reaches done in scrambled order".format(transport, budget_chars))
|
||||
if transport == "ur2":
|
||||
# Fountain indexes can range wider than seq_len, so received may
|
||||
# exceed (or fall short of) total; only progress and completion matter.
|
||||
ok(session.total >= 1 and session.received >= 1,
|
||||
"{} reports positive progress".format(transport))
|
||||
else:
|
||||
ok(
|
||||
session.received == session.total,
|
||||
"{} received matches total".format(transport),
|
||||
)
|
||||
ok(
|
||||
session.received == len(frames) and session.total == len(frames),
|
||||
"{} received/total equals frame count".format(transport),
|
||||
)
|
||||
transfer, compressed_flag = session.resolve()
|
||||
ok(transfer == transfer_text, "{} restores exact transfer text".format(transport))
|
||||
parts, payload, kind, data = app_chain(transfer, compressed_flag)
|
||||
bridge_json = json.loads(bridge_codec.finish(session))
|
||||
ok(
|
||||
bridge_json == {"kind": kind, "payload": payload, "parts": parts},
|
||||
"{} bridge.finish JSON matches the app chain".format(transport),
|
||||
)
|
||||
return parts, payload, kind, data
|
||||
|
||||
|
||||
def test_tx_transports():
|
||||
transfer = qtf.encode_transfer(TXS, compress=False)
|
||||
for transport in ("balqr", "ur1", "ur2", "bbqr"):
|
||||
parts, payload, kind, data = run_transport_case(transport, 400, transfer)
|
||||
ok(kind == "txs", "{} classifies as txs".format(transport))
|
||||
ok(parts == TXS and payload == "\n".join(TXS), "{} yields the tx list".format(transport))
|
||||
|
||||
|
||||
def test_compressed_bal_transport():
|
||||
transfer = qtf.encode_transfer(TXS, compress=True)
|
||||
frames = make_frames(transfer, "balqr", 400, compressed=True)
|
||||
session = aq.AnimatedQrSession()
|
||||
feed_frame_set(session, frames)
|
||||
ok(session.done, "compressed BAL QR done")
|
||||
parts, payload, kind, data = app_chain(*session.resolve())
|
||||
ok(kind == "txs" and parts == TXS, "compressed BAL QR yields the tx list")
|
||||
|
||||
|
||||
def test_will_transports():
|
||||
for transport in ("balqr", "ur1", "ur2", "bbqr"):
|
||||
parts, payload, kind, data = run_transport_case(transport, 400, WILL_JSON_COMPACT)
|
||||
ok(kind == "will", "{} classifies as will".format(transport))
|
||||
ok(data == WILL_ITEMS, "{} restores the whole-will dict".format(transport))
|
||||
# Pretty JSON works too (blank lines are whitespace for json.loads).
|
||||
parts, payload, kind, data = run_transport_case("ur2", 400, WILL_JSON_PRETTY)
|
||||
ok(kind == "will" and data == WILL_ITEMS, "pretty JSON transport restores the will dict")
|
||||
|
||||
|
||||
def test_duplicates_are_ignored():
|
||||
# UR v1 (multi-fragment) dedups by fragment index; UR v2 fountains never
|
||||
# report "dup" (they dedup internally), matching the plugin's behaviour.
|
||||
frames = make_frames(WILL_JSON_COMPACT, "ur1", 200)
|
||||
ok(len(frames) >= 2, "UR v1 yields multiple fragments (got {})".format(len(frames)))
|
||||
session = aq.AnimatedQrSession()
|
||||
for i in range(len(frames)):
|
||||
first = session.add_part(frames[i])
|
||||
dup = session.add_part(frames[i])
|
||||
ok(first == "ok", "fresh UR v1 frame reported as ok")
|
||||
ok(dup == "dup", "duplicate UR v1 frame reported as dup")
|
||||
ok(session.received == len(frames), "duplicates do not inflate received")
|
||||
ok(session.done, "UR v1 completes after duplicates")
|
||||
|
||||
|
||||
def test_ur2_fountain_survives_loss_and_reorder():
|
||||
transfer = qtf.encode_transfer(TXS, compress=False)
|
||||
frames = make_frames(transfer, "ur2", 120)
|
||||
ok(len(frames) >= 6, "fountain yields multiple frames (got {})".format(len(frames)))
|
||||
drop = (0, 2, len(frames) - 1)
|
||||
session = aq.AnimatedQrSession()
|
||||
rng = random.Random(99)
|
||||
order = [i for i in range(len(frames)) if i not in drop]
|
||||
rng.shuffle(order)
|
||||
feed_frame_set(session, frames, order=order, drop=drop)
|
||||
ok(session.done, "fountain completes after dropped + reordered frames")
|
||||
transfer, compressed_flag = session.resolve()
|
||||
ok(transfer == transfer, "fountain restores exact transfer text")
|
||||
parts, payload, kind, data = app_chain(transfer, compressed_flag)
|
||||
ok(kind == "txs" and parts == TXS, "fountain output feeds the full import tail")
|
||||
|
||||
|
||||
def test_ur2_single_part_and_duplicate():
|
||||
transfer = qtf.encode_transfer(TXS, compress=False)
|
||||
frames = make_frames(transfer, "ur2", 2000)
|
||||
ok(len(frames) == 1, "large budget yields a single-part UR v2 frame")
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(frames[0])
|
||||
ok(session.done and session.received == 1, "single-part UR v2 completes")
|
||||
|
||||
|
||||
def test_bbqr_all_three_encodings():
|
||||
for encoding in ("Z", "H", "2"):
|
||||
frames = aq.bbqr_frames(WILL_JSON_COMPACT.encode(), 120, encoding=encoding)
|
||||
session = aq.AnimatedQrSession()
|
||||
feed_frame_set(session, frames)
|
||||
ok(session.done, "BBQR {} done".format(encoding))
|
||||
transfer, compressed = session.resolve()
|
||||
parts, payload, kind, data = app_chain(transfer, compressed)
|
||||
ok(kind == "will" and data == WILL_ITEMS, "BBQR {} restores the will".format(encoding))
|
||||
|
||||
|
||||
def test_mid_transfer_conflict():
|
||||
ur1 = aq.ur1_frames(b"transfer-one", 400)
|
||||
ur2 = aq.ur2_frames(b"transfer-two", 400)
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(ur1[0])
|
||||
try:
|
||||
session.add_part(ur2[0])
|
||||
except aq.TransferConflictError:
|
||||
ok(True, "format switch raises TransferConflictError")
|
||||
else:
|
||||
ok(False, "format switch raised TransferConflictError")
|
||||
|
||||
|
||||
def test_garbage_and_single_line():
|
||||
session = aq.AnimatedQrSession()
|
||||
try:
|
||||
session.add_part("this is not a QR transfer")
|
||||
except aq.FormatNotDetectedError:
|
||||
ok(True, "garbage raises FormatNotDetectedError")
|
||||
else:
|
||||
ok(False, "garbage raised FormatNotDetectedError")
|
||||
|
||||
|
||||
def main():
|
||||
check_imported_bundle()
|
||||
run_payload_parity_cases()
|
||||
test_tx_transports()
|
||||
test_compressed_bal_transport()
|
||||
test_will_transports()
|
||||
test_duplicates_are_ignored()
|
||||
test_ur2_fountain_survives_loss_and_reorder()
|
||||
test_ur2_single_part_and_duplicate()
|
||||
test_bbqr_all_three_encodings()
|
||||
test_mid_transfer_conflict()
|
||||
test_garbage_and_single_line()
|
||||
print("verify_chain: {} checks passed".format(PASSED))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,202 +0,0 @@
|
||||
"""Standalone QR emitter for testing the Android reader on a real camera.
|
||||
|
||||
Replicates exactly what the plugin's QR export page puts on screen
|
||||
(``BalQrExportWidget``): the same ``encode_transfer`` + per-format frame
|
||||
encoders from ``bal.core``, rendered one QR at a time with ``qrcode``.
|
||||
|
||||
Run from the repo root with the runtime venv (has ``bal``, ``qrcode``,
|
||||
PyQt6):
|
||||
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
QT_QPA_PLATFORM=xcb python3 android/tools/emitter.py --format ur2 --loop
|
||||
|
||||
Controls:
|
||||
Left/Right previous / next frame
|
||||
Space toggle autoplay
|
||||
L toggle loop (default off)
|
||||
Q / Esc quit
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
import qrcode # noqa: E402
|
||||
from PyQt6.QtCore import Qt, QTimer # noqa: E402
|
||||
from PyQt6.QtGui import QColor, QImage, QPainter, QPixmap # noqa: E402
|
||||
from PyQt6.QtWidgets import QLabel, QMainWindow, QWidget # noqa: E402
|
||||
|
||||
from bal.core import animated_qr as aq # noqa: E402
|
||||
from bal.core import qrtransfer as qtf # noqa: E402
|
||||
|
||||
|
||||
def build_frames(tx_strings, fmt, budget):
|
||||
"""Frames exactly as BalQrExportWidget._refresh_frames produces them."""
|
||||
transfer = qtf.encode_transfer(tx_strings, compress=False)
|
||||
if fmt == "balqr":
|
||||
return qtf.split_frames(transfer, budget, compressed=False)
|
||||
payload = transfer.encode("utf-8")
|
||||
if fmt == "ur1":
|
||||
return aq.ur1_frames(payload, budget)
|
||||
if fmt == "ur2":
|
||||
return aq.ur2_frames(payload, budget)
|
||||
if fmt == "bbqr":
|
||||
return aq.bbqr_frames(payload, budget, encoding="Z")
|
||||
raise SystemExit("unknown format: {}".format(fmt))
|
||||
|
||||
|
||||
def load_payload(args):
|
||||
"""Return ``(tx_strings, description)`` mirroring ``_payload_strings``."""
|
||||
if args.will is not None:
|
||||
will = json.loads(Path(args.will).read_text())
|
||||
return (
|
||||
[json.dumps(will, ensure_ascii=False)],
|
||||
"whole-will JSON ({})".format(Path(args.will).name),
|
||||
)
|
||||
if args.txs is not None:
|
||||
raw = Path(args.txs).read_text()
|
||||
return [line.strip() for line in raw.split() if line.strip()], "txs"
|
||||
raise SystemExit("give --will FILE or --txs FILE")
|
||||
|
||||
|
||||
def qr_pixmap(text, size):
|
||||
"""Render ``text`` as a QR code fitted to a ``size``x``size`` image."""
|
||||
qr = qrcode.QRCode(border=2, error_correction=qrcode.constants.ERROR_CORRECT_M)
|
||||
qr.add_data(text)
|
||||
qr.make(fit=True)
|
||||
matrix = qr.modules
|
||||
n = len(matrix)
|
||||
border = qr.border
|
||||
scale = max(1, size // (n + 2 * border))
|
||||
dim = (n + 2 * border) * scale
|
||||
image = QImage(dim, dim, QImage.Format.Format_RGB32)
|
||||
image.fill(Qt.GlobalColor.white)
|
||||
painter = QPainter(image)
|
||||
painter.fillRect(0, 0, dim, dim, QColor("white"))
|
||||
painter.setBrush(QColor("black"))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
for y, row in enumerate(matrix):
|
||||
for x, on in enumerate(row):
|
||||
if on:
|
||||
painter.fillRect(
|
||||
(x + border) * scale, (y + border) * scale, scale, scale,
|
||||
QColor("black"),
|
||||
)
|
||||
painter.end()
|
||||
return QPixmap.fromImage(image).scaled(
|
||||
size, size, Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
|
||||
|
||||
class EmitterWindow(QMainWindow):
|
||||
def __init__(self, frames, description, fmt, fps, loop):
|
||||
super().__init__()
|
||||
self.frames = frames
|
||||
self.fps = fps
|
||||
self.loop = loop
|
||||
self.index = 0
|
||||
self.autoplay = True
|
||||
|
||||
central = QWidget(self)
|
||||
self.setCentralWidget(central)
|
||||
self.pix = QLabel(central)
|
||||
self.pix.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.caption = QLabel(central)
|
||||
self.caption.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
import PyQt6.QtWidgets as qt # noqa: N813 - local import for clarity
|
||||
|
||||
v = qt.QVBoxLayout(central)
|
||||
v.addWidget(self.pix, 1)
|
||||
v.addWidget(self.caption)
|
||||
|
||||
self.setWindowTitle("BAL Reader emitter — {}".format(fmt))
|
||||
self.resize(900, 1000)
|
||||
|
||||
self.timer = QTimer(self)
|
||||
self.timer.timeout.connect(self._step)
|
||||
self.timer.start(int(1000 / self.fps))
|
||||
|
||||
self._render()
|
||||
self.caption.setText(
|
||||
"{desc} | {fmt} | frame {i}/{n} | autoplay={auto} loop={loop}".format(
|
||||
desc=description, fmt=fmt, i=self.index + 1, n=len(self.frames),
|
||||
auto="on" if self.autoplay else "off", loop="on" if loop else "off",
|
||||
)
|
||||
)
|
||||
self.show()
|
||||
|
||||
def _render(self):
|
||||
self.pix.setPixmap(qr_pixmap(self.frames[self.index], min(self.width() - 60, 900)))
|
||||
|
||||
def _update_caption(self):
|
||||
auto = "on" if self.autoplay else "off"
|
||||
loop = "on" if self.loop else "off"
|
||||
self.caption.setText(
|
||||
"frame {i}/{n} ({fmt}) | autoplay={auto} loop={loop}".format(
|
||||
i=self.index + 1, n=len(self.frames), fmt="", auto=auto, loop=loop)
|
||||
)
|
||||
|
||||
def _step(self):
|
||||
if self.index + 1 < len(self.frames):
|
||||
self.index += 1
|
||||
elif self.loop:
|
||||
self.index = 0
|
||||
else:
|
||||
self.autoplay = False
|
||||
self.timer.stop()
|
||||
self._render()
|
||||
self._update_caption()
|
||||
|
||||
def keyPressEvent(self, event): # noqa: N802 - Qt override name
|
||||
key = event.key()
|
||||
if key == Qt.Key.Key_Right:
|
||||
self.autoplay = False
|
||||
self.index = min(self.index + 1, len(self.frames) - 1)
|
||||
self._render()
|
||||
elif key == Qt.Key.Key_Left:
|
||||
self.autoplay = False
|
||||
self.index = max(self.index - 1, 0)
|
||||
self._render()
|
||||
elif key == Qt.Key.Key_Space:
|
||||
self.autoplay = not self.autoplay
|
||||
if self.autoplay:
|
||||
self.timer.start(int(1000 / self.fps))
|
||||
else:
|
||||
self.timer.stop()
|
||||
elif key == Qt.Key.Key_L:
|
||||
self.loop = not self.loop
|
||||
elif key in (Qt.Key.Key_Q, Qt.Key.Key_Escape):
|
||||
self.close()
|
||||
self._update_caption()
|
||||
|
||||
|
||||
def main(argv):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--format", choices=["balqr", "ur1", "ur2", "bbqr"],
|
||||
default="balqr")
|
||||
parser.add_argument("--budget", type=int, default=400)
|
||||
parser.add_argument("--fps", type=float, default=1.0)
|
||||
parser.add_argument("--loop", action="store_true")
|
||||
parser.add_argument("--will", help="whole-will JSON file")
|
||||
parser.add_argument("--txs", help="file with serialized tx strings")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
tx_strings, description = load_payload(args)
|
||||
frames = build_frames(tx_strings, args.format, args.budget)
|
||||
if len(frames) == 1:
|
||||
print("single-frame transfer ready ({} bytes)".format(len(frames[0])), file=sys.stderr)
|
||||
else:
|
||||
print("{} frames ready".format(len(frames)), file=sys.stderr)
|
||||
|
||||
app = __import__("PyQt6.QtWidgets", fromlist=["QApplication"]).QApplication([])
|
||||
EmitterWindow(frames, description, args.format, args.fps, args.loop)
|
||||
app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"imp-heiress-1": {
|
||||
"tx": "0f1e2d3c4b5a69788796a5b4c3d2e1f0112233445566778899aabbccddeeff00",
|
||||
"addr": "bc1qdeadbeef0",
|
||||
"amount": 100000,
|
||||
"tag": "heiress-1",
|
||||
"metadata": {},
|
||||
"notify": "heir1@example.invalid"
|
||||
},
|
||||
"imp-heiress-2": {
|
||||
"tx": "112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011",
|
||||
"addr": "bc1qdeadbeef1",
|
||||
"amount": 200000,
|
||||
"tag": "heiress-2",
|
||||
"metadata": {},
|
||||
"notify": "heir2@example.invalid"
|
||||
},
|
||||
"imp-heiress-3": {
|
||||
"tx": "a1b2c3d4e5f60718293a4b5c6d7e8f901a2b3c4d5e6f708192a3b4c5d6e7f809",
|
||||
"addr": "bc1qdeadbeef2",
|
||||
"amount": 300000,
|
||||
"tag": "heiress-3",
|
||||
"metadata": {},
|
||||
"notify": "heir3@example.invalid"
|
||||
}
|
||||
}
|
||||
5
babel.cfg
Normal file
5
babel.cfg
Normal file
@@ -0,0 +1,5 @@
|
||||
# Babel extraction mapping for the BAL gettext catalog (see bal/i18n.py).
|
||||
# The CLI and the wallet_util helpers stay English, like Electrum's CLI.
|
||||
[ignore: bal/cli/**]
|
||||
[ignore: bal/wallet_util/**]
|
||||
[python: bal/**.py]
|
||||
@@ -40,9 +40,11 @@ from ..core import checkalive
|
||||
from ..core import heirs as heirs_mod
|
||||
from ..core import will as will_mod
|
||||
from ..core.checkalive import (
|
||||
CheckAliveAfterDeliveryError,
|
||||
CheckAliveError,
|
||||
check_alive_expired,
|
||||
ensure_check_alive_valid,
|
||||
resolve_date_to_check,
|
||||
resolve_delivery_date,
|
||||
resolve_guard_threshold,
|
||||
)
|
||||
from ..core.heirs import Heirs, is_op_return_address
|
||||
@@ -91,21 +93,27 @@ def _user_facing(e):
|
||||
_(
|
||||
"In the inheritance process, the entire wallet will always be "
|
||||
"fully emptied. Your settings require an adjustment of the "
|
||||
f"amounts: {e}"
|
||||
)
|
||||
"amounts: {}"
|
||||
).format(e)
|
||||
)
|
||||
if isinstance(e, heirs_mod.WillExecutorFeeTooHighException):
|
||||
return UserFacingException(_(f"Will-executor fee too high: {e}"))
|
||||
return UserFacingException(_("Will-executor fee too high: {}").format(e))
|
||||
if isinstance(e, heirs_mod.BalanceTooLowException):
|
||||
return UserFacingException(str(e))
|
||||
if isinstance(e, heirs_mod.HeirAmountIsDustException):
|
||||
return UserFacingException(str(e))
|
||||
if isinstance(e, heirs_mod.NotAnAddress):
|
||||
return UserFacingException(_(f"not an address, {e}"))
|
||||
return UserFacingException(_("not an address, {}").format(e))
|
||||
if isinstance(e, heirs_mod.AmountNotValid):
|
||||
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(
|
||||
_(
|
||||
@@ -272,13 +280,21 @@ 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
|
||||
)
|
||||
if check_alive_expired(self.plugin.is_basic_mode(), self.date_to_check):
|
||||
raise CheckAliveError(self.date_to_check)
|
||||
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,
|
||||
)
|
||||
self._init_heirs_to_locktime(self.plugin.ENABLE_MULTIVERSE.get())
|
||||
|
||||
def _init_heirs_to_locktime(self, multiverse=False):
|
||||
@@ -369,7 +385,9 @@ class BalController:
|
||||
tx["heirsvalue"] = txs[txid].heirsvalue
|
||||
tx["description"] = txs[txid].description
|
||||
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
|
||||
tx["status"] = _("New")
|
||||
# Stored history: always English (translated only for display by
|
||||
# format_status_history); never _() here.
|
||||
tx["status"] = "New"
|
||||
tx["baltx_fees"] = txs[txid].tx_fees
|
||||
tx["time"] = creation_time
|
||||
tx["heirs"] = copy_structure(txs[txid].heirs)
|
||||
@@ -458,7 +476,9 @@ class BalController:
|
||||
|
||||
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
||||
threshold_ts = resolve_guard_threshold(
|
||||
self.plugin.is_basic_mode(), self.will_settings
|
||||
self.plugin.is_basic_mode(),
|
||||
self.will_settings,
|
||||
creation_date=Will.get_creation_time(self.willitems),
|
||||
)
|
||||
if threshold_ts is not None:
|
||||
if locktime < threshold_ts:
|
||||
@@ -583,6 +603,11 @@ 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")
|
||||
@@ -742,7 +767,11 @@ class BalController:
|
||||
if not willitems:
|
||||
raise UserFacingException(_("No transaction matches the given txids"))
|
||||
|
||||
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
|
||||
# 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
|
||||
)
|
||||
if not willexecutors:
|
||||
return {}
|
||||
|
||||
|
||||
@@ -2,33 +2,18 @@
|
||||
bal.core.animated_qr
|
||||
====================
|
||||
|
||||
GUI-free implementation of the interoperable animated-QR transfer formats
|
||||
used to move BAL will data between devices.
|
||||
GUI-free implementation of the BBQR animated-QR transfer format used to
|
||||
move BAL will data between devices.
|
||||
|
||||
Supported wire formats (each self-describing and order-independent on
|
||||
receive):
|
||||
Supported wire format (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``, ``hashlib``, ``base64``), so the shipped plugin zip stays a
|
||||
only (``zlib``, ``base64``), so the shipped plugin zip stays a
|
||||
self-contained bundle with no third-party dependencies (house rule).
|
||||
|
||||
This module never imports Qt or any Electrum GUI code (house rule).
|
||||
@@ -37,9 +22,8 @@ This module never imports Qt or any Electrum GUI code (house rule).
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import zlib
|
||||
from typing import Dict, FrozenSet, List, Optional, Sequence, Set, Tuple
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Errors & safety caps
|
||||
@@ -62,604 +46,10 @@ 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)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -774,9 +164,6 @@ 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",
|
||||
}
|
||||
|
||||
@@ -791,34 +178,8 @@ 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
|
||||
|
||||
|
||||
@@ -830,253 +191,16 @@ 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 sessions (order-independent assembly per format)
|
||||
# Receive session (order-independent assembly)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class _BalQrSession:
|
||||
def __init__(self):
|
||||
self._frames: Dict[int, str] = {}
|
||||
self._total = 0
|
||||
self._compressed = False
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return self._total
|
||||
|
||||
@property
|
||||
def received(self) -> int:
|
||||
return len(self._frames)
|
||||
|
||||
@property
|
||||
def done(self) -> bool:
|
||||
return bool(self._total) and len(self._frames) >= self._total
|
||||
|
||||
def add(self, text: str) -> str:
|
||||
total, index, compressed, payload = _parse_balqr(text)
|
||||
if self._total and total != self._total:
|
||||
raise TransferConflictError("BAL QR transfer total changed")
|
||||
if len(self._frames) >= _MAX_SESSION_PARTS:
|
||||
raise SessionLimitError("too many BAL QR frames")
|
||||
if not self._total:
|
||||
self._total = total
|
||||
self._compressed = compressed
|
||||
if index in self._frames:
|
||||
return "dup"
|
||||
self._frames[index] = payload
|
||||
return "ok"
|
||||
|
||||
def resolve(self) -> Tuple[str, bool]:
|
||||
from bal.core.qrtransfer import assemble
|
||||
|
||||
text = assemble(self._frames, self._total)
|
||||
return text, self._compressed
|
||||
|
||||
|
||||
class _Ur1Session:
|
||||
def __init__(self):
|
||||
self._total = 0
|
||||
self._digest = ""
|
||||
self._fragments: Dict[int, str] = {}
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return self._total
|
||||
|
||||
@property
|
||||
def received(self) -> int:
|
||||
return len(self._fragments)
|
||||
|
||||
@property
|
||||
def done(self) -> bool:
|
||||
return bool(self._total) and len(self._fragments) >= self._total
|
||||
|
||||
def add(self, text: str) -> str:
|
||||
index, total, digest, fragment = ur1_parse_part(text)
|
||||
if self._total:
|
||||
if total != self._total or digest != self._digest:
|
||||
raise TransferConflictError("BC-UR v1 transfer digest changed")
|
||||
else:
|
||||
self._total = total
|
||||
self._digest = digest
|
||||
if total > _MAX_SESSION_PARTS:
|
||||
raise SessionLimitError("BC-UR v1 demands too many parts")
|
||||
if index in self._fragments:
|
||||
return "dup"
|
||||
self._fragments[index] = fragment
|
||||
return "ok"
|
||||
|
||||
def resolve(self) -> Tuple[str, bool]:
|
||||
full = "".join(self._fragments[i] for i in range(1, self._total + 1))
|
||||
try:
|
||||
message = bc32_decode(full)
|
||||
except AnimatedQrError:
|
||||
raise ChecksumError("BC-UR v1 checksum mismatch") from None
|
||||
if self._digest and _ur1_digest(message) != self._digest:
|
||||
raise ChecksumError("BC-UR v1 digest mismatch")
|
||||
return _transfer_text(unwrap_ur_cbor(message)), False
|
||||
|
||||
|
||||
class _Ur2Session:
|
||||
"""Fountain decoder mirroring the reference (C++/python) semantics."""
|
||||
|
||||
def __init__(self):
|
||||
self._seq_len = 0
|
||||
self._message_len = 0
|
||||
self._checksum = 0
|
||||
self._fragment_len = 0
|
||||
self._received: Set[int] = set()
|
||||
self._simple: Dict[FrozenSet[int], bytes] = {}
|
||||
self._mixed: Dict[FrozenSet[int], bytes] = {}
|
||||
self._queue: List[Tuple[FrozenSet[int], bytes]] = []
|
||||
self._processed = 0
|
||||
self._result: Optional[bytes] = None
|
||||
self._bad = False
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return self._seq_len
|
||||
|
||||
@property
|
||||
def received(self) -> int:
|
||||
return self._processed
|
||||
|
||||
@property
|
||||
def done(self) -> bool:
|
||||
return self._result is not None
|
||||
|
||||
def add(self, text: str) -> str:
|
||||
seq, seq_len, message_len, checksum, data = ur2_parse_part(text)
|
||||
if self._seq_len:
|
||||
if not self._validate(seq_len, message_len, checksum, len(data)):
|
||||
raise TransferConflictError("BC-UR v2 transfer header changed")
|
||||
else:
|
||||
self._seq_len = seq_len
|
||||
self._message_len = message_len
|
||||
self._checksum = checksum
|
||||
self._fragment_len = len(data)
|
||||
if seq_len > _MAX_SESSION_PARTS or message_len > _MAX_MESSAGE_BYTES:
|
||||
raise SessionLimitError("BC-UR v2 session exceeds safety caps")
|
||||
indexes = frozenset(choose_fragments(seq, self._seq_len, self._checksum))
|
||||
self._receive(indexes, bytes(data))
|
||||
return "ok"
|
||||
|
||||
def _validate(self, seq_len: int, message_len: int, checksum: int, data_len: int) -> bool:
|
||||
return (
|
||||
seq_len == self._seq_len
|
||||
and message_len == self._message_len
|
||||
and checksum == self._checksum
|
||||
and data_len == self._fragment_len
|
||||
)
|
||||
|
||||
def _receive(self, indexes: FrozenSet[int], data: bytes) -> None:
|
||||
if self._result is not None or self._bad:
|
||||
return
|
||||
self._queue.append((indexes, data))
|
||||
while self._result is None and not self._bad and self._queue:
|
||||
self._process(self._queue.pop(0))
|
||||
self._processed += 1
|
||||
|
||||
def _process(self, item: Tuple[FrozenSet[int], bytes]) -> None:
|
||||
indexes, data = item
|
||||
if len(indexes) == 1:
|
||||
self._process_simple(indexes, data)
|
||||
else:
|
||||
self._process_mixed(indexes, data)
|
||||
|
||||
def _process_simple(self, indexes: FrozenSet[int], data: bytes) -> None:
|
||||
fragment_index = next(iter(indexes))
|
||||
if fragment_index in self._received:
|
||||
return
|
||||
self._simple[indexes] = data
|
||||
self._received.add(fragment_index)
|
||||
if self._received == set(range(self._seq_len)):
|
||||
self._finish()
|
||||
return
|
||||
self._reduce_mixed_by(indexes, data)
|
||||
|
||||
def _reduce_mixed_by(self, indexes: FrozenSet[int], data: bytes) -> None:
|
||||
new_mixed: Dict[FrozenSet[int], bytes] = {}
|
||||
for other_indexes, other_data in self._mixed.items():
|
||||
reduced = self._reduce_part(other_indexes, other_data, indexes, data)
|
||||
if len(reduced[0]) == 1:
|
||||
self._queue.append(reduced)
|
||||
else:
|
||||
new_mixed[reduced[0]] = reduced[1]
|
||||
self._mixed = new_mixed
|
||||
|
||||
def _process_mixed(self, indexes: FrozenSet[int], data: bytes) -> None:
|
||||
if indexes in self._mixed:
|
||||
return
|
||||
reduced_indexes, reduced_data = indexes, data
|
||||
for simple_indexes, simple_data in self._simple.items():
|
||||
reduced_indexes, reduced_data = self._reduce_part(
|
||||
reduced_indexes, reduced_data, simple_indexes, simple_data
|
||||
)
|
||||
for other_indexes, other_data in list(self._mixed.items()):
|
||||
reduced_indexes, reduced_data = self._reduce_part(
|
||||
reduced_indexes, reduced_data, other_indexes, other_data
|
||||
)
|
||||
if len(reduced_indexes) == 1:
|
||||
self._queue.append((reduced_indexes, reduced_data))
|
||||
else:
|
||||
self._reduce_mixed_by(reduced_indexes, reduced_data)
|
||||
if reduced_indexes not in self._mixed:
|
||||
self._mixed[reduced_indexes] = reduced_data
|
||||
|
||||
@staticmethod
|
||||
def _reduce_part(
|
||||
a_indexes: FrozenSet[int], a_data: bytes, b_indexes: FrozenSet[int], b_data: bytes
|
||||
) -> Tuple[FrozenSet[int], bytes]:
|
||||
if b_indexes == a_indexes or not b_indexes.issubset(a_indexes):
|
||||
return a_indexes, a_data
|
||||
new_indexes = a_indexes - b_indexes
|
||||
new_data = bytes(x ^ y for x, y in zip(a_data, b_data, strict=True))
|
||||
return new_indexes, new_data
|
||||
|
||||
def _finish(self) -> None:
|
||||
fragments = []
|
||||
for index in range(self._seq_len):
|
||||
key = frozenset([index])
|
||||
if key not in self._simple:
|
||||
self._bad = True
|
||||
return
|
||||
fragments.append(self._simple[key])
|
||||
message = b"".join(fragments)[: self._message_len]
|
||||
if crc32_int(message) != self._checksum:
|
||||
self._bad = True
|
||||
return
|
||||
self._result = message
|
||||
|
||||
def resolve(self) -> Tuple[str, bool]:
|
||||
if self._bad:
|
||||
raise ChecksumError("BC-UR v2 message checksum mismatch")
|
||||
if self._result is None:
|
||||
raise AnimatedQrError("BC-UR v2 session is not complete")
|
||||
return _transfer_text(unwrap_ur_cbor(self._result)), False
|
||||
|
||||
|
||||
class _BbqrSession:
|
||||
def __init__(self):
|
||||
self._total = 0
|
||||
@@ -1126,8 +250,6 @@ 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
|
||||
@@ -1152,12 +274,6 @@ 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))
|
||||
|
||||
@@ -6,8 +6,21 @@ The "Check Alive" policy: the single reference timestamp (``date_to_check``)
|
||||
against which every will-validity check is evaluated, and the BASIC/ADVANCED
|
||||
mode rules that decide it.
|
||||
|
||||
Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the
|
||||
postpone/invalidate flow; the decision that it *should* be raised lives here.
|
||||
A RELATIVE Check Alive value is anchored according to its SIGN:
|
||||
|
||||
* NEGATIVE (``"-30d"`` / ``"-1y"``): counted BACKWARDS from the delivery date,
|
||||
i.e. ``delivery + N days`` (``-30d`` = 30 days BEFORE the delivery). This is
|
||||
the historical meaning of a relative threshold.
|
||||
* POSITIVE (``"30d"`` / ``"1y"``): counted FORWARD from the inheritance
|
||||
creation date (``min(WillItem.time)`` of the valid will items), i.e.
|
||||
``creation + N days``. The creation date is frozen in the wallet DB, so the
|
||||
reference never drifts with the clock.
|
||||
* When the anchor is unknown (no built will yet, or imported items without a
|
||||
``time``). The reference falls back to *now*.
|
||||
|
||||
Pure, GUI-free. The GUI raises :class:`CheckAliveError` /
|
||||
:class:`CheckAliveAfterDeliveryError` to trigger the postpone/invalidate flow;
|
||||
the decision that they *should* be raised lives here.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
@@ -19,13 +32,114 @@ from .plugin_base import BalTimestamp
|
||||
class CheckAliveError(Exception):
|
||||
"""Raised when the "check alive" date is in the past."""
|
||||
|
||||
def __init__(self, timestamp_to_check):
|
||||
def __init__(self, timestamp_to_check, reason: str | None = None):
|
||||
self.timestamp_to_check = timestamp_to_check
|
||||
self.reason = reason
|
||||
|
||||
def __str__(self):
|
||||
return "Check alive expired please update it: {}".format(
|
||||
message = "Check alive expired please update it: {}".format(
|
||||
datetime.fromtimestamp(self.timestamp_to_check, tz=timezone.utc).isoformat()
|
||||
)
|
||||
if self.reason:
|
||||
message += f" ({self.reason})"
|
||||
return message
|
||||
|
||||
|
||||
class CheckAliveAfterDeliveryError(Exception):
|
||||
"""Raised when the "check alive" date is not before the delivery date.
|
||||
|
||||
Deliberately NOT a subclass of :class:`CheckAliveError`: the existing
|
||||
``except CheckAliveError`` handlers invalidate the inheritance on-chain,
|
||||
while a Check Alive that lands on/after the delivery is a configuration
|
||||
error that must only abort the current action.
|
||||
"""
|
||||
|
||||
def __init__(self, timestamp_to_check, delivery_timestamp=None):
|
||||
self.timestamp_to_check = timestamp_to_check
|
||||
self.delivery_timestamp = delivery_timestamp
|
||||
|
||||
def __str__(self):
|
||||
check_date = datetime.fromtimestamp(
|
||||
self.timestamp_to_check, tz=timezone.utc
|
||||
).isoformat()
|
||||
if self.delivery_timestamp is None:
|
||||
return (
|
||||
"Check alive date must be after today and before the delivery "
|
||||
f"date: {check_date}"
|
||||
)
|
||||
delivery_date = datetime.fromtimestamp(
|
||||
self.delivery_timestamp, tz=timezone.utc
|
||||
).isoformat()
|
||||
return (
|
||||
"Check alive date must be after today and before the delivery "
|
||||
f"date: {check_date} >= {delivery_date}"
|
||||
)
|
||||
|
||||
|
||||
def resolve_delivery_date(
|
||||
will_settings: Any,
|
||||
now: float | None = None,
|
||||
built_locktime: float | int | None = None,
|
||||
) -> float | None:
|
||||
"""Return the delivery date every Check Alive calculation is anchored to.
|
||||
|
||||
This is the single source of the delivery reference, shared by
|
||||
:func:`resolve_date_to_check` and :func:`resolve_guard_threshold`:
|
||||
|
||||
* an ABSOLUTE stored locktime is used as-is;
|
||||
* a RELATIVE stored locktime (``"2y"``) is resolved forward from *now*,
|
||||
UNLESS a will has already been built: its frozen delivery date
|
||||
(``built_locktime``, the locktime inside the signed tx) is then
|
||||
authoritative, so an unchanged will never drifts as the clock advances.
|
||||
|
||||
Returns ``None`` when no locktime is stored.
|
||||
"""
|
||||
locktime_raw = will_settings.get("locktime")
|
||||
if locktime_raw is None:
|
||||
return None
|
||||
if BalTimestamp(locktime_raw).unit is not None and built_locktime:
|
||||
return BalTimestamp(int(built_locktime)).to_timestamp(now)
|
||||
return BalTimestamp(locktime_raw).to_timestamp(now)
|
||||
|
||||
|
||||
def resolve_relative_check_alive_date(
|
||||
threshold_raw: Any,
|
||||
delivery_date: float | None,
|
||||
creation_date: float | None = None,
|
||||
now: float | None = None,
|
||||
) -> float:
|
||||
"""Resolve a RELATIVE Check Alive value to an absolute timestamp.
|
||||
|
||||
The SIGN of the value selects the anchor (the single calculation rule):
|
||||
|
||||
* negative (``"-30d"``): ``delivery_date + N days`` (N < 0 -> BEFORE the
|
||||
delivery);
|
||||
* positive (``"30d"``): ``creation_date + N days`` (the inheritance
|
||||
creation date);
|
||||
* when the selected anchor is unknown, *now* is used as a fallback.
|
||||
|
||||
Args:
|
||||
threshold_raw: the stored ``"<n>d"`` / ``"<n>y"`` value (may be
|
||||
negative).
|
||||
delivery_date: the resolved delivery timestamp (see
|
||||
:func:`resolve_delivery_date`), or ``None``.
|
||||
creation_date: the inheritance creation timestamp
|
||||
(``Will.get_creation_time``), or ``None``.
|
||||
now: overridable clock for tests.
|
||||
|
||||
Returns:
|
||||
The absolute reference timestamp (float, UNIX seconds).
|
||||
"""
|
||||
threshold = BalTimestamp(threshold_raw)
|
||||
if threshold.unit is None:
|
||||
return threshold.to_timestamp()
|
||||
if threshold.value < 0:
|
||||
anchor = delivery_date
|
||||
else:
|
||||
anchor = creation_date
|
||||
if anchor is None:
|
||||
anchor = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
|
||||
return threshold.to_date(anchor).timestamp()
|
||||
|
||||
|
||||
def resolve_date_to_check(
|
||||
@@ -33,6 +147,7 @@ def resolve_date_to_check(
|
||||
will_settings: Any,
|
||||
now: float | None = None,
|
||||
built_locktime: float | int | None = None,
|
||||
creation_date: float | None = None,
|
||||
) -> float:
|
||||
"""Return the reference timestamp for every will-validity check.
|
||||
|
||||
@@ -41,22 +156,11 @@ def resolve_date_to_check(
|
||||
``check_amounts`` and the locktime-vs-threshold guard.
|
||||
|
||||
* BASIC mode: the Check Alive is hidden and NOT editable, so it must never
|
||||
govern those checks. ``date_to_check`` is set to *now*: every check is
|
||||
evaluated against the current moment (the Check Alive effectively does not
|
||||
exist) while the delivery locktime is still fully enforced.
|
||||
* ADVANCED mode: the user-controlled stored threshold is used as-is. An
|
||||
ABSOLUTE threshold is returned unchanged; a RELATIVE one (``"30d"``/``"1y"``)
|
||||
means "N days BEFORE the delivery date" and is resolved against the stored
|
||||
locktime (matching the date the settings widget displays), so it stays in
|
||||
lockstep with the built transactions instead of drifting with the clock.
|
||||
|
||||
A RELATIVE stored locktime is resolved against the frozen delivery date of
|
||||
the built will (``built_locktime``, the locktime inside the signed tx) when
|
||||
one exists: the will's real delivery date is authoritative, and resolving
|
||||
the relative locktime from *now* would drift ``date_to_check`` past the
|
||||
frozen tx locktime so an unchanged will wrongly reads as expired (asking to
|
||||
invalidate) every day. Without a built will the legacy forward-from-now
|
||||
resolution is kept.
|
||||
govern those checks. ``date_to_check`` is set to *now*.
|
||||
* ADVANCED mode: an ABSOLUTE stored threshold is returned unchanged; a
|
||||
RELATIVE one is resolved by :func:`resolve_relative_check_alive_date`
|
||||
(negative -> anchored to the delivery, positive -> anchored to the
|
||||
inheritance creation date).
|
||||
|
||||
Args:
|
||||
is_basic_mode: ``True`` for the SIMPLE / BASIC user type.
|
||||
@@ -65,6 +169,8 @@ 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).
|
||||
@@ -73,33 +179,24 @@ def resolve_date_to_check(
|
||||
return (now if now is not None else datetime.now(tz=timezone.utc).timestamp())
|
||||
|
||||
threshold = BalTimestamp(will_settings["threshold"])
|
||||
# A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery":
|
||||
# the settings widget resolves it as real_threshold = locktime - N days.
|
||||
# Resolving it FORWARD from now (BalTimestamp.to_timestamp) turns
|
||||
# date_to_check into a moving target that disagrees with the fixed
|
||||
# locktime of the built transactions (and with the date shown in the UI),
|
||||
# which can wrongly mark the will as expired/postponed. Resolve it
|
||||
# against the delivery date instead.
|
||||
if threshold.unit is not None:
|
||||
locktime_raw = will_settings.get("locktime")
|
||||
if locktime_raw is None:
|
||||
# No delivery reference to anchor to: fall back to the legacy
|
||||
# forward-from-now resolution.
|
||||
return threshold.to_timestamp()
|
||||
locktime_dt = BalTimestamp(locktime_raw).to_date(now)
|
||||
# A RELATIVE stored locktime ("2y") is itself a moving target; when a
|
||||
# will has already been built, its frozen delivery date (the tx
|
||||
# locktime) is the authoritative anchor (see docstring).
|
||||
if BalTimestamp(locktime_raw).unit is not None and built_locktime:
|
||||
locktime_dt = BalTimestamp(int(built_locktime)).to_date(now)
|
||||
return threshold.to_date(locktime_dt, reverse=True).timestamp()
|
||||
return threshold.to_timestamp()
|
||||
if threshold.unit is None:
|
||||
return threshold.to_timestamp()
|
||||
delivery_date = resolve_delivery_date(
|
||||
will_settings, now=now, built_locktime=built_locktime
|
||||
)
|
||||
return resolve_relative_check_alive_date(
|
||||
will_settings["threshold"],
|
||||
delivery_date,
|
||||
creation_date=creation_date,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def 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.
|
||||
|
||||
@@ -109,22 +206,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**. 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** (no ``built_locktime``). Otherwise, when the stored relative
|
||||
locktime is shorter than the frozen locktime of an old (still valid) built
|
||||
will (e.g. the delivery was shortened from ``"2y"`` to ``"1y"``), the guard
|
||||
would compare the fresh "1y" locktime against the old will's anchored
|
||||
threshold and wrongly fire, even though locktime > threshold by the
|
||||
settings themselves.
|
||||
|
||||
* BASIC mode: no threshold exists. Returns ``None`` and the caller falls
|
||||
back to comparing the locktime against ``date_to_check`` (= now), so its
|
||||
behaviour is unchanged.
|
||||
* ADVANCED mode with an ABSOLUTE threshold: returns the stored threshold
|
||||
as-is.
|
||||
* ADVANCED mode with a RELATIVE threshold (``"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.
|
||||
* ADVANCED mode with a RELATIVE threshold: resolved by
|
||||
:func:`resolve_relative_check_alive_date` (negative -> delivery anchor,
|
||||
positive -> inheritance creation date), using *now* for the delivery
|
||||
reading and never a built tx.
|
||||
|
||||
Returns ``None`` when there is no threshold to enforce (BASIC mode or a
|
||||
missing stored value).
|
||||
@@ -137,23 +234,58 @@ def resolve_guard_threshold(
|
||||
threshold = BalTimestamp(threshold_raw)
|
||||
if threshold.unit is None:
|
||||
return threshold.to_timestamp()
|
||||
now_dt = (
|
||||
datetime.fromtimestamp(now, tz=timezone.utc) if now is not None else None
|
||||
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,
|
||||
)
|
||||
locktime_dt = BalTimestamp(will_settings["locktime"]).to_date(now_dt)
|
||||
return threshold.to_date(locktime_dt, reverse=True).timestamp()
|
||||
|
||||
|
||||
def check_alive_expired(
|
||||
is_basic_mode: bool, date_to_check: float, now: float | None = None
|
||||
def is_check_alive_date_valid(
|
||||
date_to_check: float | None,
|
||||
delivery_date: float | None,
|
||||
now: float | None = None,
|
||||
) -> bool:
|
||||
"""True when the Check Alive guard should fire (``CheckAliveError``).
|
||||
"""True when the Check Alive date is inside ``(now, delivery_date)``.
|
||||
|
||||
Only ADVANCED mode can be "expired": in BASIC mode the Check Alive is inert
|
||||
by construction, so a passed check-alive date must never force a postpone or
|
||||
rewrite of the will.
|
||||
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 is_basic_mode:
|
||||
if date_to_check is None:
|
||||
return False
|
||||
current = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
|
||||
return date_to_check < current
|
||||
if date_to_check < current:
|
||||
return False
|
||||
if delivery_date is not None and date_to_check >= delivery_date:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def ensure_check_alive_valid(
|
||||
is_basic_mode: bool,
|
||||
date_to_check: float | None,
|
||||
delivery_date: float | None = None,
|
||||
now: float | None = None,
|
||||
) -> None:
|
||||
"""Raise when the Check Alive date is not valid.
|
||||
|
||||
Only ADVANCED mode can be invalid: in BASIC mode the Check Alive is inert
|
||||
by construction, so a passed check-alive date must never force a postpone
|
||||
or rewrite of the will.
|
||||
|
||||
Raises:
|
||||
CheckAliveError: the date is in the past.
|
||||
CheckAliveAfterDeliveryError: the date is on/after the delivery.
|
||||
"""
|
||||
if is_basic_mode:
|
||||
return
|
||||
if date_to_check is None:
|
||||
return
|
||||
current = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
|
||||
if date_to_check < current:
|
||||
raise CheckAliveError(date_to_check)
|
||||
if delivery_date is not None and date_to_check >= delivery_date:
|
||||
raise CheckAliveAfterDeliveryError(date_to_check, delivery_date)
|
||||
|
||||
@@ -107,7 +107,7 @@ def _checkbdy(s: str, pos: int, appendix: str) -> Tuple[int, str]:
|
||||
|
||||
|
||||
def normalize_locktime_raw_text(
|
||||
text: str, pos: int
|
||||
text: str, pos: int, allow_negative: bool = False
|
||||
) -> Tuple[str, bool, bool, int]:
|
||||
"""Sanitise the RAW locktime field text.
|
||||
|
||||
@@ -117,6 +117,9 @@ def normalize_locktime_raw_text(
|
||||
Args:
|
||||
text: the raw field text.
|
||||
pos: the cursor position within ``text``.
|
||||
allow_negative: when ``True`` a single leading ``-`` is kept (used by
|
||||
the Check Alive field, whose sign selects the anchor date). The
|
||||
delivery (locktime) field never accepts a sign.
|
||||
|
||||
Returns:
|
||||
``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised
|
||||
@@ -124,11 +127,17 @@ def normalize_locktime_raw_text(
|
||||
"""
|
||||
text = text.strip()
|
||||
chars = "0123456789dy"
|
||||
if allow_negative:
|
||||
chars += "-"
|
||||
pos = len("".join([i for i in text[:pos] if i in chars]))
|
||||
s = "".join([i for i in text if i in chars])
|
||||
isdays = False
|
||||
isyears = False
|
||||
|
||||
negative = allow_negative and "-" in s
|
||||
if negative:
|
||||
s = s.replace("-", "")
|
||||
|
||||
pos, s = _checkbdy(s, pos, "d")
|
||||
pos, s = _checkbdy(s, pos, "y")
|
||||
|
||||
@@ -141,6 +150,10 @@ def normalize_locktime_raw_text(
|
||||
s = replace_dy_suffixes(s) + "d"
|
||||
if isyears:
|
||||
s = replace_dy_suffixes(s) + "y"
|
||||
|
||||
if negative:
|
||||
s = "-" + s
|
||||
pos = max(pos, 1)
|
||||
return s, isdays, isyears, pos
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ from electrum.plugin import BasePlugin
|
||||
from electrum.transaction import tx_from_any
|
||||
from electrum.util import classproperty
|
||||
|
||||
from ..i18n import N_, _
|
||||
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
|
||||
@@ -134,12 +136,20 @@ class BalConfig:
|
||||
|
||||
Wraps ``config.get`` / ``config.set_key`` and supplies a default value
|
||||
when the key is missing.
|
||||
|
||||
``translatable=True`` is for a default *text* (marked with ``N_()``) that
|
||||
should follow the GUI language. The English default is what gets stored,
|
||||
and :meth:`get` translates it when it is read, so the stored value never
|
||||
depends on the GUI language. A text the user wrote is returned unchanged.
|
||||
Only for texts that are shown: a text that is also used to recognise
|
||||
stored data (e.g. ``HISTORY_LABEL``) must stay non-translatable.
|
||||
"""
|
||||
|
||||
def __init__(self, config, name, default):
|
||||
def __init__(self, config, name, default, translatable=False):
|
||||
self.config = config
|
||||
self.name = name
|
||||
self.default = default
|
||||
self.translatable = translatable
|
||||
|
||||
def get(self, default=None):
|
||||
"""Return the stored value, falling back to ``default`` then ``self.default``."""
|
||||
@@ -149,10 +159,20 @@ class BalConfig:
|
||||
v = default
|
||||
else:
|
||||
v = self.default
|
||||
if self.translatable and v == self.default:
|
||||
return _(v)
|
||||
return v
|
||||
|
||||
def localized_default(self):
|
||||
"""Return the default value, translated if the setting is translatable."""
|
||||
return _(self.default) if self.translatable else self.default
|
||||
|
||||
def set(self, value, save=True):
|
||||
"""Persist ``value`` for this key."""
|
||||
if self.translatable and value == self.localized_default():
|
||||
# The translated default is stored as its English original, so
|
||||
# it keeps following the GUI language (see get()).
|
||||
value = self.default
|
||||
self.config.set_key(self.name, value, save=save)
|
||||
|
||||
|
||||
@@ -243,6 +263,10 @@ class BalPlugin(BasePlugin):
|
||||
# the wallet's local history. May contain the "{willexecutor}" token,
|
||||
# which is replaced with the will-executor URL of each will item at
|
||||
# save time.
|
||||
# Deliberately NOT translatable: Util._label_matches_history() uses
|
||||
# this text to recognise BAL's own local transactions (stale-history
|
||||
# cleanup, spendable UTXOs). A label that changed with the GUI
|
||||
# language would no longer match the transactions saved before.
|
||||
self.HISTORY_LABEL = BalConfig(
|
||||
config,
|
||||
"bal_history_label",
|
||||
@@ -281,11 +305,10 @@ 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 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).
|
||||
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in characters,
|
||||
# used per QR frame when exporting/importing a will through the QR
|
||||
# channel. The export dialog seeds its own size selector from this
|
||||
# value (default 150, small QR, low-resolution cameras).
|
||||
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
|
||||
|
||||
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
|
||||
@@ -328,13 +351,20 @@ class BalPlugin(BasePlugin):
|
||||
self.WELIST_SERVER = BalConfig(
|
||||
config, "bal_welist_server", "https://welist.bitcoin-after.life/"
|
||||
)
|
||||
# Calendar (.ics) texts: only shown, never used to recognise data, so
|
||||
# their defaults follow the GUI language (translatable=True). The
|
||||
# $tokens must survive translation.
|
||||
self.EVENT_DESCRIPTION = BalConfig(
|
||||
config,
|
||||
"bal_event_description",
|
||||
"BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete",
|
||||
N_("BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete"),
|
||||
translatable=True,
|
||||
)
|
||||
self.EVENT_SUMMARY = BalConfig(
|
||||
config, "bal_event_summary", "BAL -Will execution of $wallet_name"
|
||||
config,
|
||||
"bal_event_summary",
|
||||
N_("BAL -Will execution of $wallet_name"),
|
||||
translatable=True,
|
||||
)
|
||||
|
||||
# Default will-executor servers, keyed by network. These addresses are
|
||||
@@ -467,22 +497,35 @@ class BalPlugin(BasePlugin):
|
||||
|
||||
@staticmethod
|
||||
def default_will_settings_absolute():
|
||||
"""Convert the default relative dates into absolute timestamps (from today)."""
|
||||
"""Convert the default relative dates into absolute timestamps.
|
||||
|
||||
The Check Alive anchor follows its sign (see ``bal.core.checkalive``):
|
||||
a negative default counts back from the delivery, a positive one counts
|
||||
forward from today (the inheritance creation date).
|
||||
"""
|
||||
relative_dates = BalPlugin.default_will_settings_relative()
|
||||
today = datetime.now(tz=timezone.utc).date()
|
||||
dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc)
|
||||
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}
|
||||
locktime_dt = dt + timedelta(
|
||||
days=BalTimestamp(relative_dates["locktime"]).duration_to_days()
|
||||
)
|
||||
threshold = BalTimestamp(relative_dates["threshold"])
|
||||
if threshold.unit is not None and threshold.value < 0:
|
||||
anchor_dt = locktime_dt
|
||||
else:
|
||||
anchor_dt = dt
|
||||
threshold_dt = anchor_dt + timedelta(days=threshold.duration_to_days())
|
||||
return {"threshold": threshold_dt.timestamp(), "locktime": locktime_dt.timestamp()}
|
||||
|
||||
@staticmethod
|
||||
def default_will_settings_relative():
|
||||
"""Default relative dates: 30 days threshold, 1 year locktime."""
|
||||
return {"threshold": "30d", "locktime": "1y"}
|
||||
"""Default relative dates: 30 days BEFORE the delivery, 1 year locktime.
|
||||
|
||||
The Check Alive default is NEGATIVE on purpose: it keeps counting back
|
||||
from the delivery date, preserving the historical "check alive 30 days
|
||||
before delivery" behaviour.
|
||||
"""
|
||||
return {"threshold": "-30d", "locktime": "1y"}
|
||||
|
||||
|
||||
class BalTimestamp:
|
||||
|
||||
@@ -9,31 +9,11 @@ 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.
|
||||
compressed). The transfer string is the payload carried by BBQR QR frames
|
||||
(``bal.core.animated_qr``).
|
||||
|
||||
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
|
||||
The audio-modem channel carries the whole transfer string in a single blob
|
||||
(PLAN_QR_TRANSFER.md section 4.4), so callers only use
|
||||
:func:`encode_transfer` / :func:`decode_transfer`.
|
||||
|
||||
This module never imports Qt or any Electrum GUI code (house rule).
|
||||
@@ -44,12 +24,7 @@ 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
|
||||
# 4 standard presets (label, payload budget per QR). Ordered from
|
||||
# low-resolution cameras to high-resolution cameras (owner decision D5).
|
||||
CHUNK_PRESETS = (
|
||||
("Small - ~150 bytes/QR (low-res cameras)", 150),
|
||||
@@ -58,61 +33,16 @@ 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 optional flag
|
||||
of the frame header lets the importer reverse this automatically.
|
||||
the whole bundle shrinks before being printed/scanned.
|
||||
"""
|
||||
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`.
|
||||
|
||||
@@ -124,73 +54,6 @@ 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])
|
||||
@@ -215,90 +78,3 @@ 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
|
||||
|
||||
@@ -147,6 +147,7 @@ def build_ics_reminders(
|
||||
num_reminders: int = 3,
|
||||
now: Optional[datetime] = None,
|
||||
threshold: Optional[datetime] = None,
|
||||
reminder_suffix: str = "(reminder {idx}/{total})",
|
||||
) -> Optional[str]:
|
||||
"""Build the ``.ics`` content with one VEVENT per reminder date.
|
||||
|
||||
@@ -169,6 +170,10 @@ def build_ics_reminders(
|
||||
num_reminders: requested reminder count (ADVANCED mode only).
|
||||
now: "today" reference; defaults to ``datetime.now()``.
|
||||
threshold: check-alive date (ADVANCED mode only; required there).
|
||||
reminder_suffix: text appended to each event summary, with the
|
||||
``{idx}`` and ``{total}`` fields. This module stays free of
|
||||
Electrum imports, so the GUI passes it already translated; the
|
||||
English default keeps the CLI output unchanged.
|
||||
|
||||
Returns:
|
||||
The ``.ics`` content string, or ``None`` when no reminder falls in the
|
||||
@@ -210,7 +215,9 @@ def build_ics_reminders(
|
||||
# The visible date of this event: "offset" days before the deadline.
|
||||
event_dt = format_time(locktime - timedelta(days=offset))
|
||||
# Suffix the summary so the N events are easy to tell apart.
|
||||
event_summary = ical_escape(f"{summary_base} (reminder {idx}/{total})")
|
||||
event_summary = ical_escape(
|
||||
"{} {}".format(summary_base, reminder_suffix.format(idx=idx, total=total))
|
||||
)
|
||||
lines.extend([
|
||||
"BEGIN:VEVENT",
|
||||
# Offset in the UID keeps each event unique (no merging).
|
||||
|
||||
257
bal/core/will.py
257
bal/core/will.py
@@ -26,10 +26,10 @@ 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
|
||||
from electrum.i18n import _
|
||||
from electrum.logging import Logger, get_logger
|
||||
from electrum.transaction import (
|
||||
PartialTransaction,
|
||||
@@ -44,12 +44,20 @@ from electrum.util import (
|
||||
bfh,
|
||||
)
|
||||
|
||||
from ..i18n import N_, _
|
||||
from .heirs import WillExecutorFeeTooHighException
|
||||
from .util import Util, copy_structure
|
||||
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__)
|
||||
|
||||
|
||||
@@ -68,6 +76,22 @@ 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:
|
||||
@@ -438,9 +462,68 @@ 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)
|
||||
@@ -477,7 +560,17 @@ class Will:
|
||||
utxo_to_spend, [out], locktime=locktime, version=2
|
||||
)
|
||||
tx.set_rbf(True)
|
||||
fee = tx.estimated_size() * fees_per_byte
|
||||
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"
|
||||
)
|
||||
if balance - fee > 0:
|
||||
out = PartialTxOutput.from_address_and_value(
|
||||
change_addresses[0], balance - fee
|
||||
@@ -562,9 +655,15 @@ class Will:
|
||||
def set_invalidate(wid, will=None):
|
||||
will = will if will is not None else {}
|
||||
will[wid].set_status("INVALIDATED", True)
|
||||
if will[wid].children:
|
||||
for c in will[wid].children.items():
|
||||
Will.set_invalidate(c[0], will)
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
def check_tx_height(tx, wallet):
|
||||
@@ -575,10 +674,15 @@ 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 (
|
||||
not w.father
|
||||
or willtree[w.father].get_status("CONFIRMED")
|
||||
or willtree[w.father].get_status("MEMPOOL")
|
||||
father is None
|
||||
or father.get_status("CONFIRMED")
|
||||
or father.get_status("MEMPOOL")
|
||||
):
|
||||
for inp in w.tx.inputs():
|
||||
inp_str = Util.utxo_to_str(inp)
|
||||
@@ -972,6 +1076,25 @@ class Will:
|
||||
def get_min_locktime(will,default_value=None):
|
||||
return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value)
|
||||
|
||||
@staticmethod
|
||||
def get_creation_time(will):
|
||||
"""Return the inheritance creation timestamp (oldest VALID will item).
|
||||
|
||||
A positive relative Check Alive value counts forward from this date.
|
||||
The per-item ``time`` is stamped when the will is built and persisted
|
||||
in the wallet DB, so the anchor does not drift with the clock. Only
|
||||
VALID items are considered; imported items without a ``time`` are
|
||||
ignored. Returns ``None`` when no VALID item carries a timestamp.
|
||||
"""
|
||||
return min(
|
||||
(
|
||||
v.time
|
||||
for v in will.values()
|
||||
if v.get_status('VALID') and v.time is not None
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@staticmethod
|
||||
@@ -1273,6 +1396,34 @@ class Will:
|
||||
|
||||
|
||||
|
||||
def format_status_history(status: str) -> str:
|
||||
"""Return the saved status history of a will item in the GUI language.
|
||||
|
||||
``WillItem.status`` is a dot-separated history such as
|
||||
``".Signed.Pushed.NOT Valid"``. It is stored in English (see
|
||||
``WillItem.set_status``) and translated only here, for display. A token
|
||||
that is not a known English label is shown unchanged: this keeps working
|
||||
the histories written by older versions, which stored the label already
|
||||
translated (for example ``"Firmato"``).
|
||||
"""
|
||||
labels = {label for label, _flag in WillItem.STATUS_DEFAULT.values()}
|
||||
labels.update(WillItem.LEGACY_STATUS_LABELS)
|
||||
|
||||
def translate(token):
|
||||
# "ERROR!!!" is glued to the end of the history when an item has no
|
||||
# id (see WillItem.__init__): keep it, translate what comes before.
|
||||
suffix = ""
|
||||
if token.endswith("ERROR!!!"):
|
||||
token, suffix = token[: -len("ERROR!!!")], "ERROR!!!"
|
||||
if token in labels:
|
||||
token = _(token)
|
||||
elif token.startswith("NOT ") and token[len("NOT "):] in labels:
|
||||
token = _("NOT {}").format(_(token[len("NOT "):]))
|
||||
return token + suffix
|
||||
|
||||
return ".".join(translate(token) for token in status.split("."))
|
||||
|
||||
|
||||
class WillItem(Logger):
|
||||
# Default status flags for an inheritance transaction.
|
||||
# Each entry maps an internal status key to [human-readable label, default
|
||||
@@ -1286,28 +1437,68 @@ class WillItem(Logger):
|
||||
# * "UPDATED" was added: the transaction was spendable AND valid, and a new
|
||||
# transaction replaces it while keeping the SAME locktime and SAME heirs.
|
||||
# UPDATED keeps the VALID flag (see set_status).
|
||||
#
|
||||
# The labels are marked with N_() but stay English here: they are written
|
||||
# into the saved status history (see set_status) and translated only when
|
||||
# shown, by format_status_history().
|
||||
STATUS_DEFAULT = {
|
||||
"ANTICIPATED": ["Anticipated", False],
|
||||
"BROADCASTED": ["Broadcasted", False],
|
||||
"CHECKED": ["Checked", False],
|
||||
"CHECK_FAIL": ["Check Failed", False],
|
||||
"COMPLETE": ["Signed", False],
|
||||
"CONFIRMED": ["Confirmed", False],
|
||||
"ERROR": ["Error", False],
|
||||
"EXPIRED": ["Expired", False],
|
||||
"EXPORTED": ["Exported", False],
|
||||
"IMPORTED": ["Imported", False],
|
||||
"INVALIDATED": ["Invalidated", False],
|
||||
"MEMPOOL": ["Mempool", False],
|
||||
"PUSH_FAIL": ["Push failed", False],
|
||||
"PUSHED": ["Pushed", False],
|
||||
"PARTIALLY_SIGNED": ["Partially Signed", False],
|
||||
"REPLACED": ["Replaced", False],
|
||||
"RESTORED": ["Restored", False],
|
||||
"UPDATED": ["Updated", False],
|
||||
"VALID": ["Valid", True],
|
||||
"ANTICIPATED": [N_("Anticipated"), False],
|
||||
"BROADCASTED": [N_("Broadcasted"), False],
|
||||
"CHECKED": [N_("Checked"), False],
|
||||
"CHECK_FAIL": [N_("Check Failed"), False],
|
||||
"COMPLETE": [N_("Signed"), False],
|
||||
"CONFIRMED": [N_("Confirmed"), False],
|
||||
"ERROR": [N_("Error"), False],
|
||||
"EXPIRED": [N_("Expired"), False],
|
||||
"EXPORTED": [N_("Exported"), False],
|
||||
"IMPORTED": [N_("Imported"), False],
|
||||
"INVALIDATED": [N_("Invalidated"), False],
|
||||
"MEMPOOL": [N_("Mempool"), False],
|
||||
"PUSH_FAIL": [N_("Push failed"), False],
|
||||
"PUSHED": [N_("Pushed"), False],
|
||||
"PARTIALLY_SIGNED": [N_("Partially Signed"), False],
|
||||
"REPLACED": [N_("Replaced"), False],
|
||||
"RESTORED": [N_("Restored"), False],
|
||||
"UPDATED": [N_("Updated"), False],
|
||||
"VALID": [N_("Valid"), True],
|
||||
}
|
||||
|
||||
# Labels found in histories saved by older versions but no longer
|
||||
# written: "New" opened every history (e.g. tests/samanta7).
|
||||
# format_status_history() still translates them.
|
||||
LEGACY_STATUS_LABELS = (N_("New"),)
|
||||
|
||||
# Translations of "New" that test builds of the i18n work saved INTO the
|
||||
# status history (window.py / the CLI used ``_("New")`` when building).
|
||||
# They are mapped back to "New" on load so the history is language
|
||||
# neutral again. Keep in sync with the "New" msgstr of every catalog in
|
||||
# bal/locale (tests/test_core_status_language_neutral.py checks it).
|
||||
TRANSLATED_NEW_LABELS = ("Nuovo", "Nuevo")
|
||||
|
||||
# Fields that Willexecutors.get_willexecutor_transactions /
|
||||
# push_transactions_to_willexecutor used to write into the item's own
|
||||
# executor record (older versions); they are transient push data.
|
||||
TRANSIENT_PUSH_FIELDS = ("txs", "txsids", "broadcast_status")
|
||||
|
||||
@staticmethod
|
||||
def _untranslate_new_status(status):
|
||||
"""Map a translated leading "New" of a saved history back to "New"."""
|
||||
head, sep, rest = status.partition(".")
|
||||
if head in WillItem.TRANSLATED_NEW_LABELS:
|
||||
return "New" + sep + rest
|
||||
return status
|
||||
|
||||
@staticmethod
|
||||
def _without_transient_push_fields(we):
|
||||
"""Return ``we`` without the transient push fields (a copy if needed)."""
|
||||
if not isinstance(we, dict) or not any(
|
||||
k in we for k in WillItem.TRANSIENT_PUSH_FIELDS
|
||||
):
|
||||
return we
|
||||
return {
|
||||
k: v for k, v in we.items() if k not in WillItem.TRANSIENT_PUSH_FIELDS
|
||||
}
|
||||
|
||||
def set_status(self, status, value=True):
|
||||
"""Set a status flag and apply the related side effects.
|
||||
|
||||
@@ -1341,7 +1532,11 @@ class WillItem(Logger):
|
||||
if self.STATUS[status][1] == bool(value):
|
||||
return None
|
||||
|
||||
self.status += "." + (("NOT " if not value else "") + _(self.STATUS[status][0]))
|
||||
# The history is saved in the wallet, in exported files and in QR
|
||||
# transfers, so it is always written in English: the saved data must
|
||||
# not depend on the GUI language (older versions wrote the translated
|
||||
# label). format_status_history() translates it when it is shown.
|
||||
self.status += "." + (("NOT " if not value else "") + self.STATUS[status][0])
|
||||
self.STATUS[status][1] = bool(value)
|
||||
if value:
|
||||
# NOTE: ANTICIPATED and UPDATED are intentionally NOT in this list,
|
||||
@@ -1385,8 +1580,10 @@ class WillItem(Logger):
|
||||
w = data
|
||||
self.tx = Will.get_tx_from_any(w["tx"])
|
||||
self.heirs = w.get("heirs", None)
|
||||
self.we = w.get("willexecutor", None)
|
||||
self.status = w.get("status") or ""
|
||||
self.we = WillItem._without_transient_push_fields(
|
||||
w.get("willexecutor", None)
|
||||
)
|
||||
self.status = WillItem._untranslate_new_status(w.get("status") or "")
|
||||
self.description = w.get("description", None)
|
||||
self.time = w.get("time", None)
|
||||
self.change = w.get("change", None)
|
||||
|
||||
@@ -22,10 +22,10 @@ from typing import Any
|
||||
from aiohttp import ClientResponse
|
||||
from electrum import bitcoin, constants
|
||||
from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
|
||||
from electrum.i18n import _
|
||||
from electrum.logging import get_logger
|
||||
from electrum.network import Network
|
||||
|
||||
from ..i18n import _
|
||||
from .plugin_base import BalPlugin, get_version
|
||||
|
||||
# Per-request timeout (seconds) for interactive operations (ping / info /
|
||||
@@ -230,7 +230,21 @@ class Willexecutors:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_willexecutor_transactions(will, force=False):
|
||||
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.
|
||||
"""
|
||||
willexecutors = {}
|
||||
for wid, willitem in will.items():
|
||||
if willitem.get_status("VALID"):
|
||||
@@ -238,12 +252,26 @@ 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:
|
||||
willexecutor["txs"] = ""
|
||||
willexecutor["txsids"] = []
|
||||
willexecutor["broadcast_status"] = _("Waiting...")
|
||||
willexecutors[url] = willexecutor
|
||||
# Work on a COPY of the record: the item's
|
||||
# own ``we`` dict is saved in the wallet, so
|
||||
# the transient push fields below (raw txs,
|
||||
# txids and the - translated - broadcast
|
||||
# status shown in the push dialog) must
|
||||
# never end up in it (stored data must not
|
||||
# depend on the UI language, see bal/i18n.py).
|
||||
record = dict(willexecutor)
|
||||
record["txs"] = ""
|
||||
record["txsids"] = []
|
||||
record["broadcast_status"] = _("Waiting...")
|
||||
willexecutors[url] = record
|
||||
willexecutors[url]["txs"] += str(willitem.tx) + "\n"
|
||||
willexecutors[url]["txsids"].append(wid)
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ from electrum.gui.qt.util import (
|
||||
read_QPixmap_from_bytes,
|
||||
webopen,
|
||||
)
|
||||
from electrum.i18n import _
|
||||
from electrum.logging import get_logger
|
||||
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError
|
||||
from electrum.payment_identifier import PaymentIdentifier
|
||||
@@ -137,6 +136,7 @@ from ...core.will import (
|
||||
WillExpiredException,
|
||||
WillItem,
|
||||
WillPostponedException,
|
||||
format_status_history,
|
||||
)
|
||||
from ...core.willexecutors import ( # noqa: F401
|
||||
Willexecutors,
|
||||
@@ -144,6 +144,9 @@ from ...core.willexecutors import ( # noqa: F401
|
||||
is_tor_active,
|
||||
)
|
||||
|
||||
# BAL's translator: Electrum's catalog first, then BAL's (see bal/i18n.py).
|
||||
from ...i18n import N_, _
|
||||
|
||||
# --- Presentation helpers ---
|
||||
from .theme import (
|
||||
server_status_text,
|
||||
@@ -161,6 +164,17 @@ from .window_utils import (
|
||||
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
# Labels of bal.core.qrtransfer.CHUNK_PRESETS, marked for translation here
|
||||
# because qrtransfer.py must stay free of Electrum imports (the Android reader
|
||||
# ships a copy of it). The combos show them with _(label);
|
||||
# tests/test_i18n.py checks that this list matches CHUNK_PRESETS.
|
||||
QR_PRESET_LABELS = (
|
||||
N_("Small - ~150 bytes/QR (low-res cameras)"),
|
||||
N_("Medium - ~400 bytes/QR"),
|
||||
N_("Large - ~900 bytes/QR"),
|
||||
N_("XL - ~1800 bytes/QR (high-res cameras)"),
|
||||
)
|
||||
|
||||
|
||||
class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal naming)
|
||||
_type = bool
|
||||
@@ -178,13 +192,30 @@ class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal namin
|
||||
|
||||
|
||||
def add_widget(grid, label, widget, row, help_):
|
||||
grid.addWidget(QLabel(_(label)), row, 0)
|
||||
"""Add a ``label | widget | help button`` row to ``grid``.
|
||||
|
||||
``label`` and ``help_`` must already be translated by the caller: a
|
||||
variable passed to _() cannot be extracted into the catalog.
|
||||
"""
|
||||
grid.addWidget(QLabel(label), row, 0)
|
||||
grid.addWidget(widget, row, 1)
|
||||
grid.addWidget(HelpButton(help_), row, 2)
|
||||
|
||||
|
||||
|
||||
|
||||
def translated_headers(headers):
|
||||
"""Return a translated copy of a list's ``headers`` class attribute.
|
||||
|
||||
The column headers are class attributes marked with N_(): a class body
|
||||
runs at import time, before the catalog is loaded, so they are
|
||||
translated here, each time the headers are (re)built.
|
||||
"""
|
||||
return {column: _(text) for column, text in headers.items()}
|
||||
|
||||
|
||||
|
||||
|
||||
def log_error(exec_info, window=None):
|
||||
"""Log an error and optionally show it.
|
||||
|
||||
@@ -222,7 +253,7 @@ def export_meta_gui(electrum_window, title, exporter):
|
||||
filter_ = "All files (*)"
|
||||
filename = getSaveFileName(
|
||||
parent=electrum_window,
|
||||
title=_("Select file to save your {}".format(title)),
|
||||
title=_("Select file to save your {}").format(title),
|
||||
filename="BALplugin_{}_{}_{}".format(
|
||||
BalPlugin.chainname, str(electrum_window.wallet), title
|
||||
),
|
||||
@@ -237,7 +268,7 @@ def export_meta_gui(electrum_window, title, exporter):
|
||||
electrum_window.show_critical(str(e))
|
||||
else:
|
||||
electrum_window.show_message(
|
||||
_("Your {0} were exported to '{1}'".format(title, str(filename)))
|
||||
_("Your {0} were exported to '{1}'").format(title, str(filename))
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -43,25 +43,25 @@ from ...core.animated_qr import (
|
||||
bbqr_frames,
|
||||
format_name,
|
||||
parse_for_detection,
|
||||
ur1_frames,
|
||||
ur2_frames,
|
||||
)
|
||||
from ...core.checkalive import CheckAliveError, resolve_date_to_check
|
||||
from ...core.checkalive import (
|
||||
CheckAliveAfterDeliveryError,
|
||||
CheckAliveError,
|
||||
resolve_date_to_check,
|
||||
resolve_relative_check_alive_date,
|
||||
)
|
||||
from ...core.qrtransfer import (
|
||||
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
|
||||
from .common import (
|
||||
HEIR_DUST_AMOUNT,
|
||||
HEIR_REAL_AMOUNT,
|
||||
N_,
|
||||
AmountException,
|
||||
Any,
|
||||
BalTimestamp,
|
||||
@@ -141,29 +141,6 @@ 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"):
|
||||
@@ -322,7 +299,9 @@ class BalWizardWidget(QWidget):
|
||||
self._bal_parent = parent
|
||||
self.on_next = on_next
|
||||
self.on_cancel = on_cancel
|
||||
self.titleLabel = QLabel(self.title)
|
||||
# title/message are class attributes marked with N_(): translate them
|
||||
# here, when shown (a class body runs before the catalog is loaded).
|
||||
self.titleLabel = QLabel(_(self.title) if self.title else "")
|
||||
self.vbox.addWidget(self.titleLabel)
|
||||
self.messageLabel = QLabel(_(self.message))
|
||||
self.vbox.addWidget(self.messageLabel)
|
||||
@@ -377,8 +356,8 @@ class BalWizardWidget(QWidget):
|
||||
|
||||
|
||||
class BalWizardHeirsWidget(BalWizardWidget):
|
||||
title = "Bitcoin After Life Heirs"
|
||||
message = (
|
||||
title = N_("Bitcoin After Life Heirs")
|
||||
message = N_(
|
||||
"Please add your heirs\n remember that 100% of wallet balance will be spent"
|
||||
)
|
||||
|
||||
@@ -416,18 +395,18 @@ class BalWizardHeirsWidget(BalWizardWidget):
|
||||
|
||||
|
||||
class BalWizardWEDownloadWidget(BalWizardWidget):
|
||||
title = _("Bitcoin After Life Will-Executors")
|
||||
message = _("Choose willexecutors download method")
|
||||
title = N_("Bitcoin After Life Will-Executors")
|
||||
message = N_("Choose willexecutors download method")
|
||||
|
||||
def get_content(self):
|
||||
# question = QLabel()
|
||||
self.combo = QComboBox()
|
||||
self.combo.addItems(
|
||||
[
|
||||
"Automatically download and select willexecutors",
|
||||
"Only download willexecutors list",
|
||||
"Import willexecutor list from file",
|
||||
"Manual",
|
||||
_("Automatically download and select willexecutors"),
|
||||
_("Only download willexecutors list"),
|
||||
_("Import willexecutor list from file"),
|
||||
_("Manual"),
|
||||
]
|
||||
)
|
||||
# heir_name.setFixedWidth(32 * char_width_in_lineedit())
|
||||
@@ -524,8 +503,8 @@ class BalWizardWEDownloadWidget(BalWizardWidget):
|
||||
|
||||
|
||||
class BalWizardWEWidget(BalWizardWidget):
|
||||
title = "Bitcoin After Life Will-Executors"
|
||||
message = _("Configure and select your willexecutors")
|
||||
title = N_("Bitcoin After Life Will-Executors")
|
||||
message = N_("Configure and select your willexecutors")
|
||||
|
||||
def get_content(self):
|
||||
# Lazy import to avoid a dialogs<->lists import cycle.
|
||||
@@ -544,8 +523,8 @@ class BalWizardWEWidget(BalWizardWidget):
|
||||
|
||||
|
||||
class BalWizardLocktimeAndFeeWidget(BalWizardWidget):
|
||||
title = "Bitcoin After Life Will Settings"
|
||||
message = _("")
|
||||
title = N_("Bitcoin After Life Will Settings")
|
||||
message = ""
|
||||
|
||||
def get_content(self):
|
||||
widget = QWidget()
|
||||
@@ -748,9 +727,16 @@ class BalBuildWillDialog(BalDialog):
|
||||
return
|
||||
txs = None
|
||||
_logger.debug("close plugin phase 1 started")
|
||||
varrow = self.msg_set_status("Checking variables")
|
||||
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(
|
||||
@@ -766,9 +752,9 @@ class BalBuildWillDialog(BalDialog):
|
||||
"during phase1 CAE: {}, Continue to invalidate".format(cae)
|
||||
)
|
||||
self.msg_set_status(
|
||||
"Checking variables", varrow,
|
||||
"Check Alive Threshold Passed: you have to Invalidate "
|
||||
"your old Will",
|
||||
_("Checking variables"), varrow,
|
||||
_("Check Alive Threshold Passed: you have to Invalidate "
|
||||
"your old Will"),
|
||||
self.COLOR_ERROR,
|
||||
)
|
||||
else:
|
||||
@@ -776,7 +762,7 @@ class BalBuildWillDialog(BalDialog):
|
||||
return None, tx
|
||||
except NoHeirsException:
|
||||
self.msg_set_status(
|
||||
"Checking variables", varrow, self.msg_alert("No Heirs")
|
||||
_("Checking variables"), varrow, self.msg_alert(_("No Heirs"))
|
||||
)
|
||||
return "no_heirs", None
|
||||
except Exception as e:
|
||||
@@ -799,18 +785,20 @@ class BalBuildWillDialog(BalDialog):
|
||||
max_fee=self.bal_window.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
|
||||
)
|
||||
_logger.debug("variables ok")
|
||||
self.msg_set_status("Checking variables", varrow, "Ok", self.COLOR_OK)
|
||||
self.msg_set_status(_("Checking variables"), varrow, _("Ok"), self.COLOR_OK)
|
||||
except AmountException:
|
||||
self.msg_set_checking(
|
||||
self.msg_warning(
|
||||
"In the inheritance process, "
|
||||
+ "the entire wallet will always be fully emptied. \n"
|
||||
+ "Your settings require an adjustment of the amounts"
|
||||
_(
|
||||
"In the inheritance process, "
|
||||
"the entire wallet will always be fully emptied. \n"
|
||||
"Your settings require an adjustment of the amounts"
|
||||
)
|
||||
)
|
||||
)
|
||||
except WillExecutorFeeTooHighException as e:
|
||||
self.msg_set_checking(
|
||||
self.msg_warning(f"Will-executor fee too high: {e}")
|
||||
self.msg_warning(_("Will-executor fee too high: {}").format(e))
|
||||
)
|
||||
|
||||
self.msg_set_checking()
|
||||
@@ -833,7 +821,7 @@ class BalBuildWillDialog(BalDialog):
|
||||
# makes the CHECK button and the WIZARD behave identically and fixes
|
||||
# the missing-label bug (#03).
|
||||
_logger.debug("expired")
|
||||
self.msg_set_checking("Expired")
|
||||
self.msg_set_checking(_("Expired"))
|
||||
return "invalidate_classic", None
|
||||
except WillPostponedException as e:
|
||||
# An already signed/sent will is being postponed. Like an expired
|
||||
@@ -856,7 +844,7 @@ class BalBuildWillDialog(BalDialog):
|
||||
)
|
||||
except NoHeirsException:
|
||||
_logger.debug("no heirs")
|
||||
self.msg_set_checking("No Heirs")
|
||||
self.msg_set_checking(_("No Heirs"))
|
||||
except NotCompleteWillException as e:
|
||||
_logger.debug(f"not complete {e} true")
|
||||
message = False
|
||||
@@ -1051,9 +1039,11 @@ class BalBuildWillDialog(BalDialog):
|
||||
dust_heirs.setdefault(hid, heir[HEIR_DUST_AMOUNT])
|
||||
for hid, dust_amount in dust_heirs.items():
|
||||
self.msg_set_status(
|
||||
f"{_('Heir')} {hid}",
|
||||
_("Heir {}").format(hid),
|
||||
None,
|
||||
f"{dust_amount} is DUST - excluded (amount below dust limit)",
|
||||
_("{} is DUST - excluded (amount below dust limit)").format(
|
||||
dust_amount
|
||||
),
|
||||
self.COLOR_WARNING,
|
||||
)
|
||||
|
||||
@@ -1194,10 +1184,11 @@ class BalBuildWillDialog(BalDialog):
|
||||
self.bal_window.update_setting_widgets(
|
||||
min_locktime, "locktime", update_all=True
|
||||
)
|
||||
# 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.
|
||||
# A relative "Check Alive" threshold is also PRESERVED: it is anchored
|
||||
# on every check by ``resolve_date_to_check`` / ``resolve_guard_threshold``
|
||||
# (negative counts back from the delivery, positive forward from the
|
||||
# inheritance creation date), so it never needs freezing to an absolute
|
||||
# date here.
|
||||
|
||||
def on_accept(self):
|
||||
try:
|
||||
@@ -1223,14 +1214,14 @@ class BalBuildWillDialog(BalDialog):
|
||||
for i in range(secs, 0, -1):
|
||||
if self._stopping:
|
||||
return
|
||||
wait_row = self.msg_edit_row(_(f"Please wait {i}secs"), wait_row)
|
||||
wait_row = self.msg_edit_row(_("Please wait {}secs").format(i), wait_row)
|
||||
time.sleep(1)
|
||||
self.msg_del_row(wait_row)
|
||||
|
||||
def loop_broadcast_invalidating(self, tx):
|
||||
if self._stopping:
|
||||
return
|
||||
self.msg_set_invalidating("Broadcasting")
|
||||
self.msg_set_invalidating(_("Broadcasting"))
|
||||
try:
|
||||
tx.add_info_from_wallet(self.bal_window.wallet)
|
||||
self.network.run_from_another_thread(tx.add_info_from_network(self.network))
|
||||
@@ -1341,7 +1332,7 @@ class BalBuildWillDialog(BalDialog):
|
||||
done["count"] += 1
|
||||
# Show the per-server result (Ok/Ko) in bold + color so the
|
||||
# outcome stands out, keeping the server URL in normal weight.
|
||||
result = self.msg_ok("Ok") if ok else self.msg_error("Ko")
|
||||
result = self.msg_ok(_("Ok")) if ok else self.msg_error(_("Ko"))
|
||||
self.msg_edit_row("{} : {}".format(url, result))
|
||||
self.msg_set_pushing(_status_line())
|
||||
|
||||
@@ -1393,8 +1384,11 @@ class BalBuildWillDialog(BalDialog):
|
||||
if self._stopping:
|
||||
return
|
||||
row = self.msg_edit_row(
|
||||
"checking {} - {} : <b>{}</b>".format(
|
||||
self.bal_window.willitems[wid].we["url"], wid, "Waiting"
|
||||
"{} : <b>{}</b>".format(
|
||||
_("checking {} - {}").format(
|
||||
self.bal_window.willitems[wid].we["url"], wid
|
||||
),
|
||||
_("Waiting"),
|
||||
)
|
||||
)
|
||||
w = self.bal_window.willitems[wid]
|
||||
@@ -1407,9 +1401,10 @@ class BalBuildWillDialog(BalDialog):
|
||||
checked = self.bal_window.willitems[wid].get_status("CHECKED")
|
||||
result = self.msg_ok(checked) if checked else self.msg_error(checked)
|
||||
row = self.msg_edit_row(
|
||||
"checked {} - {} : {}".format(
|
||||
self.bal_window.willitems[wid].we["url"],
|
||||
wid,
|
||||
"{} : {}".format(
|
||||
_("checked {} - {}").format(
|
||||
self.bal_window.willitems[wid].we["url"], wid
|
||||
),
|
||||
result,
|
||||
),
|
||||
row,
|
||||
@@ -1457,7 +1452,7 @@ class BalBuildWillDialog(BalDialog):
|
||||
raise Exception("not tx")
|
||||
except Exception as e:
|
||||
(f"exception:{e}")
|
||||
self.msg_set_invalidating(f"Error: {e}")
|
||||
self.msg_set_invalidating(_("Error: {}").format(e))
|
||||
raise Exception("Impossible to sign") from e
|
||||
|
||||
def on_success_invalidate(self, success):
|
||||
@@ -1542,6 +1537,18 @@ 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:
|
||||
@@ -1825,14 +1832,22 @@ class BalBuildWillDialog(BalDialog):
|
||||
|
||||
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
|
||||
if basic_mode:
|
||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
||||
# Factory defaults, in the GUI language (see BalConfig).
|
||||
raw_description = (
|
||||
self.bal_window.bal_plugin.EVENT_DESCRIPTION.localized_default()
|
||||
)
|
||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.localized_default()
|
||||
threshold = None
|
||||
num_reminders = 3
|
||||
else:
|
||||
threshold_ts = BalTimestamp(
|
||||
self.bal_window.will_settings["threshold"]
|
||||
).to_timestamp()
|
||||
# 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 = datetime.fromtimestamp(threshold_ts)
|
||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
|
||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
|
||||
@@ -1863,6 +1878,7 @@ class BalBuildWillDialog(BalDialog):
|
||||
version=self.bal_window.bal_plugin.version,
|
||||
num_reminders=num_reminders,
|
||||
threshold=threshold,
|
||||
reminder_suffix=_("(reminder {idx}/{total})"),
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(f"failed to generate .ics: {e}")
|
||||
@@ -2006,7 +2022,7 @@ class BalBuildWillDialog(BalDialog):
|
||||
def on_error_phase1(self, error):
|
||||
self.bal_window.update_all()
|
||||
a, b, c = error
|
||||
self.msg_edit_row(self.msg_error(f"Error: {b}"))
|
||||
self.msg_edit_row(self.msg_error(_("Error: {}").format(b)))
|
||||
import traceback
|
||||
_logger.error(f"error phase1: {b}\n{''.join(traceback.format_exception(a, b, c))}")
|
||||
button=QPushButton(_("Close"))
|
||||
@@ -2017,7 +2033,7 @@ class BalBuildWillDialog(BalDialog):
|
||||
def on_error_phase2(self, error):
|
||||
self.bal_window.upade_all()
|
||||
a, b, c = error
|
||||
self.msg_edit_row(self.msg_error(f"Error: {b}"))
|
||||
self.msg_edit_row(self.msg_error(_("Error: {}").format(b)))
|
||||
_logger.error(f"error phase2: {b}")
|
||||
|
||||
def _executed_inheritance_status(self):
|
||||
@@ -2203,9 +2219,9 @@ class BalBuildWillDialog(BalDialog):
|
||||
}
|
||||
|
||||
if reason in messages:
|
||||
return messages[reason] + "\n\n" + _("Skipped")
|
||||
return "{}\n\n{}".format(messages[reason], _("Skipped"))
|
||||
|
||||
return (
|
||||
return "{}\n\n{}".format(
|
||||
_(
|
||||
"Could not build the will, and the exact cause could not be "
|
||||
"determined. Please check that:\n"
|
||||
@@ -2213,12 +2229,14 @@ class BalBuildWillDialog(BalDialog):
|
||||
"- each heir's share is above the minimum (dust limit),\n"
|
||||
"- the Check Alive date is EARLIER than the delivery date,\n"
|
||||
"- at least one will-executor is selected and reachable."
|
||||
)
|
||||
+ "\n\n"
|
||||
+ _("Skipped")
|
||||
),
|
||||
_("Skipped"),
|
||||
)
|
||||
|
||||
def msg_set_checking(self, status="Waiting", row=None):
|
||||
def msg_set_checking(self, status=None, row=None):
|
||||
# The default is resolved here, not in the signature: a default
|
||||
# argument is evaluated at import time, before the catalog is loaded.
|
||||
status = _("Waiting") if status is None else status
|
||||
row = self.check_row if row is None else row
|
||||
self.check_row = self.msg_set_status(_("Checking your will"), row, status)
|
||||
|
||||
@@ -2231,30 +2249,36 @@ class BalBuildWillDialog(BalDialog):
|
||||
def msg_set_building(self, status=None, row=None,color=None):
|
||||
row = self.build_row if row is None else row
|
||||
self.build_row = self.msg_set_status(
|
||||
"Building your will", self.build_row, status, color
|
||||
_("Building your will"), self.build_row, status, color
|
||||
)
|
||||
|
||||
def msg_set_signing(self, status=None, row=None):
|
||||
row = self.sign_row if row is None else row
|
||||
self.sign_row = self.msg_set_status("Signing your will", self.sign_row, status)
|
||||
self.sign_row = self.msg_set_status(
|
||||
_("Signing your will"), self.sign_row, status
|
||||
)
|
||||
|
||||
def msg_set_pushing(self, status=None, row=None):
|
||||
row = self.push_row if row is None else row
|
||||
self.push_row = self.msg_set_status(
|
||||
"Broadcasting your will to executors", self.push_row, status
|
||||
_("Broadcasting your will to executors"), self.push_row, status
|
||||
)
|
||||
|
||||
def msg_set_waiting(self, status=None, row=None):
|
||||
row = self.wait_row if row is None else row
|
||||
self.wait_row = self.msg_edit_row(f"Please wait {status}secs", self.wait_row)
|
||||
self.wait_row = self.msg_edit_row(
|
||||
_("Please wait {}secs").format(status), self.wait_row
|
||||
)
|
||||
|
||||
def msg_error(self, e):
|
||||
# Results are shown in bold so the outcome stands out from the
|
||||
# left-side state label (which stays in normal weight).
|
||||
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_ERROR, e)
|
||||
|
||||
def msg_ok(self, e="Ok"):
|
||||
# Results are shown in bold (see msg_error).
|
||||
def msg_ok(self, e=None):
|
||||
# Results are shown in bold (see msg_error). The default "Ok" is
|
||||
# translated here, not in the signature (evaluated at import time).
|
||||
e = _("Ok") if e is None else e
|
||||
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_OK, e)
|
||||
|
||||
def msg_warning(self, e):
|
||||
@@ -2283,12 +2307,14 @@ class BalBuildWillDialog(BalDialog):
|
||||
# glance. ``status`` may already contain rich-text emitted by
|
||||
# msg_ok/msg_error/msg_warning (which add their own <b>...</b>); wrapping
|
||||
# it again in <b> is harmless for those cases.
|
||||
status = "Wait" if status is None else status
|
||||
# ``msg`` must already be translated by the caller: a variable
|
||||
# passed to _() cannot be extracted into the catalog.
|
||||
status = _("Wait") if status is None else status
|
||||
if color is None:
|
||||
line = "{}:\t<b>{}</b>".format(_(msg), status)
|
||||
line = "{}:\t<b>{}</b>".format(msg, status)
|
||||
else:
|
||||
line = "{}:\t<font color={}><b>{}</b></font>".format(
|
||||
_(msg), color, status
|
||||
msg, color, status
|
||||
)
|
||||
return self.msg_edit_row(line, row)
|
||||
|
||||
@@ -2416,15 +2442,15 @@ class WillDetailDialog(BalDialog):
|
||||
|
||||
self.paint_scroll_area()
|
||||
self.vlayout.addWidget(
|
||||
QLabel(_("Expiration date: ") + str(BalTimestamp(self.threshold)))
|
||||
QLabel(_("Expiration date: {}").format(BalTimestamp(self.threshold)))
|
||||
)
|
||||
self.vlayout.addWidget(self.scrollbox)
|
||||
w = QWidget()
|
||||
hlayout = QHBoxLayout(w)
|
||||
hlayout.addWidget(
|
||||
QLabel(_("Valid Txs:") + str(len(Will.only_valid_list(self.will))))
|
||||
QLabel(_("Valid Txs:{}").format(len(Will.only_valid_list(self.will))))
|
||||
)
|
||||
hlayout.addWidget(QLabel(_("Total Txs:") + str(len(self.will))))
|
||||
hlayout.addWidget(QLabel(_("Total Txs:{}").format(len(self.will))))
|
||||
self.vlayout.addWidget(w)
|
||||
self.setLayout(self.vlayout)
|
||||
|
||||
@@ -2477,18 +2503,19 @@ class WillDetailDialog(BalDialog):
|
||||
|
||||
def toggle_replaced(self):
|
||||
self.bal_window.bal_plugin.hide_replaced()
|
||||
toggle = _("Hide")
|
||||
# Whole sentences, so a translation can change the word order.
|
||||
text = _("Hide replaced")
|
||||
if self.bal_window.bal_plugin._hide_replaced:
|
||||
toggle = _("Unhide")
|
||||
self.toggle_replace_button.setText(f"{toggle} {_('replaced')}")
|
||||
text = _("Unhide replaced")
|
||||
self.toggle_replace_button.setText(text)
|
||||
self.update()
|
||||
|
||||
def toggle_invalidated(self):
|
||||
self.bal_window.bal_plugin.hide_invalidated()
|
||||
toggle = _("Hide")
|
||||
text = _("Hide invalidated")
|
||||
if self.bal_window.bal_plugin._hide_invalidated:
|
||||
toggle = _("Unhide")
|
||||
self.toggle_invalidate_button.setText(_(f"{toggle} {_('invalidated')}"))
|
||||
text = _("Unhide invalidated")
|
||||
self.toggle_invalidate_button.setText(text)
|
||||
self.update()
|
||||
|
||||
def update(self):
|
||||
@@ -2708,10 +2735,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):
|
||||
def __init__(self, chunk_size=CHUNK_PRESETS[0][1], parent=None, on_size_change=None):
|
||||
QWidget.__init__(self, parent)
|
||||
self.chunk_size = chunk_size
|
||||
self.format = "balqr"
|
||||
self._on_size_change = on_size_change
|
||||
self.tx_strings = []
|
||||
self.transfer = ""
|
||||
self.frames = []
|
||||
@@ -2768,7 +2795,7 @@ class BalQrExportWidget(QWidget):
|
||||
size_row = QHBoxLayout()
|
||||
size_row.addWidget(QLabel(_("QR code size:")))
|
||||
self.size_combo = QComboBox()
|
||||
self.size_combo.addItems([label for label, _budget in CHUNK_PRESETS])
|
||||
self.size_combo.addItems([_(label) for label, _budget in CHUNK_PRESETS])
|
||||
self.size_combo.setCurrentIndex(
|
||||
preset_index_for_chunk_size(self.chunk_size)
|
||||
)
|
||||
@@ -2777,35 +2804,11 @@ 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) + ((" (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()
|
||||
@@ -2815,30 +2818,18 @@ class BalQrExportWidget(QWidget):
|
||||
return len(self.frames)
|
||||
|
||||
def _refresh_frames(self):
|
||||
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.frames = bbqr_frames(
|
||||
self.transfer.encode("utf-8"), self.chunk_size, encoding="Z"
|
||||
)
|
||||
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."""
|
||||
@@ -2870,32 +2861,13 @@ class BalQrExportWidget(QWidget):
|
||||
self._next()
|
||||
|
||||
def _update_intro(self):
|
||||
self.format_hint.setText(
|
||||
{
|
||||
"balqr": _(
|
||||
"Proprietary format; every other BAL wallet understands it."
|
||||
),
|
||||
"ur1": _("Legacy BC-UR v1 (ur:bytes), compatible with older "
|
||||
"Blockchain Commons tools."),
|
||||
"ur2": _("Standard BC-UR v2 fountain codes (ur:bytes)."),
|
||||
"bbqr": _("Coinkite BBQR (B$ frames) for BitKit & friends."),
|
||||
}[self.format]
|
||||
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)
|
||||
)
|
||||
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:
|
||||
@@ -2937,6 +2909,7 @@ 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
|
||||
@@ -2990,7 +2963,10 @@ 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)
|
||||
self.qr_page = BalQrExportWidget(
|
||||
chunk_size=chunk,
|
||||
on_size_change=self._remember_qr_size,
|
||||
)
|
||||
self.stacked.addWidget(self.qr_page)
|
||||
self.audio_page = self._build_audio_page()
|
||||
self.stacked.addWidget(self.audio_page)
|
||||
@@ -3000,12 +2976,19 @@ 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)
|
||||
@@ -3040,6 +3023,7 @@ 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:
|
||||
@@ -3068,6 +3052,14 @@ 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.
|
||||
|
||||
@@ -3239,6 +3231,10 @@ 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)
|
||||
|
||||
@@ -3266,6 +3262,7 @@ 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:
|
||||
@@ -3506,9 +3503,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. ``"balqr:3"`` or
|
||||
``"ur2:2-31-3804692811"``) contradicts the transfer already being built
|
||||
is a ``reset`` (a different transfer was presented).
|
||||
* 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).
|
||||
|
||||
``state`` is a mutable mapping with keys ``last_index``, ``last_payload``,
|
||||
``pending_index``, ``pending_payload``, ``pending_count`` and ``key``.
|
||||
@@ -3694,8 +3691,8 @@ class BalQrImportWidget(QWidget):
|
||||
self._key = key
|
||||
self._init_slots()
|
||||
if status == "ok":
|
||||
# Slot grid is always 1-based even for 0-based wire formats.
|
||||
slot = index + 1 if fmt == "bbqr" else index
|
||||
# Slot grid is always 1-based even for the 0-based BBQR indexes.
|
||||
slot = index + 1
|
||||
self.frames[slot] = frame_text
|
||||
self._update_slots()
|
||||
self._update_status()
|
||||
@@ -3951,7 +3948,7 @@ class BalQrImportWidget(QWidget):
|
||||
try:
|
||||
transfer, compressed = session.resolve()
|
||||
parts = decode_transfer(transfer, compressed)
|
||||
except (MissingFramesError, QrTransferError, AnimatedQrError) as e:
|
||||
except AnimatedQrError as e:
|
||||
self.show_error(str(e))
|
||||
self._reset_all()
|
||||
return
|
||||
|
||||
@@ -20,6 +20,7 @@ from PyQt6.QtWidgets import QLineEdit as _QLineEdit
|
||||
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
|
||||
|
||||
from .common import (
|
||||
N_,
|
||||
OP_RETURN_PREFIX,
|
||||
BalTimestamp,
|
||||
Buttons,
|
||||
@@ -59,6 +60,7 @@ from .common import (
|
||||
datetime,
|
||||
enum,
|
||||
export_meta_gui,
|
||||
format_status_history,
|
||||
getOpenFileName,
|
||||
import_meta_gui,
|
||||
is_op_return_address,
|
||||
@@ -69,6 +71,7 @@ from .common import (
|
||||
server_status_tooltip,
|
||||
signature_suffix,
|
||||
status_color,
|
||||
translated_headers,
|
||||
tx_from_any,
|
||||
write_json_file,
|
||||
)
|
||||
@@ -90,13 +93,27 @@ def _can_broadcast(will_item):
|
||||
|
||||
|
||||
def _can_delete(will_item):
|
||||
"""True if a will transaction may be deleted (invalid or unsigned).
|
||||
"""True if a will transaction may be deleted.
|
||||
|
||||
Valid AND fully-signed transactions are never deletable: removing them
|
||||
would silently drop a committed inheritance.
|
||||
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.
|
||||
"""
|
||||
return bool(will_item) and (
|
||||
not will_item.get_status("VALID") or not will_item.get_status("COMPLETE")
|
||||
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")
|
||||
)
|
||||
|
||||
|
||||
@@ -128,9 +145,9 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
||||
AMOUNT = enum.auto()
|
||||
|
||||
headers = {
|
||||
Columns.NAME: _("Name"),
|
||||
Columns.ADDRESS: _("Address"),
|
||||
Columns.AMOUNT: _("Amount"),
|
||||
Columns.NAME: N_("Name"),
|
||||
Columns.ADDRESS: N_("Address"),
|
||||
Columns.AMOUNT: N_("Amount"),
|
||||
}
|
||||
filter_columns = [Columns.NAME, Columns.ADDRESS]
|
||||
|
||||
@@ -245,7 +262,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
||||
col=self.Columns.NAME, role=self.ROLE_HEIR_KEY
|
||||
)
|
||||
self.model().clear()
|
||||
self.update_headers(self.__class__.headers)
|
||||
self.update_headers(translated_headers(self.__class__.headers))
|
||||
set_current = None
|
||||
for key in sorted(self.bal_window.heirs.keys()):
|
||||
heir = self.bal_window.heirs[key]
|
||||
@@ -337,11 +354,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
SERVER = enum.auto()
|
||||
|
||||
headers = {
|
||||
Columns.LOCKTIME: _("Locktime"),
|
||||
Columns.TXID: _("Txid"),
|
||||
Columns.WILLEXECUTOR: _("Will-Executor"),
|
||||
Columns.STATUS: _("Status"),
|
||||
Columns.SERVER: _("Server"),
|
||||
Columns.LOCKTIME: N_("Locktime"),
|
||||
Columns.TXID: N_("Txid"),
|
||||
Columns.WILLEXECUTOR: N_("Will-Executor"),
|
||||
Columns.STATUS: N_("Status"),
|
||||
Columns.SERVER: N_("Server"),
|
||||
}
|
||||
|
||||
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000
|
||||
@@ -586,7 +603,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
if bal_tx.we:
|
||||
we = bal_tx.we["url"]
|
||||
labels[self.Columns.WILLEXECUTOR] = we
|
||||
status = bal_tx.status + signature_suffix(bal_tx)
|
||||
status = format_status_history(bal_tx.status) + signature_suffix(bal_tx)
|
||||
if len(status) > 53:
|
||||
status = "...{}".format(status[-50:])
|
||||
labels[self.Columns.STATUS] = status
|
||||
@@ -644,7 +661,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
col=self.Columns.TXID, role=self.ROLE_HEIR_KEY
|
||||
)
|
||||
self.model().clear()
|
||||
self.update_headers(self.__class__.headers)
|
||||
self.update_headers(translated_headers(self.__class__.headers))
|
||||
|
||||
set_current = None
|
||||
for txid, bal_tx in self.will.items():
|
||||
@@ -676,7 +693,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
# The Wizard is the main entry point to create an inheritance, so make
|
||||
# it stand out: show a bold label next to a slightly larger icon (the
|
||||
# plain icon-only button was too easy to overlook).
|
||||
wizard = QPushButton(" " + _("Build Your Will"))
|
||||
wizard = QPushButton(" {}".format(_("Build Your Will")))
|
||||
wizard.setIcon(
|
||||
read_QIcon_from_bytes(
|
||||
self.bal_window.bal_plugin.read_file("icons/wizard.png")
|
||||
@@ -904,12 +921,12 @@ class WillExecutorListWidget(MyTreeView):
|
||||
ADDRESS = enum.auto()
|
||||
|
||||
headers = {
|
||||
Columns.SELECTED: _(""),
|
||||
Columns.URL: _("Url"),
|
||||
Columns.STATUS: _("S"),
|
||||
Columns.BASE_FEE: _("Base fee"),
|
||||
Columns.INFO: _("Info"),
|
||||
Columns.ADDRESS: _("Default Address"),
|
||||
Columns.SELECTED: "",
|
||||
Columns.URL: N_("Url"),
|
||||
Columns.STATUS: N_("S"),
|
||||
Columns.BASE_FEE: N_("Base fee"),
|
||||
Columns.INFO: N_("Info"),
|
||||
Columns.ADDRESS: N_("Default Address"),
|
||||
}
|
||||
|
||||
filter_columns = [Columns.URL]
|
||||
@@ -1091,7 +1108,7 @@ class WillExecutorListWidget(MyTreeView):
|
||||
col=self.Columns.URL, role=self.ROLE_HEIR_KEY
|
||||
)
|
||||
self.model().clear()
|
||||
self.update_headers(self.__class__.headers)
|
||||
self.update_headers(translated_headers(self.__class__.headers))
|
||||
|
||||
set_current = None
|
||||
|
||||
@@ -1236,13 +1253,14 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
||||
buttonbox.addWidget(b)
|
||||
|
||||
def _menu_button(label):
|
||||
# ``label`` must already be translated by the caller.
|
||||
btn = QToolButton()
|
||||
btn.setText(_(label))
|
||||
btn.setText(label)
|
||||
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||
buttonbox.addWidget(btn)
|
||||
return btn
|
||||
|
||||
export_btn = _menu_button("Export")
|
||||
export_btn = _menu_button(_("Export"))
|
||||
export_menu = QMenu(export_btn)
|
||||
export_menu.addAction(_("Export all"), lambda: self.export_file())
|
||||
export_menu.addAction(
|
||||
@@ -1253,7 +1271,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
||||
)
|
||||
export_btn.setMenu(export_menu)
|
||||
|
||||
ping_btn = _menu_button("Ping All")
|
||||
ping_btn = _menu_button(_("Ping All"))
|
||||
ping_menu = QMenu(ping_btn)
|
||||
ping_menu.addAction(_("Ping all"), lambda: self.update_willexecutors())
|
||||
ping_menu.addAction(
|
||||
@@ -1261,7 +1279,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
||||
)
|
||||
ping_btn.setMenu(ping_menu)
|
||||
|
||||
select_btn = _menu_button("Select All")
|
||||
select_btn = _menu_button(_("Select All"))
|
||||
select_menu = QMenu(select_btn)
|
||||
select_menu.addAction(_("Select all"), lambda: self.set_select_all(True))
|
||||
select_menu.addAction(
|
||||
@@ -1295,7 +1313,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
||||
|
||||
url_edit = QLineEdit()
|
||||
url_edit.setFixedWidth(32 * char_width_in_lineedit())
|
||||
info_edit = QLineEdit("New Will Executor")
|
||||
info_edit = QLineEdit(_("New Will Executor"))
|
||||
info_edit.setFixedWidth(32 * char_width_in_lineedit())
|
||||
base_fee_spin = QSpinBox()
|
||||
base_fee_spin.setRange(0, 1000000)
|
||||
@@ -1309,7 +1327,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
||||
|
||||
if executor:
|
||||
url_edit.setText(edit_key)
|
||||
info_edit.setText(str(executor.get("info", "New Will Executor")))
|
||||
info_edit.setText(str(executor.get("info", _("New Will Executor"))))
|
||||
base_fee_spin.setValue(int(executor.get("base_fee", 0)))
|
||||
address_edit.setText(str(executor.get("address", "")))
|
||||
|
||||
@@ -1469,7 +1487,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
||||
break
|
||||
self._add_another = False
|
||||
url_edit.clear()
|
||||
info_edit.setText("New Will Executor")
|
||||
info_edit.setText(_("New Will Executor"))
|
||||
base_fee_spin.setValue(0)
|
||||
address_edit.clear()
|
||||
|
||||
|
||||
@@ -19,7 +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,
|
||||
Buttons,
|
||||
@@ -67,6 +67,11 @@ class Plugin(BalPlugin, EventListener):
|
||||
def __init__(self, parent, config, name):
|
||||
_logger.info("INIT BALPLUGIN")
|
||||
BalPlugin.__init__(self, parent, config, name)
|
||||
# Load BAL's catalog for the language Electrum's GUI is using, before
|
||||
# any window or dialog builds its texts. It must come after
|
||||
# BalPlugin.__init__: read_file() needs the plugin's parent and name.
|
||||
# The CLI plugin does not call this, so the CLI stays in English.
|
||||
init_from_config(self, config)
|
||||
self.bal_windows = {}
|
||||
# Status-bar buttons, keyed by id(sb.window()). Tracking them lets us
|
||||
# remove a stale button before creating a fresh one when a wallet is
|
||||
@@ -298,7 +303,7 @@ class Plugin(BalPlugin, EventListener):
|
||||
pass
|
||||
b = StatusBarButton(
|
||||
read_QIcon_from_bytes(self.read_file("icons/bal32x32.png")),
|
||||
"Bal " + _("Bitcoin After Life"),
|
||||
"Bal {}".format(_("Bitcoin After Life")),
|
||||
lambda: self.settings_dialog(sb.window()),
|
||||
sb.height(),
|
||||
)
|
||||
@@ -442,7 +447,7 @@ class Plugin(BalPlugin, EventListener):
|
||||
|
||||
def settings_dialog(self, window=None, wallet=None):
|
||||
|
||||
d = BalDialog(window, self, self.get_window_title("Settings"))
|
||||
d = BalDialog(window, self, self.get_window_title(_("Settings")))
|
||||
d.setMinimumSize(100, 200)
|
||||
qicon = read_QPixmap_from_bytes(self.read_file("icons/bal16x16.png"))
|
||||
lbl_logo = QLabel()
|
||||
@@ -532,21 +537,6 @@ 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()
|
||||
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)
|
||||
@@ -656,21 +646,17 @@ class Plugin(BalPlugin, EventListener):
|
||||
elif kind == "spin":
|
||||
widget.setValue(int(cfg.default))
|
||||
elif kind == "line":
|
||||
widget.setText(cfg.default)
|
||||
widget.setText(cfg.localized_default())
|
||||
elif kind == "text":
|
||||
widget.setPlainText(cfg.default)
|
||||
widget.setPlainText(cfg.localized_default())
|
||||
elif kind == "user_type":
|
||||
widget.setCurrentIndex(
|
||||
1 if str(cfg.default).lower() == "advanced" else 0
|
||||
)
|
||||
elif kind == "qr_size":
|
||||
widget.setCurrentIndex(
|
||||
preset_index_for_chunk_size(int(cfg.default))
|
||||
)
|
||||
btn.clicked.connect(reset)
|
||||
return btn
|
||||
|
||||
heir_repush = QPushButton("Rebroadcast transactions")
|
||||
heir_repush = QPushButton(_("Rebroadcast transactions"))
|
||||
heir_repush.clicked.connect(partial(self.broadcast_transactions, True))
|
||||
bal_mode = QComboBox()
|
||||
options = ["Easy", "Advanced", "Experimental"]
|
||||
@@ -696,27 +682,27 @@ class Plugin(BalPlugin, EventListener):
|
||||
# advanced-only settings, so the user picks basic/advanced first.
|
||||
add_widget(
|
||||
grid,
|
||||
"Hide Replaced",
|
||||
_("Hide Replaced"),
|
||||
heir_hide_replaced,
|
||||
0,
|
||||
"Hide replaced transactions from will detail and list",
|
||||
_("Hide replaced transactions from will detail and list"),
|
||||
)
|
||||
grid.addWidget(_make_reset_btn(self.HIDE_REPLACED, heir_hide_replaced, "check"), 0, 3)
|
||||
add_widget(
|
||||
grid,
|
||||
"Hide Invalidated",
|
||||
_("Hide Invalidated"),
|
||||
heir_hide_invalidated,
|
||||
1,
|
||||
"Hide invalidated transactions from will detail and list",
|
||||
_("Hide invalidated transactions from will detail and list"),
|
||||
)
|
||||
grid.addWidget(_make_reset_btn(self.HIDE_INVALIDATED, heir_hide_invalidated, "check"), 1, 3)
|
||||
lbl_auto_sign = QLabel(_("Auto-sign on Check"))
|
||||
help_auto_sign = HelpButton(
|
||||
help_auto_sign = HelpButton(_(
|
||||
"When checking, automatically sign and broadcast the will "
|
||||
"transactions to their will-executors.\n"
|
||||
"The wallet password is requested only if the wallet is "
|
||||
"encrypted."
|
||||
)
|
||||
))
|
||||
grid.addWidget(_hide_if_basic(lbl_auto_sign), 2, 0)
|
||||
grid.addWidget(_hide_if_basic(heir_auto_sign), 2, 1)
|
||||
grid.addWidget(_hide_if_basic(help_auto_sign), 2, 2)
|
||||
@@ -726,10 +712,10 @@ class Plugin(BalPlugin, EventListener):
|
||||
grid.addWidget(_hide_if_basic(reset_btn_auto_sign), 2, 3)
|
||||
add_widget(
|
||||
grid,
|
||||
"Panel editable Date and Fee",
|
||||
_("Panel editable Date and Fee"),
|
||||
heir_editable_dates,
|
||||
3,
|
||||
(
|
||||
_(
|
||||
"When enabled, the delivery-time and check-alive date fields "
|
||||
"can be edited everywhere (toolbar / Heirs tab), not only in "
|
||||
"the will-building wizard.\n"
|
||||
@@ -741,10 +727,10 @@ class Plugin(BalPlugin, EventListener):
|
||||
# will-executor. Visible to all users (BASIC and ADVANCED).
|
||||
add_widget(
|
||||
grid,
|
||||
"Max Will-Executor Fee (satoshi)",
|
||||
_("Max Will-Executor Fee (satoshi)"),
|
||||
heir_max_willexecutor_fee,
|
||||
4,
|
||||
(
|
||||
_(
|
||||
"Maximum fee (in satoshi) allowed to be paid to a single "
|
||||
"will-executor. If a will-executor charges more than this, "
|
||||
"the will will not be built.\n"
|
||||
@@ -756,10 +742,10 @@ class Plugin(BalPlugin, EventListener):
|
||||
# user chooses basic/advanced first, then sees the relevant options.
|
||||
add_widget(
|
||||
grid,
|
||||
"User Type",
|
||||
_("User Type"),
|
||||
user_type_combo,
|
||||
5,
|
||||
(
|
||||
_(
|
||||
"Choose how much detail the plugin shows.\n\n"
|
||||
"BASIC: simplified interface, safe configuration for most "
|
||||
"users.\n\n"
|
||||
@@ -774,10 +760,10 @@ class Plugin(BalPlugin, EventListener):
|
||||
# only in ADVANCED mode. In BASIC mode the factory defaults are always
|
||||
# used and these settings are hidden.
|
||||
lbl_num_reminders = QLabel(_("Number of reminders"))
|
||||
help_num_reminders = HelpButton(
|
||||
help_num_reminders = HelpButton(_(
|
||||
"How many reminder alarms the exported calendar (.ics) event "
|
||||
"contains. Range: 1 to 5 (default 3). Only used in ADVANCED mode."
|
||||
)
|
||||
))
|
||||
grid.addWidget(_hide_if_basic(lbl_num_reminders), 6, 0)
|
||||
grid.addWidget(_hide_if_basic(heir_num_reminders), 6, 1)
|
||||
grid.addWidget(_hide_if_basic(help_num_reminders), 6, 2)
|
||||
@@ -785,13 +771,13 @@ class Plugin(BalPlugin, EventListener):
|
||||
grid.addWidget(_hide_if_basic(reset_btn_6), 6, 3)
|
||||
|
||||
lbl_event_summary = QLabel(_("Event summary"))
|
||||
help_event_summary = HelpButton(
|
||||
help_event_summary = HelpButton(_(
|
||||
"Default message to be used in event summary\n"
|
||||
"Variables:\n"
|
||||
" $wallet_name: name of wallet\n"
|
||||
" $heirs_complete: list of heirs name,address,amount\n"
|
||||
"Only used in ADVANCED mode."
|
||||
)
|
||||
))
|
||||
grid.addWidget(_hide_if_basic(lbl_event_summary), 7, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_event_summary), 7, 1)
|
||||
grid.addWidget(_hide_if_basic(help_event_summary), 7, 2)
|
||||
@@ -799,13 +785,13 @@ class Plugin(BalPlugin, EventListener):
|
||||
grid.addWidget(_hide_if_basic(reset_btn_7), 7, 3)
|
||||
|
||||
lbl_event_description = QLabel(_("Event description"))
|
||||
help_event_description = HelpButton(
|
||||
help_event_description = HelpButton(_(
|
||||
"Default message to be used in event description\n"
|
||||
"Variables:\n"
|
||||
" $wallet_name: name of wallet\n"
|
||||
" $heirs_complete: list of heirs name,address,amount\n"
|
||||
"Only used in ADVANCED mode."
|
||||
)
|
||||
))
|
||||
grid.addWidget(_hide_if_basic(lbl_event_description), 8, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_event_description), 8, 1)
|
||||
grid.addWidget(_hide_if_basic(help_event_description), 8, 2)
|
||||
@@ -814,10 +800,10 @@ class Plugin(BalPlugin, EventListener):
|
||||
# Welist server URL: shown only in ADVANCED mode. In BASIC mode the
|
||||
# factory default is always used and the setting is hidden.
|
||||
lbl_welist_server = QLabel(_("Welist Server URL"))
|
||||
help_welist_server = HelpButton(
|
||||
help_welist_server = HelpButton(_(
|
||||
"URL of the server that provides the will-executor list. "
|
||||
"Only available in ADVANCED mode."
|
||||
)
|
||||
))
|
||||
grid.addWidget(_hide_if_basic(lbl_welist_server), 9, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_welist_server), 9, 1)
|
||||
grid.addWidget(_hide_if_basic(help_welist_server), 9, 2)
|
||||
@@ -825,11 +811,11 @@ class Plugin(BalPlugin, EventListener):
|
||||
grid.addWidget(_hide_if_basic(reset_btn_9), 9, 3)
|
||||
|
||||
lbl_calendar_app = QLabel(_("Calendar app command"))
|
||||
help_calendar_app = HelpButton(
|
||||
help_calendar_app = HelpButton(_(
|
||||
"Command used to open .ics calendar files.\n"
|
||||
"Leave empty to use the system default (xdg-open/open/start).\n"
|
||||
"Only used in ADVANCED mode."
|
||||
)
|
||||
))
|
||||
grid.addWidget(_hide_if_basic(lbl_calendar_app), 10, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_calendar_app), 10, 1)
|
||||
grid.addWidget(_hide_if_basic(help_calendar_app), 10, 2)
|
||||
@@ -840,13 +826,13 @@ class Plugin(BalPlugin, EventListener):
|
||||
# label field is disabled while the checkbox is off (see
|
||||
# on_save_history_change above).
|
||||
lbl_save_history = QLabel(_("Save inheritance transactions in history"))
|
||||
help_save_history = HelpButton(
|
||||
help_save_history = HelpButton(_(
|
||||
"After each check, save the valid will transactions into the "
|
||||
"wallet's local history (the History tab), each with a label.\n"
|
||||
"The label may contain the variable:\n"
|
||||
" {willexecutor}: replaced with the will-executor URL of the item\n"
|
||||
"Only used in ADVANCED mode."
|
||||
)
|
||||
))
|
||||
grid.addWidget(_hide_if_basic(lbl_save_history), 11, 0)
|
||||
grid.addWidget(_hide_if_basic(heir_save_history), 11, 1)
|
||||
grid.addWidget(_hide_if_basic(help_save_history), 11, 2)
|
||||
@@ -854,13 +840,13 @@ class Plugin(BalPlugin, EventListener):
|
||||
grid.addWidget(_hide_if_basic(reset_btn_11), 11, 3)
|
||||
|
||||
lbl_history_label = QLabel(_("History label"))
|
||||
help_history_label = HelpButton(
|
||||
help_history_label = HelpButton(_(
|
||||
"Label applied to the will transactions saved into the wallet's "
|
||||
"local history.\n"
|
||||
"Variables:\n"
|
||||
" {willexecutor}: replaced with the will-executor URL of the item\n"
|
||||
"Only used in ADVANCED mode."
|
||||
)
|
||||
))
|
||||
grid.addWidget(_hide_if_basic(lbl_history_label), 12, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_history_label), 12, 1)
|
||||
grid.addWidget(_hide_if_basic(help_history_label), 12, 2)
|
||||
@@ -877,7 +863,7 @@ class Plugin(BalPlugin, EventListener):
|
||||
grid.addWidget(heir_repush, 13, 0)
|
||||
grid.addWidget(
|
||||
HelpButton(
|
||||
"Broadcast all transactions to willexecutors including those already pushed"
|
||||
_("Broadcast all transactions to willexecutors including those already pushed")
|
||||
),
|
||||
13,
|
||||
2,
|
||||
@@ -887,12 +873,12 @@ class Plugin(BalPlugin, EventListener):
|
||||
# Placed below the rebroadcast button so the existing rows keep their
|
||||
# numbers.
|
||||
lbl_rebuild_on_close = QLabel(_("Rebuild will on wallet close"))
|
||||
help_rebuild_on_close = HelpButton(
|
||||
help_rebuild_on_close = HelpButton(_(
|
||||
"Run the 'Build your will' wizard every time the wallet is closed "
|
||||
"or Electrum is quit, so the will is rebuilt and re-validated.\n"
|
||||
"When disabled, the will is only rebuilt when you press Check or "
|
||||
"Prepare. The last built state is still saved to the wallet."
|
||||
)
|
||||
))
|
||||
grid.addWidget(lbl_rebuild_on_close, 14, 0)
|
||||
grid.addWidget(heir_rebuild_on_close, 14, 1)
|
||||
grid.addWidget(help_rebuild_on_close, 14, 2)
|
||||
@@ -905,7 +891,7 @@ class Plugin(BalPlugin, EventListener):
|
||||
# BASIC + ADVANCED), right below the "Rebuild will on wallet close"
|
||||
# row.
|
||||
lbl_auto_rebuild = QLabel(_("Rebuild automatically on new transactions"))
|
||||
help_auto_rebuild = HelpButton(
|
||||
help_auto_rebuild = HelpButton(_(
|
||||
"When a new transaction arrives for the wallet, automatically "
|
||||
"rebuild the will the same way the wizard does at wallet close: "
|
||||
"the delivery date is anticipated by one day so the new will "
|
||||
@@ -916,7 +902,7 @@ class Plugin(BalPlugin, EventListener):
|
||||
"threshold, or when the threshold is already in the past.\n"
|
||||
"When disabled (default), the will is only rebuilt on Check / "
|
||||
"Prepare / wallet close."
|
||||
)
|
||||
))
|
||||
grid.addWidget(lbl_auto_rebuild, 15, 0)
|
||||
grid.addWidget(heir_auto_rebuild, 15, 1)
|
||||
grid.addWidget(help_auto_rebuild, 15, 2)
|
||||
@@ -925,25 +911,6 @@ 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 #
|
||||
@@ -977,7 +944,6 @@ 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.
|
||||
@@ -990,18 +956,14 @@ class Plugin(BalPlugin, EventListener):
|
||||
elif kind == "spin":
|
||||
widget.setValue(int(cfg.default))
|
||||
elif kind == "line":
|
||||
widget.setText(cfg.default)
|
||||
widget.setText(cfg.localized_default())
|
||||
elif kind == "text":
|
||||
widget.setPlainText(cfg.default)
|
||||
widget.setPlainText(cfg.localized_default())
|
||||
elif kind == "user_type":
|
||||
# Default is "basic" -> combo index 0; "advanced" -> index 1.
|
||||
widget.setCurrentIndex(
|
||||
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.
|
||||
@@ -1031,7 +993,7 @@ class Plugin(BalPlugin, EventListener):
|
||||
bottom_row = QHBoxLayout()
|
||||
bottom_row.addWidget(btn_reset)
|
||||
bottom_row.addStretch(1)
|
||||
bottom_row.addWidget(QLabel("<b>" + _("Support:") + "</b>"))
|
||||
bottom_row.addWidget(QLabel("<b>{}</b>".format(_("Support:"))))
|
||||
bottom_row.addWidget(lbl_support)
|
||||
|
||||
# Outer layout: warning (top) -> settings grid -> bottom button row.
|
||||
@@ -1083,6 +1045,11 @@ class Plugin(BalPlugin, EventListener):
|
||||
)
|
||||
|
||||
def get_window_title(self, title):
|
||||
return _("BAL - ") + _(title)
|
||||
"""Return the window title "BAL - <title>".
|
||||
|
||||
``title`` must already be translated by the caller: a variable passed
|
||||
to _() cannot be extracted into the catalog.
|
||||
"""
|
||||
return _("BAL - {}").format(title)
|
||||
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ def server_status_text(will_item) -> str:
|
||||
the user always knows whether each inheritance transaction is actually
|
||||
stored on the will-executor servers, regardless of the row colour.
|
||||
"""
|
||||
from electrum.i18n import _
|
||||
from ...i18n import _
|
||||
|
||||
if will_item.get_status("CHECK_FAIL") and not will_item.get_status("CHECKED"):
|
||||
return _("Not on server")
|
||||
@@ -105,7 +105,7 @@ def server_status_text(will_item) -> str:
|
||||
def server_status_tooltip(will_item) -> str:
|
||||
"""Return a detailed tooltip for the "Server" column, including the
|
||||
will-executor URL (if any) and the current server state."""
|
||||
from electrum.i18n import _
|
||||
from ...i18n import _
|
||||
|
||||
url = None
|
||||
we = getattr(will_item, "we", None)
|
||||
|
||||
@@ -20,6 +20,7 @@ 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,
|
||||
@@ -31,6 +32,7 @@ from ...core.reminders import build_ics_reminders, write_temp_ics
|
||||
from .calendar import BalCalendar, BalCalendarButton
|
||||
from .common import (
|
||||
DECIMAL_POINT,
|
||||
N_,
|
||||
NLOCKTIME_BLOCKHEIGHT_MAX,
|
||||
NLOCKTIME_MAX,
|
||||
Any,
|
||||
@@ -67,6 +69,7 @@ from .common import (
|
||||
_logger,
|
||||
char_width_in_lineedit,
|
||||
datetime,
|
||||
format_status_history,
|
||||
getSaveFileName,
|
||||
log_error,
|
||||
os,
|
||||
@@ -207,14 +210,18 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
||||
current_index = None
|
||||
default_value = None
|
||||
|
||||
help_text = (
|
||||
help_text = N_(
|
||||
"if you choose Raw, you can insert various options based on suffix:\n"
|
||||
+ " - d: number of days after current day(ex: 1d means tomorrow)\n"
|
||||
+ " - y: number of years after currrent day(ex: 1y means one year from today)\n"
|
||||
" - d: number of days after current day(ex: 1d means tomorrow)\n"
|
||||
" - y: number of years after currrent day(ex: 1y means one year from today)\n"
|
||||
)
|
||||
label_text = None
|
||||
tooltip_text = None
|
||||
base_field = None
|
||||
# A leading "-" is accepted by the RAW editor only for the Check Alive
|
||||
# field (its sign selects the anchor date); the delivery field never
|
||||
# accepts a sign. Subclasses override this.
|
||||
allow_negative_raw = False
|
||||
|
||||
def __init__(self, bal_window, parent, default_locktime=None):
|
||||
super().__init__(parent)
|
||||
@@ -225,7 +232,9 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
||||
hbox.setContentsMargins(0, 0, 0, 0)
|
||||
hbox.setSpacing(0)
|
||||
self.setMinimumWidth(40 * char_width_in_lineedit())
|
||||
self.locktime_raw_e = TimeRawEditWidget(self, time_edit=self)
|
||||
self.locktime_raw_e = TimeRawEditWidget(
|
||||
self, time_edit=self, allow_negative=self.allow_negative_raw
|
||||
)
|
||||
self.locktime_date_e = LockTimeDateEdit(self, time_edit=self)
|
||||
self.editors = [self.locktime_raw_e, self.locktime_date_e]
|
||||
self.combo = QComboBox()
|
||||
@@ -276,7 +285,9 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
||||
]
|
||||
)
|
||||
#hbox.addWidget(QLabel(self.label_text))
|
||||
help_button=HelpButton(self.help_text)
|
||||
# help_text and tooltip_text are class attributes marked with N_():
|
||||
# translate them here, when shown.
|
||||
help_button=HelpButton(_(self.help_text))
|
||||
help_button.setText(self.label_text)
|
||||
# Show a short label (e.g. "Delivery time" / "Check Alive") when the
|
||||
# user hovers the icon, so the emoji button is self-explanatory.
|
||||
@@ -478,9 +489,9 @@ class TimeRawEditWidget(QWidget):
|
||||
def is_acceptable_locktime(self, value):
|
||||
return True
|
||||
|
||||
def __init__(self, parent, time_edit=None):
|
||||
def __init__(self, parent, time_edit=None, allow_negative=False):
|
||||
super().__init__(parent)
|
||||
self.editor = LockTimeRawEdit(parent, time_edit)
|
||||
self.editor = LockTimeRawEdit(parent, time_edit, allow_negative=allow_negative)
|
||||
self.label = QLabel("")
|
||||
# Group C / C3: this trailing label shows the ABSOLUTE date computed
|
||||
# from the RAW value (e.g. "30d" -> "2027-06-23"), so it needs room for a
|
||||
@@ -506,7 +517,7 @@ class TimeRawEditWidget(QWidget):
|
||||
|
||||
|
||||
class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
|
||||
def __init__(self, parent=None, time_edit=None):
|
||||
def __init__(self, parent=None, time_edit=None, allow_negative=False):
|
||||
QLineEdit.__init__(self, parent)
|
||||
# Group C / C3: narrow the RAW input to roughly a third of its former
|
||||
# width. The accepted values are short relative durations such as "30d"
|
||||
@@ -516,16 +527,22 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
|
||||
self.textChanged.connect(self.numbify)
|
||||
self.isdays = False
|
||||
self.isyears = False
|
||||
# The Check Alive field accepts a leading "-" (the sign selects the
|
||||
# anchor date); the delivery field does not.
|
||||
self.allow_negative = allow_negative
|
||||
self.time_edit = time_edit
|
||||
|
||||
def numbify(self):
|
||||
# Only digits plus the day ("d") and year ("y") suffixes are accepted.
|
||||
# The block-height suffix ("b") was removed (A1): locktimes are always
|
||||
# UNIX timestamps now, so block-relative input is no longer allowed.
|
||||
# Only digits plus the day ("d") and year ("y") suffixes are accepted
|
||||
# (and, for the Check Alive field, a single leading "-"). The
|
||||
# block-height suffix ("b") was removed (A1): locktimes are always UNIX
|
||||
# timestamps now, so block-relative input is no longer allowed.
|
||||
# The sanitisation itself lives in bal.core.input_rules.
|
||||
text = self.text().strip()
|
||||
pos = self.cursorPosition()
|
||||
s, isdays, isyears, pos = normalize_locktime_raw_text(text, pos)
|
||||
s, isdays, isyears, pos = normalize_locktime_raw_text(
|
||||
text, pos, allow_negative=self.allow_negative
|
||||
)
|
||||
self.isdays = isdays
|
||||
self.isyears = isyears
|
||||
self.blockSignals(True)
|
||||
@@ -625,7 +642,7 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
|
||||
|
||||
class ThresholdTimeWidget(BalTimeEditWidget):
|
||||
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
|
||||
help_text = (
|
||||
help_text = N_(
|
||||
"<b>CHECK ALIVE</b><br><br>"
|
||||
"In DATA mode:<br>"
|
||||
"set the date for the \u201ccheck alive\u201d parameter.<br>"
|
||||
@@ -639,13 +656,20 @@ class ThresholdTimeWidget(BalTimeEditWidget):
|
||||
"if you choose Raw, you can insert various options based on suffix:<br>"
|
||||
" - d: number of days after current day(ex: 1d means tomorrow)<br>"
|
||||
" - y: number of years after current day(ex: 1y means one year from today)<br>"
|
||||
"The sign selects the reference date:<br>"
|
||||
" - a NEGATIVE value (ex: -30d) counts backwards from the "
|
||||
"<b>delivery</b> date (30 days before the delivery);<br>"
|
||||
" - a POSITIVE value (ex: 30d) counts forward from the "
|
||||
"<b>inheritance creation</b> date (30 days after the will was created).<br>"
|
||||
"<br><b>Note:</b> the date/time is expressed in your computer's "
|
||||
"<b>Local time</b> (not UTC time).<br>"
|
||||
)
|
||||
label_text = "🚨"
|
||||
#label_text = "Check Alive"
|
||||
tooltip_text = "Check Alive"
|
||||
tooltip_text = N_("Check Alive")
|
||||
base_field = "threshold"
|
||||
# The Check Alive field accepts a signed value; the delivery field does not.
|
||||
allow_negative_raw = True
|
||||
|
||||
def __init__(self, bal_window, parent, init_value=None):
|
||||
if init_value is None:
|
||||
@@ -659,7 +683,7 @@ class ThresholdTimeWidget(BalTimeEditWidget):
|
||||
|
||||
class LockTimeWidget(BalTimeEditWidget):
|
||||
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
|
||||
help_text = (
|
||||
help_text = N_(
|
||||
"<b>DELIVERY TIME</b><br><br>"
|
||||
"Set Locktime for transactions.<br>"
|
||||
"Any time is needed transaction will be anticipated by 1day<br><br>"
|
||||
@@ -677,7 +701,7 @@ class LockTimeWidget(BalTimeEditWidget):
|
||||
#label_text = "Locktime"
|
||||
# Hover tooltip for the delivery-time icon; mirrors the style of the fee
|
||||
# icon tooltip ("..., click for more information") so the two are consistent.
|
||||
tooltip_text = "Delivery Time, click for more information"
|
||||
tooltip_text = N_("Delivery Time, click for more information")
|
||||
base_field = "locktime"
|
||||
|
||||
def __init__(self, bal_window, parent, init_value=None):
|
||||
@@ -1088,9 +1112,12 @@ class WillSettingsWidget(QWidget):
|
||||
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
|
||||
if basic_mode:
|
||||
# BASIC mode: use factory defaults (the hidden settings are
|
||||
# ignored) and the fixed 30/10/1 offsets.
|
||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
||||
# ignored) and the fixed 30/10/1 offsets. The defaults are in
|
||||
# the GUI language (see BalConfig).
|
||||
raw_description = (
|
||||
self.bal_window.bal_plugin.EVENT_DESCRIPTION.localized_default()
|
||||
)
|
||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.localized_default()
|
||||
threshold = None
|
||||
num_reminders = 3
|
||||
else:
|
||||
@@ -1120,6 +1147,7 @@ class WillSettingsWidget(QWidget):
|
||||
version=self.bal_window.bal_plugin.version,
|
||||
num_reminders=num_reminders,
|
||||
threshold=threshold,
|
||||
reminder_suffix=_("(reminder {idx}/{total})"),
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(f"failed to generate .ics: {e}")
|
||||
@@ -1127,18 +1155,23 @@ class WillSettingsWidget(QWidget):
|
||||
|
||||
def on_locktime_change(self):
|
||||
locktime = self.widgets["locktime"].get_value()
|
||||
threshold = self.widgets["threshold"].get_value()
|
||||
threshold_raw = self.widgets["threshold"].get_value()
|
||||
locktime = BalTimestamp(locktime)
|
||||
threshold = BalTimestamp(threshold)
|
||||
|
||||
min_locktime = min(
|
||||
Will.get_min_locktime(self.bal_window.willitems, NLOCKTIME_MAX),
|
||||
locktime.to_timestamp(),
|
||||
)
|
||||
# Resolved Check Alive date. For relative values this counts BACKWARDS
|
||||
# from the delivery time (reverse=True): "90d" means "90 days BEFORE the
|
||||
# delivery", not "90 days from now".
|
||||
td = threshold.to_date(min_locktime, True)
|
||||
# Resolved Check Alive date. The SIGN of the RAW value selects the
|
||||
# anchor: negative counts backwards from the delivery, positive counts
|
||||
# forward from the inheritance creation date. The rule lives in
|
||||
# ``bal.core.checkalive`` so this display matches every validity check.
|
||||
resolved = resolve_relative_check_alive_date(
|
||||
threshold_raw,
|
||||
delivery_date=min_locktime,
|
||||
creation_date=Will.get_creation_time(self.bal_window.willitems),
|
||||
)
|
||||
td = BalTimestamp._safe_fromtimestamp(resolved)
|
||||
self.widgets["threshold"].alarm=td
|
||||
self.bal_window.will_settings["real_threshold"]=td.timestamp()
|
||||
try:
|
||||
@@ -1199,7 +1232,7 @@ class PercAmountEdit(BTCAmountEdit):
|
||||
painter.drawText(
|
||||
text_rect,
|
||||
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
|
||||
self.base_unit() + " or perc value",
|
||||
_("{} or perc value").format(self.base_unit()),
|
||||
)
|
||||
|
||||
|
||||
@@ -1317,11 +1350,12 @@ class WillWidget(QWidget):
|
||||
creation = str(BalTimestamp(self.will[w].time))
|
||||
|
||||
def qlabel(title, value):
|
||||
label = "<b>" + _(str(title)) + f":</b>\t{str(value)}"
|
||||
return QLabel(label)
|
||||
# ``title`` is shown as it is: callers pass a translated
|
||||
# label, or data (an heir name, a will-executor URL).
|
||||
return QLabel("<b>{}:</b>\t{}".format(title, value))
|
||||
|
||||
detaillayout.addWidget(qlabel("Locktime", locktime))
|
||||
detaillayout.addWidget(qlabel("Creation Time", creation))
|
||||
detaillayout.addWidget(qlabel(_("Locktime"), locktime))
|
||||
detaillayout.addWidget(qlabel(_("Creation Time"), creation))
|
||||
try:
|
||||
total_fees = (
|
||||
self.will[w].tx.input_value() - self.will[w].tx.output_value()
|
||||
@@ -1331,12 +1365,17 @@ class WillWidget(QWidget):
|
||||
decoded_fees = total_fees
|
||||
fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3)
|
||||
fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)"
|
||||
detaillayout.addWidget(qlabel("Transaction fees:", fees_str))
|
||||
detaillayout.addWidget(qlabel(_("Transaction fees"), fees_str))
|
||||
# The saved status history is English: translate it for display.
|
||||
detaillayout.addWidget(
|
||||
qlabel("Status:", self.will[w].status + signature_suffix(self.will[w]))
|
||||
qlabel(
|
||||
_("Status"),
|
||||
format_status_history(self.will[w].status)
|
||||
+ signature_suffix(self.will[w]),
|
||||
)
|
||||
)
|
||||
detaillayout.addWidget(QLabel(""))
|
||||
detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
|
||||
detaillayout.addWidget(QLabel("<b>{}</b>".format(_("Heirs:"))))
|
||||
for heir_name in self.will[w].heirs:
|
||||
if 'w!ll3x3c"' in heir_name:
|
||||
continue
|
||||
@@ -1363,7 +1402,9 @@ class WillWidget(QWidget):
|
||||
)
|
||||
if self.will[w].we:
|
||||
detaillayout.addWidget(QLabel(""))
|
||||
detaillayout.addWidget(QLabel(_("<b>Willexecutor:</b:")))
|
||||
detaillayout.addWidget(
|
||||
QLabel("<b>{}</b>".format(_("Willexecutor:")))
|
||||
)
|
||||
decoded_amount = Util.decode_amount(
|
||||
self.will[w].we["base_fee"], self._bal_parent.decimal_point
|
||||
)
|
||||
|
||||
@@ -20,12 +20,15 @@ import threading
|
||||
from electrum.util import MyEncoder
|
||||
|
||||
from ...core.checkalive import (
|
||||
CheckAliveAfterDeliveryError,
|
||||
CheckAliveError,
|
||||
check_alive_expired,
|
||||
ensure_check_alive_valid,
|
||||
resolve_date_to_check,
|
||||
resolve_delivery_date,
|
||||
resolve_guard_threshold,
|
||||
)
|
||||
from .common import (
|
||||
N_,
|
||||
OP_RETURN_PREFIX,
|
||||
AmountException,
|
||||
BalPlugin,
|
||||
@@ -282,12 +285,12 @@ class BalWindow:
|
||||
|
||||
def new_heir_dialog(self, heir_key=None):
|
||||
heir = self.heirs.get(heir_key)
|
||||
title = "New heir"
|
||||
title = _("New heir")
|
||||
if heir:
|
||||
title = f"Edit: {heir_key}"
|
||||
title = _("Edit: {}").format(heir_key)
|
||||
|
||||
d = BalDialog(
|
||||
self.window, self.bal_plugin, self.bal_plugin.get_window_title(_(title))
|
||||
self.window, self.bal_plugin, self.bal_plugin.get_window_title(title)
|
||||
)
|
||||
|
||||
vbox = QVBoxLayout(d)
|
||||
@@ -491,6 +494,7 @@ 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 = []
|
||||
@@ -547,7 +551,9 @@ class BalWindow:
|
||||
tx["heirsvalue"] = txs[txid].heirsvalue
|
||||
tx["description"] = txs[txid].description
|
||||
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
|
||||
tx["status"] = _("New")
|
||||
# Stored history: always English (translated only for display by
|
||||
# format_status_history); never _() here.
|
||||
tx["status"] = "New"
|
||||
tx["baltx_fees"] = txs[txid].tx_fees
|
||||
tx["time"] = creation_time
|
||||
tx["heirs"] = copy_structure(txs[txid].heirs)
|
||||
@@ -739,6 +745,7 @@ 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
|
||||
@@ -749,19 +756,20 @@ class BalWindow:
|
||||
self.willexecutors = Willexecutors.get_willexecutors(
|
||||
self.bal_plugin, update=True, bal_window=self, task=False
|
||||
)
|
||||
# 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)
|
||||
# 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,
|
||||
)
|
||||
|
||||
self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get())
|
||||
|
||||
@@ -786,7 +794,9 @@ 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
|
||||
self.bal_plugin.is_basic_mode(),
|
||||
self.will_settings,
|
||||
creation_date=Will.get_creation_time(self.willitems),
|
||||
)
|
||||
if threshold_ts is not None:
|
||||
return locktime < threshold_ts
|
||||
@@ -828,12 +838,19 @@ class BalWindow:
|
||||
except AmountException as e:
|
||||
self.show_warning(
|
||||
_(
|
||||
f"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{e}"
|
||||
)
|
||||
"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{}"
|
||||
).format(e)
|
||||
)
|
||||
except WillExecutorFeeTooHighException as e:
|
||||
self.show_error(
|
||||
_(f"Will-executor fee too high: {e}")
|
||||
_("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:
|
||||
@@ -919,27 +936,27 @@ class BalWindow:
|
||||
_logger.info("{}:{}".format(type(e), e))
|
||||
message = False
|
||||
if isinstance(e, HeirChangeException):
|
||||
message = "Heirs changed:"
|
||||
message = _("Heirs changed:")
|
||||
elif isinstance(e, WillExecutorNotPresent):
|
||||
message = "Will-Executor not present:"
|
||||
message = _("Will-Executor not present:")
|
||||
elif isinstance(e, WillexecutorChangeException):
|
||||
message = "Will-Executor changed"
|
||||
message = _("Will-Executor changed")
|
||||
elif isinstance(e, TxFeesChangedException):
|
||||
message = "Txfees are changed"
|
||||
message = _("Txfees are changed")
|
||||
elif isinstance(e, HeirNotFoundException):
|
||||
# Task #01b: replace the misleading "Heir not found" text.
|
||||
# This branch is most often hit because the delivery date
|
||||
# was anticipated, not because an heir is missing, so we use
|
||||
# a clear message that covers both the DATE and the HEIRS
|
||||
# cases (kept consistent with dialogs.py / the CHECK window).
|
||||
message = (
|
||||
message = _(
|
||||
"Found CHANGES to the DATE or the HEIRS,\n"
|
||||
"a NEW WILL must be prepared."
|
||||
)
|
||||
|
||||
if message:
|
||||
self.show_message(
|
||||
f"{_(message)}:\n {e}\n{_('will have to be built')}"
|
||||
"{}:\n {}\n{}".format(message, e, _("will have to be built"))
|
||||
)
|
||||
|
||||
_logger.info("build will")
|
||||
@@ -959,7 +976,7 @@ class BalWindow:
|
||||
self.invalidate_will()
|
||||
except NotCompleteWillException as e:
|
||||
self.show_error(
|
||||
"Error:{}\n {}".format(
|
||||
_("Error:{}\n {}").format(
|
||||
str(e),
|
||||
_("Please, check your heirs, locktime and threshold!"),
|
||||
)
|
||||
@@ -1019,7 +1036,9 @@ class BalWindow:
|
||||
except SerializationError as e:
|
||||
_logger.error("unable to deserialize the transaction")
|
||||
parent.show_critical(
|
||||
_("Electrum was unable to deserialize the transaction:") + "\n" + str(e)
|
||||
"{}\n{}".format(
|
||||
_("Electrum was unable to deserialize the transaction:"), e
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Electrum's own TxDialog: keep it in front of the main window.
|
||||
@@ -1087,8 +1106,8 @@ class BalWindow:
|
||||
def get_message():
|
||||
msg = ""
|
||||
if signed:
|
||||
msg = _(f"signed: {signed}\n")
|
||||
return msg + _(f"signing: {tosign}")
|
||||
msg = _("signed: {}").format(signed) + "\n"
|
||||
return msg + _("signing: {}").format(tosign)
|
||||
|
||||
if txids is not None:
|
||||
targets = [
|
||||
@@ -1295,6 +1314,12 @@ 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()
|
||||
@@ -1607,10 +1632,18 @@ class BalWindow:
|
||||
willitems = {
|
||||
t: willitems[t] for t in txids if t in willitems
|
||||
}
|
||||
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
|
||||
# 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
|
||||
),
|
||||
)
|
||||
|
||||
def getMsg(willexecutors):
|
||||
msg = "Broadcasting Transactions to Will-Executors:\n"
|
||||
msg = _("Broadcasting Transactions to Will-Executors:") + "\n"
|
||||
for url in willexecutors:
|
||||
msg += f"{url}:\t{willexecutors[url]['broadcast_status']}\n"
|
||||
return msg
|
||||
@@ -1664,8 +1697,8 @@ class BalWindow:
|
||||
if self.waiting_dialog._stopping:
|
||||
return
|
||||
self.waiting_dialog.update(
|
||||
"checking {} - {} : {}".format(
|
||||
willitems[wid].we["url"], wid, "Waiting"
|
||||
_("checking {} - {} : {}").format(
|
||||
willitems[wid].we["url"], wid, _("Waiting")
|
||||
)
|
||||
)
|
||||
w = willitems[wid]
|
||||
@@ -1673,7 +1706,7 @@ class BalWindow:
|
||||
Willexecutors.check_transaction(wid, w.we["url"])
|
||||
)
|
||||
self.waiting_dialog.update(
|
||||
"checked {} - {} : {}".format(
|
||||
_("checked {} - {} : {}").format(
|
||||
willitems[wid].we["url"],
|
||||
wid,
|
||||
willitems[wid].get_status("CHECKED"),
|
||||
@@ -2142,7 +2175,7 @@ class BalWindow:
|
||||
|
||||
# Simple, user-facing message shown when the download fails for any reason
|
||||
# (the technical cause is in the Electrum log).
|
||||
DOWNLOAD_FAILED_MESSAGE = (
|
||||
DOWNLOAD_FAILED_MESSAGE = N_(
|
||||
"Could not download the will-executors list.\n\n"
|
||||
"This is usually caused by your internet connection or a firewall, "
|
||||
"not by the plugin. Please check your connection (a VPN often helps) "
|
||||
@@ -2152,7 +2185,7 @@ class BalWindow:
|
||||
# Shown when the download fails while Electrum is connected through Tor:
|
||||
# the most common cause is a slow Tor connection, so guide the user
|
||||
# accordingly instead of the generic message above.
|
||||
DOWNLOAD_FAILED_TOR_MESSAGE = (
|
||||
DOWNLOAD_FAILED_TOR_MESSAGE = N_(
|
||||
"Could not download the will-executors list over Tor.\n\n"
|
||||
"Electrum is connected through Tor and the connection is taking too "
|
||||
"long. Your Tor connection may be slow. Please try again, or use a VPN "
|
||||
@@ -2225,27 +2258,27 @@ class BalWindow:
|
||||
if err.url and err.reason == "empty response":
|
||||
# Server reached but returned no data for this chain.
|
||||
self.show_warning(_(
|
||||
f"No active will-executor servers found for the "
|
||||
f"{err.chain} network.\n\n"
|
||||
f"The welist server at {err.url} responded but "
|
||||
f"returned no will-executors for this chain."
|
||||
))
|
||||
"No active will-executor servers found for the "
|
||||
"{chain} network.\n\n"
|
||||
"The welist server at {url} responded but "
|
||||
"returned no will-executors for this chain."
|
||||
).format(chain=err.chain, url=err.url))
|
||||
elif err.url:
|
||||
# Advanced mode with a non-empty error (network issue).
|
||||
self.show_warning(_(
|
||||
f"Could not reach the configured welist server.\n\n"
|
||||
f"Server: {err.url}\n"
|
||||
f"Error: {err.reason}\n\n"
|
||||
f"Please verify the welist server URL in the plugin "
|
||||
f"settings."
|
||||
))
|
||||
"Could not reach the configured welist server.\n\n"
|
||||
"Server: {url}\n"
|
||||
"Error: {reason}\n\n"
|
||||
"Please verify the welist server URL in the plugin "
|
||||
"settings."
|
||||
).format(url=err.url, reason=err.reason))
|
||||
else:
|
||||
# Basic mode: the server responded but has no data for this
|
||||
# chain.
|
||||
self.show_warning(_(
|
||||
f"No active will-executor found for the "
|
||||
f"{err.chain} network."
|
||||
))
|
||||
"No active will-executor found for the "
|
||||
"{chain} network."
|
||||
).format(chain=err.chain))
|
||||
else:
|
||||
# Tor-aware: a generic failure/timeout while on Tor is most
|
||||
# likely a slow Tor connection.
|
||||
|
||||
198
bal/i18n.py
Normal file
198
bal/i18n.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
bal.i18n
|
||||
========
|
||||
|
||||
Translation layer of the BAL plugin (gettext domain ``bal``).
|
||||
|
||||
Why BAL has its own catalog
|
||||
---------------------------
|
||||
Electrum translates its interface with gettext (domain ``electrum``). Its
|
||||
translators only see Electrum's own source code, so the texts of an external
|
||||
(zip) plugin such as BAL are not in Electrum's catalog, and Electrum offers no
|
||||
way to translate external plugins. BAL therefore ships its own compiled
|
||||
catalogs inside the plugin: ``locale/<lang>/LC_MESSAGES/bal.mo``.
|
||||
|
||||
Lookup order
|
||||
------------
|
||||
:func:`_` returns the first translation found in:
|
||||
|
||||
1. **Electrum's catalog.** When Electrum already translates the exact same
|
||||
English text, its translation wins. BAL then reads like Electrum and
|
||||
follows Electrum's own translation fixes; if Electrum ever ships BAL as an
|
||||
internal plugin, Electrum's translations take over with no change here
|
||||
(owner's decision D7 in ``PLAN_I18N.md``).
|
||||
2. **BAL's catalog**, for the texts that only BAL has.
|
||||
3. **The English source text.**
|
||||
|
||||
Language
|
||||
--------
|
||||
Electrum picks the GUI language once, at start-up (``run_electrum``), and a
|
||||
change needs a restart. :func:`init_from_config` repeats Electrum's choice
|
||||
once, when the Qt plugin is created. BAL never detects the language by
|
||||
itself: otherwise part of a window could be in one language and part in
|
||||
another. The CLI never calls it, so the CLI stays in English, like Electrum's.
|
||||
|
||||
Rules for translatable texts
|
||||
----------------------------
|
||||
* Wrap literal English text only: ``_("Sign the will")``.
|
||||
* Put the variable parts in ``{}`` and fill them *after* translating:
|
||||
``_("{} heirs").format(count)``. Never build the text with an f-string or
|
||||
``%`` inside ``_()``: it would change at run time and never match a catalog
|
||||
entry (Ruff rules INT001-INT003 catch this).
|
||||
* Texts evaluated at import time (class attributes, tables, constants) are
|
||||
marked with :func:`N_` and passed to ``_()`` when they are displayed. Every
|
||||
class body runs before :func:`init_from_config`, so a ``_()`` there would
|
||||
always return English.
|
||||
* Never translate text that is stored or compared (wallet labels, the saved
|
||||
status history, config values used to recognise BAL transactions): stored
|
||||
data must not depend on the UI language.
|
||||
"""
|
||||
|
||||
import gettext
|
||||
import io
|
||||
import string
|
||||
from typing import Optional
|
||||
|
||||
from electrum.i18n import _ as _electrum_gettext
|
||||
from electrum.logging import get_logger
|
||||
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
#: gettext domain of the BAL catalogs.
|
||||
DOMAIN = "bal"
|
||||
|
||||
# BAL catalog of the current language, or None (English, CLI, or no usable
|
||||
# catalog). Set by set_language().
|
||||
_catalog: Optional[gettext.GNUTranslations] = None
|
||||
|
||||
# Source texts whose BAL translation was rejected: the warning is logged once,
|
||||
# not at every repaint of the widget that shows the text.
|
||||
_rejected: set = set()
|
||||
|
||||
_formatter = string.Formatter()
|
||||
|
||||
|
||||
def keeps_format_fields(msg: str, translation: str) -> bool:
|
||||
"""Tell whether ``translation`` keeps the ``{}`` fields of ``msg``.
|
||||
|
||||
A translation that adds, drops or renames a replacement field would make
|
||||
the ``.format()`` call that follows raise, or put a value in the wrong
|
||||
place, so such a translation must not be used.
|
||||
|
||||
The rules are copied from Electrum's
|
||||
``_ensure_translation_keeps_format_string_syntax_similar``
|
||||
(``electrum/i18n.py``, MIT licence, Copyright (C) The Electrum developers)
|
||||
rather than importing that private function, which may change without
|
||||
notice. Keep them identical, so a BAL translation is accepted or rejected
|
||||
exactly like an Electrum one. The only addition: a malformed source text
|
||||
returns False here instead of raising.
|
||||
"""
|
||||
try:
|
||||
# Tuples of (literal_text, field_name, format_spec, conversion).
|
||||
parsed1 = list(_formatter.parse(msg))
|
||||
parsed2 = list(_formatter.parse(translation))
|
||||
except ValueError: # malformed format string
|
||||
return False
|
||||
if len(parsed1) != len(parsed2):
|
||||
return False
|
||||
# The set of field names must not change (re-ordering them is allowed).
|
||||
return {t[1] for t in parsed1} == {t[1] for t in parsed2}
|
||||
|
||||
|
||||
def _(msg: str) -> str:
|
||||
"""Return ``msg`` translated into the GUI language.
|
||||
|
||||
See the module docstring for the lookup order. ``_("")`` returns ``""``:
|
||||
for gettext the empty text is the key of the catalog header.
|
||||
"""
|
||||
if msg == "":
|
||||
return ""
|
||||
translation = _electrum_gettext(msg)
|
||||
if translation != msg or _catalog is None:
|
||||
# Electrum's translation wins (Electrum has already checked its {}
|
||||
# fields); without a BAL catalog there is nothing else to look up.
|
||||
return translation
|
||||
translation = _catalog.gettext(msg)
|
||||
if translation == msg or keeps_format_fields(msg, translation):
|
||||
return translation
|
||||
if msg not in _rejected:
|
||||
_rejected.add(msg)
|
||||
_logger.warning(
|
||||
f"rejected BAL translation, replacement fields differ: "
|
||||
f"{msg!r} -> {translation!r}"
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
def N_(msg: str) -> str: # noqa: N802 (the conventional gettext marker name)
|
||||
"""Mark ``msg`` as translatable and return it unchanged.
|
||||
|
||||
Used where a text is defined at import time (class attributes, tables,
|
||||
constants). Extraction tools (Babel, xgettext) collect ``N_("...")`` like
|
||||
``_("...")``; the code passes the value to ``_()`` when it shows it.
|
||||
"""
|
||||
return msg
|
||||
|
||||
|
||||
def set_language(plugin, lang: Optional[str]) -> None:
|
||||
"""Load the BAL catalog of ``lang`` (for example ``"it_IT"``), or none.
|
||||
|
||||
``None``, ``""`` and English (``"en_*"``) load no catalog, so the English
|
||||
source texts are shown, as Electrum does for ``en_*``.
|
||||
|
||||
The catalog is read with ``plugin.read_file()``, the Electrum API BAL uses
|
||||
for its icons: it works both from the installed zip and from a development
|
||||
checkout. ``gettext.translation(localedir=...)`` is not used because,
|
||||
pointed inside a zip, it silently finds nothing.
|
||||
|
||||
If ``locale/<lang>/`` has no catalog, the plain language code is tried
|
||||
(``locale/it/`` for ``it_IT``). A missing or unreadable catalog is logged
|
||||
and BAL stays in English: a translation problem must never stop the
|
||||
plugin.
|
||||
"""
|
||||
global _catalog
|
||||
_catalog = None
|
||||
_rejected.clear()
|
||||
if not isinstance(lang, str) or not lang or lang.startswith("en_"):
|
||||
return
|
||||
codes = [lang]
|
||||
if "_" in lang:
|
||||
codes.append(lang.split("_", 1)[0])
|
||||
for code in codes:
|
||||
path = "locale/{}/LC_MESSAGES/{}.mo".format(code, DOMAIN)
|
||||
try:
|
||||
data = plugin.read_file(path)
|
||||
except Exception:
|
||||
# Not shipped for this code (a zip raises KeyError, a development
|
||||
# folder raises OSError): try the next code.
|
||||
continue
|
||||
try:
|
||||
_catalog = gettext.GNUTranslations(io.BytesIO(data))
|
||||
except Exception as e:
|
||||
_logger.info(f"unreadable BAL catalog {path}, using English: {e!r}")
|
||||
return
|
||||
_logger.info(f"BAL catalog loaded: {path}")
|
||||
return
|
||||
_logger.info(f"no BAL catalog for {lang!r}, using English")
|
||||
|
||||
|
||||
def init_from_config(plugin, config) -> None:
|
||||
"""Load the BAL catalog of the language Electrum's Qt GUI is using.
|
||||
|
||||
Repeats Electrum's own choice in ``run_electrum``: the ``language``
|
||||
setting (Preferences > Language) or, when it is empty, the system language
|
||||
if Electrum supports it, else English. Called once, from the Qt plugin's
|
||||
``__init__``. Never raises.
|
||||
"""
|
||||
try:
|
||||
lang = config.LOCALIZATION_LANGUAGE
|
||||
if not lang:
|
||||
from electrum.gui.default_lang import get_default_language
|
||||
|
||||
lang = get_default_language(gui_name="qt")
|
||||
except Exception as e:
|
||||
# Electrum also falls back to English when it cannot read the
|
||||
# system language.
|
||||
_logger.info(f"could not read the GUI language, using English: {e!r}")
|
||||
lang = None
|
||||
set_language(plugin, lang)
|
||||
1820
bal/locale/bal.pot
Normal file
1820
bal/locale/bal.pot
Normal file
File diff suppressed because it is too large
Load Diff
2033
bal/locale/es_ES/LC_MESSAGES/bal.po
Normal file
2033
bal/locale/es_ES/LC_MESSAGES/bal.po
Normal file
File diff suppressed because it is too large
Load Diff
2033
bal/locale/it_IT/LC_MESSAGES/bal.po
Normal file
2033
bal/locale/it_IT/LC_MESSAGES/bal.po
Normal file
File diff suppressed because it is too large
Load Diff
55
build_zip.py
55
build_zip.py
@@ -9,7 +9,12 @@ Electrum loads external plugins from a ``.zip`` using Python's ``zipimport``.
|
||||
* uses standard DEFLATE compression (well supported by ``zipimport``);
|
||||
* emits entries in a deterministic, sorted order so the archive is
|
||||
reproducible (stable SHA-256);
|
||||
* skips ``__pycache__`` directories and compiled ``*.pyc``/``*.pyo`` files.
|
||||
* skips ``__pycache__`` directories and compiled ``*.pyc``/``*.pyo`` files;
|
||||
* compiles each translation catalog ``bal/locale/<lang>/LC_MESSAGES/bal.po``
|
||||
into ``bal.mo`` inside the archive (the ``.mo`` files are not kept in
|
||||
git, and the ``.po``/``.pot`` sources are not shipped). This needs Babel
|
||||
(``pip install babel``); without it the build stops, so a release can
|
||||
never ship without its translations by mistake.
|
||||
|
||||
The archive keeps the top-level ``bal/`` directory so that the package is
|
||||
importable as ``bal`` (and Electrum derives ``dirname='bal'`` from the path of
|
||||
@@ -23,14 +28,36 @@ Prints the resulting size and SHA-256 so the download can be integrity-checked.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
SRC_ROOT = "bal"
|
||||
DEFAULT_OUT = "bal-electrum-plugin.zip"
|
||||
|
||||
|
||||
def compile_catalog(po_path: str) -> bytes:
|
||||
"""Return the compiled ``.mo`` bytes of the gettext catalog ``po_path``.
|
||||
|
||||
Fuzzy (unreviewed) entries are left out, as ``msgfmt`` does.
|
||||
"""
|
||||
try:
|
||||
from babel.messages.mofile import write_mo
|
||||
from babel.messages.pofile import read_po
|
||||
except ImportError:
|
||||
raise SystemExit(
|
||||
"ERROR: Babel is needed to compile the translations "
|
||||
"(pip install babel)"
|
||||
) from None
|
||||
with open(po_path, "rb") as f:
|
||||
catalog = read_po(f)
|
||||
buf = io.BytesIO()
|
||||
write_mo(buf, catalog, use_fuzzy=False)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def build(out_path: str) -> None:
|
||||
if os.path.exists(out_path):
|
||||
os.remove(out_path)
|
||||
@@ -40,17 +67,34 @@ def build(out_path: str) -> None:
|
||||
# prune cache dirs in place so os.walk does not descend into them
|
||||
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
|
||||
for fn in filenames:
|
||||
if fn.endswith((".pyc", ".pyo")):
|
||||
# .mo files are rebuilt from the .po sources below; the .po/.pot
|
||||
# sources themselves are not shipped.
|
||||
if fn.endswith((".pyc", ".pyo", ".po", ".pot", ".mo")):
|
||||
continue
|
||||
files.append(os.path.join(dirpath, fn))
|
||||
catalogs = {} # archive path of the .mo -> source .po
|
||||
for dirpath, _dirnames, filenames in os.walk(os.path.join(SRC_ROOT, "locale")):
|
||||
for fn in filenames:
|
||||
if fn.endswith(".po"):
|
||||
po = os.path.join(dirpath, fn)
|
||||
catalogs[po[: -len(".po")] + ".mo"] = po
|
||||
files.sort()
|
||||
|
||||
with zipfile.ZipFile(
|
||||
out_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
|
||||
) as z:
|
||||
for f in files:
|
||||
entries = [(f, None) for f in files] + [
|
||||
(mo, po) for mo, po in catalogs.items()
|
||||
]
|
||||
for f, po in sorted(entries):
|
||||
arc = f.replace(os.sep, "/") # forward slashes inside the archive
|
||||
z.write(f, arc)
|
||||
if po is None:
|
||||
z.write(f, arc)
|
||||
else:
|
||||
# Same timestamp rule as z.write(): the source file's mtime.
|
||||
info = zipfile.ZipInfo(arc, time.localtime(os.path.getmtime(po))[:6])
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
z.writestr(info, compile_catalog(po))
|
||||
|
||||
# Integrity + summary
|
||||
with zipfile.ZipFile(out_path) as z:
|
||||
@@ -63,7 +107,8 @@ def build(out_path: str) -> None:
|
||||
|
||||
data = open(out_path, "rb").read()
|
||||
print(f"built : {out_path}")
|
||||
print(f"files : {len(files)}")
|
||||
print(f"files : {len(files) + len(catalogs)}")
|
||||
print(f"langs : {', '.join(sorted(os.path.basename(os.path.dirname(os.path.dirname(m))) for m in catalogs)) or '-'}")
|
||||
print(f"size : {len(data)} bytes")
|
||||
print(f"sha256: {hashlib.sha256(data).hexdigest()}")
|
||||
|
||||
|
||||
@@ -32,6 +32,18 @@ you change anything in your will, BAL must decide between three outcomes:
|
||||
The whole point of rule 3 is safety: **a will‑executor must never be able to
|
||||
broadcast an old transaction that would execute your inheritance too early.**
|
||||
|
||||
**Check‑Alive relative values are signed.** A RELATIVE Check‑Alive
|
||||
(``-30d`` / ``30d``) is anchored according to its sign:
|
||||
|
||||
- **NEGATIVE** (``-30d``) counts **backwards from the delivery date** — the
|
||||
classic "check alive 30 days before the delivery".
|
||||
- **POSITIVE** (``30d``) counts **forward from the inheritance creation date**
|
||||
(the date the will was built), so it stays fixed while the will is unchanged.
|
||||
|
||||
The Check‑Alive date must always be **after today** and **before the delivery**;
|
||||
when it is not, BAL raises `CheckAliveAfterDeliveryError` (a configuration error
|
||||
that aborts the action and never invalidates the will).
|
||||
|
||||
---
|
||||
|
||||
## 2. Transaction states (status flags)
|
||||
@@ -164,6 +176,7 @@ exactly what the will‑executors hold — not the in‑memory copy.
|
||||
| **Move date EARLIER, still in the future** (anticipate) | **any** (signed or not) | Plain **rebuild** with the new earlier locktime (`HeirNotFound` fall‑through) — **never** an on‑chain invalidation | **No** |
|
||||
| **Move date EARLIER into the past** (new date already passed) | — | Will is genuinely **expired** → **invalidate** (`WillExpired`) | **Yes** |
|
||||
| Check‑Alive threshold already passed | — | **Invalidate** (`CheckAliveError`) | **Yes** |
|
||||
| Check‑Alive date not before the delivery (config error) | — | **Abort**, no rebuild/invalidate (`CheckAliveAfterDeliveryError`) | **No** |
|
||||
|
||||
> **Why postpone needs an on‑chain invalidation:** the will‑executor still holds
|
||||
> the *old* transaction with the *earlier* locktime. If you simply re‑signed a
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ./make-release.sh # read version from bal/manifest.json
|
||||
# ./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
|
||||
#
|
||||
# Requires: git, gpg, curl, python3, sha256sum
|
||||
# Requires: git, gpg, curl, python3, sha256sum, Babel for python3 (pip install babel)
|
||||
# Optional: ruff (lint skipped if not installed)
|
||||
# Credentials: ~/.git-credentials or GITEA_USER / GITEA_TOKEN env vars
|
||||
|
||||
@@ -33,6 +33,12 @@ else
|
||||
NEW_VER=""
|
||||
fi
|
||||
|
||||
# Babel is needed by the i18n tests (step 3) and by build_zip.py, which
|
||||
# compiles the translation catalogs into the zip (step 5). Check it up front
|
||||
# so a release never stops half-way.
|
||||
python3 -c "import babel" 2>/dev/null \
|
||||
|| die "Babel is missing for $(command -v python3): pip install babel"
|
||||
|
||||
echo "=== Release ${TAG} ==="
|
||||
echo "Current manifest version: ${CURRENT_VER}"
|
||||
[ -n "$NEW_VER" ] && echo "New version (will bump): ${NEW_VER}"
|
||||
@@ -68,6 +74,8 @@ if QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
|
||||
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
|
||||
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
|
||||
tests/test_group_h_v048.py \
|
||||
tests/test_i18n.py tests/test_translations.py \
|
||||
tests/test_cli_*.py tests/test_import_will_details.py \
|
||||
-q 2>&1; then
|
||||
echo "All tests passed."
|
||||
else
|
||||
|
||||
@@ -3,9 +3,15 @@ line-length = 88
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select =["E", "W", "F", "I", "N", "B"]
|
||||
select =["E", "W", "F", "I", "N", "B", "INT"] # INT: gettext rules (see bal/i18n.py)
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
# Classify the plugin package explicitly: otherwise ruff decides from where it
|
||||
# is run / how the project root is detected, and the same import block is
|
||||
# "sorted" on one machine and I001 on another.
|
||||
known-first-party = ["bal"]
|
||||
|
||||
[tool.ruff.lint.pep8-naming]
|
||||
classmethod-decorators = ["classmethod", "classproperty"] # electrum.util.classproperty uses cls
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""
|
||||
Tests for ``bal.core.animated_qr`` (BC-UR v1, BC-UR v2, BBQR interop).
|
||||
Tests for ``bal.core.animated_qr`` (BBQR interop).
|
||||
|
||||
Validates the self-contained codecs against the published spec vectors
|
||||
(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.
|
||||
Validates the self-contained BBQR codec against round trips, out-of-order
|
||||
assembly, compression default/fallback, malformed-input rejection and the
|
||||
format detection / parse_for_detection helpers.
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
@@ -22,328 +20,6 @@ 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
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -424,41 +100,22 @@ def test_bbqr_part_number_limits():
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
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"
|
||||
def test_detect_format_recognises_bbqr():
|
||||
assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "bbqr"
|
||||
assert aq.detect_format(REF_V2_SINGLE_12) == "ur2"
|
||||
assert aq.detect_format("ur:bytes/" + aq.bc32_encode(aq.cbor_byte_string(b"x"))) == "ur1"
|
||||
|
||||
|
||||
def test_detect_format_rejects_garbage():
|
||||
for text in ("", "hello world", "BALQ|1|1||a", "ur:", "ur:txn/xyz"):
|
||||
for text in ("", "hello world", "BALQR1|1|1||payload", "ur:", "ur:bytes/xyz"):
|
||||
assert aq.detect_format(text) is None, text
|
||||
# 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():
|
||||
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])
|
||||
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():
|
||||
for fmt in ("balqr", "ur1", "ur2", "bbqr"):
|
||||
assert aq.format_name(fmt)
|
||||
assert aq.format_name("bbqr")
|
||||
assert aq.format_name("nope") == "nope"
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""
|
||||
Tests for ``bal.core.checkalive`` (pure, GUI-free).
|
||||
|
||||
Covers the CheckAliveError exception and the BASIC/ADVANCED date_to_check
|
||||
policy (``resolve_date_to_check`` / ``check_alive_expired``).
|
||||
Covers the CheckAlive exceptions and the BASIC/ADVANCED date_to_check policy
|
||||
(``resolve_date_to_check`` / ``resolve_relative_check_alive_date`` /
|
||||
``is_check_alive_date_valid`` / ``ensure_check_alive_valid``), including the
|
||||
sign rule: a NEGATIVE relative Check Alive counts back from the delivery, a
|
||||
POSITIVE one counts forward from the inheritance creation date.
|
||||
|
||||
Run:
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
@@ -11,24 +14,41 @@ Run:
|
||||
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||
|
||||
from bal.core.checkalive import ( # noqa: E402 (path insert above)
|
||||
CheckAliveAfterDeliveryError,
|
||||
CheckAliveError,
|
||||
check_alive_expired,
|
||||
ensure_check_alive_valid,
|
||||
is_check_alive_date_valid,
|
||||
resolve_date_to_check,
|
||||
resolve_delivery_date,
|
||||
resolve_guard_threshold,
|
||||
resolve_relative_check_alive_date,
|
||||
)
|
||||
from bal.core.plugin_base import BalTimestamp # noqa: E402
|
||||
from bal.core.will import Will # noqa: E402
|
||||
|
||||
|
||||
def _midnight(ts):
|
||||
return (
|
||||
datetime.fromtimestamp(ts, tz=timezone.utc)
|
||||
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
.timestamp()
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# CheckAliveError
|
||||
# CheckAliveError / CheckAliveAfterDeliveryError
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
|
||||
def test_check_alive_error_default():
|
||||
err = CheckAliveError(1000000)
|
||||
assert err.timestamp_to_check == 1000000
|
||||
assert err.reason is None
|
||||
|
||||
|
||||
def test_check_alive_error_str():
|
||||
@@ -38,10 +58,107 @@ def test_check_alive_error_str():
|
||||
assert "1970" in s
|
||||
|
||||
|
||||
def test_check_alive_error_str_with_reason():
|
||||
err = CheckAliveError(1000000, reason="too early")
|
||||
assert "too early" in str(err)
|
||||
|
||||
|
||||
def test_check_alive_error_subclass():
|
||||
assert issubclass(CheckAliveError, Exception)
|
||||
|
||||
|
||||
def test_check_alive_after_delivery_error_is_sibling():
|
||||
# NOT a subclass of CheckAliveError: the existing ``except CheckAliveError``
|
||||
# handlers must not invalidate the will for this configuration error.
|
||||
assert not issubclass(CheckAliveAfterDeliveryError, CheckAliveError)
|
||||
err = CheckAliveAfterDeliveryError(2000, 1000)
|
||||
assert err.timestamp_to_check == 2000
|
||||
assert err.delivery_timestamp == 1000
|
||||
assert "delivery" in str(err)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# resolve_delivery_date
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
|
||||
def test_delivery_absolute_locktime():
|
||||
settings = {"locktime": 1_800_000_000}
|
||||
assert resolve_delivery_date(settings) == 1_800_000_000
|
||||
|
||||
|
||||
def test_delivery_relative_locktime_resolved_from_now():
|
||||
fake_now = 1_800_000_000.0
|
||||
settings = {"locktime": "90d"}
|
||||
result = resolve_delivery_date(settings, now=fake_now)
|
||||
expected = _midnight(fake_now) + 90 * 86400
|
||||
assert abs(result - expected) < 1
|
||||
|
||||
|
||||
def test_delivery_relative_locktime_anchored_to_built_tx():
|
||||
frozen = 1817424000
|
||||
settings = {"locktime": "2y"}
|
||||
result = resolve_delivery_date(
|
||||
settings, now=1_800_000_000.0, built_locktime=frozen
|
||||
)
|
||||
assert result == frozen
|
||||
|
||||
|
||||
def test_delivery_missing_locktime_returns_none():
|
||||
assert resolve_delivery_date({}) is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# resolve_relative_check_alive_date (the single calculation rule)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
|
||||
def test_relative_negative_anchors_to_delivery():
|
||||
fake_now = 1_800_000_000.0
|
||||
delivery = fake_now + 90 * 86400
|
||||
result = resolve_relative_check_alive_date(
|
||||
"-30d", delivery_date=delivery, creation_date=fake_now, now=fake_now
|
||||
)
|
||||
expected = _midnight(delivery) - 30 * 86400
|
||||
assert abs(result - expected) < 1
|
||||
assert result < delivery
|
||||
|
||||
|
||||
def test_relative_positive_anchors_to_creation():
|
||||
fake_now = 1_800_000_000.0
|
||||
creation = fake_now + 5 * 86400
|
||||
delivery = fake_now + 365 * 86400
|
||||
result = resolve_relative_check_alive_date(
|
||||
"30d", delivery_date=delivery, creation_date=creation, now=fake_now
|
||||
)
|
||||
expected = _midnight(creation) + 30 * 86400
|
||||
assert abs(result - expected) < 1
|
||||
# The delivery anchor is irrelevant for a positive value.
|
||||
assert result < delivery
|
||||
|
||||
|
||||
def test_relative_positive_without_creation_falls_back_to_now():
|
||||
fake_now = 1_800_000_000.0
|
||||
result = resolve_relative_check_alive_date(
|
||||
"30d", delivery_date=fake_now + 365 * 86400, creation_date=None, now=fake_now
|
||||
)
|
||||
expected = _midnight(fake_now) + 30 * 86400
|
||||
assert abs(result - expected) < 1
|
||||
|
||||
|
||||
def test_relative_negative_without_delivery_falls_back_to_now():
|
||||
fake_now = 1_800_000_000.0
|
||||
result = resolve_relative_check_alive_date(
|
||||
"-30d", delivery_date=None, creation_date=fake_now, now=fake_now
|
||||
)
|
||||
expected = _midnight(fake_now) - 30 * 86400
|
||||
assert abs(result - expected) < 1
|
||||
|
||||
|
||||
def test_relative_absolute_value_returned_unchanged():
|
||||
assert resolve_relative_check_alive_date(1_700_000_000, None, None) == 1_700_000_000
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# resolve_date_to_check
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -59,69 +176,60 @@ def test_advanced_mode_uses_threshold_absolute():
|
||||
assert abs(result - threshold) < 1
|
||||
|
||||
|
||||
def test_advanced_mode_parses_relative_threshold():
|
||||
# A relative threshold means "N days BEFORE the delivery": it resolves
|
||||
# against the stored locktime (backwards), not forward from now.
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
def test_advanced_mode_negative_relative_anchored_to_delivery():
|
||||
fake_now = 1_800_000_000.0
|
||||
locktime = fake_now + 90 * 86400
|
||||
settings = {"threshold": "30d", "locktime": locktime}
|
||||
settings = {"threshold": "-30d", "locktime": locktime}
|
||||
result = resolve_date_to_check(False, settings, now=fake_now)
|
||||
# date_to_check = (locktime, midnight-normalised) - 30 days.
|
||||
expected = (datetime.fromtimestamp(locktime, tz=timezone.utc)
|
||||
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
- timedelta(days=30)).timestamp()
|
||||
expected = _midnight(locktime) - 30 * 86400
|
||||
assert abs(result - expected) < 1
|
||||
# 90d delivery with a 30d window: the window starts 60 days after now.
|
||||
assert result > fake_now
|
||||
|
||||
|
||||
def test_advanced_mode_relative_threshold_anchored_to_locktime():
|
||||
"""A relative threshold never drifts with the clock: re-resolving it a day
|
||||
later, with the same fixed absolute locktime, yields the same date."""
|
||||
def test_advanced_mode_positive_relative_anchored_to_creation():
|
||||
fake_now = 1_800_000_000.0
|
||||
creation = fake_now + 10 * 86400
|
||||
locktime = fake_now + 365 * 86400
|
||||
settings = {"threshold": "30d", "locktime": locktime}
|
||||
result = resolve_date_to_check(
|
||||
False, settings, now=fake_now, creation_date=creation
|
||||
)
|
||||
expected = _midnight(creation) + 30 * 86400
|
||||
assert abs(result - expected) < 1
|
||||
|
||||
|
||||
def test_advanced_mode_negative_relative_never_drifts():
|
||||
"""A negative relative threshold anchored to a fixed absolute delivery does
|
||||
not drift with the clock."""
|
||||
fake_now = 1_800_000_000.0
|
||||
locktime = fake_now + 90 * 86400
|
||||
settings = {"threshold": "30d", "locktime": locktime}
|
||||
settings = {"threshold": "-30d", "locktime": locktime}
|
||||
first = resolve_date_to_check(False, settings, now=fake_now)
|
||||
# Next day: same stored settings (the fixed delivery), a later clock.
|
||||
second = resolve_date_to_check(False, settings, now=fake_now + 86400)
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_advanced_mode_relative_threshold_with_relative_locktime():
|
||||
"""A relative locktime is resolved against 'now' first, then the relative
|
||||
threshold counts N days back from it (matches the settings widget)."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from bal.core.plugin_base import BalTimestamp
|
||||
|
||||
def test_advanced_mode_positive_relative_creation_never_drifts():
|
||||
"""A positive relative threshold anchored to the frozen creation date does
|
||||
not drift with the clock."""
|
||||
fake_now = 1_800_000_000.0
|
||||
settings = {"threshold": "30d", "locktime": "90d"}
|
||||
result = resolve_date_to_check(False, settings, now=fake_now)
|
||||
# Recompute the expected value with the same resolution rules:
|
||||
# locktime = now + 90d (midnight-normalised), threshold = locktime - 30d.
|
||||
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, 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()
|
||||
creation = fake_now + 2 * 86400
|
||||
settings = {"threshold": "30d", "locktime": fake_now + 365 * 86400}
|
||||
first = resolve_date_to_check(
|
||||
False, settings, now=fake_now, creation_date=creation
|
||||
)
|
||||
second = resolve_date_to_check(
|
||||
False, settings, now=fake_now + 5 * 86400, creation_date=creation
|
||||
)
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_advanced_mode_relative_locktime_anchored_to_built_tx():
|
||||
"""A RELATIVE stored locktime is anchored to the built will's frozen
|
||||
delivery date (built_locktime), not to "now": an unchanged will must not
|
||||
read as expired as the clock advances (the karen7 daily-invalidate bug)."""
|
||||
frozen = 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.
|
||||
frozen = 1817424000 # frozen tx locktime, built 2026-08-05
|
||||
settings = {"threshold": "-30d", "locktime": "2y"}
|
||||
first = resolve_date_to_check(
|
||||
False, settings, now=1_800_000_000.0, built_locktime=frozen
|
||||
)
|
||||
@@ -130,22 +238,15 @@ def test_advanced_mode_relative_locktime_anchored_to_built_tx():
|
||||
False, settings, now=1_800_000_000.0 + 10 * 86400, built_locktime=frozen
|
||||
)
|
||||
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(datetime.fromtimestamp(fake_now, tz=timezone.utc))
|
||||
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
|
||||
locktime_dt = BalTimestamp("90d").to_date(fake_now)
|
||||
expected = BalTimestamp("-30d").to_date(locktime_dt).timestamp()
|
||||
assert abs(result - expected) < 1
|
||||
|
||||
|
||||
@@ -156,14 +257,12 @@ 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)
|
||||
|
||||
|
||||
@@ -175,43 +274,27 @@ def test_guard_threshold_absolute():
|
||||
assert resolve_guard_threshold(False, settings, now=fake_now) == threshold
|
||||
|
||||
|
||||
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).
|
||||
"""
|
||||
def test_guard_threshold_negative_relative_fresh_anchor():
|
||||
"""A negative relative threshold is anchored to the FRESH locktime so the
|
||||
guard and the settings always share one reference frame."""
|
||||
fake_now = 1_800_000_000.0
|
||||
settings = {"locktime": "1y", "threshold": "30d"}
|
||||
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
|
||||
# The helper takes no built anchor: a frozen "2y" built will must NOT
|
||||
# contaminate the result, although resolve_date_to_check (the expiry
|
||||
# reference) legitimately keeps using it.
|
||||
frozen_two_years = locktime + 365 * 86400
|
||||
anchored = resolve_date_to_check(
|
||||
False, settings, now=fake_now, built_locktime=frozen_two_years
|
||||
)
|
||||
assert anchored > threshold # built anchor pushes date_to_check forward...
|
||||
assert locktime < anchored # ...which is exactly what used to fire the bug
|
||||
|
||||
|
||||
def test_guard_threshold_relative_locktime_absolute_threshold():
|
||||
def test_guard_threshold_positive_relative_anchored_to_creation():
|
||||
fake_now = 1_800_000_000.0
|
||||
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
|
||||
creation = fake_now + 4 * 86400
|
||||
settings = {"locktime": "1y", "threshold": "30d"}
|
||||
threshold = resolve_guard_threshold(
|
||||
False, settings, now=fake_now, creation_date=creation
|
||||
)
|
||||
expected = _midnight(creation) + 30 * 86400
|
||||
assert abs(threshold - expected) < 1
|
||||
|
||||
|
||||
def test_guard_threshold_missing_returns_none():
|
||||
@@ -220,21 +303,99 @@ def test_guard_threshold_missing_returns_none():
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# check_alive_expired
|
||||
# is_check_alive_date_valid / ensure_check_alive_valid
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
|
||||
def test_is_valid_inside_window():
|
||||
now = 1_800_000_000.0
|
||||
assert is_check_alive_date_valid(now + 100, now + 1000, now=now) is True
|
||||
|
||||
|
||||
def test_is_valid_rejects_past():
|
||||
now = 1_800_000_000.0
|
||||
assert is_check_alive_date_valid(now - 100, now + 1000, now=now) is False
|
||||
|
||||
|
||||
def test_is_valid_rejects_on_or_after_delivery():
|
||||
now = 1_800_000_000.0
|
||||
delivery = now + 1000
|
||||
assert is_check_alive_date_valid(delivery, delivery, now=now) is False
|
||||
assert is_check_alive_date_valid(delivery + 1, delivery, now=now) is False
|
||||
|
||||
|
||||
def test_is_valid_without_delivery_skips_upper_bound():
|
||||
now = 1_800_000_000.0
|
||||
assert is_check_alive_date_valid(now + 100, None, now=now) is True
|
||||
|
||||
|
||||
def test_is_valid_none_is_false():
|
||||
assert is_check_alive_date_valid(None, 1000) is False
|
||||
|
||||
|
||||
def test_basic_mode_never_expired():
|
||||
assert check_alive_expired(True, 1_000_000_000.0) is False
|
||||
assert check_alive_expired(True, time.time() - 10_000) is False
|
||||
assert ensure_check_alive_valid(True, time.time() - 10_000) is None
|
||||
assert ensure_check_alive_valid(True, 1_000_000_000.0) is None
|
||||
|
||||
|
||||
def test_advanced_expired_when_past():
|
||||
assert check_alive_expired(False, time.time() - 10_000) is True
|
||||
def test_advanced_raises_when_past():
|
||||
try:
|
||||
ensure_check_alive_valid(False, time.time() - 10_000)
|
||||
except CheckAliveError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected CheckAliveError")
|
||||
|
||||
|
||||
def test_advanced_not_expired_when_future():
|
||||
assert check_alive_expired(False, time.time() + 10_000) is False
|
||||
def test_advanced_raises_when_after_delivery():
|
||||
now = time.time()
|
||||
try:
|
||||
ensure_check_alive_valid(False, now + 100, delivery_date=now + 50)
|
||||
except CheckAliveAfterDeliveryError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected CheckAliveAfterDeliveryError")
|
||||
|
||||
|
||||
def test_advanced_valid_does_not_raise():
|
||||
now = time.time()
|
||||
assert ensure_check_alive_valid(False, now + 100, delivery_date=now + 1000) is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Will.get_creation_time
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
|
||||
class _FakeWillItem:
|
||||
def __init__(self, time_value, valid=True):
|
||||
self.time = time_value
|
||||
self._valid = valid
|
||||
|
||||
def get_status(self, status):
|
||||
return self._valid if status == "VALID" else False
|
||||
|
||||
|
||||
def test_get_creation_time_min_of_valid_items():
|
||||
will = {
|
||||
"a": _FakeWillItem(2000),
|
||||
"b": _FakeWillItem(1000),
|
||||
"c": _FakeWillItem(3000),
|
||||
}
|
||||
assert Will.get_creation_time(will) == 1000
|
||||
|
||||
|
||||
def test_get_creation_time_ignores_invalid_and_none():
|
||||
will = {
|
||||
"a": _FakeWillItem(500, valid=False),
|
||||
"b": _FakeWillItem(None),
|
||||
"c": _FakeWillItem(2000),
|
||||
}
|
||||
assert Will.get_creation_time(will) == 2000
|
||||
|
||||
|
||||
def test_get_creation_time_empty_returns_none():
|
||||
assert Will.get_creation_time({}) is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
82
tests/test_core_deselected_executor.py
Normal file
82
tests/test_core_deselected_executor.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
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")
|
||||
@@ -117,6 +117,30 @@ def test_normalize_locktime_raw_single_suffix():
|
||||
assert s == "130d"
|
||||
|
||||
|
||||
def test_normalize_locktime_raw_negative_disallowed_by_default():
|
||||
# The delivery field must never accept a sign.
|
||||
s, isdays, isyears, pos = normalize_locktime_raw_text("-30d", 4)
|
||||
assert s == "30d"
|
||||
assert isdays is True
|
||||
|
||||
|
||||
def test_normalize_locktime_raw_negative_allowed_keeps_single_sign():
|
||||
s, isdays, isyears, pos = normalize_locktime_raw_text(
|
||||
"-30d", 4, allow_negative=True
|
||||
)
|
||||
assert s == "-30d"
|
||||
assert isdays is True
|
||||
assert pos >= 1
|
||||
|
||||
|
||||
def test_normalize_locktime_raw_negative_only_leading():
|
||||
# Extra signs are dropped; a single leading sign survives.
|
||||
s, _isdays, _isyears, _pos = normalize_locktime_raw_text(
|
||||
"--1y", 4, allow_negative=True
|
||||
)
|
||||
assert s == "-1y"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Percentage-or-amount normalisation
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
231
tests/test_core_invalidation_fee.py
Normal file
231
tests/test_core_invalidation_fee.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
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")
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""
|
||||
Tests for ``bal.core.qrtransfer``.
|
||||
|
||||
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).
|
||||
Covers the transfer-string encoding used for will transfer via BBQR QR codes
|
||||
/ audio modem: encoding, decoding and the preset list.
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
@@ -18,34 +16,12 @@ 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
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -67,300 +43,6 @@ 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
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -379,46 +61,6 @@ 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
|
||||
|
||||
136
tests/test_core_status_language_neutral.py
Normal file
136
tests/test_core_status_language_neutral.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Stored will data must not depend on the GUI language (rule in bal/i18n.py).
|
||||
|
||||
Covers the two findings of the i18n review:
|
||||
|
||||
* the status history of a new will item is saved as English "New" (not
|
||||
``_("New")``), and histories saved by test builds as "Nuovo"/"Nuevo" are
|
||||
mapped back to "New" on load;
|
||||
* the push helper no longer writes transient fields (``txs``, ``txsids`` and
|
||||
the translated ``broadcast_status``) into the will item's executor record,
|
||||
and such fields saved by older versions are dropped on load.
|
||||
|
||||
Run (standalone or with pytest):
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||
python3 tests/test_core_status_language_neutral.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from electrum.transaction import Transaction
|
||||
|
||||
from bal.core.will import WillItem
|
||||
from bal.core.willexecutors import Willexecutors
|
||||
|
||||
# WillItem parses a raw tx and calls add_info_from_wallet; no live wallet here.
|
||||
_patcher = patch.object(Transaction, "add_info_from_wallet")
|
||||
_patcher.start()
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
_VALID_TX_HEX = (
|
||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||
"42146f11ef8414ae929feaafc388ac00000000"
|
||||
)
|
||||
URL = "https://we.example"
|
||||
|
||||
|
||||
def _item(status="", we=None):
|
||||
return WillItem(
|
||||
{"tx": _VALID_TX_HEX, "heirs": {}, "willexecutor": we, "status": status}
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Status history "New"
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_builders_store_english_new():
|
||||
"""window.py and the CLI controller must not translate the stored status."""
|
||||
for rel in ("bal/gui/qt/window.py", "bal/cli/controller.py"):
|
||||
src = (ROOT / rel).read_text(encoding="utf-8")
|
||||
assert re.search(r'tx\["status"\]\s*=\s*"New"', src), rel
|
||||
assert not re.search(r'tx\["status"\]\s*=\s*_\(', src), rel
|
||||
|
||||
|
||||
def test_translated_new_is_mapped_back_on_load():
|
||||
assert _item("Nuovo.Signed.Pushed").status == "New.Signed.Pushed"
|
||||
assert _item("Nuevo").status == "New"
|
||||
assert _item("New.Signed").status == "New.Signed"
|
||||
|
||||
|
||||
def test_only_the_leading_new_is_touched():
|
||||
assert _item(".Nuovo").status == ".Nuovo"
|
||||
assert _item("Signed.Nuovo").status == "Signed.Nuovo"
|
||||
|
||||
|
||||
def test_translated_new_labels_match_the_catalogs():
|
||||
"""Every catalog's translation of "New" must be recognised on load."""
|
||||
found = set()
|
||||
for po in (ROOT / "bal" / "locale").glob("*/LC_MESSAGES/bal.po"):
|
||||
text = po.read_text(encoding="utf-8")
|
||||
m = re.search(r'^msgid "New"\nmsgstr "(.*)"$', text, re.MULTILINE)
|
||||
if m and m.group(1):
|
||||
found.add(m.group(1))
|
||||
assert found, "no catalog translates 'New'"
|
||||
assert found <= set(WillItem.TRANSLATED_NEW_LABELS), found
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Transient push fields
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class FakeItem:
|
||||
def __init__(self):
|
||||
self.we = {"url": URL, "selected": True, "address": "a", "base_fee": 1}
|
||||
self.tx = "rawtx"
|
||||
|
||||
def get_status(self, name):
|
||||
return name in ("VALID", "COMPLETE")
|
||||
|
||||
|
||||
def test_push_helper_does_not_modify_the_will_item():
|
||||
item = FakeItem()
|
||||
before = dict(item.we)
|
||||
out = Willexecutors.get_willexecutor_transactions({"t1": item})
|
||||
assert out[URL]["txsids"] == ["t1"]
|
||||
assert "broadcast_status" in out[URL]
|
||||
assert item.we == before
|
||||
assert out[URL] is not item.we
|
||||
|
||||
|
||||
def test_transient_fields_saved_by_older_versions_are_dropped_on_load():
|
||||
we = {
|
||||
"url": URL,
|
||||
"selected": True,
|
||||
"txs": "raw\n",
|
||||
"txsids": ["t1"],
|
||||
"broadcast_status": "Riuscito",
|
||||
}
|
||||
item = _item("New", we)
|
||||
assert item.we == {"url": URL, "selected": True}
|
||||
assert "broadcast_status" not in item.to_dict()["willexecutor"]
|
||||
|
||||
|
||||
def test_clean_executor_record_is_kept_as_is():
|
||||
we = {"url": URL, "selected": True}
|
||||
assert _item("New", we).we is we
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All language-neutral status tests passed")
|
||||
@@ -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
|
||||
|
||||
145
tests/test_core_willtree_father.py
Normal file
145
tests/test_core_willtree_father.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Tests for the parent/child tree of the will transactions (``father`` links).
|
||||
|
||||
Bug reproduced from a real wallet (Electrum log, Check wizard):
|
||||
|
||||
File "bal/core/will.py", in check_invalidated
|
||||
or willtree[w.father].get_status("CONFIRMED")
|
||||
KeyError: 'a65a682f…'
|
||||
|
||||
``father`` is persisted in the wallet (``"Father"``) and was never cleared, so
|
||||
after the father tx was removed from the will the child pointed to a txid that
|
||||
no longer exists. ``Will.add_willtree`` now rebuilds the links from scratch and
|
||||
``Will.check_invalidated`` treats a missing father as "no father".
|
||||
|
||||
Run (standalone or with pytest):
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||
python3 tests/test_core_willtree_father.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from electrum.transaction import Transaction
|
||||
|
||||
from bal.core.will import Will, WillItem
|
||||
|
||||
# WillItem parses a raw tx and calls add_info_from_wallet; no live wallet here.
|
||||
_patcher = patch.object(Transaction, "add_info_from_wallet")
|
||||
_patcher.start()
|
||||
|
||||
# A valid serialized transaction (1 input, 1 output) reused by the core tests.
|
||||
_VALID_TX_HEX = (
|
||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||
"42146f11ef8414ae929feaafc388ac00000000"
|
||||
)
|
||||
# The input of _VALID_TX_HEX spends this outpoint (little-endian in the hex).
|
||||
_FUNDING_ID = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a"
|
||||
# PARENT: _VALID_TX_HEX itself. CHILD: the same tx with its input re-pointed to
|
||||
# the parent's txid, so the child spends the parent's output 0.
|
||||
_PARENT_ID = Transaction(_VALID_TX_HEX).txid()
|
||||
_CHILD_TX_HEX = _VALID_TX_HEX.replace(
|
||||
bytes.fromhex(_FUNDING_ID)[::-1].hex(), bytes.fromhex(_PARENT_ID)[::-1].hex()
|
||||
)
|
||||
# A txid that is not in the will (the "removed father" of the bug report).
|
||||
_MISSING_ID = "a65a682f2975f0dffd9eccce9392833f227badf617fc67815674736d594d4115"
|
||||
|
||||
|
||||
def _item(tx_hex=_VALID_TX_HEX, father=None):
|
||||
"""WillItem built from ``tx_hex``, optionally with a stored father."""
|
||||
data = {"tx": tx_hex, "heirs": {}, "willexecutor": None}
|
||||
if father is not None:
|
||||
data["Father"] = father
|
||||
return WillItem(data)
|
||||
|
||||
|
||||
def _wallet_without_adb():
|
||||
"""Wallet whose check_invalidated path skips on-chain lookups."""
|
||||
wallet = MagicMock()
|
||||
wallet.adb = None
|
||||
return wallet
|
||||
|
||||
|
||||
def test_stale_father_is_discarded_by_add_willtree():
|
||||
child = _item(father=_MISSING_ID)
|
||||
will = {child._id: child}
|
||||
Will.add_willtree(will)
|
||||
assert child.father is None
|
||||
|
||||
|
||||
def test_check_invalidated_does_not_crash_on_stale_father():
|
||||
"""The exact failure of the bug report, now without KeyError."""
|
||||
child = _item(father=_MISSING_ID)
|
||||
will = {child._id: child}
|
||||
Will.add_willtree(will)
|
||||
Will.check_invalidated(will, [], _wallet_without_adb())
|
||||
|
||||
|
||||
def test_check_invalidated_tolerates_missing_father_without_add_willtree():
|
||||
"""Defensive path: even if the tree was not rebuilt, no KeyError."""
|
||||
child = _item(father=_MISSING_ID)
|
||||
will = {child._id: child}
|
||||
Will.check_invalidated(will, [], _wallet_without_adb())
|
||||
assert child.father == _MISSING_ID
|
||||
|
||||
|
||||
def test_real_parent_child_link_is_built():
|
||||
parent = _item()
|
||||
child = _item(_CHILD_TX_HEX)
|
||||
will = {_PARENT_ID: parent, child._id: child}
|
||||
Will.add_willtree(will)
|
||||
assert child.father == _PARENT_ID
|
||||
assert [c[0] for c in parent.children] == [child._id]
|
||||
assert parent.father is None
|
||||
|
||||
|
||||
def test_stale_father_is_replaced_by_the_real_parent():
|
||||
parent = _item()
|
||||
child = _item(_CHILD_TX_HEX, father=_MISSING_ID)
|
||||
will = {_PARENT_ID: parent, child._id: child}
|
||||
Will.add_willtree(will)
|
||||
assert child.father == _PARENT_ID
|
||||
|
||||
|
||||
def test_child_of_unconfirmed_parent_is_not_checked_against_utxos():
|
||||
"""Existing rule kept: a child of a VALID (not mined) parent is skipped."""
|
||||
parent = _item()
|
||||
child = _item(_CHILD_TX_HEX)
|
||||
will = {_PARENT_ID: parent, child._id: child}
|
||||
Will.add_willtree(will)
|
||||
wallet = MagicMock()
|
||||
# Funding not spent on-chain: nothing gets invalidated, we only observe
|
||||
# which items had their inputs checked.
|
||||
with patch.object(Will, "check_tx_height", return_value=-2) as height, \
|
||||
patch.object(Will, "_funding_really_spent", return_value=False):
|
||||
Will.check_invalidated({child._id: child, _PARENT_ID: parent}, [], wallet)
|
||||
# Only the parent (a root) looked up its height; the child was skipped.
|
||||
checked = [call.args[0] for call in height.call_args_list]
|
||||
assert checked == [parent.tx]
|
||||
|
||||
|
||||
def test_set_invalidate_cascades_to_children_built_by_add_willtree():
|
||||
"""add_willtree builds children as a LIST: invalidation must cascade."""
|
||||
parent = _item()
|
||||
child = _item(_CHILD_TX_HEX)
|
||||
will = {parent._id: parent, child._id: child}
|
||||
Will.add_willtree(will)
|
||||
Will.set_invalidate(parent._id, will)
|
||||
assert parent.get_status("INVALIDATED") is True
|
||||
assert child.get_status("INVALIDATED") is True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All will-tree father tests passed")
|
||||
@@ -15,7 +15,7 @@ pure policy in ``bal.core.checkalive``:
|
||||
|
||||
* ``resolve_date_to_check(is_basic_mode, will_settings)`` - the single
|
||||
reference timestamp: "now" in BASIC, the stored threshold in ADVANCED;
|
||||
* ``check_alive_expired(is_basic_mode, date_to_check)`` - whether the
|
||||
* ``ensure_check_alive_valid(is_basic_mode, ...)`` - whether the
|
||||
Check-Alive guard should fire (never in BASIC).
|
||||
|
||||
These tests exercise the exact code used at runtime (no GUI/Electrum wallet
|
||||
@@ -33,7 +33,8 @@ sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||
|
||||
from bal.core.checkalive import ( # noqa: E402 (path insert above)
|
||||
CheckAliveError,
|
||||
check_alive_expired,
|
||||
ensure_check_alive_valid,
|
||||
is_check_alive_date_valid,
|
||||
resolve_date_to_check,
|
||||
)
|
||||
|
||||
@@ -72,23 +73,29 @@ def test_advanced_mode_parses_relative_threshold():
|
||||
def test_basic_mode_never_expired():
|
||||
"""BASIC mode can never be "expired": a passed check-alive date must never
|
||||
force a postpone/rewrite of the will."""
|
||||
assert check_alive_expired(True, time.time() - 10_000) is False
|
||||
assert check_alive_expired(True, 1_000_000_000.0) is False
|
||||
assert ensure_check_alive_valid(True, time.time() - 10_000) is None
|
||||
assert ensure_check_alive_valid(True, 1_000_000_000.0) is None
|
||||
|
||||
|
||||
def test_advanced_mode_expired_when_past():
|
||||
assert check_alive_expired(False, time.time() - 10_000) is True
|
||||
assert is_check_alive_date_valid(time.time() - 10_000, None) is False
|
||||
try:
|
||||
ensure_check_alive_valid(False, time.time() - 10_000)
|
||||
except CheckAliveError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected CheckAliveError")
|
||||
|
||||
|
||||
def test_advanced_mode_not_expired_when_future():
|
||||
assert check_alive_expired(False, time.time() + 10_000) is False
|
||||
assert is_check_alive_date_valid(time.time() + 10_000, None) is True
|
||||
|
||||
|
||||
def test_basic_mode_never_raises_check_alive_error():
|
||||
"""Regression guard for the reported bug: whatever the delivery date,
|
||||
BASIC mode never raises CheckAliveError (the postpone/invalidate trigger)."""
|
||||
date_to_check = resolve_date_to_check(True, {"threshold": "30d"})
|
||||
assert not check_alive_expired(True, date_to_check)
|
||||
date_to_check = resolve_date_to_check(True, {"threshold": "-30d"})
|
||||
assert ensure_check_alive_valid(True, date_to_check) is None
|
||||
# If it did fire, this is the exact exception that would be raised.
|
||||
assert issubclass(CheckAliveError, Exception)
|
||||
|
||||
|
||||
@@ -52,9 +52,13 @@ class _Cfg:
|
||||
def get(self):
|
||||
return self._value
|
||||
|
||||
def set(self, value, save=True):
|
||||
self._value = value
|
||||
|
||||
|
||||
class FakePlugin:
|
||||
QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||
def __init__(self):
|
||||
self.QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||
|
||||
def read_file(self, path):
|
||||
return _PNG_BYTES
|
||||
@@ -301,6 +305,7 @@ 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())
|
||||
@@ -334,6 +339,7 @@ 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()
|
||||
@@ -350,6 +356,7 @@ 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()
|
||||
|
||||
|
||||
@@ -357,6 +364,7 @@ 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()
|
||||
|
||||
@@ -4,9 +4,10 @@ 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 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 QR transport now uses
|
||||
only the BBQR wire format. The wizard and the camera/audio paths need a live
|
||||
wallet/hardware and are exercised only through the shared frame-assembly
|
||||
path here.
|
||||
|
||||
Run:
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py
|
||||
@@ -14,6 +15,7 @@ Run:
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -23,7 +25,8 @@ from electrum.transaction import Transaction
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||
|
||||
import bal.gui.qt.dialogs as dialogs
|
||||
from bal.core.qrtransfer import CHUNK_PRESETS, encode_transfer, split_frames
|
||||
from bal.core.animated_qr import bbqr_frames
|
||||
from bal.core.qrtransfer import CHUNK_PRESETS, encode_transfer
|
||||
from bal.core.will import WillItem
|
||||
|
||||
_app = QApplication.instance() or QApplication(sys.argv)
|
||||
@@ -46,6 +49,15 @@ _VALID_TX_HEX = (
|
||||
)
|
||||
|
||||
|
||||
def _rand_hex(n):
|
||||
"""Incompressible lowercase hex string of ``n`` random bytes."""
|
||||
return os.urandom(n).hex()
|
||||
|
||||
|
||||
def _bbqr_frames(transfer, budget):
|
||||
return bbqr_frames(transfer.encode("utf-8"), budget, encoding="Z")
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
@@ -53,10 +65,14 @@ 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.
|
||||
QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||
def __init__(self):
|
||||
self.QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||
|
||||
def read_file(self, path):
|
||||
return _PNG_BYTES
|
||||
@@ -109,7 +125,7 @@ class StubWillItem:
|
||||
|
||||
def _make_willitems(n=3, payload_len=120, payloads=None):
|
||||
if payloads is None:
|
||||
payloads = ["T{}".format(i) * payload_len for i in range(n)]
|
||||
payloads = [_rand_hex(payload_len) for _ in range(n)]
|
||||
return {
|
||||
"item{}".format(i): StubWillItem(p)
|
||||
for i, p in enumerate(payloads)
|
||||
@@ -129,8 +145,9 @@ def test_export_dialog_builds():
|
||||
assert page.tx_strings
|
||||
assert page.frames
|
||||
assert len(page.frames) >= 1
|
||||
# Frame 1 is shown.
|
||||
# Frame 1 is shown, and it is a BBQR frame.
|
||||
assert page.qr_view.text == page.frames[0]
|
||||
assert page.frames[0].startswith("B$")
|
||||
assert "1" in page.progress_label.text()
|
||||
d.close()
|
||||
|
||||
@@ -196,6 +213,7 @@ 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)
|
||||
@@ -222,6 +240,7 @@ 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)
|
||||
@@ -244,9 +263,9 @@ def test_export_auto_scroll_loop():
|
||||
|
||||
def test_export_filter_valid_and_valid_nc():
|
||||
bw = FakeBalWindow()
|
||||
a = StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True})
|
||||
b = StubWillItem("B" * 120, statuses={"VALID": True})
|
||||
c = StubWillItem("C" * 120)
|
||||
a = StubWillItem(_rand_hex(120), statuses={"VALID": True, "COMPLETE": True})
|
||||
b = StubWillItem(_rand_hex(120), statuses={"VALID": True})
|
||||
c = StubWillItem(_rand_hex(120))
|
||||
bw.willitems = {"a": a, "b": b, "c": c}
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
@@ -263,11 +282,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) == ["A" * 120, "B" * 120]
|
||||
assert sorted(page.tx_strings) == sorted([str(a.tx), str(b.tx)])
|
||||
|
||||
# "Valid NC" filter -> only the valid, not-complete item (b).
|
||||
d._on_filter_change(2)
|
||||
assert sorted(page.tx_strings) == ["B" * 120]
|
||||
assert sorted(page.tx_strings) == [str(b.tx)]
|
||||
assert page.qr_view.text == page.frames[0]
|
||||
d.close()
|
||||
|
||||
@@ -276,7 +295,7 @@ def test_export_filter_empty_reverts():
|
||||
bw = FakeBalWindow()
|
||||
# Only a COMPLETE valid item: "Valid NC" selects nothing -> revert.
|
||||
bw.willitems = {
|
||||
"a": StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True})
|
||||
"a": StubWillItem(_rand_hex(120), statuses={"VALID": True, "COMPLETE": True})
|
||||
}
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
messages = []
|
||||
@@ -290,17 +309,11 @@ def test_export_filter_empty_reverts():
|
||||
|
||||
|
||||
def test_export_navigation_and_chunk_change():
|
||||
# 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))
|
||||
|
||||
# Incompressible serialized transactions resist deflate, so the BBQR
|
||||
# transfer still needs several frames at the default chunk and navigation
|
||||
# across frames is exercised.
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(
|
||||
n=6, payload_len=400,
|
||||
payloads=["{}0{}".format(noisy(i), "T" * 50) for i in range(6)],
|
||||
)
|
||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
first_count = len(page.frames)
|
||||
@@ -322,6 +335,20 @@ 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)
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -333,8 +360,8 @@ def test_import_frame_flow():
|
||||
assert not page.review_btn.isEnabled()
|
||||
assert not page.slot_area.isVisible()
|
||||
|
||||
transfer = encode_transfer(["A" * 120, "B" * 120, "C" * 120])
|
||||
frames = split_frames(transfer, 150)
|
||||
transfer = encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)])
|
||||
frames = _bbqr_frames(transfer, 150)
|
||||
assert len(frames) > 1
|
||||
|
||||
for frame in frames:
|
||||
@@ -361,12 +388,14 @@ def test_import_assembles_and_decodes():
|
||||
captured["payload"] = payload
|
||||
|
||||
page._finish_import = fake_finish
|
||||
transfer = encode_transfer(["P" * 130, "Q" * 130])
|
||||
for frame in split_frames(transfer, 150):
|
||||
tx_a = _rand_hex(130)
|
||||
tx_b = _rand_hex(130)
|
||||
transfer = encode_transfer([tx_a, tx_b])
|
||||
for frame in _bbqr_frames(transfer, 150):
|
||||
page._add_frame(frame)
|
||||
page._review_and_sign()
|
||||
# The payload is rejoined into an opaque string for autodetect.
|
||||
assert captured["payload"].split("\n") == ["P" * 130, "Q" * 130]
|
||||
assert captured["payload"].split("\n") == [tx_a, tx_b]
|
||||
d.close()
|
||||
|
||||
|
||||
@@ -407,7 +436,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 split_frames(transfer, dialogs.CHUNK_PRESETS[0][1]):
|
||||
for frame in _bbqr_frames(transfer, dialogs.CHUNK_PRESETS[0][1]):
|
||||
import_page._add_frame(frame)
|
||||
assert import_page.review_btn.isEnabled()
|
||||
caught = {}
|
||||
@@ -431,8 +460,10 @@ def test_import_total_mismatch_resets():
|
||||
warnings = []
|
||||
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
|
||||
|
||||
frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150)
|
||||
frames_a = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||
frames_b = _bbqr_frames(
|
||||
encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)]), 150
|
||||
)
|
||||
for frame in frames_a:
|
||||
page._add_frame(frame)
|
||||
assert page.total == len(frames_a)
|
||||
@@ -451,7 +482,7 @@ def test_import_manual_entry():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
frames = split_frames(encode_transfer(["M" * 120]), 150)
|
||||
frames = _bbqr_frames(encode_transfer(["M" * 20]), 150)
|
||||
page.manual_edit.setText(frames[0])
|
||||
page._add_from_manual()
|
||||
assert page.manual_edit.text() == ""
|
||||
@@ -477,48 +508,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, "balqr", "balqr:2", 2, 1, "PAYLOAD1") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "PAYLOAD1") == "pending"
|
||||
assert s["pending_count"] == 1
|
||||
assert s["last_index"] is None
|
||||
# A second stable read of the same identity: accepted.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "PAYLOAD1") == "accept"
|
||||
assert s["last_index"] == 1
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "PAYLOAD1") == "accept"
|
||||
assert s["last_index"] == 0
|
||||
assert s["last_payload"] == "PAYLOAD1"
|
||||
assert s["pending_count"] == 0
|
||||
|
||||
|
||||
def test_qr_import_debounce_re_reading_last_is_ignored():
|
||||
s = _fresh_debounce()
|
||||
dialogs.qr_import_accept_frame(s, "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
|
||||
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1")
|
||||
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") # accepted
|
||||
# The exporter is still showing frame 0: must be ignored, not accepted.
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") == "ignore"
|
||||
assert s["last_index"] == 0
|
||||
assert s["pending_count"] == 0
|
||||
|
||||
|
||||
def test_qr_import_debounce_transition_pending_resets():
|
||||
s = _fresh_debounce()
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1")
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") # accept frame 1
|
||||
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1")
|
||||
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") # accept frame 0
|
||||
# A new identity interrupts the pending accumulation.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "accept"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "accept"
|
||||
# Same-index duplicate with different payload is treated as new identity.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "ignore"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "ignore"
|
||||
|
||||
|
||||
def test_qr_import_debounce_total_mismatch_resets():
|
||||
s = _fresh_debounce()
|
||||
# In-range frame is accepted even though its declared total is ignored.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:3", 3, 2, "P2") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:3", 3, 2, "P2") == "accept"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:3", 3, 1, "P2") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:3", 3, 1, "P2") == "accept"
|
||||
# A frame that belongs to a different transfer.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:4", 4, 2, "P2B") == "reset"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:4", 4, 1, "P2B") == "reset"
|
||||
# Once the policy is rebased on the new transfer, frames resume normally
|
||||
# (the widget clears the debounce while wiping the import).
|
||||
s["key"] = None
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:4", 4, 2, "P2B") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:4", 4, 1, "P2B") == "pending"
|
||||
|
||||
|
||||
def test_qr_import_handle_scanned_text_autofinish():
|
||||
@@ -528,7 +559,7 @@ def test_qr_import_handle_scanned_text_autofinish():
|
||||
reviewed = []
|
||||
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||
|
||||
frames = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
frames = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||
assert len(frames) > 1
|
||||
|
||||
# The camera session is running and every frame needs two stable reads.
|
||||
@@ -557,7 +588,7 @@ def test_qr_import_handle_scanned_text_manual_does_not_autofinish():
|
||||
reviewed = []
|
||||
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||
|
||||
frames = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
frames = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||
assert len(frames) > 1
|
||||
assert not page._scanning
|
||||
for frame in frames:
|
||||
@@ -570,54 +601,16 @@ def test_qr_import_handle_scanned_text_manual_does_not_autofinish():
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Animated-QR formats (BC-UR v1/v2, BBQR) via the export/import pages
|
||||
# BBQR import/export round trips
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_export_format_combo_switches_codecs():
|
||||
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):
|
||||
def test_import_bbqr_roundtrip():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(2)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
page._on_format_change(fmt_index)
|
||||
frames = list(page.frames)
|
||||
assert frames
|
||||
assert frames and all(f.startswith("B$") for f in frames)
|
||||
d.close()
|
||||
|
||||
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
@@ -634,24 +627,11 @@ def _import_roundtrip_fmt(fmt_index):
|
||||
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()
|
||||
|
||||
@@ -664,11 +644,8 @@ def test_import_animated_scan_debounce_autofinish():
|
||||
for _rep in range(2):
|
||||
import_page._handle_scanned_text(frame)
|
||||
assert import_page.review_btn.isEnabled()
|
||||
# The fountain transfer's part count is ``len(frames) // 2`` (pure + one
|
||||
# redundant mixed wave).
|
||||
assert import_page.total == len(frames) // 2
|
||||
assert import_page.total == len(frames)
|
||||
assert len(import_page.frames) >= import_page.total
|
||||
assert import_page.review_btn.isEnabled()
|
||||
_app.processEvents()
|
||||
assert reviewed == [True]
|
||||
assert not import_page._scanning
|
||||
@@ -679,7 +656,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", ""):
|
||||
for garbage in ("hello world", "12345", "B$ZZ", "ur:bytes/xyz", ""):
|
||||
page._handle_scanned_text(garbage)
|
||||
assert not page.frames
|
||||
assert page.total == 0
|
||||
@@ -694,8 +671,10 @@ def test_import_different_animated_transfer_resets():
|
||||
warnings = []
|
||||
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
|
||||
|
||||
frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150)
|
||||
frames_a = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||
frames_b = _bbqr_frames(
|
||||
encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)]), 150
|
||||
)
|
||||
for frame in frames_a:
|
||||
page._add_frame(frame)
|
||||
assert page.total == len(frames_a)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -92,6 +92,31 @@ 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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
309
tests/test_i18n.py
Normal file
309
tests/test_i18n.py
Normal file
@@ -0,0 +1,309 @@
|
||||
"""Tests for bal/i18n.py, BAL's translation layer (PLAN_I18N.md, phase 1).
|
||||
|
||||
The BAL catalogs are built in memory (see ``_mo_bytes``), so these tests need
|
||||
no compiled .mo file and no Babel. Electrum's translator is replaced by a
|
||||
small dictionary, so the tests do not depend on Electrum's own catalogs.
|
||||
|
||||
Run standalone (``python3 tests/test_i18n.py``) or with pytest.
|
||||
"""
|
||||
|
||||
import array
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal import i18n # noqa: E402
|
||||
|
||||
IT_MO = "locale/it_IT/LC_MESSAGES/bal.mo"
|
||||
|
||||
|
||||
def _mo_bytes(messages):
|
||||
"""Return a GNU .mo catalog (UTF-8) holding ``messages`` {msgid: msgstr}.
|
||||
|
||||
Same layout as CPython's Tools/i18n/msgfmt.py: header, the sorted msgid
|
||||
and msgstr index tables, then the NUL-terminated strings.
|
||||
"""
|
||||
entries = {"": "Content-Type: text/plain; charset=UTF-8\n"}
|
||||
entries.update(messages)
|
||||
keys = sorted(entries)
|
||||
ids = strs = b""
|
||||
offsets = []
|
||||
for key in keys:
|
||||
msgid, msgstr = key.encode("utf-8"), entries[key].encode("utf-8")
|
||||
offsets.append((len(ids), len(msgid), len(strs), len(msgstr)))
|
||||
ids += msgid + b"\0"
|
||||
strs += msgstr + b"\0"
|
||||
keystart = 7 * 4 + 16 * len(keys)
|
||||
valuestart = keystart + len(ids)
|
||||
koffsets, voffsets = [], []
|
||||
for o1, l1, o2, l2 in offsets:
|
||||
koffsets += [l1, o1 + keystart]
|
||||
voffsets += [l2, o2 + valuestart]
|
||||
header = struct.pack(
|
||||
"Iiiiiii", 0x950412DE, 0, len(keys), 7 * 4, 7 * 4 + len(keys) * 8, 0, 0
|
||||
)
|
||||
return header + array.array("i", koffsets + voffsets).tobytes() + ids + strs
|
||||
|
||||
|
||||
class FakePlugin:
|
||||
"""Stands in for Electrum's BasePlugin: serves read_file() from a dict."""
|
||||
|
||||
def __init__(self, files=None):
|
||||
self.files = files or {}
|
||||
self.requested = []
|
||||
|
||||
def read_file(self, filename):
|
||||
self.requested.append(filename)
|
||||
if filename not in self.files:
|
||||
raise KeyError(filename) # what a missing entry in the zip raises
|
||||
return self.files[filename]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _i18n_state(electrum=None, files=None, lang=None):
|
||||
"""Fake Electrum's translations, load a fake BAL catalog, restore after.
|
||||
|
||||
Restoring matters: bal.i18n keeps module-level state, shared with every
|
||||
other test of the same pytest run.
|
||||
"""
|
||||
saved = (i18n._electrum_gettext, i18n._catalog)
|
||||
table = electrum or {}
|
||||
i18n._electrum_gettext = lambda msg: table.get(msg, msg)
|
||||
plugin = FakePlugin(files)
|
||||
try:
|
||||
i18n.set_language(plugin, lang)
|
||||
yield plugin
|
||||
finally:
|
||||
i18n._electrum_gettext, i18n._catalog = saved
|
||||
i18n._rejected.clear()
|
||||
|
||||
|
||||
def test_empty_string_is_never_translated():
|
||||
# For gettext "" is the key of the catalog header.
|
||||
with _i18n_state(files={IT_MO: _mo_bytes({})}, lang="it_IT"):
|
||||
assert i18n._catalog is not None
|
||||
assert i18n._("") == ""
|
||||
|
||||
|
||||
def test_electrum_translation_wins_over_bal_catalog():
|
||||
catalog = _mo_bytes({"Cancel": "Cancella", "Heirs": "Eredi"})
|
||||
with _i18n_state(
|
||||
electrum={"Cancel": "Annulla"}, files={IT_MO: catalog}, lang="it_IT"
|
||||
):
|
||||
assert i18n._("Cancel") == "Annulla" # Electrum has it: Electrum wins
|
||||
assert i18n._("Heirs") == "Eredi" # only BAL has it
|
||||
|
||||
|
||||
def test_english_source_when_nobody_translates():
|
||||
with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang="it_IT"):
|
||||
assert i18n._("Not in any catalog") == "Not in any catalog"
|
||||
|
||||
|
||||
def test_translation_with_different_fields_is_rejected():
|
||||
catalog = _mo_bytes(
|
||||
{
|
||||
"{} heirs": "eredi", # field dropped: rejected
|
||||
"Fee: {}": "Commissione: {}",
|
||||
"{0} of {1}": "{1} di {0}", # re-ordered: allowed
|
||||
"Hello {name}": "Ciao {nome}", # field renamed: rejected
|
||||
}
|
||||
)
|
||||
with _i18n_state(files={IT_MO: catalog}, lang="it_IT"):
|
||||
assert i18n._("{} heirs") == "{} heirs"
|
||||
assert i18n._("Fee: {}") == "Commissione: {}"
|
||||
assert i18n._("{0} of {1}") == "{1} di {0}"
|
||||
assert i18n._("Hello {name}") == "Hello {name}"
|
||||
|
||||
|
||||
def test_keeps_format_fields():
|
||||
assert i18n.keeps_format_fields("Amount: {}", "Importo: {}")
|
||||
assert not i18n.keeps_format_fields("Amount: {}", "Importo")
|
||||
assert not i18n.keeps_format_fields("Amount: {}", "Importo: {} {}")
|
||||
assert not i18n.keeps_format_fields("Amount: {}", "Importo: {") # malformed
|
||||
assert i18n.keeps_format_fields("$wallet_name", "$wallet_name")
|
||||
|
||||
|
||||
def test_english_loads_no_catalog():
|
||||
with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang="en_UK") as p:
|
||||
assert i18n._catalog is None
|
||||
assert p.requested == []
|
||||
assert i18n._("Heirs") == "Heirs"
|
||||
|
||||
|
||||
def test_no_language_loads_no_catalog():
|
||||
for lang in (None, ""):
|
||||
with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang=lang):
|
||||
assert i18n._catalog is None
|
||||
|
||||
|
||||
def test_missing_catalog_falls_back_to_english():
|
||||
with _i18n_state(files={}, lang="it_IT") as plugin:
|
||||
assert i18n._catalog is None
|
||||
assert plugin.requested == [IT_MO, "locale/it/LC_MESSAGES/bal.mo"]
|
||||
assert i18n._("Heirs") == "Heirs"
|
||||
|
||||
|
||||
def test_broken_catalog_falls_back_to_english():
|
||||
with _i18n_state(files={IT_MO: b"this is not a catalog"}, lang="it_IT"):
|
||||
assert i18n._catalog is None
|
||||
assert i18n._("Heirs") == "Heirs"
|
||||
|
||||
|
||||
def test_language_code_without_country_is_tried():
|
||||
files = {"locale/it/LC_MESSAGES/bal.mo": _mo_bytes({"Heirs": "Eredi"})}
|
||||
with _i18n_state(files=files, lang="it_IT"):
|
||||
assert i18n._("Heirs") == "Eredi"
|
||||
|
||||
|
||||
def test_n_marks_without_translating():
|
||||
with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang="it_IT"):
|
||||
assert i18n.N_("Heirs") == "Heirs"
|
||||
assert i18n._(i18n.N_("Heirs")) == "Eredi"
|
||||
|
||||
|
||||
def test_init_from_config_uses_electrum_language_setting():
|
||||
config = SimpleNamespace(LOCALIZATION_LANGUAGE="it_IT")
|
||||
plugin = FakePlugin({IT_MO: _mo_bytes({"Heirs": "Eredi"})})
|
||||
with _i18n_state():
|
||||
i18n.init_from_config(plugin, config)
|
||||
assert i18n._("Heirs") == "Eredi"
|
||||
|
||||
|
||||
def test_init_from_config_without_setting_uses_system_language():
|
||||
import electrum.gui.default_lang as default_lang
|
||||
|
||||
saved = default_lang.get_default_language
|
||||
default_lang.get_default_language = lambda gui_name=None: "it_IT"
|
||||
config = SimpleNamespace(LOCALIZATION_LANGUAGE="")
|
||||
plugin = FakePlugin({IT_MO: _mo_bytes({"Heirs": "Eredi"})})
|
||||
try:
|
||||
with _i18n_state():
|
||||
i18n.init_from_config(plugin, config)
|
||||
assert i18n._("Heirs") == "Eredi"
|
||||
finally:
|
||||
default_lang.get_default_language = saved
|
||||
|
||||
|
||||
def test_init_from_config_never_raises():
|
||||
plugin = FakePlugin({IT_MO: _mo_bytes({"Heirs": "Eredi"})})
|
||||
with _i18n_state():
|
||||
i18n.init_from_config(plugin, object()) # config without the setting
|
||||
assert i18n._catalog is None
|
||||
|
||||
|
||||
# --- Stored data stays language-neutral (PLAN_I18N.md, phase 2) -------------
|
||||
|
||||
|
||||
def test_status_history_is_stored_in_english():
|
||||
from bal.core.will import WillItem
|
||||
|
||||
catalog = _mo_bytes({"Signed": "Firmato", "Valid": "Valida"})
|
||||
with _i18n_state(files={IT_MO: catalog}, lang="it_IT"):
|
||||
# A bare item: set_status() only needs its status fields.
|
||||
item = WillItem.__new__(WillItem)
|
||||
item.status = ""
|
||||
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
item.set_status("COMPLETE", True)
|
||||
item.set_status("VALID", False)
|
||||
assert item.status == ".Signed.NOT Valid"
|
||||
|
||||
|
||||
def test_status_history_is_translated_for_display():
|
||||
from bal.core.will import format_status_history
|
||||
|
||||
catalog = _mo_bytes(
|
||||
{
|
||||
"Signed": "Firmato",
|
||||
"Valid": "Valida",
|
||||
"NOT {}": "NON {}",
|
||||
"New": "Nuovo",
|
||||
"Replaced": "Sostituita",
|
||||
}
|
||||
)
|
||||
with _i18n_state(files={IT_MO: catalog}, lang="it_IT"):
|
||||
assert format_status_history(".Signed.NOT Valid") == ".Firmato.NON Valida"
|
||||
# A real history saved by an older version (tests/samanta7), which
|
||||
# stored some labels already translated: known English labels are
|
||||
# translated, the other tokens are kept as they are.
|
||||
assert (
|
||||
format_status_history("New.Firmato.Pushed.Checked.Replaced")
|
||||
== "Nuovo.Firmato.Pushed.Checked.Sostituita"
|
||||
)
|
||||
assert format_status_history(".SignedERROR!!!") == ".FirmatoERROR!!!"
|
||||
with _i18n_state(): # English GUI: shown exactly as stored
|
||||
assert format_status_history("New.Firmato.Pushed") == "New.Firmato.Pushed"
|
||||
|
||||
|
||||
class _FakeConfig(dict):
|
||||
"""Minimal stand-in for Electrum's SimpleConfig (get / set_key)."""
|
||||
|
||||
def get(self, key, default=None):
|
||||
return dict.get(self, key, default)
|
||||
|
||||
def set_key(self, key, value, save=True):
|
||||
self[key] = value
|
||||
|
||||
|
||||
def test_translatable_config_default_follows_language():
|
||||
from bal.core.plugin_base import BalConfig
|
||||
|
||||
config = _FakeConfig()
|
||||
summary = BalConfig(config, "summary", "Will of $wallet_name", translatable=True)
|
||||
plain = BalConfig(config, "label", "BAL label")
|
||||
catalog = _mo_bytes(
|
||||
{"Will of $wallet_name": "Testamento di $wallet_name", "BAL label": "X"}
|
||||
)
|
||||
with _i18n_state(files={IT_MO: catalog}, lang="it_IT"):
|
||||
assert summary.get() == "Testamento di $wallet_name" # nothing stored
|
||||
assert summary.localized_default() == "Testamento di $wallet_name"
|
||||
summary.set("Testamento di $wallet_name") # e.g. the reset button
|
||||
assert config["summary"] == "Will of $wallet_name" # stored in English
|
||||
assert summary.get() == "Testamento di $wallet_name"
|
||||
summary.set("My own text") # the user's text is kept as it is
|
||||
assert summary.get() == "My own text"
|
||||
assert plain.get() == "BAL label" # not translatable: never translated
|
||||
with _i18n_state():
|
||||
summary.set("Will of $wallet_name")
|
||||
assert summary.get() == "Will of $wallet_name"
|
||||
|
||||
|
||||
def test_calendar_reminder_suffix_is_translatable():
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from bal.core.reminders import build_ics_reminders
|
||||
|
||||
now = datetime(2026, 1, 1)
|
||||
kwargs = dict(
|
||||
locktime=now + timedelta(days=400), basic_mode=True, description="d",
|
||||
summary="s", wallet_name="w", heirs_details="", version="0", now=now,
|
||||
)
|
||||
assert "s (reminder 1/" in build_ics_reminders(**kwargs) # CLI default
|
||||
ics = build_ics_reminders(reminder_suffix="(promemoria {idx}/{total})", **kwargs)
|
||||
assert "s (promemoria 1/" in ics
|
||||
|
||||
|
||||
def test_qr_preset_labels_match_chunk_presets():
|
||||
# The labels are marked for translation in common.py because
|
||||
# qrtransfer.py must stay free of Electrum imports (Android copy).
|
||||
from bal.core.qrtransfer import CHUNK_PRESETS
|
||||
from bal.gui.qt.common import QR_PRESET_LABELS
|
||||
|
||||
assert tuple(label for label, _budget in CHUNK_PRESETS) == QR_PRESET_LABELS
|
||||
|
||||
|
||||
def test_real_translator_is_electrums():
|
||||
# Outside the fakes above, BAL asks Electrum's real translator first.
|
||||
from electrum.i18n import _ as electrum_gettext
|
||||
|
||||
assert i18n._electrum_gettext is electrum_gettext
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||
for test in tests:
|
||||
test()
|
||||
print(f"[OK] All {len(tests)} i18n tests passed")
|
||||
135
tests/test_translations.py
Normal file
135
tests/test_translations.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Safety checks on BAL's translation catalogs (PLAN_I18N.md, section 4.4).
|
||||
|
||||
Runs on every ``bal/locale/<lang>/LC_MESSAGES/bal.po`` (the sources that
|
||||
build_zip.py compiles into the zip). A translation is shown to the user as
|
||||
if BAL wrote it, so a wrong or malicious one must fail here, before a zip is
|
||||
built:
|
||||
|
||||
* the catalog must parse and compile;
|
||||
* the ``{}`` replacement fields must match the English text (the same rule
|
||||
bal.i18n applies at run time), and so must the ``$tokens`` of the calendar
|
||||
texts (``$wallet_name``, ``$heirs_complete``), which Electrum does not check;
|
||||
* no Bitcoin address, e-mail address, URL or long letters-and-digits word may
|
||||
appear in a translation unless the English text has the same one: the
|
||||
patterns are copied from Electrum's ``electrum-locale/update.py`` (MIT
|
||||
licence, Copyright (C) The Electrum developers), which rejects translations
|
||||
that try to slip in an address or a link.
|
||||
|
||||
Run standalone (``python3 tests/test_translations.py``, prints a per-language
|
||||
summary) or with pytest. Needs Babel, like build_zip.py.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from babel.messages.mofile import write_mo # noqa: E402
|
||||
from babel.messages.pofile import read_po # noqa: E402
|
||||
|
||||
from bal.i18n import keeps_format_fields # noqa: E402
|
||||
|
||||
LOCALE_DIR = Path(__file__).resolve().parent.parent / "bal" / "locale"
|
||||
|
||||
# From electrum-locale/update.py (see the module docstring).
|
||||
SUSPICIOUS = {
|
||||
"Bitcoin address": re.compile("([13]|bc1)[a-zA-Z0-9]{30,}"),
|
||||
"e-mail address": re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"),
|
||||
"URL": re.compile(r"\S+\.\S*\w+\S*/\S+"),
|
||||
"URL scheme": re.compile(r"http(s){0,1}://"),
|
||||
"letters-and-digits word": re.compile(
|
||||
r"(?a)(?=\w{16,})((\w*[a-zA-Z]\w*[0-9]\w*)|(\w*[0-9]\w*[a-zA-Z]\w*))"
|
||||
),
|
||||
}
|
||||
TOKEN = re.compile(r"\$\w+")
|
||||
|
||||
|
||||
def _catalogs():
|
||||
return sorted(LOCALE_DIR.glob("*/LC_MESSAGES/bal.po"))
|
||||
|
||||
|
||||
def _messages(po_path):
|
||||
"""Yield the translated, non-fuzzy (msgid, msgstr) pairs of a catalog."""
|
||||
with open(po_path, "rb") as f:
|
||||
catalog = read_po(f)
|
||||
for m in catalog:
|
||||
if m.id and isinstance(m.id, str) and m.string and not m.fuzzy:
|
||||
yield m.id, m.string
|
||||
|
||||
|
||||
def check_catalog(po_path):
|
||||
"""Return a list of problems found in one catalog (empty = fine)."""
|
||||
problems = []
|
||||
for msgid, msgstr in _messages(po_path):
|
||||
if not keeps_format_fields(msgid, msgstr):
|
||||
problems.append("{} fields differ: {!r}".format("{}", msgid))
|
||||
if sorted(TOKEN.findall(msgid)) != sorted(TOKEN.findall(msgstr)):
|
||||
problems.append("$tokens differ: {!r}".format(msgid))
|
||||
for name, regex in SUSPICIOUS.items():
|
||||
for match in regex.finditer(msgstr):
|
||||
if match.group(0) not in msgid:
|
||||
problems.append(
|
||||
"{} {!r} not in the English text: {!r}".format(
|
||||
name, match.group(0), msgid
|
||||
)
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def summary(po_path):
|
||||
"""Return (translated, untranslated, fuzzy) counts of a catalog."""
|
||||
with open(po_path, "rb") as f:
|
||||
catalog = read_po(f)
|
||||
msgs = [m for m in catalog if m.id]
|
||||
fuzzy = sum(1 for m in msgs if m.fuzzy)
|
||||
translated = sum(1 for m in msgs if m.string and not m.fuzzy)
|
||||
return translated, len(msgs) - translated - fuzzy, fuzzy
|
||||
|
||||
|
||||
def test_there_is_an_italian_catalog():
|
||||
assert LOCALE_DIR / "it_IT" / "LC_MESSAGES" / "bal.po" in _catalogs()
|
||||
|
||||
|
||||
def test_catalogs_compile():
|
||||
for po in _catalogs():
|
||||
with open(po, "rb") as f:
|
||||
catalog = read_po(f)
|
||||
buf = io.BytesIO()
|
||||
write_mo(buf, catalog)
|
||||
assert buf.getvalue(), po
|
||||
|
||||
|
||||
def test_translations_are_safe():
|
||||
problems = [
|
||||
"{}: {}".format(po.parent.parent.name, p)
|
||||
for po in _catalogs()
|
||||
for p in check_catalog(po)
|
||||
]
|
||||
assert not problems, "\n".join(problems)
|
||||
|
||||
|
||||
def test_suspicious_patterns_are_detected():
|
||||
# A translation that adds an address or a link must be caught.
|
||||
regexes = SUSPICIOUS.values()
|
||||
assert any(r.search("invia a bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq") for r in regexes)
|
||||
assert any(r.search("scarica da https://example.com") for r in regexes)
|
||||
assert any(r.search("scrivi a truffa@example.com") for r in regexes)
|
||||
assert not any(r.search("Firma il testamento") for r in regexes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_there_is_an_italian_catalog()
|
||||
test_catalogs_compile()
|
||||
test_suspicious_patterns_are_detected()
|
||||
for po in _catalogs():
|
||||
translated, untranslated, fuzzy = summary(po)
|
||||
print(
|
||||
"{}: {} translated, {} untranslated, {} fuzzy".format(
|
||||
po.parent.parent.name, translated, untranslated, fuzzy
|
||||
)
|
||||
)
|
||||
test_translations_are_safe()
|
||||
print("[OK] translation catalogs are safe")
|
||||
Reference in New Issue
Block a user