2 Commits

Author SHA1 Message Date
4a33116b66 fix: stop infinite RecursionError in will wizard (Date editor timezone roundtrip)
LockTimeDateEdit stored the aware-UTC datetime from _safe_fromtimestamp
into the QDateTimeEdit, which keeps the wall time with a LocalTime spec.
get_value() therefore read back x + utc_offset, so set_value(x) never
equalized x and the valueEdited -> update_setting_widgets -> set_value
signal cycle fired forever, crashing with RecursionError when clicking
Next in the 'Build your will' wizard. Store the local wall-clock time
instead (alarm stays aware-UTC for the .ics export).
2026-09-19 17:42:11 -04:00
fb88d7540c feat: animated-QR transfer, Android reader, relative-locktime preservation, karen7 hermetic tests 2026-09-14 09:11:50 -04:00
64 changed files with 5170 additions and 610 deletions

View File

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

View File

@@ -3,6 +3,9 @@
BAL — Bitcoin After Life, an Electrum plugin (inheritance / dead-man's-switch). BAL — Bitcoin After Life, an Electrum plugin (inheritance / dead-man's-switch).
Source-of-truth docs: `README.md`, `HANDOFF.md`, `COMPATIBILITY.md`. Source-of-truth docs: `README.md`, `HANDOFF.md`, `COMPATIBILITY.md`.
Paths below are relative to the repo, or use `$BAL_HOME` (the directory
containing this repo and the sibling `electrum/` checkout).
## Environments (critical) ## Environments (critical)
Two separate venvs; using the wrong one is the #1 mistake. Two separate venvs; using the wrong one is the #1 mistake.

View File

@@ -3265,3 +3265,65 @@ as the default.
`external_zip_test.py` all pass. `external_zip_test.py` all pass.
**Outcome:** DONE (uncommitted). **Outcome:** DONE (uncommitted).
---
## Balanced-QR wire format v2: compact header + best-of compression
**Date:** 2026-09-13
**Goal:** Shrink the native BAL QR wire format to its minimum. The old
pipe-separated header (`BALQR1|total|index|flags|`) wasted 12-14 characters on
direction marker, separators and decimal count fields, and the export always
sent uncompressed hex text. New exports should fit a will in the fewest,
densest frames possible.
**What changed:**
- `bal/core/qrtransfer.py`:
- New wire format v2: `BAL1<TTT><iii><F><payload>` — fixed 11-char header,
no separators. `BAL1` magic, 3-digit **base36** zero-padded totals/index
(values `00A`-`ZZZ`, cap 46655 frames), single flag char.
- Flags: `0` = plain payload, `Z` = zlib+base64 compressed payload (the importer
already decompressed `Z`; the exporter now produces it).
- `encode_transfer_best(tx_strings)` returns the shorter of plain vs
compressed; the export widget uses it as the default for BAL QR.
- `parse_frame` is dual: old `BALQR1|total|index|flags|payload` frames still
import unchanged (backwards-compatible receive).
- Frame-count overflow (a transfer needing > 46655 frames) raises
`QrTransferError` at encode time instead of emitting corrupt headers.
- `bal/gui/qt/dialogs.py` (`BalQrExportWidget`): BAL QR export now encodes via
`encode_transfer_best`, so plain *or* compressed frames are emitted per
transfer; import is untouched (already format-agnostic and flag-driven).
- `bal/core/animated_qr.py`: `detect_format` accepts `BAL1` in addition to the
legacy `BALQR` prefix; wire-format docstring updated.
- `bal/gui/qt/widgets.py` (`WillWidget`): the will detail view now shows each
heir's address (or the decoded UTF-8 text of an `OP_RETURN:` heir) and a
dedicated Address row for the will-executor.
**Verification:**
- `tests/test_core_qr_transfer.py` (new v2 tests: header structure, field width,
`Z` flag round-trip, best-of selection, malformed `BAL1` frames, 46655 cap and
exact boundary): all pass; legacy `BALQR1` parse tests unchanged and green.
- `tests/test_core_animated_qr.py`: `BAL1` detection + `parse_for_detection`;
`tests/test_gui_qr_transfer.py`: format-combo + chunk-navigation updated for
the compact export.
- `pytest tests/test_core_*.py tests/test_import_will_details.py -q`: 345 passed.
- `ruff` clean on all touched files except the pre-existing `dialogs.py` I001
(present on HEAD); `pyright` 0 errors on the codec modules.
- Android Chaquopy bundle re-synced (`sync_codecs.py` + `verify_chain.py`).
**Notes / caveats:**
- **Compatibility break (forward):** the new default export (compressed
`BAL1…`) is NOT readable by older BAL versions — nor by the previously
released Android APK — until those are updated to accept `BAL1`. Imports of
legacy `BALQR1…` exports keep working on this version. Existing audio
transfers are unaffected (they keep explicit `compress=False`, and the audio
format was never flag-driven on receive).
- A typical multi-tx will now ships as a single dense frame instead of two
sparse ones: header overhead dropped from 12-14 chars to a constant 11, and
the base36 count fields are 3 chars regardless of how many frames exist.
**Outcome:** DONE.

View File

@@ -31,7 +31,7 @@ supports **BC-UR v1**, **BC-UR v2** and **BBQR**:
| Format | Wire appearance | Interop target | | Format | Wire appearance | Interop target |
|-----------|----------------------------|------------------------------------------------------| |-----------|----------------------------|------------------------------------------------------|
| BAL QR | `BALQR1\|total\|index\|…` | Past/other BAL versions (default, always exported) | | 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 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) | | BC-UR v2 | `ur:bytes/<seq>-<seqlen>/<bytewords>` | BC-UR 2.x fountain codes (CBOR parts, CRC-32, bytewords-minimal) |
| BBQR | `B$<enc><type><N><n>…` | Coinkite BitKit / Coldcard's BBQR animated-QR mode | | BBQR | `B$<enc><type><N><n>…` | Coinkite BitKit / Coldcard's BBQR animated-QR mode |

View File

@@ -111,7 +111,8 @@ Two separate venvs — using the wrong one is the #1 mistake:
- **Lint venv** (repo-local `venv/`): ruff, black, flake8 only. It cannot - **Lint venv** (repo-local `venv/`): ruff, black, flake8 only. It cannot
import `electrum` or `PyQt6`. Do NOT use it to run tests. import `electrum` or `PyQt6`. Do NOT use it to run tests.
Run everything from the repo root. Run everything from the repo root (the checkout directory; set
`BAL_HOME` to its parent to use `$BAL_HOME/electrum`).
**Tests are standalone scripts (not pytest):** each `tests/test_*.py` runs its **Tests are standalone scripts (not pytest):** each `tests/test_*.py` runs its
`test_*` functions from `if __name__ == "__main__"`. Run a file directly: `test_*` functions from `if __name__ == "__main__"`. Run a file directly:

View File

