feat: animated-QR transfer, Android reader, relative-locktime preservation, karen7 hermetic tests
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@ tmp*
|
||||
|
||||
# Release artifacts
|
||||
bal_v*.zip.*
|
||||
tests/karen7
|
||||
|
||||
231
.opencode/plans/plan_android_reader.md
Normal file
231
.opencode/plans/plan_android_reader.md
Normal 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).
|
||||
@@ -3,6 +3,9 @@
|
||||
BAL — Bitcoin After Life, an Electrum plugin (inheritance / dead-man's-switch).
|
||||
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)
|
||||
|
||||
Two separate venvs; using the wrong one is the #1 mistake.
|
||||
|
||||
170
AUDIO_MODEM_DEBIAN.md
Normal file
170
AUDIO_MODEM_DEBIAN.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Audio MODEM on Debian — setup & troubleshooting
|
||||
|
||||
How to make the optional **audio channel** of BAL (and Electrum's own
|
||||
`audio_modem` plugin) work on Debian/Ubuntu. The channel lets you send a will
|
||||
to another device as acoustic OFDM tones instead of scanning QR codes.
|
||||
|
||||
Recommended reading before starting: `CHANGELOG.md` entry 56
|
||||
(Audio-environment notes) and `HANDOFF.md` (Dev-box audio prerequisites).
|
||||
|
||||
---
|
||||
|
||||
## 1. What you need (three independent pieces)
|
||||
|
||||
| Piece | Provides | Where it comes from |
|
||||
|--------------------------|--------------------------------------------|--------------------------------------|
|
||||
| `amodem` (Python) | OFDM modulation/demodulation | `pip install amodem` (any venv) |
|
||||
| `libportaudio.so` | sound I/O backend used by `amodem.audio` | Debian package `libportaudio2` (+ dev symlink, see §2) |
|
||||
| Electrum `audio_modem` | the plugin whose `_send`/`_recv` BAL reuses | built into Electrum |
|
||||
|
||||
BAL shows the audio buttons only when the plugin is **enabled** (Tools →
|
||||
Plugins → Audio Modem) and `amodem` is importable.
|
||||
|
||||
> On a headless/CI box there is no speaker/mic, but the channel can still be
|
||||
> verified with the **sink-monitor loopback** in §4.
|
||||
|
||||
---
|
||||
|
||||
## 2. The two line fixes (this is the part everyone forgets)
|
||||
|
||||
Debian ships a versioned `libportaudio.so.2` but **not** the unversioned
|
||||
`libportaudio.so` that old `amodem` code uses, and `amodem` uses NumPy APIs
|
||||
removed in NumPy 2.x. Both fail **silently** (the plugin's `_send` runs the
|
||||
load inside a `WaitingDialog` thread without an `on_error` handler).
|
||||
|
||||
### 2a. PortAudio unversioned symlink
|
||||
|
||||
Install the dev package (creates the unversioned symlink), or create it by
|
||||
hand:
|
||||
|
||||
```bash
|
||||
sudo apt install libportaudio2 libportaudio-dev # preferred
|
||||
# or, without the package:
|
||||
sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 \
|
||||
/usr/lib/x86_64-linux-gnu/libportaudio.so
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
python3 -c "import amodem.audio; print(amodem.audio.Interface(config=None).load('libportaudio.so').call('GetVersionText'))"
|
||||
# b'PortAudio V19...' <-- success
|
||||
```
|
||||
|
||||
> **No-sudo alternative** (fine for one-shot tests): point `LD_LIBRARY_PATH`
|
||||
> at a directory containing a `libportaudio.so` symlink to the `.so.2`:
|
||||
> ```bash
|
||||
> mkdir -p /tmp/portaudio_stub
|
||||
> ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /tmp/portaudio_stub/libportaudio.so
|
||||
> export LD_LIBRARY_PATH=/tmp/portaudio_stub:$LD_LIBRARY_PATH
|
||||
> ```
|
||||
|
||||
### 2b. amodem vs NumPy 2.x (`tostring` removed)
|
||||
|
||||
`amodem` 1.16.0 calls `numpy.ndarray.tostring()`, removed in NumPy 2.x
|
||||
(≥ 2.4.6 dies with `AttributeError` on the first sample write, so **no carrier
|
||||
is ever emitted**). Either pin NumPy < 2, or patch the single line in the
|
||||
installed package:
|
||||
|
||||
```bash
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
python3 -m pip install "numpy<2" # option A (downgrade)
|
||||
# option B (patch; path depends on your site-packages):
|
||||
sed -i "s/sym.astype('int16').tostring()/sym.astype('int16').tobytes()/" \
|
||||
"$BAL_HOME/electrum/env/lib/python3.11/site-packages/amodem/common.py"
|
||||
```
|
||||
|
||||
> This must be done on **every** machine that receives/sends audio (both ends
|
||||
> of the channel use the same code), and again after reinstalling/upgrading
|
||||
> `amodem`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Environment checklist (dev box, already applied)
|
||||
|
||||
These were applied on the current dev box and do NOT need to be re-done:
|
||||
|
||||
- `amodem` installed in the runtime venv (`1.16.0`).
|
||||
- `amodem/common.py` patched `tostring()` → `tobytes()`.
|
||||
- System symlink or `LD_LIBRARY_PATH` stub for `libportaudio.so`.
|
||||
- PulseAudio running; default sink `ALC236 Analog`, default source DMIC.
|
||||
|
||||
Check them in one command:
|
||||
|
||||
```bash
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
python3 - <<'EOF'
|
||||
import amodem, ctypes, numpy, zlib
|
||||
print("amodem", amodem.__version__)
|
||||
print("numpy", numpy.__version__, "(2.x needs the tobytes patch)")
|
||||
import amodem.audio
|
||||
amodem.audio.Interface(config=None).load("libportaudio.so")
|
||||
print("libportaudio.so loaded OK (symlink or LD_LIBRARY_PATH in place)")
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Verifying the channel (no speakers/mic needed)
|
||||
|
||||
Full **send → sink → sink-monitor → recv** round-trip on one machine:
|
||||
|
||||
```bash
|
||||
# 1) route capture at the loop and remember the original source
|
||||
MON="$(pactl get-default-sink).monitor"; ORIG=$(pactl get-default-source)
|
||||
pactl set-default-source "$MON"
|
||||
|
||||
# 2) run the round-trip (uses zlib-compressed payload like the plugin)
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
timeout 90 python3 /tmp/opencode/bal_audio_loopback.py
|
||||
# expected: bitrate 1.0 kbps ... send done ... RECV OK
|
||||
|
||||
# 3) restore the original source
|
||||
pactl set-default-source "$ORIG"
|
||||
```
|
||||
|
||||
Any payload you like: `python3 /tmp/opencode/bal_audio_loopback.py "BALQR|1|1|0|hi"`.
|
||||
|
||||
With real speakers + mic instead, skip the `pactl` swapping, put the devices
|
||||
close, keep volumes high, and run the same script.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing through the real GUI
|
||||
|
||||
1. **Tools → (Plugins) → Audio Modem** → enable it. If asked for settings,
|
||||
pick a bitrate: default `slowest()` is ~1.0–1.2 kbps (a ~2 KB will takes
|
||||
~15–20 s of audio); higher bitrates are faster but less robust.
|
||||
2. Wallet A → BAL will list → **Export → QR Codes → Audio…**
|
||||
(the audio transport sends the raw newline-joined tx list, no BAL framing).
|
||||
3. Wallet B → will list → **Import via QR → Audio…** → wait for
|
||||
"Waiting for audio (... kbps)…", a loading cursor while demodulating,
|
||||
then the decoded slots appear → review/sign wizard opens.
|
||||
4. One machine only: apply the §4 monitor trick in the shell where Electrum
|
||||
runs (export plays to the sink; import records from the sink monitor).
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| No sound at all, no error anywhere in the log | `libportaudio.so` not loadable (silent) | §2a symlink or `LD_LIBRARY_PATH` stub |
|
||||
| Sound played, "Timeout waiting for carrier" on the receive end | Capture routed to the wrong device / mic muted / no speakers | §4 monitor trick; `pactl` source check; raise volume; move devices closer |
|
||||
| "Decoding failed" after carrier, no payload | Send side died with numpy `tostring` → nothing modulated | §2b patch or `numpy<2` on BOTH machines |
|
||||
| Buttons "Audio…" missing in BAL dialogs | `audio_modem` disabled in Plugins, or `amodem` not importable in the running venv | Enable plugin; `pip install amodem` |
|
||||
| Audio too long / too slow | 1 kbps default | Raise bitrate in Audio Modem settings dialog |
|
||||
|
||||
---
|
||||
|
||||
## 7. No-sudo quick reference (all commands)
|
||||
|
||||
```bash
|
||||
python3 -m pip install amodem
|
||||
mkdir -p /tmp/portaudio_stub
|
||||
ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /tmp/portaudio_stub/libportaudio.so
|
||||
export LD_LIBRARY_PATH=/tmp/portaudio_stub:$LD_LIBRARY_PATH
|
||||
# numpy >= 2 (one of):
|
||||
pip install "numpy<2" # or patch amodem/common.py tobytes
|
||||
```
|
||||
334
CHANGELOG.md
334
CHANGELOG.md
@@ -2909,6 +2909,108 @@ renumber the grid rows.
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
## 56. QR / audio will transfer (chunked multi-QR export/import + review-and-sign wizard)
|
||||
|
||||
**Date:** 2026-08-27
|
||||
|
||||
**Goal (owner request):** export a will to another device via QR codes (with
|
||||
an optional audio channel on top), and import it there with a per-transaction
|
||||
review-and-sign flow. QR codes are chunked because a full will usually exceeds
|
||||
a single code's capacity.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/qrtransfer.py` (new, GUI-free): the wire format and chunk
|
||||
scheduler — `BALQR{version}|{total}|{index}|{flags}|{payload}` frames,
|
||||
`encode_transfer` / `decode_transfer`, `split_frames` / `parse_frame` /
|
||||
`assemble`, optional `Z` (zlib+base64) compression at export, four chunk
|
||||
presets (150/400/900/1800 bytes/frame, EC level M), plus
|
||||
`preset_index_for_chunk_size` and the `QrTransferError` exception family.
|
||||
- `tests/test_core_qr_transfer.py` (new): round-trips (plain/compressed),
|
||||
boundaries (exact-fit, size>payload, `|` in payload), min-size guard, bad
|
||||
magic/version/numbers/flags, multi-frame reassembly, header consistency.
|
||||
- `bal/core/plugin_base.py`: new `QR_CHUNK_SIZE` config (default 150).
|
||||
- `bal/gui/qt/plugin.py`: settings-dialog row 16 "QR Code Size" combo
|
||||
(4 presets) + reset button, visible in BASIC and ADVANCED.
|
||||
- `bal/gui/qt/dialogs.py`:
|
||||
- `BalQrImage`: QR widget with MEDIUM error correction (Electrum's
|
||||
`QRCodeWidget` is EC-L), reusing `draw_qr`.
|
||||
- `WillQrExportDialog`: walks the frames (Prev/Next, "i of N" progress),
|
||||
export filters **All / Valid / Valid-NC**, live chunk-preset selector,
|
||||
**Auto slideshow** (toggle button + "QR codes per second" spinbox, stops on
|
||||
the last frame and on filter/chunk changes), optional audio-send button.
|
||||
- `WillQrImportDialog`: camera scan (Electrum `scan_qrcode_from_camera`,
|
||||
one code at a time), manual paste fallback, slot grid (1..N) with
|
||||
green=stored, total-mismatch reset, optional audio-receive that mirrors
|
||||
the audio_modem `_recv` sink with a callback instead of `setText`.
|
||||
- `WillTxReviewSignDialog`: per-transaction review (outputs via
|
||||
`get_ui_address_str`, total outputs, fee) with Sign / Skip / Cancel and a
|
||||
single wallet password; final page offers "Save signed file…" and
|
||||
"Show signed QR…". Runs on the imported local copy only.
|
||||
- `bal/gui/qt/window.py`: `export_will_via_qr`, `import_will_via_qr`,
|
||||
`get_audio_modem_plugin`, `_audio_send_payload`; `sign_transactions`
|
||||
refactored into a byte-equivalent batch loop plus the reusable
|
||||
`_prepare_and_sign_tx(…, txid, password)` single-transaction helper.
|
||||
- `bal/gui/qt/lists.py`: will-list menu gains **Export → QR Codes** and
|
||||
**Import via QR**.
|
||||
- `tests/test_gui_qr_transfer.py` (new): export build/navigation/chunk
|
||||
change, filters (Valid, Valid-NC, empty-revert), import frame flow,
|
||||
assembly+decode, total-mismatch reset, manual entry.
|
||||
- `PLAN_QR_TRANSFER.md`: the full spec (wire format, settings, export,
|
||||
import, wizard checklist P0–P6, findings log).
|
||||
- Docs: `README.md` QR-transfer section; `QML_PLAN.md` updated (Phase 2
|
||||
`BalQrTransferModel`, Phase 3 dedicated QML export/import pages, R6
|
||||
mitigation rewritten, deferred-chunks note removed).
|
||||
|
||||
**Audio channel caveats:**
|
||||
- The audio send/receive buttons only appear when Electrum's `audio_modem`
|
||||
plugin is enabled *and* `amodem` + PortAudio are installed (not present in
|
||||
the current dev runtime — verified F22). On that channel the transport
|
||||
zlib-compresses internally, so no BAL framing/`Z` flag is used.
|
||||
- `WaitingDialog` requires a real `QWidget` parent and the plugin's `_recv`
|
||||
hard-wires `parent.setText`, so receive uses a local mirror with a
|
||||
callback sink.
|
||||
|
||||
**Verification:**
|
||||
- `python3 tests/test_core_qr_transfer.py`: all pass.
|
||||
- `QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py`: all pass.
|
||||
- Pytest batch `tests/test_core_*.py tests/test_gui_*.py`: 374 passed (only
|
||||
the two pre-existing `test_bt_to_date_*` datetime compare failures remain).
|
||||
- `QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py
|
||||
electrum.plugins.bal`: passed (clean import under real Electrum).
|
||||
- `ruff`: no new violations on changed files.
|
||||
|
||||
**Audio-environment notes (dev box, discovered while testing):**
|
||||
- `amodem` 1.16.0 is old and uses `np.ndarray.tostring()`, removed in numpy 2.x;
|
||||
the runtime venv (numpy 2.4.6) needs the one-line patch
|
||||
`tostring()` → `tobytes()` in
|
||||
`electrum/env/lib/python3.11/site-packages/amodem/common.py` (done locally,
|
||||
not in the repo). Any machine with numpy>=2 and this amodem version needs
|
||||
the same patch (or numpy<2).
|
||||
- Electrum's `audio_modem` plugin hardcodes `libportaudio.so` (unversioned).
|
||||
Debian/Ubuntu only ship `libportaudio.so.2`, so the load fails silently
|
||||
inside the plugin's `_send` `WaitingDialog` (no `on_error` → no sound, no
|
||||
message). Fix on the dev box:
|
||||
`sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /usr/lib/x86_64-linux-gnu/libportaudio.so`
|
||||
(created by the `libportaudio-dev` package; a `LD_LIBRARY_PATH` stub works
|
||||
without root). The audio buttons stay hidden unless the plugin is enabled
|
||||
and available.
|
||||
- Verified on the dev box (no physical mic required) via a full
|
||||
send→sink→monitor→recv round-trip: set the default PulseAudio source to
|
||||
`<sink>.monitor` at receive time; payload returned byte-identical.
|
||||
|
||||
**Follow-up fixes (same session, reported during audio testing):**
|
||||
- `WillItem.__init__` now defaults `status` to `""` instead of `None`. A
|
||||
`WillItem` built from a bare `{"tx": ...}` (QR/audio import, clipboard
|
||||
merge) crashed in `set_status` with
|
||||
`unsupported operand type(s) for +=: 'NoneType' and 'str'` during the
|
||||
validity pass / `IMPORTED` marking.
|
||||
- `BalWindow.invalidate_will` guards a missing `date_to_check` (first-action
|
||||
case) like `merge_will` already did, fixing
|
||||
`AttributeError: 'BalWindow' object has no attribute 'date_to_check'` when
|
||||
invalidating before the periodic check initialized it.
|
||||
- Regression test `test_imported_item_status_not_none` added to
|
||||
`tests/test_gui_qr_transfer.py`; QR GUI suite 12/12, batch 377 passed.
|
||||
---
|
||||
|
||||
## 57. Name the real cause of a failed build instead of guessing
|
||||
@@ -2992,4 +3094,236 @@ misleading.
|
||||
- Manually tested by the owner in Electrum 4.8.1: `NO_FUTURE_DATE`,
|
||||
`WILLEXECUTOR_FEE` and `No Heirs` were all confirmed on screen.
|
||||
|
||||
## 57. Remove all `copy.deepcopy` (ad-hoc copy helpers; `WillItem` copies serialize/deserialize)
|
||||
|
||||
**Date:** 2026-08-28
|
||||
|
||||
**Goal (owner request):** eliminate every `copy.deepcopy` from the codebase
|
||||
and replace it with ad-hoc copy methods; `WillItem` copies must be produced by
|
||||
serializing and deserializing the item rather than by deep-copying live
|
||||
runtime objects (which can hold a `threading.RLock` and cannot be pickled).
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/util.py`: new `copy_structure(value, _path="copy")` — the single
|
||||
JSON-safe, deepcopy-free recursive cloner (dict / list / tuple cloned
|
||||
structurally, JSON scalars kept as-is, any accidental runtime object coerced
|
||||
to `str` + logged). It replaces the old `heirs._json_safe` implementation.
|
||||
- `bal/core/heirs.py`: `_json_safe` is now a thin backward-compatible alias of
|
||||
`bal.core.util.copy_structure`; `Heirs.save` behaviour is unchanged.
|
||||
- `bal/core/will.py`:
|
||||
- `WillItem.__init__` on a `WillItem` argument no longer does
|
||||
`self.__dict__ = w.__dict__.copy()` + `copy.deepcopy`; instead it
|
||||
serializes (`to_dict()`) and deserializes: the tx is re-parsed into a fresh
|
||||
object, `STATUS` is rebuilt from a clone, and heirs / will-executors are
|
||||
cloned recursively, so the copy shares no mutable state with the source.
|
||||
- New `WillItem.copy(wallet=None)` (serialize/deserialize round trip; re-adds
|
||||
wallet tx info when a wallet is passed) and the static
|
||||
`WillItem.copy_status_table(table)` used for the `STATUS` tables.
|
||||
- `to_dict()` now also emits `Father` / `Children` so the round trip is
|
||||
faithful.
|
||||
- `normalize_will` routes copies through the constructor / `copy()`.
|
||||
- `bal/gui/qt/window.py` and `bal/cli/controller.py`: the Build-will flow now
|
||||
uses `copy_structure(...)` instead of `copy.deepcopy(...)` for heirs and
|
||||
will-executors.
|
||||
- Dropped now-unused `import copy` (`will.py`, `controller.py`, `qt/common.py`,
|
||||
`qt/window.py`).
|
||||
- Tests updated to the same helpers: STATUS tables via
|
||||
`WillItem.copy_status_table`, heirs / built dicts via `copy_structure`
|
||||
(`test_core_will.py`, `test_core_will_invalidate.py`,
|
||||
`test_heir_relative_anchor.py`, `test_anticipate_manual_locktime.py`,
|
||||
`test_no_willexecutor_karen7.py`, `test_reproduce_none_type.py`,
|
||||
`test_group_e_mock_karen7.py`, `test_group_e_karen7_invalidate.py`,
|
||||
`sim_update_flows.py`).
|
||||
|
||||
**Verification:**
|
||||
- Full offline batch `tests/test_core_*.py tests/test_gui_*.py`: 377 passed,
|
||||
only the two pre-existing `test_bt_to_date_*` datetime compare failures
|
||||
remain (identical to HEAD — no regression; `test_heir_relative_anchor`
|
||||
isolated-file failure is pre-existing test-pollution at HEAD too).
|
||||
- Ad-hoc semantics check: `copy()`/ctor copy share no mutable state with the
|
||||
source (mutating source heirs/STATUS does not leak into the copy and vice
|
||||
versa), `copy_status_table` returns fresh lists, `normalize_will` runs.
|
||||
- `ruff` on all touched files: no new violations (4 findings, all pre-existing
|
||||
at HEAD).
|
||||
- `tests/smoke_test.py electrum.plugins.bal`: passed.
|
||||
- `python3 build_zip.py`: 45 files, 343591 bytes, sha256 `aa8f8154…`;
|
||||
`tests/external_zip_test.py bal-electrum-plugin.zip`: passed (Plugin class
|
||||
loads via the zipimport shim).
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## Next. Animated-QR interop (BC-UR v1/v2, BBQR)
|
||||
|
||||
**Date:** 2026-09-08
|
||||
|
||||
**Goal:** Let BAL export/import a will not only as its own BAL QR frame format
|
||||
but also as BC-UR v1 (`ur:bytes`, BC32 + SHA-256), BC-UR v2 (`ur:bytes`, CBOR
|
||||
bytewords-minimal fountain codes) and BBQR (Coinkite `B$…`) animated-QR
|
||||
sequences, so transfers interoperate with Blockchain Commons / Coldcard-style
|
||||
tools and BitKit. Codecs must be stdlib-only and the export must keep BAL QR
|
||||
as the default.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/animated_qr.py` (new): stdlib-only codec module.
|
||||
- BC32 (bech32_bis checksum, XOR `0x3FFFFFFF`) encode/decode matching the
|
||||
BCR-2020-004/005 reference vectors.
|
||||
- bytewords-minimal encode/decode (BCR-2020-012) with CRC-32 rejection;
|
||||
the word list was transcribed verbatim from the reference C++.
|
||||
- BC-UR v2: CBOR part writer/reader, CRC-32, `choose_fragments`
|
||||
(xoshiro256** + alias + ary-threshold sampler) and XOR-based fountain
|
||||
mixing/solving; emits a redundant mixed wave for loss tolerance.
|
||||
- BC-UR v1: multipart with SHA-256 digest and single-part digest-less
|
||||
frames; `1of1` handling.
|
||||
- BBQR: base32 (encoding `2`), hex (uppercase, `H`) and zlib (lowercase,
|
||||
`Z`, automatic compression fallback) frames; out-of-order reconstruction.
|
||||
- One `AnimatedQrSession` + `detect_format` + `parse_for_detection` for
|
||||
auto-detecting the incoming format and keying the GUI debounce.
|
||||
- Safety caps: `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`,
|
||||
zlib-bomb guard, `TransferConflictError`/`SessionLimitError`.
|
||||
- `bal/gui/qt/dialogs.py`:
|
||||
- Export page (`BalQrExportWidget`) gained a **Format** selector
|
||||
(BAL QR default, BC-UR v1, BC-UR v2, BBQR) reusing the QR-size presets,
|
||||
with per-format intro/format-hint text.
|
||||
- Import page (`BalQrImportWidget`) now routes every frame through
|
||||
`parse_for_detection` + `AnimatedQrSession.add_part`, auto-detecting the
|
||||
format and resetting when the transfer's session key changes; the
|
||||
review/sign step resolves the session and decodes parts uniformly.
|
||||
- `qr_import_accept_frame` generalised to
|
||||
`(state, fmt, session_key, frame_total, index, payload, stable_reads=2)`.
|
||||
- `tests/test_core_animated_qr.py` (new, 32 tests): BC32 spec vectors,
|
||||
bytewords round-trip/CRC, C++ reference-frame decode+re-encode parity
|
||||
(single-part 12B, seq_len=2, seq_len=7), fountain solve with missing pure
|
||||
part, out-of-order/duplicate handling, single/multipart UR v1, BBQR
|
||||
Z/2/H round-trips, runt last part, zlib-bomb guard, detection positive/
|
||||
negative.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- `tests/test_core_animated_qr.py`: 32/32 pass.
|
||||
- `tests/test_gui_qr_transfer.py` (now 36 tests) + `test_gui_export_dialogs.py`: pass.
|
||||
- `ruff` clean on `animated_qr.py`, `dialogs.py` and both test files;
|
||||
`pyright` 0 errors on the touched modules.
|
||||
- `tests/smoke_test.py electrum.plugins.bal`, `python3 build_zip.py` and
|
||||
`external_zip_test.py` all pass.
|
||||
- Full regression: 462 passed; only pre-existing failures remain
|
||||
(`test_bt_to_date_*`, will-invalidate fee, unrelated `sign_transactions`
|
||||
stub test).
|
||||
|
||||
**Notes / caveats:**
|
||||
|
||||
- A real bug was found & fixed during this work: `_ur2_part_cost` used
|
||||
`2 * body_len` but `bytewords_minimal_encode` appends a 4-byte CRC, so every
|
||||
UR v2 frame was undercounted by 8 characters and could overflow the QR
|
||||
budget for large transfers.
|
||||
- UR v1 multipart emits the digest-carrying `1of1/<digest>/<frag>` form for a
|
||||
single part (both headered and headerless single parts are accepted on
|
||||
import); this keeps deterministic digest verification.
|
||||
- Imported payloads are UTF-8 text; the codec sessions do not decode raw
|
||||
binary transfer blobs.
|
||||
|
||||
**Outcome:** DONE (uncommitted).
|
||||
|
||||
---
|
||||
|
||||
## Animated-QR bugfix: QVideoSink signal wiring + will-export JSON crash
|
||||
|
||||
**Date:** 2026-09-08
|
||||
|
||||
**Goal:** Fix two runtime crashes found by manual testing of the QR paths.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/gui/qt/dialogs.py`:
|
||||
- `_start_scan`/`_stop_scan` used `QVideoSink.videoFrame.connect/.disconnect`,
|
||||
but on PyQt6 `videoFrame` is the frame **getter method**, not a signal —
|
||||
this raised ``AttributeError: 'builtin_function_or_method' object has no
|
||||
attribute 'connect'`` on camera scan. Switched to the `videoFrameChanged`
|
||||
signal (same wiring Electrum's `QrReaderVideoSurface` uses).
|
||||
- `_stop_scan` now tolerates `AttributeError` when disconnecting the sink
|
||||
and guards the `errorOccurred` disconnect too, so a mid-init failure can
|
||||
never cascade into a second uncaught exception.
|
||||
- `_whole_will_json` (whole-will QR export) serialized ``WillItem.to_dict()``
|
||||
with plain `json.dumps`, crashing with ``TypeError: Object of type
|
||||
Transaction is not JSON serializable`` (the ``tx`` field holds a real
|
||||
``Transaction``). Now uses Electrum's `MyEncoder`, matching `write_json_file`.
|
||||
- `tests/test_gui_qr_transfer.py`: new `test_import_start_stop_scan_signal_wiring`
|
||||
drives the real `QVideoSink` life-cycle with a mocked camera and fails if
|
||||
the signal name regresses to `videoFrame`.
|
||||
- `tests/test_gui_export_dialogs.py`: new
|
||||
`test_qr_whole_will_json_serializes_transaction` covers the JSON export.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- `pytest tests/test_gui_qr_transfer.py tests/test_gui_export_dialogs.py -q`: pass.
|
||||
- Full offline batch `tests/test_core_*.py tests/test_gui_*.py`: 444 passed.
|
||||
- Regression test flips correctly (fails when reverted to the buggy call).
|
||||
- `ruff` clean on touched files; `tests/smoke_test.py`, `build_zip.py`,
|
||||
`external_zip_test.py` all pass.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -23,6 +23,25 @@ is updated to mark them as supported.
|
||||
See [`README.md`](README.md) for supported Electrum versions (currently 4.7.2
|
||||
and 4.8.0).
|
||||
|
||||
## QR wire-format compatibility
|
||||
|
||||
BAL exports/imports wills as QR codes. **BAL QR** (the default) is the plugin's
|
||||
own frame format and is only understood by BAL itself. The export page also
|
||||
supports **BC-UR v1**, **BC-UR v2** and **BBQR**:
|
||||
|
||||
| Format | Wire appearance | Interop target |
|
||||
|-----------|----------------------------|------------------------------------------------------|
|
||||
| BAL QR | `BAL1<total><index><flag>…` (v2) / `BALQR1\|total\|index\|…` (legacy import-only) | Past/other BAL versions: **v2 exports are NOT readable by old builds**; old `BALQR1` exports still import here (default, best-of compression, flag `0` = plain, `Z` = deflate) |
|
||||
| BC-UR v1 | `ur:bytes/<bc32>` | Blockchain Commons / Coldcard-style UR (BC32, SHA-256 digest, part counts per part) |
|
||||
| BC-UR v2 | `ur:bytes/<seq>-<seqlen>/<bytewords>` | BC-UR 2.x fountain codes (CBOR parts, CRC-32, bytewords-minimal) |
|
||||
| BBQR | `B$<enc><type><N><n>…` | Coinkite BitKit / Coldcard's BBQR animated-QR mode |
|
||||
|
||||
Import auto-detects the format of each scanned code; out-of-order, duplicate
|
||||
and (for UR v2) partially-lost fountain frames are handled. Interop is
|
||||
validation-tested against the reference C++ bc-ur encoder output and the
|
||||
BCR-2020-004/005 BC32 test vectors; it has not yet been cross-verified against
|
||||
third-party libraries (`ur`, `bbqr`, Coldcard firmwares).
|
||||
|
||||
## Reporting compatibility issues
|
||||
|
||||
If you find a compatibility problem not listed here, please open an issue on
|
||||
|
||||
85
HANDOFF.md
85
HANDOFF.md
@@ -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
|
||||
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
|
||||
`test_*` functions from `if __name__ == "__main__"`. Run a file directly:
|
||||
@@ -392,3 +393,85 @@ See Section 5 for details.
|
||||
push to `origin/main`, then run `./make-release.sh` to create the Gitea
|
||||
**Release** with the ZIP + signatures attached (it becomes the owner's
|
||||
"Latest" download). Always give the owner the Release URL.
|
||||
|
||||
### In progress: QR / audio will transfer (branch `feature/bal-qr-transfer`)
|
||||
|
||||
- The full QR-transfer feature (P0–P6) is implemented, tested and committed on
|
||||
`feature/bal-qr-transfer` (commits `d288b55`, `ce3e36d`, pushed to
|
||||
`origin`). PR creation URL:
|
||||
`https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin/pulls/new/feature/bal-qr-transfer`
|
||||
- Included: core scheduler (`bal/core/qrtransfer.py`), `QR_CHUNK_SIZE`
|
||||
setting (4 export presets), export/import dialogs + review/sign wizard +
|
||||
lists/window wiring, export filters, auto slideshow with per-second rate +
|
||||
loop option, audio send/receive buttons, and the crash fixes
|
||||
(`status` default, `invalidate_will` guard). Docs: README, CHANGELOG entry
|
||||
56, QML_PLAN, `AUDIO_MODEM_DEBIAN.md`.
|
||||
- Follow-up refactor (CHANGELOG entry 57): all `copy.deepcopy` removed —
|
||||
`copy_structure()` in `bal/core/util.py`, `WillItem.copy()` / ctor
|
||||
serialize/deserialize, `copy_status_table()`. Working tree clean after the
|
||||
branch's three commits.
|
||||
- Verification: batch 377 passed / 2 pre-existing `test_bt_to_date_*`
|
||||
failures; ruff no new violations; smoke + `build_zip.py` +
|
||||
external-zip OK; pyright clean. The isolated
|
||||
`test_heir_relative_anchor.py::test_karen7_frozen_delivery_not_expired`
|
||||
failure is pre-existing test pollution (fails identically on clean HEAD,
|
||||
passes inside the full batch) — not caused by entry 57.
|
||||
- Remaining: manual on-device walkthrough of the QR path (and, if wanted,
|
||||
the audio path — buttons only appear when the `audio_modem` plugin +
|
||||
`amodem` are installed; see the prerequisites below).
|
||||
|
||||
### In progress: animated-QR interop (BC-UR v1/v2, BBQR)
|
||||
|
||||
- `bal/core/animated_qr.py` implements stdlib-only codecs for **BC-UR v1**
|
||||
(BC32 + SHA-256 digest; the bech32_bis checksum variant per
|
||||
BCR-2020-004/005), **BC-UR v2** (CBOR part structure, bytewords-minimal,
|
||||
CRC-32, xoshiro256-based fountain with alias-sampled mixing) and **BBQR**
|
||||
(Coinkite `B$…` base32/hex/zlib frames), plus one shared
|
||||
`AnimatedQrSession` with `detect_format` auto-detection and
|
||||
`parse_for_detection` frame identity for the GUI debounce.
|
||||
- Current status as of this session: reference parity, GUI, and tests done;
|
||||
not yet committed.
|
||||
- **BC32/bytewords/codec parity:** BC32 reproduces the BCR-2020-004/005
|
||||
test vectors (`Hello, world`, `Hello world`, the long seed vector);
|
||||
bytewords-minimal round-trips with CRC rejection; UR v2 part encode +
|
||||
decode is byte-exact against the reference C++ bc-ur encoder for a
|
||||
single part, seq_len=2 (12 frames) and seq_len=7 (3 sampled mixes),
|
||||
validating CBOR framing, bytewords, alias+ary-threshold sampling,
|
||||
xoshiro256** and the XOR mix.
|
||||
- **Sessions:** UR v2 single-part (no seq header), out-of-order frames,
|
||||
duplicate drops, solve with a missing pure fragment (a second redundant
|
||||
mixed wave is emitted by `ur2_frames`), UR v1 single-part
|
||||
(digest-less `ur:bytes/<bc32>` accepted) and multipart, BBQR full-frame
|
||||
decode in any order for Z/2/H encodings.
|
||||
- **Safety:** `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`,
|
||||
`TransferConflictError` on a frame from a different transfer,
|
||||
`SessionLimitError`, BBQR zlib-bomb guard, UTF-8 payloads only.
|
||||
- **GUI:** `BalQrExportWidget` gained a Format selector (BAL QR default,
|
||||
BC-UR v1, BC-UR v2, BBQR) reusing the QR-size presets; the importer now
|
||||
routes every frame through `detect_format` +
|
||||
`AnimatedQrSession.add_part` with the shared
|
||||
`qr_import_accept_frame(state, fmt, session_key, frame_total, index,
|
||||
payload, stable_reads=2)` debounce (reset on session-key change).
|
||||
`_review_and_sign` resolves the session to the transfer text and decodes
|
||||
parts uniformly across formats.
|
||||
- **Verification:** `tests/test_core_animated_qr.py` (32 tests incl. the
|
||||
C++-reference parity vectors and BC32 spec vectors) and the extended
|
||||
`tests/test_gui_qr_transfer.py` pass; ruff clean on the new/changed
|
||||
files; pyright 0 errors; smoke test, `build_zip.py` and
|
||||
`external_zip_test.py` green. Only the pre-existing failures remain
|
||||
(`test_bt_to_date_*`, fee-exceeds-balance, karen7 pollution).
|
||||
- **Any remaining work:** manual on-device walkthrough of the QR path with
|
||||
the new formats; optionally validate against third-party libraries
|
||||
(`ur`, `bbqr`) once available; add the docstrings/branch notes already
|
||||
captured in `ag1.md`/`ag2.md` context where needed.
|
||||
|
||||
**Dev-box audio prerequisites (audio_modem channel):**
|
||||
See `AUDIO_MODEM_DEBIAN.md` — the full Debian setup + verification, with the
|
||||
two pitfalls (unversioned `libportaudio.so`, numpy>=2 `tostring` removal):
|
||||
- `sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /usr/lib/x86_64-linux-gnu/libportaudio.so`
|
||||
(the unversioned name the plugin loads; Debian ships only `.so.2`).
|
||||
- numpy>=2 patch in `electrum/env/.../amodem/common.py`: `tostring()` →
|
||||
`tobytes()` (already applied locally). Both are runtime-env fixes, not repo
|
||||
changes; see CHANGELOG entry 56.
|
||||
- To loop-test on one machine without speakers/mic: during receive,
|
||||
`pactl set-default-source <sink>.monitor` (restore after).
|
||||
|
||||
499
PLAN_QR_TRANSFER.md
Normal file
499
PLAN_QR_TRANSFER.md
Normal file
@@ -0,0 +1,499 @@
|
||||
# PLAN — Will transfer via QR codes / audio modem (Qt now, QML planned)
|
||||
|
||||
> Goal: let the user move an inheritance ("will") between devices over two
|
||||
> air-gap channels:
|
||||
>
|
||||
> 1. **QR codes** (primary): export the **valid** inheritance transactions as
|
||||
> a sequence of QR codes, and import them back on another machine with the
|
||||
> camera;
|
||||
> 2. **Audio modem** (secondary, when Electrum's `audio_modem` plugin is
|
||||
> enabled): send/receive the same payload through the PC speaker +
|
||||
> microphone.
|
||||
>
|
||||
> Both channels converge on the same review-and-sign flow afterwards.
|
||||
>
|
||||
> Status: APPROVED by owner (2026-08-25). No code written yet — this document
|
||||
> is the implementation contract. Work top-down through §9 Checklist.
|
||||
>
|
||||
> Chat language: Italian; this document is in English (global rule R1).
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- **Desktop PyQt6 GUI** (primary, implemented by this plan):
|
||||
- New plugin setting: QR chunk size, offered as **4 standard presets**.
|
||||
- *"Export via QR"* action: serializes the **valid** will transactions,
|
||||
optionally compresses, splits the resulting string into fixed-size frames,
|
||||
shows one QR at a time with prev/next navigation, live re-chunking and
|
||||
progress (`i di N`).
|
||||
- *"Import via QR"* action: camera capture dialog with a slot grid
|
||||
(1..N); the user selects which shot he is about to capture, scans, the
|
||||
frame lands in its slot; when 1..N are filled the payload is assembled,
|
||||
parsed into `WillItem`s, validity-checked locally.
|
||||
- **Post-capture flow (owner decision D6, amended)**: after capture
|
||||
completes there is NO read-only preview. Instead a review-and-sign wizard
|
||||
walks through every transaction one at a time showing **outputs
|
||||
(address + amount), total outputs, total fees**, signs it (wallet
|
||||
password asked once), and at the end **proposes exporting the signed
|
||||
transactions** (to file and/or back via QR).
|
||||
- **Audio-modem channel** (owner decision D7): when the Electrum
|
||||
`audio_modem` plugin is enabled and available, the export dialog gains a
|
||||
*"Send via Audio Modem…"* button and the import dialog a *"Receive via
|
||||
Audio Modem…"* button, reusing the same transfer string (no QR framing).
|
||||
- **QML**: document-only update to `QML_PLAN.md` adding dedicated view specs
|
||||
(owner decision D1). No QML code in this feature.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Implementing the QML frontend (gated behind `QML_PLAN.md` Phases 0–2).
|
||||
- Merging imported wills into the live wallet state (existing Merge flows
|
||||
stay unchanged).
|
||||
- Broadcast of the reviewed transactions (user exports them; broadcasting
|
||||
remains an explicit action elsewhere).
|
||||
- CLI/cmdline parity for QR transfer.
|
||||
|
||||
---
|
||||
|
||||
## 2. Owner decisions (locked)
|
||||
|
||||
| # | Decision |
|
||||
|---|----------|
|
||||
| D1 | QML part = update `QML_PLAN.md` document only; implementation later. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Verified facts (research done on the local checkouts)
|
||||
|
||||
All verified by reading source; references are `file:line`.
|
||||
|
||||
| # | Fact | Where |
|
||||
|---|------|-------|
|
||||
| F1 | Export today: `BalWindow.export_will()` writes `{wid: WillItem.to_dict()}` JSON; subsets All/Valid/Valid-NC built in `WillList` | `bal/gui/qt/window.py:1605-1621`, `lists.py:666-669, 743-767` |
|
||||
| F2 | Batch signer: `BalWindow.sign_transactions(password, will, txids)` loops valid txs, resolves input values from change prevouts (`txin._trusted_value_sats` …), calls `wallet.sign_transaction`, updates `COMPLETE` + signature counts | `window.py:1017-1086` |
|
||||
| F3 | External-will signing already supported (`will=` param, nothing saved to live wallet/history) | `window.py:1443-1484` |
|
||||
| F4 | Single-tx import precedent: `merge_single_transaction` wraps `WillItem({"tx": str(tx)}, _id=tx.txid(), wallet=...)` | `window.py:1715-1724` |
|
||||
| F5 | Local validity recomputation recipe (no network): `add_willtree` → `Util.get_available_utxos` → `check_invalidated` → `search_rai` → `check_signatures` | `window.py:1678-1701` |
|
||||
| F6 | Plugin config accessor pattern `BalConfig`; keys declared in ctor | `bal/core/plugin_base.py:130-154, 211-264` |
|
||||
| F7 | Plugin settings dialog: grid rows 0..12, `add_widget(grid,label,widget,row,help)` + `_make_reset_btn(cfgvar,widget,kind)`; ADVANCED-only rows wrapped with `_hide_if_basic(...)` | `bal/gui/qt/plugin.py:442-850` (rows at 677-850) |
|
||||
| F8 | `BalDialog(parent, bal_plugin, title=None, icon=...)` base class anchors to top-level window | `bal/gui/qt/dialogs.py:92-129` |
|
||||
| F9 | Fee display precedent: `fee = tx.input_value() - tx.output_value()`, fee rate = `fee / tx.estimated_size()` | `bal/gui/qt/widgets.py:1319-1328` |
|
||||
| F10 | Input-value resolution helper exists: `Will.add_info_from_will(will, wid, wallet)` sets trusted input values from sibling will change outputs | `bal/core/will.py:118-136` |
|
||||
| F11 | `str(tx)` = `tx.serialize()`: raw hex for complete txs; `PartialTransaction.serialize()` → base64 PSBT. Both accepted by `tx_from_any` (= `Will.get_tx_from_any`). This is exactly how BAL persists/reloads txs today | `electrum/transaction.py:907, 2539`; `will.py:106-113`; `window.py:1041-1044` |
|
||||
| F12 | `QRCodeWidget` exists but **hardcodes `ERROR_CORRECT_L`** → cannot satisfy D5/M; must render our own `qrcode` instance | `electrum/gui/qt/qrcodewidget.py:35-37` |
|
||||
| F13 | Camera scanning one-shot API with OS-permission handling: `scan_qrcode_from_camera(*, parent, config, callback(success: bool, error: str, data: Optional[str]))`; on Linux uses zbar CLI backend | `electrum/gui/qt/qrreader/__init__.py:47-64` |
|
||||
| F14 | QR painting without PIL: `draw_qr(qr, paint_device, ...)` from `electrum.gui.common_qt.util` (what `QRCodeWidget.paintEvent` uses) | `electrum/gui/qt/qrcodewidget.py:63-72` |
|
||||
| F15 | QR capacity sanity (byte mode, EC **M**): v40-M ≈ 2331 B ≥ 1800 ✓; v10-M ≈ 213 B ≥ 150 ✓; the `qrcode` lib auto-picks the version | `qrcode` lib |
|
||||
| F16 | `build_zip.py` walks the tree with `os.walk` → new `.py` files ship automatically | `build_zip.py:39-47` |
|
||||
| F17 | QML fork already has `QRImage.qml`, `QRScan.qml`, `ScanDialog.qml`; ScanDialog carries upstream comment "currently not used on android … qt6 camera support stops crashing" | `electrum/gui/qml/components/ScanDialog.qml:8-9` |
|
||||
| F18 | `QML_PLAN.md` currently defers chunked multi-QR streams (Phase 3 note + risk R6) — this feature supersedes that deferral | `QML_PLAN.md:228-231, 304` |
|
||||
| F19 | House test conventions: `def test_*` + `if __name__ == "__main__"` + `sys.path.insert(0, ..pardir)`; run standalone or via pytest | `tests/test_core_heirs.py:1-24` |
|
||||
| F20 | Fork ships an `audio_modem` plugin. `_send(parent, blob)` zlib-compresses an **ASCII** blob, plays it via speaker through `amodem` inside a `WaitingDialog`; bit-rate selectable in the plugin's own settings (default = `amodem.config.slowest()`) | `electrum/plugins/audio_modem/qt.py:96-110` |
|
||||
| F21 | `_recv(parent)` records from mic and delivers the decompressed ASCII text by calling `parent.setText(blob)` — the only integration contract is "an object with `setText(str)`"; there is no callback API | `audio_modem/qt.py:112-127` |
|
||||
| F22 | Plugin lookup for enabled plugins: `window.plugins.get(name)` → instance or `None` (`Plugins.get`, electrum/plugin.py:575-576); availability check is the plugin's own `is_available()` (imports `amodem`). **`amodem` is NOT installed in the runtime env today** → optional dependency (pip `amodem` + libportaudio); feature must degrade gracefully | `electrum/plugin.py:575`, runtime-env check |
|
||||
| F23 | `amodem.main.send/recv` stream the whole blob with their own framing/training → BAL must NOT apply QR frame chunking on this channel; and since `_send` compresses internally, BAL sends the **plain** transfer string to avoid double compression | consequence of F20/F21 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Wire format specification
|
||||
|
||||
### 4.1 Transfer string
|
||||
|
||||
```
|
||||
transfer_string = "\n".join( tx_str(tx) for tx in valid_txs_sorted_by_txid )
|
||||
```
|
||||
|
||||
- `tx_str(tx)` = `str(tx)` (F11): hex for complete txs, base64-PSBT for
|
||||
partially-signed ones. Neither alphabet contains `\n` or `|`, so both are
|
||||
safe delimiters.
|
||||
- Ordering: ascending `tx.txid()` → deterministic output for identical input.
|
||||
- If compression enabled (D4):
|
||||
`transfer_string = base64_ascii( zlib_compress( transfer_string ) )`.
|
||||
|
||||
### 4.2 Frame layout (one frame = content of ONE QR code)
|
||||
|
||||
Wire format v2 (compact, current export):
|
||||
|
||||
```
|
||||
BAL1<TTT><iii><F><payload>
|
||||
```
|
||||
|
||||
- Magic+version literal `BAL1` (reject anything else with a clear message).
|
||||
- `<TTT>` = `<iii>` — **base36** zero-padded 3-char strings (`000`…`ZZZ`),
|
||||
representing total N and index i, `1 ≤ i ≤ N ≤ 46655`. Fixed width means a
|
||||
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
|
||||
`chunk_size` bytes each (last slice may be shorter). No separators: both
|
||||
base36 count fields are fixed-width, so the header is unambiguously 11
|
||||
chars and the payload starts at offset 11.
|
||||
- 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)
|
||||
|
||||
| Preset label | Payload budget (bytes/frame) | Typical QR version @EC-M |
|
||||
|--------------|------------------------------|--------------------------|
|
||||
| Small (low-res cameras) | 150 | ~v10 |
|
||||
| Medium | 400 | ~v15 |
|
||||
| Large | 900 | ~v22 |
|
||||
| XL (high-res cameras) | 1800 | ~v40 |
|
||||
|
||||
EC level fixed **M** for scan reliability (D5). Presets live in
|
||||
`bal/core/qrtransfer.py::CHUNK_PRESETS` so core tests can cover them.
|
||||
|
||||
### 4.4 Audio-modem channel (D7, F20-F23)
|
||||
|
||||
- Payload = the **plain** `transfer_string` of §4.1 — no BAL frames
|
||||
(`split_frames`/`parse_frame` are QR-only), no BAL compression (the plugin
|
||||
compresses internally; double compression wastes airtime).
|
||||
- The existing core functions `encode_transfer(tx_strings,
|
||||
compress=False)` + `decode_transfer(text, compressed=False)` are reused
|
||||
unchanged; only the transport differs.
|
||||
- Bit-rate is owned by the audio_modem plugin's settings dialog — BAL adds
|
||||
no setting of its own.
|
||||
|
||||
---
|
||||
|
||||
## 5. New core module — `bal/core/qrtransfer.py`
|
||||
|
||||
GUI-free (never imports Qt — house rule). Public API:
|
||||
|
||||
```python
|
||||
MAGIC = "BALQR"
|
||||
VERSION = 1
|
||||
FLAG_COMPRESSED = "Z"
|
||||
CHUNK_PRESETS = [(label_en, budget_bytes), ...] # §4.3 table
|
||||
|
||||
def encode_transfer(tx_strings: list[str], compress: bool = False) -> str
|
||||
"""Join -> optional zlib+base64 -> return transfer_string."""
|
||||
|
||||
def split_frames(transfer_string: str, chunk_size: int) -> list[str]
|
||||
"""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."""
|
||||
|
||||
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]
|
||||
"""-> (total, index, compressed, payload); accepts v2 'BAL1…' and legacy
|
||||
'BALQR1|…' (wrapped as _parse_v1/_parse_v2); ValueError on bad magic/
|
||||
version/arity/non-numeric fields."""
|
||||
|
||||
def assemble(frames: dict[int, str]) -> str
|
||||
"""Validate indices form exactly range(1..max_total) (taken from any
|
||||
frame header), concatenate payloads in order, decode flags ->
|
||||
transfer_string. Raises MissingFramesError(indexes) / InconsistentTotalError."""
|
||||
|
||||
def decode_transfer(transfer_string: str, compressed: bool) -> list[str]
|
||||
"""Inverse of encode_transfer -> list of tx strings."""
|
||||
```
|
||||
|
||||
Plus exceptions `QrTransferError(ValueError)`, `MissingFramesError`,
|
||||
`InconsistentTotalError`. All docstrings/comments English; ruff-clean
|
||||
(line-length 88, E501 ignored).
|
||||
|
||||
---
|
||||
|
||||
## 6. Settings (Qt)
|
||||
|
||||
1. `bal/core/plugin_base.py`: after `REBUILD_ON_CLOSE` (~line 264) add
|
||||
|
||||
```python
|
||||
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
|
||||
```
|
||||
|
||||
2. `bal/gui/qt/plugin.py::settings_dialog` (rows end at 12, ~line 844):
|
||||
append row **13** — visible in BASIC and ADVANCED (do NOT wrap with
|
||||
`_hide_if_basic`):
|
||||
|
||||
- Label: `"QR Code Size"`
|
||||
- `QComboBox` fed from `CHUNK_PRESETS`; item text e.g.
|
||||
`"Small — ~150 bytes/QR (low-res cameras)"`; `currentIndexChanged`
|
||||
→ `self.QR_CHUNK_SIZE.set(budget_bytes)`; initial index from
|
||||
`QR_CHUNK_SIZE.get()` (fallback to nearest preset if the stored value
|
||||
was customized).
|
||||
- `HelpButton` text: explains trade-off (small QR = more shots but easier
|
||||
to scan with poor cameras; large QR = fewer shots, needs good camera)
|
||||
and that the size can also be changed inside the export dialog.
|
||||
- Reset button via existing `_make_reset_btn(self.QR_CHUNK_SIZE, combo, ...)`
|
||||
pattern (plugin.py:684).
|
||||
|
||||
---
|
||||
|
||||
## 7. Qt export flow
|
||||
|
||||
### 7.1 Entry point
|
||||
|
||||
`bal/gui/qt/lists.py::WillList.create_toolbar` (menu block lines 666-670):
|
||||
|
||||
```python
|
||||
export_menu.addAction(_("Via QR…"), self.export_will_valid_qr)
|
||||
```
|
||||
|
||||
New `WillList.export_will_valid_qr()` mirrors `export_will_valid`
|
||||
(lists.py:743-754): builds `{wid: wi}` subset of `VALID` items, empty →
|
||||
`show_message(_("No valid will item to export"))`, else
|
||||
`self.bal_window.export_will_via_qr(will=subset)`.
|
||||
|
||||
### 7.2 `BalWindow.export_will_via_qr(will=None)` (new, `window.py` near
|
||||
`export_will`)
|
||||
|
||||
- Collect `tx_strings = [str(wi.tx) for wid, wi in sorted-by-txid ...]`
|
||||
(F11).
|
||||
- Mark exported items `EXPORTED` (parity with `export_json_file`,
|
||||
window.py:1607-1609) — only when `will` came from the live list.
|
||||
- Open `WillQrExportDialog(self, tx_strings)`.
|
||||
|
||||
Shared helper used by both dialogs:
|
||||
|
||||
```python
|
||||
def get_audio_modem_plugin(self): # on BalWindow
|
||||
"""Return the loaded audio_modem plugin if enabled AND available
|
||||
(amodem importable), else None. Never raises."""
|
||||
p = self.window.plugins.get("audio_modem") # F22
|
||||
return p if p is not None and p.is_available() else None
|
||||
```
|
||||
|
||||
### 7.3 `WillQrExportDialog(BalDialog)` (new class in `dialogs.py`)
|
||||
|
||||
Layout:
|
||||
|
||||
```
|
||||
[Size ▾ Small/Medium/Large/XL] ← compressed best-of automatically (no checkbox)
|
||||
[ QR image ] ← BalQrImage (see below)
|
||||
«i di N» [◀ Prev] [Next ▶]
|
||||
[Save current QR as PNG…] [Send via Audio Modem…] [Close]
|
||||
```
|
||||
|
||||
Behaviour:
|
||||
|
||||
- On any control change: rebuild `split_frames(encode_transfer_best(...))`,
|
||||
reset index to frame 1, refresh counter (owner requirement: "cambiare la
|
||||
risoluzione").
|
||||
- `BalQrImage(QWidget)` ≈ trimmed copy of `QRCodeWidget`
|
||||
(`electrum/gui/qt/qrcodewidget.py:21-72`) but constructing
|
||||
`qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2)` and painting
|
||||
via `electrum.gui.common_qt.util.draw_qr` (F12/F14). ~30 lines.
|
||||
- Prev/Next wrap or disable at ends (disable chosen: clearer).
|
||||
- PNG export optional convenience via existing
|
||||
`getSaveFileName` + `QWidget.grab()` (same trick as
|
||||
`qrcodewidget.py:110`).
|
||||
- **Send via Audio Modem…** (D7): shown only when
|
||||
`bal_window.get_audio_modem_plugin()` returns a usable instance (below);
|
||||
otherwise hidden. Handler: re-encode the payload **plain**
|
||||
(`encode_transfer(tx_strings, compress=False)`) and call the plugin's
|
||||
`_send(parent=self, blob=transfer_string)` — its own WaitingDialog owns
|
||||
progress/cancellation (F20). Tooltip when hidden is unnecessary; instead,
|
||||
if the plugin is enabled but `is_available()` is False, show an info
|
||||
message pointing to `pip install amodem` + portaudio (F22).
|
||||
|
||||
---
|
||||
|
||||
## 8. Qt import flow + review/sign wizard
|
||||
|
||||
### 8.1 Entry point
|
||||
|
||||
`lists.py` toolbar menu, next to Import/Merge (lines 670-671):
|
||||
|
||||
```python
|
||||
menu.addAction(_("Import via QR…"), lambda: self.bal_window.import_will_via_qr())
|
||||
```
|
||||
|
||||
`BalWindow.import_will_via_qr()` opens `WillQrImportDialog(self)`.
|
||||
|
||||
### 8.2 `WillQrImportDialog(BalDialog)`
|
||||
|
||||
State: `self.frames: dict[int, str]`, `self.total: int | None`,
|
||||
`self.target_index: int | None`.
|
||||
|
||||
Layout:
|
||||
|
||||
```
|
||||
«Captured k of N» [Scan ▶] [Reset]
|
||||
[slot grid: push-buttons 1..N; states: empty / filled ✓ / selected-target]
|
||||
hint line («Select a slot, then scan» / «Scan the first QR»)
|
||||
[Receive via Audio Modem…] [Review & Sign ▶] [Close]
|
||||
```
|
||||
|
||||
Behaviour:
|
||||
|
||||
- **Scan** → `scan_qrcode_from_camera(parent=self,
|
||||
config=self.bal_window.window.config, callback=self._on_scan)`
|
||||
(F13). One-shot per press; dialog stays open between shots (simplest,
|
||||
matches Electrum UX; no continuous mode).
|
||||
- `_on_scan(success, error, data)`:
|
||||
- failure → `show_error(error)` (covers missing zbar/camera too);
|
||||
- `parse_frame` errors → `show_warning(_("Not a BAL will QR"))`;
|
||||
- first valid frame adopts `total` and materializes the slot grid;
|
||||
- frame whose `total` ≠ adopted total → warn + offer Reset (user may have
|
||||
restarted the export with another size);
|
||||
- valid → `frames[index] = payload`; auto-advance `target_index` to the
|
||||
lowest missing index; refresh grid + counter.
|
||||
- Clicking an empty slot sets `target_index` (owner requirement: manual
|
||||
shot selection); a filled slot click asks to overwrite.
|
||||
- **Receive via Audio Modem…** (D7): shown only when
|
||||
`bal_window.get_audio_modem_plugin()` returns a usable instance. Handler:
|
||||
build a tiny adapter object exposing `setText(str)` that stores the text
|
||||
and invokes the shared post-receive continuation, then call
|
||||
`plugin._recv(parent=self, ...)`-style flow (F21 contract). On success the
|
||||
received string is treated as the **whole payload**: skip frames/slots
|
||||
entirely → `decode_transfer(text, compressed=False)` → continue at §8.2's
|
||||
item-building step (WillItem construction + validity pass + wizard).
|
||||
Errors from the modem surface through the plugin's own dialog; empty
|
||||
result (user cancelled) is silently ignored.
|
||||
- **Review & Sign** enabled only when `set(frames) == set(range(1, N+1))`:
|
||||
runs `assemble` + `decode_transfer` → `list[str]`; any `QrTransferError`
|
||||
surfaces as `show_error` and keeps the dialog open.
|
||||
- Build items exactly like `merge_single_transaction` (F4):
|
||||
`WillItem({"tx": s}, wallet=self.wallet)` per string; failures per-string
|
||||
are collected and reported at the end (bad string ≠ fatal for the rest).
|
||||
- Local validity pass (F5 recipe) on the resulting dict; items failing
|
||||
`VALID` are dropped and listed in a warning. Set
|
||||
`wi.set_status("IMPORTED", True)` on survivors (mirrors
|
||||
`import_will_into_details`, window.py:1753-1754).
|
||||
- Then `close()` and start the wizard (§8.3) with the valid subset. Empty
|
||||
result → stop with a message.
|
||||
|
||||
### 8.3 `WillTxReviewSignDialog(BalDialog)` — post-capture wizard (D6)
|
||||
|
||||
Constructed with `(bal_window, willitems: dict[str, WillItem])` — the
|
||||
imported subset lives **outside** the live wallet state (external mode,
|
||||
F3).
|
||||
|
||||
Flow:
|
||||
|
||||
1. **Password once**: `password = bal_window.get_wallet_password()`
|
||||
(window.py:1088-1100). Returns `False` on cancel → abort wizard; `None`
|
||||
means unencrypted wallet → proceed without password.
|
||||
2. **Per-transaction page** (one `QStackedWidget` step per tx, ordered by
|
||||
txid like export):
|
||||
|
||||
```
|
||||
Tx 2 of 5 — a1b2…c3d1 (short txid)
|
||||
Locktime: 2033-04-05 Status: unsigned (0/1 sigs)
|
||||
┌ outputs ─────────────────────────────────┐
|
||||
│ bc1q…heir1 0,042 BTC │
|
||||
│ bc1q…willexec fee 0,00012 BTC │
|
||||
│ bc1q…change 0,00988 BTC │
|
||||
└───────────────────────────────────────────┘
|
||||
Total outputs: 0,052 BTC Fees: 420 sat (1.2 sat/vB)
|
||||
[Sign & Next ▶] [Skip] [Cancel all]
|
||||
```
|
||||
- Outputs from `tx.outputs()` (address via `TxOutput.get_ui_address_str()`
|
||||
style helpers already imported in the qt layer; value via
|
||||
`bal_window.window.format_amount`).
|
||||
- Totals: `output_value()` sum; fees via `input_value() - output_value()`
|
||||
after resolving inputs with `Will.add_info_from_will(will, wid, wallet)`
|
||||
(F10); `-1`/unknown handled like widgets.py:1319-1324 (F9).
|
||||
3. **Sign & Next** → sign this single tx through a **refactored helper**
|
||||
extracted from the loop body of `sign_transactions`
|
||||
(window.py:1037-1083 → `_sign_single_tx(tx, willitems, password)` kept
|
||||
byte-equivalent; batch method calls the helper per iteration so existing
|
||||
behaviour/tests are unaffected). Update `COMPLETE`/sig-counts exactly as
|
||||
today; then advance.
|
||||
4. **Skip** leaves the tx untouched and advances. **Cancel all** stops; the
|
||||
already-signed txs remain in the wizard's local dict (still exportable —
|
||||
confirmation dialog warns about skipped ones).
|
||||
5. **Summary page**: `signed X of Y`, skipped/failed lists, then:
|
||||
|
||||
```
|
||||
[Save signed file…] [Show QR…] [Close]
|
||||
```
|
||||
- *Save file* = existing JSON path: `export_meta_gui(window,
|
||||
"will.json", writer)` writing `{wid: wi.to_dict()}` of the signed
|
||||
subset (same serializer as `export_json_file`, window.py:1605).
|
||||
- *Show QR* = `WillQrExportDialog` over `[str(wi.tx)]` of the signed
|
||||
subset (the online machine can scan them straight into Merge).
|
||||
- Nothing touches `self.willitems`/history (external-mode rule, F3).
|
||||
|
||||
---
|
||||
|
||||
## 9. Checklist (execution order — tick here when resuming work)
|
||||
|
||||
- [x] **P0** `bal/core/qrtransfer.py` + unit tests `tests/test_core_qr_transfer.py`
|
||||
(cases: round-trip plain/compressed; boundaries: len%size==0, size>len,
|
||||
min-size guard; bad magic/version; missing middle frame; duplicate
|
||||
overwrite; inconsistent totals; multi-PSBT mixes; presets sanity vs
|
||||
qrcode capacities F15). Run:
|
||||
`QT_QPA_PLATFORM=offscreen python3 tests/test_core_qr_transfer.py`
|
||||
- [x] **P1** Settings: `QR_CHUNK_SIZE` config var + settings-dialog row 16
|
||||
(ø16) + reset kind (§6). Verify in `QT_QPA_PLATFORM=offscreen` GUI run.
|
||||
- [x] **P2** Export: `BalWindow.export_will_via_qr`, `get_audio_modem_plugin`
|
||||
helper, `WillList` menu action, `WillQrExportDialog` + `BalQrImage`,
|
||||
audio-modem send button (§7).
|
||||
- [x] **P3** Import: `import_will_via_qr`, `WillQrImportDialog` (§8.2),
|
||||
incl. camera error paths, audio-modem receive button (local mirror of
|
||||
`_recv`, `setText` sink replaced by a callback), plain-payload fast
|
||||
path into the wizard.
|
||||
- [x] **P4** Wizard: `_prepare_and_sign_tx` refactor + `WillTxReviewSignDialog`
|
||||
(§8.3). Regression-gate: full batch sign still green
|
||||
(`tests/test_core_*.py` offline batch; `tests/test_gui_*.py` batch
|
||||
including new `tests/test_gui_qr_transfer.py`).
|
||||
- [x] **P5** Docs & QML plan sync: update `QML_PLAN.md` — Phase 2 models +=
|
||||
`BalQrTransferModel` (thin QObject over `bal.core.qrtransfer`),
|
||||
Phase 3 += dedicated views `BalQrExportPage.qml` /
|
||||
`BalQrImportPage.qml` (slot grid + `QRScan` reuse), delete the
|
||||
"chunked streams deferred" note, rewrite R6 mitigation, add Android
|
||||
caveat quoting F17 with file/paste fallback; README/HANDOFF sections;
|
||||
CHANGELOG numbered entry 56 at END (house rule).
|
||||
- [x] **P6** Release hygiene: `python3 build_zip.py` +
|
||||
`QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py
|
||||
electrum.plugins.bal` + external-zip test; ruff (repo venv
|
||||
`venv/bin/ruff`) no NEW violations; pyright false-positive policy per
|
||||
AGENTS.md. Version bump only via `make-release.sh` (owner-driven).
|
||||
Docs: document audio-modem as OPTIONAL channel — requires the
|
||||
Electrum `audio_modem` plugin enabled plus `pip install amodem`
|
||||
and libportaudio (not installed in the dev runtime env today, F22);
|
||||
manual test matrix gains an audiomodem round-trip row (two machines,
|
||||
default slowest bitrate) marked optional/skippable when hardware
|
||||
unavailable.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| High frame counts annoy users (e.g. 40+ QR at 150 B) | Presets span 150→1800; compress option; counter always visible |
|
||||
| Big QR versions fail on cheap cameras | EC=M fixed; Small preset targets low-res cams (D5 rationale) |
|
||||
| User rescans old export with different total | `InconsistentTotalError` → clear warning + Reset (§8.2) |
|
||||
| `_sign_single_tx` refactor regresses batch signing | Byte-equivalent extraction; batch callers unchanged; offline core tests gate P4 |
|
||||
| Imported txs reference UTXOs the importing wallet doesn't know | Validity pass drops them with an explicit report instead of silently merging garbage |
|
||||
| zbar/camera unavailable (esp. Windows/macOS packaging) | `scan_qrcode_from_camera` error path → suggest file export/import fallback |
|
||||
| `amodem`/portaudio not installed (current dev env state, F22) or audio_modem plugin disabled | Buttons simply hidden; QR/file remain the primary channels; P6 documents the optional dependency |
|
||||
| Audio transfer fails mid-way (noise, wrong volume) | Plugin's WaitingDialog surfaces the error; user retries — nothing to clean up on BAL side (single atomic blob, no slot state touched) |
|
||||
| Very slow airtime at default slowest bitrate | Bitrate is selectable in the audio_modem plugin's own settings (F20); BAL adds no knob; tooltip in export dialog hints at large payloads |
|
||||
| Qt6 camera instability on Android (future QML work) | Recorded as caveat in QML_PLAN update (P5), file/paste stays the primary mobile fallback |
|
||||
|
||||
---
|
||||
|
||||
## 11. Findings log (append-only)
|
||||
|
||||
- 2026-08-25: plan drafted after code exploration; owner answered D1-D6
|
||||
(D6 amended live from "preview dialog" to "review+sign wizard").
|
||||
- Verified F12 (QRCodeWidget hardcodes EC-L) and F11 (str(tx) round-trip
|
||||
guarantees) — both shaped §§4/7.
|
||||
- 2026-08-25: owner requested an audio-modem transfer path → researched
|
||||
`electrum/plugins/audio_modem/qt.py`, added D7 + F20-F23, §4.4, buttons
|
||||
in §§7.3/8.2, checklist/risk updates. Key constraint found: `_recv`'s
|
||||
only contract is `parent.setText(blob)` (F21) → thin adapter object; and
|
||||
BAL must not chunk/compress on this channel (F23). `amodem` is NOT in
|
||||
the runtime env yet — feature is strictly optional.
|
||||
27
README.md
27
README.md
@@ -26,6 +26,8 @@ bal/ the installable Electrum plugin package
|
||||
│ ├── willexecutors.py
|
||||
│ ├── checkalive.py
|
||||
│ ├── reminders.py
|
||||
│ ├── qrtransfer.py BAL QR will-transfer wire format / chunk scheduler
|
||||
│ ├── animated_qr.py BC-UR v1/v2 + BBQR codecs (stdlib-only)
|
||||
│ └── input_rules.py
|
||||
├── cli/ headless command-line layer (no Qt)
|
||||
│ ├── commands.py bal_* daemon commands (@plugin_command)
|
||||
@@ -87,6 +89,31 @@ Copy the `bal/` directory into your Electrum installation's
|
||||
`electrum/plugins/` directory, so that `electrum/plugins/bal/manifest.json`
|
||||
exists, then enable it from **Tools → Plugins**.
|
||||
|
||||
## Transfer a will with QR codes (or audio)
|
||||
|
||||
From the will list (**Export → QR Codes**) a will can be exported as a
|
||||
sequence of QR codes and imported on another device (**Import via QR**). The
|
||||
export offers All / Valid / Valid-NC filters plus a QR size preset
|
||||
(150–1800 bytes/frame) and ships the default **BAL QR** format already
|
||||
compressed whenever that is smaller (best-of zlib, flag per frame); the import
|
||||
flow reviews and sign each transaction
|
||||
one at a time, then proposes exporting the signed transactions. When
|
||||
Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` +
|
||||
PortAudio) Send/Receive audio buttons complement the QR channel. See
|
||||
[`PLAN_QR_TRANSFER.md`](PLAN_QR_TRANSFER.md) for the BAL QR wire-format spec.
|
||||
|
||||
### Animated-QR formats (interop)
|
||||
|
||||
BAL QR is the default export format, but the export page's **Format** selector
|
||||
also emits **BC-UR v1** (`ur:bytes`, BC32 + SHA-256), **BC-UR v2**
|
||||
(`ur:bytes`, CBOR fountain codes) and **BBQR** (`B$…`, Coinkite, used by
|
||||
BitKit) animated-QR sequences. The importer auto-detects the format of each
|
||||
code it sees, so any of the four formats can be imported on a BAL device, and
|
||||
a BAL export can be imported by any tool that understands these standards.
|
||||
UR v2 imports tolerate out-of-order and duplicate frames (fountain decoding);
|
||||
BBQR frames may arrive in any order. Rotation/redundancy caps and the
|
||||
32 MB message limit (zlib-bomb guard) bound untrusted scanner input.
|
||||
|
||||
## Command-line / headless usage
|
||||
|
||||
BAL can be used without the Qt GUI via Electrum's daemon mode. The CLI layer
|
||||
|
||||
10
android/.gitignore
vendored
Normal file
10
android/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
*.apk
|
||||
*.aab
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.hprof
|
||||
153
android/README.md
Normal file
153
android/README.md
Normal 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.
|
||||
67
android/app/build.gradle.kts
Normal file
67
android/app/build.gradle.kts
Normal 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
5
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Chaquopy Python runtime.
|
||||
-keep class com.chaquo.python.** { *; }
|
||||
|
||||
# ML Kit barcode scanning.
|
||||
-keep class com.google.mlkit.** { *; }
|
||||
31
android/app/src/main/AndroidManifest.xml
Normal file
31
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
129
android/app/src/main/java/life/after/bitcoin/BalDecoder.kt
Normal file
129
android/app/src/main/java/life/after/bitcoin/BalDecoder.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
209
android/app/src/main/java/life/after/bitcoin/MainActivity.kt
Normal file
209
android/app/src/main/java/life/after/bitcoin/MainActivity.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
100
android/app/src/main/java/life/after/bitcoin/ResultActivity.kt
Normal file
100
android/app/src/main/java/life/after/bitcoin/ResultActivity.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
21
android/app/src/main/python/bal/core/__init__.py
Normal file
21
android/app/src/main/python/bal/core/__init__.py
Normal 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.
|
||||
"""
|
||||
1180
android/app/src/main/python/bal/core/animated_qr.py
Normal file
1180
android/app/src/main/python/bal/core/animated_qr.py
Normal file
File diff suppressed because it is too large
Load Diff
304
android/app/src/main/python/bal/core/qrtransfer.py
Normal file
304
android/app/src/main/python/bal/core/qrtransfer.py
Normal 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
|
||||
1
android/app/src/main/python/balreader/__init__.py
Normal file
1
android/app/src/main/python/balreader/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Android reader helpers built on the bundled plugin codecs."""
|
||||
33
android/app/src/main/python/balreader/bridge.py
Normal file
33
android/app/src/main/python/balreader/bridge.py
Normal 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": []})
|
||||
33
android/app/src/main/python/balreader/payload.py
Normal file
33
android/app/src/main/python/balreader/payload.py
Normal 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)
|
||||
34
android/app/src/main/res/drawable/ic_launcher.xml
Normal file
34
android/app/src/main/res/drawable/ic_launcher.xml
Normal 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>
|
||||
59
android/app/src/main/res/layout/activity_main.xml
Normal file
59
android/app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
67
android/app/src/main/res/layout/activity_result.xml
Normal file
67
android/app/src/main/res/layout/activity_result.xml
Normal 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>
|
||||
5
android/app/src/main/res/values/colors.xml
Normal file
5
android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
24
android/app/src/main/res/values/strings.xml
Normal file
24
android/app/src/main/res/values/strings.xml
Normal 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>
|
||||
3
android/app/src/main/res/values/themes.xml
Normal file
3
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<style name="Theme.BalReader" parent="Theme.AppCompat.DayNight.NoActionBar" />
|
||||
</resources>
|
||||
7
android/build.gradle.kts
Normal file
7
android/build.gradle.kts
Normal 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
|
||||
}
|
||||
5
android/gradle.properties
Normal file
5
android/gradle.properties
Normal 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
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
251
android/gradlew
vendored
Executable 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
94
android/gradlew.bat
vendored
Normal 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
161
android/scripts/build_apk.py
Executable 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:]))
|
||||
99
android/scripts/sync_codecs.py
Normal file
99
android/scripts/sync_codecs.py
Normal 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:]))
|
||||
23
android/settings.gradle.kts
Normal file
23
android/settings.gradle.kts
Normal 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")
|
||||
314
android/test_chain/verify_chain.py
Normal file
314
android/test_chain/verify_chain.py
Normal 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
202
android/tools/emitter.py
Normal 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:])
|
||||
26
android/tools/sample_will.json
Normal file
26
android/tools/sample_will.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ This module is imported lazily (only when a ``bal_*`` command actually runs),
|
||||
so a missing wallet or a network-less daemon can still start Electrum.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import time
|
||||
|
||||
@@ -44,10 +43,11 @@ from ..core.checkalive import (
|
||||
CheckAliveError,
|
||||
check_alive_expired,
|
||||
resolve_date_to_check,
|
||||
resolve_guard_threshold,
|
||||
)
|
||||
from ..core.heirs import Heirs, is_op_return_address
|
||||
from ..core.plugin_base import BalConfig, BalPlugin
|
||||
from ..core.util import Util
|
||||
from ..core.util import Util, copy_structure
|
||||
from ..core.will import Will, WillItem
|
||||
from ..core.willexecutors import Willexecutors, is_onion_url, is_tor_active
|
||||
|
||||
@@ -315,6 +315,28 @@ class BalController:
|
||||
executor.
|
||||
"""
|
||||
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.plugin, update=False, task=False
|
||||
)
|
||||
@@ -346,11 +368,11 @@ class BalController:
|
||||
tx["my_locktime"] = txs[txid].my_locktime
|
||||
tx["heirsvalue"] = txs[txid].heirsvalue
|
||||
tx["description"] = txs[txid].description
|
||||
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
|
||||
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
|
||||
tx["status"] = _("New")
|
||||
tx["baltx_fees"] = txs[txid].tx_fees
|
||||
tx["time"] = creation_time
|
||||
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
|
||||
tx["heirs"] = copy_structure(txs[txid].heirs)
|
||||
tx["txchildren"] = []
|
||||
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
||||
Will.update_will(self.willitems, will)
|
||||
@@ -435,7 +457,13 @@ class BalController:
|
||||
raise _user_facing(e) from e
|
||||
|
||||
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"))
|
||||
|
||||
if not self.no_willexecutor:
|
||||
|
||||
1180
bal/core/animated_qr.py
Normal file
1180
bal/core/animated_qr.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,54 @@ def resolve_date_to_check(
|
||||
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(
|
||||
is_basic_mode: bool, date_to_check: float, now: float | None = None
|
||||
) -> bool:
|
||||
|
||||
@@ -59,7 +59,7 @@ from electrum.util import (
|
||||
write_json_file,
|
||||
)
|
||||
|
||||
from .util import Util
|
||||
from .util import Util, copy_structure
|
||||
from .willexecutors import Willexecutors
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -321,40 +321,14 @@ def get_change_output(wallet, in_amount, out_amount, fee):
|
||||
return out
|
||||
|
||||
|
||||
def _json_safe(value, _path="heirs", _depth=0):
|
||||
"""Return a JSON-serializable deep copy of *value*.
|
||||
def _json_safe(value, _path="heirs"):
|
||||
"""Backward-compatible alias of :func:`bal.core.util.copy_structure`.
|
||||
|
||||
The wallet DB persists the heirs dict via ``json_db.put``, which calls
|
||||
``copy.deepcopy`` on the value. If any nested element is a live runtime
|
||||
object (e.g. one holding a ``threading.RLock``), deepcopy raises
|
||||
``TypeError: cannot pickle '_thread.RLock' object`` and the whole
|
||||
"Build will" task fails.
|
||||
|
||||
To make persistence robust we coerce the structure to plain
|
||||
JSON-compatible types (dict / list / str / int / float / bool / None).
|
||||
Anything else is converted to ``str(value)`` and logged with its path so
|
||||
the offending field can be identified, instead of crashing the task.
|
||||
Kept so call sites that imported ``_json_safe`` directly keep working; the
|
||||
actual implementation (a JSON-safe, deepcopy-free clone) lives in
|
||||
``bal.core.util`` so every copy path shares one code base.
|
||||
"""
|
||||
# Primitive JSON scalars are kept as-is.
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(k): _json_safe(v, "{}[{!r}]".format(_path, k), _depth + 1)
|
||||
for k, v in value.items()
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [
|
||||
_json_safe(v, "{}[{}]".format(_path, i), _depth + 1)
|
||||
for i, v in enumerate(value)
|
||||
]
|
||||
# Unexpected runtime object: do not let it reach deepcopy. Log where it
|
||||
# was found so the real source can be fixed, then store a safe string.
|
||||
_logger.error(
|
||||
"heirs.save: non-serializable value at {} (type={}); coercing to str. "
|
||||
"value={!r}".format(_path, type(value).__name__, value)
|
||||
)
|
||||
return str(value)
|
||||
return copy_structure(value, _path=_path)
|
||||
|
||||
|
||||
class Heirs(dict, Logger):
|
||||
|
||||
@@ -24,7 +24,7 @@ This module performs **no** GUI work and imports nothing from PyQt / electrum.gu
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from electrum import constants, json_db
|
||||
from electrum.logging import get_logger
|
||||
@@ -109,7 +109,9 @@ def get_will(x):
|
||||
|
||||
try:
|
||||
# Electrum >= 4.8.0
|
||||
from electrum.stored_dict import register_name as _electrum_register_name # pyright: ignore[reportMissingImports]
|
||||
from electrum.stored_dict import (
|
||||
register_name as _electrum_register_name, # pyright: ignore[reportMissingImports]
|
||||
)
|
||||
|
||||
def _register_will_dict(name, method, _type=None):
|
||||
"""Register a plugin dict in the wallet DB (Electrum >= 4.8.0 API)."""
|
||||
@@ -279,6 +281,13 @@ class BalPlugin(BasePlugin):
|
||||
# stay display-only outside the wizard unless the user opts in.
|
||||
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
|
||||
|
||||
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in bytes, used
|
||||
# per QR frame when exporting/importing a will through the QR channel.
|
||||
# The settings dialog offers the 4 standard presets of
|
||||
# bal.core.qrtransfer.CHUNK_PRESETS; this stores the selected budget.
|
||||
# Default 150 (small QR, low-resolution cameras).
|
||||
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
|
||||
|
||||
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
|
||||
# exported .ics calendar should contain. Each reminder becomes its own
|
||||
# VEVENT (its own date in the calendar). The dates are spread uniformly
|
||||
|
||||
304
bal/core/qrtransfer.py
Normal file
304
bal/core/qrtransfer.py
Normal 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
|
||||
@@ -21,8 +21,11 @@ import bisect
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
||||
from electrum.logging import get_logger
|
||||
from electrum.transaction import PartialTxOutput
|
||||
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
# Bitcoin consensus rule: an nLockTime value strictly below this threshold is
|
||||
# interpreted as a *block height*, otherwise it is interpreted as a *UNIX
|
||||
# timestamp*.
|
||||
@@ -35,6 +38,41 @@ from electrum.transaction import PartialTxOutput
|
||||
LOCKTIME_THRESHOLD = 500000000
|
||||
|
||||
|
||||
def copy_structure(value, _path="copy"):
|
||||
"""Return a JSON-serializable deep copy of *value*.
|
||||
|
||||
This is the ad-hoc, deepcopy-free stand-in used every time the plugin needs
|
||||
an independent copy of a plain-data structure (heirs dicts, will-executor
|
||||
dicts, status tables). It recursively clones dict / list / tuple values
|
||||
while leaving JSON scalars (str / int / float / bool / None) as-is.
|
||||
|
||||
If any nested element is a live runtime object (e.g. one holding a
|
||||
``threading.RLock``), ``copy.deepcopy`` would raise
|
||||
``TypeError: cannot pickle '_thread.RLock' object``; instead we coerce the
|
||||
offending value to ``str(value)`` and log it with its path so the source
|
||||
field can be identified, without crashing the caller.
|
||||
"""
|
||||
# Primitive JSON scalars are kept as-is.
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(k): copy_structure(v, "{}[{!r}]".format(_path, k))
|
||||
for k, v in value.items()
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [
|
||||
copy_structure(v, "{}[{}]".format(_path, i)) for i, v in enumerate(value)
|
||||
]
|
||||
# Unexpected runtime object: do not let it reach deepcopy. Log where it
|
||||
# was found so the real source can be fixed, then store a safe string.
|
||||
_logger.error(
|
||||
"copy_structure: non-serializable value at {} (type={}); coercing to "
|
||||
"str. value={!r}".format(_path, type(value).__name__, value)
|
||||
)
|
||||
return str(value)
|
||||
|
||||
|
||||
class Util:
|
||||
"""Namespace of static helpers (kept as a class to preserve the original
|
||||
``Util.method(...)`` call sites used throughout the plugin)."""
|
||||
|
||||
161
bal/core/will.py
161
bal/core/will.py
@@ -26,9 +26,9 @@ The status flags themselves (the source of truth) stay here; only the mapping
|
||||
"status -> colour" now lives in the GUI layer. No behaviour changed.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
||||
from electrum.i18n import _
|
||||
from electrum.logging import Logger, get_logger
|
||||
from electrum.transaction import (
|
||||
@@ -45,7 +45,7 @@ from electrum.util import (
|
||||
)
|
||||
|
||||
from .heirs import WillExecutorFeeTooHighException
|
||||
from .util import Util
|
||||
from .util import Util, copy_structure
|
||||
from .willexecutors import Willexecutors
|
||||
|
||||
MIN_LOCKTIME = 1
|
||||
@@ -143,7 +143,7 @@ class Will:
|
||||
willitems = {}
|
||||
for wid in will:
|
||||
Will.add_info_from_will(will, wid, wallet)
|
||||
willitems[wid] = WillItem(will[wid])
|
||||
willitems[wid] = WillItem(will[wid], wallet=wallet)
|
||||
will = willitems
|
||||
errors = {}
|
||||
for wid in will:
|
||||
@@ -165,7 +165,7 @@ class Will:
|
||||
outputs = will[wid].tx.outputs()
|
||||
ow = will[wid]
|
||||
ow.normalize_locktime(others_input)
|
||||
will[wid] = WillItem(ow.to_dict())
|
||||
will[wid] = ow.copy()
|
||||
|
||||
for i in range(0, len(outputs)):
|
||||
Will.change_input(
|
||||
@@ -465,7 +465,7 @@ class Will:
|
||||
continue
|
||||
utxo_str = utxo.prevout.to_str()
|
||||
if utxo_str in prevout_to_spend:
|
||||
balance += inputs[utxo_str][0][2].value_sats()
|
||||
balance += utxo.value_sats()
|
||||
utxo_to_spend.append(utxo)
|
||||
_logger.debug("utxo to spend: {}".format(utxo_to_spend))
|
||||
if len(utxo_to_spend) > 0:
|
||||
@@ -848,6 +848,48 @@ class Will:
|
||||
except Exception as 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
|
||||
def _add_transaction_to_history(wallet, tx, txid):
|
||||
"""Store *tx* into the wallet's local history via ``adb``.
|
||||
@@ -1214,9 +1256,9 @@ class Will:
|
||||
|
||||
if Util.parse_locktime_string(heirs[h][2]) >= check_date:
|
||||
count_heirs += 1
|
||||
if h not in heirs_found:
|
||||
_logger.debug(f"heir: {h} not found")
|
||||
raise HeirNotFoundException(h)
|
||||
if h not in heirs_found:
|
||||
_logger.debug(f"heir: {h} not found")
|
||||
raise HeirNotFoundException(h)
|
||||
if not count_heirs:
|
||||
raise NoHeirsException("there are not valid heirs")
|
||||
if self_willexecutor and no_willexecutor == 0:
|
||||
@@ -1327,49 +1369,76 @@ class WillItem(Logger):
|
||||
return self.STATUS[status][1]
|
||||
|
||||
def __init__(self, w, _id=None, wallet=None):
|
||||
if isinstance(
|
||||
w,
|
||||
WillItem,
|
||||
):
|
||||
self.__dict__ = w.__dict__.copy()
|
||||
self.STATUS = copy.deepcopy(w.STATUS)
|
||||
self.heirs = copy.deepcopy(w.heirs) if w.heirs is not None else None
|
||||
else:
|
||||
self.tx = Will.get_tx_from_any(w["tx"])
|
||||
self.heirs = w.get("heirs", None)
|
||||
self.we = w.get("willexecutor", None)
|
||||
self.status = w.get("status", None)
|
||||
self.description = w.get("description", None)
|
||||
self.time = w.get("time", None)
|
||||
self.change = w.get("change", None)
|
||||
self.tx_fees = w.get("baltx_fees", 0)
|
||||
self.sigs_required = int(w.get("sigs_required", 0))
|
||||
self.sigs_have = int(w.get("sigs_have", 0))
|
||||
self.father = w.get("Father", None)
|
||||
self.children = w.get("Children", None)
|
||||
self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
for s in self.STATUS:
|
||||
self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1])
|
||||
# Backward-compatibility migration (A2): the "PENDING" status was
|
||||
# renamed to "MEMPOOL". Wills saved by older versions of the plugin
|
||||
# store the flag under the legacy "PENDING" key, so if that key is
|
||||
# present and set, carry it over to "MEMPOOL". This way no state is
|
||||
# lost when loading an older will. The new key always wins if both
|
||||
# happen to be present.
|
||||
if "MEMPOOL" not in w and w.get("PENDING"):
|
||||
self.STATUS["MEMPOOL"][1] = True
|
||||
if isinstance(w, WillItem):
|
||||
# Copy a WillItem WITHOUT deepcopy. Serialize it to its plain-dict
|
||||
# form and deserialize from there: the tx is re-parsed into a fresh
|
||||
# object, STATUS is rebuilt from the clones below and heirs /
|
||||
# will-executors are cloned recursively, so the copy shares no
|
||||
# mutable state with the source. See also copy().
|
||||
data = w.to_dict()
|
||||
data["heirs"] = copy_structure(w.heirs) if w.heirs is not None else None
|
||||
data["willexecutor"] = (
|
||||
copy_structure(w.we) if w.we is not None else None
|
||||
)
|
||||
if not _id:
|
||||
self._id = self.tx.txid()
|
||||
else:
|
||||
self._id = _id
|
||||
_id = w._id
|
||||
w = data
|
||||
self.tx = Will.get_tx_from_any(w["tx"])
|
||||
self.heirs = w.get("heirs", None)
|
||||
self.we = w.get("willexecutor", None)
|
||||
self.status = w.get("status") or ""
|
||||
self.description = w.get("description", None)
|
||||
self.time = w.get("time", None)
|
||||
self.change = w.get("change", None)
|
||||
self.tx_fees = w.get("baltx_fees", 0)
|
||||
self.sigs_required = int(w.get("sigs_required", 0))
|
||||
self.sigs_have = int(w.get("sigs_have", 0))
|
||||
self.father = w.get("Father", None)
|
||||
self.children = w.get("Children", None)
|
||||
self.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
for s in self.STATUS:
|
||||
self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1])
|
||||
# Backward-compatibility migration (A2): the "PENDING" status was
|
||||
# renamed to "MEMPOOL". Wills saved by older versions of the plugin
|
||||
# store the flag under the legacy "PENDING" key, so if that key is
|
||||
# present and set, carry it over to "MEMPOOL". This way no state is
|
||||
# lost when loading an older will. The new key always wins if both
|
||||
# happen to be present.
|
||||
if "MEMPOOL" not in w and w.get("PENDING"):
|
||||
self.STATUS["MEMPOOL"][1] = True
|
||||
if not _id:
|
||||
self._id = self.tx.txid()
|
||||
else:
|
||||
self._id = _id
|
||||
|
||||
if not self._id:
|
||||
self.status += "ERROR!!!"
|
||||
self.valid = False
|
||||
if not self._id:
|
||||
self.status += "ERROR!!!"
|
||||
self.valid = False
|
||||
|
||||
if wallet:
|
||||
self.tx.add_info_from_wallet(wallet)
|
||||
|
||||
def copy(self, wallet=None):
|
||||
"""Return an independent copy of this WillItem (no deepcopy).
|
||||
|
||||
The copy is produced by serializing this item and deserializing it:
|
||||
the transaction is re-parsed, the STATUS table is rebuilt and
|
||||
heirs / will-executors are cloned recursively, so the result shares no
|
||||
mutable state with ``self``. Pass a ``wallet`` when the copy's tx
|
||||
needs its address/value information restored
|
||||
(``tx.add_info_from_wallet``).
|
||||
"""
|
||||
return WillItem(self, _id=self._id, wallet=wallet)
|
||||
|
||||
@staticmethod
|
||||
def copy_status_table(status_table):
|
||||
"""Clone a STATUS table (``{flag: [label, bool]}``) without deepcopy.
|
||||
|
||||
Both the outer dict and every inner ``[label, bool]`` list are new
|
||||
objects, so mutating the returned table never affects the source.
|
||||
"""
|
||||
return {k: [label, value] for k, (label, value) in status_table.items()}
|
||||
|
||||
def to_dict(self):
|
||||
out = {
|
||||
"_id": self._id,
|
||||
@@ -1383,6 +1452,8 @@ class WillItem(Logger):
|
||||
"baltx_fees": self.tx_fees,
|
||||
"sigs_required": self.sigs_required,
|
||||
"sigs_have": self.sigs_have,
|
||||
"Father": self.father,
|
||||
"Children": self.children,
|
||||
}
|
||||
for key in self.STATUS:
|
||||
try:
|
||||
|
||||
@@ -16,11 +16,10 @@ the Qt button and the OS/subprocess glue.
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from electrum.gui.qt.util import getSaveFileName
|
||||
from PyQt6.QtGui import QAction
|
||||
from PyQt6.QtWidgets import QInputDialog, QMenu, QToolButton
|
||||
|
||||
from electrum.gui.qt.util import getSaveFileName
|
||||
|
||||
from ...core.reminders import write_temp_ics
|
||||
from .common import _, _logger
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ hosts a few GUI helpers that do not deserve a module of their own:
|
||||
(:class:`CheckAliveError` now lives in ``bal.core.checkalive``.)
|
||||
"""
|
||||
|
||||
import copy
|
||||
import enum
|
||||
import os
|
||||
import subprocess
|
||||
@@ -28,6 +27,7 @@ from functools import partial
|
||||
from typing import Any, Callable, Mapping, Optional, Union
|
||||
|
||||
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
|
||||
from electrum.gui.common_qt.util import draw_qr
|
||||
from electrum.gui.qt.amountedit import BTCAmountEdit
|
||||
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
|
||||
from electrum.gui.qt.my_treeview import MyTreeView
|
||||
@@ -42,6 +42,7 @@ from electrum.gui.qt.util import (
|
||||
MessageBoxMixin,
|
||||
OkButton,
|
||||
TaskThread,
|
||||
WaitingDialog,
|
||||
WindowModalDialog,
|
||||
char_width_in_lineedit,
|
||||
getOpenFileName,
|
||||
@@ -80,6 +81,7 @@ from PyQt6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QAbstractSpinBox,
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDateTimeEdit,
|
||||
@@ -92,6 +94,7 @@ from PyQt6.QtWidgets import (
|
||||
QMenu,
|
||||
QMenuBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
@@ -119,7 +122,7 @@ from ...core.heirs import (
|
||||
|
||||
# --- Core (GUI-free) logic layer ---
|
||||
from ...core.plugin_base import BalPlugin, BalTimestamp
|
||||
from ...core.util import Util
|
||||
from ...core.util import Util, copy_structure
|
||||
from ...core.will import (
|
||||
AmountException,
|
||||
HeirChangeException,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,15 +20,13 @@ from PyQt6.QtWidgets import QLineEdit as _QLineEdit
|
||||
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
|
||||
|
||||
from .common import (
|
||||
_,
|
||||
_logger,
|
||||
OP_RETURN_PREFIX,
|
||||
BalTimestamp,
|
||||
Buttons,
|
||||
CancelButton,
|
||||
HelpButton,
|
||||
MessageBoxMixin,
|
||||
MyTreeView,
|
||||
OP_RETURN_PREFIX,
|
||||
OkButton,
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
@@ -46,15 +44,17 @@ from .common import (
|
||||
QSpinBox,
|
||||
QStandardItem,
|
||||
QStandardItemModel,
|
||||
Qt,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
Qt,
|
||||
TaskThread,
|
||||
Util,
|
||||
Will,
|
||||
WillItem,
|
||||
Willexecutors,
|
||||
WillItem,
|
||||
_,
|
||||
_logger,
|
||||
char_width_in_lineedit,
|
||||
datetime,
|
||||
enum,
|
||||
@@ -63,8 +63,8 @@ from .common import (
|
||||
import_meta_gui,
|
||||
is_op_return_address,
|
||||
partial,
|
||||
read_QIcon_from_bytes,
|
||||
read_json_file,
|
||||
read_QIcon_from_bytes,
|
||||
server_status_text,
|
||||
server_status_tooltip,
|
||||
signature_suffix,
|
||||
@@ -663,11 +663,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
menu.addAction(_("Prepare"), self.build_transactions)
|
||||
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
|
||||
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
|
||||
export_menu = menu.addMenu(_("Export"))
|
||||
export_menu.addAction(_("All"), self.export_will)
|
||||
export_menu.addAction(_("Valid"), self.export_will_valid)
|
||||
export_menu.addAction(_("Valid NC"), self.export_will_valid_incomplete)
|
||||
menu.addAction(_("Import"), self.import_will_into_details)
|
||||
# Export/Import open a single window that offers all transports
|
||||
# (file / QR / audio). The Choose Filter / transport settings live
|
||||
# inside that window.
|
||||
menu.addAction(_("Export"), self.export_will)
|
||||
menu.addAction(_("Import"), self.import_will)
|
||||
menu.addAction(_("Merge"), self.merge_will)
|
||||
menu.addAction(_("Broadcast"), self.broadcast)
|
||||
menu.addAction(_("Check"), self.check)
|
||||
@@ -733,38 +733,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
if will:
|
||||
self.update_will(will)
|
||||
|
||||
def export_json_file(self, path):
|
||||
write_json_file(path, self.will)
|
||||
|
||||
def export_will(self):
|
||||
self.bal_window.export_will()
|
||||
self.update()
|
||||
self.bal_window.export_will_dialog()
|
||||
|
||||
def export_will_valid(self):
|
||||
"""Export only the will items that are valid."""
|
||||
subset = {
|
||||
wid: wi
|
||||
for wid, wi in self.will.items()
|
||||
if wi.get_status("VALID")
|
||||
}
|
||||
if not subset:
|
||||
self.show_message(_("No valid will item to export"))
|
||||
return
|
||||
self.bal_window.export_will(will=subset)
|
||||
self.update()
|
||||
|
||||
def export_will_valid_incomplete(self):
|
||||
"""Export only the will items that are valid but not yet fully signed (V-NC)."""
|
||||
subset = {
|
||||
wid: wi
|
||||
for wid, wi in self.will.items()
|
||||
if wi.get_status("VALID") and not wi.get_status("COMPLETE")
|
||||
}
|
||||
if not subset:
|
||||
self.show_message(_("No valid, incomplete will item to export"))
|
||||
return
|
||||
self.bal_window.export_will(will=subset)
|
||||
self.update()
|
||||
def import_will(self):
|
||||
self.bal_window.import_will_dialog()
|
||||
|
||||
def import_will_into_details(self):
|
||||
self.bal_window.import_will_into_details()
|
||||
|
||||
@@ -19,9 +19,8 @@ from electrum.plugin import hook
|
||||
from electrum.util import EventListener, event_listener
|
||||
from PyQt6.QtWidgets import QLayout
|
||||
|
||||
from ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size
|
||||
from .common import (
|
||||
_,
|
||||
_logger,
|
||||
BalPlugin,
|
||||
Buttons,
|
||||
EnterButton,
|
||||
@@ -38,6 +37,8 @@ from .common import (
|
||||
QWidget,
|
||||
UserCancelled,
|
||||
Willexecutors,
|
||||
_,
|
||||
_logger,
|
||||
add_widget,
|
||||
partial,
|
||||
read_QIcon_from_bytes,
|
||||
@@ -531,6 +532,21 @@ class Plugin(BalPlugin, EventListener):
|
||||
# users (BASIC and ADVANCED).
|
||||
heir_auto_rebuild = BalCheckBox(self.AUTO_REBUILD)
|
||||
|
||||
# QR Code Size selector (will transfer via QR). A 4-standard-size combo
|
||||
# bound to the QR_CHUNK_SIZE config (payload budget in bytes per frame).
|
||||
# Ordered low -> high so the user picks the resolution matching their
|
||||
# camera. Visible to all users (BASIC and ADVANCED).
|
||||
qr_size_combo = QComboBox()
|
||||
qr_size_combo.addItems([label for label, _budget in CHUNK_PRESETS])
|
||||
qr_size_combo.setCurrentIndex(
|
||||
preset_index_for_chunk_size(int(self.QR_CHUNK_SIZE.get()))
|
||||
)
|
||||
|
||||
def on_qr_size_change(index):
|
||||
self.QR_CHUNK_SIZE.set(CHUNK_PRESETS[index][1])
|
||||
|
||||
qr_size_combo.currentIndexChanged.connect(on_qr_size_change)
|
||||
|
||||
# USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo
|
||||
# (not a free-text field) bound to the USER_TYPE config:
|
||||
# index 0 -> "BASIC" -> stored value "basic" (DEFAULT)
|
||||
@@ -647,6 +663,10 @@ class Plugin(BalPlugin, EventListener):
|
||||
widget.setCurrentIndex(
|
||||
1 if str(cfg.default).lower() == "advanced" else 0
|
||||
)
|
||||
elif kind == "qr_size":
|
||||
widget.setCurrentIndex(
|
||||
preset_index_for_chunk_size(int(cfg.default))
|
||||
)
|
||||
btn.clicked.connect(reset)
|
||||
return btn
|
||||
|
||||
@@ -905,6 +925,25 @@ class Plugin(BalPlugin, EventListener):
|
||||
)
|
||||
grid.addWidget(reset_btn_auto_rebuild, 15, 3)
|
||||
|
||||
# "QR Code Size" row (always visible, BASIC + ADVANCED). Default QR
|
||||
# size used when exporting a will via QR codes; changeable per export
|
||||
# inside the export dialog itself.
|
||||
lbl_qr_size = QLabel(_("QR Code Size"))
|
||||
help_qr_size = HelpButton(
|
||||
"Payload size of a single QR code when exporting a will via QR.\n\n"
|
||||
"Larger QR codes hold more data (fewer shots) but are easier to "
|
||||
"scan with a high-resolution camera; smaller QR codes scan fine "
|
||||
"even with low-resolution cameras but require more shots.\n"
|
||||
"The same selector is available inside the export dialog."
|
||||
)
|
||||
grid.addWidget(lbl_qr_size, 16, 0)
|
||||
grid.addWidget(qr_size_combo, 16, 1)
|
||||
grid.addWidget(help_qr_size, 16, 2)
|
||||
reset_btn_qr_size = _make_reset_btn(
|
||||
self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"
|
||||
)
|
||||
grid.addWidget(reset_btn_qr_size, 16, 3)
|
||||
|
||||
# ----------------------------------------------------------------- #
|
||||
# Group C / C4b: "Reset" button that restores the dialog settings to #
|
||||
# their factory defaults. It only resets the settings exposed by THIS #
|
||||
@@ -938,6 +977,7 @@ class Plugin(BalPlugin, EventListener):
|
||||
(self.HISTORY_LABEL, edit_history_label, "line"),
|
||||
(self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"),
|
||||
(self.AUTO_REBUILD, heir_auto_rebuild, "check"),
|
||||
(self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"),
|
||||
]
|
||||
for cfg, widget, kind in resets:
|
||||
# Persist the default value back into the Electrum config.
|
||||
@@ -958,6 +998,10 @@ class Plugin(BalPlugin, EventListener):
|
||||
widget.setCurrentIndex(
|
||||
1 if str(cfg.default).lower() == "advanced" else 0
|
||||
)
|
||||
elif kind == "qr_size":
|
||||
widget.setCurrentIndex(
|
||||
preset_index_for_chunk_size(int(cfg.default))
|
||||
)
|
||||
# Re-sync the history-label field's enabled state after a reset: the
|
||||
# reset restores SAVE_HISTORY to its default, so the field must
|
||||
# follow the (default) checkbox state again.
|
||||
|
||||
@@ -20,6 +20,7 @@ Contents:
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...core.heirs import get_op_return_hex, is_op_return_address
|
||||
from ...core.input_rules import (
|
||||
LockTimeEditor,
|
||||
normalize_locktime_raw_text,
|
||||
@@ -29,17 +30,15 @@ from ...core.input_rules import (
|
||||
from ...core.reminders import build_ics_reminders, write_temp_ics
|
||||
from .calendar import BalCalendar, BalCalendarButton
|
||||
from .common import (
|
||||
_,
|
||||
_logger,
|
||||
Any,
|
||||
BTCAmountEdit,
|
||||
BalTimestamp,
|
||||
ColorScheme,
|
||||
DECIMAL_POINT,
|
||||
Decimal,
|
||||
HelpButton,
|
||||
NLOCKTIME_BLOCKHEIGHT_MAX,
|
||||
NLOCKTIME_MAX,
|
||||
Any,
|
||||
BalTimestamp,
|
||||
BTCAmountEdit,
|
||||
ColorScheme,
|
||||
Decimal,
|
||||
HelpButton,
|
||||
Optional,
|
||||
QAbstractSpinBox,
|
||||
QCheckBox,
|
||||
@@ -57,13 +56,15 @@ from .common import (
|
||||
QSpinBox,
|
||||
QStyle,
|
||||
QStyleOptionFrame,
|
||||
Qt,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
Qt,
|
||||
Union,
|
||||
Util,
|
||||
Will,
|
||||
_,
|
||||
_logger,
|
||||
char_width_in_lineedit,
|
||||
datetime,
|
||||
getSaveFileName,
|
||||
@@ -1331,14 +1332,28 @@ class WillWidget(QWidget):
|
||||
)
|
||||
detaillayout.addWidget(QLabel(""))
|
||||
detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
|
||||
for heir in self.will[w].heirs:
|
||||
if 'w!ll3x3c"' not in heir:
|
||||
decoded_amount = Util.decode_amount(
|
||||
self.will[w].heirs[heir][3], self._bal_parent.decimal_point
|
||||
)
|
||||
for heir_name in self.will[w].heirs:
|
||||
if 'w!ll3x3c"' in heir_name:
|
||||
continue
|
||||
h = self.will[w].heirs[heir_name]
|
||||
decoded_amount = Util.decode_amount(
|
||||
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(
|
||||
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:
|
||||
@@ -1354,6 +1369,10 @@ class WillWidget(QWidget):
|
||||
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()
|
||||
pal = QPalette()
|
||||
pal.setColor(
|
||||
|
||||
@@ -23,10 +23,10 @@ from ...core.checkalive import (
|
||||
CheckAliveError,
|
||||
check_alive_expired,
|
||||
resolve_date_to_check,
|
||||
resolve_guard_threshold,
|
||||
)
|
||||
from .common import (
|
||||
_,
|
||||
_logger,
|
||||
OP_RETURN_PREFIX,
|
||||
AmountException,
|
||||
BalPlugin,
|
||||
Buttons,
|
||||
@@ -40,10 +40,10 @@ from .common import (
|
||||
Mapping,
|
||||
Network,
|
||||
NoHeirsException,
|
||||
NoWillExecutorNotPresent,
|
||||
NotCompleteWillException,
|
||||
OP_RETURN_PREFIX,
|
||||
NoWillExecutorNotPresent,
|
||||
OkButton,
|
||||
Optional,
|
||||
PaymentIdentifier,
|
||||
QGridLayout,
|
||||
QLabel,
|
||||
@@ -57,15 +57,17 @@ from .common import (
|
||||
TxFeesChangedException,
|
||||
Util,
|
||||
Will,
|
||||
WillexecutorChangeException,
|
||||
WillExecutorFeeTooHighException,
|
||||
WillExecutorNotPresent,
|
||||
Willexecutors,
|
||||
WillExpiredException,
|
||||
WillItem,
|
||||
WillPostponedException,
|
||||
WillexecutorChangeException,
|
||||
Willexecutors,
|
||||
_,
|
||||
_logger,
|
||||
char_width_in_lineedit,
|
||||
copy,
|
||||
copy_structure,
|
||||
export_meta_gui,
|
||||
import_meta_gui,
|
||||
is_onion_url,
|
||||
@@ -73,8 +75,8 @@ from .common import (
|
||||
is_tor_active,
|
||||
log_error,
|
||||
partial,
|
||||
read_QIcon_from_bytes,
|
||||
read_json_file,
|
||||
read_QIcon_from_bytes,
|
||||
show_on_top,
|
||||
shown_cv,
|
||||
time,
|
||||
@@ -88,6 +90,10 @@ from .dialogs import (
|
||||
BalWizardDialog,
|
||||
WillDetailDialog,
|
||||
WillExecutorDialog,
|
||||
WillExportDialog,
|
||||
WillImportDialog,
|
||||
_complete_import,
|
||||
decode_will_payload,
|
||||
)
|
||||
from .lists import HeirListWidget, PreviewList
|
||||
from .widgets import LockTimeWidget, PercAmountEdit
|
||||
@@ -461,6 +467,31 @@ class BalWindow:
|
||||
|
||||
def build_will(self, ignore_duplicate=True, keep_original=True):
|
||||
_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 = {}
|
||||
# willtodelete = []
|
||||
# willtoappend = {}
|
||||
@@ -515,11 +546,11 @@ class BalWindow:
|
||||
tx["my_locktime"] = txs[txid].my_locktime
|
||||
tx["heirsvalue"] = txs[txid].heirsvalue
|
||||
tx["description"] = txs[txid].description
|
||||
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
|
||||
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
|
||||
tx["status"] = _("New")
|
||||
tx["baltx_fees"] = txs[txid].tx_fees
|
||||
tx["time"] = creation_time
|
||||
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
|
||||
tx["heirs"] = copy_structure(txs[txid].heirs)
|
||||
tx["txchildren"] = []
|
||||
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
||||
self.update_will(will)
|
||||
@@ -740,6 +771,27 @@ class BalWindow:
|
||||
|
||||
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):
|
||||
try:
|
||||
_logger.info(
|
||||
@@ -752,6 +804,11 @@ class BalWindow:
|
||||
if not self.heirs:
|
||||
_logger.warning("not heirs {}".format(self.heirs))
|
||||
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:
|
||||
self.init_class_variables()
|
||||
Will.check_amounts(
|
||||
@@ -786,8 +843,7 @@ class BalWindow:
|
||||
)
|
||||
)
|
||||
return
|
||||
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
||||
if locktime < self.date_to_check:
|
||||
if self.is_locktime_below_threshold():
|
||||
self.show_error(_("locktime is lower than threshold"))
|
||||
return
|
||||
if not self.no_willexecutor:
|
||||
@@ -980,6 +1036,13 @@ class BalWindow:
|
||||
return self.show_transaction_real(tx, parent=parent)
|
||||
|
||||
def invalidate_will(self, will=None):
|
||||
# The reference timestamp is normally set by init_class_variables();
|
||||
# fall back to "now" so a first-action invalidation always has it.
|
||||
if not hasattr(self, "date_to_check") or self.date_to_check is None:
|
||||
self.date_to_check = resolve_date_to_check(
|
||||
self.bal_plugin.is_basic_mode(), self.will_settings
|
||||
)
|
||||
|
||||
def on_success(result):
|
||||
if result:
|
||||
self.show_message(
|
||||
@@ -1015,75 +1078,93 @@ class BalWindow:
|
||||
self.waiting_dialog.exe()
|
||||
|
||||
def sign_transactions(self, password, will=None, txids=None):
|
||||
try:
|
||||
willitems = will if will is not None else self.willitems
|
||||
txs = {}
|
||||
signed = None
|
||||
tosign = None
|
||||
try:
|
||||
willitems = will if will is not None else self.willitems
|
||||
txs = {}
|
||||
signed = None
|
||||
tosign = None
|
||||
|
||||
def get_message():
|
||||
msg = ""
|
||||
if signed:
|
||||
msg = _(f"signed: {signed}\n")
|
||||
return msg + _(f"signing: {tosign}")
|
||||
def get_message():
|
||||
msg = ""
|
||||
if signed:
|
||||
msg = _(f"signed: {signed}\n")
|
||||
return msg + _(f"signing: {tosign}")
|
||||
|
||||
if txids is not None:
|
||||
targets = [
|
||||
t for t in txids
|
||||
if t in willitems and willitems[t].get_status("VALID")
|
||||
]
|
||||
else:
|
||||
targets = Will.only_valid(willitems)
|
||||
for txid in targets:
|
||||
wi = willitems[txid]
|
||||
# Do NOT deepcopy: the stored tx carries wallet-derived objects
|
||||
# (utxo / script_descriptor) that hold a threading.RLock, and
|
||||
# copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse
|
||||
# from the serialized form instead, which is exactly how the will
|
||||
# is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any).
|
||||
tx = Will.get_tx_from_any(str(wi.tx))
|
||||
if wi.get_status("COMPLETE"):
|
||||
if txids is not None:
|
||||
targets = [
|
||||
t for t in txids
|
||||
if t in willitems and willitems[t].get_status("VALID")
|
||||
]
|
||||
else:
|
||||
targets = Will.only_valid(willitems)
|
||||
for txid in targets:
|
||||
wi = willitems[txid]
|
||||
if wi.get_status("COMPLETE"):
|
||||
# Already signed and complete: keep as-is (the single-tx
|
||||
# helper short-circuits without touching the wallet).
|
||||
tx, _ = self._prepare_and_sign_tx(willitems, txid, password)
|
||||
txs[txid] = tx
|
||||
continue
|
||||
tosign = txid
|
||||
try:
|
||||
self.waiting_dialog.update(get_message())
|
||||
except Exception:
|
||||
pass
|
||||
tx, _signed = self._prepare_and_sign_tx(willitems, txid, password)
|
||||
signed = tosign
|
||||
txs[txid] = tx
|
||||
continue
|
||||
tosign = txid
|
||||
except Exception:
|
||||
return None
|
||||
return txs
|
||||
|
||||
def _prepare_and_sign_tx(self, willitems, txid, password):
|
||||
"""Prepare one will transaction and sign it.
|
||||
|
||||
Shared by the batch signer (:meth:`sign_transactions`) and the
|
||||
per-transaction review wizard of the QR import flow
|
||||
(:class:`WillTxReviewSignDialog`).
|
||||
|
||||
Returns ``(tx, newly_signed)``: ``newly_signed`` is False when the
|
||||
transaction was already COMPLETE (nothing was signed).
|
||||
"""
|
||||
wi = willitems[txid]
|
||||
# Do NOT deepcopy: the stored tx carries wallet-derived objects
|
||||
# (utxo / script_descriptor) that hold a threading.RLock, and
|
||||
# copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse
|
||||
# from the serialized form instead, which is exactly how the will
|
||||
# is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any).
|
||||
tx = Will.get_tx_from_any(str(wi.tx))
|
||||
if wi.get_status("COMPLETE"):
|
||||
return tx, False
|
||||
for txin in tx.inputs():
|
||||
prevout = txin.prevout.to_json()
|
||||
if prevout[0] in willitems:
|
||||
change = willitems[prevout[0]].tx.outputs()[prevout[1]]
|
||||
txin._trusted_value_sats = change.value
|
||||
try:
|
||||
self.waiting_dialog.update(get_message())
|
||||
txin.script_descriptor = change.script_descriptor
|
||||
except Exception:
|
||||
pass
|
||||
for txin in tx.inputs():
|
||||
prevout = txin.prevout.to_json()
|
||||
if prevout[0] in willitems:
|
||||
change = willitems[prevout[0]].tx.outputs()[prevout[1]]
|
||||
txin._trusted_value_sats = change.value
|
||||
try:
|
||||
txin.script_descriptor = change.script_descriptor
|
||||
except Exception:
|
||||
pass
|
||||
txin.is_mine = True
|
||||
txin._TxInput__address = change.address
|
||||
txin._TxInput__scriptpubkey = change.scriptpubkey
|
||||
txin._TxInput__value_sats = change.value
|
||||
txin.is_mine = True
|
||||
txin._TxInput__address = change.address
|
||||
txin._TxInput__scriptpubkey = change.scriptpubkey
|
||||
txin._TxInput__value_sats = change.value
|
||||
txin._trusted_value_sats = change.value
|
||||
|
||||
self.wallet.sign_transaction(tx, password, ignore_warnings=True)
|
||||
signed = tosign
|
||||
# is_complete = False
|
||||
if tx.is_complete():
|
||||
# is_complete = True
|
||||
wi.set_status("COMPLETE", True)
|
||||
# Refresh the per-item signature counts from the freshly signed
|
||||
# partial tx: at this point the signatures are still present
|
||||
# (before any finalization), so the will list can show the real
|
||||
# "added/required" count (e.g. "1/2" for a multisig).
|
||||
try:
|
||||
have, required = tx.signature_count()
|
||||
wi.sigs_have = int(have)
|
||||
wi.sigs_required = int(required)
|
||||
except Exception as e:
|
||||
_logger.debug(f"signature_count after signing failed: {e}")
|
||||
txs[txid] = tx
|
||||
except Exception:
|
||||
return None
|
||||
return txs
|
||||
self.wallet.sign_transaction(tx, password, ignore_warnings=True)
|
||||
if tx.is_complete():
|
||||
wi.set_status("COMPLETE", True)
|
||||
# Refresh the per-item signature counts from the freshly signed
|
||||
# partial tx: at this point the signatures are still present
|
||||
# (before any finalization), so the will list can show the real
|
||||
# "added/required" count (e.g. "1/2" for a multisig).
|
||||
try:
|
||||
have, required = tx.signature_count()
|
||||
wi.sigs_have = int(have)
|
||||
wi.sigs_required = int(required)
|
||||
except Exception as e:
|
||||
_logger.debug(f"signature_count after signing failed: {e}")
|
||||
return tx, True
|
||||
|
||||
def get_wallet_password(self, message=None, parent=None):
|
||||
parent = self.window if not parent else parent
|
||||
@@ -1611,6 +1692,19 @@ class BalWindow:
|
||||
else:
|
||||
write_json_file(path, {wid: wi.to_dict() for wid, wi in will.items()})
|
||||
|
||||
def export_tx_file(self, path, will=None):
|
||||
"""Export only the serialized transactions of the given will items.
|
||||
|
||||
Writes a plain text file with every transaction (or PSBT) serialized
|
||||
on a single line, separated by a comma (``tx1,tx2,tx3``). The raw hex
|
||||
and PSBT base64 alphabets never contain a comma, so the separator is
|
||||
unambiguous. When ``will`` is omitted the live will items are used.
|
||||
"""
|
||||
willitems = will if will is not None else self.willitems
|
||||
serialized = ",".join(str(wi.tx) for wid, wi in willitems.items())
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(serialized)
|
||||
|
||||
def export_will(self, will=None):
|
||||
try:
|
||||
export_meta_gui(
|
||||
@@ -1620,6 +1714,73 @@ class BalWindow:
|
||||
self.show_error(str(e))
|
||||
raise e
|
||||
|
||||
def export_will_dialog(self, will=None, initial_mode: Optional[str] = None):
|
||||
"""Open the unified export window (File / QR / Audio).
|
||||
|
||||
The window lets the user pick an All / Valid / Valid NC filter in
|
||||
the top row and choose one of the three transports, each with its
|
||||
contextual settings (file format for File, QR-code size and autoplay
|
||||
for QR, KB/sec for Audio). ``will`` defaults to the live will items;
|
||||
``initial_mode`` opens the window directly on the given transport.
|
||||
"""
|
||||
try:
|
||||
willitems = will if will is not None else self.willitems
|
||||
d = WillExportDialog(
|
||||
self,
|
||||
will=willitems,
|
||||
bal_plugin=self.bal_plugin,
|
||||
initial_mode=initial_mode or "file",
|
||||
)
|
||||
show_on_top(d)
|
||||
except Exception as e:
|
||||
self.show_error(str(e))
|
||||
raise e
|
||||
|
||||
def get_audio_modem_plugin(self):
|
||||
"""Return Electrum's ``audio_modem`` plugin instance, or None.
|
||||
|
||||
The plugin is only usable when Electrum exposes it (the ``Plugins``
|
||||
manager knows the name) and its optional runtime dependency
|
||||
``amodem`` is installed (:meth:`is_available`). Every other case
|
||||
returns None so callers can simply hide the audio buttons.
|
||||
"""
|
||||
try:
|
||||
p = self.window.gui_object.plugins.get("audio_modem")
|
||||
except Exception:
|
||||
return None
|
||||
if not p or not getattr(p, "is_available", lambda: False)():
|
||||
return None
|
||||
return p
|
||||
|
||||
def _audio_send_payload(self, payload):
|
||||
"""Send a transfer payload through the audio_modem plugin.
|
||||
|
||||
Wraps the plugin's own ``_send`` with a proper parent widget. The
|
||||
audio channel zlib-compresses internally, so the payload is passed
|
||||
uncompressed (no BAL ``Z`` flag needed on that transport).
|
||||
"""
|
||||
plugin = self.get_audio_modem_plugin()
|
||||
if plugin is None:
|
||||
self.show_error(_("Audio MODEM plugin is not available."))
|
||||
return
|
||||
plugin._send(parent=self.window, blob=payload)
|
||||
|
||||
def set_audio_modem_bitrate(self, kbps):
|
||||
"""Set the ``audio_modem`` plugin transfer speed to ``kbps`` KB/sec.
|
||||
|
||||
Both the send and the receive paths read ``modem_config``, so the
|
||||
sender and the receiver must be configured with the same speed. Raises
|
||||
when the plugin (or its ``amodem`` dependency) is unavailable.
|
||||
"""
|
||||
plugin = self.get_audio_modem_plugin()
|
||||
if plugin is None:
|
||||
raise Exception(_("Audio MODEM plugin is not available."))
|
||||
try:
|
||||
import amodem.config
|
||||
except Exception as e:
|
||||
raise Exception(str(e)) from e
|
||||
plugin.modem_config = amodem.config.bitrates[int(kbps)]
|
||||
|
||||
def merge_will(self, imported):
|
||||
"""Merge imported will items into the live will.
|
||||
|
||||
@@ -1743,16 +1904,34 @@ class BalWindow:
|
||||
|
||||
def on_file(path):
|
||||
try:
|
||||
willitems = self._load_will_file(path)
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
except Exception as e:
|
||||
self.show_error(_("Invalid will file: {}").format(e))
|
||||
return
|
||||
# Attach wallet/input info so the imported txs can be signed and
|
||||
# broadcast (mirrors what merge_will_from_file does).
|
||||
Will.normalize_will(willitems, self.wallet)
|
||||
for wi in willitems.values():
|
||||
wi.set_status("IMPORTED", True)
|
||||
imported.update(willitems)
|
||||
kind, data = decode_will_payload(text)
|
||||
try:
|
||||
if kind == "will":
|
||||
willitems = self._load_will_payload(data)
|
||||
# Attach wallet/input info so the imported txs can be
|
||||
# signed and broadcast (mirrors merge_will_from_file).
|
||||
Will.normalize_will(willitems, self.wallet)
|
||||
for wi in willitems.values():
|
||||
wi.set_status("IMPORTED", True)
|
||||
imported.update(willitems)
|
||||
else:
|
||||
# Serialized transactions: route through the shared import
|
||||
# tail (validity pass + review/sign wizard).
|
||||
_complete_import(
|
||||
self,
|
||||
self.bal_plugin,
|
||||
text,
|
||||
show_error=self.show_error,
|
||||
show_warning=self.show_warning,
|
||||
close=lambda: None,
|
||||
)
|
||||
except Exception as e:
|
||||
self.show_error(_("Invalid will file: {}").format(e))
|
||||
|
||||
def on_success():
|
||||
if not imported:
|
||||
@@ -1762,6 +1941,18 @@ class BalWindow:
|
||||
|
||||
import_meta_gui(self.window, _("will"), on_file, on_success)
|
||||
|
||||
def import_will_dialog(self):
|
||||
"""Open the unified import window (File / QR / Audio).
|
||||
|
||||
The window offers three transports: File opens the read-only
|
||||
:class:`WillDetailDialog` preview; QR and Audio capture the
|
||||
transfer and send it through the per-transaction review wizard
|
||||
(:class:`WillTxReviewSignDialog`). Every flow works on fresh
|
||||
:class:`WillItem` objects and never touches the live will.
|
||||
"""
|
||||
d = WillImportDialog(self, bal_plugin=self.bal_plugin)
|
||||
show_on_top(d)
|
||||
|
||||
def _load_will_file(self, path):
|
||||
data = read_json_file(path)
|
||||
willitems = {}
|
||||
@@ -1770,6 +1961,15 @@ class BalWindow:
|
||||
willitems[k] = WillItem(data[k], _id=k)
|
||||
return willitems
|
||||
|
||||
def _load_will_payload(self, data):
|
||||
"""Build WillItems from decoded whole-will JSON data."""
|
||||
willitems = {}
|
||||
for k, v in data.items():
|
||||
d = dict(v)
|
||||
d["tx"] = tx_from_any(d["tx"])
|
||||
willitems[k] = WillItem(d, _id=k)
|
||||
return willitems
|
||||
|
||||
def check_transactions_task(self, will):
|
||||
start = time.time()
|
||||
# Servers are now contacted in parallel (see
|
||||
|
||||
@@ -6,6 +6,9 @@ target-version = "py312"
|
||||
select =["E", "W", "F", "I", "N", "B"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.lint.pep8-naming]
|
||||
classmethod-decorators = ["classmethod", "classproperty"] # electrum.util.classproperty uses cls
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"bal/gui/qt/common.py" = ["F401"] # re-exports consumed via explicit imports
|
||||
"bal/gui/qt/dialogs.py" = ["N802"] # Qt overrides: closeEvent/hideEvent/getText
|
||||
|
||||
18
tests/conftest.py
Normal file
18
tests/conftest.py
Normal 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
|
||||
2700
tests/karen7
2700
tests/karen7
File diff suppressed because it is too large
Load Diff
@@ -21,12 +21,12 @@ Run:
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.util import copy_structure
|
||||
from bal.core.will import (
|
||||
HeirNotFoundException,
|
||||
NoHeirsException,
|
||||
@@ -58,7 +58,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
||||
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
|
||||
d = {
|
||||
"tx": _VALID_TX_HEX,
|
||||
"heirs": copy.deepcopy(heirs),
|
||||
"heirs": copy_structure(heirs),
|
||||
"willexecutor": None,
|
||||
"status": "",
|
||||
"description": "",
|
||||
@@ -67,7 +67,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
||||
"baltx_fees": TX_FEES,
|
||||
}
|
||||
item = WillItem(d, _id="willid_1")
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
# Force the locktime frozen "inside" the signed tx.
|
||||
item.tx.locktime = tx_locktime
|
||||
if status_complete:
|
||||
@@ -118,7 +118,7 @@ def main():
|
||||
# Scenario 0: nothing changed -> should be coherent.
|
||||
heirs = {"alice": ["addr_alice", 5000, same_lt]}
|
||||
_run("0. nothing changed",
|
||||
will_heirs=heirs, current_heirs=copy.deepcopy(heirs),
|
||||
will_heirs=heirs, current_heirs=copy_structure(heirs),
|
||||
tx_locktime=base_lt, check_date=0)
|
||||
|
||||
# Scenario 1: delivery date moved forward (postpone), will NOT yet signed.
|
||||
|
||||
@@ -27,7 +27,6 @@ Run:
|
||||
tests/test_anticipate_manual_locktime.py -q
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -35,6 +34,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
import pytest # noqa: E402 # pyright: ignore[reportMissingImports]
|
||||
|
||||
from bal.core.util import copy_structure # noqa: E402
|
||||
from bal.core.will import ( # noqa: E402
|
||||
NotCompleteWillException,
|
||||
Will,
|
||||
@@ -70,7 +70,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
||||
"""
|
||||
d = {
|
||||
"tx": _VALID_TX_HEX,
|
||||
"heirs": copy.deepcopy(heirs),
|
||||
"heirs": copy_structure(heirs),
|
||||
"willexecutor": None,
|
||||
"status": "",
|
||||
"description": "",
|
||||
@@ -79,7 +79,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
||||
"baltx_fees": TX_FEES,
|
||||
}
|
||||
item = WillItem(d, _id="willid_1")
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
item.tx.locktime = tx_locktime
|
||||
if status_complete:
|
||||
item.set_status("COMPLETE", True)
|
||||
|
||||
@@ -18,6 +18,7 @@ import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest.mock as mock
|
||||
|
||||
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 bal.cli.controller import BalController
|
||||
from bal.core.heirs import Heirs
|
||||
from bal.core.util import Util
|
||||
|
||||
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
|
||||
|
||||
@@ -227,6 +230,39 @@ def test_auto_rebuild_threshold_passed_invalidates():
|
||||
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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
482
tests/test_core_animated_qr.py
Normal file
482
tests/test_core_animated_qr.py
Normal file
@@ -0,0 +1,482 @@
|
||||
"""
|
||||
Tests for ``bal.core.animated_qr`` (BC-UR v1, BC-UR v2, BBQR interop).
|
||||
|
||||
Validates the self-contained codecs against the published spec vectors
|
||||
(BCR-2020-004/005 BC32, BCR-2020-012 bytewords) and against byte-exact
|
||||
output captured from the reference C++ bc-ur encoder (fountain/xoshiro/
|
||||
alias-sampler parity), plus round trips, out-of-order assembly, missing-part
|
||||
fountain solving and malformed-input rejection for all four formats.
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
python3 tests/test_core_animated_qr.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
import random
|
||||
|
||||
from bal.core import animated_qr as aq
|
||||
|
||||
|
||||
def _payload(plen: int) -> bytes:
|
||||
"""Deterministic payload matching the C++ reference driver (``(i*7)&0xff``)."""
|
||||
return bytes((i * 7) & 0xFF for i in range(plen))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC32 (BCR-2020-004 / bcr-2020-005 rev1 reference implementation vectors)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_bc32_official_vectors():
|
||||
cases = [
|
||||
(b"Hello, world", "fpjkcmr09ss8wmmjd3jq6ax7w9"),
|
||||
(b"Hello world", "fpjkcmr0ypmk7unvvsh4ra4j"),
|
||||
(
|
||||
bytes.fromhex("d934063e82001eec0585ee41ab5d8e4b703a4be1f73aec21e143912c56"),
|
||||
"my6qv05zqq0wcpv9aeq6khvwfdcr5jlp7uawcg0pgwgjc4shjm6xu",
|
||||
),
|
||||
]
|
||||
for payload, encoded in cases:
|
||||
assert aq.bc32_encode(payload) == encoded
|
||||
assert aq.bc32_decode(encoded) == payload
|
||||
|
||||
|
||||
def test_bc32_checksum_rejected():
|
||||
good = aq.bc32_encode(b"Hello, world")
|
||||
corrupted = good[:-1] + ("a" if good[-1] != "a" else "b")
|
||||
try:
|
||||
aq.bc32_decode(corrupted)
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for corrupted BC32")
|
||||
|
||||
|
||||
def test_bc32_bad_char_rejected():
|
||||
try:
|
||||
aq.bc32_decode("1" * 26)
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for '1' (not in alphabet)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Bytewords (BCR-2020-012)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_bytewords_minimal_roundtrip():
|
||||
samples = [bytes(range(256)), _payload(59), b"\x00"] + [
|
||||
os.urandom(64) for _ in range(4)
|
||||
]
|
||||
for data in samples:
|
||||
words = aq.bytewords_minimal_encode(data)
|
||||
assert len(words) == (len(data) + 4) * 2 # 2 chars per byte incl. CRC
|
||||
assert aq.bytewords_minimal_decode(words) == data
|
||||
|
||||
|
||||
def test_bytewords_rejects_corrupted_crc():
|
||||
data = _payload(40)
|
||||
words = aq.bytewords_minimal_encode(data)
|
||||
flip = "a" if words[-1] != "a" else "b"
|
||||
try:
|
||||
aq.bytewords_minimal_decode(words[:-1] + flip)
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for corrupted CRC")
|
||||
|
||||
|
||||
def test_bytewords_rejects_odd_length():
|
||||
try:
|
||||
aq.bytewords_minimal_decode("abc")
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for odd-length bytewords")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC-UR v2: byte-exact parity with the reference C++ encoder
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Reference frames from the bc-ur C++ fountain encoder
|
||||
# (payload x=(i*7)&0xFF, cbor wrapped, single-part and multipart).
|
||||
REF_V2_SINGLE_12 = "ur:bytes/gsaeatbabzcecndrehetfhfggtoeemhpmo"
|
||||
|
||||
REF_V2_MULTI_59 = [
|
||||
"ur:bytes/2-2/lpaoaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeeccasket",
|
||||
"ur:bytes/3-2/lpaxaocsfscyrpdpjzbyhdcthdfraeatbabzcecndrehetfhfggtghhpidinjoktkblplkmunyoypdperpryssimryrldt",
|
||||
"ur:bytes/4-2/lpaaaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaefeimteue",
|
||||
"ur:bytes/5-2/lpahaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssgdaontls",
|
||||
"ur:bytes/6-2/lpamaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssisescmwt",
|
||||
"ur:bytes/7-2/lpataocsfscyrpdpjzbyhdcthdfraeatbabzcecndrehetfhfggtghhpidinjoktkblplkmunyoypdperprysslsspplgm",
|
||||
"ur:bytes/8-2/lpayaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeonlrzebg",
|
||||
"ur:bytes/9-2/lpasaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeaaryknzt",
|
||||
"ur:bytes/10-2/lpbkaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnsslotsfrfn",
|
||||
"ur:bytes/11-2/lpbdaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssdtwyrstd",
|
||||
"ur:bytes/12-2/lpbnaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaegswnvdin",
|
||||
"ur:bytes/13-2/lpbtaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnsshknlptee",
|
||||
]
|
||||
|
||||
# Reference message for the 59-byte payload: byte-string head (0x58,0x3b) + data.
|
||||
REF_V2_MULTI_59_MSG = bytes([0x58, 0x3B]) + _payload(59)
|
||||
|
||||
|
||||
def test_v2_single_part_matches_reference():
|
||||
frames = aq.ur2_frames(_payload(12), len(REF_V2_SINGLE_12))
|
||||
assert frames == [REF_V2_SINGLE_12]
|
||||
|
||||
|
||||
def test_v2_reference_frames_decode_and_reencode_exactly():
|
||||
message = REF_V2_MULTI_59_MSG
|
||||
fragment_len = -(-len(message) // 2)
|
||||
for frame in REF_V2_MULTI_59:
|
||||
seq, seq_len, message_len, checksum, data = aq.ur2_parse_part(frame)
|
||||
assert seq_len == 2
|
||||
assert message_len == len(message)
|
||||
assert checksum == aq.crc32_int(message)
|
||||
assert len(data) == fragment_len
|
||||
# re-encoding the parsed values reproduces the reference line exactly
|
||||
assert aq._ur2_part_string(seq, seq_len, message_len, checksum, data) == frame
|
||||
# our choose_fragments + partition + xor reproduces the reference data
|
||||
indexes = aq.choose_fragments(seq, seq_len, checksum)
|
||||
assert seq_num_indexes_valid(seq, seq_len, indexes)
|
||||
mixed = aq._mix_fragments(aq._partition_message(message, fragment_len), indexes, fragment_len)
|
||||
assert mixed == data
|
||||
|
||||
|
||||
def seq_num_indexes_valid(seq, seq_len, indexes):
|
||||
# pure part for seq <= seq_len contains exactly fragment seq-1
|
||||
if seq <= seq_len:
|
||||
return indexes == {seq - 1}
|
||||
return set(indexes) <= set(range(seq_len)) and bool(indexes)
|
||||
|
||||
|
||||
def test_v2_multipart_encoder_matches_reference_from_seq2():
|
||||
# Our frames start at seq 1 (spec-aligned); parts seq 2.. must equal the
|
||||
# reference (which starts at seq 2 due to first_seq_num=1).
|
||||
mine = aq.ur2_frames(_payload(59), 120)
|
||||
assert mine[0].split("/", 1)[1].startswith("1-2") or "1-2" in mine[0].split("/")[1]
|
||||
assert mine[1:4] == REF_V2_MULTI_59[:3]
|
||||
|
||||
|
||||
def test_v2_reference_seq7_mix_parity():
|
||||
# Higher-degree mixed parts (seq_len=7) also match: message uses the
|
||||
# reference head 0x58|0x00 for the 256-byte driver payload.
|
||||
message = bytes([0x58, 0x00]) + _payload(256)
|
||||
seq_len = 7
|
||||
fragment_len = -(-len(message) // seq_len)
|
||||
frames = [
|
||||
"ur:bytes/9-7/lpasatcfadaocyfysnjlsrhddaykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtntoxpyprrhrtsttotluovlwdwnsrfejzhd",
|
||||
"ur:bytes/10-7/lpbkatcfadaocyfysnjlsrhddazeahbnbwcycldedlenfsfygrgmhkhniojtkpkelslememkneolpmqzrksasotitsuevwwpwfzswzpmdrvo",
|
||||
"ur:bytes/11-7/lpbdatcfadaocyfysnjlsrhddawkwtbbbefnaefnbebbjojybebnaebndybbbewkwtceaecedyeebebbjobnaebnbeeedybbbeztwproyapd",
|
||||
]
|
||||
for frame in frames:
|
||||
seq, sl, mlen, checksum, data = aq.ur2_parse_part(frame)
|
||||
assert sl == seq_len and mlen == len(message)
|
||||
assert checksum == aq.crc32_int(message)
|
||||
mixed = aq._mix_fragments(
|
||||
aq._partition_message(message, fragment_len),
|
||||
aq.choose_fragments(seq, seq_len, checksum),
|
||||
fragment_len,
|
||||
)
|
||||
assert mixed == data
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC-UR v2: sessions / fountain decoding
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_v2_roundtrip_in_order():
|
||||
payload = ("BAL transfer " * 9).encode()
|
||||
frames = aq.ur2_frames(payload, 120)
|
||||
seq_len = int(frames[0].split("/")[1].split("-")[1])
|
||||
assert len(frames) == 2 * seq_len # pure wave + redundant mixed wave
|
||||
session = aq.AnimatedQrSession()
|
||||
for frame in frames:
|
||||
session.add_part(frame)
|
||||
assert session.done
|
||||
assert session.received == session.total
|
||||
text, _ = session.resolve()
|
||||
assert text == payload.decode()
|
||||
|
||||
|
||||
def test_v2_out_of_order_and_duplicate():
|
||||
payload = ("BAL transfer " * 9).encode()
|
||||
frames = aq.ur2_frames(payload, 120)
|
||||
order = list(range(len(frames)))
|
||||
random.Random(11).shuffle(order)
|
||||
session = aq.AnimatedQrSession()
|
||||
for i in order:
|
||||
status = session.add_part(frames[i])
|
||||
assert status in ("ok", "dup")
|
||||
session.add_part(frames[0]) # duplicate of an already-received part
|
||||
assert session.done
|
||||
assert session.resolve()[0] == payload.decode()
|
||||
|
||||
|
||||
def test_v2_solves_without_a_pure_fragment():
|
||||
payload = ("BAL transfer " * 9).encode()
|
||||
frames = aq.ur2_frames(payload, 120)
|
||||
session = aq.AnimatedQrSession()
|
||||
for frame in frames[1:]: # drop the first pure fragment
|
||||
session.add_part(frame)
|
||||
assert session.done
|
||||
assert session.resolve()[0] == payload.decode()
|
||||
|
||||
|
||||
def test_v2_single_part_import():
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(REF_V2_SINGLE_12)
|
||||
assert session.done and session.total == 1
|
||||
assert session.resolve()[0] == _payload(12).decode("latin-1")
|
||||
|
||||
|
||||
def test_v2_conflicting_transfer_rejected():
|
||||
payload_a = b"AAAAAAAAAAAAAAAA"
|
||||
payload_b = b"BBBBBBBBBBBBBBBB"
|
||||
fa = aq.ur2_frames(payload_a, 500)[0]
|
||||
fb = aq.ur2_frames(payload_b, 500)[0]
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(fa)
|
||||
try:
|
||||
session.add_part(fb)
|
||||
except aq.TransferConflictError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected TransferConflictError for a different transfer")
|
||||
|
||||
|
||||
def test_v2_corrupt_crc_rejected():
|
||||
frame = list(REF_V2_MULTI_59[0])
|
||||
idx = len(frame) - 1
|
||||
frame[idx] = "a" if frame[idx] != "a" else "b"
|
||||
try:
|
||||
aq.ur2_parse_part("".join(frame))
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for a corrupt v2 part")
|
||||
|
||||
|
||||
def test_v2_session_cap_rejected():
|
||||
part = aq._ur2_part_string(1, 30000, 100, 1234, b"\x00" * 100)
|
||||
session = aq._Ur2Session()
|
||||
try:
|
||||
session.add(part)
|
||||
except aq.SessionLimitError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected SessionLimitError for oversized seq_len")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC-UR v1
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_v1_multipart_roundtrip():
|
||||
payload = ("v1 transfer payload " * 6).encode()
|
||||
frames = aq.ur1_frames(payload, 120)
|
||||
assert len(frames) > 1
|
||||
session = aq.AnimatedQrSession()
|
||||
for frame in reversed(frames):
|
||||
session.add_part(frame)
|
||||
assert session.done
|
||||
assert session.resolve()[0] == payload.decode()
|
||||
|
||||
|
||||
def test_v1_single_part_roundtrip():
|
||||
payload = b"hello, bal"
|
||||
frames = aq.ur1_frames(payload, 400)
|
||||
assert len(frames) == 1
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(frames[0])
|
||||
assert session.done and session.total == 1
|
||||
assert session.resolve()[0] == payload.decode()
|
||||
|
||||
|
||||
def test_v1_headerless_single_part_import():
|
||||
# bcr-2020-005 rev1 allows omitting the sequence header + digest entirely.
|
||||
payload = b"hello, bal"
|
||||
message = aq.cbor_byte_string(payload)
|
||||
single = "ur:bytes/" + aq.bc32_encode(message)
|
||||
assert aq.detect_format(single) == "ur1"
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(single)
|
||||
assert session.done
|
||||
assert session.resolve()[0] == payload.decode()
|
||||
|
||||
|
||||
def test_v1_digest_mismatch_rejected():
|
||||
frame = aq.ur1_frames(b"hello, bal", 400)[0]
|
||||
tampered = frame[:-4] + "abcd"
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(tampered)
|
||||
try:
|
||||
session.resolve()
|
||||
except aq.ChecksumError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ChecksumError for a tampered v1 digest")
|
||||
|
||||
|
||||
def test_v1_part_numbers_validated():
|
||||
for bad in (
|
||||
"ur:bytes/0of1/{}full".format("x" * 51),
|
||||
"ur:bytes/2of1/{}full".format("x" * 51),
|
||||
"ur:bytes/1of0/{}full".format("x" * 51),
|
||||
"ur:bytes/1aof1/{}full".format("x" * 51),
|
||||
):
|
||||
try:
|
||||
aq.ur1_parse_part(bad)
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for: {}".format(bad))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BBQR
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_bbqr_all_encodings_roundtrip():
|
||||
payload = ("BBQR payload " * 8).encode()
|
||||
for encoding in ("Z", "2", "H"):
|
||||
frames = aq.bbqr_frames(payload, 90, encoding=encoding)
|
||||
assert len(frames) >= 1
|
||||
order = list(range(len(frames)))
|
||||
random.Random(3).shuffle(order)
|
||||
session = aq.AnimatedQrSession()
|
||||
for i in order:
|
||||
session.add_part(frames[i])
|
||||
assert session.done
|
||||
assert session.resolve()[0] == payload.decode()
|
||||
|
||||
|
||||
def test_bbqr_compression_default_and_fallback():
|
||||
payload = ("repetitive data " * 40).encode() # compresses well
|
||||
frames_z = aq.bbqr_frames(payload, 90, encoding="Z")
|
||||
# Highly compressible: Z yields one frame and a 'Z' flag.
|
||||
assert all(f[2] == "Z" for f in frames_z)
|
||||
assert len(frames_z) == 1
|
||||
raw = os.urandom(600) # incompressible
|
||||
frames_2 = aq.bbqr_frames(raw, 90, encoding="Z")
|
||||
assert all(f[2] == "2" for f in frames_2) # Z loses, '2' is used
|
||||
|
||||
|
||||
def test_bbqr_hex_uppercase():
|
||||
payload = b"\xde\xad\xbe\xef"
|
||||
frame = aq.bbqr_frames(payload, 50, encoding="H")[0]
|
||||
assert "DEADBEEF" in frame
|
||||
encoding, _type, total, index, frag = aq.bbqr_parse_part(frame)
|
||||
assert (encoding, total, index) == ("H", 1, 0)
|
||||
|
||||
|
||||
def test_bbqr_runt_last_part():
|
||||
payload = os.urandom(33)
|
||||
frames = aq.bbqr_frames(payload, 60, encoding="2")
|
||||
parts = [aq.bbqr_parse_part(f)[4] for f in frames]
|
||||
joined = aq._bbqr_decode(parts, "2")
|
||||
assert joined == payload
|
||||
assert len(parts[-1]) < len(parts[0]) # last part is a runt
|
||||
|
||||
|
||||
def test_bbqr_zlib_bomb_rejected():
|
||||
compressed = aq._bbqr_encode(b"\x00" * 1000000, "Z")[1]
|
||||
try:
|
||||
aq._bbqr_decode(["0" * len(compressed)], "2") # not zlib data
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
# direct inflate bomb guard:
|
||||
inflated = aq._bbqr_encode(b"\x00" * 1000000, "Z")
|
||||
assert inflated[0] == "Z" # 1MB zeros compresses
|
||||
bomb = aq._bbqr_encode(b"\x00" * (aq._MAX_MESSAGE_BYTES + 100), "Z")[1]
|
||||
parts = [bomb[i : i + 90] for i in range(0, len(bomb), 90)]
|
||||
try:
|
||||
aq._bbqr_decode(parts, "Z")
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for an oversized decompression")
|
||||
|
||||
|
||||
def test_bbqr_part_number_limits():
|
||||
try:
|
||||
aq.bbqr_frames(os.urandom(30000), 40, encoding="2")
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for too many BBQR parts")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Detection / parse_for_detection
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_detect_format_recognises_all_formats():
|
||||
assert aq.detect_format("BALQR1|1|1||payload") == "balqr"
|
||||
assert aq.detect_format("BAL1" + "001" + "001" + "0" + "payload") == "balqr"
|
||||
assert aq.detect_format(aq.ur1_frames(b"x", 400)[0]) == "ur1"
|
||||
assert aq.detect_format(aq.ur2_frames(b"x", 400)[0]) == "ur2"
|
||||
assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "bbqr"
|
||||
assert aq.detect_format(REF_V2_SINGLE_12) == "ur2"
|
||||
assert aq.detect_format("ur:bytes/" + aq.bc32_encode(aq.cbor_byte_string(b"x"))) == "ur1"
|
||||
|
||||
|
||||
def test_detect_format_rejects_garbage():
|
||||
for text in ("", "hello world", "BALQ|1|1||a", "ur:", "ur:txn/xyz"):
|
||||
assert aq.detect_format(text) is None, text
|
||||
# Lenient prefix probe: a string that merely *starts* with "balqr" is
|
||||
# reported as balqr (the strict parse then rejects it downstream).
|
||||
assert aq.detect_format("BALQRX|1|1||a") == "balqr"
|
||||
|
||||
|
||||
def test_parse_for_detection_keys():
|
||||
bal = aq.parse_for_detection("BALQR1|3|2||payload")
|
||||
assert bal == ("balqr", "balqr:3", 3, 2)
|
||||
# Compact v2 frame (fixed 11-char header) is detected too.
|
||||
bal_v2 = aq.parse_for_detection("BAL1" + "007" + "004" + "0" + "payload")
|
||||
assert bal_v2 == ("balqr", "balqr:7", 7, 4)
|
||||
v2 = aq.parse_for_detection(aq.ur2_frames(b"x"*50, 400)[0])
|
||||
assert v2[0] == "ur2" and v2[2] == 1 and v2[3] == 1
|
||||
v1 = aq.parse_for_detection(aq.ur1_frames(b"x"*50, 120)[0])
|
||||
assert v1[0] == "ur1" and v1[2] > 1 and 1 <= v1[3] <= v1[2]
|
||||
bb = aq.parse_for_detection(aq.bbqr_frames(b"x"*50, 40)[0])
|
||||
assert bb[0] == "bbqr" and bb[2] >= 1 and 0 <= bb[3] < bb[2]
|
||||
|
||||
|
||||
def test_format_names_exist():
|
||||
for fmt in ("balqr", "ur1", "ur2", "bbqr"):
|
||||
assert aq.format_name(fmt)
|
||||
assert aq.format_name("nope") == "nope"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
if __name__ == "__main__":
|
||||
import traceback
|
||||
|
||||
failures = 0
|
||||
for _name, fn in sorted(globals().items()):
|
||||
if _name.startswith("test_") and callable(fn):
|
||||
try:
|
||||
fn()
|
||||
print("ok: {}".format(_name))
|
||||
except Exception:
|
||||
failures += 1
|
||||
print("FAIL: {}".format(_name))
|
||||
traceback.print_exc()
|
||||
if failures:
|
||||
print("{} test(s) failed".format(failures))
|
||||
sys.exit(1)
|
||||
print("all tests passed")
|
||||
@@ -18,6 +18,7 @@ from bal.core.checkalive import ( # noqa: E402 (path insert above)
|
||||
CheckAliveError,
|
||||
check_alive_expired,
|
||||
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
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -36,6 +36,7 @@ from bal.core.heirs import (
|
||||
is_op_return_address,
|
||||
validate_op_return_hex,
|
||||
)
|
||||
from bal.core.util import Util
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Constants
|
||||
@@ -167,6 +168,32 @@ def test_heirs_amount_to_float():
|
||||
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)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -14,7 +14,7 @@ import time
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from bal.core.plugin_base import BalConfig, BalPlugin, BalTimestamp
|
||||
|
||||
@@ -74,7 +74,7 @@ def test_bt_to_date_absolute():
|
||||
|
||||
|
||||
def test_bt_to_date_relative():
|
||||
now = datetime.now()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# relative days from now
|
||||
bt = BalTimestamp("7d")
|
||||
@@ -86,8 +86,8 @@ def test_bt_to_date_relative():
|
||||
d_rev = bt.to_date(reverse=True)
|
||||
assert d_rev < now
|
||||
|
||||
# from explicit datetime
|
||||
base = datetime(2025, 6, 1, 12, 0, 0)
|
||||
# from explicit datetime (UTC, so the naive-timestamp roundtrip below is stable)
|
||||
base = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
d = bt.to_date(from_date=base)
|
||||
expected = (base + timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
assert d == expected
|
||||
@@ -101,7 +101,7 @@ def test_bt_to_date_relative():
|
||||
def test_bt_to_date_years():
|
||||
bt = BalTimestamp("1y")
|
||||
d = bt.to_date()
|
||||
assert d > datetime.now()
|
||||
assert d > datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def test_bt_to_date_overflow():
|
||||
|
||||
439
tests/test_core_qr_transfer.py
Normal file
439
tests/test_core_qr_transfer.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
Tests for ``bal.core.qrtransfer``.
|
||||
|
||||
Covers the BALQR frame encoding used for will transfer via QR codes /
|
||||
audio modem: encoding, framing, reassembly, malformed input and the preset
|
||||
list (optionally cross-checked against the ``qrcode`` library's EC-M
|
||||
capacity when it is installed).
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
python3 tests/test_core_qr_transfer.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.qrtransfer import (
|
||||
CHUNK_PRESETS,
|
||||
MIN_CHUNK_SIZE,
|
||||
InconsistentTotalError,
|
||||
MissingFramesError,
|
||||
QrTransferError,
|
||||
assemble,
|
||||
decode_transfer,
|
||||
encode_transfer,
|
||||
parse_frame,
|
||||
preset_index_for_chunk_size,
|
||||
split_frames,
|
||||
)
|
||||
|
||||
|
||||
def _frames(tx_strings, chunk_size, compress=False):
|
||||
"""Split a payload and return (payload, total, {index: payload})."""
|
||||
payload = encode_transfer(tx_strings, compress=compress)
|
||||
parsed = {}
|
||||
total = None
|
||||
for frame in split_frames(payload, chunk_size, compressed=compress):
|
||||
t, index, _compressed, p = parse_frame(frame)
|
||||
if total is not None:
|
||||
assert total == t
|
||||
total = t
|
||||
parsed[index] = p
|
||||
assert total is not None
|
||||
return payload, total, parsed
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Round trips
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_encode_decode_plain():
|
||||
tx_strings = ["00" * 32, "aa" * 40, "ff" * 50]
|
||||
payload = encode_transfer(tx_strings, compress=False)
|
||||
assert decode_transfer(payload, compressed=False) == tx_strings
|
||||
|
||||
|
||||
def test_encode_decode_compressed():
|
||||
tx_strings = ["00" * 32, "aa" * 40, "ff" * 50]
|
||||
payload = encode_transfer(tx_strings, compress=True)
|
||||
assert decode_transfer(payload, compressed=True) == tx_strings
|
||||
|
||||
|
||||
def test_empty_list_roundtrip():
|
||||
assert decode_transfer(encode_transfer([]), compressed=False) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Framing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_single_frame():
|
||||
tx_strings = ["11" * 10]
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 150)
|
||||
assert len(frames) == 1
|
||||
total, index, compressed, p = parse_frame(frames[0])
|
||||
assert (total, index, compressed) == (1, 1, False)
|
||||
assert p == payload
|
||||
|
||||
|
||||
def test_multiple_frames_reassemble():
|
||||
tx_strings = ["ab" * 100, "cd" * 100] # 600 chars -> multiple frames
|
||||
_payload, total, parsed = _frames(tx_strings, CHUNK_PRESETS[0][1])
|
||||
assert total > 1
|
||||
decoded = decode_transfer(assemble(parsed, total), compressed=False)
|
||||
assert decoded == tx_strings
|
||||
|
||||
|
||||
def test_size_greater_than_payload():
|
||||
tx_strings = ["12" * 5]
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 1800)
|
||||
assert len(frames) == 1
|
||||
_t, _i, _c, p = parse_frame(frames[0])
|
||||
assert p == payload
|
||||
|
||||
|
||||
def test_exact_single_frame_boundary():
|
||||
# A 139-byte payload exactly fills the 150-byte preset budget (the 11-char
|
||||
# compact header plus payload), so the encoded frame is exactly 150.
|
||||
tx_strings = ["a" * 139]
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 150)
|
||||
assert len(frames) == 1
|
||||
assert len(frames[0]) == 150
|
||||
_t, _i, _c, p = parse_frame(frames[0])
|
||||
assert p == payload
|
||||
|
||||
|
||||
def test_frames_fit_chunk_size():
|
||||
tx_strings = ["".join("{:02x}".format(i) * 2) for i in range(300)]
|
||||
payload = encode_transfer(tx_strings)
|
||||
for _label, size in CHUNK_PRESETS:
|
||||
for frame in split_frames(payload, size):
|
||||
assert len(frame) <= size, (size, len(frame))
|
||||
|
||||
|
||||
def test_single_tx_larger_than_chunk():
|
||||
# A huge serialized tx must be split over several frames and reassemble
|
||||
# exactly (positional slicing is safe for hex/base64 text).
|
||||
tx_strings = ["7b" * 1000] # 2000 chars
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 150)
|
||||
assert len(frames) > 1
|
||||
parsed = {parse_frame(f)[1]: parse_frame(f)[3] for f in frames}
|
||||
total = parse_frame(frames[0])[0]
|
||||
assert assemble(parsed, total) == payload
|
||||
|
||||
|
||||
def test_compressed_frames_carry_flag():
|
||||
tx_strings = ["ab" * 40]
|
||||
frames = split_frames(encode_transfer(tx_strings, compress=True), 150, compressed=True)
|
||||
for frame in frames:
|
||||
_t, _i, compressed, _p = parse_frame(frame)
|
||||
assert compressed is True
|
||||
# Plain frames do not.
|
||||
frames_plain = split_frames(encode_transfer(tx_strings), 150)
|
||||
_t, _i, compressed, _p = parse_frame(frames_plain[0])
|
||||
assert compressed is False
|
||||
|
||||
|
||||
def test_compressed_roundtrip_through_frames():
|
||||
tx_strings = ["ab" * 50, "cd" * 50, "12" * 60]
|
||||
payload = encode_transfer(tx_strings, compress=True)
|
||||
parsed = {}
|
||||
total = None
|
||||
for frame in split_frames(payload, 400, compressed=True):
|
||||
t, index, _c, p = parse_frame(frame)
|
||||
total = t
|
||||
parsed[index] = p
|
||||
assert total is not None
|
||||
decoded = decode_transfer(assemble(parsed, total), compressed=True)
|
||||
assert decoded == tx_strings
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Compact v2 wire format ("BAL1")
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_v2_frame_header_structure():
|
||||
frames = split_frames(encode_transfer(["11" * 10]), 150)
|
||||
assert len(frames) == 1
|
||||
frame = frames[0]
|
||||
assert frame.startswith("BAL1")
|
||||
# Fixed 11-char header: magic + 3-char total + 3-char index + 1 flag.
|
||||
assert len(frame) > 11
|
||||
magic, total_s, index_s, flag, payload = (
|
||||
frame[:4],
|
||||
frame[4:7],
|
||||
frame[7:10],
|
||||
frame[10],
|
||||
frame[11:],
|
||||
)
|
||||
assert magic == "BAL1"
|
||||
assert total_s == "001"
|
||||
assert index_s == "001"
|
||||
assert flag == "0"
|
||||
assert payload == "11" * 10
|
||||
total, index, compressed, p = parse_frame(frame)
|
||||
assert (total, index, compressed) == (1, 1, False)
|
||||
assert p == payload
|
||||
|
||||
|
||||
def test_v2_compressed_flag_is_z():
|
||||
frames = split_frames(
|
||||
encode_transfer(["11" * 10], compress=True), 150, compressed=True
|
||||
)
|
||||
assert frames[0][10] == "Z"
|
||||
_t, _i, compressed, _p = parse_frame(frames[0])
|
||||
assert compressed is True
|
||||
|
||||
|
||||
def test_v2_header_fixed_width_high_counts():
|
||||
# A long transfer needs multi-digit counts; the v2 header stays exactly
|
||||
# 11 chars no matter how many frames (3-char base36 zero-padded counts).
|
||||
tx_strings = ["ab" * 300] # 600 chars -> several frames at 150
|
||||
frames = split_frames(encode_transfer(tx_strings), 150)
|
||||
assert len(frames) > 1
|
||||
for frame in frames:
|
||||
# magic(4) + total(3) + index(3) + flag(1) = 11 chars, then payload.
|
||||
assert len(frame) - len(frame[11:]) == 11
|
||||
|
||||
|
||||
def test_v2_max_frame_count():
|
||||
# A transfer needing more than 46655 frames must be rejected (3-char
|
||||
# base36 count fields cannot represent larger totals).
|
||||
from bal.core.qrtransfer import _MAX_TOTAL
|
||||
|
||||
oversized = "A" * (_MAX_TOTAL * (150 - 11) + 1)
|
||||
try:
|
||||
split_frames(oversized, 150)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError above the frame cap")
|
||||
|
||||
|
||||
def test_v2_boundary_at_max_count():
|
||||
from bal.core.qrtransfer import _MAX_TOTAL
|
||||
|
||||
# Exactly at the cap: must still produce (bounded) frames with 3-char
|
||||
# counts "VVV" (46655) for the highest serialised part.
|
||||
payload = "B" * (_MAX_TOTAL * (150 - 11))
|
||||
frames = split_frames(payload, 150)
|
||||
assert len(frames) == _MAX_TOTAL
|
||||
total, index, _c, _p = parse_frame(frames[-1])
|
||||
assert total == _MAX_TOTAL
|
||||
assert index == _MAX_TOTAL
|
||||
assert frames[-1][:10] == "BAL1" + "ZZZ" + "ZZZ"
|
||||
|
||||
|
||||
def test_encode_transfer_best():
|
||||
from bal.core.qrtransfer import encode_transfer_best
|
||||
|
||||
# Redundant JSON-ish text compresses -> compressed (and longer source
|
||||
# must round-trip unchanged).
|
||||
txs = ['{"a": "%s"}' % ("x" * 300), '{"b": "%s"}' % ("y" * 300)]
|
||||
transfer, compressed = encode_transfer_best(txs)
|
||||
assert compressed is True
|
||||
assert decode_transfer(transfer, compressed) == txs
|
||||
|
||||
# Already-compact input stays plain (never larger than the source).
|
||||
txs_small = ["ab", "cd"]
|
||||
transfer, compressed = encode_transfer_best(txs_small)
|
||||
assert compressed is False
|
||||
assert decode_transfer(transfer, compressed) == txs_small
|
||||
|
||||
|
||||
def test_v2_malformed_frames():
|
||||
bad = (
|
||||
"BAL1", # header only, no fields
|
||||
"BAL1" + "001", # truncated
|
||||
"BAL1" + "G-1" + "001" + "Z" + "p", # non-base36 total
|
||||
"BAL1" + "001" + "G-1" + "Z" + "p", # non-base36 index
|
||||
"BAL1" + "000" + "001" + "Z" + "p", # total 0
|
||||
"BAL1" + "001" + "000" + "Z" + "p", # index 0
|
||||
"BAL1" + "001" + "002" + "Z" + "p", # index beyond total
|
||||
"BAL1" + "001" + "001" + "Q" + "p", # unknown flag
|
||||
)
|
||||
for frame in bad:
|
||||
try:
|
||||
parse_frame(frame)
|
||||
except QrTransferError:
|
||||
continue
|
||||
raise AssertionError("expected QrTransferError for: {!r}".format(frame))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Malformed input
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_parse_bad_magic_and_version():
|
||||
for frame in (
|
||||
"BALQR|1|1||a", # missing version
|
||||
"BALQR2|1|1||a", # unknown version
|
||||
"XXXXX1|1|1||a", # unknown magic
|
||||
):
|
||||
try:
|
||||
parse_frame(frame)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for: {}".format(frame))
|
||||
|
||||
|
||||
def test_parse_bad_arity():
|
||||
for frame in ("BALQR1", "BALQR1|1|1|"):
|
||||
try:
|
||||
parse_frame(frame)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for: {}".format(frame))
|
||||
|
||||
|
||||
def test_parse_pipe_in_payload_is_folded():
|
||||
# maxsplit keeps the tail (including any inner '|') in the payload part.
|
||||
frame = "BALQR1|1|1||a|b|c"
|
||||
total, index, compressed, payload = parse_frame(frame)
|
||||
assert (total, index, compressed) == (1, 1, False)
|
||||
assert payload == "a|b|c"
|
||||
|
||||
|
||||
def test_parse_bad_numbers():
|
||||
for frame in (
|
||||
"BALQR1|x|1||a",
|
||||
"BALQR1|1|y||a",
|
||||
"BALQR1|0|1||a",
|
||||
"BALQR1|1|0||a",
|
||||
"BALQR1|1|2||a", # index beyond total
|
||||
"BALQR1|-1|1||a",
|
||||
):
|
||||
try:
|
||||
parse_frame(frame)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for: {}".format(frame))
|
||||
|
||||
|
||||
def test_parse_bad_flags():
|
||||
try:
|
||||
parse_frame("BALQR1|1|1|Q|payload")
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for unknown flags")
|
||||
|
||||
|
||||
def test_assemble_missing_frames():
|
||||
try:
|
||||
assemble({1: "a", 3: "c"}, total=3)
|
||||
except MissingFramesError as e:
|
||||
assert e.missing == [2]
|
||||
else:
|
||||
raise AssertionError("expected MissingFramesError")
|
||||
|
||||
|
||||
def test_assemble_index_beyond_total():
|
||||
try:
|
||||
assemble({1: "a", 2: "b"}, total=1)
|
||||
except InconsistentTotalError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected InconsistentTotalError")
|
||||
|
||||
|
||||
def test_assemble_order_and_total_validation():
|
||||
assert assemble({1: "a", 2: "b"}, total=2) == "ab"
|
||||
try:
|
||||
assemble({}, total=0)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Constants / presets
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_preset_count_and_order():
|
||||
assert len(CHUNK_PRESETS) == 4
|
||||
budgets = [budget for _label, budget in CHUNK_PRESETS]
|
||||
assert budgets == sorted(budgets)
|
||||
|
||||
|
||||
def test_preset_index_for_chunk_size():
|
||||
for index, (_label, budget) in enumerate(CHUNK_PRESETS):
|
||||
assert preset_index_for_chunk_size(budget) == index
|
||||
assert preset_index_for_chunk_size(150) == 0
|
||||
assert preset_index_for_chunk_size(1800) == 3
|
||||
|
||||
|
||||
def test_min_chunk_size_guard():
|
||||
try:
|
||||
split_frames("x" * 10, MIN_CHUNK_SIZE - 1)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for tiny chunk size")
|
||||
|
||||
|
||||
def test_split_frame_headers_consistent():
|
||||
tx_strings = ["ab" * 80]
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 150)
|
||||
totals = {parse_frame(frame)[0] for frame in frames}
|
||||
assert len(totals) == 1
|
||||
assert totals.pop() == len(frames)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Optional: cross-check presets against the qrcode library (EC level M)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_presets_fit_qrcode_ec_m():
|
||||
"""Every preset budget must render inside a QR at EC level M."""
|
||||
try:
|
||||
import qrcode
|
||||
from qrcode.constants import ERROR_CORRECT_M
|
||||
except ImportError:
|
||||
print("qrcode not installed - skipping capacity check")
|
||||
return
|
||||
for _label, size in CHUNK_PRESETS:
|
||||
# Worst-case frame: header with the largest plausible total/index plus
|
||||
# a full payload of the preset budget.
|
||||
frame = "BALQR1|9999|9999|Z|" + "a" * (size - 14)
|
||||
qr = qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2)
|
||||
qr.add_data(frame)
|
||||
qr.get_matrix() # raises DataOverflowError if it does not fit
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
if __name__ == "__main__":
|
||||
import traceback
|
||||
|
||||
failures = 0
|
||||
for _name, fn in sorted(globals().items()):
|
||||
if _name.startswith("test_") and callable(fn):
|
||||
try:
|
||||
fn()
|
||||
print("ok: {}".format(_name))
|
||||
except Exception:
|
||||
failures += 1
|
||||
print("FAIL: {}".format(_name))
|
||||
traceback.print_exc()
|
||||
if failures:
|
||||
print("{} test(s) failed".format(failures))
|
||||
sys.exit(1)
|
||||
print("all tests passed")
|
||||
@@ -8,13 +8,19 @@ Run:
|
||||
python3 tests/test_core_will.py
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.will import Will, WillItem
|
||||
from bal.core.checkalive import resolve_date_to_check
|
||||
from bal.core.util import copy_structure
|
||||
from bal.core.will import (
|
||||
HeirNotFoundException,
|
||||
NoHeirsException,
|
||||
Will,
|
||||
WillItem,
|
||||
)
|
||||
|
||||
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
|
||||
_VALID_TX_HEX = (
|
||||
@@ -48,7 +54,7 @@ def _make_willitem_blank():
|
||||
"""Create a fresh WillItem from scratch."""
|
||||
item = WillItem(_make_minimal_willitem_dict())
|
||||
# Reset STATUS to clean defaults
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
return item
|
||||
|
||||
|
||||
@@ -151,8 +157,8 @@ def test_will_only_valid_list():
|
||||
def _make_will_with_heirs(heirs, tx_locktime):
|
||||
"""Build a single-item will whose stored heirs == ``heirs`` and whose
|
||||
frozen tx.locktime == ``tx_locktime`` (what the will-executors hold)."""
|
||||
item = WillItem(_make_minimal_willitem_dict(heirs=copy.deepcopy(heirs)))
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
item = WillItem(_make_minimal_willitem_dict(heirs=copy_structure(heirs)))
|
||||
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
item.tx.locktime = tx_locktime
|
||||
return {"willid_1": item}
|
||||
|
||||
@@ -163,7 +169,7 @@ def test_check_heirs_unchanged_is_coherent():
|
||||
heirs = {"alice": ["addr_alice", 5000, str(lt)]}
|
||||
will = _make_will_with_heirs(heirs, lt)
|
||||
result = Will.check_willexecutors_and_heirs(
|
||||
will, copy.deepcopy(heirs), {}, False, 0, 100
|
||||
will, copy_structure(heirs), {}, False, 0, 100
|
||||
)
|
||||
assert result is True
|
||||
|
||||
@@ -210,6 +216,59 @@ def test_check_heir_added_triggers_rebuild():
|
||||
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():
|
||||
"""Check button selection logic: only a VALID, PUSHED will with a
|
||||
will-executor that is not yet CHECKED must be queried on the server.
|
||||
|
||||
@@ -450,6 +450,12 @@ class FakeADB:
|
||||
|
||||
def remove_transaction(self, 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):
|
||||
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) == []
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -17,7 +17,6 @@ Run:
|
||||
python3 -m pytest tests/test_core_will_invalidate.py -q
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -75,7 +74,7 @@ def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None):
|
||||
"change": "",
|
||||
"baltx_fees": 100,
|
||||
})
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
# Set the input value so the balance calculation works.
|
||||
# Use the name-mangled attribute because tx_from_any creates a
|
||||
# Transaction whose inputs are TxInput objects; TxInput.value_sats()
|
||||
@@ -270,7 +269,7 @@ class TestInvalidateWill:
|
||||
"""
|
||||
item = _make_willitem(value_sats=100)
|
||||
will = {"willtxid1": item}
|
||||
wallet = _mock_wallet([_make_utxo()])
|
||||
wallet = _mock_wallet([_make_utxo(value_sats=100)])
|
||||
|
||||
result, mock_from_io, _ = _run_invalidate(will, wallet, fees_per_byte=100)
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ Run:
|
||||
python3 -m pytest tests/test_group_e_karen7_invalidate.py -q
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -46,6 +45,7 @@ from electrum.transaction import (
|
||||
from electrum.util import bfh
|
||||
|
||||
from bal.core.heirs import Heirs
|
||||
from bal.core.util import copy_structure
|
||||
from bal.core.will import Will, WillItem
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -219,7 +219,7 @@ def _txs_to_will(txs, heirs_data):
|
||||
for txid, tx in txs.items():
|
||||
item_dict = {
|
||||
"tx": tx,
|
||||
"heirs": copy.deepcopy(heirs_data),
|
||||
"heirs": copy_structure(heirs_data),
|
||||
"willexecutor": None,
|
||||
"status": "",
|
||||
"description": "",
|
||||
|
||||
@@ -22,7 +22,6 @@ Run:
|
||||
python3 -m pytest tests/test_group_e_mock_karen7.py -q
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -43,6 +42,7 @@ from bal.core.reminders import (
|
||||
ical_escape,
|
||||
write_temp_ics,
|
||||
)
|
||||
from bal.core.util import copy_structure
|
||||
from bal.core.will import HeirNotFoundException, Will, WillItem
|
||||
from bal.core.willexecutors import Willexecutors
|
||||
|
||||
@@ -136,7 +136,7 @@ def _make_willitem(**overrides):
|
||||
}
|
||||
d.update(overrides)
|
||||
item = WillItem(d)
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
return item
|
||||
|
||||
|
||||
@@ -343,7 +343,7 @@ def test_e2_heir_change_triggers_rebuild():
|
||||
item = WillItem(
|
||||
{
|
||||
"tx": _VALID_TX_HEX,
|
||||
"heirs": copy.deepcopy(will_heirs),
|
||||
"heirs": copy_structure(will_heirs),
|
||||
"willexecutor": None,
|
||||
"status": "",
|
||||
"description": "",
|
||||
@@ -352,7 +352,7 @@ def test_e2_heir_change_triggers_rebuild():
|
||||
"baltx_fees": 100,
|
||||
}
|
||||
)
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
item.tx.locktime = lt
|
||||
will = {"willid_1": item}
|
||||
|
||||
|
||||
456
tests/test_gui_export_dialogs.py
Normal file
456
tests/test_gui_export_dialogs.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
Tests for the filter-based unified export/import dialogs and the BalWindow
|
||||
transport helpers (``bal.gui.qt.dialogs``, ``bal.gui.qt.window``).
|
||||
|
||||
Covers the shared export filters (All / Valid / Valid NC), the unified
|
||||
``WillExportDialog`` file page (whole item vs tx-only content, empty-filter
|
||||
abort), the audio export/import pages (KB/sec wiring, missing plugin guard,
|
||||
receive flow) and the comma-separated tx-only file writer. The audio pages
|
||||
run against a stub plugin so no sound hardware is exercised.
|
||||
|
||||
Run:
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_export_dialogs.py
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import zlib
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||
|
||||
import bal.gui.qt.dialogs as dialogs
|
||||
from bal.core.qrtransfer import CHUNK_PRESETS
|
||||
|
||||
_app = QApplication.instance() or QApplication(sys.argv)
|
||||
|
||||
# A valid 1x1 transparent PNG, good enough for BalDialog's window icon.
|
||||
_PNG_BYTES = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
|
||||
# version 2); used for real-WillItem serialization tests.
|
||||
_VALID_TX_HEX = (
|
||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||
"42146f11ef8414ae929feaafc388ac00000000"
|
||||
)
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def get(self):
|
||||
return self._value
|
||||
|
||||
|
||||
class FakePlugin:
|
||||
QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||
|
||||
def read_file(self, path):
|
||||
return _PNG_BYTES
|
||||
|
||||
|
||||
class FakeWindow(QMainWindow):
|
||||
config = {}
|
||||
|
||||
def format_amount(self, amount):
|
||||
return "{:.8f}".format(amount)
|
||||
|
||||
def format_amount_and_units(self, amount):
|
||||
return "{:.8f} sat".format(amount)
|
||||
|
||||
|
||||
class FakeAudioPlugin:
|
||||
"""Duck-typed ``audio_modem`` plugin for the audio pages."""
|
||||
|
||||
def __init__(self):
|
||||
self.modem_config = None
|
||||
|
||||
def is_available(self):
|
||||
return True
|
||||
|
||||
|
||||
class _FakeModemConfig:
|
||||
def __init__(self, kbps):
|
||||
self.modem_bps = kbps * 1000
|
||||
|
||||
|
||||
class FakeBalWindow:
|
||||
"""Duck-typed stand-in for BalWindow (dialog layer only)."""
|
||||
|
||||
def __init__(self, audio_plugin=None):
|
||||
self.window = FakeWindow()
|
||||
self.bal_plugin = FakePlugin()
|
||||
self.willitems = {}
|
||||
self.audio_plugin = audio_plugin
|
||||
self.bitrate_set = None
|
||||
self.audio_payloads = []
|
||||
|
||||
def get_audio_modem_plugin(self):
|
||||
return self.audio_plugin
|
||||
|
||||
def set_audio_modem_bitrate(self, kbps):
|
||||
self.bitrate_set = kbps
|
||||
if self.audio_plugin is not None:
|
||||
self.audio_plugin.modem_config = _FakeModemConfig(kbps)
|
||||
|
||||
def _audio_send_payload(self, payload):
|
||||
self.audio_payloads.append(payload)
|
||||
|
||||
def export_json_file(self, path, will=None):
|
||||
items = will if will is not None else self.willitems
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump({wid: wi.to_dict() for wid, wi in items.items()}, f)
|
||||
|
||||
def export_tx_file(self, path, will=None):
|
||||
items = will if will is not None else self.willitems
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(",".join(str(wi.tx) for _, wi in items.items()))
|
||||
|
||||
|
||||
class StubTx:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def txid(self):
|
||||
return "{:064x}".format(hash(self.payload) & 0xFFFFFFFFFFFFFFFF)
|
||||
|
||||
def __str__(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class StubWillItem:
|
||||
def __init__(self, payload, statuses=None):
|
||||
self.tx = StubTx(payload)
|
||||
self.statuses = statuses or {}
|
||||
|
||||
def get_status(self, name):
|
||||
return self.statuses.get(name, False)
|
||||
|
||||
def to_dict(self):
|
||||
return {"tx": str(self.tx)}
|
||||
|
||||
|
||||
def _make_willitems(n=3, payload_len=60, statuses=None):
|
||||
return {
|
||||
"item{}".format(i): StubWillItem(
|
||||
"T{}".format(i) * payload_len, statuses=statuses
|
||||
)
|
||||
for i in range(n)
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Shared export filters
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_export_filter_options():
|
||||
opts = dialogs.export_filter_options()
|
||||
assert [label for label, _fn in opts] == ["All", "Valid", "Valid NC"]
|
||||
complete = StubWillItem("C*", statuses={"VALID": True, "COMPLETE": True})
|
||||
valid = StubWillItem("V*", statuses={"VALID": True})
|
||||
plain = StubWillItem("P*")
|
||||
assert opts[0][1](complete) and opts[0][1](plain)
|
||||
assert opts[1][1](complete) and opts[1][1](valid) and not opts[1][1](plain)
|
||||
assert not opts[2][1](complete)
|
||||
assert opts[2][1](valid) and not opts[2][1](plain)
|
||||
|
||||
|
||||
def test_filter_willitems_by_index():
|
||||
items = {
|
||||
"a": StubWillItem("A*", statuses={"VALID": True, "COMPLETE": True}),
|
||||
"b": StubWillItem("B*", statuses={"VALID": True}),
|
||||
"c": StubWillItem("C*"),
|
||||
}
|
||||
opts = dialogs.export_filter_options()
|
||||
assert set(dialogs.filter_willitems(items, opts, 0)) == {"a", "b", "c"}
|
||||
assert set(dialogs.filter_willitems(items, opts, 1)) == {"a", "b"}
|
||||
assert dialogs.filter_willitems(items, opts, 2) == {"b": items["b"]}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillExportDialog file page
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_file_export_selects_by_filter():
|
||||
bw = FakeBalWindow()
|
||||
items = _make_willitems(3)
|
||||
items["item0"].statuses = {"VALID": True, "COMPLETE": True}
|
||||
items["item1"].statuses = {"VALID": True}
|
||||
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||
assert set(d._selected_items()) == set(items)
|
||||
|
||||
d._on_filter_change(1)
|
||||
assert set(d._selected_items()) == {"item0", "item1"}
|
||||
d._on_filter_change(2)
|
||||
assert list(d._selected_items()) == ["item1"]
|
||||
d.close()
|
||||
|
||||
|
||||
def test_file_export_run_tx_only(tmpdir):
|
||||
bw = FakeBalWindow()
|
||||
items = _make_willitems(3)
|
||||
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||
d.content_check.setChecked(False)
|
||||
captured = {}
|
||||
|
||||
def fake_gui(window, title, exporter):
|
||||
captured["title"] = title
|
||||
captured["exporter"] = exporter
|
||||
|
||||
with patch.object(dialogs, "export_meta_gui", side_effect=fake_gui):
|
||||
d._export_file()
|
||||
assert captured["title"] == "will_tx"
|
||||
out = tmpdir.join("will_tx.txt").strpath
|
||||
captured["exporter"](out)
|
||||
expected = ",".join(str(wi.tx) for _, wi in items.items())
|
||||
assert open(out, encoding="utf-8").read() == expected
|
||||
d.close()
|
||||
|
||||
|
||||
def test_file_export_run_willitem(tmpdir):
|
||||
bw = FakeBalWindow()
|
||||
items = _make_willitems(2)
|
||||
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||
assert d.content_check.isChecked()
|
||||
captured = {}
|
||||
|
||||
def fake_gui(window, title, exporter):
|
||||
captured["title"] = title
|
||||
captured["exporter"] = exporter
|
||||
|
||||
with patch.object(dialogs, "export_meta_gui", side_effect=fake_gui):
|
||||
d._export_file()
|
||||
assert captured["title"] == "will"
|
||||
out = tmpdir.join("will.json").strpath
|
||||
captured["exporter"](out)
|
||||
data = json.load(open(out, encoding="utf-8"))
|
||||
assert set(data) == set(items)
|
||||
assert data["item0"]["tx"] == str(items["item0"].tx)
|
||||
d.close()
|
||||
|
||||
|
||||
def test_file_export_empty_under_filter_aborts():
|
||||
bw = FakeBalWindow()
|
||||
items = {
|
||||
"a": StubWillItem("A*", statuses={"VALID": True, "COMPLETE": True})
|
||||
}
|
||||
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||
messages = []
|
||||
d.show_message = lambda msg: messages.append(msg) # type: ignore[assignment]
|
||||
d._filter_index = 2 # force an empty "Valid NC" selection
|
||||
with patch.object(dialogs, "export_meta_gui") as gui:
|
||||
d._export_file()
|
||||
assert not gui.called
|
||||
assert messages
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# BalWindow transport helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_bal_window_export_tx_file(tmpdir):
|
||||
import bal.gui.qt.window as window
|
||||
|
||||
items = _make_willitems(3)
|
||||
bw = object.__new__(window.BalWindow)
|
||||
bw.willitems = items
|
||||
out = tmpdir.join("will_tx.txt").strpath
|
||||
bw.export_tx_file(out)
|
||||
expected = ",".join(str(wi.tx) for _, wi in items.items())
|
||||
assert open(out, encoding="utf-8").read() == expected
|
||||
|
||||
|
||||
def test_bal_window_set_audio_modem_bitrate():
|
||||
try:
|
||||
import amodem.config
|
||||
except ImportError:
|
||||
return
|
||||
import bal.gui.qt.window as window
|
||||
|
||||
class P:
|
||||
def __init__(self):
|
||||
self.modem_config = None
|
||||
|
||||
probe = P()
|
||||
bw = object.__new__(window.BalWindow)
|
||||
bw.get_audio_modem_plugin = lambda: probe
|
||||
bw.set_audio_modem_bitrate(1)
|
||||
assert probe.modem_config is amodem.config.bitrates[1]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillExportDialog audio page
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_audio_export_page_send():
|
||||
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||
items = _make_willitems(3, payload_len=30)
|
||||
bw.willitems = items
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert d.audio_send_btn.text() == dialogs._("Send")
|
||||
assert d.audio_send_btn.isEnabled()
|
||||
assert d.kbps_combo.count() > 0
|
||||
|
||||
kbps = int(d.kbps_combo.currentText())
|
||||
d._send_audio()
|
||||
assert bw.bitrate_set == kbps
|
||||
# Whole-will default: the audio payload is a single JSON document.
|
||||
assert len(bw.audio_payloads) == 1
|
||||
data = json.loads(bw.audio_payloads[0])
|
||||
assert set(data) == set(items)
|
||||
d.close()
|
||||
|
||||
|
||||
def test_audio_export_page_send_tx_only():
|
||||
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||
items = _make_willitems(3, payload_len=30)
|
||||
bw.willitems = items
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
d.content_check.setChecked(False)
|
||||
|
||||
kbps = int(d.kbps_combo.currentText())
|
||||
d._send_audio()
|
||||
assert bw.bitrate_set == kbps
|
||||
expected = "\n".join(dialogs.serialize_tx_list(items))
|
||||
assert bw.audio_payloads == [expected]
|
||||
d.close()
|
||||
|
||||
|
||||
def test_audio_export_page_plugin_missing():
|
||||
# The window stays usable: only the audio option is disabled.
|
||||
bw = FakeBalWindow(audio_plugin=None)
|
||||
bw.willitems = _make_willitems(2)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert not d.audio_send_btn.isEnabled()
|
||||
assert "not available" in d.audio_warn_label.text()
|
||||
assert len(d.qr_page.frames) >= 1 # QR still usable
|
||||
assert d.file_export_btn.isEnabled()
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillImportDialog audio page
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_audio_import_page_build():
|
||||
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert d.kbps_combo.count() > 0
|
||||
assert d.receive_btn.text() == dialogs._("Receive by audio…")
|
||||
assert d.receive_btn.isEnabled()
|
||||
d.close()
|
||||
|
||||
|
||||
def test_audio_import_page_plugin_missing():
|
||||
bw = FakeBalWindow(audio_plugin=None)
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert not d.receive_btn.isEnabled()
|
||||
assert "not available" in d.audio_warn_label.text()
|
||||
assert d.qr_page is not None # QR import still usable
|
||||
d.close()
|
||||
|
||||
|
||||
def test_audio_import_receive_wiring():
|
||||
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
captured = {}
|
||||
|
||||
class FakeWaitingDialog:
|
||||
def __init__(self, parent, msg, task, on_success=None, on_error=None):
|
||||
captured["msg"] = msg
|
||||
captured["task"] = task
|
||||
captured["success"] = on_success
|
||||
captured["error"] = on_error
|
||||
|
||||
imported = []
|
||||
|
||||
def fake_complete(bal_window, bal_plugin, payload, **kwargs):
|
||||
imported.append(payload)
|
||||
|
||||
blob = zlib.compress(b"A" * 40 + b"\n" + b"B" * 40)
|
||||
with patch.object(dialogs, "WaitingDialog", FakeWaitingDialog), patch.object(
|
||||
dialogs, "_complete_import", side_effect=fake_complete
|
||||
):
|
||||
d._audio_receive()
|
||||
kbps = int(d.kbps_combo.currentText())
|
||||
assert bw.bitrate_set == kbps
|
||||
assert captured["task"] is not None
|
||||
captured["success"](blob)
|
||||
# Payload is the raw decompressed text; autodetect handles the splitting.
|
||||
assert imported == ["A" * 40 + "\n" + "B" * 40]
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Whole-will JSON payload serializes a real Transaction (MyEncoder)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_qr_whole_will_json_serializes_transaction():
|
||||
"""Regression: _whole_will_json must not raise
|
||||
"Object of type Transaction is not JSON serializable".
|
||||
|
||||
Real WillItems keep a ``Transaction`` object in ``tx``; the whole-will
|
||||
QR payload (default content scope) must serialize it via MyEncoder the
|
||||
same way write_json_file does.
|
||||
"""
|
||||
from bal.core.will import WillItem
|
||||
|
||||
item = WillItem({
|
||||
"tx": _VALID_TX_HEX,
|
||||
"heirs": {},
|
||||
"willexecutor": None,
|
||||
"status": "",
|
||||
"description": "",
|
||||
"time": 0,
|
||||
"change": "",
|
||||
"baltx_fees": 100,
|
||||
})
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillExportDialog(
|
||||
bw, will={"imp0": item}, bal_plugin=bw.bal_plugin, initial_mode="qr"
|
||||
)
|
||||
j = d._whole_will_json()
|
||||
data = json.loads(j)
|
||||
assert data["imp0"]["tx"] == _VALID_TX_HEX
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
if __name__ == "__main__":
|
||||
import inspect
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
class _Path:
|
||||
def __init__(self, d, name):
|
||||
self.strpath = os.path.join(d, name)
|
||||
|
||||
class _Tmp:
|
||||
def __init__(self):
|
||||
self._d = tempfile.mkdtemp()
|
||||
|
||||
def join(self, name):
|
||||
return _Path(self._d, name)
|
||||
|
||||
tmp = _Tmp()
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
fn = globals()[name]
|
||||
fn(tmp) if inspect.signature(fn).parameters else fn()
|
||||
print(" [OK] {}".format(name))
|
||||
print("[OK] All export dialog GUI tests passed")
|
||||
@@ -179,6 +179,7 @@ def test_rebuild_path_schedules_full_refresh():
|
||||
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"),
|
||||
@@ -204,6 +205,46 @@ def test_rebuild_path_schedules_full_refresh():
|
||||
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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
760
tests/test_gui_qr_transfer.py
Normal file
760
tests/test_gui_qr_transfer.py
Normal file
@@ -0,0 +1,760 @@
|
||||
"""
|
||||
Tests for the QR / audio will-transfer dialogs (``bal.gui.qt.dialogs``).
|
||||
|
||||
Covers the unified ``WillExportDialog`` (transport radios, stacked pages,
|
||||
QR build/navigation, chunk-size / autoplay, filter revert) and the unified
|
||||
``WillImportDialog`` (QR frame capture, slot grid, complete-review enabling,
|
||||
total mismatch reset, frame assembly -> decode). The wizard and the
|
||||
camera/audio paths need a live wallet/hardware and are exercised only
|
||||
through the shared frame-assembly path here.
|
||||
|
||||
Run:
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||
|
||||
from electrum.transaction import Transaction
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||
|
||||
import bal.gui.qt.dialogs as dialogs
|
||||
from bal.core.qrtransfer import CHUNK_PRESETS, encode_transfer, split_frames
|
||||
from bal.core.will import WillItem
|
||||
|
||||
_app = QApplication.instance() or QApplication(sys.argv)
|
||||
|
||||
# A valid 1x1 transparent PNG, good enough for BalDialog's window icon.
|
||||
_PNG_BYTES = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output),
|
||||
# reused for the WillItem status regression test.
|
||||
_VALID_TX_HEX = (
|
||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||
"42146f11ef8414ae929feaafc388ac00000000"
|
||||
)
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def get(self):
|
||||
return self._value
|
||||
|
||||
|
||||
class FakePlugin:
|
||||
# Smallest QR preset: long transfers produce several frames.
|
||||
QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||
|
||||
def read_file(self, path):
|
||||
return _PNG_BYTES
|
||||
|
||||
|
||||
class FakeWindow(QMainWindow):
|
||||
config = {}
|
||||
|
||||
def format_amount(self, amount):
|
||||
return "{:.8f}".format(amount)
|
||||
|
||||
def format_amount_and_units(self, amount):
|
||||
return "{:.8f} sat".format(amount)
|
||||
|
||||
|
||||
class FakeBalWindow:
|
||||
"""Duck-typed stand-in for BalWindow (dialog layer only)."""
|
||||
|
||||
def __init__(self):
|
||||
self.window = FakeWindow()
|
||||
self.bal_plugin = FakePlugin()
|
||||
self.willitems = {}
|
||||
|
||||
def get_audio_modem_plugin(self):
|
||||
return None
|
||||
|
||||
|
||||
class StubTx:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def txid(self):
|
||||
return "{:064x}".format(hash(self.payload) & 0xFFFFFFFFFFFFFFFF)
|
||||
|
||||
def __str__(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class StubWillItem:
|
||||
def __init__(self, payload, statuses=None):
|
||||
self.tx = StubTx(payload)
|
||||
self.statuses = statuses or {}
|
||||
|
||||
def get_status(self, name):
|
||||
return self.statuses.get(name, False)
|
||||
|
||||
def to_dict(self):
|
||||
return {"tx": str(self.tx), "status": self.statuses}
|
||||
|
||||
|
||||
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 {
|
||||
"item{}".format(i): StubWillItem(p)
|
||||
for i, p in enumerate(payloads)
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillExportDialog (QR transport via d.qr_page)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_export_dialog_builds():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems()
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert d.transport_qr.isChecked()
|
||||
assert page.tx_strings
|
||||
assert page.frames
|
||||
assert len(page.frames) >= 1
|
||||
# Frame 1 is shown.
|
||||
assert page.qr_view.text == page.frames[0]
|
||||
assert "1" in page.progress_label.text()
|
||||
d.close()
|
||||
|
||||
|
||||
def test_export_dialog_unified_transports():
|
||||
# One window hosts the three transports as stacked, radio-selected pages.
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems()
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert d.stacked.count() == 3
|
||||
assert d.transport_file.isChecked()
|
||||
assert d.stacked.currentWidget() is d.file_page
|
||||
d._on_mode_clicked(d.MODE_QR)
|
||||
assert d.stacked.currentWidget() is d.qr_page
|
||||
d._on_mode_clicked(d.MODE_AUDIO)
|
||||
assert d.stacked.currentWidget() is d.audio_page
|
||||
d.close()
|
||||
|
||||
|
||||
def test_import_dialog_qr_page_has_no_audio():
|
||||
# Audio lives on the import dialog's own audio page, never in the QR page.
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
assert not hasattr(page, "_audio_receive")
|
||||
texts = [b.text() for b in page.findChildren(dialogs.QPushButton)]
|
||||
assert not any("Audio" in t for t in texts)
|
||||
assert d.receive_btn is not None
|
||||
d.close()
|
||||
|
||||
|
||||
def test_export_dialog_empty_close():
|
||||
# An empty will shows a modal message and no widgets are built; stub the
|
||||
# message out for the test.
|
||||
orig = dialogs.MessageBoxMixin.show_message
|
||||
dialogs.MessageBoxMixin.show_message = lambda self, msg, icon=None: None
|
||||
try:
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert not d.isVisible()
|
||||
assert not hasattr(d, "qr_page")
|
||||
d.close()
|
||||
finally:
|
||||
dialogs.MessageBoxMixin.show_message = orig
|
||||
|
||||
|
||||
def test_imported_item_status_not_none():
|
||||
# Regression: a WillItem built from a bare {"tx": hex} had a None status,
|
||||
# so set_status (e.g. IMPORTED / INVALIDATED from the import validity
|
||||
# pass) crashed with "unsupported operand type(s) for +=: 'NoneType' and
|
||||
# 'str'".
|
||||
with patch.object(Transaction, "add_info_from_wallet"):
|
||||
wi = WillItem({"tx": _VALID_TX_HEX}, wallet=None)
|
||||
assert wi.status == ""
|
||||
assert wi.set_status("IMPORTED", True) is True
|
||||
assert wi.set_status("INVALIDATED", True) is True
|
||||
assert "Imported" in wi.status and "Invalidated" in wi.status
|
||||
|
||||
|
||||
def test_export_auto_scroll():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert page.fps_spin is not None
|
||||
assert not page.auto_timer.isActive()
|
||||
|
||||
page.fps_spin.setValue(2)
|
||||
page._toggle_auto()
|
||||
assert page.auto_timer.isActive()
|
||||
assert page.auto_btn.text() == dialogs._("Stop")
|
||||
page._auto_step()
|
||||
assert page.index == 1
|
||||
page._toggle_auto()
|
||||
assert not page.auto_timer.isActive()
|
||||
assert page.auto_btn.text() == dialogs._("Auto")
|
||||
|
||||
# Advancing past the last frame stops the slideshow automatically.
|
||||
page._toggle_auto()
|
||||
page.index = len(page.frames) - 1
|
||||
page._auto_step()
|
||||
assert not page.auto_timer.isActive()
|
||||
d.close()
|
||||
|
||||
|
||||
def test_export_auto_scroll_loop():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert page.loop_check is not None
|
||||
|
||||
# Loop on: reaching the last code wraps back to the first and keeps going.
|
||||
page.loop_check.setChecked(True)
|
||||
page._toggle_auto()
|
||||
assert page.auto_timer.isActive()
|
||||
page.index = len(page.frames) - 1
|
||||
page._auto_step()
|
||||
assert page.index == 0
|
||||
assert page.auto_timer.isActive()
|
||||
page._toggle_auto()
|
||||
|
||||
# Loop off: reaching the last code stops the slideshow.
|
||||
page.loop_check.setChecked(False)
|
||||
page._toggle_auto()
|
||||
page.index = len(page.frames) - 1
|
||||
page._auto_step()
|
||||
assert not page.auto_timer.isActive()
|
||||
d.close()
|
||||
|
||||
|
||||
def test_export_filter_valid_and_valid_nc():
|
||||
bw = FakeBalWindow()
|
||||
a = StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True})
|
||||
b = StubWillItem("B" * 120, statuses={"VALID": True})
|
||||
c = StubWillItem("C" * 120)
|
||||
bw.willitems = {"a": a, "b": b, "c": c}
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert d.filter_combo.count() == 3
|
||||
# Whole-will default: a single JSON document carrying all selected items.
|
||||
assert d.content_check.isChecked()
|
||||
assert len(page.tx_strings) == 1
|
||||
data = json.loads(page.tx_strings[0])
|
||||
assert set(data) == {"a", "b", "c"}
|
||||
|
||||
# Switch to tx-only content, then exercise the filters.
|
||||
d.content_check.setChecked(False)
|
||||
assert len(page.tx_strings) == 3
|
||||
|
||||
# "Valid" filter -> only the valid items (a, b).
|
||||
d._on_filter_change(1)
|
||||
assert sorted(page.tx_strings) == ["A" * 120, "B" * 120]
|
||||
|
||||
# "Valid NC" filter -> only the valid, not-complete item (b).
|
||||
d._on_filter_change(2)
|
||||
assert sorted(page.tx_strings) == ["B" * 120]
|
||||
assert page.qr_view.text == page.frames[0]
|
||||
d.close()
|
||||
|
||||
|
||||
def test_export_filter_empty_reverts():
|
||||
bw = FakeBalWindow()
|
||||
# Only a COMPLETE valid item: "Valid NC" selects nothing -> revert.
|
||||
bw.willitems = {
|
||||
"a": StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True})
|
||||
}
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
messages = []
|
||||
d.show_message = lambda msg: messages.append(msg) # type: ignore[assignment]
|
||||
d._on_filter_change(2) # "Valid NC" -> empty subset
|
||||
assert messages
|
||||
assert d._filter_index == 0 # reverted to "All"
|
||||
assert d.filter_combo.currentIndex() == 0
|
||||
assert len(d.qr_page.tx_strings) == 1
|
||||
d.close()
|
||||
|
||||
|
||||
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.willitems = _make_willitems(
|
||||
n=6, payload_len=400,
|
||||
payloads=["{}0{}".format(noisy(i), "T" * 50) for i in range(6)],
|
||||
)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
first_count = len(page.frames)
|
||||
assert first_count > 1 # long transfer, small default chunk
|
||||
|
||||
assert not page.prev_btn.isEnabled()
|
||||
page._next()
|
||||
assert page.index == 1
|
||||
assert page.qr_view.text == page.frames[1]
|
||||
assert page.prev_btn.isEnabled()
|
||||
page._prev()
|
||||
assert page.index == 0
|
||||
assert page.qr_view.text == page.frames[0]
|
||||
|
||||
# Switch to the largest preset: fewer, bigger frames.
|
||||
page._on_chunk_change(len(dialogs.CHUNK_PRESETS) - 1)
|
||||
assert len(page.frames) < first_count
|
||||
assert page.index == 0
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillImportDialog (QR transport via d.qr_page)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_import_frame_flow():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
assert not page.review_btn.isEnabled()
|
||||
assert not page.slot_area.isVisible()
|
||||
|
||||
transfer = encode_transfer(["A" * 120, "B" * 120, "C" * 120])
|
||||
frames = split_frames(transfer, 150)
|
||||
assert len(frames) > 1
|
||||
|
||||
for frame in frames:
|
||||
page._add_frame(frame)
|
||||
assert page.total == len(frames)
|
||||
assert page.review_btn.isEnabled()
|
||||
# isVisible() needs a shown parent; assert the widget is not hidden instead.
|
||||
assert not page.slot_area.isHidden()
|
||||
assert len(page.slot_widgets) == page.total
|
||||
assert "All" in page.status_label.text()
|
||||
# Duplicate capture is harmless.
|
||||
page._add_frame(frames[0])
|
||||
assert len(page.frames) == page.total
|
||||
d.close()
|
||||
|
||||
|
||||
def test_import_assembles_and_decodes():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
captured = {}
|
||||
|
||||
def fake_finish(payload):
|
||||
captured["payload"] = payload
|
||||
|
||||
page._finish_import = fake_finish
|
||||
transfer = encode_transfer(["P" * 130, "Q" * 130])
|
||||
for frame in split_frames(transfer, 150):
|
||||
page._add_frame(frame)
|
||||
page._review_and_sign()
|
||||
# The payload is rejoined into an opaque string for autodetect.
|
||||
assert captured["payload"].split("\n") == ["P" * 130, "Q" * 130]
|
||||
d.close()
|
||||
|
||||
|
||||
def test_decode_will_payload_autodetect():
|
||||
# Whole-will JSON is recognized as a will document.
|
||||
payload = json.dumps({"item1": {"tx": "AAAA", "status": {"VALID": True}}})
|
||||
kind, data = dialogs.decode_will_payload(payload)
|
||||
assert kind == "will"
|
||||
assert data["item1"]["tx"] == "AAAA"
|
||||
|
||||
# A singleton dict whose value is not an item dict falls back to txs.
|
||||
kind, data = dialogs.decode_will_payload('{"foo": 1}')
|
||||
assert kind == "txs"
|
||||
|
||||
# Comma and/or newline separated transactions.
|
||||
kind, data = dialogs.decode_will_payload("AAAA,BBBB\nCCCC")
|
||||
assert kind == "txs"
|
||||
assert data == ["AAAA", "BBBB", "CCCC"]
|
||||
|
||||
# A single transaction with no separators.
|
||||
kind, data = dialogs.decode_will_payload("HEXHEX")
|
||||
assert kind == "txs"
|
||||
assert data == ["HEXHEX"]
|
||||
|
||||
|
||||
def test_whole_will_qr_roundtrip():
|
||||
# "Whole will" produces a single JSON document that survives a full
|
||||
# QR encode -> frame capture -> assemble -> decode cycle.
|
||||
bw = FakeBalWindow()
|
||||
items = _make_willitems(2)
|
||||
bw.willitems = items
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert d.content_check.isChecked()
|
||||
assert len(page.tx_strings) == 1
|
||||
|
||||
# Rebuild the transfer from the dialog's own strings, as the importer does.
|
||||
transfer = encode_transfer(page.tx_strings)
|
||||
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
import_page = impl.qr_page
|
||||
for frame in split_frames(transfer, dialogs.CHUNK_PRESETS[0][1]):
|
||||
import_page._add_frame(frame)
|
||||
assert import_page.review_btn.isEnabled()
|
||||
caught = {}
|
||||
|
||||
def fake_finish(payload):
|
||||
caught["payload"] = payload
|
||||
|
||||
import_page._finish_import = fake_finish
|
||||
import_page._review_and_sign()
|
||||
kind, data = dialogs.decode_will_payload(caught["payload"])
|
||||
assert kind == "will"
|
||||
assert set(data) == {"item0", "item1"}
|
||||
d.close()
|
||||
impl.close()
|
||||
|
||||
|
||||
def test_import_total_mismatch_resets():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
warnings = []
|
||||
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
|
||||
|
||||
frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150)
|
||||
for frame in frames_a:
|
||||
page._add_frame(frame)
|
||||
assert page.total == len(frames_a)
|
||||
|
||||
# A frame with a different total wipes the import; the first frame of
|
||||
# the new transfer must be scanned afresh.
|
||||
page._add_frame(frames_b[0])
|
||||
assert warnings
|
||||
assert page.total == 0
|
||||
assert not page.frames
|
||||
assert not page.review_btn.isEnabled()
|
||||
d.close()
|
||||
|
||||
|
||||
def test_import_manual_entry():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
frames = split_frames(encode_transfer(["M" * 120]), 150)
|
||||
page.manual_edit.setText(frames[0])
|
||||
page._add_from_manual()
|
||||
assert page.manual_edit.text() == ""
|
||||
assert page.total == 1
|
||||
assert page.review_btn.isEnabled()
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Continuous camera scan (change/detection debounce + auto-finish)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _fresh_debounce():
|
||||
return {
|
||||
"last_index": None,
|
||||
"last_payload": None,
|
||||
"pending_index": None,
|
||||
"pending_payload": None,
|
||||
"pending_count": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_qr_import_debounce_pending_then_accept():
|
||||
s = _fresh_debounce()
|
||||
# First sighting of a new identity: pending, not yet stored.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "PAYLOAD1") == "pending"
|
||||
assert s["pending_count"] == 1
|
||||
assert s["last_index"] is None
|
||||
# A second stable read of the same identity: accepted.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "PAYLOAD1") == "accept"
|
||||
assert s["last_index"] == 1
|
||||
assert s["last_payload"] == "PAYLOAD1"
|
||||
assert s["pending_count"] == 0
|
||||
|
||||
|
||||
def test_qr_import_debounce_re_reading_last_is_ignored():
|
||||
s = _fresh_debounce()
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1")
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") # accepted
|
||||
# The exporter is still showing frame 1: must be ignored, not accepted.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") == "ignore"
|
||||
assert s["last_index"] == 1
|
||||
assert s["pending_count"] == 0
|
||||
|
||||
|
||||
def test_qr_import_debounce_transition_pending_resets():
|
||||
s = _fresh_debounce()
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1")
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") # accept frame 1
|
||||
# A new identity interrupts the pending accumulation.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "accept"
|
||||
# Same-index duplicate with different payload is treated as new identity.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "ignore"
|
||||
|
||||
|
||||
def test_qr_import_debounce_total_mismatch_resets():
|
||||
s = _fresh_debounce()
|
||||
# In-range frame is accepted even though its declared total is ignored.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:3", 3, 2, "P2") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:3", 3, 2, "P2") == "accept"
|
||||
# A frame that belongs to a different transfer.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:4", 4, 2, "P2B") == "reset"
|
||||
# Once the policy is rebased on the new transfer, frames resume normally
|
||||
# (the widget clears the debounce while wiping the import).
|
||||
s["key"] = None
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:4", 4, 2, "P2B") == "pending"
|
||||
|
||||
|
||||
def test_qr_import_handle_scanned_text_autofinish():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
reviewed = []
|
||||
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||
|
||||
frames = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
assert len(frames) > 1
|
||||
|
||||
# The camera session is running and every frame needs two stable reads.
|
||||
page._scanning = True
|
||||
for frame in frames:
|
||||
for _rep in range(2):
|
||||
page._handle_scanned_text(frame)
|
||||
assert page.total == len(frames)
|
||||
assert len(page.frames) == len(frames)
|
||||
assert page.review_btn.isEnabled()
|
||||
|
||||
# With all frames stored, the loop auto-finishes exactly once.
|
||||
_app.processEvents()
|
||||
assert reviewed == [True]
|
||||
# The camera loop was stopped before handing over to the review step.
|
||||
assert not page._scanning
|
||||
assert not page._scan_timer.isActive()
|
||||
d.close()
|
||||
|
||||
|
||||
def test_qr_import_handle_scanned_text_manual_does_not_autofinish():
|
||||
# Without a camera session running, extra frames never auto-proceed.
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
reviewed = []
|
||||
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||
|
||||
frames = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
assert len(frames) > 1
|
||||
assert not page._scanning
|
||||
for frame in frames:
|
||||
for _rep in range(2):
|
||||
page._handle_scanned_text(frame)
|
||||
assert len(page.frames) == len(frames)
|
||||
_app.processEvents()
|
||||
assert reviewed == []
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Animated-QR formats (BC-UR v1/v2, BBQR) via the export/import pages
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_export_format_combo_switches_codecs():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert page.format == "balqr"
|
||||
assert page.frames[0].startswith("BAL1")
|
||||
assert page.format_combo.count() == 4
|
||||
|
||||
page._on_format_change(1) # BC-UR v1
|
||||
assert page.format == "ur1"
|
||||
assert page.frames[0].startswith("ur:bytes/")
|
||||
assert page.index == 0
|
||||
assert page.qr_view.text == page.frames[0]
|
||||
|
||||
page._on_format_change(2) # BC-UR v2
|
||||
assert page.format == "ur2"
|
||||
assert page.frames[0].startswith("ur:bytes/")
|
||||
|
||||
page._on_format_change(3) # BBQR
|
||||
assert page.format == "bbqr"
|
||||
assert page.frames[0].startswith("B$")
|
||||
d.close()
|
||||
|
||||
|
||||
def test_export_animated_format_frames_fit_budget():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
for index in range(1, 4):
|
||||
page._on_format_change(index)
|
||||
for frame in page.frames:
|
||||
assert len(frame) <= dialogs.CHUNK_PRESETS[0][1]
|
||||
d.close()
|
||||
|
||||
|
||||
def _import_roundtrip_fmt(fmt_index):
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(2)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
page._on_format_change(fmt_index)
|
||||
frames = list(page.frames)
|
||||
assert frames
|
||||
d.close()
|
||||
|
||||
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
import_page = impl.qr_page
|
||||
caught = {}
|
||||
import_page._finish_import = lambda payload: caught.__setitem__("payload", payload)
|
||||
for frame in frames:
|
||||
import_page._add_frame(frame)
|
||||
assert import_page.review_btn.isEnabled()
|
||||
import_page._review_and_sign()
|
||||
kind, data = dialogs.decode_will_payload(caught["payload"])
|
||||
assert kind == "will"
|
||||
assert set(data) == {"item0", "item1"}
|
||||
impl.close()
|
||||
|
||||
|
||||
def test_import_ur1_roundtrip():
|
||||
_import_roundtrip_fmt(1)
|
||||
|
||||
|
||||
def test_import_ur2_roundtrip():
|
||||
_import_roundtrip_fmt(2)
|
||||
|
||||
|
||||
def test_import_bbqr_roundtrip():
|
||||
_import_roundtrip_fmt(3)
|
||||
|
||||
|
||||
def test_import_animated_scan_debounce_autofinish():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(2)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
page._on_format_change(2) # BC-UR v2 fountain
|
||||
frames = list(page.frames)
|
||||
d.close()
|
||||
|
||||
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
import_page = impl.qr_page
|
||||
reviewed = []
|
||||
import_page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||
import_page._scanning = True
|
||||
for frame in frames:
|
||||
for _rep in range(2):
|
||||
import_page._handle_scanned_text(frame)
|
||||
assert import_page.review_btn.isEnabled()
|
||||
# The fountain transfer's part count is ``len(frames) // 2`` (pure + one
|
||||
# redundant mixed wave).
|
||||
assert import_page.total == len(frames) // 2
|
||||
assert len(import_page.frames) >= import_page.total
|
||||
assert import_page.review_btn.isEnabled()
|
||||
_app.processEvents()
|
||||
assert reviewed == [True]
|
||||
assert not import_page._scanning
|
||||
impl.close()
|
||||
|
||||
|
||||
def test_import_garbage_scan_is_ignored():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
for garbage in ("hello world", "12345", "B$ZZ", ""):
|
||||
page._handle_scanned_text(garbage)
|
||||
assert not page.frames
|
||||
assert page.total == 0
|
||||
assert not page.review_btn.isEnabled()
|
||||
d.close()
|
||||
|
||||
|
||||
def test_import_different_animated_transfer_resets():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
warnings = []
|
||||
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
|
||||
|
||||
frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150)
|
||||
for frame in frames_a:
|
||||
page._add_frame(frame)
|
||||
assert page.total == len(frames_a)
|
||||
assert not warnings
|
||||
|
||||
page._add_frame(frames_b[0])
|
||||
assert warnings
|
||||
assert page.total == 0
|
||||
assert not page.frames
|
||||
assert not page.review_btn.isEnabled()
|
||||
d.close()
|
||||
|
||||
|
||||
def test_import_start_stop_scan_signal_wiring():
|
||||
"""Regression: _start_scan/_stop_scan must use the QVideoSink signal
|
||||
videoFrameChanged, not the videoFrame frame getter.
|
||||
|
||||
On PyQt6, ``QVideoSink.videoFrame`` is a method (the frame getter), so
|
||||
``.videoFrame.connect(...)`` raises AttributeError. This test drives the
|
||||
real sink life-cycle with a mocked camera and asserts the scan session
|
||||
starts/ends cleanly with no error.
|
||||
"""
|
||||
from PyQt6.QtMultimedia import QCamera, QMediaCaptureSession, QMediaDevices
|
||||
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
errors = []
|
||||
page.show_error = lambda msg: errors.append(msg) # type: ignore[assignment]
|
||||
|
||||
fake_device = MagicMock()
|
||||
fake_device.isNull.return_value = False
|
||||
|
||||
with (
|
||||
patch.object(QMediaDevices, "defaultVideoInput", return_value=fake_device),
|
||||
# Mock camera + capture session; the QVideoSink stays real so the
|
||||
# videoFrameChanged connect/disconnect wiring is exercised for real.
|
||||
patch.object(QCamera, "__new__", return_value=MagicMock()),
|
||||
patch.object(QMediaCaptureSession, "__new__", return_value=MagicMock()),
|
||||
):
|
||||
page._start_scan()
|
||||
assert page._scanning is True
|
||||
assert not errors
|
||||
|
||||
page._stop_scan()
|
||||
assert page._scanning is False
|
||||
assert page._camera is None
|
||||
assert page._video_sink is None
|
||||
assert not errors
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All QR transfer GUI tests passed")
|
||||
@@ -390,6 +390,110 @@ def test_insufficient_funds_warns():
|
||||
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():
|
||||
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
|
||||
for fn in tests:
|
||||
|
||||
@@ -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
|
||||
tx locktime.
|
||||
|
||||
The karen7 regtest wallet fixture (``tests/karen7``) reproduces the exact
|
||||
reported state: heirs with ``"1y"``, a signed/pushed/checked item whose frozen
|
||||
tx.locktime is 2027-08-05 (built 2026-08-05), and will_settings
|
||||
The reported state (reproduced hermetically here — the original live wallet
|
||||
dump ``tests/karen7`` is gitignored and regenerated as the wallet evolves) is:
|
||||
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"}``.
|
||||
|
||||
Run:
|
||||
@@ -28,18 +29,16 @@ Run:
|
||||
python3 tests/test_heir_relative_anchor.py
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
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)
|
||||
|
||||
constants.net = constants.BitcoinRegtest
|
||||
|
||||
from bal.core.checkalive import resolve_date_to_check # noqa: E402
|
||||
from bal.core.util import copy_structure # noqa: E402
|
||||
from bal.core.will import ( # noqa: E402
|
||||
HeirNotFoundException,
|
||||
NoHeirsException,
|
||||
@@ -49,6 +48,16 @@ from bal.core.will import ( # noqa: E402
|
||||
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;
|
||||
# the tests override ``tx.locktime`` to simulate the frozen signed locktime.
|
||||
_VALID_TX_HEX = (
|
||||
@@ -71,7 +80,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
||||
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
|
||||
d = {
|
||||
"tx": _VALID_TX_HEX,
|
||||
"heirs": copy.deepcopy(heirs),
|
||||
"heirs": copy_structure(heirs),
|
||||
"willexecutor": None,
|
||||
"status": "",
|
||||
"description": "",
|
||||
@@ -80,7 +89,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
||||
"baltx_fees": 1,
|
||||
}
|
||||
item = WillItem(d, _id="willid_1")
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
item.tx.locktime = tx_locktime
|
||||
if status_complete:
|
||||
item.set_status("COMPLETE", True)
|
||||
@@ -111,7 +120,7 @@ def test_unchanged_relative_recipe_signed_is_coherent():
|
||||
read as a postpone just because the clock has advanced past build day."""
|
||||
heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
||||
outcome = _run_heir_check(
|
||||
copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=True
|
||||
copy_structure(heirs), copy_structure(heirs), _FROZEN, status_complete=True
|
||||
)
|
||||
assert outcome.startswith("coherent"), outcome
|
||||
|
||||
@@ -119,7 +128,7 @@ def test_unchanged_relative_recipe_signed_is_coherent():
|
||||
def test_unchanged_relative_recipe_unsigned_is_coherent():
|
||||
heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
||||
outcome = _run_heir_check(
|
||||
copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=False
|
||||
copy_structure(heirs), copy_structure(heirs), _FROZEN, status_complete=False
|
||||
)
|
||||
assert outcome.startswith("coherent"), outcome
|
||||
|
||||
@@ -145,7 +154,7 @@ def test_relative_recipe_shortened_on_signed_is_rebuild():
|
||||
def test_unchanged_absolute_recipe_is_coherent():
|
||||
built = {"alice": ["addr_alice", 5000, str(_FROZEN)]}
|
||||
outcome = _run_heir_check(
|
||||
copy.deepcopy(built), copy.deepcopy(built), _FROZEN, status_complete=True
|
||||
copy_structure(built), copy_structure(built), _FROZEN, status_complete=True
|
||||
)
|
||||
assert outcome.startswith("coherent"), outcome
|
||||
|
||||
@@ -158,55 +167,52 @@ def test_absolute_postpone_on_signed_still_detected():
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# karen7 wallet regression (real fixture)
|
||||
# karen7 regression (hermetic, no live wallet fixture)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
|
||||
def _load_karen7():
|
||||
path = os.path.join(os.path.dirname(__file__), "karen7")
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
# karen7's reported state, reproduced hermetically: heirs "1y", a signed item
|
||||
# frozen at delivery 2027-08-05 (built 2026-08-05), will_settings with a
|
||||
# relative "150d" delivery window and a "2y" promised locktime.
|
||||
_WILL_SETTINGS = {"locktime": "2y", "threshold": "150d"}
|
||||
|
||||
|
||||
def test_karen7_frozen_delivery_not_expired():
|
||||
"""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."""
|
||||
data = _load_karen7()
|
||||
will_settings = data["will_settings"]
|
||||
valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
|
||||
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
|
||||
built_locktime = Will.get_min_locktime({valid_wid: wi})
|
||||
assert built_locktime == int(wi.tx.locktime)
|
||||
heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
||||
item = _make_will_item(copy_structure(heirs), _FROZEN, status_complete=True)
|
||||
will = {"willid_1": item}
|
||||
built_locktime = Will.get_min_locktime(will)
|
||||
assert built_locktime is not None
|
||||
assert built_locktime == int(item.tx.locktime)
|
||||
|
||||
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
|
||||
# Re-evaluated 10 days later the window is identical (no daily drift).
|
||||
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,
|
||||
)
|
||||
assert date_to_check == later
|
||||
|
||||
|
||||
def test_karen7_unchanged_heirs_are_coherent():
|
||||
"""The karen7 heirs (unchanged relative "2d") are coherent with the frozen
|
||||
signed tx: the plugin must NOT ask to invalidate the will."""
|
||||
data = _load_karen7()
|
||||
valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
|
||||
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
|
||||
"""Unchanged relative "1y" heirs are coherent with the frozen signed tx:
|
||||
the plugin must NOT ask to invalidate the will."""
|
||||
heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
||||
# Use _FROZEN (a UTC-midnight value) so the check is compatible with
|
||||
# the UTC anchoring code.
|
||||
frozen_locktime = _FROZEN
|
||||
date_to_check = resolve_date_to_check(
|
||||
False, data["will_settings"],
|
||||
False, _WILL_SETTINGS,
|
||||
now=1_800_000_000.0,
|
||||
built_locktime=frozen_locktime,
|
||||
)
|
||||
outcome = _run_heir_check(
|
||||
data["will"][valid_wid]["heirs"],
|
||||
data["heirs"],
|
||||
copy_structure(heirs),
|
||||
copy_structure(heirs),
|
||||
frozen_locktime,
|
||||
status_complete=True,
|
||||
)
|
||||
@@ -219,6 +225,7 @@ def test_karen7_unchanged_heirs_are_coherent():
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
if __name__ == "__main__":
|
||||
constants.net = constants.BitcoinRegtest
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
|
||||
@@ -127,6 +127,11 @@ def test_sign_transactions_external_only():
|
||||
wallet=FakeWallet(),
|
||||
waiting_dialog=SimpleNamespace(update=lambda msg: None),
|
||||
)
|
||||
# sign_transactions dispatches to self._prepare_and_sign_tx; bind the real
|
||||
# implementation onto the fake so the external-sign run actually executes.
|
||||
fake._prepare_and_sign_tx = MethodType(
|
||||
window_mod.BalWindow._prepare_and_sign_tx, fake
|
||||
)
|
||||
|
||||
result = window_mod.BalWindow.sign_transactions(fake, None, will=imported)
|
||||
|
||||
@@ -166,6 +171,50 @@ def test_will_widget_explicit_will():
|
||||
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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -27,7 +27,6 @@ Run::
|
||||
python3 -m pytest tests/test_no_willexecutor_karen7.py -v -s
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -49,6 +48,7 @@ from electrum.transaction import PartialTxInput, TxOutpoint
|
||||
from electrum.util import bfh
|
||||
|
||||
from bal.core.heirs import Heirs
|
||||
from bal.core.util import copy_structure
|
||||
from bal.core.will import (
|
||||
NotCompleteWillException,
|
||||
NoWillExecutorNotPresent,
|
||||
@@ -278,11 +278,11 @@ class FakeBalWindow:
|
||||
tx["my_locktime"] = txs[txid].my_locktime
|
||||
tx["heirsvalue"] = txs[txid].heirsvalue
|
||||
tx["description"] = txs[txid].description
|
||||
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
|
||||
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
|
||||
tx["status"] = "New"
|
||||
tx["baltx_fees"] = txs[txid].tx_fees
|
||||
tx["time"] = creation_time
|
||||
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
|
||||
tx["heirs"] = copy_structure(txs[txid].heirs)
|
||||
tx["txchildren"] = []
|
||||
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
||||
self.update_will(will)
|
||||
|
||||
@@ -7,7 +7,6 @@ but without requiring a full Qt event loop.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -24,6 +23,7 @@ if os.path.isdir(ELECTRUM_DIR):
|
||||
|
||||
from bal.core.heirs import Heirs
|
||||
from bal.core.plugin_base import BalPlugin, BalTimestamp
|
||||
from bal.core.util import copy_structure
|
||||
from bal.core.will import (
|
||||
NoHeirsException,
|
||||
NotCompleteWillException,
|
||||
@@ -145,11 +145,11 @@ class FakeBalWindow:
|
||||
tx["my_locktime"] = txs[txid].my_locktime
|
||||
tx["heirsvalue"] = txs[txid].heirsvalue
|
||||
tx["description"] = txs[txid].description
|
||||
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
|
||||
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
|
||||
tx["status"] = "New"
|
||||
tx["baltx_fees"] = txs[txid].tx_fees
|
||||
tx["time"] = creation_time
|
||||
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
|
||||
tx["heirs"] = copy_structure(txs[txid].heirs)
|
||||
tx["txchildren"] = []
|
||||
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
||||
Will.update_will(self.willitems, will)
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs``.
|
||||
|
||||
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
|
||||
BUILT transactions' fixed locktime. The bug it fixes (reported by the owner):
|
||||
(WILL_SETTINGS["locktime"]) in lockstep with the BUILT transactions' fixed
|
||||
locktime when the core AUTOMATICALLY anticipates it (one day earlier than
|
||||
stored).
|
||||
|
||||
ADVANCED mode + RELATIVE locktime ("90d") / threshold ("30d") -> the plugin
|
||||
asks to invalidate the will EVERY DAY. The relative value is re-parsed
|
||||
against "now" on every check, so it drifts one day per day away from the
|
||||
fixed tx locktime and the postpone check always sees a "postpone".
|
||||
RELATIVE recipes ("90d" / "1y") are now PRESERVED: the daily-drift problem
|
||||
that once forced freezing them to absolute timestamps is solved at the root by
|
||||
anchoring every relative recipe against the built transactions
|
||||
(``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
|
||||
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))
|
||||
|
||||
from bal.core.plugin_base import BalTimestamp # 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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_relative_locktime_normalized_to_absolute():
|
||||
"""The reported bug: a relative stored locktime is frozen to the absolute
|
||||
value of the built transaction, even when it parses to the same moment."""
|
||||
def test_relative_locktime_preserved():
|
||||
"""A RELATIVE stored locktime ("90d"/"1y") is PRESERVED after a rebuild:
|
||||
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
|
||||
recorded = []
|
||||
fake = _call_sync(
|
||||
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
||||
)
|
||||
assert fake.bal_window.will_settings["locktime"] == tx_locktime
|
||||
assert fake.bal_window.will_settings["locktime"] != "90d"
|
||||
# A pure relative->absolute normalisation is NOT an anticipation: the sign
|
||||
# prompt must not claim the date was anticipated.
|
||||
assert fake.bal_window.will_settings["locktime"] == "90d"
|
||||
assert fake.bal_window.will_settings["threshold"] == "30d"
|
||||
assert recorded == [], "a relative recipe must never be rewritten"
|
||||
assert fake._date_was_anticipated is False
|
||||
|
||||
|
||||
def test_relative_threshold_frozen_to_absolute():
|
||||
"""A relative threshold ("N days BEFORE the delivery") is normalised to the
|
||||
same absolute value the settings widget computes (real_threshold)."""
|
||||
def test_relative_threshold_preserved():
|
||||
"""Same for the relative "Check Alive" threshold: it stays relative."""
|
||||
tx_locktime = 1_800_000_000
|
||||
recorded = []
|
||||
fake = _call_sync(
|
||||
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
||||
)
|
||||
expected = int(
|
||||
BalTimestamp("30d").to_date(tx_locktime, reverse=True).timestamp()
|
||||
)
|
||||
assert fake.bal_window.will_settings["threshold"] == expected
|
||||
assert ("threshold", expected, True) in recorded
|
||||
assert fake.bal_window.will_settings["threshold"] == "30d"
|
||||
assert recorded == []
|
||||
|
||||
|
||||
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():
|
||||
"""A real anticipation (built locktime earlier than the stored absolute
|
||||
one) still moves the date earlier and flags the sign prompt."""
|
||||
"""A real automatic anticipation of an ABSOLUTE stored date (built earlier
|
||||
than stored) still moves the date earlier and flags the sign prompt."""
|
||||
tx_locktime = 1_700_000_000
|
||||
recorded = []
|
||||
fake = _call_sync(
|
||||
@@ -129,7 +128,7 @@ def test_anticipation_sets_flag_and_moves_earlier():
|
||||
def test_stored_earlier_than_built_never_moved_later():
|
||||
"""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
|
||||
anticipation (built < stored) and relative normalisation move the value."""
|
||||
anticipation (built < stored) moves the value."""
|
||||
stored = 1_800_000_000
|
||||
recorded = []
|
||||
fake = _call_sync(
|
||||
@@ -142,39 +141,39 @@ def test_stored_earlier_than_built_never_moved_later():
|
||||
|
||||
|
||||
def test_multiple_txs_uses_minimum_locktime():
|
||||
"""When several transactions carry different locktimes, the minimum is used
|
||||
(owner-confirmed behaviour for the delivery date shown in the UI)."""
|
||||
"""When several ABSOLUTE transactions carry different locktimes, the minimum
|
||||
is used for a genuine automatic anticipation (owner-confirmed behaviour for
|
||||
the delivery date shown in the UI)."""
|
||||
min_locktime = 1_750_000_000
|
||||
recorded = []
|
||||
fake = _call_sync(
|
||||
{"locktime": "90d", "threshold": "30d"},
|
||||
{"locktime": 1_800_000_000, "threshold": 1_600_000_000},
|
||||
[min_locktime, min_locktime + 86_400],
|
||||
recorded,
|
||||
)
|
||||
assert fake.bal_window.will_settings["locktime"] == min_locktime
|
||||
|
||||
|
||||
def test_relative_locktime_stops_daily_postpone():
|
||||
"""End-to-end guard for the reported bug: after the sync, re-parsing the
|
||||
stored (now absolute) locktime on later days always equals the built
|
||||
tx locktime, so the postpone check never fires again."""
|
||||
from datetime import datetime, timedelta
|
||||
def test_relative_locktime_stays_coherent_via_anchor():
|
||||
"""Daily-drift guard: an UNCHANGED relative recipe is resolved against the
|
||||
tx build moment (``Util.resolve_locktime_against_tx``), so even WITHOUT
|
||||
being frozen to an absolute value it still reads as COHERENT (== tx
|
||||
locktime) on later days - the postpone check never fires again."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from bal.core.util import Util
|
||||
|
||||
tx_locktime = 1_800_000_000
|
||||
recorded = []
|
||||
fake = _call_sync(
|
||||
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
||||
)
|
||||
stored = fake.bal_window.will_settings["locktime"]
|
||||
# resolve_locktime_against_tx normalises to UTC midnight before anchoring,
|
||||
# so use a midnight-UTC frozen tx locktime (the timestamp the engine itself
|
||||
# stores after building).
|
||||
tx_locktime = int(datetime(2027, 1, 15, tzinfo=timezone.utc).timestamp())
|
||||
built = "90d" # recipe frozen at build time
|
||||
current = "90d" # unchanged recipe today
|
||||
for _day in range(0, 7):
|
||||
# Simulate the check on later days: parse the STORED value (which is
|
||||
# now the absolute tx locktime) and compare with the fixed tx locktime.
|
||||
new_locktime = Util.parse_locktime_string(stored)
|
||||
assert new_locktime == tx_locktime
|
||||
assert new_locktime <= tx_locktime # no POSTPONE / drift
|
||||
# Sanity: a RELATIVE value would have drifted past it (the bug).
|
||||
resolved = Util.resolve_locktime_against_tx(current, built, tx_locktime)
|
||||
assert resolved == tx_locktime # no POSTPONE / drift
|
||||
# Sanity: a naive forward-from-now re-parse would have drifted past it
|
||||
# (the bug the anchor fixes).
|
||||
drifted = int(
|
||||
(
|
||||
datetime.fromtimestamp(tx_locktime) + timedelta(days=1)
|
||||
|
||||
Reference in New Issue
Block a user