@@ -62,9 +62,9 @@
| # | Decision | | # | Decision |
|---|----------| |---|----------|
| D1 | QML part = update `QML_PLAN.md` document only; implementation later. | | D1 | QML part = update `QML_PLAN.md` document only; implementation later. |
| D2 | Frames carry a small ASCII header (`BALQR1\|N\|i\|flags`) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. | | D2 | Frames carry a small **compact** ASCII header (`BAL1<total><index><flag>`, fixed 11 chars, base36 count fields) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. Legacy `BALQR1\|N\|i\|flags\|` export removed; legacy import kept. |
| D3 | Payload = **serialized transaction strings only** (`str(wi.tx)`), NOT the JSON will dump. Loses statuses/metadata on purpose; import rebuilds items like `merge_single_transaction` does. | | D3 | Payload = **serialized transaction strings only** (`str(wi.tx)`), NOT the JSON will dump. Loses statuses/metadata on purpose; import rebuilds items like `merge_single_transaction` does. |
| D4 | Compression (zlib+base64 over the whole payload) = checkbox in the export dialog, **default OFF**, advertised via a frame flag. | | D4 | Compression (zlib+base64 over the whole payload) exported as **best-of**: `encode_transfer_best` ships compressed when it is shorter, plain otherwise; flag `0` = plain, `Z` = compressed. No user-facing checkbox. |
| D5 | 4 standard size presets: **~150 / ~400 / ~900 / ~1800 bytes** of payload per QR (low-res cams → high-res cams). Error-correction level fixed **M**. Stored as plugin config default; selectable again inside the export dialog. | | D5 | 4 standard size presets: **~150 / ~400 / ~900 / ~1800 bytes** of payload per QR (low-res cams → high-res cams). Error-correction level fixed **M**. Stored as plugin config default; selectable again inside the export dialog. |
| D6 | After capture completes: **review + sign each tx one at a time** (show outputs, total outputs, total fees), then **propose export of the signed txs** (file and/or QR). Supersedes the earlier "WillDetailDialog preview" answer. | | D6 | After capture completes: **review + sign each tx one at a time** (show outputs, total outputs, total fees), then **propose export of the signed txs** (file and/or QR). Supersedes the earlier "WillDetailDialog preview" answer. |
| D7 | Add an **audio-modem transfer path** gated on Electrum's `audio_modem` plugin being enabled and available (`amodem` importable). Same payload semantics as QR (transfer string of serialized txs), but NO BAL frame chunking — `amodem` handles transport framing internally. Buttons simply hidden when the plugin is absent/unavailable (info message pointing at `pip install amodem` when enabled-but-broken); graceful degradation, never a hard dependency. | | D7 | Add an **audio-modem transfer path** gated on Electrum's `audio_modem` plugin being enabled and available (`amodem` importable). Same payload semantics as QR (transfer string of serialized txs), but NO BAL frame chunking — `amodem` handles transport framing internally. Buttons simply hidden when the plugin is absent/unavailable (info message pointing at `pip install amodem` when enabled-but-broken); graceful degradation, never a hard dependency. |
@@ -120,19 +120,30 @@ transfer_string = "\n".join( tx_str(tx) for tx in valid_txs_sorted_by_txid )
### 4.2 Frame layout (one frame = content of ONE QR code) ### 4.2 Frame layout (one frame = content of ONE QR code)
Wire format v2 (compact, current export):
``` ```
BALQR1|<total>|<index>|<flags>|<payload> BAL1<TTT><iii><F><payload>
``` ```
- Magic+version literal `BALQR1` (reject anything else with a clear message; - Magic+version literal `BAL1` (reject anything else with a clear message).
keeps the door open for a future `BALQR2`). - `<TTT>` = `<iii>`**base36** zero-padded 3-char strings (`000``ZZZ`),
- `<total>` N, `<index>` i — integers, `1 ≤ i ≤ N`. representing total N and index i, `1 ≤ i ≤ N ≤ 46655`. Fixed width means a
- `<flags>`: subset of chars, today `` (empty ⇒ plain) or `Z` (compressed). 3-digit count field costs the same for a 1-frame or a 46655-frame transfer.
- `<F>`: single flag char — `0` ⇒ plain, `Z` ⇒ zlib+base64 compressed.
- `<payload>`: the i-th slice of `transfer_string`, exactly - `<payload>`: the i-th slice of `transfer_string`, exactly
`chunk_size` bytes each (last slice may be shorter). `chunk_size` bytes each (last slice may be shorter). No separators: both
- Header overhead ≈ 1620 bytes → effective payload = `chunk_size overhead`; base36 count fields are fixed-width, so the header is unambiguously 11
the chunker slices the transfer string so that **header+payload ≤ preset chars and the payload starts at offset 11.
size**. - Header overhead is a constant **11 bytes** → effective payload =
`chunk_size 11`; the chunker slices the transfer string so that
**header+payload ≤ preset size**.
Legacy frames `BALQR1|<total>|<index>|<flags>|<payload>` (variable-width
decimal header, pipe-separated) are still **imported** by `parse_frame`
(`_parse_v1`), so old exports keep working; the exporter emits v2 only.
That is the one deliberate compatibility break: **v2 frames are not readable
by builds older than this change.**
### 4.3 Size presets (D5) ### 4.3 Size presets (D5)
@@ -173,12 +184,18 @@ def encode_transfer(tx_strings: list[str], compress: bool = False) -> str
"""Join -> optional zlib+base64 -> return transfer_string.""" """Join -> optional zlib+base64 -> return transfer_string."""
def split_frames(transfer_string: str, chunk_size: int) -> list[str] def split_frames(transfer_string: str, chunk_size: int) -> list[str]
"""Slice into full frames 'BALQR1|N|i|flags|payload'. Raises """Slice into frames 'BAL1<TTT><iii><F>payload' (v2) — header+payload <=
chunk_size. Raises QrTransferError over the 46655-frame base36 cap, or
ValueError if chunk_size < MIN_CHUNK_SIZE.""" ValueError if chunk_size < MIN_CHUNK_SIZE."""
def encode_transfer_best(tx_strings: list[str]) -> tuple[str, bool]
"""-> (transfer_string, compressed); ships the shorter of plain vs
zlib+base64 so the export emits the densest frames."""
def parse_frame(frame: str) -> tuple[int, int, bool, str] def parse_frame(frame: str) -> tuple[int, int, bool, str]
"""-> (total, index, compressed, payload); ValueError on bad magic/ """-> (total, index, compressed, payload); accepts v2 'BAL1…' and legacy
version/arity/non-int fields.""" 'BALQR1|…' (wrapped as _parse_v1/_parse_v2); ValueError on bad magic/
version/arity/non-numeric fields."""
def assemble(frames: dict[int, str]) -> str def assemble(frames: dict[int, str]) -> str
"""Validate indices form exactly range(1..max_total) (taken from any """Validate indices form exactly range(1..max_total) (taken from any
@@ -260,7 +277,7 @@ def get_audio_modem_plugin(self): # on BalWindow
Layout: Layout:
``` ```
[Size ▾ Small/Medium/Large/XL] [x Compress (zlib+base64)] [Size ▾ Small/Medium/Large/XL] ← compressed best-of automatically (no checkbox)
[ QR image ] ← BalQrImage (see below) [ QR image ] ← BalQrImage (see below)
«i di N» [◀ Prev] [Next ▶] «i di N» [◀ Prev] [Next ▶]
[Save current QR as PNG…] [Send via Audio Modem…] [Close] [Save current QR as PNG…] [Send via Audio Modem…] [Close]
@@ -268,7 +285,7 @@ Layout:
Behaviour: Behaviour:
- On any control change: rebuild `split_frames(encode_transfer(...))`, - On any control change: rebuild `split_frames(encode_transfer_best(...))`,
reset index to frame 1, refresh counter (owner requirement: "cambiare la reset index to frame 1, refresh counter (owner requirement: "cambiare la
risoluzione"). risoluzione").
- `BalQrImage(QWidget)` ≈ trimmed copy of `QRCodeWidget` - `BalQrImage(QWidget)` ≈ trimmed copy of `QRCodeWidget`

View File

@@ -1,379 +0,0 @@
# QML PLAN — BAL on Electrum QML / Android (Option B: minimal viable support)
> Goal: let Android users (Electrum QML GUI) use BAL. **The Android device is
> the OFFLINE SIGNING DEVICE**: its primary job is to receive unsigned will
> transactions from an online machine (desktop/another phone), sign them with
> the wallet keys held on it, and return the signed transactions — a classic
> air-gapped signer workflow. Online features (willexecutor contact,
> broadcast, build) are secondary on Android and belong mainly to the online
> machine.
> Strategy: a *third frontend* (`bal/gui/qml/`) that reuses `bal/core` logic
> through the existing GUI-free `BalController`, exactly like `bal/cli/`
> already does. The PyQt6 desktop GUI remains untouched and primary.
>
> Status: DRAFT for owner review (rule R4 — no code until explicit OK).
> Chat language: Italian; this document is in English per rule R1.
---
## 1. Verified facts (research done on the local checkouts)
All items below were verified by reading source, not assumed.
| # | Fact | Where |
|---|------|-------|
| F1 | The fork's Electrum ships a full QML GUI built on **PyQt6.QtQml** (PyQt6 6.11 installed in runtime env imports `QtQml`/`QtQuick` fine). | `electrum/electrum/gui/qml/` |
| F2 | The QML GUI has a **plugin mechanism**: manifest `"available_for"` must contain `"qml"`; Electrum then loads `<plugin>/qml.py` and calls the `init_qml(app)` hook. | `electrum/plugin.py` (`load_plugin_by_name`, gui_name), `gui/qml/__init__.py:88` |
| F3 | On load, `main.qml` reads `plugin.so.loader` and auto-creates the component from `<plugins>/<name>/qml/<loader>.qml`. The plugin itself sets `.so` (a `PluginQObject`). Canonical example: `electrum/plugins/labels/qml.py`. | `gui/qml/components/main.qml` (`onPluginLoaded`), `gui/common_qt/plugins.py` |
| F4 | The plugin must support **both** target versions: the QML GUI + `common_qt/plugins.py` exist in the 4.7.x line too (verified on the local 4.7.0 checkout). Exact 4.7.2 parity is Phase-0 task T1. | `$BAL_HOME/electrum470/electrum/gui/{qml,common_qt}` |
| F5 | `BalController(plugin, wallet)` is GUI-free, per-wallet, and already implements state init + sign/build/broadcast flows against the bare `wallet` object (no `ElectrumWindow` needed). This is the reuse cornerstone of this plan. | `bal/cli/controller.py:119-175`, `sign_transactions` at :646 |
| F6 | In the repo, `bal` is already symlinked into the Electrum tree as an **internal** plugin: `electrum/electrum/plugins/bal -> ../../../bal-electrum-plugin/bal`. Internal plugins are plain files on disk → the QML engine can load `.qml` assets directly. | `ls -la electrum/electrum/plugins/` |
| F7 | The APK build spec lists packaged plugins explicitly and **BAL is not yet in that list**. | `electrum/contrib/android/buildozer_qml.spec:34-48` |
| F8 | The QML Preferences page has **hardcoded toggles only** for `labels` and `psbt_nostr`; there is no generic plugin manager UI. Plugin enabling works via config regardless (`plugins.bal.enabled = true`). | `gui/qml/components/Preferences.qml:168,186,511-512` |
| F9 | Extension points inside the QML app are minimal: `run_hook('init_qml', app)`, `run_hook('load_wallet', wallet)` (**one** argument, unlike Qt's two), `get_tx_extra_fee`, `tc_sign_wrapper`, and one named-component injection slot (`pluginsComponentsByName('export_tx_button')`). No tools menu, no status bar. | grep over `gui/qml/*.py`, `main.qml:778` |
| F10 | External ZIP plugins cannot serve `.qml` files from inside the zip (zipimport exposes Python modules only; `Qt.resolvedUrl` needs real disk paths). Distribution as internal plugin (F6) or runtime extraction avoids this. | consequence of F3 |
| F11 | Core signing path used by the CLI controller calls `wallet.sign_transaction(tx, password)` and updates signature counts — identical flow works under `QEWallet.wallet`. | `bal/cli/controller.py:646-700` |
---
## 2. Scope
### In scope (MVP)
Two usage **profiles** share one codebase:
- **Offline signer profile** (Android, PRIORITY): works with no network.
1. Import a will bundle exported by the online machine (existing JSON will
format, see F12) — via file share, paste, or QR.
2. Review what is being signed (destinations, amounts, locktimes, fees).
3. Sign internally (`wallet.sign_transaction` + password prompt).
Partial signatures are combined when the bundle is re-imported
(`combine_with_other_psbt`, already supported by `merge_will` logic).
4. Export the signed bundle back to the online machine.
- **Online manager profile** (desktop QML, secondary):
- Will status overview (state, expiry/check-alive date, reminder info).
- Heirs list (view/add/edit/remove, addresses or URIs).
- Will-executor selection (list, enable/disable, fee display, refresh).
- Build will (simplified wizard reusing core validation).
- Sign in place (password) or hand off to an offline signer via bundles.
- Broadcast / push to will-executors; invalidate will; check-alive refresh.
- Basic settings mapped onto `will_settings`.
The offline signer pages are built first and must function with the network
disabled (Electrum runs fine offline; willexecutor refresh simply degrades).
Also in scope:
- Android packaging: BAL bundled as internal plugin in the custom APK,
**enabled by default** (owner decision D3).
- Keep desktop (`qt`) and CLI (`cmdline`) behavior byte-for-byte unchanged.
### Out of scope (explicitly deferred)
- Full parity with the PyQt6 GUI (calendar widget, preview list editor,
advanced fee controls, themes).
- External-ZIP distribution of QML assets (F10 workaround postponed; ZIP
builds keep working for desktop exactly as today, without `qml` UI).
- iOS, upstream-Electrum (spesmilo) compatibility.
- Lightning-related features (irrelevant to BAL).
---
## 3. Architecture
```
┌───────────────────────────────────────────┐
│ bal/core │
│ heirs, will, willexecutors, checkalive, │
│ reminders, input_rules, plugin_base │
└────────────┬──────────────────────────────┘
│ (no Qt anywhere)
┌─────────────────────┼──────────────────────┐
▼ ▼ ▼
bal/gui/qt/ bal/cli/ bal/gui/qml/ ← NEW
BalWindow etc. BalController qml_plugin.py (BalQmlPlugin)
(~9.3k lines) (headless flows) models.py (QObject VMs)
so.py (PluginQObject)
*.qml (views)
wraps ONE BalController
per loaded wallet
```
Design rules:
- **Reuse, do not duplicate.** `bal/gui/qml/models.py` holds thin QObject
wrappers around one `BalController` instance per wallet. No business logic
in QML or in the wrappers beyond formatting.
- **Same persistence.** Wallet DB dicts (`heirs`, `will`, `will_settings`)
are registered by `bal/core/plugin_base.py` already; QML reads/writes them
through the controller, so a wallet moves between desktop/Android unchanged.
- **Threading.** Network operations (willexecutor fetch/push, broadcast)
run in worker threads exactly as the CLI does; results marshalled to the UI
thread via Qt signals on the wrapper objects. No blocking calls in slots.
- **One transfer format.** The airgap round trip reuses the existing JSON
will serialization (`WillItem.to_dict()` maps, exactly what the Qt GUI's
`export_json_file`/`import` + `merge_will` flow already produces and
consumes — see F12). No new format is invented; export/merge logic gets a
single shared home usable by both frontends.
- **Version gating.** Every import of `electrum.gui.qml.*` happens lazily and
defensively; if absent (e.g., odd build), the plugin degrades to core-only
behavior instead of crashing the daemon.
---
## 4. Work breakdown
### Phase 0 — Verification spikes (no product code)
| Task | Description | Exit criterion |
|------|-------------|----------------|
| T1 | Diff `gui/qml` + `gui/common_qt` between the 4.7.0 checkout here and current 4.8.x, focused on: `PluginQObject`, `init_qml` hook call sites, `onPluginLoaded` handler, `load_wallet` hook arity. If 4.7.2 differs, note shims needed. | Written compatibility note appended to COMPATIBILITY.md draft section |
| T2 | Run desktop QML GUI headless with BAL enabled via config: `QT_QPA_PLATFORM=offscreen run_electrum -g qml` with `plugins.bal.enabled=true`, manifest updated ad-hoc (throwaway branch). Confirms discovery/loading path end-to-end before writing any code. | Log shows `init_qml` called for bal; no crash |
| T3 | APK feasibility: add `electrum/plugins/bal` to `buildozer_qml.spec` package list locally, confirm p4a includes `.qml` data files and icons (may need `source.include_exts` adjustment). Do NOT ship. | Test APK contains `plugins/bal/qml/*.qml` |
| T4 | Decide entry-point UX given F9 (no menu hook): candidate = tiny patch in fork's `main.qml` adding a "BAL" item in the wallet drawer/menu that opens our window object from `app.pluginobjects['bal']`. Confirm with owner. | Decision recorded in this file |
Deliverable: short findings report appended to this document; go/no-go.
### Phase 1 — Skeleton integration
Files (all NEW unless noted):
```
bal/qml.py zipimport-style shim mirroring qt.py/cmdline.py
bal/gui/qml/__init__.py package docstring
bal/gui/qml/qml_plugin.py class BalQmlPlugin(BalPluginBase)
bal/gui/qml/so.py class BalSignalObject(PluginQObject)
bal/manifest.json MODIFIED: available_for += ["qml"]
```
Details:
- `qml_plugin.py`:
- `@hook init_qml(self, app)`: store app ref; create `so` parented to app;
for each already-loaded wallet call `_on_wallet_loaded(wallet)`
(mirrors labels' pattern, see F3).
- `@hook load_wallet(self, wallet)`**single argument** (F9); creates the
per-wallet view-model bundle (Phase 2) keyed by `wallet`.
- `@hook unload_wallet(self, wallet)`: drop controllers, close windows.
- `so.py`: `BalSignalObject(PluginQObject)` exposing:
- `loader` property returning `"BalMain.qml"` (drives F3 auto-create);
- signals: `walletChanged`, `willStateChanged`, `heirsChanged`,
`willexecutorsChanged`, `busyChanged`;
- slots called from QML: open/close window, refresh willexecutors,
check-alive now, build/sign/broadcast/invalidate commands.
- `manifest.json`: append `"qml"` to `available_for`. Desktop untouched
(Electrum filters per running GUI, verified F2).
Exit criterion: with `-g qml`, plugin loads, `so.loader` component is created
(log line from `onPluginLoaded`), no functional UI yet.
### Phase 2 — View-models (QObject layer)
New file `bal/gui/qml/models.py`:
- `BalQmlWallet(QObject)`: owns one `BalController`; exposes read-only
properties (`willState`, `dateToCheck`, `expired`, `reminderInfo`,
`sigsHave/sigsRequired` per tx) + notification signals; forwards actions to
controller methods (`build_will`, `sign_transactions`, `broadcast_will`,
`invalidate_will_headless`, `check_alive`... — names per controller).
- **Airgap methods (priority):** `export_will_bundle()` and
`import_will_bundle(json_text)` returning summary of what changed. These
are small ports of the Qt GUI's `export_json_file` (window.py:1605) and
`merge_will` (window.py:1620) semantics. Preferred implementation: move
the logic into shared helpers (controller level or `bal/core/will.py`
static functions) and make the Qt GUI call the same helpers, so the two
frontends cannot diverge; regression-covered by existing core tests plus
new round-trip tests.
- Offline profile detection: expose an `isOffline` property derived from
`wallet.network is None` / config, so QML can hide online-only pages.
- `HeirListModel(QAbstractListModel)`: roles `name`, `address`, `amountPct`,
`valid`; edit methods delegate to `Heirs` helpers through controller.
- `WillTxListModel(QAbstractListModel)`: roles `txid`, `status`, `fee`,
`sigsHave`, `sigsRequired`, `isComplete`.
- `WillExecutorListModel(QAbstractListModel)`: roles `url`, `selected`,
`fee`, `valid`; toggle + async refresh.
- `BalQrTransferModel(QObject)`: thin scheduler over the shared transfer
planner `bal.core.qrtransfer` (already battle-tested by the desktop Qt
plugin, P1-P4). Exposes `encode(items)` → frames, `frameAt(i)` (data URL /
pixmap for QML), `decode(text)` → tx list, `total`, `current`, presets;
re-emits a `frameChanged` notifier so the QML page can step 1..N. No QR
rendering inside the model (QML paints it).
- All list mutations happen on the controller state then `beginResetModel/
endResetModel` (datasets are small; simplicity over incremental updates).
Exit criterion: pytest-driven model tests pass offscreen (create models over a
regtest/testnet wallet fixture, assert roles after mutations).
### Phase 3 — QML views
New directory `bal/gui/qml/components/`:
```
BalMain.qml top-level Window; stack of pages below
BalSignPage.qml PRIORITY (offline signer): import bundle
(paste / file / QR), review summary of each tx,
password-sign, export signed bundle back
BalQrExportPage.qml QR export view: drives BalQrTransferModel, one
frame at a time (Prev/Next, progress i/N, chunk
preset selector), mirror of desktop Qt dialog
BalQrImportPage.qml QR import view: slot grid (1..N), camera via
Electrum `QRScan` reuse, manual paste fallback,
then jump into BalSignPage review/sign
BalStatusPage.qml will state, expiry countdown, check-alive button
(works offline with last-known data)
BalHeirsPage.qml ListView + add/edit dialog [online profile]
BalExecutorsPage.qml ListView with switches + refresh [online profile]
BalBuildPage.qml simplified build form (threshold selector, fees,
executor pick) → runs controller.build_will
[online profile]
BalSettingsPage.qml maps onto will_settings subset
controls/BalButton.qml, BalField.qml minimal styled primitives
qmldir module registration
```
Notes:
- **BalSignPage review screen is mandatory**: before signing, the user must
see per-transaction heir address, amount, locktime (date), and fee — this
device is the security boundary, so no "blind signing".
- QR transfer: a full will bundle may exceed one QR's capacity when there are
many heirs/transactions. The desktop Qt plugin now resolves this with
chunked multi-QR streams (`bal/core/qrtransfer.py`, chunk presets
150/400/900/1800 bytes/frame, EC level M); the QML GUI reuses the same
scheduler via a thin model. Order of preference stays (1) share/save file +
paste text; (2) chunked QR streams through `BalQrTransferModel`.
- Styling minimal, follow existing QML components' look (reuse
`controls/` from Electrum where importable — prefer copying tiny primitives
to avoid coupling to upstream churn; decide during implementation).
- Every string wrapped with `electrum.i18n._`.
Exit criterion: full manual walkthrough on desktop QML GUI (offscreen +
interactive) performing BOTH: (a) online profile — configure heirs → select
executors → build → sign → broadcast → invalidate; and (b) offline signer
profile — export unsigned bundle from an online wallet, import into a second
offline wallet instance, sign, re-export, import signed bundle back into the
first wallet and verify signatures combined/status COMPLETE. Walkthrough (b)
must pass with networking disabled.
### Phase 4 — Android integration
- Add `electrum/plugins/bal` (+ data extensions for `.qml`, `icons/*`) to
`contrib/android/buildozer_qml.spec` in the Electrum fork.
- Patch fork's `Preferences.qml` with a BAL toggle **(owner approved, D1)**
and default-enable BAL in the APK build (D3: enabled by default).
- Entry point per T4 decision (menu/drawer patch in fork's `main.qml`, D1
approved). Fallback if T4 picks auto-open: window opens on wallet load.
- Rebuild APK; smoke-test on device/emulator:
install → BAL already enabled → open wallet → full MVP walkthrough,
including airplane-mode signing round trip (file/QR transfer between an
online desktop and the offline device — for emulator testing, "offline" =
network disabled via settings).
- Watch-outs: filesystem paths (use `os.path.join`, no hardcoded separators —
already house style), background network on mobile (willexecutor timeouts),
APK size impact (bal is small; icons only), share-intent/file access
permissions for bundle import/export.
Exit criterion: signed test APK passes the same walkthrough as Phase 3.
### Phase 5 — Tests & CI hygiene
- New tests following repo conventions (`def test_*` + `__main__` block):
- `tests/test_qml_models.py` — models over fake/controller-backed wallet
(offscreen, no network).
- `tests/test_qml_airgap_roundtrip.py` — PRIORITY: export unsigned bundle
from wallet A → import into wallet B (same seed, offline) → sign →
export signed → merge back into A; assert COMPLETE status and signature
counts. Must pass with no network.
- `tests/test_qml_plugin_loading.py` — plugin instantiates under a stubbed
QML app object; `so` wiring correct; load/unload wallet lifecycle.
- Extend `tests/smoke_test.py` usage: `QT_QPA_PLATFORM=offscreen python3
tests/smoke_test.py electrum.plugins.bal` still green (qt path intact).
- Regression gate: full `tests/test_core_*.py` batch + ruff (no NEW
violations) before any delivery ZIP.
- Manual matrix recorded in CHANGELOG entry: [desktop qt, desktop qml
offscreen, Android APK] × [4.7.2, 4.8.0] where applicable.
### Phase 6 — Release plumbing & docs
- `build_zip.py`: ensure new `bal/gui/qml/**` and `components/*.qml` included
in deterministic zip (harmless on desktop; enables future extraction-based
loading).
- `COMPATIBILITY.md`, `README.md`, `HANDOFF.md`: document QML/Android status,
limitations, and how to enable (`-g qml` / APK toggle).
- `CHANGELOG.md`: numbered entry at END per house rules.
- Version bump + release handled by `make-release.sh` as usual (owner-driven).
---
## 5. Risks & mitigations
| Risk | Impact | Mitigation |
|------|--------|------------|
| R1: 4.7.2 vs 4.8 QML internals drift | broken load on one version | Phase-0 T1 diff first; lazy imports + capability checks; shim module if needed |
| R2: no generic plugin UI in QML (F8) | users can't enable BAL from UI | config default-enable in fork APK; small Preferences.qml patch in fork (we control it); document manual config for desktop |
| R3: no natural entry point in main.qml (F9) | user can't find/open BAL window | T4 decision: fork-side menu/drawer patch; fallback = auto-open window on wallet load behind a setting |
| R4: dual-GUI maintenance burden | long-term cost | strict reuse of `BalController`; QML layer forbidden from business logic (review rule); parity features stay in qt GUI |
| R5: threading bugs on mobile networks | ANRs/crashes | all network ops in threads like CLI; signals-only UI updates; timeouts already configurable |
| R6: airgap transfer friction (bundle size vs QR capacity, share permissions on Android) | users cannot move bundles reliably | desktop Qt: chunked multi-QR streams + audio-modem optional channel shipped (P1-P4) and regression-gated; QML: file share + paste first, QR via `BalQrTransferModel` (Phase 2/3 notes); Android camera on Qt6 is known-flaky, file/paste stays the primary mobile fallback and is tested first in Phase 4 |
| R7: export/merge semantics divergence between frontends | signed bundles rejected or double-counted | single shared helper used by qt GUI and qml layer (Phase 2); round-trip regression test |
| R8: hidden coupling of qt code into shared modules | qml import pulls QtWidgets | lint guard idea: import-linter/ruff rule forbidding `PyQt6.QtWidgets` under `bal/gui/qml/` |
| R9: zip distribution ambiguity (F10) | confusion about what ships where | clear policy: ZIP = desktop qt+cmdline only; QML requires internal-plugin/APK route (Phase 6 documents this) |
| R10: unknown Android/Electrum baseline (owner to confirm, OQ4) | wrong Qt/PyQt6 assumptions in APK build | Phase-0 T3 builds against the fork's current toolchain; code keeps 4.7.x/4.8.x dual support so the answer can arrive late without rework |
---
## 6. Questions for the owner — ANSWERED (2026-08-25)
1. **Fork patches (T4/R3):** ✅ **D1 — APPROVED.** Patching the fork's
`main.qml`/`Preferences.qml` is allowed for the BAL menu entry and toggle.
2. **Offline signing topology:** ✅ **D2 — Android IS the offline device.**
The phone holds the keys and acts as air-gapped signer: import unsigned
bundle → review → sign → export signed bundle back to the online machine.
The sign/import/export page is therefore the top priority of Phase 3, and
the round-trip test is the top priority of Phase 5.
3. **Enable-by-default:** ✅ **D3 — BAL pre-enabled in the custom APK**
(toggle still available to disable).
4. **Target Android/Electrum baseline:** ⏳ **OPEN (OQ4)** — owner will get
back later. Not blocking: see risk R10 mitigation.
---
## 7. Effort estimate
| Phase | Rough size |
|-------|-----------|
| 0 spikes | ~half day (mostly reading + one throwaway branch) |
| 1 skeleton | ~300 lines Python |
| 2 models (incl. shared export/merge helpers) | ~600800 lines Python |
| 3 views (offline signer page first) | ~9001300 lines QML |
| 4 android | fork-side patches + build iteration (device-dependent) |
| 5 tests | ~500 lines |
| 6 release/docs | small |
Overall: comparable to a medium feature, dominated by Phase 3 UI polish and
Phase 4 device iteration.
---
## 8. Findings log (append-only)
- **F12** — The airgap round trip already exists in the Qt GUI layer and can
be ported almost verbatim: `export_json_file()` (window.py:1605) exports
all will items as `{wid: WillItem.to_dict()}` JSON (marking them
`EXPORTED`); the import side goes through `merge_will()`
(window.py:1620), which carries operational statuses, combines partial
signatures via `tx.combine_with_other_psbt()` when txids match, substitutes
the tx otherwise, and recomputes validity locally without network.
Conclusion: no new transfer format is needed; the plan is to give this
logic a shared home (controller/core) so Qt, CLI-adjacent tooling and QML
all use one implementation.
### Decisions
- **D1** — Fork-side patches to `main.qml` / `Preferences.qml` approved by
the owner.
- **D2** — Android = offline signing device; sign/import/export flow has
top priority.
- **D3** — BAL enabled by default in the custom APK.
- **OQ4** — Android/Electrum baseline: open, non-blocking (see R10).

View File

@@ -94,7 +94,9 @@ exists, then enable it from **Tools → Plugins**.
From the will list (**Export → QR Codes**) a will can be exported as a From the will list (**Export → QR Codes**) a will can be exported as a
sequence of QR codes and imported on another device (**Import via QR**). The sequence of QR codes and imported on another device (**Import via QR**). The
export offers All / Valid / Valid-NC filters plus a QR size preset export offers All / Valid / Valid-NC filters plus a QR size preset
(1501800 bytes/frame); the import flow reviews and sign each transaction (1501800 bytes/frame) and ships the default **BAL QR** format already
compressed whenever that is smaller (best-of zlib, flag per frame); the import
flow reviews and sign each transaction
one at a time, then proposes exporting the signed transactions. When one at a time, then proposes exporting the signed transactions. When
Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` + Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` +
PortAudio) Send/Receive audio buttons complement the QR channel. See PortAudio) Send/Receive audio buttons complement the QR channel. See

10
android/.gitignore vendored Normal file
View File

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

153
android/README.md Normal file
View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

Binary file not shown.

View File

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

251
android/gradlew vendored Executable file
View File

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

94
android/gradlew.bat vendored Normal file
View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -43,6 +43,7 @@ from ..core.checkalive import (
CheckAliveError, CheckAliveError,
check_alive_expired, check_alive_expired,
resolve_date_to_check, resolve_date_to_check,
resolve_guard_threshold,
) )
from ..core.heirs import Heirs, is_op_return_address from ..core.heirs import Heirs, is_op_return_address
from ..core.plugin_base import BalConfig, BalPlugin from ..core.plugin_base import BalConfig, BalPlugin
@@ -314,6 +315,28 @@ class BalController:
executor. executor.
""" """
will = {} will = {}
# Drop stale wallet-LOCAL will placeholders (mirror of the GUI
# build_will) so their coins are available to this build.
Will.remove_stale_wallet_history(
self.wallet, self.plugin.HISTORY_LABEL.get()
)
# A (re)build may have anticipated the delivery (shorter heir recipes)
# while ``date_to_check`` is still anchored to the OLD built will.
# Recompute it for the will being built (earliest future delivery among
# the CURRENT heirs), mirroring ``BalWindow.build_will``, so the
# anticipated dates pass the build filter.
_new_locktime = min(
(
Util.parse_locktime_string(h[2])
for h in self.heirs.values()
),
default=None,
)
if _new_locktime:
self.date_to_check = resolve_date_to_check(
self.plugin.is_basic_mode(), self.will_settings,
built_locktime=_new_locktime,
)
self.willexecutors = Willexecutors.get_willexecutors( self.willexecutors = Willexecutors.get_willexecutors(
self.plugin, update=False, task=False self.plugin, update=False, task=False
) )
@@ -434,7 +457,13 @@ class BalController:
raise _user_facing(e) from e raise _user_facing(e) from e
locktime = Util.parse_locktime_string(self.will_settings["locktime"]) locktime = Util.parse_locktime_string(self.will_settings["locktime"])
if locktime < date_to_check: threshold_ts = resolve_guard_threshold(
self.plugin.is_basic_mode(), self.will_settings
)
if threshold_ts is not None:
if locktime < threshold_ts:
raise UserFacingException(_("locktime is lower than threshold"))
elif locktime < date_to_check:
raise UserFacingException(_("locktime is lower than threshold")) raise UserFacingException(_("locktime is lower than threshold"))
if not self.no_willexecutor: if not self.no_willexecutor:

View File

@@ -8,7 +8,9 @@ used to move BAL will data between devices.
Supported wire formats (each self-describing and order-independent on Supported wire formats (each self-describing and order-independent on
receive): receive):
* **BALQR** (native, unchanged): ``BALQR1|total|index|flags|payload``. * **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):: * **BC-UR v1** (BCR-2020-005 rev1 draft, May 2020)::
ur:bytes/1of7/<bc32-digest>/<bc32-fragment> ur:bytes/1of7/<bc32-digest>/<bc32-fragment>
Fragments partition the BC32 rendering of the CBOR byte string; the Fragments partition the BC32 rendering of the CBOR byte string; the
@@ -790,7 +792,7 @@ def detect_format(text: str) -> Optional[str]:
if not text: if not text:
return None return None
lowered = text.lower() lowered = text.lower()
if lowered.startswith("balqr"): if lowered.startswith(("balqr", "bal1")):
return "balqr" return "balqr"
if text.startswith(_BBQR_PREFIX): if text.startswith(_BBQR_PREFIX):
return "bbqr" return "bbqr"

View File

@@ -96,6 +96,54 @@ def resolve_date_to_check(
return threshold.to_timestamp() return threshold.to_timestamp()
def resolve_guard_threshold(
is_basic_mode: bool,
will_settings: Any,
now: float | None = None,
) -> float | None:
"""Resolve the "locktime is lower than threshold" guard's reference.
The guard compares the stored settings on ONE reference frame: the
delivery (``locktime``, kept as at the call site) against this threshold.
Unlike :func:`resolve_date_to_check` -- which may be *anchored* to the
built will's frozen tx locktime so that an unchanged will never reads as
expired -- this helper resolves the threshold from the **stored settings
alone**. 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.
Returns ``None`` when there is no threshold to enforce (BASIC mode or a
missing stored value).
"""
if is_basic_mode:
return None
threshold_raw = will_settings.get("threshold")
if threshold_raw is None:
return None
threshold = BalTimestamp(threshold_raw)
if threshold.unit is None:
return threshold.to_timestamp()
now_dt = (
datetime.fromtimestamp(now, tz=timezone.utc) if now is not None else None
)
locktime_dt = BalTimestamp(will_settings["locktime"]).to_date(now_dt)
return threshold.to_date(locktime_dt, reverse=True).timestamp()
def check_alive_expired( def check_alive_expired(
is_basic_mode: bool, date_to_check: float, now: float | None = None is_basic_mode: bool, date_to_check: float, now: float | None = None
) -> bool: ) -> bool:

View File

@@ -10,9 +10,27 @@ Scope
* converts will transactions into a compact ``transfer_string`` * converts will transactions into a compact ``transfer_string``
(newline-joined serialized transactions, optionally zlib + base64 (newline-joined serialized transactions, optionally zlib + base64
compressed); compressed);
* splits that string into fixed-size ``BALQR1|N|i|flags|payload`` frames for * splits that string into fixed-size ``BAL1<TTT><iii><flag>`` frames for
multi-QR export, and reassembles/validates them on import. 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 The audio-modem channel deliberately bypasses the framing helpers here
(PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and (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 carries the whole transfer string in a single blob, so callers only use
@@ -29,6 +47,7 @@ import zlib
MAGIC = "BALQR" MAGIC = "BALQR"
VERSION = 1 VERSION = 1
FLAG_COMPRESSED = "Z" FLAG_COMPRESSED = "Z"
FLAG_PLAIN = "0"
# 4 standard presets (label, payload budget in bytes per QR). Ordered from # 4 standard presets (label, payload budget in bytes per QR). Ordered from
# low-resolution cameras to high-resolution cameras (owner decision D5). # low-resolution cameras to high-resolution cameras (owner decision D5).
@@ -43,7 +62,15 @@ CHUNK_PRESETS = (
# could consume the whole budget. # could consume the whole budget.
MIN_CHUNK_SIZE = 40 MIN_CHUNK_SIZE = 40
_FRAME_MAGIC = MAGIC + str(VERSION) # 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): class QrTransferError(ValueError):
@@ -66,12 +93,26 @@ def encode_transfer(tx_strings, compress=False):
"""Join serialized transaction strings into a transfer string. """Join serialized transaction strings into a transfer string.
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so ``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
the whole bundle shrinks before being printed/scanned. The optional flags the whole bundle shrinks before being printed/scanned. The optional flag
of the frame header let the importer reverse this automatically. of the frame header lets the importer reverse this automatically.
""" """
return __compress("\n".join(tx_strings), enabled=compress) return __compress("\n".join(tx_strings), enabled=compress)
def encode_transfer_best(tx_strings):
"""Encode ``tx_strings`` with the smaller of plain vs compressed form.
Returns ``(transfer_string, compressed: bool)``. Compressed wins only
when zlib + base64 really is shorter (best-of, never larger).
"""
joined = "\n".join(tx_strings)
plain = joined
compressed = __compress(joined, enabled=True)
if len(compressed) < len(plain):
return compressed, True
return plain, False
def decode_transfer(transfer_string, compressed): def decode_transfer(transfer_string, compressed):
"""Inverse of :func:`encode_transfer`. """Inverse of :func:`encode_transfer`.
@@ -84,28 +125,33 @@ def decode_transfer(transfer_string, compressed):
def split_frames(transfer_string, chunk_size, compressed=False): def split_frames(transfer_string, chunk_size, compressed=False):
"""Split ``transfer_string`` into full ``BALQR`` frames. """Split ``transfer_string`` into full compact ``BAL1`` frames.
Every returned frame is at most ``chunk_size`` characters long (header Every returned frame has the fixed 11-char v2 header followed by its
included). ``compressed`` propagates the ``Z`` flag into every frame so share of the payload, so each frame is at most ``chunk_size`` characters
the importer knows how to reverse the encoding. 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 Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold
the header plus any payload. the header plus any payload, or when the transfer needs more than
:data:`_MAX_TOTAL` frames.
""" """
flags = FLAG_COMPRESSED if compressed else "" flag = FLAG_COMPRESSED if compressed else FLAG_PLAIN
total = __compute_total(len(transfer_string), chunk_size, flags) total = __compute_total(len(transfer_string), chunk_size)
budget = chunk_size - _HEADER_V2_LEN
frames = [] frames = []
pos = 0 pos = 0
length = len(transfer_string) length = len(transfer_string)
for index in range(1, total + 1): for index in range(1, total + 1):
overhead = len(__frame_header(total, index, flags))
budget = chunk_size - overhead
end = min(pos + budget, length) end = min(pos + budget, length)
frames.append(__build_frame(total, index, flags, transfer_string[pos:end])) frames.append(
_FRAME_MAGIC_V2
+ _base36(total)
+ _base36(index)
+ flag
+ transfer_string[pos:end]
)
pos = end pos = end
if pos >= length:
break
if pos < length: if pos < length:
# __compute_total guarantees this cannot happen; keep a safety net. # __compute_total guarantees this cannot happen; keep a safety net.
raise QrTransferError("internal error: frames did not cover the transfer string") raise QrTransferError("internal error: frames did not cover the transfer string")
@@ -115,26 +161,16 @@ def split_frames(transfer_string, chunk_size, compressed=False):
def parse_frame(frame): def parse_frame(frame):
"""Parse a single 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 Returns ``(total, index, compressed: bool, payload: str)``. Raises
:class:`QrTransferError` on malformed input (bad magic/version, wrong :class:`QrTransferError` on malformed input (bad magic/version, wrong
arity, non-integer or out-of-range frame numbers, unknown flags). arity, non-integer or out-of-range frame numbers, unknown flags).
""" """
parts = frame.split("|", maxsplit=4) if frame.startswith(_FRAME_MAGIC_V2):
if len(parts) != 5: return _parse_v2(frame)
raise QrTransferError("Not a BAL will QR (bad frame structure)") return _parse_v1(frame)
magic_seen, total_s, index_s, flags, payload = parts
if magic_seen != _FRAME_MAGIC:
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 assemble(frames, total): def assemble(frames, total):
@@ -181,32 +217,88 @@ def __decompress(text, *, enabled):
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8") return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
def __frame_header(total, index, flags): def _base36(n):
return "{}|{}|{}|{}|".format(_FRAME_MAGIC, total, index, flags) """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 __build_frame(total, index, flags, payload): def _base36_decode(text):
return __frame_header(total, index, flags) + payload """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 __compute_total(transfer_len, chunk_size, flags): 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. """Smallest frame count whose budget covers the whole transfer string.
The budget shrinks as ``total`` gains digits (wider header), so the count The v2 header is fixed-width, so the budget is constant and the count is
is recomputed iteratively until it converges. a plain ceiling division, capped at :data:`_MAX_TOTAL`.
""" """
if chunk_size < MIN_CHUNK_SIZE: if chunk_size < MIN_CHUNK_SIZE:
raise QrTransferError( raise QrTransferError(
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size) "chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
) )
total = 1 budget = chunk_size - _HEADER_V2_LEN
while True:
overhead = len(__frame_header(total, total, flags))
budget = chunk_size - overhead
if budget <= 0: if budget <= 0:
raise QrTransferError( raise QrTransferError(
"chunk size too small for the BAL QR frame header: {}".format(chunk_size) "chunk size too small for the BAL QR frame header: {}".format(chunk_size)
) )
if transfer_len <= budget * total: 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 return total
total += 1

View File

@@ -28,6 +28,7 @@ The status flags themselves (the source of truth) stay here; only the mapping
from datetime import datetime, timezone from datetime import datetime, timezone
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
from electrum.i18n import _ from electrum.i18n import _
from electrum.logging import Logger, get_logger from electrum.logging import Logger, get_logger
from electrum.transaction import ( from electrum.transaction import (
@@ -847,6 +848,48 @@ class Will:
except Exception as e: except Exception as e:
_logger.error(f"save_valid_transactions_to_history failed: {e}") _logger.error(f"save_valid_transactions_to_history failed: {e}")
@staticmethod
def remove_stale_wallet_history(wallet, history_label):
"""Delete wallet-LOCAL will transactions saved under ``history_label``.
``save_valid_transactions_to_history`` stores the not-yet-signed
inheritance txs into the wallet's local history; those local
placeholders nominally spend the coins they reference. When the will is
REBUILT (prepare/build, auto-rebuild, on-close rebuild, CLI build) the
stale placeholders must be removed so the coins become available again
to the new build (see ``Util.get_available_utxos``). Only
wallet-local/future (non-broadcast) txs whose label matches the history
label template are removed; confirmed/broadcast history is never
touched. Returns the txids that were removed.
"""
if not wallet or not getattr(wallet, "adb", None):
return []
removed = []
for txid, label in Will._wallet_labels(wallet):
if not label or not Util._label_matches_history(label, history_label):
continue
try:
height = int(wallet.adb.get_tx_height(txid).height())
except Exception:
continue
if height not in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
continue
try:
wallet.adb.remove_transaction(txid)
removed.append(txid)
except Exception as e:
_logger.error(f"remove from history failed for {txid}: {e}")
continue
try:
wallet.set_label(txid, None)
except Exception as e:
_logger.error(f"set_label failed for {txid}: {e}")
try:
wallet.save_db()
except Exception as e:
_logger.error(f"save_db failed after history purge: {e}")
return removed
@staticmethod @staticmethod
def _add_transaction_to_history(wallet, tx, txid): def _add_transaction_to_history(wallet, tx, txid):
"""Store *tx* into the wallet's local history via ``adb``. """Store *tx* into the wallet's local history via ``adb``.

View File

@@ -53,6 +53,7 @@ from ...core.qrtransfer import (
QrTransferError, QrTransferError,
decode_transfer, decode_transfer,
encode_transfer, encode_transfer,
encode_transfer_best,
preset_index_for_chunk_size, preset_index_for_chunk_size,
split_frames, split_frames,
) )
@@ -1135,13 +1136,14 @@ class BalBuildWillDialog(BalDialog):
desired behaviour, and that the date shown in the panel/wizard must desired behaviour, and that the date shown in the panel/wizard must
reflect this anticipated date (so the calendar .ics also uses it). reflect this anticipated date (so the calendar .ics also uses it).
RELATIVE dates are additionally normalised here: a relative value RELATIVE recipes ("30d"/"1y") are PRESERVED: they are resolved against
("30d"/"1y") is re-parsed against "now" on every check, so it drifts the built will's frozen locktime on every check (via
away from the fixed transaction locktime and the postpone check would ``Util.resolve_locktime_against_tx`` for the postpone detection and
wrongly ask to invalidate the will every day. The stored locktime is ``resolve_date_to_check(..., built_locktime=...)`` for the reference
therefore frozen to the built transactions' absolute locktime, and a timestamp), so they no longer drift away from the built transactions
relative threshold is frozen to its "N days before the delivery" and never trigger the daily invalidate prompt. Freezing them to an
absolute value. absolute timestamp here would silently erase the user's relative
choice from WILL_SETTINGS.
We route the update through BalWindow.update_setting_widgets, which is We route the update through BalWindow.update_setting_widgets, which is
the single place that (1) stores the value in WILL_SETTINGS, (2) the single place that (1) stores the value in WILL_SETTINGS, (2)
@@ -1156,17 +1158,17 @@ class BalBuildWillDialog(BalDialog):
return return
min_locktime = int(min_locktime) min_locktime = int(min_locktime)
stored_locktime = self.bal_window.will_settings["locktime"] stored_locktime = self.bal_window.will_settings["locktime"]
# A relative value ("30d"/"1y") is a MOVING TARGET: it is re-parsed # A RELATIVE stored value ("30d"/"1y") is PRESERVED: it is resolved
# against "now" on every check, so it drifts one day per day away from # against the built transactions on every check (the post-build
# the fixed tx locktime and the postpone check would ALWAYS see a # `resolve_date_to_check` anchoring and `resolve_locktime_against_tx`
# postpone -> the plugin asks to invalidate the will every day. It must # in the postpone detection), so it no longer drifts and must not be
# therefore be normalised here to the frozen absolute locktime of the # frozen to an absolute timestamp here. Only an ABSOLUTE stored value
# built transactions, even when it happens to parse to the same moment # is compared with the built transactions (see below).
# today. (Only an absolute stored value is comparable, see below.)
is_relative_locktime = ( is_relative_locktime = (
isinstance(stored_locktime, str) isinstance(stored_locktime, str)
and stored_locktime[-1:].lower() in ("d", "y") and stored_locktime[-1:].lower() in ("d", "y")
) )
if not is_relative_locktime:
# Current stored delivery date, as a comparable UNIX timestamp. # Current stored delivery date, as a comparable UNIX timestamp.
try: try:
current = int(Util.parse_locktime_string(stored_locktime)) current = int(Util.parse_locktime_string(stored_locktime))
@@ -1174,54 +1176,28 @@ class BalBuildWillDialog(BalDialog):
# If the stored value cannot be parsed, fall back to syncing. # If the stored value cannot be parsed, fall back to syncing.
current = None current = None
# A genuine user-chosen POSTPONE (a later absolute date) is never # A genuine user-chosen POSTPONE (a later absolute date) is never
# overwritten; anything else is synced to the built transactions. # overwritten; a genuine automatic ANTICIPATION (built earlier
# than stored) is synced to the built transactions.
was_anticipation = current is not None and min_locktime < current was_anticipation = current is not None and min_locktime < current
if not is_relative_locktime and current is not None and not was_anticipation: if was_anticipation:
pass
else:
_logger.debug( _logger.debug(
f"sync delivery date to built tx locktime: " f"sync delivery date to built tx locktime: "
f"{current} -> {min_locktime}" f"{current} -> {min_locktime}"
) )
# Remember that we anticipated the date, so the later sign prompt can # Remember that we anticipated the date, so the later sign
# explain WHY signing is needed (see on_success_phase1). A pure # prompt can explain WHY signing is needed
# relative->absolute normalisation is NOT an anticipation. # (see on_success_phase1).
if was_anticipation:
self._date_was_anticipated = True self._date_was_anticipated = True
# update_setting_widgets stores the value, persists it and refreshes # update_setting_widgets stores the value, persists it and
# the date widgets in all panels/wizard (the .ics calendar too). # refreshes the date widgets in all panels/wizard (the .ics
# calendar too).
self.bal_window.update_setting_widgets( self.bal_window.update_setting_widgets(
min_locktime, "locktime", update_all=True min_locktime, "locktime", update_all=True
) )
# Same moving-target problem for a relative "Check Alive" threshold: # A relative "Check Alive" threshold ("N days BEFORE the delivery") is
# it means "N days BEFORE the delivery" (the settings widget resolves it # also PRESERVED: it is anchored on every check by
# as real_threshold = locktime - N days), so it is normalised to that # ``resolve_date_to_check`` / ``resolve_guard_threshold``, so it does
# absolute date, referenced against the now-absolute stored locktime. # not need to be frozen to an absolute date here.
threshold_raw = self.bal_window.will_settings.get("threshold")
if (
isinstance(threshold_raw, str)
and threshold_raw[-1:].lower() in ("d", "y")
):
try:
locktime_ts = int(
Util.parse_locktime_string(
self.bal_window.will_settings["locktime"]
)
)
real_threshold = int(
BalTimestamp(threshold_raw)
.to_date(locktime_ts, reverse=True)
.timestamp()
)
except Exception as e:
_logger.error(f"sync threshold to absolute failed: {e}")
else:
_logger.debug(
f"sync threshold {threshold_raw} -> absolute {real_threshold}"
)
self.bal_window.update_setting_widgets(
real_threshold, "threshold", update_all=True
)
def on_accept(self): def on_accept(self):
try: try:
@@ -2824,6 +2800,12 @@ class BalQrExportWidget(QWidget):
self.tx_strings = list(tx_strings) self.tx_strings = list(tx_strings)
self._stop_auto() self._stop_auto()
self.transfer = encode_transfer(self.tx_strings, compress=False) self.transfer = encode_transfer(self.tx_strings, compress=False)
# BAL QR now ships compact (best-of) compressed by default: smaller
# frames, and the importer reverses it via the per-frame flag. The
# other formats keep the raw transfer (they compress internally).
self._balqr_transfer, self._balqr_compressed = encode_transfer_best(
self.tx_strings
)
self._refresh_frames() self._refresh_frames()
self._update_intro() self._update_intro()
self._render() self._render()
@@ -2835,7 +2817,9 @@ class BalQrExportWidget(QWidget):
def _refresh_frames(self): def _refresh_frames(self):
if self.format == "balqr": if self.format == "balqr":
self.frames = split_frames( self.frames = split_frames(
self.transfer, self.chunk_size, compressed=False self._balqr_transfer,
self.chunk_size,
compressed=self._balqr_compressed,
) )
else: else:
self.frames = encode_animated_frames( self.frames = encode_animated_frames(

View File

@@ -20,6 +20,7 @@ Contents:
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ...core.heirs import get_op_return_hex, is_op_return_address
from ...core.input_rules import ( from ...core.input_rules import (
LockTimeEditor, LockTimeEditor,
normalize_locktime_raw_text, normalize_locktime_raw_text,
@@ -611,9 +612,14 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
# Use the overflow-safe converter: on Windows datetime.fromtimestamp # Use the overflow-safe converter: on Windows datetime.fromtimestamp
# raises OverflowError for timestamps past 2038 (e.g. NLOCKTIME_MAX). # raises OverflowError for timestamps past 2038 (e.g. NLOCKTIME_MAX).
_dt = BalTimestamp._safe_fromtimestamp(x) _dt = BalTimestamp._safe_fromtimestamp(x)
#if self.alarm != dt:
self.setDateTime(_dt)
self.alarm = _dt self.alarm = _dt
# Store the LOCAL wall-clock time, not the aware-UTC datetime:
# QDateTimeEdit keeps the given wall time with a LocalTime spec, so
# an aware-UTC datetime would make get_value() read back a timezone-
# shifted epoch. That broke the set_value -> get_value roundtrip and
# kept the valueEdited -> update_setting_widgets -> set_value cycle
# firing forever (infinite RecursionError on wizard "Next").
self.setDateTime(_dt.astimezone().replace(tzinfo=None))
@@ -1331,14 +1337,28 @@ class WillWidget(QWidget):
) )
detaillayout.addWidget(QLabel("")) detaillayout.addWidget(QLabel(""))
detaillayout.addWidget(QLabel("<b>Heirs:</b>")) detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
for heir in self.will[w].heirs: for heir_name in self.will[w].heirs:
if 'w!ll3x3c"' not in heir: if 'w!ll3x3c"' in heir_name:
continue
h = self.will[w].heirs[heir_name]
decoded_amount = Util.decode_amount( decoded_amount = Util.decode_amount(
self.will[w].heirs[heir][3], self._bal_parent.decimal_point h[3], self._bal_parent.decimal_point
) )
if is_op_return_address(h[0]):
data_hex = get_op_return_hex(h[0]) or ""
try:
decoded = bytes.fromhex(data_hex).decode(
"utf-8", errors="replace"
)
except Exception:
decoded = h[0]
detaillayout.addWidget(qlabel(heir_name, "OP_RETURN: " + decoded))
else:
detaillayout.addWidget( detaillayout.addWidget(
qlabel( qlabel(
heir, f"{decoded_amount} {self._bal_parent.base_unit_name}" heir_name,
f"{decoded_amount} {self._bal_parent.base_unit_name} "
f"[{h[0]}]",
) )
) )
if self.will[w].we: if self.will[w].we:
@@ -1354,6 +1374,10 @@ class WillWidget(QWidget):
f"{decoded_amount} {self._bal_parent.base_unit_name}", f"{decoded_amount} {self._bal_parent.base_unit_name}",
) )
) )
if self.will[w].we.get("address"):
detaillayout.addWidget(
qlabel(_("Address"), self.will[w].we["address"])
)
detaillayout.addStretch() detaillayout.addStretch()
pal = QPalette() pal = QPalette()
pal.setColor( pal.setColor(

View File

@@ -23,6 +23,7 @@ from ...core.checkalive import (
CheckAliveError, CheckAliveError,
check_alive_expired, check_alive_expired,
resolve_date_to_check, resolve_date_to_check,
resolve_guard_threshold,
) )
from .common import ( from .common import (
OP_RETURN_PREFIX, OP_RETURN_PREFIX,
@@ -466,6 +467,31 @@ class BalWindow:
def build_will(self, ignore_duplicate=True, keep_original=True): def build_will(self, ignore_duplicate=True, keep_original=True):
_logger.debug("building will...") _logger.debug("building will...")
# Drop stale wallet-LOCAL will placeholders saved by previous prepares
# so their coins are available to this build (see remove_stale...).
Will.remove_stale_wallet_history(
self.window.wallet, self.bal_plugin.HISTORY_LABEL.get()
)
# A (re)build may have anticipated the delivery (shorter heir recipes)
# while ``date_to_check`` is still anchored to the OLD built will. Using
# that stale anchor as the build filter would block every future
# delivery ("NO_FUTURE_DATE"). Recompute ``date_to_check`` for the will
# that is being built: its locktime is the earliest future delivery
# among the CURRENT heirs. The checks of the EXISTING will keep their
# anchored ``date_to_check`` (set in init_class_variables).
_new_locktime = min(
(
Util.parse_locktime_string(h[2])
for h in self.heirs.values()
),
default=None,
)
if _new_locktime:
self.date_to_check = resolve_date_to_check(
self.bal_plugin.is_basic_mode(),
self.will_settings,
built_locktime=_new_locktime,
)
will = {} will = {}
# willtodelete = [] # willtodelete = []
# willtoappend = {} # willtoappend = {}
@@ -745,6 +771,27 @@ class BalWindow:
raise e raise e
def is_locktime_below_threshold(self) -> bool:
"""True when the stored settings make the delivery earlier than the
Check Alive threshold (the "locktime is lower than threshold" guard).
Compares the delivery against the settings-derived threshold on the
SAME reference frame (see ``resolve_guard_threshold``), never against
the built-will-anchored ``date_to_check``: anchoring the guard to an
old, longer built will would wrongly fire right after the delivery was
shortened. The anchored reference still governs the validity and
expiry checks, which is where ``date_to_check`` belongs.
In BASIC mode there is no threshold, so the locktime is checked against
``date_to_check`` (= now) exactly as before.
"""
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
threshold_ts = resolve_guard_threshold(
self.bal_plugin.is_basic_mode(), self.will_settings
)
if threshold_ts is not None:
return locktime < threshold_ts
return self.date_to_check is not None and locktime < self.date_to_check
def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True): def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True):
try: try:
_logger.info( _logger.info(
@@ -757,6 +804,11 @@ class BalWindow:
if not self.heirs: if not self.heirs:
_logger.warning("not heirs {}".format(self.heirs)) _logger.warning("not heirs {}".format(self.heirs))
return return
# Free the coins locked by stale wallet-LOCAL will placeholders
# BEFORE the amount/UTXO checks below (Step 1) see them.
Will.remove_stale_wallet_history(
self.window.wallet, self.bal_plugin.HISTORY_LABEL.get()
)
try: try:
self.init_class_variables() self.init_class_variables()
Will.check_amounts( Will.check_amounts(
@@ -791,8 +843,7 @@ class BalWindow:
) )
) )
return return
locktime = Util.parse_locktime_string(self.will_settings["locktime"]) if self.is_locktime_below_threshold():
if locktime < self.date_to_check:
self.show_error(_("locktime is lower than threshold")) self.show_error(_("locktime is lower than threshold"))
return return
if not self.no_willexecutor: if not self.no_willexecutor:

18
tests/conftest.py Normal file
View File

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

View File

@@ -18,6 +18,7 @@ import shutil
import sys import sys
import tempfile import tempfile
import time import time
import unittest.mock as mock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
@@ -25,6 +26,8 @@ from electrum.simple_config import SimpleConfig
from electrum.util import UserFacingException from electrum.util import UserFacingException
from bal.cli.controller import BalController from bal.cli.controller import BalController
from bal.core.heirs import Heirs
from bal.core.util import Util
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf" VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
@@ -227,6 +230,39 @@ def test_auto_rebuild_threshold_passed_invalidates():
assert result["invalidation_tx"] == {"txid": None, "tx": None} assert result["invalidation_tx"] == {"txid": None, "tx": None}
def test_build_will_reanchors_date_to_check_to_new_locktime():
"""CLI mirror of the GUI regression: ``build_will`` must re-anchor
``date_to_check`` to the CURRENT heirs' earliest delivery before building,
so an anticipated (shortened) rebuild is not blocked by the old built-will
anchor (which would yield NO_FUTURE_DATE in ``get_transactions``).
"""
with Plugin() as plugin:
plugin.USER_TYPE.set("advanced")
plugin.NO_WILLEXECUTOR.set(True)
plugin.ENABLE_MULTIVERSE.set(True)
plugin.WILL_SETTINGS.set({"threshold": "150d", "locktime": "2y", "baltx_fees": 20})
c = _make_controller(plugin)
c.no_willexecutor = True
c.heirs["alice"] = [VALID_ADDRESS, "100%", "1y"]
# Simulate an old built will frozen at 2y: reload keeps its (stale)
# anchor, which would reject the anticipated "1y" delivery.
c.init_class_variables()
stale_anchor = Util.parse_locktime_string("2y") - 150 * 86400
c.date_to_check = stale_anchor
assert Util.parse_locktime_string("1y") < c.date_to_check
with mock.patch.object(Heirs, "get_transactions", return_value={}) as gt:
result = c.build_will()
assert result == {}
# build_will re-anchored date_to_check to the new 1y delivery...
expected = Util.parse_locktime_string("1y") - 150 * 86400
assert abs(c.date_to_check - expected) < 3600
# ...and used THAT anchor as the build filter, not the stale 2y one.
assert gt.call_args.args[-1] == c.date_to_check
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# runner # runner
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -426,6 +426,7 @@ def test_bbqr_part_number_limits():
def test_detect_format_recognises_all_formats(): def test_detect_format_recognises_all_formats():
assert aq.detect_format("BALQR1|1|1||payload") == "balqr" 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.ur1_frames(b"x", 400)[0]) == "ur1"
assert aq.detect_format(aq.ur2_frames(b"x", 400)[0]) == "ur2" assert aq.detect_format(aq.ur2_frames(b"x", 400)[0]) == "ur2"
assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "bbqr" assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "bbqr"
@@ -444,6 +445,9 @@ def test_detect_format_rejects_garbage():
def test_parse_for_detection_keys(): def test_parse_for_detection_keys():
bal = aq.parse_for_detection("BALQR1|3|2||payload") bal = aq.parse_for_detection("BALQR1|3|2||payload")
assert bal == ("balqr", "balqr:3", 3, 2) 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]) 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 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]) v1 = aq.parse_for_detection(aq.ur1_frames(b"x"*50, 120)[0])

View File

@@ -18,6 +18,7 @@ from bal.core.checkalive import ( # noqa: E402 (path insert above)
CheckAliveError, CheckAliveError,
check_alive_expired, check_alive_expired,
resolve_date_to_check, resolve_date_to_check,
resolve_guard_threshold,
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -148,6 +149,76 @@ def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
assert abs(result - expected) < 1 assert abs(result - expected) < 1
# ------------------------------------------------------------------ #
# resolve_guard_threshold
# ------------------------------------------------------------------ #
def test_guard_threshold_basic_mode_returns_none():
fake_now = 1_800_000_000.0
threshold = resolve_guard_threshold(True, {"threshold": "30d"}, now=fake_now)
assert threshold is None
def _guard_locktime(settings, fake_now):
"""Reproduce the call-site locktime expression of the guard."""
from bal.core.plugin_base import BalTimestamp
return BalTimestamp(settings["locktime"]).to_timestamp(fake_now)
def test_guard_threshold_absolute():
fake_now = 1_800_000_000.0
locktime = fake_now + 90 * 86400
threshold = locktime - 30 * 86400
settings = {"locktime": locktime, "threshold": threshold}
assert resolve_guard_threshold(False, settings, now=fake_now) == threshold
def test_guard_threshold_relative_fresh_anchor():
"""A relative threshold must be anchored to the FRESH locktime so the
guard and the settings always share one reference frame.
Regression for the false positive where a still-valid built will frozen at
a LONGER delivery ("2y") anchored ``date_to_check`` beyond the currently
stored shorter delivery ("1y"): the old guard compared the fresh "1y"
locktime against that anchored threshold and wrongly fired "locktime is
lower than threshold", even though the settings themselves are consistent
(locktime is 30d AFTER the threshold).
"""
fake_now = 1_800_000_000.0
settings = {"locktime": "1y", "threshold": "30d"}
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():
fake_now = 1_800_000_000.0
threshold = fake_now + 200 * 86400
settings = {"locktime": "1y", "threshold": threshold}
assert resolve_guard_threshold(False, settings, now=fake_now) == threshold
# "1y" from now is later than the stored absolute threshold: allowed.
locktime = _guard_locktime(settings, fake_now)
assert locktime > threshold
def test_guard_threshold_missing_returns_none():
fake_now = 1_800_000_000.0
assert resolve_guard_threshold(False, {"locktime": "1y"}, now=fake_now) is None
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# check_alive_expired # check_alive_expired
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

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

View File

@@ -100,9 +100,9 @@ def test_size_greater_than_payload():
def test_exact_single_frame_boundary(): def test_exact_single_frame_boundary():
# A 138-byte payload exactly fills the 150-byte preset budget (the 12-char # A 139-byte payload exactly fills the 150-byte preset budget (the 11-char
# empty-flags header plus payload), so the encoded frame is exactly 150. # compact header plus payload), so the encoded frame is exactly 150.
tx_strings = ["a" * 138] tx_strings = ["a" * 139]
payload = encode_transfer(tx_strings) payload = encode_transfer(tx_strings)
frames = split_frames(payload, 150) frames = split_frames(payload, 150)
assert len(frames) == 1 assert len(frames) == 1
@@ -157,6 +157,119 @@ def test_compressed_roundtrip_through_frames():
assert decoded == tx_strings 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 # Malformed input
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #

View File

@@ -13,8 +13,14 @@ import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.checkalive import resolve_date_to_check
from bal.core.util import copy_structure from bal.core.util import copy_structure
from bal.core.will import Will, WillItem from bal.core.will import (
HeirNotFoundException,
NoHeirsException,
Will,
WillItem,
)
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2) # A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
_VALID_TX_HEX = ( _VALID_TX_HEX = (
@@ -210,6 +216,59 @@ def test_check_heir_added_triggers_rebuild():
assert raised, "adding an heir must raise HeirNotFoundException" assert raised, "adding an heir must raise HeirNotFoundException"
def test_shortened_relative_recipe_on_signed_rebuilds_not_noheirs():
"""Regression (karen7): heirs shortened "2y"->"1y" on a signed will whose
ADVANCED check window is anchored to the frozen built delivery must trigger
a plain rebuild (HeirNotFoundException), NOT "No Heirs".
Earlier the count gate resolved each current relative recipe from *now*
while ``check_date`` was anchored to the (longer) frozen built locktime, so
every heir fell below the window and was silently excluded -> NoHeirs even
though the will simply needs rebuilding on the new, shorter schedule."""
lt = 2_100_000_000 # a far-future frozen delivery (a "2y" build)
will_heirs = {"alice": ["addr_alice", 5000, "2y"]}
current_heirs = {"alice": ["addr_alice", 5000, "1y"]}
will = _make_will_with_heirs(will_heirs, lt)
will["willid_1"].set_status("COMPLETE", True)
check_date = resolve_date_to_check(
False, {"locktime": "2y", "threshold": "150d"}, built_locktime=lt
)
assert check_date < lt # the anchored window really precedes the delivery
raised = None
try:
Will.check_willexecutors_and_heirs(
will, copy_structure(current_heirs), {}, False, check_date, 100
)
except HeirNotFoundException:
raised = "rebuild"
except NoHeirsException:
raised = "noheirs"
assert raised == "rebuild", (
f"shortened recipe on a signed will must rebuild, got {raised!r}"
)
def test_all_heirs_past_check_date_still_noheirs():
"""The "no valid heirs" gate is preserved: when every heir is coherent with
the built will but its delivery lies before ``check_date``, the check still
reports NoHeirsException (there is literally nothing future to inherit)."""
lt = 1_900_000_000
will_heirs = {"alice": ["addr_alice", 5000, str(lt)]}
will = _make_will_with_heirs(will_heirs, lt)
raised = None
try:
Will.check_willexecutors_and_heirs(
will, copy_structure(will_heirs), {}, False, lt + 86400, 100
)
except HeirNotFoundException:
raised = "rebuild"
except NoHeirsException:
raised = "noheirs"
assert raised == "noheirs", (
f"a fully delivered will must report NoHeirs, got {raised!r}"
)
def test_needs_server_check(): def test_needs_server_check():
"""Check button selection logic: only a VALID, PUSHED will with a """Check button selection logic: only a VALID, PUSHED will with a
will-executor that is not yet CHECKED must be queried on the server. will-executor that is not yet CHECKED must be queried on the server.

View File

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

View File

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

View File

@@ -107,10 +107,12 @@ class StubWillItem:
return {"tx": str(self.tx), "status": self.statuses} return {"tx": str(self.tx), "status": self.statuses}
def _make_willitems(n=3, payload_len=120): 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)]
return { return {
"item{}".format(i): StubWillItem("T{}".format(i) * payload_len) "item{}".format(i): StubWillItem(p)
for i in range(n) for i, p in enumerate(payloads)
} }
@@ -288,8 +290,17 @@ def test_export_filter_empty_reverts():
def test_export_navigation_and_chunk_change(): def test_export_navigation_and_chunk_change():
# Low-redundancy serialized transactions resist deflate, so even the
# compressed best-of transfer still needs several frames at the default
# chunk and navigation across frames is exercised.
def noisy(pad):
return "".join("{:02x}".format((pad * 31 + j * 101 + j * j) % 256) for j in range(200))
bw = FakeBalWindow() bw = FakeBalWindow()
bw.willitems = _make_willitems(n=6, payload_len=400) bw.willitems = _make_willitems(
n=6, payload_len=400,
payloads=["{}0{}".format(noisy(i), "T" * 50) for i in range(6)],
)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page page = d.qr_page
first_count = len(page.frames) first_count = len(page.frames)
@@ -568,7 +579,7 @@ def test_export_format_combo_switches_codecs():
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page page = d.qr_page
assert page.format == "balqr" assert page.format == "balqr"
assert page.frames[0].startswith("BALQR1|") assert page.frames[0].startswith("BAL1")
assert page.format_combo.count() == 4 assert page.format_combo.count() == 4
page._on_format_change(1) # BC-UR v1 page._on_format_change(1) # BC-UR v1

View File

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

View File

@@ -390,6 +390,110 @@ def test_insufficient_funds_warns():
assert not ctl.willitems assert not ctl.willitems
def test_guard_not_blocked_by_old_built_will():
"""Regression: shortening the delivery in the STORED settings (relative
"1y"/"30d") while an old, still-VALID built will is frozen at a longer
locktime must NOT fire the "locktime is lower than threshold" guard.
The old guard compared the fresh settings locktime against ``date_to_check``
anchored to the built will (see ``resolve_date_to_check``), so a built-will
delivery longer than the settings' one made it fire even though the settings
are internally consistent (locktime is 30d AFTER the threshold). The guard
must instead compare the stored settings on a single reference frame
(``BalWindow.is_locktime_below_threshold``); ``date_to_check`` keeps its
built anchor for the expiry/validity checks.
"""
with _no_willexecutors():
ctl = make_controller()
ctl.bal_plugin.USER_TYPE.set("advanced") # ADVANCED Check-Alive mode
ctl.prepare_will()
txid, item = _single(ctl)
# Freeze the built (VALID) will at a delivery one year longer than the
# now-shortened settings: the pre-fix guard would reject the rebuild.
item.tx.locktime = item.tx.locktime + 365 * 86400
ctl.will_settings = {"locktime": "1y", "threshold": "30d"}
Util.fix_will_settings_tx_fees(ctl.will_settings)
ctl.init_class_variables()
# date_to_check is anchored to the built will (long delivery)...
assert ctl.date_to_check == item.tx.locktime - 30 * 86400
# ...and the OLD guard would have fired here:
old_locktime = Util.parse_locktime_string(ctl.will_settings["locktime"])
assert old_locktime < ctl.date_to_check
# but the settings themselves are consistent, so the guard must pass:
assert ctl.is_locktime_below_threshold() is False
assert not ctl.window.errors
def test_anticipated_rebuild_reanchors_date_to_check():
"""Regression (karen7): rebuilding a SIGNED will whose delivery was
anticipated (per-heir recipes shortened from 2y to 1y, ADVANCED mode) must
succeed.
``date_to_check`` stays anchored to the OLD built delivery for the validity
checks, but ``build_will`` must re-anchor it to the NEW (earliest current)
delivery as its build filter: before the fix the stale 2028 anchor rejected
every "1y" heir (cmp <= 0 in ``fixed_percent_lists_amount``) and the build
reported ``NO_FUTURE_DATE``. The old signed item is then superseded by
``search_rai`` (REPLACED -> no on-chain invalidation) and the rebuilt will
is coherent again.
"""
with _no_willexecutors():
ctl = make_controller()
ctl.bal_plugin.USER_TYPE.set("advanced")
# Per-heir deliveries require multiverse mode (the only way heirs can
# carry a different recipe than the settings locktime).
ctl.bal_plugin.ENABLE_MULTIVERSE.set(True)
ctl.will_settings = {"locktime": "2y", "threshold": "150d", "baltx_fees": 20}
Util.fix_will_settings_tx_fees(ctl.will_settings)
ctl.heirs["alice"][2] = "2y"
ctl.heirs["bob"][2] = "2y"
# Build and sign a 2y will (the old, committed delivery).
ctl.prepare_will()
old_txid, _old_item = _single(ctl)
old_locktime = _old_item.tx.locktime
signed = ctl.sign_transactions(None)
_old_item.tx = Will.get_tx_from_any(str(signed[old_txid]))
Will.check_signatures(ctl.willitems, ctl.wallet)
assert _old_item.get_status("COMPLETE")
# Anticipate: shorten every heir to 1y.
ctl.heirs["alice"][2] = "1y"
ctl.heirs["bob"][2] = "1y"
ctl.init_class_variables()
# date_to_check stays anchored to the OLD built delivery...
assert ctl.date_to_check == old_locktime - 150 * 86400
# ...and that stale anchor would reject the anticipated "1y" dates.
assert Util.parse_locktime_string("1y") < ctl.date_to_check
# The rebuild must succeed (re-anchored to the new delivery).
willitems = ctl.build_inheritance_transaction()
assert ctl.heirs.last_build_error is None, "NO_FUTURE_DATE must not fire"
new_valid = [
it for tid, it in willitems.items()
if tid != old_txid and it.get_status("VALID")
]
assert new_valid, "the anticipated (1y) will must build and stay VALID"
new_item = new_valid[0]
assert new_item.tx.locktime < old_locktime, "delivery must be anticipated"
# date_to_check was re-anchored to the rebuilt delivery (1y minus 150d).
assert abs(ctl.date_to_check - (new_item.tx.locktime - 150 * 86400)) < 3600
# The old signed item is kept but superseded (REPLACED -> not VALID).
assert _old_item.get_status("REPLACED") is True
assert _old_item.get_status("VALID") is False
# The rebuilt will is coherent (plain rebuild, no on-chain invalidation).
assert ctl.check_will() is True
assert not any("delivery date" in m for m in ctl.window.messages)
assert not ctl.window.errors
def _run_all(): def _run_all():
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")] tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
for fn in tests: for fn in tests:

View File

@@ -18,9 +18,10 @@ The two gates that produced the prompt are covered here:
never read as EXPIRED because the check window drifts past the frozen never read as EXPIRED because the check window drifts past the frozen
tx locktime. tx locktime.
The karen7 regtest wallet fixture (``tests/karen7``) reproduces the exact The reported state (reproduced hermetically here — the original live wallet
reported state: heirs with ``"1y"``, a signed/pushed/checked item whose frozen dump ``tests/karen7`` is gitignored and regenerated as the wallet evolves) is:
tx.locktime is 2027-08-05 (built 2026-08-05), and will_settings heirs with ``"1y"``, a signed/pushed/checked item whose frozen tx.locktime is
2027-08-05 (built 2026-08-05), and will_settings
``{"locktime": "2y", "threshold": "150d"}``. ``{"locktime": "2y", "threshold": "150d"}``.
Run: Run:
@@ -28,16 +29,14 @@ Run:
python3 tests/test_heir_relative_anchor.py python3 tests/test_heir_relative_anchor.py
""" """
import json
import os import os
import sys import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
import pytest # noqa: E402 (path insert above)
from electrum import constants # noqa: E402 (path insert above) from electrum import constants # noqa: E402 (path insert above)
constants.net = constants.BitcoinRegtest
from bal.core.checkalive import resolve_date_to_check # noqa: E402 from bal.core.checkalive import resolve_date_to_check # noqa: E402
from bal.core.util import copy_structure # noqa: E402 from bal.core.util import copy_structure # noqa: E402
from bal.core.will import ( # noqa: E402 from bal.core.will import ( # noqa: E402
@@ -49,6 +48,16 @@ from bal.core.will import ( # noqa: E402
WillPostponedException, WillPostponedException,
) )
@pytest.fixture(autouse=True)
def _regtest_net():
"""Run these regtest-focused tests with BitcoinRegtest, restoring mainnet
afterwards so sibling test modules are unaffected by the net switch."""
constants.net = constants.BitcoinRegtest
yield
constants.net = constants.BitcoinMainnet
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0; # A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0;
# the tests override ``tx.locktime`` to simulate the frozen signed locktime. # the tests override ``tx.locktime`` to simulate the frozen signed locktime.
_VALID_TX_HEX = ( _VALID_TX_HEX = (
@@ -158,55 +167,52 @@ def test_absolute_postpone_on_signed_still_detected():
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# karen7 wallet regression (real fixture) # karen7 regression (hermetic, no live wallet fixture)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# karen7's reported state, reproduced hermetically: heirs "1y", a signed item
def _load_karen7(): # frozen at delivery 2027-08-05 (built 2026-08-05), will_settings with a
path = os.path.join(os.path.dirname(__file__), "karen7") # relative "150d" delivery window and a "2y" promised locktime.
with open(path) as f: _WILL_SETTINGS = {"locktime": "2y", "threshold": "150d"}
return json.load(f)
def test_karen7_frozen_delivery_not_expired(): def test_karen7_frozen_delivery_not_expired():
"""ADVANCED date_to_check anchored to the frozen tx locktime: the check """ADVANCED date_to_check anchored to the frozen tx locktime: the check
window opens BEFORE the delivery, so the will is never read as expired.""" window opens BEFORE the delivery, so the will is never read as expired."""
data = _load_karen7() heirs = {"alice": ["addr_alice", 5000, "1y"]}
will_settings = data["will_settings"] item = _make_will_item(copy_structure(heirs), _FROZEN, status_complete=True)
valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d" will = {"willid_1": item}
wi = WillItem(data["will"][valid_wid], _id=valid_wid) built_locktime = Will.get_min_locktime(will)
built_locktime = Will.get_min_locktime({valid_wid: wi})
assert built_locktime is not None assert built_locktime is not None
assert built_locktime == int(wi.tx.locktime) assert built_locktime == int(item.tx.locktime)
date_to_check = resolve_date_to_check( date_to_check = resolve_date_to_check(
False, will_settings, now=1_800_000_000.0, built_locktime=built_locktime False, _WILL_SETTINGS, now=1_800_000_000.0, built_locktime=built_locktime
) )
assert int(date_to_check) < built_locktime assert int(date_to_check) < built_locktime
# Re-evaluated 10 days later the window is identical (no daily drift). # Re-evaluated 10 days later the window is identical (no daily drift).
later = resolve_date_to_check( later = resolve_date_to_check(
False, will_settings, now=1_800_000_000.0 + 10 * 86400, False, _WILL_SETTINGS, now=1_800_000_000.0 + 10 * 86400,
built_locktime=built_locktime, built_locktime=built_locktime,
) )
assert date_to_check == later assert date_to_check == later
def test_karen7_unchanged_heirs_are_coherent(): def test_karen7_unchanged_heirs_are_coherent():
"""The karen7 heirs (unchanged relative "2d") are coherent with the frozen """Unchanged relative "1y" heirs are coherent with the frozen signed tx:
signed tx: the plugin must NOT ask to invalidate the will.""" the plugin must NOT ask to invalidate the will."""
data = _load_karen7() heirs = {"alice": ["addr_alice", 5000, "1y"]}
valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
# Use _FROZEN (a UTC-midnight value) so the check is compatible with # Use _FROZEN (a UTC-midnight value) so the check is compatible with
# the UTC anchoring code. # the UTC anchoring code.
frozen_locktime = _FROZEN frozen_locktime = _FROZEN
date_to_check = resolve_date_to_check( date_to_check = resolve_date_to_check(
False, data["will_settings"], False, _WILL_SETTINGS,
now=1_800_000_000.0, now=1_800_000_000.0,
built_locktime=frozen_locktime, built_locktime=frozen_locktime,
) )
outcome = _run_heir_check( outcome = _run_heir_check(
data["will"][valid_wid]["heirs"], copy_structure(heirs),
data["heirs"], copy_structure(heirs),
frozen_locktime, frozen_locktime,
status_complete=True, status_complete=True,
) )
@@ -219,6 +225,7 @@ def test_karen7_unchanged_heirs_are_coherent():
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
if __name__ == "__main__": if __name__ == "__main__":
constants.net = constants.BitcoinRegtest
for name in sorted(dir()): for name in sorted(dir()):
if name.startswith("test_"): if name.startswith("test_"):
globals()[name]() globals()[name]()

View File

@@ -171,6 +171,50 @@ def test_will_widget_explicit_will():
assert w2.will is live assert w2.will is live
# ------------------------------------------------------------------ #
# WillWidget shows heir/willexecutor addresses and decodes OP_RETURN
# ------------------------------------------------------------------ #
def test_will_widget_shows_addresses_and_decodes_opreturn():
from PyQt6.QtWidgets import QLabel
from bal.core.will import WillItem
from bal.gui.qt.widgets import WillWidget
op_hex = "68656c6c6f" # "hello" in hex
heirs = {
"bob": ["bc1qtestaddr", 1_000_000, 1000, 500_000],
"msg": [f"OP_RETURN:{op_hex}", 0, 1000, 0],
}
wi = WillItem(
_make_willitem_dict(
heirs=heirs,
willexecutor={
"url": "https://exec.example",
"address": "bc1qexecaddr",
"base_fee": 200_000,
},
)
)
wi._id = "w0"
fake_parent = SimpleNamespace(
decimal_point=8,
base_unit_name="BTC",
bal_window=SimpleNamespace(
willitems={},
bal_plugin=SimpleNamespace(
_hide_replaced=False, _hide_invalidated=False
),
show_transaction=lambda *a, **k: None,
),
)
w = WillWidget(parent=fake_parent, will={"w0": wi})
texts = [lbl.text() for lbl in w.findChildren(QLabel)]
assert any("bc1qtestaddr" in t for t in texts), texts
assert any("OP_RETURN: hello" in t for t in texts), texts
assert any("bc1qexecaddr" in t for t in texts), texts
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# WillDetailDialog external-will mode # WillDetailDialog external-will mode
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

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