34 Commits
v0.6.0 ... main

Author SHA1 Message Date
4a33116b66 fix: stop infinite RecursionError in will wizard (Date editor timezone roundtrip)
LockTimeDateEdit stored the aware-UTC datetime from _safe_fromtimestamp
into the QDateTimeEdit, which keeps the wall time with a LocalTime spec.
get_value() therefore read back x + utc_offset, so set_value(x) never
equalized x and the valueEdited -> update_setting_widgets -> set_value
signal cycle fired forever, crashing with RecursionError when clicking
Next in the 'Build your will' wizard. Store the local wall-clock time
instead (alarm stays aware-UTC for the .ics export).
2026-09-19 17:42:11 -04:00
fb88d7540c feat: animated-QR transfer, Android reader, relative-locktime preservation, karen7 hermetic tests 2026-09-14 09:11:50 -04:00
9c4697c923 Merge pull request 'core+gui: name the real cause of a failed build instead of guessing' (#7) from ui/build-will-error-messages into main
Reviewed-on: #7
2026-09-09 12:31:28 +00:00
42f05d3c4f core+gui: name the real cause of a failed build instead of guessing
The Building Will report showed a fixed list of three "possible reasons"
whenever a build produced nothing, regardless of what actually happened; in a
case reproduced from the owner log all three were false and the real cause was
not even listed. The "Checking your will" row had the same flaw, showing one
sentence ("Found CHANGES to the DATE or the HEIRS") for five situations,
including one where it is plainly wrong (funds received).

core/heirs.py: record WHY buildTransactions gave up in a new last_build_error
attribute (8 reason codes), set at each path that previously returned empty
with no explanation, plus a processed_willexecutors counter to tell "every
will-executor was skipped" apart from "we tried and failed". Also fix a latent
crash in the prepare_transactions handler, which read a no-longer-existing
e.heirname attribute and re-raised the resulting AttributeError, masking the
real error.

gui/qt/dialogs.py: add msg_alert() (amber warning sign, body text in the theme
colour, readable in both themes), _build_failure_message() and
_check_failure_message() to turn those causes into one precise sentence each,
with an honest "cause could not be determined" fallback. Catch
BalanceTooLowException, which already carried the figures but fell through to
the generic red technical error.

No new exception classes were introduced (owner request): the plain
NotCompleteWillException cases are told apart structurally, not by text.
2026-09-04 11:52:21 +02:00
3a9ee5adb9 core+gui: timezone-correct datetimes, deep-copy WillItem, dead code removal, explicit imports 2026-08-19 20:57:22 -04:00
2c9f6bdd9d gui: remove 'Add transaction without willexecutor' from plugin settings dialog
The checkbox is already available in the Will-Executor tab; showing it
in the settings dialog was redundant. Renumber grid rows 5-16 -> 4-15
to close the gap.
2026-08-17 23:42:01 -04:00
4dfb3fc41c fix: chainname regtest bug (classproperty); add no-heirs buttons in build-will dialog
- bal/core/plugin_base.py: change chainname from frozen class attribute
  to @classproperty so it reads constants.net.NET_NAME at runtime, fixing
  regtest/testnet always downloading the mainnet executor list
- bal/core/willexecutors.py: remove module-level chainname capture;
  all uses now read BalPlugin.chainname directly
- bal/gui/qt/dialogs.py: when BalBuildWillDialog detects no heirs,
  return 'no_heirs' signal and show Heirs/Wizard/Close buttons (mirroring
  the existing no-willexecutor pattern); add HeirsDialog with full
  HeirListWidget (New Heir, Import, Export)
2026-08-17 12:06:21 -04:00
1ae1617172 cli: add bal_will_autorebuild (headless one-shot check/rebuild/sign/push flow with invalidation tx) 2026-08-16 06:40:40 -04:00
125bf09b9a gui: rebuild the will automatically on new transactions (AUTO_REBUILD, default off; anticipate delivery by 1 day, invalidate on-chain only when the anticipated locktime crosses the check-alive threshold) 2026-08-16 06:40:35 -04:00
b5752a42f0 cli: add headless command-line layer (bal_* commands for the daemon, cmdline entry point, manifest 'cmdline' support, offline controller tests) 2026-08-14 23:57:28 -04:00
ce659048ca core: add 'Rebuild will on wallet close' setting to skip the build wizard at close (REBUILD_ON_CLOSE); settings checkbox + tests 2026-08-14 23:56:14 -04:00
c80f0e1524 test: end-to-end GUI tests for will flows (prepare, sign->COMPLETE, export/merge roundtrip, insufficient funds) 2026-08-14 23:41:39 -04:00
e8e4d5e451 core: anchor relative locktime/threshold recipes to the built tx locktime so an unchanged will no longer reads as expired/postponed; stop the daily invalidate prompt (karen7) 2026-08-09 12:08:25 -04:00
e74c348bbb core: extract GUI-free logic into bal.core (reminders/checkalive/input_rules); fix save/sign tx RLock pickle by serializing tx; keep invalid heirs in wallet on build; tb1 testnet will-executor addresses; move tests to core modules 2026-08-05 17:17:32 -04:00
fdcab7c6bd core: replace debug print() with _logger, drop dead print_transaction helper 2026-08-01 23:33:52 -04:00
eac03da530 docs: align all markdown docs with current code (v0.6.1)
- CHANGELOG: add entries #47 (is_selected/is_valid fee bounds, extremes allowed) and #48 (merge_will crash fix on missing date_to_check)
- HANDOFF: Electrum 4.7.2 and 4.8.0, current layout (qt.py, plugin.py, wallet_util/, docs/, bal_cli.py, 36-file ZIP), runtime/lint venv paths, standalone-script tests (427 collected), direct-to-main workflow, version history up to v0.6.1
- README: drop VERSION from layout, correct anticipate/expire/postpone behavior, update test commands
- docs/manual/README.md: fix Tools menu label to Will-Executors, add BASIC/ADVANCED notes, extend chromatic status table (Partially signed, Updated)
- docs/inheritance-options.md: add PARTIALLY_SIGNED status/transition/colour, version note to v0.6.1
- docs/README.md: make HTML-hosting instructions host-agnostic
- bal/README.md: Prepare button lives on the WILL tab
2026-08-01 23:08:07 -04:00
167d1228b2 willexecutors: fee-bound is_valid (extremes allowed), is_selected flag-only; fix merge_will crash on missing date_to_check; DNSSEC async, ical folding, pyright/ruff cleanup; refresh karen7/samanta7 fixtures 2026-08-01 22:08:53 -04:00
8649556a18 Will toolbar: Export dropdown submenu (All / Valid / Valid NC)
Replace the separate Export / Export V-NC toolbar actions with a cascading
Export submenu: All exports every will item, Valid exports only valid ones,
Valid NC exports valid items that are not yet fully signed.
2026-08-01 19:31:28 -04:00
5e53a87bb6 Will list: context menu actions and will-executor bulk tools
PreviewList: right-click menu with sign / details / broadcast / check /
copy id / copy / merge from txn / delete, with multi-selection support and
availability rules (broadcast force-repushes, delete restricted to invalid
or unsigned txs, sign skips non-valid txs).

WillExecutorWidget: Export, Ping All and Select All become dropdown buttons
(export all/selected/valid, ping all/selected, select all/only valid,
deselect all/only invalid).

WillExecutorListWidget: single-cell Copy action.

BalWindow: txids-scoped sign/broadcast plumbing and merge_single_transaction.
2026-08-01 19:11:35 -04:00
251fd93cbd BalWindow/plugin: history persistence, will import/merge, sig tracking, local-spender fixes 2026-08-01 17:21:36 -04:00
bdeef3dc6a lint: ruff cleanup pass across bal/ and tests/
- Sort imports and fix pyproject ruff config (per-file ignores for
  intentional Qt/core exceptions)
- Mark Heirs.validate_* helpers as @staticmethod
- Clean up dead code, rename shadowing vars, use raise ... from
- Add AGENTS.md with env/lint/test/release guidance
2026-07-31 16:03:17 -04:00
6f7a364bc6 Add OP_RETURN support for heirs
Allow heirs to produce an OP_RETURN output instead of a regular BTC
payment. An address prefixed with OP_RETURN: carries hex data (max 80
bytes); such heirs always have amount 0 and are excluded from amount
calculations. GUI dialog shows a message field with the decoded text,
the heir list displays the decoded message, and the OP_RETURN output
is emitted with zero value in inheritance transactions.
2026-07-30 22:06:49 -04:00
a1b13947c3 fix version lookup for internal plugin install; silence JSON parse debug noise 2026-07-30 18:53:43 -04:00
6e0ae7e4ed add samanta7 for tests 2026-07-30 18:25:52 -04:00
9275e9c6a2 Add Willexecutors.is_valid + grey italic styling for invalid executors 2026-07-30 18:11:29 -04:00
658e3dca0b add make-release.sh, svatantrya.asc, and update HANDOFF.md
- make-release.sh: automated release script (tests, lint, build, GPG sign, SHA-256, Gitea)
- svatantrya.asc: PGP public key for release verification
- HANDOFF.md: updated release workflow documentation
- willexecutors.py: input validation for addresses, fees, and API responses
- manifest.json: fixed version back to 0.6.1
- test_core_plugin_base.py: updated baltx_fees default
2026-07-22 21:07:32 -04:00
c82b5dda37 Merge pull request 'Update bal/core/plugin_base.py' (#4) from bitcoinafterlife-patch-5 into main 2026-07-22 15:43:19 +00:00
deca1a548e v0.6.1: read version from manifest.json (single source of truth)
Read the plugin version at runtime from bal/manifest.json via
importlib.resources (zip-safe, works from inside a zip on Windows).
Removes the four-file duplication (VERSION, __init__.py, plugin_base.py,
manifest.json) and deletes the bal/VERSION file.

- bal/core/plugin_base.py: new get_version() function, BalPlugin.version
  is now a @property, removed hardcoded __version__
- bal/__init__.py: removed __version__, replaced with a comment
- bal/core/willexecutors.py: user-agent uses get_version()
- bal/gui/qt/dialogs.py, widgets.py: call sites use .version property
- tests/test_version_source.py: 7 tests incl. zip-safe subprocess check
- HANDOFF.md: updated to single-source-of-truth documentation
- CHANGELOG.md: entries 43-46
- bal/manifest.json: version bumped to 0.6.1

PR #4 (branch bitcoinafterlife-patch-5).
2026-07-22 11:19:18 -04:00
db573aa1b3 Merge pull request 'Add COMPATIBILITY_ROADMAP.md - multisig and TrustedCoin analysis' (#6) from SAFE21.io/bal-electrum-plugin:update-compat-docs into main
Reviewed-on: #6
2026-07-22 09:16:27 +00:00
326b3f6f76 Merge pull request 'Update CHANGELOG.md' (#3) from bitcoinafterlife-patch-6 into main
Reviewed-on: #3
2026-07-22 09:16:03 +00:00
943731c070 Merge pull request 'Add COMPATIBILITY.md and update README for 4.8.0 support' (#5) from update-compat-docs into main
Reviewed-on: #5
2026-07-22 08:55:28 +00:00
bitcoinafterlife
40675ac986 Add compatibility roadmap for multisig and TrustedCoin (2FA) wallets 2026-07-19 16:51:16 +02:00
bitcoinafterlife
73be102f7c Add COMPATIBILITY.md; update README with 4.8.0 support and wallet compat section 2026-07-19 11:08:09 +02:00
37a417ae59 Update CHANGELOG.md
v0.6.0: update changelog
2026-07-17 17:42:13 +00:00
135 changed files with 26831 additions and 13718 deletions

30
.gitignore vendored
View File

@@ -4,3 +4,33 @@
bal-electrum-plugin.zip
electrum-src/
preview_*.png
.env
# Virtual environment
venv/
.venv/
# Node modules
node_modules/
# Editor temp files
*.swp
*.swo
*.bak
# Local tooling / scratch files (not part of the plugin)
TODO_LIST.md
opencode.json
package.json
package-lock.json
pyrightconfig.json
# Debug / scratch files
debug.py
init.ol
temp*
tmp*
# Release artifacts
bal_v*.zip.*
tests/karen7

View File

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

97
AGENTS.md Normal file
View File

@@ -0,0 +1,97 @@
# AGENTS.md
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.
- **Runtime env** (Electrum + PyQt6, has `electrum` importable):
`source "$BAL_HOME/electrum/env/bin/activate"`
This is an editable install of the Electrum 4.8.0 checkout at
`$BAL_HOME/electrum`. Use it for anything that imports
`electrum`, runs GUI code, or runs tests.
- **Lint venv** (repo-local `venv/`): ruff, black, flake8 only. It cannot
import `electrum` or `PyQt6`. Do NOT use it to run tests.
The plugin's `bal/` directory is symlinked into
`electrum/electrum/plugins/bal` (internal-plugin install used during dev).
## Test & verify
Tests work **both** as standalone scripts and via pytest (tests use `def test_*`
naming and also have `if __name__ == "__main__"` blocks). Run a single file
directly:
```bash
source "$BAL_HOME/electrum/env/bin/activate"
python3 tests/test_core_heirs.py # core, no Qt needed
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need offscreen
```
Or run a batch with pytest (as `make-release.sh` does):
```bash
source "$BAL_HOME/electrum/env/bin/activate"
QT_QPA_PLATFORM=offscreen python3 -m pytest tests/test_core_*.py -q
```
- Most core tests run offline (no wallet/network). Some files
(`test_group_*.py`, `test_no_willexecutor_karen7.py`, `parallel_ping_test.py`)
exercise will-executor/network flows and need the live servers — don't rely on
them for quick verification.
- `tests/smoke_test.py` proves clean import under real Electrum:
`QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py electrum.plugins.bal`
- `tests/external_zip_test.py` loads the built zip the way Electrum's plugin
dialog does (`electrum_external_plugins.bal`); run it after `build_zip.py`.
## Lint / typecheck
- **Ruff is NOT clean** (hundreds of pre-existing errors in `bal/` and
`tests/`). Do not run `--fix` wholesale and do not try to silence everything;
just avoid adding new violations. Config: `pyproject.toml` (line-length 88,
E501 ignored). Per-file ignores suppress `F403`/`F405` for the intentional
`from .common import *` hub pattern in `bal/gui/qt/`.
- Lint via the repo venv: `./venv/bin/ruff`
- Typecheck: `pyright` (npm, `node_modules/`), config `pyrightconfig.json`
(`extraPaths: ["../electrum"]`). Pyright reports many false positives on
dynamically-attached attrs (e.g. `self.window`, `BalPlugin.*`); don't chase
them.
## Architecture
- `bal/core/` = GUI-free logic (`heirs.py`, `will.py`, `willexecutors.py`,
`plugin_base.py`, `util.py`, `checkalive.py`, `reminders.py`,
`input_rules.py`).
Must never import Qt.
- `bal/gui/qt/` = PyQt6 layer. `window.py` is the per-wallet controller,
`plugin.py` is the Electrum `@hooks` entry. `qt.py` is a zipimport shim.
`common.py` uses `import *` intentionally (ruff suppresses F403/F405 here);
`bal/gui/qt/*.py` all import from it.
- `bal/cli/` = headless command-line layer (no Qt). `plugin.py` is the daemon
entry point, `commands.py` registers `bal_*` commands with Electrum.
- `bal/wallet_util/` = wallet helper utilities for Qt and core.
- `bal/qt.py` and `bal/cmdline.py` are thin shims that Electrum discovers
via `manifest.json`; they import the real `Plugin` class via `importlib`.
- `bal/manifest.json` = version source of truth (Electrum reads it; also read by
`make-release.sh`).
- Compatibility constraint: must support Electrum **4.7.2 and 4.8.0**; the DB
registration API differs between them (`json_db.register_dict` vs
`stored_dict.register_name`).
## Build / release
```bash
python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha256)
./make-release.sh [v0.x.y] # bump manifest version, tag, sign, push Gitea release
```
- `make-release.sh` requires gpg and Gitea credentials (`~/.git-credentials`
or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the
version there, never invent a new source of truth.
- Remote is Gitea (`origin` = bitcoin-after.life). `.env` holds a Gitea token
(gitignored, never commit it).

170
AUDIO_MODEM_DEBIAN.md Normal file
View 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.01.2 kbps (a ~2 KB will takes
~1520 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
```

File diff suppressed because it is too large Load Diff

49
COMPATIBILITY.md Normal file
View File

@@ -0,0 +1,49 @@
# Wallet Compatibility
BAL (Bitcoin After Life) builds and signs Electrum transactions using
Electrum's own wallet and signing infrastructure. Its compatibility therefore
depends on the wallet type in use.
| Wallet type | Status | Notes |
|-------------------------------------------------|-----------------------------|-------|
| Standard wallet (single-signature, seed-based) | ✅ Supported | Primary, fully tested target |
| Hardware wallets (Ledger, Trezor, Coldcard, BitBox02, Jade, KeepKey, etc.) | ✅ Supported | Any hardware wallet supported by Electrum itself |
| Multisig wallets | ❌ Not yet supported | Known limitation identified 2026-07-18. Support is planned for a future plugin release. |
| Electrum TrustedCoin (2FA) wallets | ❓ Unknown / unsupported | Known limitation identified 2026-07-18. It has not yet been determined whether or when this will be addressed. |
## What "not supported" means in practice
For multisig and TrustedCoin (2FA) wallets, BAL's behavior has not been
verified and should be considered **unreliable**. Do not rely on BAL to
protect an inheritance set up on one of these wallet types until this document
is updated to mark them as supported.
## Electrum version compatibility
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
this repository describing the wallet type, Electrum version, and the exact
error or unexpected behavior observed.

99
COMPATIBILITY_ROADMAP.md Normal file
View File

@@ -0,0 +1,99 @@
# Compatibility Roadmap: Multisig and TrustedCoin (2FA) Wallets
Status document, 2026-07-19. Companion to [`COMPATIBILITY.md`](COMPATIBILITY.md):
that file states *what* is supported today; this one explains *why* multisig and
TrustedCoin (2FA) wallets are currently unsupported and *how* support can be
added.
## Root cause (common to both)
BAL currently does two things that are only valid for standard
(single-signature) wallets:
1. **It builds transactions itself** with `PartialTransaction.from_io(...)`
(`bal/core/will.py`), bypassing `wallet.make_unsigned_transaction`.
2. **It signs with a single call**`wallet.sign_transaction(tx, password)`
(`bal/gui/qt/window.py`, `sign_transactions`) — and considers the will ready
when `tx.is_complete()` is true.
On a standard wallet one signature completes the transaction. On multisig and
2FA wallets **one signature is not enough**: the transaction stays incomplete,
is never marked `COMPLETE`, and can never be pushed to will-executors.
A secondary single-sig assumption: `plugin.py` uses `wallet.get_keystore()`
(singular); multisig wallets expose `get_keystores()` (plural).
## Multisig wallets — solvable, medium/large effort
A 2-of-3 multisig wallet typically holds **only one** of the required private
keys locally; the other cosigners hold theirs. `wallet.sign_transaction` adds
the local signature only, and BAL has no flow to collect the missing ones.
**Proposed solution: the standard PSBT coordination round** (the same flow
Electrum itself uses for multisig spending):
1. Build and sign locally as today.
2. If the transaction is not complete, **export the partially-signed
transaction(s)** (file and/or QR) and mark the will with a new status such
as `WAITING_COSIGNERS`.
3. Each cosigner signs in their own Electrum (native feature — no new
software needed on their side).
4. BAL **re-imports and merges the signatures**; once complete, the will is
pushed to will-executors as today.
Notes and caveats:
- **Chained will transactions** (a will tx spending the change of a previous
will tx) remain workable: with segwit, the txid of an unsigned/partially
signed transaction is already stable, so the whole chain can be exported as
a batch of PSBTs in one round.
- **Every rebuild requires a new cosigner round.** Check Alive postponements
and balance-change rebuilds re-sign the will, so each of them needs the
cosigners again. This is inherent to multisig and must be clearly
communicated in the UI.
- Implementation surface: export/import/merge pipeline, GUI for it, the new
status in the transaction list, and tests.
Target: **next plugin release**, as announced.
## TrustedCoin (2FA) wallets — harder, with one blocking unknown
An Electrum 2FA wallet (`Wallet_2fa`, defined in Electrum's `trustedcoin`
plugin) is technically a **2-of-3 multisig whose second signer is the
TrustedCoin server**:
- signing requires a **one-time password (OTP) per transaction**
(`server.sign(short_id, raw_tx, otp)`);
- the server co-signs only transactions that include **its billing fee**,
which Electrum adds inside `Wallet_2fa.make_unsigned_transaction` — a code
path BAL currently bypasses (see root cause #1).
So today: no billing output, no OTP prompt, local signature only → incomplete
transaction.
Even with full integration (building via the wallet's
`make_unsigned_transaction`, adding the OTP prompt flow), one **decisive
unknown** remains: will transactions carry a **locktime years in the future**.
Whether the TrustedCoin server agrees to co-sign a transaction with such a
far-future `nLockTime` is an undocumented server-side policy. If it refuses,
2FA support is **not achievable** without TrustedCoin's cooperation. This is
why `COMPATIBILITY.md` marks 2FA as *unknown*.
**Proposed plan:**
1. **Empirical test on testnet** (cheap, decisive): create a test 2FA wallet,
build a far-future-locktime transaction through the proper 2FA path, and
check whether the server signs it.
2. If it signs → implement support: build via `make_unsigned_transaction`
(billing output included), integrate the OTP prompt, and document that
every rebuild costs one OTP round and TrustedCoin fees.
3. If it refuses → document 2FA as unsupported, with the practical
workaround: Electrum allows disabling 2FA by restoring the wallet from the
full seed, which turns it into a standard wallet — fully supported by BAL.
## Recommended order of work
1. **Multisig first**: deterministic path, standard Electrum tooling, already
announced for the next release.
2. **TrustedCoin empirical test in parallel**: low cost, and its outcome
decides whether 2FA support is feasible at all.

View File

@@ -10,7 +10,7 @@
## 0. TL;DR — what this project is
- **Product:** BAL ("Bitcoin After Life") — an inheritance plugin for the
**Electrum 4.7.2** Bitcoin wallet (Qt / **PyQt6**).
**Electrum 4.7.2 and 4.8.0** Bitcoin wallet (Qt / **PyQt6**).
- **Form:** external **ZIP plugin** (not bundled in Electrum). The user
installs the ZIP from Electrum's plugin manager.
- **What it does:** lets a wallet owner pre-build, sign and (later) broadcast
@@ -19,7 +19,7 @@
services) can be paid a fee to broadcast the inheritance when due. The owner
periodically proves they are alive ("check-alive"); if the deadline passes,
the inheritance becomes spendable.
- **Current version:** see `bal/VERSION` (last shipped: **0.4.8**).
- **Current version:** see the `"version"` field of `bal/manifest.json` (the single source of truth; read at runtime via `get_version()` in `bal/core/plugin_base.py`).
---
@@ -55,29 +55,41 @@ These are non-negotiable. They come from the owner directly.
```
bal/ <- the plugin package (this is what ships in the ZIP)
__init__.py <- __version__ (one of 4 version files)
VERSION <- plain-text version (one of 4 version files)
manifest.json <- plugin manifest, "version" field (one of 4)
__init__.py <- package docstring (no version here anymore)
manifest.json <- plugin manifest, "version" field (SINGLE SOURCE OF TRUTH for the version)
qt.py <- zipimport shim used when loaded as an external ZIP plugin
cmdline.py <- CLI entry-point shim (Electrum gui_name='cmdline')
core/
plugin_base.py <- __version__ "AUTOMATICALLY GENERATED" (one of 4)
plugin_base.py <- get_version() reads the version from manifest.json (zip-safe)
heirs.py <- HEIRS + transaction building (prepare_lists,
prepare_transactions, buildTransactions). CORE LOGIC.
will.py <- Will/WillItem, validation (check_amounts, check_will),
exceptions (AmountException, WillExpiredException, ...).
willexecutors.py <- remote will-executor services handling.
willexecutors.py <- remote will-executor services handling (is_selected / is_valid,
parallel push/check).
util.py <- locktime parsing/most helpers (timestamps only).
checkalive.py <- resolve_date_to_check, check_alive_expired (GUI-free).
reminders.py <- compute_reminder_offsets, BALCalendar .ics generation (GUI-free).
input_rules.py <- locktime/threshold data models, Raw/Date selector logic (GUI-free).
cli/ <- headless command-line layer (no Qt)
__init__.py <- registers bal_* commands on import
commands.py <- bal_* daemon commands (@plugin_command, async, thin transport)
controller.py <- BalController: headless replica of BalWindow (no Qt)
plugin.py <- CLI Plugin entry point (extends BalPlugin, no Qt hooks)
gui/qt/
common.py <- shared imports; every gui module does
`from .common import *`. Add new shared imports HERE.
dialogs.py <- the big build/sign/broadcast dialog
(BalBuildWillDialog, task_phase1/2), wizard glue.
widgets.py <- WillSettingsWidget + wizard widgets/labels.
window.py <- BalWalletWindow (build_will, check_will, get_transactions).
lists.py, calendar.py, theme.py, window_utils.py, ...
tests/ <- pytest suite (see run command below).
electrum-src/ <- a copy of Electrum source, used ONLY for tests
(PYTHONPATH=electrum-src). NOT shipped in the ZIP.
build_zip.py <- builds the shippable ZIP (37 files).
window.py <- BalWindow, the per-wallet controller (build_will, check_will,
get_transactions, merge_will, on_close, menubar wiring).
plugin.py <- Electrum @hooks entry point (init_qt, tools menu, settings dialog).
lists.py, calendar.py, theme.py (status colours), window_utils.py
wallet_util/ <- standalone wallet-inspection helpers, no Qt
tests/ <- standalone test scripts (see Section 3).
docs/ <- user manual + inheritance-options guide (.md sources).
build_zip.py <- builds the shippable ZIP (36 files).
CHANGELOG.md <- numbered task log (English).
.agent_memory_tasks.md <- terse internal memory notes per task batch.
HANDOFF.md <- this file.
@@ -87,46 +99,73 @@ HANDOFF.md <- this file.
## 3. How to build, test and lint
Run everything from `/home/user/webapp`.
Two separate venvs — using the wrong one is the #1 mistake:
- **Runtime env** (Electrum + PyQt6, has `electrum` importable):
`source "$BAL_HOME/electrum/env/bin/activate"` — an editable
install of the Electrum **4.8.0** checkout at
`$BAL_HOME/electrum`. Use it for anything that imports
`electrum`, runs GUI code, or runs tests. The plugin's `bal/` directory is
symlinked into `electrum/electrum/plugins/bal` (internal-plugin install used
during dev).
- **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 (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:
**Full test suite (expected: 266 passed as of v0.4.8):**
```bash
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
tests/test_core_*.py tests/test_gui_*.py \
tests/test_anticipate_past_locktime.py tests/test_anticipate_manual_locktime.py \
tests/test_group_b_auto_sign.py tests/test_group_c_settings.py \
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
tests/test_group_h_v048.py -q
source "$BAL_HOME/electrum/env/bin/activate"
python3 tests/test_core_heirs.py # core, no Qt needed
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need offscreen
```
Most core tests run offline (no wallet/network). Some files
(`tests/test_group_*.py`, `tests/test_no_willexecutor_karen7.py`,
`parallel_ping_test.py`) exercise will-executor/network flows and need the live
servers — don't rely on them for quick verification.
**Current state of the suite: 427 tests collected.** The offline subset passes
(414 passed) apart from pre-existing failures that are NOT yours to fix without
asking: 13 failures in `tests/test_core_will_invalidate.py` (a `None` fee when a
UTXO has no fee value, `bal/core/will.py:482`) and 1 collection error in
`tests/test_group_i_basic_checkalive.py` (missing
`BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS`).
**Lint (only NEW errors matter; ignore pre-existing noise):**
```bash
ruff check <files> | grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" \
| grep -vE "F401|F403|F405|F841"
./venv/bin/ruff check <files> \
| grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" | grep -vE "F401|F403|F405|F841"
```
Pre-existing, KNOWN-OK ruff noise: `F401/F403/F405` (star-imports via
Ruff is NOT clean repo-wide (hundreds of pre-existing errors in `bal/` and
`tests/`); do NOT run `--fix` wholesale — just avoid adding new violations.
Pre-existing, KNOWN-OK noise: `F401/F403/F405` (star-imports via
`from .common import *`) and 2× `F841` (an unused `e` in two `except` blocks).
Do NOT "fix" these unless asked — they are intentional / out of scope.
**Build the ZIP (always clear caches first so zipimport doesn't ship stale .pyc):**
```bash
find bal -name "__pycache__" -type d -exec rm -rf {} + ; find bal -name "*.pyc" -delete
python3 build_zip.py bal-electrum-plugin-vX.Y.Z.zip # produces 37 files
python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha256; 36 files)
```
**Bump version — there are FOUR files, keep them in sync:**
**Bump version — ONE file only (single source of truth):**
```
bal/core/plugin_base.py -> __version__ = "X.Y.Z" # AUTOMATICALLY GENERATED DO NOT EDIT
bal/__init__.py -> __version__ = "X.Y.Z"
bal/VERSION -> X.Y.Z
bal/manifest.json -> "version": "X.Y.Z",
```
The code reads this at runtime via `get_version()` in `bal/core/plugin_base.py` (exposed as the `BalPlugin.version` property), so there is nothing else to keep in sync. There is no longer a `bal/VERSION` file nor a hardcoded `__version__`.
**IMPORTANT for the owner when testing:** after installing a ZIP, the owner
must **fully restart Electrum** (not just reload the plugin) — Electrum's
`zipimport` caches modules, so a partial reload runs stale code.
**Automated release:** use `./make-release.sh` to run the full release flow
(tests, lint, build, GPG sign, SHA-256, Electrum test pause, Gitea release).
See Section 5 for details.
---
## 4. Key technical knowledge (hard-won — saves you hours)
@@ -225,25 +264,49 @@ must **fully restart Electrum** (not just reload the plugin) — Electrum's
## 5. Git / delivery workflow
- **Branch:** work on `genspark_ai_developer`. Open PRs into `main`.
- **Branch:** work directly on `main` (no PR flow anymore). Push straight to
`origin/main` (Gitea).
- **Commit policy:** ZIP-FIRST — build a test ZIP, let the owner confirm it
works, THEN commit. (This differs from "commit after every change"; the owner
explicitly prefers ZIP-first because they manually test each build.)
- Before opening/updating a PR: `git fetch origin main`, rebase, resolve
conflicts preferring remote `main` unless a local change is essential,
squash local commits into ONE comprehensive commit, push (force if needed),
then create/update the PR and SHARE the PR URL with the owner.
- Before pushing: check `git status`/`git diff`, stage only the intended files
(never secrets), commit with a concise message, then push to `origin/main`.
- **ZIPs are NOT committed** (`.gitignore` excludes `*.zip`). They are
distributed via **GitHub Releases** (`gh release create vX.Y.Z file.zip ...`).
The newest release is the "Latest" and is the owner's convenient download.
- Deliverable ZIPs are ALSO uploaded with the file-wrapper tool so the owner can
download them directly from chat.
- **Auth note:** if `git push` / `gh` fails with "Invalid username or token",
re-run the GitHub environment setup, then retry.
- PR history for this line of work: **#13** (v0.4.7), **#14** (docs/DUST section +
translation), **#15** (v0.4.8). All merged into `main`.
- Releases: latest is **v0.4.8** (asset `bal-electrum-plugin-v0.4.8.zip`);
v0.4.7 kept in history.
distributed via **Gitea Releases** using `make-release.sh`.
- **Release process** (`make-release.sh`):
1. Version bump in `bal/manifest.json` (single source of truth)
2. Clean `__pycache__` and `.pyc` files
3. Run full test suite
4. Lint with ruff (skip if not installed)
5. Build ZIP via `build_zip.py` (deterministic order, SHA-256, manifest check)
6. GPG sign: `.asc` (armor) + `.sig` (binary) with key `A847D004DB91610711CA6A0DFE756706E833E0D1`
7. Export public key as `svatantrya.asc`
8. SHA-256 checksum
9. Interactive pause for Electrum testing (ZIP-FIRST policy)
10. Create Gitea tag, push, create release, upload 5 assets (ZIP + .asc + .sig + .sha256 + svatantrya.asc)
- **Usage:**
```bash
./make-release.sh # read version from bal/manifest.json
./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
```
- **Release assets** (5 files):
- `bal_vX.Y.Z.zip` — the plugin
- `bal_vX.Y.Z.zip.asc` — GPG signature (armor)
- `bal_vX.Y.Z.zip.sig` — GPG signature (binary)
- `bal_vX.Y.Z.zip.sha256` — SHA-256 checksum
- `svatantrya.asc` — signing public key
- **GPG verification instructions** (included in release body):
```bash
gpg --fetch-key https://bitcoin-after.life/svatantrya.asc
gpg --verify bal_vX.Y.Z.zip.asc bal_vX.Y.Z.zip
```
- **Auth note:** if `git push` or Gitea API fails with "invalid credentials",
update `GITEA_TOKEN` env var or `~/.git-credentials`, then retry.
- Older PR history (pre-`main` direct workflow): **#13** (v0.4.7), **#14**
(docs/DUST section + translation), **#15** (v0.4.8), **#4** (v0.6.1 —
manifest.json version); all merged into `main`.
- Releases: latest is **v0.7.0**; v0.6.1, v0.6.0 and v0.5.18 before it; the older
v0.2.x line is kept in history.
---
@@ -272,6 +335,31 @@ must **fully restart Electrum** (not just reload the plugin) — Electrum's
(#7b) "Balance is too low… Skipped" recoloured ORANGE + space fix; Reset button
renamed "Reset to Default Setting". 8 new tests (`test_group_h_v048.py`).
266 tests pass.
- **v0.5.1 — v0.5.10** — BASIC/ADVANCED ("user type") mode work: Windows
settings-dialog flicker fix; Check Alive shown read-only in BASIC; BASIC
builds the will against "now" (`date_to_check = now()`, Check Alive fully
ignored); ADVANCED defaults to RAW (1y/30d); consistent Raw/Date default per
mode; Check Alive red-highlight fixes; clearer "could not build the will"
message; CHECK no longer resets a manual Date/RAW choice.
- **v0.5.11** — Electrum **4.8.0** compatibility (the `json_db.register_dict`
DB-registration API was removed in 4.8; the plugin now supports 4.7.2 and
4.8.0).
- **v0.5.12 — v0.5.18** — Check Alive soft-red highlight removed; short Tor
(.onion) will-executor URLs; KeyError fix on .onion executor actions; skip
.onion executors from download when Electrum is not on Tor; crash fix on a
non-dict welist response; clearer message when the list download
fails/times out over Tor.
- **v0.6.0** — version bump for the official repository release.
- **v0.6.1** — version read from `bal/manifest.json` (single source of truth);
`bal/VERSION` file removed.
- **#47 / #48 (post-v0.6.1)** — `is_selected`/`is_valid` fee bounds (extremes
allowed) and the `merge_will` missing-`date_to_check` crash fix (see
CHANGELOG).
- **v0.7.0** — OP_RETURN heirs; core extraction (checkalive, reminders,
input_rules); RLock pickle fix; `REBUILD_ON_CLOSE`; headless CLI layer
(`bal/cli/`, `bal/cmdline.py`, 30 `bal_*` commands); `AUTO_REBUILD` on new
transactions; `bal_will_autorebuild` CLI command; removed redundant
"Add transaction without willexecutor" from settings dialog.
### Open / suspended / backlog items (see `.agent_memory_tasks.md` for detail)
- **SUSPENDED — "(UTC)" label in the wizard.** The owner asked to show an
@@ -292,13 +380,98 @@ must **fully restart Electrum** (not just reload the plugin) — Electrum's
## 7. How to resume (checklist for the next AI)
1. Read this file, then `CHANGELOG.md` (last entries) and `.agent_memory_tasks.md`.
2. Confirm the environment: `git status`, current branch, `bal/VERSION`.
3. Run the full test suite (Section 3) — expect all green (266 as of v0.4.8).
2. Confirm the environment: `git status`, current branch, and the `"version"` field of `bal/manifest.json`.
3. Run the offline test files (Section 3) — expect the offline subset to pass
(414 passed; the 13 pre-existing failures in `test_core_will_invalidate.py`
and the 1 collection error in `test_group_i_basic_checkalive.py` are NOT
yours to fix without asking).
4. Talk to the owner in **Italian**, write everything else in **English**.
5. For any change: present a PLAN, wait for "OK" (R4), then implement, test,
build a ZIP, let the owner test, and only commit after explicit confirmation.
6. Keep credit usage low: summarize, don't paste big code blocks; batch work.
7. When the owner confirms a ZIP works: commit (ZIP-first), sync with `main`,
squash to one commit, push, open a PR, merge it, then create/refresh a GitHub
**Release** with the ZIP attached (it becomes the owner's "Latest" download).
Always give the owner the PR URL and the Release URL.
7. When the owner confirms a ZIP works: commit (ZIP-first) directly on `main`,
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 (P0P6) 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).

415
PLAN_CMDLINE_PLUGIN.md Normal file
View File

@@ -0,0 +1,415 @@
# Piano: supporto da riga di comando (CLI) per il plugin BAL
> **Stato**: solo piano. Nessun codice viene modificato finché il piano non viene approvato.
>
> **Versione di riferimento**: commit `2221389` (`core: anchor relative locktime/threshold recipes...`), working tree pulito.
---
## 1. Obiettivo
Rendere il plugin **Bitcoin After Life** utilizzabile da riga di comando / daemon
di Electrum, senza GUI Qt, esponendo comandi per:
1. **Willexecutors** — elenco, aggiunta, modifica, selezione, eliminazione, import/export, ping, download lista.
2. **Heirs** — elenco, aggiunta, modifica, eliminazione, import/export.
3. **Impostazioni** — lettura e modifica (`settings set chiave=valore`), reset a default.
4. **Will** — ciclo di vita completo: visualizza stato, check di coerenza, prepara/ricostruisci, firma, import/merge, esporta, invalida, trasmette ai will-executor, verifica lato will-executor (searchtx).
Il tutto riusando **esclusivamente la logica già presente in `bal/core/`** (che è
già GUI-free) e senza importare mai PyQt.
---
## 2. Stato attuale (verificato sul codice)
### 2.1 Meccanica di Electrum (4.8.0, checkout `electrum/`)
Ho verificato sul codice reale (`electrum/commands.py`, `electrum/plugin.py`,
`electrum/daemon.py`, `run_electrum`) i punti che governano i comandi dei plugin:
- **Registrazione comandi**: `@plugin_command(s, plugin_name)` in
`electrum/commands.py:2317`. Un comando plugin:
- è **sempre** un `async def`;
- viene registrato come `bal_<nome_funzione>` su `Commands` (quindi anche nel parser CLI);
- **forza il flag `'n'`** (richiede rete/daemon): *tutti* i comandi plugin richiedono un daemon in esecuzione e NON funzionano con `--offline`;
- alla chiamata inietta `plugin = daemon._plugins.get_plugin('bal')` (riga 2337).
- **Pre-parse CLI** (`run_electrum` riga 425): `Plugins(tmp_config, cmd_only=True)` importa solo l'`__init__.py` di ogni plugin abilitato per registrare i comandi nel parser. In modalità `cmd_only` il filtro `available_for` viene **saltato** (`plugin.py:128`), ma serve `config['plugins.bal.enabled'] is True` (`plugin.py:117`).
- **Daemon** (`daemon.py:626`): `Plugins(self.config, 'cmdline')`. Qui il filtro `available_for` **vale**: il plugin deve dichiarare `"cmdline"`.
- **Caricamento entry-point** (`plugin.py:622`): il daemon importa `electrum.plugins.bal.<gui_name>` con `gui_name='cmdline'`, quindi serve un modulo `bal/cmdline.py` con una classe `Plugin`.
- **Iniezione wallet**: il decorator `@command` (righe 170-194) gestisce i flag:
- `'w'` → risolve e inietta `wallet` da `daemon.get_wallet(wallet_path)` (il wallet deve essere già caricato con `electrum load_wallet`);
- `'p'` → richiede `--password` (o wallet già sbloccato) per le operazioni di firma.
- **Output**: il valore di ritorno del comando viene stampato come JSON da `run_electrum` (righe 626-630); in modalità daemon gli errori `UserFacingException` vengono stampati con exit code 1.
### 2.2 Il plugin (bal v0.6.1)
- `bal/core/` è già GUI-free e contiene tutta la logica riutilizzabile:
- `heirs.py``Heirs` (dict persistito in wallet DB, chiave `"heirs"`), validazione (`validate_heir`, `_validate`), `import_file`/`export_file`, `get_transactions`/`buildTransactions`.
- `willexecutors.py``Willexecutors` (config `bal_willexecutors`, chiave per `chainname`), `get_willexecutors`, `save`, `initialize_willexecutor`, `is_selected`, `is_valid`, `ping_servers_parallel`, `push_transactions_parallel`, `check_transactions_parallel`, `check_transaction`, `download_list`, `get_willexecutors_list_from_json`.
- `will.py``Will` (statiche) e `WillItem` (stato per-tx: `VALID/COMPLETE/PUSHED/CHECKED/...`), `is_will_valid`, `check_will`, `check_willexecutors_and_heirs`, `invalidate_will`, `normalize_will`, `get_min_locktime`, `get_tx_from_any`, `set_check_willexecutor`, `save_valid_transactions_to_history`.
- `plugin_base.py``BalPlugin` (tutte le `BalConfig`: chiavi `bal_*`), `BalTimestamp`, `get_version`, registrazione dei dict `heirs`/`will`/`will_settings` nel wallet DB.
- `checkalive.py``resolve_date_to_check`, `check_alive_expired` (riferimento temporale unico per ogni check).
- `util.py``Util` (locktime, quantità, confronto tx/heirs, `get_available_utxos`, `fix_will_settings_tx_fees`).
- `bal/gui/qt/window.py``BalWindow` contiene i flussi da **replicare in headless** (non riusabile direttamente perché legato a Qt):
- `init_will` (riga 151), `load_willitems`/`save_willitems` (120/129),
- `init_class_variables` (618) e `build_will` (397),
- `build_inheritance_transaction` (678) → il flusso completo "prepara will",
- `sign_transactions` (952), `ask_password_and_sign_transactions` (1084),
- `push_transactions_to_willexecutors` (1164), `broadcast_transactions` (1127),
- `check_transactions_task`/`check_transactions` (1414/1464),
- `export_json_file` (1246), `merge_will` (1264), `merge_will_from_file` (1348), `_load_will_file` (1406),
- `invalidate_will` (917).
- `bal/manifest.json`: `"available_for": ["qt"]`, `"version": "0.6.1"`.
- `build_zip.py`: cammina ricorsivamente su `bal/` (esclude `__pycache__`, `.pyc`), quindi **includerà automaticamente** i nuovi file di `bal/cli/` e `bal/cmdline.py`.
---
## 3. Architettura proposta
```
bal/
__init__.py # MODIFICATO: importa ``from .cli import commands`` (registra i comandi)
cmdline.py # NUOVO: shim zip-safe (come qt.py) che ri-espone Plugin da bal.cli.plugin
cli/
__init__.py # NUOVO
commands.py # NUOVO: tutti i @plugin_command (async), sottili, delegano al controller
controller.py # NUOVO: BalController — facciata headless per-wallet (replica di BalWindow senza Qt)
plugin.py # NUOVO: class Plugin(BalPlugin) — entry-point per il daemon (gui_name='cmdline')
manifest.json # MODIFICATO: available_for = ["qt", "cmdline"]
```
Principi:
- **`bal/cli/` non importa mai Qt** (stessa regola di `bal/core/`). Può importare solo `bal.core`, `electrum.*` e stdlib.
- **`commands.py` = livello di trasporto**: firma `async def bal_x(self, wallet=None, plugin=None, ...)`, valida/parsa argomenti, chiama il controller, ritorna strutture JSON-serializzabili. Zero logica di business.
- **`controller.py` = il cuore**: replica i passi GUI-free di `BalWindow`, ma con errori espressi come eccezioni (i messaggi GUI `show_message`/`show_error` diventano raise/ritorni), e persiste esplicitamente su wallet DB.
- **`plugin.py`** è quasi vuoto: eredita `BalPlugin.__init__` e basta (serve solo perché Electrum istanzi `module.Plugin(self, config, name)`).
- **Nessuna dipendenza nuova** richiesta: `aiohttp`, `dns` e il resto sono già usati da `bal/core`.
### 3.1 Perché i comandi richiedono il daemon
`plugin_command` forza il flag `'n'` in `commands.py:2321-2322`. Conseguenza
architetturale da documentare chiaramente:
```
electrum daemon -d # avvia il daemon (rete + plugin cmdline)
electrum load_wallet # carica/sblocca il wallet
electrum bal_heirs_list # i comandi BAL girano contro il daemon
```
Questa è la stessa limitazione di tutti gli altri plugin con comandi CLI
(es. `swapserver`, `nwc`). Non è aggirabile senza hackare `plugin_command`, che
escludiamo dal piano.
---
## 4. Modifiche ai file esistenti
### 4.1 `bal/manifest.json`
- `"available_for": ["qt", "cmdline"]`.
Nessun cambio di versione necessario per lo sviluppo; la versione si alzerà in
`make-release.sh` come già avviene.
### 4.2 `bal/__init__.py`
- Aggiungere in fondo:
```python
# Registra i comandi CLI (bal_*) appena Electrum importa il pacchetto,
# sia in modalità cmd_only (pre-parse) sia nel daemon.
from . import cli # noqa: F401 (importa bal.cli.commands, che registra i @plugin_command)
```
(oppure `from .cli import commands` esplicito).
- Accortezza: `bal/cli/commands.py` deve essere importabile **senza Qt** e senza
effetti collaterali pesanti, perché viene importato anche nel pre-parse CLI e
all'avvio della GUI.
### 4.3 `build_zip.py`
- Nessuna modifica obbligatoria: il walker include già `cli/` e `cmdline.py`.
- **Opzionale (consigliato)**: aggiungere una stampa di avviso quando l'archivio
contiene sia `cmdline.py` che `qt.py`, e verificare che `manifest.json` abbia
entrambi i valori in `available_for`.
---
## 5. Nuovi file
### 5.1 `bal/cmdline.py` (shim, ~stesso schema di `qt.py`)
Riproduce il pattern zip-safe di `qt.py` (creazione dei package intermedi in
`sys.modules`, import via `importlib.import_module`), ma punta a
`bal.cli.plugin`:
```python
Plugin = _plugin_module.Plugin
```
### 5.2 `bal/cli/plugin.py`
```python
class Plugin(BalPlugin):
def __init__(self, parent, config, name):
BalPlugin.__init__(self, parent, config, name)
```
Niente hook Qt, niente `bal_windows`. Il daemon lo istanzia quando
`get_plugin('bal')` viene chiamato dal wrapper di `plugin_command`.
### 5.3 `bal/cli/controller.py` — `BalController`
Facciata per-wallet che incapsula lo stato e i flussi. Attributi (speculari a
`BalWindow`):
- `plugin` (il `BalPlugin`/`Plugin` iniettato),
- `wallet` (iniettato da Electrum),
- `will_settings` (da `plugin.WILL_SETTINGS.get()` + `Util.fix_will_settings_tx_fees`),
- `heirs` (`Heirs(wallet)` validati),
- `willexecutors` (`Willexecutors.get_willexecutors(plugin)`),
- `willitems` (da `wallet.db.get_dict("will")` → `WillItem(w, wallet=wallet)`),
- `date_to_check` (via `resolve_date_to_check`).
Metodi principali (replicano le funzioni Qt, senza dialoghi):
| Metodo | Replica di (`window.py`) | Note |
|---|---|---|
| `load_willitems()` | 120 | Costruisce i `WillItem` dal dict `will` del wallet DB. |
| `save_willitems()` | 129 | `to_dict()` con `tx` serializzato a stringa, `json.dumps` di prova, scrittura su `wallet.db` + `wallet.save_db()`. |
| `init_class_variables()` | 618 | `date_to_check`, `no_willexecutor`, `willexecutors`, check `check_alive_expired`. |
| `check_will()` | 473 | `Will.is_will_valid(...)`; le eccezioni di dominio vengono propagate al comando. |
| `build_inheritance_transaction()` | 678 | Flusso 1/7→2/7 replicato: `Will.check_amounts`, guardie locktime/willexecutor, `check_will()` e rebuild su `NotCompleteWillException`. Le `show_message/show_error` diventano raise (`UserFacingException` con testo chiaro) oppure ritorni `{"status": "postponed", "invalidation": tx}`. |
| `sign_transactions(password)` | 952 | Firma i `VALID` non completi: fixup input dai willitems padre, `wallet.sign_transaction(tx, password, ignore_warnings=True)`, `set_status("COMPLETE")`, `check_signatures`. |
| `push_transactions_to_willexecutors(force)` | 1164 | `get_willexecutor_transactions` + `push_transactions_parallel` + gestione "already present" con `check_transaction`. Aggiorna `PUSHED/PUSH_FAIL`. |
| `check_transactions()` | 1414 | `check_transactions_parallel` + `set_check_willexecutor(res)` per item. |
| `export_json_file(path)` | 1246 | `write_json_file(path, {wid: wi.to_dict()...})` con `tx` come stringa (formato identico a `_load_will_file`). |
| `merge_will_from_file(path)` | 1348 | `_load_will_file` + `merge_will` (stessa semantica di `window.py:1264`). |
| `_load_will_file(path)` | 1406 | `read_json_file` + `tx_from_any` + `WillItem`. |
| `invalidate_will()` | 917 | `Will.invalidate_will(...)` con `history_label` e `will_locktime`. |
| `fetch_will_executors_list()` / `ping()` | 1491/1771 | `download_list(old, welist_server)` + `ping_servers_parallel`, poi `Willexecutors.save(plugin, ...)`. |
| `apply_settings(cfg_name, value)` | — | Mappa il nome chiave all'attributo `BalConfig` del plugin e fa `set(...)`. |
Regole di persistenza (fondamentali):
- **heirs** → `heirs.save()` (via `__setitem__`/`pop` già implementati) + `wallet.save_db()`.
- **will** → `save_willitems()` + `wallet.save_db()`.
- **willexecutors** → `Willexecutors.save(plugin, willexecutors)` (config, non wallet DB).
- **settings** → `BalConfig.set(...)` (config).
### 5.4 `bal/cli/commands.py` — comandi (tutti `async def` + `@plugin_command`)
Firma standard: `async def bal_x(self, wallet=None, plugin=None, ...)`. Flag:
- `'n'` — imposto automaticamente da `plugin_command` (rete/daemon).
- `'w'` — wallet richiesto e iniettato da Electrum.
- `'p'` — solo per i comandi che firmano (richiede `--password`).
Tutti i comandi costruiscono `controller = BalController(plugin, wallet)` e
ritornano strutture JSON-serializzabili. Elenco completo al §6.
---
## 6. Tabella comandi
Convenzioni:
- `<WALLET>`: wallet caricato nel daemon (non serve passarlo; Electrum usa quello
configurato o `--wallet`).
- Output: `list`/`dict` stampati come JSON; exit 0 su successo, 1 su errore.
- `*` = richiede password (`--password`) se il wallet è cifrato.
### 6.1 Willexecutors
| Comando | Flag | Argomenti | Descrizione / output |
|---|---|---|---|
| `bal_willexecutors_list` | `nw` | — | Elenco `{url: {address, base_fee, status, info, selected, last_update, sort}}` per la chain corrente. |
| `bal_willexecutors_show` | `nw` | `url` | Dettaglio di un singolo will-executor. |
| `bal_willexecutors_add` | `nw` | `url` `address` `base_fee` | Aggiunge/aggiorna un will-executor (via `initialize_willexecutor`), `selected=false` di default. Ritorna il record. |
| `bal_willexecutors_update` | `nw` | `url` `[address]` `[base_fee]` `[info]` `[promo_code]` | Modifica i campi indicati e salva. |
| `bal_willexecutors_select` | `nw` | `url` `value` | `is_selected(we, eval_bool(value))` + salva. |
| `bal_willexecutors_delete` | `nw` | `url` | Rimuove dalla lista e salva. |
| `bal_willexecutors_ping` | `nw` | `[url]` | `ping_servers_parallel` (tutti o uno); aggiorna `status/base_fee/address`; salva. Output: risultati per url. |
| `bal_willexecutors_download` | `nw` | — | `download_list(old, plugin.WELIST_SERVER.get())`; unisce e salva. Output: n. record. |
| `bal_willexecutors_import` | `nw` | `path` | Legge un JSON `{url: record}` (stesso formato di export), `initialize_willexecutor` per record, salva. |
| `bal_willexecutors_export` | `nw` | `path` | Scrive `{url: record}` su file JSON. |
### 6.2 Heirs
| Comando | Flag | Argomenti | Descrizione / output |
|---|---|---|---|
| `bal_heirs_list` | `nw` | — | `{name: [address, amount, locktime, ...]}` (tutte le colonne `HEIR_*`). |
| `bal_heirs_show` | `nw` | `name` | Dettaglio di un singolo heir. |
| `bal_heirs_add` | `nw` | `name` `address` `amount` `locktime` | Valida con `Heirs.validate_heir` (OP_RETURN incluso) e salva. `amount` può essere satoshi o `"50%"`. `locktime` può essere timestamp assoluto o relativo `"30d"`/`"1y"`. |
| `bal_heirs_update` | `nw` | `name` `[address]` `[amount]` `[locktime]` | Modifica i campi indicati (ri-validazione) e salva. |
| `bal_heirs_delete` | `nw` | `name` | `heirs.pop(name)` + `save_db()`. |
| `bal_heirs_import` | `nw` | `path` | `Heirs.import_file(path)` (validazione + merge). |
| `bal_heirs_export` | `nw` | `path` | `Heirs.export_file(path)`. |
### 6.3 Impostazioni
| Comando | Flag | Argomenti | Descrizione / output |
|---|---|---|---|
| `bal_settings_list` | `n` | — | Elenco di tutte le `BalConfig` del plugin: `{chiave: {value, default, name}}` (nome leggibile). |
| `bal_settings_get` | `n` | `key` | Valore corrente di una chiave (`bal_*`). |
| `bal_settings_set` | `n` | `key=value` | Scrive il valore (conversione di tipo: bool/int/str/JSON) via `BalConfig.set(...)`. `bal_will_settings` accetta JSON. |
| `bal_settings_reset` | `n` | `key` | `BalConfig.set(cfg.default)`. |
### 6.4 Will
| Comando | Flag | Argomenti | Descrizione / output |
|---|---|---|---|
| `bal_will_status` | `nw` | — | Per ogni `wid` (txid): locktime, `heirsvalue`, executor, flag di stato (`VALID/COMPLETE/PUSHED/CHECKED/CHECK_FAIL/...`), `sigs_have/sigs_required`, `tx_fees`, executor URL. |
| `bal_will_check` | `nw` | — | `check_will()` (coerenza heirs+executor+fees+locktime, in locale). Ritorna `{"valid": true}` o un errore esplicito (es. `HeirNotFound`, `WillPostponed`, `WillExpired`, `NoHeirs`). |
| `bal_will_prepare` | `nw` | — | Flusso completo `build_inheritance_transaction`: check → rebuild se non coerente → persiste. Output: riepilogo tx nuova/aggiornata per wid. |
| `bal_will_sign` | `nwp` | `[txid]` | Firma i `VALID` non completi (o solo `txid`). Aggiorna `COMPLETE` e `sigs_*`; persiste. Output per txid. |
| `bal_will_broadcast` | `nw` | `[txid]` `force` | `push_transactions_to_willexecutors(force, txids)` parallelo; aggiorna `PUSHED/PUSH_FAIL`. Output: `{url: status}`. |
| `bal_will_export` | `nw` | `path` | `export_json_file(path)`. |
| `bal_will_import_merge` | `nw` | `path` | `merge_will_from_file(path)` (stessa semantica GUI: merge psbt/stati, mai perdere una tx viva). |
| `bal_will_invalidate` | `nw` | — | `Will.invalidate_will(...)`; ritorna la tx di invalidazione (da firmare+trasmettere con i comandi sopra). |
| `bal_will_check_executor` | `nw` | `[txid]` | Verifica lato will-executor: `check_transactions_parallel` (searchtx) per i `VALID+PUSHED` non `CHECKED`; applica `set_check_willexecutor`. Output: `{wid: {url, checked, ok}}`. |
---
## 7. Flusso dati e persistenza
```
CLI (electrum bal_*) Daemon (Electrum 4.8.0)
┌───────────────────────┐ ┌──────────────────────────────────────┐
│ run_electrum │ RPC │ Daemon.run_cmdline │
│ pre-parse cmd_only │ ─────────────► │ plugin_command wrapper │
│ -> importa bal │ jsonrpc │ inietta plugin + wallet │
│ (registra bal_*) │ │ bal/cli/commands.py │
└───────────────────────┘ │ -> BalController(plugin, wallet) │
│ -> bal.core.* │
│ -> wallet.db / config (persist) │
└──────────────────────────────────────┘
```
- **Lettura**: `wallet.db.get_dict("will")` (wills), `Heirs(wallet)` (heirs),
`plugin.WILLEXECUTORS.get()`/`plugin.WILL_SETTINGS.get()` (config).
- **Scrittura**: `save_willitems()` → `wallet.db` + `wallet.save_db()`;
`heirs.save()`; `Willexecutors.save(...)`; `BalConfig.set(...)`.
- **Firma**: `wallet.sign_transaction(tx, password, ignore_warnings=True)` —
idem GUI, quindi compatibile con multisig e wallet cifrati (password via `--password`).
- **Rete**: `Network.get_instance()` già usato da `bal/core/willexecutors.py`
(i comandi `'n'` garantiscono rete attiva).
---
## 8. Errori, exit code, output
- Ritorno `None` → nessun output; `str` → stampato; `dict`/`list` → `json_encode`.
- Errori utente: sollevare `electrum.util.UserFacingException(msg)` → in modalità
daemon viene stampato `msg` con exit 1.
- Errori di dominio BAL (`WillExpiredException`, `WillPostponedException`,
`HeirNotFoundException`, `NoWillExecutorNotPresent`, `CheckAliveError`,
`AmountException`, ...): il controller le converte in `UserFacingException`
con testo in chiaro (riuso dei messaggi già presenti, senza HTML/Qt).
- Convenzione consigliata per comandi che producono più di un risultato:
ritornare un `dict` con chiave `"result"`/`"warnings"` quando servono avvisi
(es. dopo `prepare` con heirs scartati per dust).
---
## 9. Compatibilità Electrum 4.7.2 / 4.8.0
- `plugin_command`, il wrapper `@command` e `daemon._plugins.get_plugin` esistono
in entrambe le versioni (verificati su 4.8.0; usati identici da `swapserver`).
- Il `BalPlugin` già gestisce il cambio API di registrazione dict
(`json_db.register_dict` vs `stored_dict.register_name`): nessun intervento.
- `available_for: ["cmdline"]` è lo stesso meccanismo di `trustedcoin`
(che ha già `cmdline.py` in 4.8.0).
- **Nessun nuovo import Qt** in `bal/cli/`: verificabile in CI con un check
statico su `bal/cli/*.py` e `bal/cmdline.py`.
---
## 10. Build / release
- `python3 build_zip.py` produce `bal-electrum-plugin.zip` con `cli/`, `cmdline.py`
e il manifest aggiornato. Lo zip serve sia per la GUI che per il daemon.
- Il test `external_zip_test.py` andrà esteso (vedi §11) per verificare che il
zip, caricato da Electrum, registri anche i comandi `bal_*`.
- Nessun cambiamento a `make-release.sh` (la versione resta nel manifest).
---
## 11. Piano di test e verifica
### 11.1 Nuovi test standalone (stile repo: `tests/test_*.py` con `if __name__ == "__main__"`)
- `tests/test_cli_commands_registered.py` (runtime env):
- importa `electrum.plugins.bal` con `Plugins(config, cmd_only=True)`;
- asserisce che `known_commands` contenga tutti i nomi `bal_*` della tabella;
- asserisce che ogni funzione sia coroutine e abbia il flag `n`.
- `tests/test_cli_controller.py` (runtime env, offline, senza rete):
- wallet "fake"/temporaneo (pattern di `test_core_heirs.py`);
- CRUD heirs e willexecutors, settings get/set/reset, export/import will
(merge), build will con fixtures note.
- `tests/test_cli_zip.py` (o estensione di `external_zip_test.py`):
- costruisce lo zip, lo carica come `electrum_external_plugins.bal` con
`Plugins(config, 'cmdline')`, asserisce `available_for` include `"cmdline"`
e che `get_plugin('bal')` restituisca il `Plugin` di `bal.cli.plugin`
(nessun import Qt eseguito).
- `tests/test_cli_will_flows.py` (offline, dove possibile):
- prepare → sign → export → merge su un wallet di test con heirs fissi;
- verifica che `wallet.db.get_dict("will")` rifletta COMPLETE/PUSHED dopo
le operazioni che non toccano rete.
### 11.2 Verifica manuale (da documentare nel README/HANDOFF)
```bash
source "$BAL_HOME/electrum/env/bin/activate"
electrum daemon -d
electrum load_wallet
electrum bal_heirs_list
electrum bal_settings_list
electrum bal_will_status
electrum bal_will_prepare
electrum bal_will_sign --password '...' # se wallet cifrato
electrum bal_will_broadcast
electrum bal_will_check_executor
electrum bal_willexecutors_ping
electrum stop
```
### 11.3 Regressione
- `QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py electrum.plugins.bal`
deve continuare a passare (prova che `bal/__init__` + Qt convivono con il
nuovo import di `bal.cli.commands`).
- Eseguire i `test_core_*.py` esistenti (nessuna logica core toccata).
- Ruff: evitare nuove violazioni in `bal/cli/`.
---
## 12. Rischi e decisioni aperte
1. **Daemon obbligatorio** (non `--offline`): imposto da `plugin_command`.
→ Accettato; documentato al §3.1.
2. **Wallet pre-caricato**: i comandi `w` falliscono con "wallet not loaded" se
non si lancia prima `electrum load_wallet`. → Documentare.
3. **`bal/__init__.py` che importa `bal.cli.commands`**: viene eseguito anche
all'avvio della GUI. `commands.py` deve restare leggero (solo definizioni +
import di `electrum.commands` e `bal.core`). Da verificare con `smoke_test.py`.
4. **Doppio caricamento**: se un install è contemporaneamente interno E zip
esterno, la seconda importazione di `commands.py` potrebbe sollevare
"Command name bal_... already exists". Pratica corrente: un solo install;
si può mitigare con un guard `if not getattr(module, '_registered')`.
5. **OP_RETURN heirs** in CLI: gestiti come in GUI (`validate_op_return_hex`,
colonne quantità `"0"`). Da testare.
6. **Persistenza `will_settings`**: oggi letta dalla config globale
(`bal_will_settings`) in `BalWindow.__init__`, non dal wallet DB. Il
controller deve replicare esattamente questo (config), non introdurre una
seconda sorgente.
7. **Multisig**: la firma usa `wallet.sign_transaction` → supportata; il flusso
"merge PSBT" copre la firma parziale. Test dedicato con wallet multisig in
fase di implementazione.
---
## 13. Fasi di implementazione (ordine proposto)
1. `bal/cli/__init__.py`, `bal/cli/plugin.py`, `bal/cmdline.py`, update
`bal/manifest.json` + `bal/__init__.py`.
2. `tests/test_cli_commands_registered.py` + verifica `smoke_test.py`.
3. `bal/cli/controller.py` (read-only: status/list/show) → `commands.py` per
willexecutors/heirs/settings (senza rete).
4. Comandi will: `prepare`, `sign`, `export`, `import_merge`, `invalidate`.
5. Comandi di rete: `ping`, `download`, `broadcast`, `check_executor`.
6. Test zip (`test_cli_zip.py`), estensione `external_zip_test.py`, prova
manuale col daemon, aggiornamento README/HANDOFF.

499
PLAN_QR_TRANSFER.md Normal file
View 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 02).
- 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.

124
README.md
View File

@@ -5,10 +5,11 @@ Free and decentralized **Bitcoin inheritance** support for the
that transfer your funds to your heirs if you stop refreshing them
(dead-man's switch), optionally relayed by will-executor servers.
This repository contains a **behavior-preserving refactor** of the original
plugin. The logic was kept byte-identical wherever possible; only the file
layout was reorganized to cleanly separate **business logic** from the
**PyQt GUI**.
This repository contains a **refactored and extended** version of the original
plugin. The logic was reorganized to cleanly separate **business logic** from the
**PyQt GUI**, and new features have been added including a headless CLI,
auto-rebuild on new transactions, OP_RETURN heirs, and configurable calendar
reminders.
## Repository layout
@@ -16,12 +17,22 @@ layout was reorganized to cleanly separate **business logic** from the
bal/ the installable Electrum plugin package
├── manifest.json plugin metadata (Electrum reads this)
├── qt.py Qt entry-point shim (re-exports Plugin)
├── cmdline.py CLI entry-point shim (re-exports Plugin)
├── core/ GUI-free logic (importable without Qt)
│ ├── util.py
│ ├── plugin_base.py
│ ├── heirs.py
│ ├── will.py
── willexecutors.py
── 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)
│ ├── controller.py headless BalController (replicates BalWindow)
│ └── plugin.py CLI Plugin entry point
├── gui/qt/ PyQt6 presentation layer
│ ├── theme.py status → color mapping
│ ├── common.py shared imports / helpers
@@ -30,18 +41,28 @@ bal/ the installable Electrum plugin package
│ ├── dialogs.py dialog windows
│ ├── lists.py tree/list views
│ ├── window.py per-wallet GUI controller
│ ├── window_utils.py GUI utility helpers
│ └── plugin.py Plugin (Electrum @hooks → GUI)
├── icons/ wallet_util/ LICENSE VERSION README.md
├── icons/ wallet_util/ LICENSE README.md
build_zip.py builds a clean, zipimport-friendly distribution zip
tests/ smoke + external-zip regression tests
```
## Requirements
- **Electrum 4.7.2** — the last stable release exposing `json_db.register_dict`,
which this plugin relies on. Newer versions removed it.
- **Electrum 4.7.2 or 4.8.0** — the plugin detects which wallet-DB
registration API is available (`json_db.register_dict` on 4.7.2,
`stored_dict.register_name` on 4.8.0) and adapts automatically.
- **PyQt6** (bundled with the Electrum desktop GUI).
## Wallet compatibility
BAL currently supports **standard (single-signature) wallets** and
**hardware wallets** supported by Electrum. **Multisig wallets** and
**Electrum TrustedCoin (2FA) wallets** are **not yet supported** — see
[`COMPATIBILITY.md`](COMPATIBILITY.md) for the full compatibility matrix and
current status.
## Installation
### Build the distribution archive
@@ -68,6 +89,67 @@ 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
(1501800 bytes/frame) and ships the default **BAL QR** format already
compressed whenever that is smaller (best-of zlib, flag per frame); the import
flow reviews and sign each transaction
one at a time, then proposes exporting the signed transactions. When
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
exposes `bal_*` commands that replicate the full inheritance cycle.
### Prerequisites
- An **Electrum daemon** running (`electrum daemon -d`)
- A wallet loaded (`electrum load_wallet`)
### Available commands
| Category | Commands |
|----------|----------|
| Settings | `bal_settings_list`, `bal_settings_get`, `bal_settings_set`, `bal_settings_reset` |
| Heirs | `bal_heirs_list`, `bal_heirs_show`, `bal_heirs_add`, `bal_heirs_update`, `bal_heirs_delete`, `bal_heirs_import`, `bal_heirs_export` |
| Will-Executors | `bal_willexecutors_list`, `bal_willexecutors_show`, `bal_willexecutors_add`, `bal_willexecutors_update`, `bal_willexecutors_select`, `bal_willexecutors_delete`, `bal_willexecutors_ping`, `bal_willexecutors_download`, `bal_willexecutors_import`, `bal_willexecutors_export` |
| Will | `bal_will_status`, `bal_will_check`, `bal_will_prepare`, `bal_will_autorebuild`, `bal_will_sign`, `bal_will_broadcast`, `bal_will_export`, `bal_will_import_merge`, `bal_will_invalidate`, `bal_will_check_executor` |
### Example workflow
```bash
electrum daemon -d
electrum load_wallet
electrum bal_heirs_list
electrum bal_will_prepare
electrum bal_will_sign --password '...'
electrum bal_will_broadcast
electrum stop
```
All commands require a running daemon (Electrum's `plugin_command` enforces
this). Wallet-bound commands (`bal_heirs_*`, `bal_will_*`, etc.) require the
wallet to be loaded first. Signing commands require `--password` for encrypted
wallets.
## Inheritance safety: anticipate / postpone
A will transaction is signed with a **fixed, immutable locktime** and then
@@ -76,12 +158,17 @@ to broadcast it (they collect fees). Because the locktime is baked into the
signed transaction, simply changing the delivery time later is **not enough**:
the old, already-signed transaction keeps living on the will-executors.
The plugin handles the two cases as follows (triggered when you press
**Tools → Prepare**):
The plugin handles the cases as follows (triggered when you press
**Prepare** on the **WILL** tab):
* **Anticipate** (new delivery time *earlier* than the signed locktime): the
will is treated as expired and you are asked to **invalidate** the old
transaction on-chain, then rebuild.
* **Anticipate** (new delivery time *earlier* than the signed locktime, still
in the future): a plain **rebuild** the transactions are re-created with
the new, earlier locktime. **No on-chain invalidation and no Bitcoin fee**,
even if the will was already signed/sent: moving the date earlier only makes
the inheritance available *sooner*, so there is no early-execution risk.
* **Expire** (new delivery time now in the **past**): the will is genuinely
expired and you are asked to **invalidate** the old transaction on-chain,
then rebuild.
* **Postpone** (new delivery time *later* than the signed locktime) on a will
that was already **signed and/or pushed**: the previously committed coins
must be invalidated on-chain **first**, otherwise a will-executor could
@@ -112,14 +199,15 @@ state.
## Testing
Run the tests with the **runtime environment** active (see `HANDOFF.md` §3 for
the two venvs and how to activate them):
```bash
# imports + behavior
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
python3 tests/smoke_test.py electrum.plugins.bal
QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py electrum.plugins.bal
# external-zip loading regression
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
python3 tests/external_zip_test.py bal-electrum-plugin.zip
# external-zip loading regression (run after build_zip.py)
QT_QPA_PLATFORM=offscreen python3 tests/external_zip_test.py bal-electrum-plugin.zip
```
## ⚠️ Safety

10
android/.gitignore vendored Normal file
View File

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

153
android/README.md Normal file
View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

Binary file not shown.

View File

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

251
android/gradlew vendored Executable file
View File

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

94
android/gradlew.bat vendored Normal file
View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -12,8 +12,8 @@ servers.
already-signed will is handled safely. Postponing a signed/sent will first
asks you to invalidate the old transaction on-chain (so a will-executor can
never broadcast the earlier-locktime transaction and execute the inheritance
too early), then lets you rebuild and re-send the new one via
**Tools → Prepare**.
too early), then lets you rebuild and re-send the new one via the
**Prepare** button on the **WILL** tab.
- **"Server" column**: the will transaction list shows whether each transaction
is actually stored on the will-executor servers
(`Confirmed on server`, `Sent (not checked)`, `Send failed`,

View File

@@ -1 +0,0 @@
0.5.18

View File

@@ -24,11 +24,18 @@ distinct sub-packages:
lists.py Tree/list views (heirs, preview, will-executors)
window.py BalWindow controller (per-wallet GUI state)
plugin.py Plugin class wiring Electrum @hooks to the GUI
cli/ Headless command-line layer (no Qt)
commands.py The @plugin_command transport layer (registers
the ``bal_*`` commands)
controller.py Headless replica of the Qt flows (later phases)
plugin.py Plugin(BalPlugin) entry point for the daemon
qt.py Thin loader shim re-exporting `Plugin` for Electrum
cmdline.py Thin loader shim re-exporting `Plugin` for the daemon
Electrum discovers the plugin through ``manifest.json`` and loads the GUI
entry point from ``qt.py`` (the shim), which imports the real ``Plugin``
from ``gui.qt.plugin``.
from ``gui.qt.plugin``; the command-line/daemon entry point is ``cmdline.py``
(the shim), which imports ``Plugin`` from ``cli.plugin``.
The plugin supports Electrum 4.7.2 and 4.8.0 with PyQt6. Electrum 4.8.0 removed
``json_db.register_dict`` and replaced it with the path-based
@@ -36,4 +43,89 @@ The plugin supports Electrum 4.7.2 and 4.8.0 with PyQt6. Electrum 4.8.0 removed
available and adapts, so both releases keep working.
"""
__version__ = "0.5.18"
# The plugin version is NOT defined here. It lives only in ``bal/manifest.json``
# (the single source of truth) and is read at runtime via ``get_version()`` in
# ``bal/core/plugin_base.py`` (exposed as the ``BalPlugin.version`` property).
# Keeping a hardcoded ``__version__`` here would just be a stale duplicate.
# --------------------------------------------------------------------------- #
# CLI command registration
# --------------------------------------------------------------------------- #
# Electrum's CLI pre-parse (run_electrum calls ``Plugins(config, cmd_only=True)``)
# only imports the plugin package ``__init__`` to discover its commands.
# Importing ``bal.cli.commands`` here registers every ``bal_*`` command with
# ``electrum.commands`` (``known_commands`` + the ``Commands`` class), so the
# commands become available on the command line and over JSON-RPC without any Qt.
#
# The import must be zip-safe: when the plugin is loaded as an external zip,
# Electrum registers the package under the synthetic name
# ``electrum_external_plugins.bal``, but the module's ``__package__`` is only
# ``bal`` (the zip-internal directory name), which is not present in
# ``sys.modules`` and cannot be used for sub-module imports. We therefore
# resolve the real package name and import through ``importlib`` (the same
# trick as ``qt.py``).
import importlib
import sys as _sys
def _resolve_package_name() -> str:
"""Return the name this package is registered under in ``sys.modules``.
Internal plugins are imported as ``electrum.plugins.bal`` (a normal import,
so ``__package__`` is already correct). External zip plugins are imported
under the synthetic name ``electrum_external_plugins.bal`` with
``__package__`` set to just the zip-internal directory name (``bal``); only
the synthetic name is present in ``sys.modules``.
"""
pkg = __package__ or "bal"
if pkg in _sys.modules:
return pkg
synthetic = "electrum_external_plugins." + __name__
if synthetic in _sys.modules:
return synthetic
return pkg
def _ensure_parent_packages(pkg_name: str) -> None:
"""Backfill missing ancestor packages in ``sys.modules``.
When loaded from a zip as an external plugin, Electrum only executes the
package ``__init__``; the synthetic root package (``electrum_external_plugins``)
may be missing, which would break sub-module imports. We stub it out as a
namespace package so ``importlib`` can still resolve its children (same
helper as ``qt.py``).
"""
parts = pkg_name.split(".")
for i in range(1, len(parts)):
ancestor = ".".join(parts[:i])
if ancestor in _sys.modules:
continue
try:
importlib.import_module(ancestor)
except Exception:
import types
module = types.ModuleType(ancestor)
module.__path__ = [] # mark as a (namespace) package
_sys.modules[ancestor] = module
def _register_cli_commands() -> None:
"""Import ``bal.cli.commands`` so Electrum registers the ``bal_*`` commands.
Guarded so a dual install (internal package AND external zip) cannot
register the same command names twice, which would make
``electrum.commands.plugin_command`` raise
"Command name bal_... already exists".
"""
from electrum import commands as _electrum_commands
if getattr(_electrum_commands, "_bal_cli_commands_registered", False):
return
pkg = _resolve_package_name()
_ensure_parent_packages(pkg)
importlib.import_module(pkg + ".cli.commands")
_electrum_commands._bal_cli_commands_registered = True
_register_cli_commands()

19
bal/cli/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
"""
bal.cli
=======
Headless command-line layer of the Bitcoin After Life (BAL) Electrum plugin.
This sub-package implements the ``"cmdline"`` front-end: it exposes the
plugin's functionality through Electrum ``bal_*`` commands while reusing only
the GUI-free logic from ``bal.core``. Like ``bal.core``, it MUST never import
PyQt or ``electrum.gui``.
* ``bal.cli.commands`` -> the ``@plugin_command`` transport layer
* ``bal.cli.controller`` -> headless replica of the Qt flows (later phases)
* ``bal.cli.plugin`` -> ``Plugin(BalPlugin)`` entry point for the daemon
Electrum discovers the plugin through ``manifest.json`` (``available_for``
includes ``"cmdline"``) and loads the entry point from ``cmdline.py``, a thin
zip-safe shim following the same pattern as ``qt.py``.
"""

424
bal/cli/commands.py Normal file
View File

@@ -0,0 +1,424 @@
"""
bal.cli.commands
================
CLI commands (``bal_*``) for the Bitcoin After Life plugin.
This module is the *transport layer* of the command-line front-end: every
function is a coroutine decorated with ``@plugin_command`` so Electrum exposes
it as ``bal_<name>`` both on the command line and over JSON-RPC. The functions
validate their arguments and delegate the real work to
:mod:`bal.cli.controller` (a headless replica of the Qt flows); this module
never imports Qt.
It must stay lightweight: Electrum imports it during the CLI pre-parse
(``run_electrum`` calls ``Plugins(config, cmd_only=True)``) and on every
GUI/daemon startup, before any wallet or network object exists. The heavy
imports (``bal.core``, the controller) happen lazily inside each command.
Flags (see ``electrum.commands.plugin_command``):
* ``n`` -> requires a running daemon/network (always set for plugins);
* ``w`` -> resolves and injects the wallet from the daemon;
* ``p`` -> requires the wallet password (for signing).
"""
from electrum.commands import plugin_command
from electrum.util import UserFacingException
from .controller import BalController, _user_facing
plugin_name = "bal"
def _controller(plugin, wallet):
"""Build the headless controller, or fail with a clear message."""
if plugin is None:
raise UserFacingException("the bal plugin is not enabled in this daemon")
if wallet is None:
raise UserFacingException("wallet not loaded")
return BalController(plugin, wallet)
def _call(plugin, wallet, method, *args, **kwargs):
controller = _controller(plugin, wallet)
try:
return getattr(controller, method)(*args, **kwargs)
except Exception as e:
raise _user_facing(e) from e
# --------------------------------------------------------------------------- #
# Settings
# --------------------------------------------------------------------------- #
@plugin_command("n", plugin_name)
async def settings_list(self, plugin=None):
"""List all BAL plugin configuration options (key, name and value).
Returns a JSON object mapping every BAL configuration option (``bal_*``)
to an object with ``value``, ``default`` and ``name``.
"""
return _call(plugin, None, "settings_list")
@plugin_command("n", plugin_name)
async def settings_get(self, key, plugin=None):
"""Show the current value of one BAL configuration option.
arg:str:key:The configuration key (e.g. ``bal_tx_fees``).
"""
return _call(plugin, None, "settings_get", key)
@plugin_command("n", plugin_name)
async def settings_set(self, key, value, plugin=None):
"""Set a BAL configuration option (booleans, integers, strings, JSON).
arg:str:key:The configuration key (e.g. ``bal_user_type``).
arg:str:value:The new value; JSON for object-typed keys such as ``bal_will_settings``.
"""
return _call(plugin, None, "settings_set", key, value)
@plugin_command("n", plugin_name)
async def settings_reset(self, key, plugin=None):
"""Reset a BAL configuration option to its default value.
arg:str:key:The configuration key (e.g. ``bal_tx_fees``).
"""
return _call(plugin, None, "settings_reset", key)
# --------------------------------------------------------------------------- #
# Heirs
# --------------------------------------------------------------------------- #
@plugin_command("nw", plugin_name)
async def heirs_list(self, wallet=None, plugin=None):
"""List the heirs of the current wallet.
Returns a JSON object mapping heir names to their ``[address, amount,
locktime]`` values.
"""
return _call(plugin, wallet, "heirs_list")
@plugin_command("nw", plugin_name)
async def heirs_show(self, name, wallet=None, plugin=None):
"""Show the details of a single heir.
arg:str:name:The heir name.
"""
return _call(plugin, wallet, "heirs_show", name)
@plugin_command("nw", plugin_name)
async def heirs_add(self, name, address, amount, locktime=None, wallet=None, plugin=None):
"""Add (or replace) an heir in the current wallet.
arg:str:name:The heir name.
arg:str:address:The destination address (or ``OP_RETURN:<hex>`` for an OP_RETURN heir).
arg:str:amount:The amount in satoshis or a percentage like ``50%%``.
arg:str:locktime:The delivery locktime (absolute timestamp or ``30d``/``1y``); defaults to the will locktime.
"""
return _call(plugin, wallet, "heirs_add", name, address, amount, locktime)
@plugin_command("nw", plugin_name)
async def heirs_update(
self,
name,
address=None,
amount=None,
locktime=None,
wallet=None,
plugin=None,
):
"""Update an existing heir (only the given fields).
arg:str:name:The heir name.
arg:str:address:The new destination address.
arg:str:amount:The new amount in satoshis or a percentage.
arg:str:locktime:The new delivery locktime.
"""
return _call(plugin, wallet, "heirs_update", name, address, amount, locktime)
@plugin_command("nw", plugin_name)
async def heirs_delete(self, names, wallet=None, plugin=None):
"""Delete one or more heirs.
arg:json:names:A JSON array of heir names (e.g. ``["Alice","Bob"]``).
"""
return _call(plugin, wallet, "heirs_delete", names)
@plugin_command("nw", plugin_name)
async def heirs_import(self, path, wallet=None, plugin=None):
"""Import heirs from a JSON file (validated, merged).
arg:str:path:Path to the JSON file.
"""
return _call(plugin, wallet, "heirs_import", path)
@plugin_command("nw", plugin_name)
async def heirs_export(self, path, wallet=None, plugin=None):
"""Export the heirs to a JSON file.
arg:str:path:Destination file path.
"""
return _call(plugin, wallet, "heirs_export", path)
# --------------------------------------------------------------------------- #
# Will-Executors
# --------------------------------------------------------------------------- #
@plugin_command("nw", plugin_name)
async def willexecutors_list(self, wallet=None, plugin=None):
"""List the will-executors for the current network.
Returns a JSON object mapping executor URLs to their records (address,
base_fee, status, info, selected, ...).
"""
return _call(plugin, wallet, "willexecutors_list")
@plugin_command("nw", plugin_name)
async def willexecutors_show(self, url, wallet=None, plugin=None):
"""Show the details of a single will-executor.
arg:str:url:The will-executor URL.
"""
return _call(plugin, wallet, "willexecutors_show", url)
@plugin_command("nw", plugin_name)
async def willexecutors_add(
self,
url,
address="",
base_fee=0,
info=None,
wallet=None,
plugin=None,
):
"""Add a new will-executor (not selected by default).
arg:str:url:The will-executor base URL.
arg:str:address:The executor fee address for this network.
arg:int:base_fee:The executor base fee in satoshis.
arg:str:info:A human-readable description.
"""
return _call(plugin, wallet, "willexecutors_add", url, address, base_fee, info)
@plugin_command("nw", plugin_name)
async def willexecutors_update(
self,
url,
address=None,
base_fee=None,
info=None,
promo_code=None,
rename_to=None,
wallet=None,
plugin=None,
):
"""Update an existing will-executor (only the given fields).
arg:str:url:The will-executor URL to update.
arg:str:address:The new fee address.
arg:int:base_fee:The new base fee in satoshis.
arg:str:info:The new description.
arg:str:promo_code:The new promo code.
arg:str:rename_to:Optionally move the record to a new URL.
"""
return _call(
plugin,
wallet,
"willexecutors_update",
url,
address,
base_fee,
info,
promo_code,
rename_to,
)
@plugin_command("nw", plugin_name)
async def willexecutors_select(
self, url, value=True, wallet=None, plugin=None
):
"""Select (or deselect) a will-executor.
arg:str:url:The will-executor URL.
arg:bool:value:True to select, False to deselect.
"""
return _call(plugin, wallet, "willexecutors_select", [url], value)
@plugin_command("nw", plugin_name)
async def willexecutors_delete(self, urls, wallet=None, plugin=None):
"""Delete one or more will-executors.
arg:json:urls:A JSON array of executor URLs (e.g. ``["https://we.example.com"]``).
"""
return _call(plugin, wallet, "willexecutors_delete", urls)
@plugin_command("nw", plugin_name)
async def willexecutors_ping(self, urls=None, wallet=None, plugin=None):
"""Ping the selected (or the given) will-executor servers.
Updates status/base_fee/address from each server and saves. Returns
``{url: {status, ok}}``.
arg:json:urls:Optional JSON array of URLs to ping; defaults to the selected executors.
"""
return _call(plugin, wallet, "willexecutors_ping", urls)
@plugin_command("nw", plugin_name)
async def willexecutors_download(self, wallet=None, plugin=None):
"""Download the will-executor list from the welist server and merge it.
Returns the number of records downloaded and the new total.
"""
return _call(plugin, wallet, "willexecutors_download")
@plugin_command("nw", plugin_name)
async def willexecutors_import(self, path, wallet=None, plugin=None):
"""Import will-executors from a JSON file (``{url: record}``).
arg:str:path:Path to the JSON file.
"""
return _call(plugin, wallet, "willexecutors_import", path)
@plugin_command("nw", plugin_name)
async def willexecutors_export(self, path, wallet=None, plugin=None):
"""Export the will-executors to a JSON file.
arg:str:path:Destination file path.
"""
return _call(plugin, wallet, "willexecutors_export", path)
# --------------------------------------------------------------------------- #
# Will
# --------------------------------------------------------------------------- #
@plugin_command("nw", plugin_name)
async def will_status(self, wallet=None, plugin=None):
"""Show the current will: per-transaction status, locktime and executors.
Returns a JSON object with a per-txid detail list and global status counts.
"""
return _call(plugin, wallet, "will_status")
@plugin_command("nw", plugin_name)
async def will_check(self, wallet=None, plugin=None):
"""Check the local coherence of the will (heirs, executors, fees, locktime).
Returns ``{"valid": true}`` when coherent, or raises a descriptive error.
"""
return _call(plugin, wallet, "will_check")
@plugin_command("nw", plugin_name)
async def will_prepare(self, wallet=None, plugin=None):
"""Run the full prepare/inheritance flow (check, rebuild, persist).
Returns a JSON object with ``result`` (``coherent``, ``rebuilt``,
``expired``, ``postponed``) and, when needed, the invalidation
transaction to sign and broadcast.
"""
return _call(plugin, wallet, "prepare_will")
@plugin_command("nw", plugin_name)
async def will_autorebuild(self, wallet=None, plugin=None):
"""Run the automatic rebuild flow in one shot (check, rebuild, sign, push).
The same flow the GUI runs automatically on new wallet transactions:
the delivery date is anticipated by one day to orphan the old will on-chain
and, only when the anticipated locktime crosses the Check Alive threshold
(or the threshold is already in the past), an invalidation transaction is
returned instead. Signing needs a passwordless wallet.
Returns a JSON object with ``result``: ``valid``, ``no_heirs``,
``invalidated`` (with ``invalidation_tx``), ``nothing``,
``needs_signing`` or ``rebuilt``.
"""
return _call(plugin, wallet, "auto_rebuild")
@plugin_command("nwp", plugin_name)
async def will_sign(self, txid=None, password=None, wallet=None, plugin=None):
"""Sign the valid, not-yet-complete will transactions (or just one).
Updates the COMPLETE status and the signature counters and persists.
arg:str:txid:Optional transaction id to sign; signs all valid ones when omitted.
"""
txids = [txid] if txid is not None else None
txs = _call(plugin, wallet, "sign_transactions", password, txids)
return {wid: str(tx) for wid, tx in txs.items()}
@plugin_command("nw", plugin_name)
async def will_broadcast(
self, txid=None, force=False, wallet=None, plugin=None
):
"""Send the signed will transactions to their will-executors (in parallel).
Updates the PUSHED/PUSH_FAIL statuses and persists. Returns ``{url: status}``.
arg:str:txid:Optional transaction id to broadcast; all valid+signed ones when omitted.
arg:bool:force:Force re-pushing transactions already marked as PUSHED.
"""
txids = [txid] if txid is not None else None
return _call(plugin, wallet, "push_transactions_to_willexecutors", force, txids)
@plugin_command("nw", plugin_name)
async def will_export(self, path, wallet=None, plugin=None):
"""Export the whole will to a JSON file.
arg:str:path:Destination file path.
"""
return _call(plugin, wallet, "export_will", path)
@plugin_command("nw", plugin_name)
async def will_import_merge(self, path, wallet=None, plugin=None):
"""Merge a will file into the current will (PSBTs and statuses are merged).
arg:str:path:Path to the will JSON file.
"""
return _call(plugin, wallet, "merge_will_from_file", path)
@plugin_command("nw", plugin_name)
async def will_invalidate(self, wallet=None, plugin=None):
"""Build the on-chain invalidation transaction for the current will.
Returns ``{txid, tx}`` (or nulls when there is nothing to invalidate); the
transaction still needs to be signed and broadcast.
"""
return _call(plugin, wallet, "invalidate_will_command")
@plugin_command("nw", plugin_name)
async def will_check_executor(self, txid=None, wallet=None, plugin=None):
"""Ask the will-executors whether they hold our pushed transactions.
Runs the searchtx check in parallel, applies the per-item status and
persists. Returns ``{txid: {url, pushed, checked, check_fail}}``.
arg:str:txid:Optional transaction id to check; checks all pending ones when omitted.
"""
txids = [txid] if txid is not None else None
return _call(plugin, wallet, "check_transactions", txids)

1274
bal/cli/controller.py Normal file

File diff suppressed because it is too large Load Diff

21
bal/cli/plugin.py Normal file
View File

@@ -0,0 +1,21 @@
"""
bal.cli.plugin
==============
The headless (command-line) entry point of the plugin.
:class:`Plugin` subclasses :class:`bal.core.plugin_base.BalPlugin` without
adding any Qt hooks or per-window state. Electrum instantiates this class when
the plugin runs with ``gui_name='cmdline'`` (the daemon loads
``bal/cmdline.py``, which re-exports it), and it is the object injected as
``plugin`` into every ``bal_*`` command by ``electrum.commands.plugin_command``.
"""
from ..core.plugin_base import BalPlugin
class Plugin(BalPlugin):
"""Minimal ``BasePlugin`` subclass for the command-line front-end."""
def __init__(self, parent, config, name):
BalPlugin.__init__(self, parent, config, name)

69
bal/cmdline.py Normal file
View File

@@ -0,0 +1,69 @@
"""
bal.cmdline
===========
Compatibility shim for Electrum's plugin loader (command-line front-end).
Electrum loads a plugin with ``gui_name='cmdline'`` by importing the
``cmdline`` module of the plugin package and looking for a ``Plugin`` class.
The real implementation lives in the ``bal.cli`` sub-package, so this module
re-exports ``Plugin`` from ``bal.cli.plugin``.
Like ``qt.py``, this file is not a one-line relative import because the very
same code may be loaded as an *external* plugin from a ``.zip``, where Electrum
imports the package under the synthetic top-level name
``electrum_external_plugins.bal`` and never registers the intermediate parent
packages. See the module docstring of ``bal.qt`` for the full rationale. The
shim resolves the run-time package name, backfills the missing parents into
``sys.modules`` and imports the real implementation via
:func:`importlib.import_module`.
Unlike ``qt.py``, this module MUST never import PyQt (the daemon loads it in a
headless process).
"""
import importlib
import sys
def _ensure_parent_packages(pkg_name: str) -> None:
"""Make sure every ancestor package of *pkg_name* is in ``sys.modules``.
When loaded from a zip as an external plugin, Electrum only executes the
plugin package ``__init__`` and the ``cmdline`` module. The synthetic root
package (e.g. ``electrum_external_plugins``) and any intermediate packages
may be missing from ``sys.modules``, which breaks relative/absolute
sub-module imports. We backfill them here using this module's own loader
so that ``importlib`` can find sibling sub-packages.
"""
parts = pkg_name.split(".")
# Walk from the top-most ancestor down to (but not including) pkg_name.
for i in range(1, len(parts)):
ancestor = ".".join(parts[:i])
if ancestor in sys.modules:
continue
try:
importlib.import_module(ancestor)
except Exception:
# The synthetic root (e.g. 'electrum_external_plugins') often has no
# real spec. Create a minimal namespace package stub so that the
# import machinery can still resolve its children.
import types
module = types.ModuleType(ancestor)
module.__path__ = [] # mark as a (namespace) package
sys.modules[ancestor] = module
# The package this module belongs to. Could be 'electrum.plugins.bal' (internal)
# or 'electrum_external_plugins.bal' (external zip), depending on how Electrum
# loaded us.
_PKG = __package__ or "bal"
_ensure_parent_packages(_PKG)
# Import the real implementation using the fully-qualified, run-time package
# name so it works regardless of the synthetic prefix Electrum assigned.
_plugin_module = importlib.import_module(_PKG + ".cli.plugin")
Plugin = _plugin_module.Plugin # noqa: F401 (re-exported for Electrum)

1180
bal/core/animated_qr.py Normal file

File diff suppressed because it is too large Load Diff

159
bal/core/checkalive.py Normal file
View File

@@ -0,0 +1,159 @@
"""
bal.core.checkalive
===================
The "Check Alive" policy: the single reference timestamp (``date_to_check``)
against which every will-validity check is evaluated, and the BASIC/ADVANCED
mode rules that decide it.
Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the
postpone/invalidate flow; the decision that it *should* be raised lives here.
"""
from datetime import datetime, timezone
from typing import Any
from .plugin_base import BalTimestamp
class CheckAliveError(Exception):
"""Raised when the "check alive" date is in the past."""
def __init__(self, timestamp_to_check):
self.timestamp_to_check = timestamp_to_check
def __str__(self):
return "Check alive expired please update it: {}".format(
datetime.fromtimestamp(self.timestamp_to_check, tz=timezone.utc).isoformat()
)
def resolve_date_to_check(
is_basic_mode: bool,
will_settings: Any,
now: float | None = None,
built_locktime: float | int | None = None,
) -> float:
"""Return the reference timestamp for every will-validity check.
``date_to_check`` is the single reference timestamp that EVERY downstream
check reads: the build filter, the heir count, ``check_will_expired``,
``check_amounts`` and the locktime-vs-threshold guard.
* BASIC mode: the Check Alive is hidden and NOT editable, so it must never
govern those checks. ``date_to_check`` is set to *now*: every check is
evaluated against the current moment (the Check Alive effectively does not
exist) while the delivery locktime is still fully enforced.
* ADVANCED mode: the user-controlled stored threshold is used as-is. An
ABSOLUTE threshold is returned unchanged; a RELATIVE one (``"30d"``/``"1y"``)
means "N days BEFORE the delivery date" and is resolved against the stored
locktime (matching the date the settings widget displays), so it stays in
lockstep with the built transactions instead of drifting with the clock.
A RELATIVE stored locktime is resolved against the frozen delivery date of
the built will (``built_locktime``, the locktime inside the signed tx) when
one exists: the will's real delivery date is authoritative, and resolving
the relative locktime from *now* would drift ``date_to_check`` past the
frozen tx locktime so an unchanged will wrongly reads as expired (asking to
invalidate) every day. Without a built will the legacy forward-from-now
resolution is kept.
Args:
is_basic_mode: ``True`` for the SIMPLE / BASIC user type.
will_settings: the per-wallet settings dict (``"threshold"`` and
``"locktime"`` keys).
now: overridable clock for tests; defaults to ``datetime.now()``.
built_locktime: the absolute locktime frozen inside the built will's
transactions (``None`` when there is no built will yet).
Returns:
The reference timestamp (float, UNIX seconds).
"""
if is_basic_mode:
return (now if now is not None else datetime.now(tz=timezone.utc).timestamp())
threshold = BalTimestamp(will_settings["threshold"])
# A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery":
# the settings widget resolves it as real_threshold = locktime - N days.
# Resolving it FORWARD from now (BalTimestamp.to_timestamp) turns
# date_to_check into a moving target that disagrees with the fixed
# locktime of the built transactions (and with the date shown in the UI),
# which can wrongly mark the will as expired/postponed. Resolve it
# against the delivery date instead.
if threshold.unit is not None:
locktime_raw = will_settings.get("locktime")
if locktime_raw is None:
# No delivery reference to anchor to: fall back to the legacy
# forward-from-now resolution.
return threshold.to_timestamp()
locktime_dt = BalTimestamp(locktime_raw).to_date(now)
# A RELATIVE stored locktime ("2y") is itself a moving target; when a
# will has already been built, its frozen delivery date (the tx
# locktime) is the authoritative anchor (see docstring).
if BalTimestamp(locktime_raw).unit is not None and built_locktime:
locktime_dt = BalTimestamp(int(built_locktime)).to_date(now)
return threshold.to_date(locktime_dt, reverse=True).timestamp()
return threshold.to_timestamp()
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:
"""True when the Check Alive guard should fire (``CheckAliveError``).
Only ADVANCED mode can be "expired": in BASIC mode the Check Alive is inert
by construction, so a passed check-alive date must never force a postpone or
rewrite of the will.
"""
if is_basic_mode:
return False
current = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
return date_to_check < current

View File

@@ -21,6 +21,8 @@ Will-executor "heirs" are synthetic entries whose key starts with the
``w!ll3x3c"`` marker; they are skipped by most heir comparisons.
"""
import asyncio
import inspect
import math
import random
import re
@@ -31,6 +33,7 @@ from typing import (
Dict,
Optional,
Tuple,
cast,
)
import dns
@@ -56,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:
@@ -65,6 +68,19 @@ if TYPE_CHECKING:
_logger = get_logger(__name__)
def _query_txt_records(url: str) -> Tuple[Any, bool]:
"""Resolve TXT records with DNSSEC validation.
``electrum.dnssec.query`` became an ``async`` function in Electrum 4.7.2,
so adapt the call for this synchronous context while staying compatible
with older synchronous implementations.
"""
query = dnssec.query
if inspect.iscoroutinefunction(query):
return asyncio.run(query(url, dns.rdatatype.TXT))
return cast(Tuple[Any, bool], query(url, dns.rdatatype.TXT))
# Column layout of a stored heir list. These indices are part of the on-disk
# wallet format and are relied upon all over the codebase, so they must NEVER
# be reordered.
@@ -75,6 +91,8 @@ HEIR_REAL_AMOUNT = 3 # resolved amount once percentages are computed
HEIR_DUST_AMOUNT = 4 # amount when below dust threshold (marked "DUST: ...")
TRANSACTION_LABEL = "inheritance transaction"
OP_RETURN_PREFIX = "OP_RETURN:"
class AliasNotFoundException(Exception):
pass
@@ -86,6 +104,27 @@ def reduce_outputs(in_amount, out_amount, fee, outputs):
output.value = math.floor((in_amount - fee) / out_amount * output.value)
def is_op_return_address(address: str) -> bool:
return str(address).startswith(OP_RETURN_PREFIX)
def get_op_return_hex(address: str) -> Optional[str]:
if is_op_return_address(address):
return address[len(OP_RETURN_PREFIX):]
return None
def validate_op_return_hex(data_hex: str) -> None:
try:
data = bytes.fromhex(data_hex)
except ValueError:
raise NotAnAddress(f"OP_RETURN data is not valid hex: {data_hex}") from None
if len(data) > 80:
raise NotAnAddress(
f"OP_RETURN data too long ({len(data)} bytes, max 80)"
)
def create_op_return_script(data_hex: str) -> bytes:
"""Crea scriptpubkey OP_RETURN in bytes"""
data = bytes.fromhex(data_hex)
@@ -132,14 +171,22 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
heir[HEIR_REAL_AMOUNT]
):
try:
real_amount = heir[HEIR_REAL_AMOUNT]
outputs.append(
PartialTxOutput.from_address_and_value(
heir[HEIR_ADDRESS], real_amount
if is_op_return_address(heir[HEIR_ADDRESS]):
data_hex = heir[HEIR_ADDRESS][len(OP_RETURN_PREFIX):]
op_return_script = create_op_return_script(data_hex)
outputs.append(
PartialTxOutput(value=0, scriptpubkey=op_return_script)
)
)
out_amount += real_amount
description += f"{name}\n"
description += f"{name}\n"
else:
real_amount = heir[HEIR_REAL_AMOUNT]
outputs.append(
PartialTxOutput.from_address_and_value(
heir[HEIR_ADDRESS], real_amount
)
)
out_amount += real_amount
description += f"{name}\n"
except BitcoinException as e:
_logger.info("exception decoding output {} - {}".format(type(e), e))
heir[HEIR_REAL_AMOUNT] = e
@@ -174,7 +221,7 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
change = get_change_output(wallet, in_amount, out_amount, fee)
if change:
outputs.append(change)
for i in range(0, 100):
for _ in range(0, 100):
random.shuffle(outputs)
#op_return_text = "Hello Bal!"
@@ -230,6 +277,7 @@ def get_utxos_from_inputs(tx_inputs, tx, utxos):
# TODO calculate de minimum inputs to be invalidated
def invalidate_inheritance_transactions(wallet):
_logger.debug("invalidate tx in heir method")
# listids = []
utxos = {}
dtxs = {}
@@ -249,7 +297,7 @@ def invalidate_inheritance_transactions(wallet):
del dtxs[txid]
utxos = {}
for txid, tx in dtxs.items():
for _, tx in dtxs.items():
get_utxos_from_inputs(tx.inputs(), tx, utxos)
utxos = sorted(utxos.items(), key=lambda item: len(item[1]))
@@ -264,39 +312,6 @@ def invalidate_inheritance_transactions(wallet):
remaining[key] = value
def print_transaction(heirs, tx, locktimes, tx_fees):
jtx = tx.to_json()
print(f"TX: {tx.txid()}\t-\tLocktime: {jtx['locktime']}")
print("---")
for inp in jtx["inputs"]:
print(f"{inp['address']}: {inp['value_sats']}")
print("---")
for out in jtx["outputs"]:
heirname = ""
for key in heirs.keys():
heir = heirs[key]
if heir[HEIR_ADDRESS] == out["address"] and str(heir[HEIR_LOCKTIME]) == str(
jtx["locktime"]
):
heirname = key
print(f"{heirname}\t{out['address']}: {out['value_sats']}")
print()
size = tx.estimated_size()
print(
"fee: {}\texpected: {}\tsize: {}".format(
tx.input_value() - tx.output_value(), size * tx_fees, size
)
)
print()
try:
print(tx.serialize_to_network())
except Exception:
print("impossible to serialize")
print()
def get_change_output(wallet, in_amount, out_amount, fee):
change_amount = int(in_amount - out_amount - fee)
if change_amount > wallet.dust_threshold():
@@ -306,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):
@@ -348,6 +337,10 @@ class Heirs(dict, Logger):
Logger.__init__(self)
self.db = wallet.db
self.wallet = wallet
# Reason code explaining why the last buildTransactions() produced no
# transaction (None when the last build succeeded or never ran). See
# buildTransactions for the list of codes and why they exist.
self.last_build_error = None
d = self.db.get("heirs", {})
try:
self.update(d)
@@ -400,6 +393,9 @@ class Heirs(dict, Logger):
amount = 0
for key, v in heir_list.items():
try:
if is_op_return_address(v[HEIR_ADDRESS]):
heir_list[key].insert(HEIR_REAL_AMOUNT, 0)
continue
column = HEIR_AMOUNT
if real:
column = HEIR_REAL_AMOUNT
@@ -451,6 +447,12 @@ class Heirs(dict, Logger):
)
)
continue
if is_op_return_address(self[key][HEIR_ADDRESS]):
heir = list(self[key])
heir.insert(HEIR_REAL_AMOUNT, 0)
fixed_heirs[key] = heir
_logger.debug(f"OP_RETURN heir {key} excluded from amount calculation")
continue
if Util.is_perc(self[key][HEIR_AMOUNT]):
percent_amount += float(self[key][HEIR_AMOUNT][:-1])
percent_heirs[key] = list(self[key])
@@ -478,7 +480,8 @@ class Heirs(dict, Logger):
)
def prepare_lists(
self, balance, total_fees, wallet, willexecutor=False, from_locktime=0
self, balance, total_fees, wallet, willexecutor: Optional[dict] = None,
from_locktime=0, max_fee=None,
):
if balance<total_fees or balance < wallet.dust_threshold():
raise BalanceTooLowException(balance,wallet.dust_threshold(),total_fees)
@@ -493,8 +496,12 @@ class Heirs(dict, Logger):
if int(Util.int_locktime(locktime)) > int(from_locktime):
try:
base_fee = int(willexecutor["base_fee"])
if max_fee is not None and base_fee > max_fee:
raise WillExecutorFeeTooHighException(
willexecutor, max_fee
)
willexecutors_amount += base_fee
h = [None] * 4
h: list = [None] * 4
h[HEIR_AMOUNT] = base_fee
h[HEIR_REAL_AMOUNT] = base_fee
h[HEIR_LOCKTIME] = locktime
@@ -586,6 +593,8 @@ class Heirs(dict, Logger):
heir[HEIR_REAL_AMOUNT]
):
valid_real_heirs += 1
elif len(heir) > HEIR_REAL_AMOUNT and is_op_return_address(heir[HEIR_ADDRESS]):
valid_real_heirs += 1
if real_heirs > 0 and valid_real_heirs == 0:
raise HeirAmountIsDustException(
"All heirs' shares are below the dust limit"
@@ -599,8 +608,35 @@ class Heirs(dict, Logger):
def buildTransactions(
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
):
Heirs._validate(self)
# Reset the diagnostic reason at the start of every build attempt.
#
# WHY: when the build produced nothing, the GUI used to show a fixed
# list of three "possible reasons" (low balance / dust shares /
# check-alive after the delivery date). In practice the real cause is
# often NONE of those three - several code paths below simply return
# an empty result with no explanation at all, so the user was shown
# three guesses that were all wrong. Each such path now records WHY
# it gave up, and BalBuildWillDialog names the actual cause.
#
# Codes: NO_HEIRS, NO_UTXO, NO_WILLEXECUTOR_USABLE, NO_FUTURE_DATE,
# WILLEXECUTOR_FEE, WILLEXECUTOR_FEE_TOO_HIGH, TX_BUILD_FAILED,
# WILLEXECUTOR_TX_ERROR.
self.last_build_error = None
_before = list(self.keys())
Heirs._validate(self, persist=False)
_removed = [k for k in _before if k not in self]
if _removed:
# The build skips invalid heirs, but they are only dropped in memory
# (persist=False): the wallet still keeps them, so the user can fix
# or remove them deliberately instead of losing them silently.
_logger.warning(
"buildTransactions: skipped %d invalid heir(s) (kept in wallet, "
"not removed): %s",
len(_removed),
", ".join(_removed),
)
if len(self) <= 0:
self.last_build_error = "NO_HEIRS"
_logger.info("while building transactions there was no heirs")
return
balance = 0.0
@@ -612,35 +648,43 @@ class Heirs(dict, Logger):
self.decimal_point = bal_plugin.get_decimal_point()
no_willexecutors = bal_plugin.NO_WILLEXECUTOR.get()
for utxo in utxos:
if utxo.value_sats() > 0 * tx_fees:
if utxo.value_sats() > 0:
balance += utxo.value_sats()
len_utxo_set += 1
available_utxos.append(utxo)
if len_utxo_set == 0:
self.last_build_error = "NO_UTXO"
_logger.info("no usable utxos")
return
j = -2
willexecutorsitems = list(willexecutors.items())
willexecutorslen = len(willexecutorsitems)
alltxs = {}
# Counts how many will-executors were actually PROCESSED (i.e. passed
# the is_selected/is_valid filter below and reached the build loop).
# If it stays 0 the loop silently skipped every single one, which is a
# distinct failure from "we tried and the build failed".
processed_willexecutors = 0
while True:
j += 1
if j >= willexecutorslen:
break
elif 0 <= j:
url, willexecutor = willexecutorsitems[j]
if not Willexecutors.is_selected(willexecutor) or willexecutor["base_fee"] < wallet.dust_threshold():
if not (Willexecutors.is_selected(willexecutor) and Willexecutors.is_valid(willexecutor, max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(), dust=wallet.dust_threshold())):
continue
else:
willexecutor["url"] = url
elif j == -1:
if not no_willexecutors:
continue
url = willexecutor = False
url = willexecutor = None
else:
break
processed_willexecutors += 1
fees = {}
i = 0
txs = {}
while i < 10:
txs = {}
redo = False
@@ -651,9 +695,15 @@ class Heirs(dict, Logger):
# newbalance = balance
try:
locktimes, onlyfixed = self.prepare_lists(
balance, total_fees, wallet, willexecutor, from_locktime
balance, total_fees, wallet, willexecutor, from_locktime,
max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
)
except WillExecutorFeeException:
self.last_build_error = "WILLEXECUTOR_FEE"
i = 10
continue
except WillExecutorFeeTooHighException:
self.last_build_error = "WILLEXECUTOR_FEE_TOO_HIGH"
i = 10
continue
if locktimes:
@@ -662,19 +712,33 @@ class Heirs(dict, Logger):
locktimes, available_utxos[:], fees, wallet
)
if not txs:
self.last_build_error = "TX_BUILD_FAILED"
return {}
except Exception as e:
# An unexpected failure while assembling the
# transactions for THIS will-executor.
#
# WHY THIS CHANGED: the previous code read
# ``e.heirname`` here, in order to auto-deselect the
# will-executor blamed by the exception. NOTHING in
# the plugin sets that attribute any more (it is a
# leftover from an older exception design), so the
# lookup itself raised AttributeError, and the inner
# ``except Exception: raise`` re-raised THAT - aborting
# the whole build with a confusing secondary error
# instead of the real one. We now record the reason,
# log the actual exception together with the
# will-executor it happened on, and simply move on to
# the next one, which is what the original code was
# clearly trying to do.
self.last_build_error = "WILLEXECUTOR_TX_ERROR"
_logger.error(
f"build transactions: error preparing transactions: {e}"
"build transactions: error preparing transactions "
"for will-executor %s: %r",
(willexecutor or {}).get("url", "(none)"),
e,
)
try:
if "w!ll3x3c" in e.heirname:
Willexecutors.is_selected(
e.heirname[len("w!ll3x3c") :], False
)
break
except Exception:
raise e
break
total_fees = 0
total_fees_real = 0
total_in = 0
@@ -698,12 +762,26 @@ class Heirs(dict, Logger):
if i >= 10:
break
else:
self.last_build_error = "NO_FUTURE_DATE"
_logger.info(
f"no locktimes for willexecutor {willexecutor} skipped"
)
break
alltxs.update(txs)
# Every will-executor was skipped by the is_selected/is_valid filter
# (or the list was empty) and no "no will-executor" build was allowed,
# so the loop above never even attempted a build. This path used to
# return silently with no log line at all, which is exactly the case
# the owner hit: the dialog then blamed balance/dust/check-alive, none
# of which was true.
if not alltxs and processed_willexecutors == 0:
self.last_build_error = "NO_WILLEXECUTOR_USABLE"
_logger.info(
"no usable will-executor: all %d skipped (not selected or not valid)",
willexecutorslen,
)
return alltxs
def get_transactions(
@@ -776,7 +854,7 @@ class Heirs(dict, Logger):
# support email-style addresses, per the OA standard
url = url.replace("@", ".")
try:
records, validated = dnssec.query(url, dns.rdatatype.TXT)
records, validated = _query_txt_records(url)
except DNSException as e:
_logger.info(f"Error resolving openalias: {repr(e)}")
return None
@@ -785,60 +863,78 @@ class Heirs(dict, Logger):
string = to_string(record.strings[0], "utf8")
if string.startswith("oa1:" + prefix):
address = cls.find_regex(string, r"recipient_address=([A-Za-z0-9]+)")
if not address:
continue
name = cls.find_regex(string, r"recipient_name=([^;]+)")
if not name:
name = address
if not address:
continue
return address, name, validated
@staticmethod
def find_regex(haystack, needle):
def find_regex(haystack, needle) -> Optional[str]:
regex = re.compile(needle)
try:
return regex.search(haystack).groups()[0]
except AttributeError:
return None
@staticmethod
def validate_address(address):
if is_op_return_address(address):
data_hex = address[len(OP_RETURN_PREFIX):]
validate_op_return_hex(data_hex)
return address
if not bitcoin.is_address(address, net=constants.net):
raise NotAnAddress(f"not an address,{address}")
return address
@staticmethod
def validate_amount(amount):
try:
famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount)
if famount <= 0.00000001:
raise AmountNotValid(f"amount have to be positive {famount} < 0")
except Exception as e:
raise AmountNotValid(f"amount not properly formatted, {e}")
raise AmountNotValid(f"amount not properly formatted, {e}") from e
return amount
@staticmethod
def validate_locktime(locktime, timestamp_to_check=False):
try:
if timestamp_to_check:
if Util.parse_locktime_string(locktime, None) < timestamp_to_check:
raise HeirExpiredException()
except Exception as e:
raise LocktimeNotValid(f"locktime string not properly formatted, {e}")
raise LocktimeNotValid(f"locktime string not properly formatted, {e}") from e
return locktime
@staticmethod
def validate_heir(k, v, timestamp_to_check=False):
address = Heirs.validate_address(v[HEIR_ADDRESS])
amount = Heirs.validate_amount(v[HEIR_AMOUNT])
if is_op_return_address(v[HEIR_ADDRESS]):
amount = "0"
else:
amount = Heirs.validate_amount(v[HEIR_AMOUNT])
locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check)
return (address, amount, locktime)
def _validate(data, timestamp_to_check=False):
@staticmethod
def _validate(data, timestamp_to_check=False, persist=True):
for k, v in list(data.items()):
if k == "heirs":
return Heirs._validate(v, timestamp_to_check)
return Heirs._validate(v, timestamp_to_check, persist)
try:
Heirs.validate_heir(k, v, timestamp_to_check)
except Exception as e:
_logger.info(f"exception heir removed {e}")
data.pop(k)
if persist:
data.pop(k)
else:
# Drop the invalid heir in memory only, so the overridden
# Heirs.pop (which calls save()) is not triggered: a build
# must not silently delete heirs from the wallet.
dict.pop(data, k)
return data
@@ -874,6 +970,19 @@ class WillExecutorFeeException(Exception):
return "WillExecutorFeeException: {} fee:{}".format(
self.willexecutor["url"], self.willexecutor["base_fee"]
)
class WillExecutorFeeTooHighException(Exception):
def __init__(self, willexecutor, max_fee):
self.willexecutor = willexecutor
self.max_fee = max_fee
def __str__(self):
return "WillExecutorFeeTooHighException: {} fee:{} > max:{}".format(
self.willexecutor["url"],
self.willexecutor["base_fee"],
self.max_fee,
)
class BalanceTooLowException(Exception):
def __init__(self,balance, dust_threshold, fees):
self.balance=balance

193
bal/core/input_rules.py Normal file
View File

@@ -0,0 +1,193 @@
"""
bal.core.input_rules
====================
Pure, GUI-free rules for the plugin's text-input widgets: locktime bounds and
acceptance, the RAW locktime sanitisation ("30d"/"1y"), and the percentage-or-
amount field normalisation.
These used to live inside the Qt widget classes in ``bal.gui.qt.widgets``.
Keeping them here makes them testable without Qt and lets any GUI front-end
reuse the exact same parsing rules.
"""
from datetime import datetime
from decimal import Decimal
from typing import Any, Optional, Tuple, Union
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
__all__ = [
"NLOCKTIME_BLOCKHEIGHT_MAX",
"NLOCKTIME_MAX",
"NLOCKTIME_MIN",
"LockTimeEditor",
"normalize_locktime_raw_text",
"normalize_perc_amount_text",
"parse_perc_amount",
"replace_dy_suffixes",
]
class LockTimeEditor:
"""Acceptance bounds shared by the RAW and Date locktime editors.
A Qt widget mixin historically; the pure parts (bounds and acceptance test)
live here so they can be tested and reused without Qt. Widget subclasses
override ``min_allowed_value``/``max_allowed_value`` to tighten the bounds.
"""
min_allowed_value = NLOCKTIME_MIN
max_allowed_value = NLOCKTIME_MAX
alarm = None
def get_value(self) -> Optional[int]:
raise NotImplementedError()
def set_value(self, x: Any, force=True) -> None:
raise NotImplementedError()
@classmethod
def is_acceptable_locktime(cls, x: Any) -> bool:
"""True when ``x`` (string or int) is within the allowed locktime bounds.
An empty/falsy value is accepted (the field is not yet filled in).
"""
if not x: # e.g. empty string
return True
try:
x = int(x)
except Exception as _e:
return False
return cls.min_allowed_value <= x <= cls.max_allowed_value
@staticmethod
def get_max_allowed_timestamp() -> int:
"""Highest locktime timestamp accepted on this platform.
On 32-bit ``time_t`` Windows builds ``datetime.fromtimestamp`` overflows
past 2038, so the ceiling is clamped to INT32_MAX (see #6170).
"""
ts = NLOCKTIME_MAX
# Test if this value is within the valid timestamp limits (which is
# platform-dependent). see #6170
try:
datetime.fromtimestamp(ts)
except (OSError, OverflowError):
ts = 2**31 - 1 # INT32_MAX
datetime.fromtimestamp(ts) # test if raises
return ts
def replace_dy_suffixes(text: str) -> str:
"""Strip the relative-time suffixes (d/y) from ``text``.
Only days ("d") and years ("y") are supported. The block-height suffix
("b") was removed (A1): locktimes are always timestamps now.
"""
return str(text).replace("d", "").replace("y", "")
def _checkbdy(s: str, pos: int, appendix: str) -> Tuple[int, str]:
"""Keep a ``d``/``y`` suffix typed right after an existing suffix.
When the character just before ``pos`` equals ``appendix``, the text is
re-normalised so only one suffix remains.
"""
try:
charpos = pos - 1
charpos = max(0, charpos)
charpos = min(len(s) - 1, charpos)
if appendix == s[charpos]:
s = replace_dy_suffixes(s) + appendix
pos = charpos
except Exception:
pass
return pos, s
def normalize_locktime_raw_text(
text: str, pos: int
) -> Tuple[str, bool, bool, int]:
"""Sanitise the RAW locktime field text.
Only digits plus the day ("d") and year ("y") suffixes are kept; the block
suffix ("b") is removed (A1). Exactly one ``d``/``y`` suffix survives.
Args:
text: the raw field text.
pos: the cursor position within ``text``.
Returns:
``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised
text and ``new_pos`` the adjusted cursor position.
"""
text = text.strip()
chars = "0123456789dy"
pos = len("".join([i for i in text[:pos] if i in chars]))
s = "".join([i for i in text if i in chars])
isdays = False
isyears = False
pos, s = _checkbdy(s, pos, "d")
pos, s = _checkbdy(s, pos, "y")
if "d" in s:
isdays = True
if "y" in s:
isyears = True
if isdays:
s = replace_dy_suffixes(s) + "d"
if isyears:
s = replace_dy_suffixes(s) + "y"
return s, isdays, isyears, pos
def normalize_perc_amount_text(text: str, decimal_point: str) -> Tuple[str, bool]:
"""Sanitise the amount-or-percentage field text.
Keeps digits, ``%`` and the decimal point; a trailing ``%`` marks the value
as a percentage (``is_perc``). At most 8 decimal digits after the point.
Args:
text: the raw field text.
decimal_point: the decimal separator character (``electrum`` uses
``DECIMAL_POINT``, locale dependent).
Returns:
``(clean, is_perc)``.
"""
text = text.strip()
chars = "0123456789%"
chars += decimal_point
s = "".join([i for i in text if i in chars])
if "%" in s:
is_perc = True
s = s.replace("%", "")
else:
is_perc = False
if decimal_point in s:
p = s.find(decimal_point)
s = s.replace(decimal_point, "")
s = s[:p] + decimal_point + s[p : p + 8]
if is_perc:
s += "%"
return s, is_perc
def parse_perc_amount(text: str, decimal_point: str) -> Union[None, Decimal, int]:
"""Parse an amount-or-percentage field text into a numeric value.
Returns ``None`` when the text cannot be parsed.
"""
try:
text = text.replace(decimal_point, ".")
text = text.replace("%", "")
return Decimal(text)
except Exception:
return None

View File

@@ -21,18 +21,60 @@ serialised together with the wallet file.
This module performs **no** GUI work and imports nothing from PyQt / electrum.gui.
"""
import json
import os
import platform
from datetime import date, datetime, timedelta
from datetime import datetime, timedelta, timezone
from electrum import constants, json_db
from electrum.logging import get_logger
from electrum.plugin import BasePlugin
from electrum.transaction import tx_from_any
from electrum.util import classproperty
_logger = get_logger(__name__)
# --------------------------------------------------------------------------- #
# Plugin version - single source of truth
# --------------------------------------------------------------------------- #
# The version lives ONLY in bal/manifest.json (the file Electrum itself reads).
# We used to hardcode it in four files and keep them in sync with a pre-commit
# hook; reading it from the manifest removes that duplication.
#
# importlib.resources is used on purpose: it reads a data file bundled inside
# the ``bal`` package and works identically whether the plugin runs from an
# extracted directory or from INSIDE a zip (Electrum loads external plugins via
# zipimport). It never builds a path by hand, so there is no os.path.join
# backslash issue on Windows inside a zip.
_VERSION_CACHE = None
def get_version():
"""Return the plugin version from ``bal/manifest.json`` (cached).
Zip-safe and independent of the current working directory. Falls back to
``"unknown"`` if the manifest cannot be read, so importing the plugin never
fails just because of version lookup.
"""
global _VERSION_CACHE
if _VERSION_CACHE is None:
try:
import importlib.resources
_parent_pkg = __package__.rpartition(".")[0] if __package__ else "bal"
data = (
importlib.resources.files(_parent_pkg)
.joinpath("manifest.json")
.read_text(encoding="utf-8")
)
_VERSION_CACHE = json.loads(data)["version"]
except Exception as e: # noqa: BLE001 - never break import over version
_logger.error(f"failed to read version from manifest.json: {e}")
_VERSION_CACHE = "unknown"
return _VERSION_CACHE
# --------------------------------------------------------------------------- #
# Wallet-DB registration
# --------------------------------------------------------------------------- #
@@ -67,7 +109,9 @@ def get_will(x):
try:
# Electrum >= 4.8.0
from electrum.stored_dict import register_name as _electrum_register_name
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)."""
@@ -77,7 +121,7 @@ except ImportError:
# Electrum <= 4.7.2
def _register_will_dict(name, method, _type=None):
"""Register a plugin dict in the wallet DB (Electrum <= 4.7.2 API)."""
json_db.register_dict(name, method, _type)
json_db.register_dict(name, method, _type) # pyright: ignore[reportAttributeAccessIssue]
_register_will_dict("heirs", tuple)
@@ -120,9 +164,6 @@ class BalPlugin(BasePlugin):
layer (or unit tests) can use the plugin logic without importing Qt.
"""
_version = None
__version__ = "0.5.18" # AUTOMATICALLY GENERATED DO NOT EDIT
# Command used to open an .ics calendar file, per operating system.
default_app = {
"Linux": "xdg-open",
@@ -131,25 +172,21 @@ class BalPlugin(BasePlugin):
}
# Human-readable chain name ("bitcoin", "testnet", "regtest", ...).
chainname = (
constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin"
)
# Must be a classproperty (not a plain class attribute) because the class
# is defined before constants.net is set to the correct network — a plain
# attribute would capture "bitcoin" and never update.
@classproperty
def chainname(cls):
return constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin"
# Default geometry hint for some dialogs (kept from the original code).
SIZE = (159, 97)
@property
def version(self):
"""Return the plugin version, read once from the ``VERSION`` file."""
if not self._version:
try:
f = ""
with open("{}/VERSION".format(self.plugin_dir), "r") as fi:
f = str(fi.read())
self._version = f.strip()
except Exception as e:
_logger.error(f"failed to get version: {e}")
self._version = "unknown"
return self._version
"""Plugin version, read from ``bal/manifest.json`` (single source of
truth). See :func:`get_version`."""
return get_version()
def __init__(self, parent, config, name):
self.logger = get_logger(__name__)
@@ -197,6 +234,21 @@ class BalPlugin(BasePlugin):
self.PREVIEW = BalConfig(config, "bal_preview", True)
self.SAVE_TXS = BalConfig(config, "bal_save_txs", True)
# SAVE_HISTORY (history persistence): when enabled, the valid will
# transactions are saved into the wallet's LOCAL history (the History
# tab) after every check, each with a configurable label. Default ON.
self.SAVE_HISTORY = BalConfig(config, "bal_save_history", True)
# HISTORY_LABEL: label text applied to the will transactions saved into
# the wallet's local history. May contain the "{willexecutor}" token,
# which is replaced with the will-executor URL of each will item at
# save time.
self.HISTORY_LABEL = BalConfig(
config,
"bal_history_label",
"BitcoinAfterLife inheritance transaction - {willexecutor}",
)
# AUTO_SIGN (Group B / B2): when enabled, pressing "Check" will, after
# querying the will-executor servers, automatically sign the will
# transactions and broadcast them to their will-executors, without the
@@ -205,12 +257,37 @@ class BalPlugin(BasePlugin):
# (handled by BalWindow.get_wallet_password). Default ON.
self.AUTO_SIGN = BalConfig(config, "bal_auto_sign", True)
# REBUILD_ON_CLOSE: when enabled (default), closing the wallet or
# quitting Electrum runs the "Build your will" wizard
# (BalBuildWillDialog) to rebuild and re-validate the will. When
# disabled, on_close() only persists the current in-memory willitems to
# the wallet DB: no rebuild dialog, no auto-sign/broadcast, no
# invalidation prompts at close. Default ON.
self.REBUILD_ON_CLOSE = BalConfig(config, "bal_rebuild_on_close", True)
# AUTO_REBUILD: when enabled, an incoming/outgoing wallet transaction
# automatically re-runs the same rebuild flow the wizard runs at
# wallet close (anticipate the delivery date by one day to orphan the
# previous will; build an on-chain invalidation tx ONLY when the
# anticipated locktime would fall before the check-alive threshold or
# the threshold is already in the past). When disabled (default) the
# will is only rebuilt when the user presses Check / Prepare or closes
# the wallet. Default OFF.
self.AUTO_REBUILD = BalConfig(config, "bal_auto_rebuild", False)
# EDITABLE_DATES (Group C / C2): when enabled, the delivery-time and
# check-alive date fields are editable everywhere (toolbar / Heirs tab),
# not only inside the "Build your will" wizard. Default OFF, so the dates
# 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
@@ -228,6 +305,9 @@ class BalPlugin(BasePlugin):
# follows what is saved in that wallet (the default only applies when no
# value has been stored yet, i.e. new wallets).
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", False)
self.MAX_WILLEXECUTOR_FEE = BalConfig(
config, "bal_max_willexecutor_fee", 500000
)
self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True)
self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)
self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True)
@@ -257,7 +337,10 @@ class BalPlugin(BasePlugin):
config, "bal_event_summary", "BAL -Will execution of $wallet_name"
)
# Default will-executor servers, keyed by network.
# Default will-executor servers, keyed by network. These addresses are
# the ones currently reported by each server's <chain>/info endpoint and
# are refreshed again on ping; testnet/testnet4 must NOT be regtest
# (bcrt1...) addresses, which are invalid on those networks.
self.WILLEXECUTORS = BalConfig(
config,
"bal_willexecutors",
@@ -276,7 +359,7 @@ class BalPlugin(BasePlugin):
"base_fee": 100000,
"status": "New",
"info": "Bitcoin After Life Will Executor",
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
"address": "tb1qp5tmrvtm6dmz23mzkf55n5d53xh39wt0gwpp5m",
"selected": True,
}
},
@@ -285,7 +368,7 @@ class BalPlugin(BasePlugin):
"base_fee": 100000,
"status": "New",
"info": "Bitcoin After Life Will Executor",
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
"address": "tb1qfj5ewmczg8ck2z0eeff6uysdrxd4qdy2ltrx5a",
"selected": True,
}
},
@@ -356,7 +439,7 @@ class BalPlugin(BasePlugin):
"""Fill in any missing will-setting with its default value."""
defaults = BalPlugin.default_will_settings()
if not will_settings:
will_settings = []
will_settings = {}
if int(will_settings.get("baltx_fees", 0)) < 1:
will_settings["baltx_fees"] = defaults['baltx_fees']
if not will_settings.get("threshold"):
@@ -378,7 +461,7 @@ class BalPlugin(BasePlugin):
@staticmethod
def default_will_settings():
"""Default will settings: a fee rate plus absolute threshold/locktime."""
will_settings = {"baltx_fees": 20}
will_settings: dict[str, float] = {"baltx_fees": 20}
will_settings.update(BalPlugin.default_will_settings_absolute())
return will_settings
@@ -386,8 +469,8 @@ class BalPlugin(BasePlugin):
def default_will_settings_absolute():
"""Convert the default relative dates into absolute timestamps (from today)."""
relative_dates = BalPlugin.default_will_settings_relative()
today = date.today()
dt = datetime(today.year, today.month, today.day, 0, 0, 0)
today = datetime.now(tz=timezone.utc).date()
dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc)
threshold = (
dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days())
).timestamp()
@@ -411,10 +494,12 @@ class BalTimestamp:
* an integer -> an absolute UNIX timestamp (``unit is None``)
"""
value = None
unit = None
value: int
unit: str | None
def __init__(self, value):
self.value = 1
self.unit = None
str_value = str(value)
if str_value and str_value[-1].lower() in ("y", "d"):
self.value = int(str_value[:-1])
@@ -443,14 +528,14 @@ class BalTimestamp:
We clamp out-of-range timestamps to INT32_MAX, mirroring Electrum's own
``get_max_allowed_timestamp`` workaround (see Electrum issue #6170).
"""
INT32_MAX = 2 ** 31 - 1
int32_max = 2 ** 31 - 1
try:
return datetime.fromtimestamp(ts)
return datetime.fromtimestamp(ts, tz=timezone.utc)
except (OSError, OverflowError, ValueError):
try:
return datetime.fromtimestamp(min(int(ts), INT32_MAX))
return datetime.fromtimestamp(min(int(ts), int32_max), tz=timezone.utc)
except (OSError, OverflowError, ValueError):
return datetime.fromtimestamp(INT32_MAX)
return datetime.fromtimestamp(int32_max, tz=timezone.utc)
def to_date(self, from_date=None, reverse=False):
"""Resolve to a ``datetime``.
@@ -463,7 +548,7 @@ class BalTimestamp:
return self._safe_fromtimestamp(self.value)
else:
if from_date is None:
from_date = datetime.now()
from_date = datetime.now(tz=timezone.utc)
if isinstance(from_date, (int, float)):
from_date = self._safe_fromtimestamp(from_date)
reverse = 1 if not reverse else -1

304
bal/core/qrtransfer.py Normal file
View File

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

228
bal/core/reminders.py Normal file
View File

@@ -0,0 +1,228 @@
"""
bal.core.reminders
==================
Pure, GUI-free logic for the dead-man's-switch calendar reminders: choosing the
reminder offsets (BASIC vs ADVANCED modes) and rendering them as an RFC-5545
iCalendar (.ics) document.
Everything in this module is stdlib-only, so it can be imported and tested
without Electrum or Qt (e.g. in the lint venv).
"""
import os
import tempfile
from datetime import datetime, timedelta, timezone
from typing import Optional
def compute_reminder_offsets(days, count):
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
Group D / D1. The reminders are spread uniformly across the check-alive
period and always fall *before* the delivery deadline, i.e. every returned
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
Rules:
* ``count`` is the requested number of reminders (the settings dialog
caps it at 5, default 3).
* at most ONE reminder per available day: the effective number is
``min(count, days)``;
* with ``days`` available days, offsets are chosen as evenly spaced
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
= the first day of the period), de-duplicated and returned sorted
descending (earliest reminder first).
Args:
days: number of whole days between check-alive and the deadline.
count: requested number of reminders.
Returns:
A list of integer day-offsets (each ``>= 1``), e.g. ``[30, 16, 1]`` for
``days=30, count=3``. Empty if there is no room for any reminder.
"""
# No room for any reminder (deadline today or already passed).
if days < 1 or count < 1:
return []
# Never more reminders than available days (one per day at most).
effective = min(int(count), int(days))
# A single reminder: put it one day before the deadline.
if effective == 1:
return [1]
# Spread "effective" points evenly inside [1, days]. Using i/(effective-1)
# for i in 0..effective-1 gives fractions 0..1; map them onto [1, days].
# This places the first reminder at the start of the period (offset ~days)
# and the last one one day before the deadline (offset 1).
offsets = set()
for i in range(effective):
frac = i / (effective - 1) # 0.0 .. 1.0
# offset = days at frac 0 (start), 1 at frac 1 (just before deadline).
offset = round(days - frac * (days - 1))
offset = max(1, min(days, offset))
offsets.add(offset)
# Sorted descending: earliest reminder (largest offset) first.
return sorted(offsets, reverse=True)
# Fixed reminder offsets (in days BEFORE the delivery date) used in BASIC mode.
# In BASIC the check-alive parameter is hidden/unmanaged, so reminders cannot be
# spread over it; instead the owner asked for three fixed reminders: 30, 10 and
# 1 day before the inheritance delivery date.
BASIC_REMINDER_OFFSETS = (30, 10, 1)
def basic_reminder_offsets(days_to_deadline):
"""Return the BASIC-mode reminder offsets that still fall in the future.
BASIC mode uses the fixed offsets in ``BASIC_REMINDER_OFFSETS`` (30, 10 and
1 day before the delivery date). Any offset that would land in the past is
dropped, because a reminder before "today" is useless: if the delivery date
is only ``days_to_deadline`` days away, only the offsets that are ``<=
days_to_deadline`` are kept.
Args:
days_to_deadline: whole days from now until the delivery date.
Returns:
A list of integer day-offsets (each ``>= 1``), sorted as in
``BASIC_REMINDER_OFFSETS`` (descending: earliest reminder first). Empty
when the delivery date is less than one day away.
"""
horizon = max(int(days_to_deadline), 0)
return [off for off in BASIC_REMINDER_OFFSETS if 1 <= off <= horizon]
def format_time(time) -> str:
"""Render a datetime as an RFC-5545 UTC timestamp (``YYYYMMDDTHHMMSSZ``)."""
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def fold_ical_line(line: str, limit: int = 75) -> str:
"""Fold a line to at most ``limit`` bytes per RFC-5545, without splitting
multi-byte UTF-8 characters. Continuation lines start with a space."""
encoded = line.encode("utf-8")
parts = []
while len(encoded) > limit:
# cut without splitting a UTF-8 continuation byte
cut = limit
while (encoded[cut] & 0xC0) == 0x80: # byte de continuazione UTF-8
cut -= 1
parts.append(encoded[:cut].decode("utf-8"))
encoded = encoded[cut:]
parts.append(encoded.decode("utf-8"))
return "\r\n ".join(parts)
def ical_escape(text: str) -> str:
"""Escape a string per RFC-5545: backslash, semicolon, comma, newlines."""
text = (
text.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
)
return "\r\n".join(fold_ical_line(line) for line in text.split("\r\n"))
def write_temp_ics(content: str) -> str:
"""Write ``content`` to a temporary ``.ics`` file and return its path."""
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
with os.fdopen(fd, "wb") as f:
f.write(content.encode("utf-8"))
return path
def build_ics_reminders(
*,
locktime: datetime,
basic_mode: bool,
description: str,
summary: str,
wallet_name: str,
heirs_details: str,
version: str,
num_reminders: int = 3,
now: Optional[datetime] = None,
threshold: Optional[datetime] = None,
) -> Optional[str]:
"""Build the ``.ics`` content with one VEVENT per reminder date.
Group D / D1 (revised): N *separate* VEVENTs, one per reminder date, so the
user sees several distinct appointments in their calendar. The reminder
offsets come from :func:`basic_reminder_offsets` (BASIC mode) or
:func:`compute_reminder_offsets` (ADVANCED mode, spread over the check-alive
period).
Args:
locktime: the delivery deadline (datetime).
basic_mode: use the fixed BASIC offsets instead of spreading over the
check-alive period.
description: raw EVENT_DESCRIPTION template; ``$wallet_name`` and
``$heirs_complete`` placeholders are substituted and escaped.
summary: raw EVENT_SUMMARY template; ``$wallet_name`` is substituted.
wallet_name: label used in the UID and template substitutions.
heirs_details: pre-formatted heir list injected into ``description``.
version: plugin version, embedded in the PRODID line.
num_reminders: requested reminder count (ADVANCED mode only).
now: "today" reference; defaults to ``datetime.now()``.
threshold: check-alive date (ADVANCED mode only; required there).
Returns:
The ``.ics`` content string, or ``None`` when no reminder falls in the
future (the delivery date is too close or already passed) so the caller
can show a warning instead of producing an empty-looking file.
"""
now = now if now is not None else datetime.now()
if basic_mode:
days_to_deadline = (locktime - now).days
offsets = basic_reminder_offsets(days_to_deadline)
else:
if threshold is None:
raise ValueError("threshold is required in ADVANCED mode")
days = (locktime - threshold).days
offsets = compute_reminder_offsets(days, num_reminders)
# ToDo #2: no future reminder means there are no events to write. Return
# None so the caller shows a clear warning instead of an empty .ics file.
if not offsets:
return None
event_description = ical_escape(
f"{description}"
.replace("$wallet_name", str(wallet_name))
.replace("$heirs_complete", heirs_details)
)
summary_base = f"{summary}".replace("$wallet_name", str(wallet_name))
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{version}",
]
# One separate VEVENT per reminder offset (its own date in the calendar).
total = len(offsets)
for idx, offset in enumerate(offsets, start=1):
# The visible date of this event: "offset" days before the deadline.
event_dt = format_time(locktime - timedelta(days=offset))
# Suffix the summary so the N events are easy to tell apart.
event_summary = ical_escape(f"{summary_base} (reminder {idx}/{total})")
lines.extend([
"BEGIN:VEVENT",
# Offset in the UID keeps each event unique (no merging).
f"UID:bal-{str(wallet_name)}-{offset}d",
f"DTSTAMP:{format_time(now)}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{event_summary}",
f"DESCRIPTION:{event_description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
lines = [s.rstrip("\r\n") for s in lines]
return "\r\n".join(lines) + "\r\n"

View File

@@ -18,10 +18,14 @@ original implementation.
"""
import bisect
from datetime import datetime, timedelta
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*.
@@ -34,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)."""
@@ -102,7 +141,7 @@ class Util:
except Exception:
pass
try:
now = datetime.now()
now = datetime.now(tz=timezone.utc)
if locktime[-1] == "y":
locktime = str(int(locktime[:-1]) * 365) + "d"
if locktime[-1] == "d":
@@ -130,6 +169,72 @@ class Util:
+ days * 60 * 60 * 24
)
@staticmethod
def _relative_days(value):
"""Duration in days of a relative ``"Nd"``/``"Ny"`` recipe.
Returns ``None`` when the value is not a relative recipe (an absolute
timestamp, a plain number, or garbage).
"""
s = str(value)
if s and s[-1] in "yYdD":
try:
n = int(s[:-1])
except ValueError:
return None
return n * 365 if s[-1] in "yY" else n
return None
@staticmethod
def resolve_locktime_against_tx(current, built, tx_locktime):
"""Resolve a locktime recipe against the moment the signed tx was built.
A RELATIVE recipe stored in the wallet (``"1y"``/``"30d"``) is a moving
target: parsing it against *now* on every check drifts it one day per
day away from the fixed locktime frozen inside the signed Bitcoin
transaction, so an UNCHANGED will is mistaken for a POSTPONE and the
plugin asks to invalidate it every day (reported bug). This resolves
the current recipe against the build moment instead, recovered from the
signed transaction's locktime and the recipe that was actually frozen
at build time (``built``, the value stored in the will item):
build_moment = tx_locktime - duration(built)
expected = build_moment + duration(current)
An unchanged recipe therefore resolves to exactly ``tx_locktime``
(coherent), a lengthened one resolves later (postpone) and a shortened
one earlier (anticipate).
Args:
current: the current locktime recipe (relative or absolute).
built: the recipe frozen at build time (stored in the will item).
tx_locktime: the absolute locktime frozen inside the signed tx.
Returns:
int: the resolved absolute locktime (UNIX timestamp).
"""
current_days = Util._relative_days(current)
built_days = Util._relative_days(built)
if current_days is None:
# Absolute current date: compare directly against the frozen tx.
try:
return int(current)
except Exception:
return Util.parse_locktime_string(current)
if built_days is None or not tx_locktime:
# The stored recipe was absolute (a fixed date) or the tx has no
# usable locktime: there is no relative anchor to recover the build
# moment, so fall back to the legacy forward-from-now resolution.
return Util.parse_locktime_string(current)
try:
base = datetime.fromtimestamp(int(tx_locktime), tz=timezone.utc).replace(
hour=0, minute=0, second=0, microsecond=0
)
build_moment = base - timedelta(days=built_days)
return int((build_moment + timedelta(days=current_days)).timestamp())
except Exception:
return Util.parse_locktime_string(current)
# ------------------------------------------------------------------ #
# Amount helpers
# ------------------------------------------------------------------ #
@@ -373,9 +478,9 @@ class Util:
# On Windows datetime.fromtimestamp raises OverflowError past 2038
# (e.g. NLOCKTIME_MAX); clamp to INT32_MAX (Electrum issue #6170).
try:
dt = datetime.fromtimestamp(locktime)
dt = datetime.fromtimestamp(locktime, tz=timezone.utc)
except (OverflowError, OSError, ValueError):
dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1))
dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1), tz=timezone.utc)
dt -= timedelta(seconds=seconds)
out = dt.timestamp()
@@ -383,34 +488,6 @@ class Util:
out = 1
return out
@staticmethod
def cmp_locktime(locktimea, locktimeb):
"""Compare two relative locktime strings sharing the same unit."""
if locktimea == locktimeb:
return 0
strlocktimea = str(locktimea)
strlocktimeb = str(locktimeb)
if locktimea[-1] in "ydb":
if locktimeb[-1] == locktimea[-1]:
return int(strlocktimea[-1]) - int(strlocktimeb[-1])
else:
return int(locktimea) - (locktimeb)
@staticmethod
def get_lowest_valid_tx(available_utxos, will):
"""Placeholder kept from the original code (sorts the will by locktime)."""
will = sorted(will.items(), key=lambda x: x[1]["tx"].locktime)
for txid, willitem in will.items():
pass
@staticmethod
def get_locktimes(will):
"""Return the distinct locktimes used by the transactions in ``will``."""
locktimes = {}
for txid, willitem in will.items():
locktimes[willitem["tx"].locktime] = True
return locktimes.keys()
@staticmethod
def get_lowest_locktimes(locktimes):
"""Split a list of locktimes into (sorted_timestamps, sorted_blocks)."""
@@ -425,32 +502,6 @@ class Util:
return sorted(sorted_timestamp), sorted(sorted_block)
@staticmethod
def get_lowest_locktimes_from_will(will):
"""Convenience wrapper: lowest locktimes directly from a will dict."""
return Util.get_lowest_locktimes(Util.get_locktimes(will))
@staticmethod
def search_willtx_per_io(will, tx):
"""Find a will entry whose tx has the same inputs/outputs as ``tx``."""
for wid, w in will.items():
if Util.cmp_txs(w["tx"], tx["tx"]):
return wid, w
return None, None
@staticmethod
def invalidate_will(will):
raise Exception("not implemented")
@staticmethod
def get_will_spent_utxos(will):
"""Collect every input spent by any transaction in ``will``."""
utxos = []
for txid, willitem in will.items():
utxos += willitem["tx"].inputs()
return utxos
# ------------------------------------------------------------------ #
# UTXO helpers
# ------------------------------------------------------------------ #
@@ -493,6 +544,106 @@ class Util:
return True
return False
@staticmethod
def get_available_utxos(wallet, history_label, will_locktime=None):
"""Return the wallet's UTXOs as seen by the plugin's flows.
``wallet.get_utxos()`` drops any output that a wallet-LOCAL transaction
marks as spent. The plugin itself creates such local spenders when it
saves an incomplete will transaction into the local history; a *later*
will transaction stored there (a replacement/future will with a locktime
strictly after ``will_locktime``) must not hide the coins from the will
being checked or rebuilt. This view therefore restores those coins.
A local spender is ignored (the coin is kept available) only when ALL of
these hold:
* it is a wallet-local or future transaction (not broadcast),
* its wallet label matches the BAL history label template (after the
"{willexecutor}" substitution),
* the stored spender's locktime is strictly LATER than ``will_locktime``.
Real (broadcast/confirmed) spenders are never ignored. With a falsy
``will_locktime`` this returns ``wallet.get_utxos()`` unchanged.
Args:
wallet: The Electrum wallet object.
history_label: The BAL history label template (may contain
"{willexecutor}").
will_locktime: Reference locktime of the will being operated on.
"""
if not wallet or not will_locktime:
return list(wallet.get_utxos()) if wallet else []
adb = getattr(wallet, "adb", None)
if adb is None or not hasattr(adb, "get_addr_outputs"):
return list(wallet.get_utxos())
addresses = (
wallet.get_addresses() if hasattr(wallet, "get_addresses") else []
)
utxos = []
for addr in addresses:
try:
outputs = adb.get_addr_outputs(addr)
except Exception:
continue
for utxo in outputs.values():
if utxo.spent_height is None:
utxos.append(utxo)
continue
spender = getattr(utxo, "spent_txid", None)
if spender and Util._is_ignorable_local_spender(
wallet, spender, history_label, will_locktime
):
utxos.append(utxo)
return utxos
@staticmethod
def _is_ignorable_local_spender(wallet, spender, history_label, will_locktime):
"""True when the local ``spender`` tx is a later BAL history will tx.
See ``get_available_utxos`` for the exact conditions. Defensive: any
lookup failure makes this return False, so a spender is never ignored
on uncertain data.
"""
adb = wallet.adb
try:
height = int(adb.get_tx_height(spender).height())
except Exception:
return False
if height not in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
return False
try:
label = wallet.get_label_for_txid(spender)
except Exception:
label = None
if not label or not Util._label_matches_history(label, history_label):
return False
try:
stored = adb.db.get_transaction(spender)
except Exception:
return False
if stored is None:
return False
try:
return int(stored.locktime) > int(will_locktime)
except Exception:
return False
@staticmethod
def _label_matches_history(label, history_label):
"""True when ``label`` is the ``history_label`` template with the
"{willexecutor}" token substituted by some (possibly empty) executor URL.
"""
token = "{willexecutor}"
if token in history_label:
prefix, suffix = history_label.split(token, 1)
return (
label.startswith(prefix)
and label.endswith(suffix)
and len(label) >= len(prefix) + len(suffix)
)
return label == history_label
@staticmethod
def cmp_output(outputa, outputb):
"""Two outputs are equal when both address and value match."""

View File

@@ -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 (
@@ -40,10 +40,12 @@ from electrum.transaction import (
tx_from_any,
)
from electrum.util import (
UnrelatedTransactionException,
bfh,
)
from .util import Util
from .heirs import WillExecutorFeeTooHighException
from .util import Util, copy_structure
from .willexecutors import Willexecutors
MIN_LOCKTIME = 1
@@ -72,11 +74,6 @@ class Will:
if not will[child[0]].father:
will[child[0]].father = willid
# return a list of will sorted by locktime
@staticmethod
def get_sorted_will(will):
return sorted(will.items(), key=lambda x: x[1]["tx"].locktime)
@staticmethod
def only_valid(will):
for k, v in will.items():
@@ -105,15 +102,6 @@ class Will:
and not w.get_status("CHECKED")
)
@staticmethod
def search_equal_tx(will, tx, wid):
for w in will:
if w != wid and not tx.to_json() != will[w]["tx"].to_json():
if will[w]["tx"].txid() != tx.txid():
if Util.cmp_txs(will[w]["tx"], tx):
return will[w]["tx"]
return False
@staticmethod
def get_tx_from_any(x):
try:
@@ -155,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:
@@ -177,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(
@@ -219,13 +207,8 @@ class Will:
if ow.we["url"] == nw.we["url"]:
if int(ow.we["base_fee"]) > int(nw.we["base_fee"]):
return anticipate
else:
if int(ow.tx_fees) != int(nw.tx_fees):
return anticipate
else:
ow.tx.locktime
else:
ow.tx.locktime
elif int(ow.tx_fees) != int(nw.tx_fees):
return anticipate
else:
if nw.we == ow.we:
if not Util.cmp_heirs_by_values(ow.heirs, nw.heirs, [0, 3]):
@@ -446,19 +429,25 @@ class Will:
for wid in will:
wtx = will[wid].tx
found = False
inp = None
for inp in wtx.inputs():
if inp.prevout.txid.hex() in will:
found = True
break
if not found:
if not found and inp is not None:
out[inp.prevout.to_str()] = inp
return out
@staticmethod
def invalidate_will(will, wallet, fees_per_byte):
def invalidate_will(will, wallet, fees_per_byte, history_label=None,
will_locktime=None):
_logger.debug("invalidate tx in will module")
will_only_valid = Will.only_valid_list(will)
inputs = Will.get_all_inputs(will_only_valid)
utxos = wallet.get_utxos()
if history_label is not None and will_locktime is not None:
utxos = Util.get_available_utxos(wallet, history_label, will_locktime)
else:
utxos = wallet.get_utxos()
filtered_inputs = []
prevout_to_spend = []
current_height = Util.get_current_height(wallet.network)
@@ -472,11 +461,13 @@ class Will:
utxo_to_spend = []
for utxo in utxos:
if utxo.is_coinbase_output() and utxo.block_height < current_height+100:
_logger.debug("is not mature coinbase output")
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:
change_addresses = wallet.get_change_addresses_for_new_transaction()
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
@@ -508,9 +499,10 @@ class Will:
@staticmethod
def is_new(will):
for wid, w in will.items():
for _wid, w in will.items():
if w.get_status("VALID") and not w.get_status("COMPLETE"):
return True
return False
@staticmethod
def search_rai(all_inputs, all_utxos, will, wallet):
@@ -534,10 +526,26 @@ class Will:
wi.set_status("INVALIDATED", True)
else:
if wallet.db.get_transaction(wi._id):
wi.set_status("CONFIRMED", True)
else:
# The funding outpoint is not part of the will tree:
# decide from whether a broadcast transaction really
# spends it (a wallet-local history copy of the same
# will tx must neither turn the item CONFIRMED nor
# INVALIDATED - it is just a persistence artifact).
stored = None
if wallet and getattr(wallet, "db", None):
try:
stored = wallet.db.get_transaction(wi._id)
except Exception:
stored = None
spender_height = Will._funding_spender_height(wallet, inp)
if spender_height is None:
if stored:
continue
wi.set_status("INVALIDATED", True)
elif spender_height == 0:
wi.set_status("MEMPOOL", True)
else:
wi.set_status("CONFIRMED", True)
for child in wi.search(all_inputs):
if child.tx.locktime < wi.tx.locktime:
@@ -575,14 +583,22 @@ class Will:
for inp in w.tx.inputs():
inp_str = Util.utxo_to_str(inp)
if inp_str not in utxos_list:
if wallet:
height = Will.check_tx_height(w.tx, wallet)
if height < 0:
if not wallet or not getattr(wallet, "adb", None):
continue
height = Will.check_tx_height(w.tx, wallet)
if height < 0:
# The will tx itself is not on-chain. A missing
# funding UTXO is only a real problem when a
# broadcast transaction actually spends it; a
# wallet-local (history) copy of the same will tx
# marks the funding spent locally and must not
# invalidate the will.
if Will._funding_really_spent(wallet, inp_str):
Will.set_invalidate(wid, willtree)
elif height == 0:
w.set_status("MEMPOOL", True)
else:
w.set_status("CONFIRMED", True)
elif height == 0:
w.set_status("MEMPOOL", True)
else:
w.set_status("CONFIRMED", True)
# def reflect_to_children(treeitem):
# if not treeitem.get_status("VALID"):
@@ -598,7 +614,8 @@ class Will:
# Will.reflect_to_children(wc)
@staticmethod
def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust):
def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust,
max_fee=None):
fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = (
heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True)
)
@@ -614,13 +631,92 @@ class Will:
raise PercAmountException(f"Perc amount({perc_amount}) =! 100%")
for url, wex in willexecutors.items():
if Willexecutors.is_selected(wex):
if Willexecutors.is_selected(wex) and Willexecutors.is_valid(wex, max_fee=max_fee, dust=dust):
if max_fee is not None and int(wex["base_fee"]) > max_fee:
raise WillExecutorFeeTooHighException(wex, max_fee)
temp_balance = wallet_balance - int(wex["base_fee"])
if fixed_amount >= temp_balance:
raise FixedAmountException(
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
)
@staticmethod
def _funding_really_spent(wallet, inp_str):
"""True when a broadcast transaction really spends the ``txid:n`` outpoint.
``wallet.adb.get_spender`` discards wallet-local spenders (the stored
will tx from the local history) and future transactions, so this is True
only when the funding was consumed by a real on-chain/mempool tx.
"""
if not wallet or not getattr(wallet, "adb", None):
return False
try:
return wallet.adb.get_spender(inp_str) is not None
except Exception as e:
_logger.error(f"get_spender failed for {inp_str}: {e}")
return False
@staticmethod
def _funding_spender_height(wallet, inp_str):
"""Mined height of the broadcast tx spending ``inp_str``, or None.
Returns ``None`` when no broadcast transaction spends the outpoint (a
wallet-local history spender or a future tx are ignored by
``adb.get_spender``). The height is 0 for a mempool spender and positive
for a confirmed one.
"""
if not wallet or not getattr(wallet, "adb", None):
return None
try:
spender = wallet.adb.get_spender(inp_str)
except Exception as e:
_logger.error(f"get_spender failed for {inp_str}: {e}")
return None
if spender is None:
return None
try:
return int(wallet.adb.get_tx_height(spender).height())
except Exception as e:
_logger.error(f"get_tx_height failed for {spender}: {e}")
return 0
@staticmethod
def _absorb_history_signatures(will, wallet):
"""Merge signatures from the wallet's stored local copy of each will tx.
An incomplete will transaction saved into the local history (see
``save_valid_transactions_to_history``) may later accumulate signatures
(e.g. after a manual merge from a more complete copy). The in-memory
will item would otherwise miss those signatures on the next check. For
every item whose stored wallet copy is the same partial transaction the
signatures are merged into the in-memory one and, if it becomes fully
signed, the item is marked COMPLETE.
This method must never raise: history absorption is a convenience on top
of the will check, so any failure is logged and ignored.
"""
if not wallet or not getattr(wallet, "db", None):
return
for wi in will.values():
try:
if (
wi.tx is None
or not isinstance(wi.tx, PartialTransaction)
or wi.tx.is_complete()
):
continue
stored = wallet.db.get_transaction(wi._id)
if (
not isinstance(stored, Transaction)
or stored.txid() != wi.tx.txid()
):
continue
wi.tx.combine_with_other_psbt(stored)
if wi.tx.is_complete():
wi.set_status("COMPLETE", True)
except Exception as e:
_logger.error(f"absorb history signatures failed for item {wi._id}: {e}")
@staticmethod
def check_will(will, all_utxos, wallet, timestamp_to_check):
"""Validate a will against the current wallet state.
@@ -636,6 +732,7 @@ class Will:
timestamp_to_check: The reference UNIX timestamp (usually "now")
used to decide whether any transaction has expired.
"""
Will._absorb_history_signatures(will, wallet)
Will.add_willtree(will)
utxos_list = Will.utxos_strs(all_utxos)
@@ -649,6 +746,228 @@ class Will:
Will.search_rai(all_inputs, all_utxos, will, wallet)
Will.check_signatures(will, wallet)
@staticmethod
def save_valid_transactions_to_history(will, wallet, history_label):
"""Keep the wallet's LOCAL history in sync with the current will state.
Called after the will has been built/signed/checked (see the
SAVE_HISTORY / HISTORY_LABEL settings). A will transaction belongs in the
local history while it is still "New" (not yet fully signed - i.e. an
incomplete partial transaction), and must be removed once it becomes
"Complete" (fully signed), because at that point it is ready to be
broadcast and will appear in the history on its own.
For every will item that is valid and whose transaction has a txid it:
1. decodes the label template, replacing "{willexecutor}" with the
will-executor URL of the item,
2. if the transaction is NOT complete, stores it via
``wallet.adb.add_transaction`` (merging signatures when an
already-stored partial transaction is upgraded by a more complete
one) and tags it with the decoded label,
3. if the transaction IS complete, does not store it: its matching
local-history entry is removed by the cleanup below.
Finally it deletes every wallet-local transaction whose label exactly
matches the decoded label of a current valid item but that is no longer
among the just-saved transactions, so fully-signed, rebuilt or replaced
wills do not pile up stale entries.
This method must never raise: history persistence is a convenience on
top of the will check, so any failure is logged and ignored.
Args:
will: The will dictionary (WillItem entries keyed by txid).
wallet: The Electrum wallet object (may be falsy for offline
checks, in which case this is a no-op).
history_label: The label template to apply (may contain
"{willexecutor}").
"""
if not wallet or not getattr(wallet, "adb", None):
return
saved_txids = []
try:
current_labels = {
history_label.replace(
"{willexecutor}", (wi.we or {}).get("url", "")
)
for wi in will.values()
if wi.get_status("VALID")
and wi.tx is not None
and wi.tx.txid() is not None
}
for wi in will.values():
if not wi.get_status("VALID"):
continue
if wi.tx is None or wi.tx.txid() is None:
continue
# Fully-signed (complete) transactions must NOT be saved: they
# are removed from the local history so the list does not show a
# placeholder for a transaction that will appear on its own once
# broadcast/confirmed. Only the not-yet-complete "New" items are
# stored. Note that fully-segwit partial txs have a txid even
# when incomplete, so the txid() check alone is not enough.
if wi.tx.is_complete():
continue
try:
txid = wi.tx.txid()
label = history_label.replace(
"{willexecutor}", (wi.we or {}).get("url", "")
)
Will._add_transaction_to_history(wallet, wi.tx, txid)
try:
wallet.set_label(txid, label)
except Exception as e:
_logger.error(f"set_label failed for {txid}: {e}")
saved_txids.append(txid)
except Exception as e:
_logger.error(f"save to history failed for item {wi._id}: {e}")
# Delete stale wallet-local txs whose label matches a current valid
# item but that are no longer among the saved ones. This removes
# entries for fully-signed (complete) items and for rebuilt/replaced
# wills with the same executor.
for txid, label in Will._wallet_labels(wallet):
if txid in saved_txids:
continue
if label not in current_labels:
continue
try:
wallet.adb.remove_transaction(txid)
try:
wallet.set_label(txid, None)
except Exception:
pass
except Exception as e:
_logger.error(f"remove from history failed for {txid}: {e}")
try:
wallet.save_db()
except Exception as e:
_logger.error(f"save_db failed after history update: {e}")
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``.
If a partial transaction with the same txid is already stored and *tx*
carries additional signatures, the signatures are merged into the stored
one before saving. ``allow_unrelated`` is retried as a fallback so that
self-created txs (which are not yet part of the wallet's UTXO set) are
still accepted.
"""
adb = wallet.adb
existing = None
try:
existing = wallet.db.get_transaction(txid)
except Exception:
existing = None
try:
if (
isinstance(existing, PartialTransaction)
and not existing.is_complete()
and isinstance(tx, PartialTransaction)
):
existing.combine_with_other_psbt(tx)
adb.add_transaction(existing)
else:
try:
adb.add_transaction(tx)
except UnrelatedTransactionException:
adb.add_transaction(tx, allow_unrelated=True)
except Exception as e:
raise RuntimeError(f"add_transaction failed for {txid}: {e}") from e
@staticmethod
def _wallet_labels(wallet):
"""Return the wallet's ``(txid, label)`` pairs in a defensive way."""
try:
get_all_labels = wallet.get_all_labels
except AttributeError:
return []
try:
return list(get_all_labels().items())
except Exception as e:
_logger.error(f"get_all_labels failed: {e}")
return []
@staticmethod
def check_signatures(will, wallet=None):
"""Refresh the per-item signature counts and the PARTIALLY_SIGNED status.
The signature counts are derived from the transaction itself via
Electrum's ``signature_count()``, which needs a script descriptor on
each input (attached from the wallet when available). Items that already
carry their own descriptors (e.g. imported/merged partial transactions)
are counted even without a wallet.
An item with at least one signature present but fewer than required is
marked PARTIALLY_SIGNED. Items that are already signed (COMPLETE) or
whose transaction is complete always clear the flag.
"""
for wi in will.values():
try:
if wi.get_status("COMPLETE") or wi.tx is None or wi.tx.is_complete():
wi.set_status("PARTIALLY_SIGNED", False)
continue
if wallet:
wi.tx.add_info_from_wallet(wallet)
if not hasattr(wi.tx, "signature_count"):
continue
have, required = wi.tx.signature_count()
wi.sigs_have = int(have)
wi.sigs_required = int(required)
if required > 1 and 0 < have < required:
wi.set_status("PARTIALLY_SIGNED", True)
else:
wi.set_status("PARTIALLY_SIGNED", False)
except Exception as e:
_logger.error(f"check_signatures failed for item {wi._id}: {e}")
@staticmethod
def get_min_locktime(will,default_value=None):
return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value)
@@ -777,7 +1096,7 @@ class Will:
timestamp_to_check: Reference UNIX timestamp (usually "now").
"""
_logger.info("check if some transaction is expired")
for prevout_str, wid in all_inputs_min_locktime.items():
for _inputs, wid in all_inputs_min_locktime.items():
for w in wid:
if w[1].get_status("VALID"):
locktime = int(wid[0][1].tx.locktime)
@@ -854,8 +1173,6 @@ class Will:
if heir := heirs.get(wheir, None):
if heir[0] == their[0] and heir[1] == their[1]:
# The requested (possibly new) locktime for this heir.
new_locktime = Util.parse_locktime_string(heir[2])
# IMPORTANT: compare against the locktime that is
# actually frozen inside the already-signed Bitcoin
# transaction (w.tx.locktime), NOT against their[2].
@@ -866,6 +1183,16 @@ class Will:
# undetected. w.tx.locktime is immutable once signed
# and is exactly what the will-executors hold.
tx_locktime = int(w.tx.locktime)
# The requested (possibly new) locktime for this heir.
# A RELATIVE recipe ("1y"/"30d") is resolved against
# the moment the signed tx was built, NOT against now:
# re-parsing it from "now" drifts it one day per day
# away from the frozen tx locktime, so an UNCHANGED
# will would be read as a POSTPONE and the plugin
# would ask to invalidate it every day.
new_locktime = Util.resolve_locktime_against_tx(
heir[2], their[2], tx_locktime
)
if new_locktime == tx_locktime:
# Unchanged: this heir is still coherent.
count = heirs_found.get(wheir, 0)
@@ -929,15 +1256,15 @@ 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:
raise NoWillExecutorNotPresent("Backup tx")
for url, we in willexecutors.items():
if Willexecutors.is_selected(we):
if Willexecutors.is_selected(we) and Willexecutors.is_valid(we):
if url not in willexecutors_found:
_logger.debug(f"will-executor: {url} not fount")
raise WillExecutorNotPresent(url)
@@ -974,6 +1301,7 @@ class WillItem(Logger):
"MEMPOOL": ["Mempool", False],
"PUSH_FAIL": ["Push failed", False],
"PUSHED": ["Pushed", False],
"PARTIALLY_SIGNED": ["Partially Signed", False],
"REPLACED": ["Replaced", False],
"RESTORED": ["Restored", False],
"UPDATED": ["Updated", False],
@@ -1032,51 +1360,85 @@ class WillItem(Logger):
self.STATUS["PUSHED"][1] = True
self.STATUS["PUSH_FAIL"][1] = False
if status in ["COMPLETE"]:
self.STATUS["PARTIALLY_SIGNED"][1] = False
return value
def get_status(self, status):
return self.STATUS[status][1]
def __init__(self, w, _id=None, wallet=None):
if isinstance(
w,
WillItem,
):
self.__dict__ = w.__dict__.copy()
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.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,
@@ -1088,6 +1450,10 @@ class WillItem(Logger):
"time": self.time,
"change": self.change,
"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:

View File

@@ -17,13 +17,16 @@ interaction is handled by the Qt layer.
import json
import time
from datetime import datetime
from typing import Any
from aiohttp import ClientResponse
from electrum import bitcoin, constants
from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
from electrum.i18n import _
from electrum.logging import get_logger
from electrum.network import Network
from .plugin_base import BalPlugin
from .plugin_base import BalPlugin, get_version
# Per-request timeout (seconds) for interactive operations (ping / info /
# list download). These fail fast (no retries) so a dead server does not
@@ -109,8 +112,6 @@ def is_tor_active():
return False
chainname = BalPlugin.chainname
class Willexecutors:
@@ -143,19 +144,19 @@ class Willexecutors:
@staticmethod
def save(bal_plugin, willexecutors):
_logger.debug(f"save {willexecutors},{chainname}")
_logger.debug(f"save {willexecutors},{BalPlugin.chainname}")
aw = bal_plugin.WILLEXECUTORS.get()
aw[chainname] = willexecutors
aw[BalPlugin.chainname] = willexecutors
bal_plugin.WILLEXECUTORS.set(aw)
_logger.debug(f"saved: {aw}")
# bal_plugin.WILLEXECUTORS.set(willexecutors)
@staticmethod
def get_willexecutors(
bal_plugin, update=False, bal_window=False, force=False, task=True
bal_plugin, update=False, bal_window: Any = None, force=False, task=True
):
willexecutors = bal_plugin.WILLEXECUTORS.get()
willexecutors = willexecutors.get(chainname, {})
willexecutors = willexecutors.get(BalPlugin.chainname, {})
to_del = []
for w in willexecutors:
if not isinstance(willexecutors[w], dict):
@@ -169,7 +170,7 @@ class Willexecutors:
)
)
del willexecutors[w]
bal = bal_plugin.WILLEXECUTORS.default.get(chainname, {})
bal = bal_plugin.WILLEXECUTORS.default.get(BalPlugin.chainname, {})
for bal_url, bal_executor in bal.items():
if bal_url not in willexecutors:
_logger.debug(f"force add {bal_url} willexecutor")
@@ -214,6 +215,20 @@ class Willexecutors:
willexecutor["selected"] = False
return False
@staticmethod
def is_valid(willexecutor, max_fee=None, dust=None):
if not willexecutor:
return False
address = willexecutor.get("address", "")
if not address or not bitcoin.is_address(address, net=constants.net):
return False
base_fee = int(willexecutor.get("base_fee", 0))
if dust is not None and base_fee < dust:
return False
if max_fee is not None and base_fee > max_fee:
return False
return True
@staticmethod
def get_willexecutor_transactions(will, force=False):
willexecutors = {}
@@ -272,55 +287,48 @@ class Willexecutors:
raise Exception("You are offline.")
_logger.debug(f"<-- {method} {url} {data}")
headers = {}
headers["user-agent"] = f"BalPlugin v:{BalPlugin.__version__}"
headers["user-agent"] = f"BalPlugin v:{get_version()}"
headers["Content-Type"] = "text/plain"
if not handle_response:
handle_response = Willexecutors.handle_response
try:
if method == "get":
response = Network.send_http_on_proxy(
method,
url,
params=data,
headers=headers,
on_finish=handle_response,
timeout=timeout,
)
elif method == "post":
response = Network.send_http_on_proxy(
method,
url,
body=data,
headers=headers,
on_finish=handle_response,
timeout=timeout,
)
else:
raise Exception(f"unexpected {method=!r}")
except TimeoutError:
if count_reply < max_retries:
_logger.debug(
f"timeout({count_reply}) error: retry in {retry_sleep} sec..."
)
if retry_sleep:
time.sleep(retry_sleep)
return Willexecutors.send_request(
method,
url,
data,
timeout=timeout,
handle_response=handle_response,
count_reply=count_reply + 1,
max_retries=max_retries,
retry_sleep=retry_sleep,
)
else:
_logger.debug(f"Too many timeouts: {count_reply}")
except Exception as e:
raise e
else:
_logger.debug(f"--> {response}")
return response
attempts = max_retries + 1
for attempt in range(attempts):
try:
if method == "get":
response = Network.send_http_on_proxy(
method,
url,
params=data,
headers=headers,
on_finish=handle_response,
timeout=timeout,
)
elif method == "post":
response = Network.send_http_on_proxy(
method,
url,
body=data,
headers=headers,
on_finish=handle_response,
timeout=timeout,
)
else:
raise Exception(f"unexpected {method=!r}")
_logger.debug(f"--> {response}")
return response
except TimeoutError:
if attempt < max_retries:
_logger.debug(
f"timeout({attempt}) error: "
f"retry in {retry_sleep} sec..."
)
if retry_sleep:
time.sleep(retry_sleep)
else:
_logger.debug(f"Too many timeouts: {attempt}")
except Exception as e:
raise e
return None
@staticmethod
def get_we_url_from_response(resp):
@@ -331,15 +339,11 @@ class Willexecutors:
@staticmethod
async def handle_response(resp: ClientResponse):
resp.raise_for_status()
r = await resp.text()
try:
r = json.loads(r)
# url = Willexecutors.get_we_url_from_response(resp)
# r["url"]= url
# r["status"]=resp.status
except Exception as e:
_logger.debug(f"error handling response:{e}")
except json.JSONDecodeError:
pass
return r
@@ -362,23 +366,25 @@ class Willexecutors:
_logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}")
if w := Willexecutors.send_request(
"post",
willexecutor["url"] + "/" + chainname + "/pushtxs",
willexecutor["url"] + "/" + BalPlugin.chainname + "/pushtxs",
data=willexecutor["txs"].encode("ascii"),
timeout=timeout,
max_retries=max_retries,
retry_sleep=retry_sleep,
):
willexecutor["broadcast_status"] = _("Success")
_logger.debug(f"pushed: {w}")
if w != "thx":
_logger.debug(f"error: {w}")
raise Exception(w)
willexecutor["broadcast_status"] = _("Success")
else:
raise Exception("empty reply from:{willexecutor['url']}")
raise Exception(
f"empty reply from:{willexecutor['url']}"
)
except Exception as e:
_logger.debug(f"error:{e}")
if str(e) == "already present":
raise Willexecutors.AlreadyPresentException()
raise Willexecutors.AlreadyPresentException() from None
out = False
willexecutor["broadcast_status"] = _("Failed")
@@ -400,15 +406,38 @@ class Willexecutors:
# single short timeout instead of retrying 10x with sleeps, which
# used to freeze the UI for minutes per unreachable server.
w = Willexecutors.send_request(
"get", url + "/" + chainname + "/info",
"get", url + "/" + BalPlugin.chainname + "/info",
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
)
if isinstance(w, dict):
willexecutor["url"] = url
willexecutor["status"] = 200
willexecutor["base_fee"] = w["base_fee"]
willexecutor["address"] = w["address"]
willexecutor["info"] = w["info"]
address = w.get("address")
if not isinstance(address, str) or not bitcoin.is_address(
address, net=constants.net
):
_logger.warning(
f"invalid address from {url}: {address!r}"
)
willexecutor["status"] = "KO"
else:
base_fee = w.get("base_fee")
try:
base_fee = int(base_fee or 0)
if base_fee < 0:
raise ValueError("negative fee")
if base_fee > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN:
raise ValueError("fee exceeds total coin supply")
except (TypeError, ValueError) as e:
_logger.warning(
f"invalid base_fee from {url}: "
f"{w.get('base_fee')!r} ({e})"
)
willexecutor["status"] = "KO"
else:
willexecutor["url"] = url
willexecutor["status"] = 200
willexecutor["base_fee"] = base_fee
willexecutor["address"] = address
willexecutor["info"] = w.get("info", "")
else:
# No dict reply (timeout / empty) -> mark as unreachable.
willexecutor["status"] = "KO"
@@ -451,8 +480,7 @@ class Willexecutors:
Returns:
The same ``willexecutors`` mapping, updated in place.
"""
from concurrent.futures import ThreadPoolExecutor, wait
from concurrent.futures import FIRST_COMPLETED
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
items = list(willexecutors.items())
if not items:
@@ -532,8 +560,7 @@ class Willexecutors:
Returns ``{url: (ok, exception_or_None)}`` for the servers that
answered in time (timed-out servers are reported via ``on_timeout``).
"""
from concurrent.futures import ThreadPoolExecutor, wait
from concurrent.futures import FIRST_COMPLETED
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
targets = [(url, we) for url, we in willexecutors.items() if "txs" in we]
results = {}
@@ -650,8 +677,7 @@ class Willexecutors:
Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers
that answered in time.
"""
from concurrent.futures import ThreadPoolExecutor, wait
from concurrent.futures import FIRST_COMPLETED
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
targets = [(wid, url) for wid, url in items if url]
results = {}
@@ -742,7 +768,14 @@ class Willexecutors:
else:
willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko"))
willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False)
willexecutor["address"]=old_willexecutor.get("address",willexecutor.get("address",""))
address = old_willexecutor.get("address", willexecutor.get("address", ""))
if address and not bitcoin.is_address(address, net=constants.net):
_logger.warning(
f"invalid address {address!r} for executor {url}, "
f"falling back to empty"
)
address = ""
willexecutor["address"] = address
willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code"))
@@ -753,16 +786,25 @@ class Willexecutors:
welist_server = welist_server if welist_server[-1] == '/' else welist_server+'/'
willexecutors = Willexecutors.send_request(
"get",
f"{welist_server}data/{chainname}?page=0&limit=100",
f"{welist_server}data/{BalPlugin.chainname}?page=0&limit=100",
)
# del willexecutors["status"]
if not isinstance(willexecutors, dict):
_logger.warning(
f"unexpected download_list response type: "
f"{type(willexecutors).__name__}"
)
return {}
for w in willexecutors:
if w not in ("status", "url"):
if not isinstance(willexecutors.get(w), dict):
_logger.warning(
f"malformed entry {w!r} in executor list, "
f"type={type(willexecutors.get(w)).__name__}"
)
continue
Willexecutors.initialize_willexecutor(
willexecutors[w], w, None, old_willexecutors.get(w,None)
)
# bal_plugin.WILLEXECUTORS.set(l)
# bal_plugin.config.set_key(bal_plugin.WILLEXECUTORS,l,save=True)
return willexecutors
except Exception as e:
@@ -794,6 +836,12 @@ class Willexecutors:
"post", url + "/searchtx", data=txid.encode("ascii"),
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
)
if not isinstance(w, dict):
_logger.warning(
f"unexpected check_transaction response type "
f"from {url}: {type(w).__name__}"
)
return None
return w
except Exception as e:
_logger.error(f"error contacting {url} for checking txs {e}")

View File

@@ -7,12 +7,21 @@ iCalendar (.ics) generation and "open with default calendar app" helper.
When a will is built, the plugin can create a calendar event reminding the user
to "check in" before the locktime expires. This module turns the event data
into an RFC-5545 .ics file and opens it with the OS default application.
The pure RFC-5545 logic (offsets, escaping, folding, the unified .ics builder,
``write_temp_ics``) lives in :mod:`bal.core.reminders`; this module keeps only
the Qt button and the OS/subprocess glue.
"""
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
import os
import subprocess
from electrum.gui.qt.util import getSaveFileName
from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QToolButton
from PyQt6.QtWidgets import QInputDialog, QMenu, QToolButton
from ...core.reminders import write_temp_ics
from .common import _, _logger
class BalCalendarButton(QToolButton):
@@ -56,7 +65,7 @@ class BalCalendarButton(QToolButton):
try:
content = self._ics_provider()
if content:
self._calendar_temp_path = BalCalendar.write_temp_ics(content)
self._calendar_temp_path = write_temp_ics(content)
else:
self._calendar_temp_path = None
self._bal_window.show_warning(
@@ -79,7 +88,8 @@ class BalCalendarButton(QToolButton):
path = self._ensure_ics()
if not path:
return
import shlex, subprocess
import shlex
import subprocess
if self._bal_window.bal_plugin.is_basic_mode():
app = self._bal_window.bal_plugin.CALENDAR_APP.default
else:
@@ -147,13 +157,6 @@ class BalCalendarButton(QToolButton):
class BalCalendar:
@staticmethod
def write_temp_ics(content):
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
with os.fdopen(fd, "wb") as f:
f.write(content.encode("utf-8"))
return path
@staticmethod
def open_with_default_app(calendar_app, path):
_logger.debug("opening calendar app")
@@ -182,52 +185,3 @@ class BalCalendar:
if os.path.isdir(desktop):
return desktop
return home
@staticmethod
def format_time(time):
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
#return time.astimezone(timezone.utc).strftime("%Y%m%d")
@staticmethod
def ical_escape(text: str) -> str:
# escape per RFC5545: backslash, ; , newlines
text = text.encode("utf-8")
text = (
text.replace(b"\\", b"\\\\")
.replace(b";", b"\\;")
.replace(b",", b"\\,")
)
out =""
temp=text.split(b"\r\n")
for s in temp:
encoded= s
cut =0
while len(encoded) >75:
cut+=5
encoded=f"{s[:len(s)-cut]}"
if encoded[-1]==b"\\" and encoded[-2]!=b"\\\\":
cut += 1
encoded=f"{s[:len(s)-cut]}"
encoded=f"{encoded}...\r\n".encode("utf-8")
if cut>0:
out+=str(f"{s[:len(s)-cut].decode()}...\r\n")
else:
out+=str(f"{s.decode()}\r\n")
return out[:-2]
@staticmethod
def fold_ical_line(line: str, limit: int = 75) -> str:
# ritorna linee separate da CRLF e folding con spazio iniziale sulle righe successive
encoded = line.encode("utf-8")
parts = []
while len(encoded) > limit:
# taglia senza spezzare byte UTF-8
cut = limit
while (encoded[cut] & 0xC0) == 0x80: # byte di continuazione UTF-8
cut -= 1
parts.append(encoded[:cut].decode("utf-8"))
encoded = encoded[cut:]
parts.append(encoded.decode("utf-8"))
return "\r\n ".join(parts)

View File

@@ -12,10 +12,9 @@ hosts a few GUI helpers that do not deserve a module of their own:
* :func:`add_widget` - add a labelled widget (plus optional help) to a grid.
* :func:`log_error` - format an exception traceback for a dialog.
* :func:`export_meta_gui` - export plugin metadata to a JSON file.
* :class:`CheckAliveError`- raised when the "check alive" date is in the past.
(:class:`CheckAliveError` now lives in ``bal.core.checkalive``.)
"""
import copy
import enum
import os
import subprocess
@@ -27,62 +26,143 @@ from decimal import Decimal
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.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
from electrum.gui.qt.password_dialog import PasswordDialog
from electrum.gui.qt.transaction_dialog import TxDialog
from electrum.gui.qt.util import (Buttons, CancelButton, ColorScheme,
EnterButton, HelpButton, MessageBoxMixin,
OkButton, TaskThread, WindowModalDialog,
char_width_in_lineedit, getSaveFileName,
import_meta_gui, read_QIcon_from_bytes,
read_QPixmap_from_bytes, webopen)
from electrum.gui.qt.util import (
Buttons,
CancelButton,
ColorScheme,
EnterButton,
HelpButton,
MessageBoxMixin,
OkButton,
TaskThread,
WaitingDialog,
WindowModalDialog,
char_width_in_lineedit,
getOpenFileName,
getSaveFileName,
import_meta_gui,
read_QIcon_from_bytes,
read_QPixmap_from_bytes,
webopen,
)
from electrum.i18n import _
from electrum.logging import get_logger
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError
from electrum.payment_identifier import PaymentIdentifier
from electrum.plugin import hook
from electrum.transaction import SerializationError, Transaction, tx_from_any
from electrum.util import (DECIMAL_POINT, FileExportFailed, UserCancelled,
decimal_point_to_base_unit_name, read_json_file,
write_json_file)
from PyQt6.QtCore import (QDateTime, QModelIndex, QPersistentModelIndex, QSize,
Qt, QTimer, pyqtSignal)
from PyQt6.QtGui import (QColor, QPainter, QPalette, QStandardItem,
QStandardItemModel)
from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox,
QComboBox, QDateTimeEdit, QGridLayout, QHBoxLayout,
QInputDialog, QLabel, QLineEdit, QTextEdit, QMenu,
QMenuBar, QPushButton, QScrollArea, QSizePolicy,
QSpinBox, QStackedWidget, QStyle, QStyleOptionFrame,
QVBoxLayout, QWidget, QDialog)
from electrum.util import (
DECIMAL_POINT,
FileExportFailed,
FileImportFailed,
UserCancelled,
decimal_point_to_base_unit_name,
read_json_file,
write_json_file,
)
from PyQt6.QtCore import (
QDateTime,
QModelIndex,
QPersistentModelIndex,
QSize,
Qt,
QTimer,
pyqtSignal,
)
from PyQt6.QtGui import QColor, QPainter, QPalette, QStandardItem, QStandardItemModel
from PyQt6.QtWidgets import (
QAbstractItemView,
QAbstractSpinBox,
QApplication,
QButtonGroup,
QCheckBox,
QComboBox,
QDateTimeEdit,
QDialog,
QGridLayout,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QMenu,
QMenuBar,
QPushButton,
QRadioButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QStackedWidget,
QStyle,
QStyleOptionFrame,
QTextEdit,
QToolButton,
QVBoxLayout,
QWidget,
)
from ...core.heirs import (
HEIR_DUST_AMOUNT,
HEIR_REAL_AMOUNT,
OP_RETURN_PREFIX,
BalanceTooLowException,
HeirAmountIsDustException,
Heirs,
WillExecutorFeeTooHighException,
get_op_return_hex,
is_op_return_address,
validate_op_return_hex,
)
# --- Core (GUI-free) logic layer ---
from ...core.plugin_base import BalPlugin, BalTimestamp
from ...core.heirs import (HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT,
HeirAmountIsDustException, Heirs)
from ...core.util import Util
from ...core.will import (AmountException, HeirChangeException,
HeirNotFoundException, NoHeirsException,
NotCompleteWillException, NoWillExecutorNotPresent,
TxFeesChangedException, Will,
WillexecutorChangeException, WillExecutorNotPresent,
WillExpiredException, WillItem, WillPostponedException)
from ...core.willexecutors import Willexecutors
from ...core.willexecutors import is_onion_url, is_tor_active # noqa: F401
from ...core.util import Util, copy_structure
from ...core.will import (
AmountException,
HeirChangeException,
HeirNotFoundException,
NoHeirsException,
NotCompleteWillException,
NoWillExecutorNotPresent,
TxFeesChangedException,
Will,
WillexecutorChangeException,
WillExecutorNotPresent,
WillExpiredException,
WillItem,
WillPostponedException,
)
from ...core.willexecutors import ( # noqa: F401
Willexecutors,
is_onion_url,
is_tor_active,
)
# --- Presentation helpers ---
from .theme import server_status_text, server_status_tooltip, status_color
from .window_utils import (bring_to_front, show_modal, show_on_top,
stop_thread, top_level_of)
from .theme import (
server_status_text,
server_status_tooltip,
signature_suffix,
status_color,
)
from .window_utils import (
bring_to_front,
show_modal,
show_on_top,
stop_thread,
top_level_of,
)
_logger = get_logger(__name__)
class shown_cv:
class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal naming)
_type = bool
def __init__(self, value):
@@ -105,18 +185,6 @@ def add_widget(grid, label, widget, row, help_):
class CheckAliveError(Exception):
def __init__(self, timestamp_to_check):
self.timestamp_to_check = timestamp_to_check
def __str__(self):
return "Check alive expired please update it: {}".format(
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
)
def log_error(exec_info, window=None):
"""Log an error and optionally show it.

File diff suppressed because it is too large Load Diff

View File

@@ -14,12 +14,111 @@ construction) for all business actions, so the heavy logic stays in ``window``
and ``dialogs``.
"""
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from .widgets import BalCheckBox, PercAmountEdit, WillSettingsWidget
from PyQt6.QtWidgets import QMessageBox
from PyQt6.QtWidgets import QStyledItemDelegate, QLineEdit as _QLineEdit
from typing import TYPE_CHECKING
from PyQt6.QtWidgets import QLineEdit as _QLineEdit
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
from .common import (
OP_RETURN_PREFIX,
BalTimestamp,
Buttons,
CancelButton,
HelpButton,
MessageBoxMixin,
MyTreeView,
OkButton,
QAbstractItemView,
QApplication,
QColor,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMenu,
QModelIndex,
QPersistentModelIndex,
QPushButton,
QSize,
QSizePolicy,
QSpinBox,
QStandardItem,
QStandardItemModel,
Qt,
QToolButton,
QVBoxLayout,
QWidget,
TaskThread,
Util,
Will,
Willexecutors,
WillItem,
_,
_logger,
char_width_in_lineedit,
datetime,
enum,
export_meta_gui,
getOpenFileName,
import_meta_gui,
is_op_return_address,
partial,
read_json_file,
read_QIcon_from_bytes,
server_status_text,
server_status_tooltip,
signature_suffix,
status_color,
tx_from_any,
write_json_file,
)
from .dialogs import BalBuildWillDialog, BalDialog
from .widgets import BalCheckBox, WillSettingsWidget
if TYPE_CHECKING:
from .window import BalWindow
def _can_sign(will_item):
"""True if a will transaction may still be signed (not fully signed)."""
return bool(will_item) and not will_item.get_status("COMPLETE")
def _can_broadcast(will_item):
"""True if a will transaction is ready to broadcast (fully signed)."""
return bool(will_item) and will_item.get_status("COMPLETE")
def _can_delete(will_item):
"""True if a will transaction may be deleted (invalid or unsigned).
Valid AND fully-signed transactions are never deletable: removing them
would silently drop a committed inheritance.
"""
return bool(will_item) and (
not will_item.get_status("VALID") or not will_item.get_status("COMPLETE")
)
def _apply_select_all(willexecutors, select, valid=None):
"""Apply a bulk selection across a ``{url: we_dict}`` mapping.
``select`` is the target selection value. When ``valid`` is given (a
``{url: bool}`` validity map), the selection is restricted by validity:
selecting sets valid ones to True and invalid ones to False, while
deselecting only clears the invalid ones (valid ones keep their state).
Without ``valid`` every entry is simply set to ``select``. The mapping is
mutated in place and returned.
"""
for url, we in willexecutors.items():
if valid is not None:
if select:
we["selected"] = valid.get(url, False)
elif not valid.get(url, False):
we["selected"] = False
else:
we["selected"] = select
return willexecutors
class HeirListWidget(MyTreeView, MessageBoxMixin):
@@ -84,7 +183,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
self.bal_window.new_heir_dialog(edit_key)
def on_edited(self, idx, edit_key, *, text):
original = prior_name = self.bal_window.heirs.get(edit_key)
prior_name = self.bal_window.heirs.get(edit_key)
if not prior_name:
return
col = idx.column()
@@ -105,12 +204,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
try:
self.bal_window.set_heir(prior_name)
except Exception:
pass
try:
self.bal_window.set_heir((edit_key,) + original)
except Exception:
self.update()
self.update()
def delete_heirs(self, selected_keys):
self.bal_window.delete_heirs(selected_keys)
@@ -157,7 +251,17 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
heir = self.bal_window.heirs[key]
labels = [""] * len(self.Columns)
labels[self.Columns.NAME] = key
labels[self.Columns.ADDRESS] = heir[0]
if is_op_return_address(heir[0]):
data_hex = heir[0][len(OP_RETURN_PREFIX):]
try:
decoded = bytes.fromhex(data_hex).decode("utf-8", errors="replace")
if len(decoded) > 40:
decoded = decoded[:40] + "\u2026"
labels[self.Columns.ADDRESS] = decoded
except Exception:
labels[self.Columns.ADDRESS] = "OP_RETURN"
else:
labels[self.Columns.ADDRESS] = heir[0]
labels[self.Columns.AMOUNT] = Util.decode_amount(
heir[1], self.decimal_point
)
@@ -184,7 +288,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
set_current = QPersistentModelIndex(idx)
try:
self.will_settings_widget.on_locktime_change()
except Exception as e:
except Exception:
pass
self.set_current_idx(set_current)
# FIXME refresh loses sort order; so set "default" here:
@@ -204,15 +308,15 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
menu.addAction(_("Import"), self.bal_window.import_heirs)
menu.addAction(_("Export"), lambda: self.bal_window.export_heirs())
newHeirButton = QPushButton(_("New Heir"))
newHeirButton.clicked.connect(self.bal_window.new_heir_dialog)
new_heir_button = QPushButton(_("New Heir"))
new_heir_button.clicked.connect(self.bal_window.new_heir_dialog)
widget = QWidget(self)
layout = QHBoxLayout(widget)
self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
layout.addWidget(self.will_settings_widget)
layout.addWidget(newHeirButton)
layout.addWidget(new_heir_button)
toolbar.insertWidget(2, widget)
@@ -271,7 +375,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
self.setModel(QStandardItemModel(self))
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
except Exception as e:
except Exception:
pass
self.setSortingEnabled(True)
@@ -297,36 +401,64 @@ class PreviewList(MyTreeView, MessageBoxMixin):
selected_keys.append(sel_key)
if selected_keys and idx.isValid():
column_title = self.model().horizontalHeaderItem(column).text()
# column_data = "\n".join(
# self.model().itemFromIndex(s_idx).text()
# for s_idx in self.selected_in_column(column)
# )
menu.addAction(
_("details").format(column_title),
lambda: self.show_transaction(selected_keys),
).setEnabled(len(selected_keys) < 2)
).setEnabled(len(selected_keys) == 1)
menu.addAction(
_("sign").format(column_title),
lambda: self.sign_transactions(selected_keys),
).setEnabled(
any(_can_sign(self.will.get(k)) for k in selected_keys)
)
menu.addAction(
_("broadcast").format(column_title),
lambda: self.broadcast_transactions(selected_keys),
).setEnabled(
any(_can_broadcast(self.will.get(k)) for k in selected_keys)
)
menu.addAction(
_("check ").format(column_title),
lambda: self.check_transactions(selected_keys),
)
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
try:
self.importaction = self.menu.addAction(
_("Import"), self.import_will
)
except Exception:
pass
menu.addSeparator()
menu.addAction(
_("copy id").format(column_title),
lambda: self.copy_txids(selected_keys),
)
menu.addAction(
_("copy").format(column_title),
lambda: self.copy_tx_hexes(selected_keys),
)
menu.addAction(
_("merge from txn").format(column_title),
lambda: self.merge_from_txn(),
)
menu.addSeparator()
menu.addAction(
_("delete").format(column_title), lambda: self.delete(selected_keys)
).setEnabled(
any(_can_delete(self.will.get(k)) for k in selected_keys)
)
menu.exec(self.viewport().mapToGlobal(position))
def is_deletable(self, key):
"""True if the will transaction ``key`` may be deleted.
Deletion is only allowed for transactions that are NOT valid or NOT
fully signed (invalidated/replaced/mempool/confirmed items, or
unsigned/partially signed ones). Valid and complete transactions are
kept (deleting them would silently drop a committed inheritance).
"""
return _can_delete(self.will.get(key))
def delete(self, selected_keys):
for key in selected_keys:
keys = [k for k in selected_keys if self.is_deletable(k)]
for key in keys:
del self.will[key]
try:
del self.bal_window.willitems[key]
@@ -338,6 +470,75 @@ class PreviewList(MyTreeView, MessageBoxMixin):
pass
self.update()
def sign_transactions(self, selected_keys):
"""Sign all selected transactions that are not fully signed yet."""
keys = [
k for k in selected_keys
if _can_sign(self.will.get(k))
]
if keys:
self.bal_window.ask_password_and_sign_transactions(
callback=self.update, txids=keys
)
def broadcast_transactions(self, selected_keys):
"""Force-broadcast all selected fully-signed transactions.
``force=True`` makes the will-executors re-accept transactions that
were already pushed before.
"""
keys = [
k for k in selected_keys
if _can_broadcast(self.will.get(k))
]
if keys:
self.bal_window.broadcast_transactions(force=True, txids=keys)
self.update()
def copy_txids(self, selected_keys):
"""Copy the selected transaction IDs to the clipboard (one per line)."""
self.place_text_on_clipboard(
"\n".join(selected_keys), title=_("Transaction IDs")
)
def copy_tx_hexes(self, selected_keys):
"""Copy the selected transactions (serialised hex) to the clipboard."""
hexes = "\n".join(str(self.will[k].tx) for k in selected_keys)
self.place_text_on_clipboard(hexes, title=_("Transactions"))
def merge_from_txn(self):
"""Merge a transaction read from the clipboard, or from a file if the
clipboard does not contain a valid transaction.
"""
tx = None
try:
tx = tx_from_any(QApplication.clipboard().text())
except Exception:
tx = None
if tx is None:
filename = getOpenFileName(
parent=self.bal_window.window,
title=_("Open transaction file"),
filter="All files (*)",
config=self.bal_window.window.config,
)
if not filename:
return
try:
with open(filename, "r") as f:
data = f.read()
tx = tx_from_any(data)
except Exception as e:
self.bal_window.show_error(
_("Invalid transaction file: {}").format(e)
)
return
try:
self.bal_window.merge_single_transaction(tx)
except Exception as e:
self.bal_window.show_error(str(e))
self.update()
def check_transactions(self, selected_keys):
wout = {}
for k in selected_keys:
@@ -385,8 +586,8 @@ class PreviewList(MyTreeView, MessageBoxMixin):
if bal_tx.we:
we = bal_tx.we["url"]
labels[self.Columns.WILLEXECUTOR] = we
status = bal_tx.status
if len(bal_tx.status) > 53:
status = bal_tx.status + signature_suffix(bal_tx)
if len(status) > 53:
status = "...{}".format(status[-50:])
labels[self.Columns.STATUS] = status
# Dedicated, always-readable label describing whether the inheritance
@@ -462,9 +663,12 @@ 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/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)
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
self.importaction = menu.addAction(_("Import"), self.import_will)
menu.addAction(_("Import"), self.import_will)
menu.addAction(_("Merge"), self.merge_will)
menu.addAction(_("Broadcast"), self.broadcast)
menu.addAction(_("Check"), self.check)
menu.addAction(_("Invalidate"), self.invalidate_will)
@@ -529,15 +733,17 @@ 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 import_will(self):
self.bal_window.import_will()
self.bal_window.import_will_dialog()
def import_will_into_details(self):
self.bal_window.import_will_into_details()
def merge_will(self):
self.bal_window.merge_will_ui()
def ask_password_and_sign_transactions(self):
self.bal_window.ask_password_and_sign_transactions(callback=self.update)
@@ -756,6 +962,7 @@ class WillExecutorListWidget(MyTreeView):
idx = self.indexAt(position)
column = idx.column() or self.Columns.URL
selected_keys = []
sel_key = None
for s_idx in self.selected_in_column(self.Columns.URL):
item = self.model().itemFromIndex(s_idx)
# Use the FULL url stored in the key role, NOT item.data(0): the
@@ -771,6 +978,15 @@ class WillExecutorListWidget(MyTreeView):
# self.model().itemFromIndex(s_idx).text()
# for s_idx in self.selected_in_column(column)
# )
# When exactly ONE cell is selected, offer "Copy" to copy the value
# of that cell to the clipboard (e.g. a single url / address / fee).
# This list has no ``main_window``, so use QApplication directly.
if len(self.selectionModel().selectedIndexes()) == 1:
cell_value = self.model().itemFromIndex(idx).text()
menu.addAction(
_("Copy"),
lambda: QApplication.clipboard().setText(cell_value),
)
if Willexecutors.is_selected(self._bal_parent.willexecutors_list[sel_key]):
menu.addAction(
_("deselect").format(column_title),
@@ -929,6 +1145,17 @@ class WillExecutorListWidget(MyTreeView):
pass
else:
items.append(QStandardItem(e))
max_fee = self._bal_parent.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
dust = self._bal_parent.bal_window.window.wallet.dust_threshold()
if not Willexecutors.is_valid(value, max_fee=max_fee, dust=dust):
grey = QColor("#808080")
for item in items:
font = item.font()
font.setItalic(True)
item.setFont(font)
item.setForeground(grey)
items[self.Columns.SELECTED].setEditable(False)
items[self.Columns.URL].setEditable(True)
items[self.Columns.ADDRESS].setEditable(True)
@@ -1008,13 +1235,44 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
b.clicked.connect(self.import_file)
buttonbox.addWidget(b)
b = QPushButton(_("Export"))
b.clicked.connect(self.export_file)
buttonbox.addWidget(b)
def _menu_button(label):
btn = QToolButton()
btn.setText(_(label))
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
buttonbox.addWidget(btn)
return btn
b = QPushButton(_("Ping All"))
b.clicked.connect(self.update_willexecutors)
buttonbox.addWidget(b)
export_btn = _menu_button("Export")
export_menu = QMenu(export_btn)
export_menu.addAction(_("Export all"), lambda: self.export_file())
export_menu.addAction(
_("Export selected"), lambda: self.export_file(subset="selected")
)
export_menu.addAction(
_("Export only valid"), lambda: self.export_file(subset="valid")
)
export_btn.setMenu(export_menu)
ping_btn = _menu_button("Ping All")
ping_menu = QMenu(ping_btn)
ping_menu.addAction(_("Ping all"), lambda: self.update_willexecutors())
ping_menu.addAction(
_("Ping selected"), lambda: self.ping_selected_willexecutors()
)
ping_btn.setMenu(ping_menu)
select_btn = _menu_button("Select All")
select_menu = QMenu(select_btn)
select_menu.addAction(_("Select all"), lambda: self.set_select_all(True))
select_menu.addAction(
_("Select only valid"), lambda: self.set_select_all(True, only_valid=True)
)
select_menu.addAction(_("Deselect all"), lambda: self.set_select_all(False))
select_menu.addAction(
_("Deselect only invalid"),
lambda: self.set_select_all(False, only_valid=True),
)
select_btn.setMenu(select_menu)
vbox.addLayout(buttonbox)
# self.will_executor_list_widget.update()
@@ -1135,6 +1393,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
add_another_btn.clicked.connect(add_another)
else:
self._add_another = False
add_another_btn = None
row = 0
grid.addWidget(QLabel(_("URL")), row, 0)
@@ -1227,13 +1486,68 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
self.bal_window.download_list(self.bal_window.willexecutors, on_success)
def export_file(self, path):
def export_file(self, subset=None):
data = self.export_data(subset)
if subset and not data:
self.show_message(_("No will-executor matches the selected filter"))
return
export_meta_gui(
self.bal_window.window, "willexecutors.json", self.export_json_file
self.bal_window.window,
"willexecutors.json",
partial(self.export_json_file, subset=subset),
)
def export_json_file(self, path):
write_json_file(path, self.willexecutors_list)
def export_data(self, subset=None):
data = self.willexecutors_list
if subset == "selected":
data = {
url: we
for url, we in data.items()
if Willexecutors.is_selected(we)
}
elif subset == "valid":
valid = self._validity()
data = {
url: we for url, we in data.items() if valid.get(url, False)
}
return data
def export_json_file(self, path, subset=None):
write_json_file(path, self.export_data(subset))
def _validity(self):
max_fee = self.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
dust = self.bal_window.window.wallet.dust_threshold()
return {
url: Willexecutors.is_valid(we, max_fee=max_fee, dust=dust)
for url, we in self.willexecutors_list.items()
}
def _selected_willexecutors(self):
return {
url: we
for url, we in self.willexecutors_list.items()
if Willexecutors.is_selected(we)
}
def set_select_all(self, select, only_valid=False):
"""Apply a bulk selection across all will-executors.
``select=True`` selects all (or only the valid ones when
``only_valid=True``, deselecting the invalid ones); ``select=False``
deselects all (or only the invalid ones when ``only_valid=True``,
leaving the valid ones selected).
"""
valid = self._validity() if only_valid else None
_apply_select_all(self.willexecutors_list, select, valid)
self.save_willexecutors()
def ping_selected_willexecutors(self):
wes = self._selected_willexecutors()
if not wes:
self.show_message(_("No will-executor is selected"))
return
self.update_willexecutors(wes)
def import_file(self):
import_meta_gui(

View File

@@ -15,14 +15,40 @@ and cached in ``self.bal_windows``.
"""
from electrum.gui.qt.main_window import StatusBarButton
from electrum.plugin import hook
from electrum.util import EventListener, event_listener
from PyQt6.QtWidgets import QLayout
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from .common import read_QIcon_from_bytes
from ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size
from .common import (
BalPlugin,
Buttons,
EnterButton,
HelpButton,
PasswordDialog,
QComboBox,
QGridLayout,
QHBoxLayout,
QInputDialog,
QLabel,
QPushButton,
QTimer,
QVBoxLayout,
QWidget,
UserCancelled,
Willexecutors,
_,
_logger,
add_widget,
partial,
read_QIcon_from_bytes,
read_QPixmap_from_bytes,
show_modal,
webopen,
)
from .dialogs import BalDialog
from .widgets import BalCheckBox, BalLineEdit, BalSpinBox, BalTextEdit
from .window import BalWindow
from .dialogs import BalDialog
from PyQt6.QtWidgets import QLayout
def _window_key(window):
@@ -37,7 +63,7 @@ def _window_key(window):
return id(window)
class Plugin(BalPlugin):
class Plugin(BalPlugin, EventListener):
def __init__(self, parent, config, name):
_logger.info("INIT BALPLUGIN")
BalPlugin.__init__(self, parent, config, name)
@@ -46,6 +72,10 @@ class Plugin(BalPlugin):
# remove a stale button before creating a fresh one when a wallet is
# switched / Electrum is restarted, so the icon is never duplicated.
self._statusbar_buttons = {}
# Register the on_event_* handlers with Electrum's callback manager so
# the plugin learns about new wallet transactions (used by the
# AUTO_REBUILD setting).
self.register_callbacks()
@hook
def init_qt(self, gui_object):
@@ -88,9 +118,11 @@ class Plugin(BalPlugin):
except Exception:
return []
try:
from electrum.gui.qt.plugins_dialog import PluginsDialog
from electrum.gui.qt.plugins_dialog import (
PluginsDialog as plugins_dialog, # noqa: N813
)
except Exception:
PluginsDialog = None
plugins_dialog = None
app = QApplication.instance()
if app is None:
return []
@@ -106,7 +138,7 @@ class Plugin(BalPlugin):
for w in app.topLevelWidgets():
try:
is_match = False
if PluginsDialog is not None and isinstance(w, PluginsDialog):
if plugins_dialog is not None and isinstance(w, plugins_dialog):
is_match = True
elif type(w).__name__ == "PluginsDialog":
is_match = True
@@ -142,17 +174,17 @@ class Plugin(BalPlugin):
each is guarded independently.
"""
try:
from PyQt6.QtWidgets import QDialog
from PyQt6.QtWidgets import QDialog as qdialog # noqa: N813
except Exception:
QDialog = None
qdialog = None
# 1) reject() / done(): the reliable way to end an exec() modal loop.
if QDialog is not None and isinstance(d, QDialog):
if qdialog is not None and isinstance(d, qdialog):
try:
d.reject()
except Exception as e:
_logger.debug("reject() failed: {}".format(e))
try:
d.done(QDialog.DialogCode.Rejected)
d.done(qdialog.DialogCode.Rejected)
except Exception as e:
_logger.debug("done() failed: {}".format(e))
# 2) close(): covers non-QDialog top-levels and is a harmless extra.
@@ -172,9 +204,9 @@ class Plugin(BalPlugin):
it and closes it themselves (it must not linger in the background).
"""
try:
from PyQt6.QtCore import QTimer
from PyQt6.QtCore import QTimer as qtimer # noqa: N813
except Exception:
QTimer = None
qtimer = None
# Schedule of retry delays (ms) measured from each call.
retry_delays = [400, 800, 1500]
dialogs = Plugin._find_plugins_manager_dialogs()
@@ -190,8 +222,8 @@ class Plugin(BalPlugin):
if not still_open:
_logger.info("plugins dialog closed successfully")
return
if attempt < len(retry_delays) and QTimer is not None:
QTimer.singleShot(
if attempt < len(retry_delays) and qtimer is not None:
qtimer.singleShot(
retry_delays[attempt],
lambda: Plugin._handle_plugins_manager_dialog(attempt + 1),
)
@@ -323,6 +355,44 @@ class Plugin(BalPlugin):
except Exception as e:
_logger.error("close_wallet: on_close failed: {}".format(e))
@event_listener
def on_event_new_transaction(self, wallet, tx):
"""Electrum event: a transaction was added to *wallet*."""
self._wallet_activity(wallet)
@event_listener
def on_event_wallet_updated(self, wallet):
"""Electrum event: *wallet* finished a sync pass."""
self._wallet_activity(wallet)
def _wallet_activity(self, wallet):
"""React to wallet activity (new transaction / sync update).
When the AUTO_REBUILD setting is enabled, any change to a wallet that
has a live BalWindow schedules the headless "auto rebuild" flow
(``BalWindow.schedule_auto_rebuild``): it re-runs the same check the
wizard runs at wallet close, anticipating the delivery date by one day
and building an on-chain invalidation tx only when the anticipated
locktime would fall before the check-alive threshold (or the threshold
is already in the past).
This handler runs on the asyncio callback thread, so it only touches
thread-safe state and defers all work to the BalWindow (which marshals
itself onto the GUI thread through QTimer).
"""
if not self.AUTO_REBUILD.get():
return
for win in list(self.bal_windows.values()):
try:
if (
getattr(win, "wallet", None) == wallet
and win.ok
and not win.disable_plugin
):
win.schedule_auto_rebuild()
except Exception as e:
_logger.debug("_wallet_activity failed: {}".format(e))
@hook
def init_keystore(self):
_logger.debug("init keystore")
@@ -436,13 +506,46 @@ class Plugin(BalPlugin):
# persisted NUM_REMINDERS config (default 3), with a range of 1..5.
heir_num_reminders = BalSpinBox(self.NUM_REMINDERS, minimum=1, maximum=5)
# "No will-executor TX" checkbox. Bound to the persisted NO_WILLEXECUTOR
# config (default ON, see plugin_base.py), the SAME config used by the
# checkbox inside the "Build your will" wizard's will-executor download
# window, so the two stay in sync automatically. When enabled the plugin
# also builds a will that does not require a will-executor (e.g. it can
# be saved on a USB stick and a copy given to the heirs).
heir_no_willexecutor = BalCheckBox(self.NO_WILLEXECUTOR)
# Max willexecutor fee spin box. Maximum fee (in satoshi) allowed for
# a single will-executor. If a will-executor charges more, the will
# will not be built. Default 500,000 satoshi (0.005 BTC).
heir_max_willexecutor_fee = BalSpinBox(
self.MAX_WILLEXECUTOR_FEE, minimum=0, maximum=10000000
)
# "Rebuild will on wallet close" checkbox. Bound to the persisted
# REBUILD_ON_CLOSE config (default ON). When ticked, closing the wallet
# / quitting Electrum runs the "Build your will" wizard to rebuild and
# re-validate the will. When unticked, the will is only rebuilt when
# the user presses Check/Prepare. Visible to all users (BASIC and
# ADVANCED).
heir_rebuild_on_close = BalCheckBox(self.REBUILD_ON_CLOSE)
# "Rebuild automatically on new transactions" checkbox. Bound to the
# persisted AUTO_REBUILD config (default OFF). When ticked, an incoming
# or outgoing wallet transaction automatically re-runs the same rebuild
# flow the wizard runs at wallet close: the delivery date is
# anticipated by one day (so the new will replaces the previous one
# without an invalidation tx), and an on-chain invalidation is only
# built when the anticipated locktime would fall before the Check Alive
# threshold or the threshold is already in the past. Visible to all
# 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:
@@ -498,8 +601,10 @@ class Plugin(BalPlugin):
lbl_event_description, edit_event_description, help_event_description,
lbl_calendar_app, edit_calendar_app, help_calendar_app,
lbl_auto_sign, heir_auto_sign, help_auto_sign,
lbl_save_history, heir_save_history, help_save_history,
lbl_history_label, edit_history_label, help_history_label,
reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10,
reset_btn_auto_sign):
reset_btn_11, reset_btn_12, reset_btn_auto_sign):
w.setVisible(not basic)
# Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on
# a real USER TYPE change (not inside update_all/CHECK), so pressing
@@ -525,6 +630,20 @@ class Plugin(BalPlugin):
edit_calendar_app = BalLineEdit(self.CALENDAR_APP)
edit_calendar_app.setMinimumWidth(360)
# "Save inheritance transactions in wallet history" checkbox + label
# field (History persistence). When the checkbox is ON, the valid will
# transactions are saved into the wallet's LOCAL history (the History
# tab) after each check, each tagged with the label below. The label
# field is disabled while the checkbox is off, so the user cannot set a
# label for a feature that is not active.
def on_save_history_change():
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
heir_save_history = BalCheckBox(self.SAVE_HISTORY, on_click=on_save_history_change)
edit_history_label = BalLineEdit(self.HISTORY_LABEL)
edit_history_label.setMinimumWidth(360)
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
def _make_reset_btn(cfg, widget, kind):
"""Return a small ``↺`` button that resets a single setting."""
btn = QPushButton("\u21ba")
@@ -544,6 +663,10 @@ class Plugin(BalPlugin):
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
@@ -614,28 +737,21 @@ class Plugin(BalPlugin):
),
)
grid.addWidget(_make_reset_btn(self.EDITABLE_DATES, heir_editable_dates, "check"), 3, 3)
# "Add transaction without will-executor" setting (formerly labelled
# "No will-executor TX"). When ON the plugin ALSO builds the backup
# inheritance transaction that does NOT require a will-executor (the
# "celeste"/light-blue one shown in the will list): it can be saved on a
# USB stick and a copy handed to the heirs. When OFF only the
# transactions destined to the selected will-executors are built.
#
# Placed here (row 5, right below "Panel editable Date and Fee" and above
# "Number of reminders") at the user's request so related options sit
# together. The remaining grid rows below were renumbered accordingly.
# Max willexecutor fee: maximum fee (in satoshi) allowed for a single
# will-executor. Visible to all users (BASIC and ADVANCED).
add_widget(
grid,
"Add transaction without willexecutor",
heir_no_willexecutor,
"Max Will-Executor Fee (satoshi)",
heir_max_willexecutor_fee,
4,
(
"Create a will that does not require a Will-executor; it can be "
"saved, for example, on a USB stick, and a copy can be given to "
"the heirs."
"Maximum fee (in satoshi) allowed to be paid to a single "
"will-executor. If a will-executor charges more than this, "
"the will will not be built.\n"
"Default: 500,000 satoshi (0.005 BTC)."
),
)
grid.addWidget(_make_reset_btn(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"), 4, 3)
grid.addWidget(_make_reset_btn(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"), 4, 3)
# User Type selector placed BEFORE the advanced-only settings so the
# user chooses basic/advanced first, then sees the relevant options.
add_widget(
@@ -720,6 +836,37 @@ class Plugin(BalPlugin):
reset_btn_10 = _make_reset_btn(self.CALENDAR_APP, edit_calendar_app, "line")
grid.addWidget(_hide_if_basic(reset_btn_10), 10, 3)
# Save-in-history toggle and history label: advanced-only rows. The
# label field is disabled while the checkbox is off (see
# on_save_history_change above).
lbl_save_history = QLabel(_("Save inheritance transactions in history"))
help_save_history = HelpButton(
"After each check, save the valid will transactions into the "
"wallet's local history (the History tab), each with a label.\n"
"The label may contain the variable:\n"
" {willexecutor}: replaced with the will-executor URL of the item\n"
"Only used in ADVANCED mode."
)
grid.addWidget(_hide_if_basic(lbl_save_history), 11, 0)
grid.addWidget(_hide_if_basic(heir_save_history), 11, 1)
grid.addWidget(_hide_if_basic(help_save_history), 11, 2)
reset_btn_11 = _make_reset_btn(self.SAVE_HISTORY, heir_save_history, "check")
grid.addWidget(_hide_if_basic(reset_btn_11), 11, 3)
lbl_history_label = QLabel(_("History label"))
help_history_label = HelpButton(
"Label applied to the will transactions saved into the wallet's "
"local history.\n"
"Variables:\n"
" {willexecutor}: replaced with the will-executor URL of the item\n"
"Only used in ADVANCED mode."
)
grid.addWidget(_hide_if_basic(lbl_history_label), 12, 0)
grid.addWidget(_hide_if_basic(edit_history_label), 12, 1)
grid.addWidget(_hide_if_basic(help_history_label), 12, 2)
reset_btn_12 = _make_reset_btn(self.HISTORY_LABEL, edit_history_label, "line")
grid.addWidget(_hide_if_basic(reset_btn_12), 12, 3)
# NOTE: the ADVANCED-only widgets above have ALREADY been given their
# correct initial visibility inline (via _hide_if_basic) BEFORE being
# added to the grid. The old code did the opposite - it added them
@@ -727,15 +874,76 @@ class Plugin(BalPlugin):
# the Windows relayout flicker. Do NOT reintroduce a post-hoc
# setVisible() loop here.
grid.addWidget(heir_repush, 11, 0)
grid.addWidget(heir_repush, 13, 0)
grid.addWidget(
HelpButton(
"Broadcast all transactions to willexecutors including those already pushed"
),
11,
13,
2,
)
# "Rebuild will on wallet close" row (always visible, BASIC + ADVANCED).
# Placed below the rebroadcast button so the existing rows keep their
# numbers.
lbl_rebuild_on_close = QLabel(_("Rebuild will on wallet close"))
help_rebuild_on_close = HelpButton(
"Run the 'Build your will' wizard every time the wallet is closed "
"or Electrum is quit, so the will is rebuilt and re-validated.\n"
"When disabled, the will is only rebuilt when you press Check or "
"Prepare. The last built state is still saved to the wallet."
)
grid.addWidget(lbl_rebuild_on_close, 14, 0)
grid.addWidget(heir_rebuild_on_close, 14, 1)
grid.addWidget(help_rebuild_on_close, 14, 2)
reset_btn_rebuild_on_close = _make_reset_btn(
self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"
)
grid.addWidget(reset_btn_rebuild_on_close, 14, 3)
# "Rebuild automatically on new transactions" row (always visible,
# BASIC + ADVANCED), right below the "Rebuild will on wallet close"
# row.
lbl_auto_rebuild = QLabel(_("Rebuild automatically on new transactions"))
help_auto_rebuild = HelpButton(
"When a new transaction arrives for the wallet, automatically "
"rebuild the will the same way the wizard does at wallet close: "
"the delivery date is anticipated by one day so the new will "
"replaces the previous one, and the rebuilt transactions are "
"signed and sent to their will-executors.\n"
"An on-chain invalidation transaction is only built when the "
"anticipated delivery date would fall before the Check Alive "
"threshold, or when the threshold is already in the past.\n"
"When disabled (default), the will is only rebuilt on Check / "
"Prepare / wallet close."
)
grid.addWidget(lbl_auto_rebuild, 15, 0)
grid.addWidget(heir_auto_rebuild, 15, 1)
grid.addWidget(help_auto_rebuild, 15, 2)
reset_btn_auto_rebuild = _make_reset_btn(
self.AUTO_REBUILD, heir_auto_rebuild, "check"
)
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 #
@@ -760,11 +968,16 @@ class Plugin(BalPlugin):
(self.AUTO_SIGN, heir_auto_sign, "check"),
(self.EDITABLE_DATES, heir_editable_dates, "check"),
(self.NUM_REMINDERS, heir_num_reminders, "spin"),
(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"),
(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"),
(self.EVENT_SUMMARY, edit_event_summary, "line"),
(self.EVENT_DESCRIPTION, edit_event_description, "text"),
(self.WELIST_SERVER, edit_welist_server, "line"),
(self.CALENDAR_APP, edit_calendar_app, "line"),
(self.SAVE_HISTORY, heir_save_history, "check"),
(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.
@@ -785,6 +998,14 @@ class Plugin(BalPlugin):
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.
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
# Refresh the open BAL windows so any dependent view (e.g. the
# editable-dates state is not in this list, but hide filters are)
# reflects the reset values.

View File

@@ -54,12 +54,31 @@ def status_color(will_item) -> str:
return "#e83845" # red - failed to push to will-executor
elif will_item.get_status("PUSHED"):
return "#73f3c8" # teal - pushed to will-executor
elif will_item.get_status("PARTIALLY_SIGNED"):
return "#ffb347" # amber - some signatures present, more needed
elif will_item.get_status("COMPLETE"):
return "#2bc8ed" # blue - signed
else:
return _DEFAULT_COLOR
def signature_suffix(will_item) -> str:
"""Return the ``" (added/required)"`` suffix for a non-signed will item.
Used by the transaction list and the detail view to show how many of the
required signatures have already been added, e.g. ``"(1/2)"`` for a 2-of-3
transaction carrying one signature. Returns ``""`` for signed transactions
or when the required count is unknown (no descriptor available yet).
"""
if will_item.get_status("COMPLETE"):
return ""
required = int(getattr(will_item, "sigs_required", 0) or 0)
added = int(getattr(will_item, "sigs_have", 0) or 0)
if not required:
return ""
return " ({}/{})".format(added, required)
def server_status_text(will_item) -> str:
"""Return a short, human-readable label describing the state of a will
item on the will-executor servers (the online inheritance backup).

View File

@@ -18,89 +18,67 @@ Contents:
* WillWidget - single will-tx box
"""
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
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,
normalize_perc_amount_text,
parse_perc_amount,
)
from ...core.reminders import build_ics_reminders, write_temp_ics
from .calendar import BalCalendar, BalCalendarButton
from .common import (
DECIMAL_POINT,
NLOCKTIME_BLOCKHEIGHT_MAX,
NLOCKTIME_MAX,
Any,
BalTimestamp,
BTCAmountEdit,
ColorScheme,
Decimal,
HelpButton,
Optional,
QAbstractSpinBox,
QCheckBox,
QColor,
QComboBox,
QDateTime,
QDateTimeEdit,
QHBoxLayout,
QLabel,
QLineEdit,
QPainter,
QPalette,
QPushButton,
QSizePolicy,
QSpinBox,
QStyle,
QStyleOptionFrame,
Qt,
QTextEdit,
QVBoxLayout,
QWidget,
Union,
Util,
Will,
_,
_logger,
char_width_in_lineedit,
datetime,
getSaveFileName,
log_error,
os,
partial,
pyqtSignal,
read_QIcon_from_bytes,
signature_suffix,
status_color,
)
def compute_reminder_offsets(days, count):
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
Group D / D1. The reminders are spread uniformly across the check-alive
period and always fall *before* the delivery deadline, i.e. every returned
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
Rules:
* ``count`` is the requested number of reminders (the settings dialog
caps it at 5, default 3).
* at most ONE reminder per available day: the effective number is
``min(count, days)``;
* with ``days`` available days, offsets are chosen as evenly spaced
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
= the first day of the period), de-duplicated and returned sorted
descending (earliest reminder first).
Args:
days: number of whole days between check-alive and the deadline.
count: requested number of reminders.
Returns:
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
``days=30, count=3``. Empty if there is no room for any reminder.
"""
# No room for any reminder (deadline today or already passed).
if days < 1 or count < 1:
return []
# Never more reminders than available days (one per day at most).
effective = min(int(count), int(days))
# A single reminder: put it one day before the deadline.
if effective == 1:
return [1]
# Spread "effective" points evenly inside [1, days]. Using i/(effective-1)
# for i in 0..effective-1 gives fractions 0..1; map them onto [1, days].
# This places the first reminder at the start of the period (offset ~days)
# and the last one one day before the deadline (offset 1).
offsets = set()
for i in range(effective):
frac = i / (effective - 1) # 0.0 .. 1.0
# offset = days at frac 0 (start), 1 at frac 1 (just before deadline).
offset = round(days - frac * (days - 1))
offset = max(1, min(days, offset))
offsets.add(offset)
# Sorted descending: earliest reminder (largest offset) first.
return sorted(offsets, reverse=True)
# Fixed reminder offsets (in days BEFORE the delivery date) used in BASIC mode.
# In BASIC the check-alive parameter is hidden/unmanaged, so reminders cannot be
# spread over it; instead the owner asked for three fixed reminders: 30, 10 and
# 1 day before the inheritance delivery date.
BASIC_REMINDER_OFFSETS = (30, 10, 1)
def basic_reminder_offsets(days_to_deadline):
"""Return the BASIC-mode reminder offsets that still fall in the future.
BASIC mode uses the fixed offsets in ``BASIC_REMINDER_OFFSETS`` (30, 10 and
1 day before the delivery date). Any offset that would land in the past is
dropped, because a reminder before "today" is useless: if the delivery date
is only ``days_to_deadline`` days away, only the offsets that are ``<=
days_to_deadline`` are kept.
Args:
days_to_deadline: whole days from now until the delivery date.
Returns:
A list of integer day-offsets (each ``>= 1``), sorted as in
``BASIC_REMINDER_OFFSETS`` (descending: earliest reminder first). Empty
when the delivery date is less than one day away.
"""
horizon = max(int(days_to_deadline), 0)
return [off for off in BASIC_REMINDER_OFFSETS if 1 <= off <= horizon]
if TYPE_CHECKING:
from .window import BalWindow
class ClickableLabel(QLabel):
@@ -214,39 +192,12 @@ class BalTxFeesWidget(QWidget):
class _LockTimeEditor:
min_allowed_value = NLOCKTIME_MIN
max_allowed_value = NLOCKTIME_MAX
alarm = None
def get_value(self) -> Optional[int]:
raise NotImplementedError()
def set_value(self, x: Any, force=True) -> None:
raise NotImplementedError()
@classmethod
def is_acceptable_locktime(cls, x: Any) -> bool:
if not x: # e.g. empty string
return True
try:
x = int(x)
except Exception as _e:
return False
return cls.min_allowed_value <= x <= cls.max_allowed_value
@staticmethod
def get_max_allowed_timestamp() -> int:
ts = NLOCKTIME_MAX
# Test if this value is within the valid timestamp limits (which is platform-dependent).
# see #6170
try:
datetime.fromtimestamp(ts)
except (OSError, OverflowError):
ts = 2**31 - 1 # INT32_MAX
datetime.fromtimestamp(ts) # test if raises
return ts
class _LockTimeEditor(LockTimeEditor):
"""Qt-side locktime editor base.
The pure acceptance bounds and helpers live in ``bal.core.input_rules``;
this is just the mixin that lets Qt widgets reuse them.
"""
class BalTimeEditWidget(QWidget, _LockTimeEditor):
@@ -567,51 +518,16 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
self.isyears = False
self.time_edit = time_edit
@staticmethod
def replace_str(text):
"""Strip the relative-time suffixes (d/y) from the text.
Only days ("d") and years ("y") are supported. The block-height
suffix ("b") was removed (A1): locktimes are always timestamps now.
"""
return str(text).replace("d", "").replace("y", "")
def checkbdy(self, s, pos, appendix):
try:
charpos = pos - 1
charpos = max(0, charpos)
charpos = min(len(s) - 1, charpos)
if appendix == s[charpos]:
s = self.replace_str(s) + appendix
pos = charpos
except Exception:
pass
return pos, s
def numbify(self):
# Only digits plus the day ("d") and year ("y") suffixes are accepted.
# The block-height suffix ("b") was removed (A1): locktimes are always
# UNIX timestamps now, so block-relative input is no longer allowed.
# The sanitisation itself lives in bal.core.input_rules.
text = self.text().strip()
chars = "0123456789dy"
pos = self.cursorPosition()
pos = len("".join([i for i in text[:pos] if i in chars]))
s = "".join([i for i in text if i in chars])
self.isdays = False
self.isyears = False
pos, s = self.checkbdy(s, pos, "d")
pos, s = self.checkbdy(s, pos, "y")
if "d" in s:
self.isdays = True
if "y" in s:
self.isyears = True
if self.isdays:
s = self.replace_str(s) + "d"
if self.isyears:
s = self.replace_str(s) + "y"
s, isdays, isyears, pos = normalize_locktime_raw_text(text, pos)
self.isdays = isdays
self.isyears = isyears
self.blockSignals(True)
self.setText(s)
self.blockSignals(False)
@@ -690,15 +606,20 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
return
try:
x = int(x)
except Exception as e:
except Exception:
x = QDateTime.currentDateTime().timestamp()
finally:
# Use the overflow-safe converter: on Windows datetime.fromtimestamp
# raises OverflowError for timestamps past 2038 (e.g. NLOCKTIME_MAX).
_dt = BalTimestamp._safe_fromtimestamp(x)
#if self.alarm != dt:
self.setDateTime(_dt)
self.alarm = _dt
# Store the LOCAL wall-clock time, not the aware-UTC datetime:
# QDateTimeEdit keeps the given wall time with a LocalTime spec, so
# an aware-UTC datetime would make get_value() read back a timezone-
# shifted epoch. That broke the set_value -> get_value roundtrip and
# kept the valueEdited -> update_setting_widgets -> set_value cycle
# firing forever (infinite RecursionError on wizard "Next").
self.setDateTime(_dt.astimezone().replace(tzinfo=None))
@@ -1068,11 +989,12 @@ class WillSettingsWidget(QWidget):
sees several distinct appointments in their calendar.
The number of events is read from the NUM_REMINDERS setting (default 3,
capped at 5 by the settings dialog). Their dates are computed with
``compute_reminder_offsets``: the offsets are spread uniformly across the
check-alive period and the LAST event always falls one day before the
delivery deadline (locktime). If the period is shorter than the
requested number of reminders, at most one event per day is produced.
capped at 5 by the settings dialog). Their dates are computed by
``bal.core.reminders.build_ics_reminders`` (offsets spread uniformly
across the check-alive period; the LAST event always falls one day
before the delivery deadline / locktime). If the period is shorter than
the requested number of reminders, at most one event per day is
produced.
Each event:
* is placed on ``locktime - offset`` days (its own visible date);
@@ -1087,103 +1009,23 @@ class WillSettingsWidget(QWidget):
path the user picks in the save dialog (default name "BAL_will_event.ics"
on the Desktop).
"""
now = BalCalendar.format_time(datetime.now())
# locktime = delivery deadline. It is exposed by the date widget as
# ``.alarm`` and already reflects the (possibly auto-anticipated) minimum
# transaction locktime, so the calendar uses the correct delivery date.
locktime = self.widgets["locktime"].alarm
# BASIC vs ADVANCED reminder strategy.
#
# In ADVANCED mode the reminders are spread uniformly across the
# check-alive (threshold) period, ending one day before the deadline.
#
# In BASIC mode the check-alive parameter is NOT shown nor managed by the
# user (it stays at an arbitrary default), so spreading reminders over it
# is meaningless. The owner asked that, in BASIC, the calendar simply
# saves the inheritance delivery date with three fixed reminders: 30 days
# before, 10 days before and 1 day before. We also drop any fixed offset
# that would fall in the past (a reminder before "today" is useless), so
# a short-dated will still gets the reminders that are still in the
# future.
if self.bal_window.bal_plugin.is_basic_mode():
# Whole days from now until the delivery date. Fixed offsets (30, 10,
# 1 day before) are applied by basic_reminder_offsets, which also
# drops any offset that would fall in the past.
days_to_deadline = (locktime - datetime.now()).days
offsets = basic_reminder_offsets(days_to_deadline)
else:
# ADVANCED: spread reminders over the check-alive period as before.
threshold = self.widgets["threshold"].alarm
# Whole days available between check-alive and the deadline.
days = (locktime - threshold).days
# How many reminders the user asked for (default 3 if unreadable).
try:
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
except Exception:
count = 3
# Day-offsets BEFORE the deadline, e.g. [30, 16, 1]. The list always
# ends with 1 (one day before the locktime) when >= 2 reminders fit.
offsets = compute_reminder_offsets(days, count)
# Per-event heir details and the shared description/summary templates.
heirs_details = "\r\n".join(
f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}"
for heir in self.bal_window.heirs
)
# BASIC mode: use factory defaults (the hidden settings are ignored).
# ADVANCED mode: use the user-configured values.
if self.bal_window.bal_plugin.is_basic_mode():
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
else:
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
event_description = BalCalendar.ical_escape(
f"{raw_description}"
.replace("$wallet_name", str(self.bal_window.wallet))
.replace("$heirs_complete", heirs_details)
)
summary_base = (
f"{raw_summary}"
.replace("$wallet_name", str(self.bal_window.wallet))
)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{BalPlugin.__version__}",
]
# One separate VEVENT per reminder offset (its own date in the calendar).
total = len(offsets)
for idx, offset in enumerate(offsets, start=1):
# The visible date of this event: "offset" days before the deadline.
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
# Suffix the summary so the N events are easy to tell apart.
summary = BalCalendar.ical_escape(
f"{summary_base} (reminder {idx}/{total})"
# The .ics content (offsets, escaping, folding, VEVENT layout) is built
# by the pure ``build_ics_reminders`` in bal.core.reminders. When no
# reminder falls in the future it returns None (ToDo #2) and the user is
# warned instead of getting an empty-looking file.
ics_content = self._ics_provider()
if not ics_content:
self.bal_window.show_warning(
_(
"No reminders were saved: the delivery date is too "
"close (or already passed)"
)
)
lines.extend([
"BEGIN:VEVENT",
# Offset in the UID keeps each event unique (no merging).
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
f"DTSTAMP:{now}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{event_description}",
"END:VEVENT",
])
return
lines.append("END:VCALENDAR")
lines = [s.rstrip("\r\n") for s in lines]
ics_content = "\r\n".join(lines) + "\r\n"
# Keep the generated .ics in a temp file; it is copied to the path the
# user picks below.
self.temp_path = BalCalendar.write_temp_ics(ics_content)
self.temp_path = write_temp_ics(ics_content)
# Group D / D1b: always ask the user WHERE to save the .ics file (the
# plugin no longer tries to open it with a calendar app). The save
@@ -1236,26 +1078,31 @@ class WillSettingsWidget(QWidget):
def _ics_provider(self):
"""Return the .ics content for the current locktime/threshold values.
Used by :class:`BalCalendarButton` as its content provider.
Used by :class:`BalCalendarButton` as its content provider. The whole
document (reminder offsets, escaping, folding, VEVENT layout) is built
by the pure :func:`bal.core.reminders.build_ics_reminders`.
"""
from datetime import datetime, timedelta
try:
locktime = self.widgets["locktime"].alarm
if self.bal_window.bal_plugin.is_basic_mode():
days_to_deadline = (locktime - datetime.now()).days
offsets = basic_reminder_offsets(days_to_deadline)
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
if basic_mode:
# BASIC mode: use factory defaults (the hidden settings are
# ignored) and the fixed 30/10/1 offsets.
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
threshold = None
num_reminders = 3
else:
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
threshold = self.widgets["threshold"].alarm
days = (locktime - threshold).days
try:
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
num_reminders = int(
self.bal_window.bal_plugin.NUM_REMINDERS.get()
)
except Exception:
count = 3
offsets = compute_reminder_offsets(days, count)
now = BalCalendar.format_time(datetime.now())
num_reminders = 3
heirs_details = "\r\n".join(
f" {heir} - {self.bal_window.heirs[heir][0]}, "
@@ -1263,50 +1110,17 @@ class WillSettingsWidget(QWidget):
for heir in self.bal_window.heirs
)
if self.bal_window.bal_plugin.is_basic_mode():
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
else:
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
event_description = BalCalendar.ical_escape(
f"{raw_description}"
.replace("$wallet_name", str(self.bal_window.wallet))
.replace("$heirs_complete", heirs_details)
return build_ics_reminders(
locktime=locktime,
basic_mode=basic_mode,
description=raw_description,
summary=raw_summary,
wallet_name=str(self.bal_window.wallet),
heirs_details=heirs_details,
version=self.bal_window.bal_plugin.version,
num_reminders=num_reminders,
threshold=threshold,
)
summary_base = (
f"{raw_summary}"
.replace("$wallet_name", str(self.bal_window.wallet))
)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Bitcoin After Life//Electrum Plugin/"
f"{BalPlugin.__version__}",
]
total = len(offsets)
for idx, offset in enumerate(offsets, start=1):
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
summary = BalCalendar.ical_escape(
f"{summary_base} (reminder {idx}/{total})"
)
lines.extend([
"BEGIN:VEVENT",
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
f"DTSTAMP:{now}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{event_description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
lines = [s.rstrip("\r\n") for s in lines]
return "\r\n".join(lines) + "\r\n"
except Exception as e:
_logger.error(f"failed to generate .ics: {e}")
return None
@@ -1349,40 +1163,20 @@ class PercAmountEdit(BTCAmountEdit):
super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
def numbify(self):
# The text sanitisation lives in bal.core.input_rules.
text = self.text().strip()
if text == "!":
self.shortcut.emit()
return
pos = self.cursorPosition()
chars = "0123456789%"
chars += DECIMAL_POINT
s = "".join([i for i in text if i in chars])
if "%" in s:
self.is_perc = True
s = s.replace("%", "")
else:
self.is_perc = False
if DECIMAL_POINT in s:
p = s.find(DECIMAL_POINT)
s = s.replace(DECIMAL_POINT, "")
s = s[:p] + DECIMAL_POINT + s[p : p + 8]
if self.is_perc:
s += "%"
s, self.is_perc = normalize_perc_amount_text(text, DECIMAL_POINT)
self.setText(s)
self.setModified(self.hasFocus())
self.setCursorPosition(pos)
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
try:
text = text.replace(DECIMAL_POINT, ".")
text = text.replace("%", "")
return (Decimal)(text)
except Exception:
return None
return parse_perc_amount(text, DECIMAL_POINT)
def _get_text_from_amount(self, amount):
out = super()._get_text_from_amount(amount)
@@ -1395,15 +1189,15 @@ class PercAmountEdit(BTCAmountEdit):
if self.base_unit:
panel = QStyleOptionFrame()
self.initStyleOption(panel)
textRect = self.style().subElementRect(
text_rect = self.style().subElementRect(
QStyle.SubElement.SE_LineEditContents, panel, self
)
textRect.adjust(2, 0, -10, 0)
text_rect.adjust(2, 0, -10, 0)
painter = QPainter(self)
painter.setPen(ColorScheme.GRAY.as_color())
if len(self.text()) == 0:
painter.drawText(
textRect,
text_rect,
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
self.base_unit() + " or perc value",
)
@@ -1482,11 +1276,11 @@ class BalSpinBox(QSpinBox):
class WillWidget(QWidget):
def __init__(self, father=None, parent=None):
def __init__(self, father=None, parent=None, will=None):
super().__init__()
vlayout = QVBoxLayout()
self.setLayout(vlayout)
self.will = parent.bal_window.willitems
self.will = will if will is not None else parent.bal_window.willitems
self._bal_parent = parent
for w in self.will:
if (
@@ -1513,7 +1307,10 @@ class WillWidget(QWidget):
willpushbutton = QPushButton(w)
willpushbutton.clicked.connect(
partial(self._bal_parent.bal_window.show_transaction, txid=w)
partial(
self._bal_parent.bal_window.show_transaction,
tx=self.will[w].tx,
)
)
detaillayout.addWidget(willpushbutton)
locktime = str(BalTimestamp(self.will[w].tx.locktime))
@@ -1535,17 +1332,33 @@ class WillWidget(QWidget):
fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3)
fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)"
detaillayout.addWidget(qlabel("Transaction fees:", fees_str))
detaillayout.addWidget(qlabel("Status:", self.will[w].status))
detaillayout.addWidget(
qlabel("Status:", self.will[w].status + signature_suffix(self.will[w]))
)
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:
@@ -1561,6 +1374,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(
@@ -1570,6 +1387,6 @@ class WillWidget(QWidget):
detailw.setPalette(pal)
hlayout.addWidget(detailw)
hlayout.addWidget(WillWidget(w, parent=parent))
hlayout.addWidget(WillWidget(w, parent=parent, will=self.will))

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,13 @@
{
"name": "bal",
"fullname": "Bitcoin After Life",
"version": "0.5.18",
"version": "0.7.0",
"description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.",
"author": "Svatantrya",
"licence": "MIT",
"available_for": ["qt"],
"available_for": [
"qt",
"cmdline"
],
"icon": "icons/bal32x32.png"
}
}

View File

@@ -6,11 +6,12 @@
Documentation for the **BAL** opensource Electrum plugin for Bitcoin digital
inheritance. Everything here is plain Markdown + images (and optional styled
HTML), so it renders directly on GitHub and via GitHub Pages — **no PDF needed**.
HTML), so it renders directly on any forge (Gitea/GitHub) and in any browser —
**no PDF needed**.
## Contents
| Document | Markdown (GitHub) | Styled HTML |
| Document | Markdown | Styled HTML |
|---|---|---|
| **User Manual (revB)** — full plugin manual with screenshots | [`manual/README.md`](./manual/README.md) | [`manual/manual.html`](./manual/manual.html) |
| **Inheritance Options Guide** — every change (date earlier/later, add/remove heir, change %, fees, executors) + decision flow chart + transaction states & server effects | [`inheritance-options.md`](./inheritance-options.md) | [`inheritance-options.html`](./inheritance-options.html) |
@@ -23,9 +24,8 @@ HTML), so it renders directly on GitHub and via GitHub Pages — **no PDF needed
## Viewing the HTML versions
- On GitHub Pages: enable Pages for this repository (Settings → Pages → deploy
from branch, folder `/docs`), then open
`https://<owner>.github.io/<repo>/manual/manual.html`.
- Online: serve the `docs/` folder as static files (e.g. Pages on Gitea or
GitHub) and open `manual/manual.html`.
- Offline: download the `docs/` folder and open the `.html` files in any browser
(the styled manual works fully offline; the inheritanceoptions page loads
Mermaid from a CDN for the live diagram, and also ships a static SVG fallback).

View File

@@ -308,7 +308,7 @@ executor that <em>should</em> hold your tx did not return it — reBroadcast
<li><strong>Mind the dust limit.</strong> A share below Bitcoin's dust limit is skipped; if <strong>every</strong> heir is dust the build is blocked with a clear message (§4.8) — raise the amounts or use fewer heirs.</li>
</ol>
<footer>This document reflects BAL plugin v0.4.7. Behaviour is derived directly from
<footer>This document reflects BAL plugin v0.7.0. Behaviour is derived directly from
<code>core/will.py</code>, <code>core/heirs.py</code> and <code>gui/qt/window.py</code>.</footer>
</div>

View File

@@ -43,6 +43,7 @@ important ones:
|---|---|---|
| `VALID` | The item is the current, usable plan | default `True`; cleared by INVALIDATED/REPLACED/CONFIRMED/MEMPOOL |
| `COMPLETE` (*Signed*) | The transaction has been **signed** | after you press **Sign** |
| `PARTIALLY_SIGNED` | Only **some** of the required signatures are present | a multisig will after a partial sign (cleared by `COMPLETE`) |
| `PUSHED` | The signed tx was **sent to the willexecutor(s)** | after **Broadcast** to executors |
| `CHECKED` | The willexecutor **confirmed** it holds the tx | after a successful server **Check** (implies `PUSHED`) |
| `CHECK_FAIL` | The server **check failed** | a queried executor did not return the tx |
@@ -65,6 +66,7 @@ Flag transitions enforced by `set_status` (the safety rules baked in the code):
- Setting `CONFIRMED` / `MEMPOOL` → clears `INVALIDATED`.
- Setting `PUSHED` → clears `PUSH_FAIL` **and** `CHECK_FAIL`.
- Setting `CHECKED` → implies `PUSHED` (and clears `PUSH_FAIL`).
- Setting `COMPLETE` → clears `PARTIALLY_SIGNED`.
### How states map to row colour in the list
@@ -83,7 +85,8 @@ wins:
| 7 | `CHECKED` | green | `#8afa6c` |
| 8 | `PUSH_FAIL` | red | `#e83845` |
| 9 | `PUSHED` | teal | `#73f3c8` |
| 10 | `COMPLETE` (signed, **not** yet pushed) | blue | `#2bc8ed` |
| 10 | `PARTIALLY_SIGNED` | amber | `#ffb347` |
| 11 | `COMPLETE` (signed, **not** yet pushed) | blue | `#2bc8ed` |
| — | none of the above (e.g. plain `VALID`, prepared) | default white | `#ffffff` |
> **Note (v0.3.3 fix):** a will that is *signed but not yet broadcast*
@@ -354,5 +357,5 @@ that limit.
---
*This document reflects BAL plugin v0.4.7. Behaviour is derived directly from
`core/will.py`, `core/heirs.py` and `gui/qt/window.py`.*
*This document reflects the current BAL plugin (v0.7.0). Behaviour is derived
directly from `core/will.py`, `core/heirs.py` and `gui/qt/window.py`.*

View File

@@ -76,6 +76,22 @@ inheritance cases.
*Figure 2 — the parameters on the HEIRS tab: (1) Delivery Time, (2) Check Alive,
(3) Fees.*
### User type: BASIC / ADVANCED
The plugin has two usage modes, chosen from the plugin settings
(**Tools → Plugins → BAL**, *User Type* selector):
- **BASIC** (default) — hides the advanced controls: the **Delivery Time** is
entered only as a precise **Date** (the relative **RAW** durations and the
Raw/Date selector are hidden), the **Check Alive** field is hidden, and the
postpone-on-open behaviour is disabled.
- **ADVANCED** — reveals the **Raw/Date selector** (relative durations such as
`1y` or `30d`) and the **Check Alive** field, and enables the postpone
behaviour described below. Switching to ADVANCED requires typing the
confirmation phrase **"at My Risk"**.
The rest of this section describes the full (ADVANCED) parameter set.
### 1 — Delivery Time (Locktime)
Indicates the date on which the inheritance of your wallet on the blockchain
@@ -94,6 +110,11 @@ If you choose **Raw**, you can insert various options based on a suffix:
*(i.e. check whether you are still alive, and then postpone the inheritance.)*
> **NB:** the **Check Alive** parameter is available only in **ADVANCED** mode.
> In **BASIC** (default) it is hidden and the plugin re-evaluates the will
> against "now" every time you open Electrum, so the postpone behaviour
> described here does not apply.
This parameter — settable as relative (`RAW`) or absolute (`DATE`) — indicates
the time by which the inheritance will **not** be changed by postponing it.
@@ -223,6 +244,9 @@ plugin will notify you that you need to update the inheritance.
## RAW settings
> **NB:** relative (**RAW**) durations are available only in **ADVANCED** mode;
> in **BASIC** the Delivery Time is entered only as a precise date.
If you set, for example, `RAW1d` and it is, say, 5 p.m., the plugin will not
execute the inheritance precisely 24 hours later (5 p.m. the next day) but will
roughly estimate the blockchain block number corresponding to that time — so
@@ -239,7 +263,8 @@ with a tolerance of a few hours.
If you want a quick test run, enter an upcoming legacy date/time (e.g. 18 hours
later). For such short intervals the **Check Alive** could create problems, so
set the Check Alive parameter **in the past** (a date before today) — e.g. a
previous month.
previous month. *(The Check Alive only exists in **ADVANCED** mode; in
**BASIC** this is not needed.)*
---
@@ -335,7 +360,7 @@ inheritance:
## WillExecutor service list
This window opens from the Electrum menu, **Tools → Willexecutor**, and shows
This window opens from the Electrum menu, **Tools → WillExecutors**, and shows
the official list of willexecutor servers.
If you want to make changes — such as adding an additional willexecutor server —
@@ -409,14 +434,16 @@ transactions can have in the WILL tab, on each willexecutor that is online.
| # | Status | Meaning | Colour | HEX |
|---|--------|---------|--------|-----|
| 1 | **New** | TX new inheritance | White (transparent) | `#FFFFFF` |
| 2 | **Signed** | TX inheritance signed into the wallet | Azure | `#2BC8ED` |
| 3 | **Pushed** | TX sent to willexecutor | Azuregreen | `#73F3C8` |
| 4 | **Checked** | TX actually present in the willexecutor | Bright green | `#8AFA6C` |
| 5 | **Confirmed** | TX confirmed in the blockchain | Gray | `#BFBFBF` |
| 6 | **Pending** | TX awaiting confirmation on blockchain | Yellow | `#FFCE30` |
| 7 | **Failed** | Communication failure with willexecutor | Red | `#E83845` |
| 8 | **Invalidated** | UTXO input is no longer available | Orange | `#F87838` |
| 9 | **Replaced** | A backdatedlocktime transaction spends the same input | Violet | `#FF97E9` |
| 2 | **Partially signed** | TX has some, but not all, of the required signatures | Amber | `#FFB347` |
| 3 | **Signed** | TX inheritance signed into the wallet | Azure | `#2BC8ED` |
| 4 | **Pushed** | TX sent to willexecutor | Azuregreen | `#73F3C8` |
| 5 | **Checked** | TX actually present in the willexecutor | Bright green | `#8AFA6C` |
| 6 | **Confirmed** | TX confirmed in the blockchain | Gray | `#BFBFBF` |
| 7 | **Pending** | TX awaiting confirmation on blockchain | Yellow | `#FFCE30` |
| 8 | **Failed** | Communication failure with willexecutor | Red | `#E83845` |
| 9 | **Invalidated** | UTXO input is no longer available | Orange | `#F87838` |
| 10 | **Replaced** | A backdatedlocktime transaction spends the same input | Violet | `#FF97E9` |
| 11 | **Updated** | TX reissued keeping the same locktime and heirs | Light violet | `#B266B2` |
---
@@ -448,6 +475,27 @@ transactions can have in the WILL tab, on each willexecutor that is online.
> **NB:** When you close Electrum, the plugin automatically proceeds to execute
> **Prepare → Sign → Broadcast** (if they have not already been completed) to
> ensure the inheritance is correctly executed.
>
> Optionally, the **Rebuild on close** setting (available in **Tools → Plugins →
> BAL**, default OFF) skips the full wizard and runs a one-shot rebuild/sign/push
> flow when Electrum closes.
---
## Auto-rebuild on new transactions
> **NB:** this feature requires the **Auto-rebuild** setting to be enabled
> (available in **Tools → Plugins → BAL**, default OFF).
When the **Auto-rebuild** setting is enabled, the plugin automatically rebuilds
the will when new transactions are detected in the wallet (e.g. incoming
payments). The delivery date is anticipated by one day so the new will orphans
the old one on-chain without requiring a manual invalidation. An on-chain
invalidation is only needed when the anticipated locktime crosses the **Check
Alive** threshold (ADVANCED mode only).
This is useful for wallets that receive funds regularly: the inheritance stays
up-to-date without manual intervention.
---
@@ -500,6 +548,32 @@ value.
---
## Command-line / headless usage
BAL can also be used without the Qt GUI, via Electrum's daemon mode. This is
useful for scripting, automation, or running on a headless server.
**Prerequisites:** an Electrum daemon (`electrum daemon -d`) and a loaded wallet
(`electrum load_wallet`).
**Example:**
```bash
electrum daemon -d
electrum load_wallet
electrum bal_heirs_list
electrum bal_will_prepare
electrum bal_will_sign --password '...'
electrum bal_will_broadcast
electrum stop
```
All GUI operations (prepare, sign, broadcast, check, rebuild) are available as
`bal_*` commands. See the full command table in the
[README](../../README.md#command-line--headless-usage).
---
About installing a willexecutor server or collaboration, send your request to:
**info@bitcoin-after.life**

285
make-release.sh Executable file
View File

@@ -0,0 +1,285 @@
#!/usr/bin/env bash
# make-release.sh — Create a Gitea release for bal-electrum-plugin
#
# Usage:
# ./make-release.sh # read version from bal/manifest.json
# ./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
#
# Requires: git, gpg, curl, python3, sha256sum
# Optional: ruff (lint skipped if not installed)
# Credentials: ~/.git-credentials or GITEA_USER / GITEA_TOKEN env vars
set -eo pipefail
# ── helpers ──────────────────────────────────────────────────────────
die() { echo "Error: $*" >&2; exit 1; }
info() { echo ""; echo "── $* ──"; }
# ── 0. Resolve version ──────────────────────────────────────────────
MANIFEST="bal/manifest.json"
[ -f "$MANIFEST" ] || die "manifest not found: $MANIFEST"
# read current version from manifest
CURRENT_VER=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['version'])")
[ -n "$CURRENT_VER" ] || die "cannot read version from $MANIFEST"
ARG="${1:-}"
if [ -n "$ARG" ]; then
# normalise: accept "v0.6.2" or "0.6.2"
NEW_VER="${ARG#v}"
TAG="v${NEW_VER}"
else
TAG="v${CURRENT_VER}"
NEW_VER=""
fi
echo "=== Release ${TAG} ==="
echo "Current manifest version: ${CURRENT_VER}"
[ -n "$NEW_VER" ] && echo "New version (will bump): ${NEW_VER}"
# ── 1. Bump version in manifest (if arg provided) ──────────────────
if [ -n "$NEW_VER" ] && [ "$NEW_VER" != "$CURRENT_VER" ]; then
info "[1/10] Bumping version to ${NEW_VER} in ${MANIFEST}"
python3 -c "
import json
f = open('${MANIFEST}')
d = json.load(f); f.close()
d['version'] = '${NEW_VER}'
json.dump(d, open('${MANIFEST}', 'w'), indent=4, ensure_ascii=False)
print(json.dumps(d, indent=4, ensure_ascii=False))
"
git add "$MANIFEST"
else
info "[1/10] Version already ${CURRENT_VER}, no bump needed"
fi
# ── 2. Clean caches ─────────────────────────────────────────────────
info "[2/10] Cleaning __pycache__ and .pyc"
find bal -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
find bal -name "*.pyc" -delete 2>/dev/null || true
find bal -name "*.pyo" -delete 2>/dev/null || true
# ── 3. Run tests ────────────────────────────────────────────────────
info "[3/10] Running test suite"
if QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
tests/test_core_*.py \
tests/test_anticipate_past_locktime.py tests/test_anticipate_manual_locktime.py \
tests/test_group_b_auto_sign.py tests/test_group_c_settings.py \
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
tests/test_group_h_v048.py \
-q 2>&1; then
echo "All tests passed."
else
die "Tests failed — aborting release."
fi
# ── 4. Lint (optional) ─────────────────────────────────────────────
info "[4/10] Lint with ruff"
if command -v ruff &>/dev/null; then
RUFF_ERRORS=$(ruff check bal/ 2>&1 \
| grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" \
| grep -vE "F401|F403|F405|F841" || true)
if [ -n "$RUFF_ERRORS" ]; then
echo "New ruff errors:"
echo "$RUFF_ERRORS"
die "Lint errors found — fix before releasing."
else
echo "Lint clean (ignoring known pre-existing warnings)."
fi
else
echo "ruff not installed — skipping lint."
fi
# ── 5. Build ZIP via build_zip.py ───────────────────────────────────
info "[5/10] Building ZIP"
ZIP_NAME="bal_${TAG}.zip"
python3 build_zip.py "$ZIP_NAME"
# ── 6. GPG sign (armor + binary) + export public key ───────────────
info "[6/10] Signing with GPG"
GPG_KEY="A847D004DB91610711CA6A0DFE756706E833E0D1"
gpg --default-key "$GPG_KEY" --batch --yes --armor --detach-sign "$ZIP_NAME"
gpg --default-key "$GPG_KEY" --batch --yes --detach-sign "$ZIP_NAME"
ASC_FILE="${ZIP_NAME}.asc"
SIG_FILE="${ZIP_NAME}.sig"
PGP_FILE="svatantrya.asc"
gpg --armor --export "$GPG_KEY" > "$PGP_FILE"
echo " Signed: $ASC_FILE"
echo " Signed: $SIG_FILE"
echo " Public key: $PGP_FILE"
# ── 7. SHA-256 checksum ────────────────────────────────────────────
info "[7/10] Computing SHA-256"
SHA256_HASH=$(sha256sum "$ZIP_NAME" | cut -d' ' -f1)
echo "${SHA256_HASH} ${ZIP_NAME}" > "${ZIP_NAME}.sha256"
echo " SHA-256: ${SHA256_HASH}"
# ── 8. Pause for Electrum test ─────────────────────────────────────
info "[8/10] Test in Electrum (ZIP-FIRST policy)"
echo ""
echo " ZIP ready: $(pwd)/${ZIP_NAME}"
echo ""
echo " Install it in Electrum (Tools -> Plugins -> Install from file)."
echo " IMPORTANT: fully restart Electrum (not just reload the plugin)."
echo ""
read -r -p " Does the plugin work correctly in Electrum? [y/N] " CONFIRM
case "$CONFIRM" in
[yY][eE][sS]|[yY]) echo " Confirmed." ;;
*) die "Aborted by user." ;;
esac
# ── 9. Git tag + push ──────────────────────────────────────────────
info "[9/10] Creating and pushing tag ${TAG}"
ORIGIN_URL="$(git remote get-url origin 2>/dev/null || true)"
[ -n "$ORIGIN_URL" ] || die "no git remote 'origin' found"
GITEA_HOST="$(echo "$ORIGIN_URL" | sed -n 's|https://\([^/]*\)/.*|\1|p')"
[ -n "$GITEA_HOST" ] || die "cannot parse Gitea host from origin URL"
TARGET_REPO="bitcoinafterlife/bal-electrum-plugin"
# credentials
GITEA_USER="${GITEA_USER:-}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
if [ -z "$GITEA_USER" ] && [ -z "$GITEA_TOKEN" ]; then
if [ -f ~/.git-credentials ]; then
CREDS_LINE="$(grep "${GITEA_HOST}" ~/.git-credentials | head -n1)"
if [ -n "$CREDS_LINE" ]; then
CREDS="$(echo "$CREDS_LINE" | sed -n 's|https://\([^@]*\)@.*|\1|p')"
GITEA_USER="$(echo "$CREDS" | cut -d: -f1)"
GITEA_PASS="$(echo "$CREDS" | cut -d: -f2-)"
GITEA_TOKEN="$GITEA_PASS"
fi
fi
fi
[ -n "$GITEA_TOKEN" ] || die "GITEA_TOKEN not set and no credentials in ~/.git-credentials"
API="https://$GITEA_HOST/gitea/api/v1"
# create annotated tag
git tag -d "$TAG" 2>/dev/null || true
git tag -a "$TAG" -m "$TAG" HEAD
# push tag
REMOTE_NAME="gitea-target"
REMOTE_URL="https://$GITEA_USER:$GITEA_TOKEN@$GITEA_HOST/gitea/$TARGET_REPO.git"
git remote rm "$REMOTE_NAME" 2>/dev/null || true
git remote add "$REMOTE_NAME" "$REMOTE_URL"
echo " Pushing tag ${TAG} to ${TARGET_REPO}..."
git push "$REMOTE_NAME" "$TAG" --force
# ── 10. Create release + upload assets ──────────────────────────────
info "[10/10] Creating Gitea release"
RELEASE_BODY=$(python3 -c "
import json
sha256 = '${SHA256_HASH}'
zip_name = '${ZIP_NAME}'
asc_name = '${ASC_FILE}'
sig_name = '${SIG_FILE}'
pgp_file = '${PGP_FILE}'
tag = '${TAG}'
body = f'''Release {tag}
## SHA-256 Checksum
\`\`\`
{sha256} {zip_name}
\`\`\`
### Verify SHA-256
\`\`\`bash
sha256sum -c {zip_name}.sha256
\`\`\`
## Download
- \`{zip_name}\` - Plugin BAL {tag}
- \`{asc_name}\` - GPG signature (armor)
- \`{sig_name}\` - GPG signature (binary)
- \`{pgp_file}\` - Signing public key ([also available online](https://bitcoin-after.life/svatantrya.asc))
## GPG Verification
### Import the signing key
\`\`\`bash
gpg --fetch-key https://bitcoin-after.life/svatantrya.asc
\`\`\`
Or download \`{pgp_file}\` from the assets above:
\`\`\`bash
gpg --import {pgp_file}
\`\`\`
### Verify the signature (armor)
\`\`\`bash
gpg --verify {asc_name} {zip_name}
\`\`\`
### Verify the signature (binary)
\`\`\`bash
gpg --verify {sig_name} {zip_name}
\`\`\`
Expected output:
\`\`\`
gpg: Good signature from "Svātantrya <svatantrya@bitcoin-after.life>"
\`\`\`
Fingerprint: \`A847D004DB91610711CA6A0DFE756706E833E0D1\`
Public key: https://bitcoin-after.life/svatantrya.asc'''
print(json.dumps({'body': body}, ensure_ascii=False))
")
RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token $GITEA_TOKEN" \
-X POST "${API}/repos/${TARGET_REPO}/releases" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":${RELEASE_BODY},\"draft\":false,\"prerelease\":false}")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" != "201" ]; then
echo "Error creating release: HTTP $HTTP_CODE"
echo "$BODY"
exit 1
fi
RELEASE_ID=$(echo "$BODY" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
HTML_URL=$(echo "$BODY" | python3 -c "import json,sys; print(json.load(sys.stdin)['html_url'])")
echo " Release created: $HTML_URL (ID: $RELEASE_ID)"
# upload assets
for FILE in "$ZIP_NAME" "$ASC_FILE" "$SIG_FILE" "${ZIP_NAME}.sha256" "$PGP_FILE"; do
BASENAME=$(basename "$FILE")
echo " Uploading $BASENAME ..."
UPLOAD=$(curl -s -w "\n%{http_code}" -H "Authorization: token $GITEA_TOKEN" \
-X POST "${API}/repos/${TARGET_REPO}/releases/${RELEASE_ID}/assets" \
-F "attachment=@${FILE}" -F "name=${BASENAME}")
UPLOAD_CODE=$(echo "$UPLOAD" | tail -1)
if [ "$UPLOAD_CODE" == "201" ]; then
echo " OK ($BASENAME)"
else
echo " FAILED ($BASENAME) - HTTP $UPLOAD_CODE"
fi
done
echo ""
echo "=== Done ==="
echo "Release: $HTML_URL"
echo "Assets:"
echo " ${ZIP_NAME}"
echo " ${ASC_FILE}"
echo " ${SIG_FILE}"
echo " ${ZIP_NAME}.sha256"
echo " ${PGP_FILE}"
echo "SHA-256: ${SHA256_HASH}"

21
pyproject.toml Normal file
View File

@@ -0,0 +1,21 @@
[tool.ruff]
line-length = 88
target-version = "py312"
[tool.ruff.lint]
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
"bal/gui/qt/lists.py" = ["N802"] # Qt overrides: createEditor/setEditorData/setModelData
"bal/gui/qt/widgets.py" = ["N802", "N815"] # Qt overrides + Qt signal attrs (valueChanged, ...)
"bal/gui/qt/window.py" = ["N802"] # getMsg
"bal/core/heirs.py" = ["N818", "N802"] # public exception names + buildTransactions API
"bal/core/will.py" = ["N818"] # public exception names
"bal/core/willexecutors.py" = ["N818"] # public exception names
"tests/*.py" = ["N802", "E402"] # deliberate UPPER_CASE helpers + sys.path-before-import

30
svatantrya.asc Normal file
View File

@@ -0,0 +1,30 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQENBGfgPmMBCAC4VXQn/ofBGPn/Wr9dF4tM/4uYNcWLvvz+/+TQsCi/bv4GG6jf
6Ttlg4TDwqF3JlZ1YfPImcdWKxr9is4fyq12OEZvz12LoFEJG8+0NdJrCoT2sm2f
yGmWKgZqRzH9LVBtIOOQIrXF3PdE0X77trWnSFrK/qAv9dszYiVOk9IBwUVI/3Wp
PN5EV7zqbCjYvzD0Hxl2sFzZKqsZCsiy70PJtaJKvKISd8RVTNuIiwZj0gu6hCSa
ZnBr5SLLr56YO4xaTzYNYh7XIEaQXZTHugEJbwygfZajnJ8gC91wWB3BsxVeHDdm
uDy1VGkAs65qvRn9ml5udmnEIPoEsS95HblpABEBAAG0K1N2xIF0YW50cnlhIDxz
dmF0YW50cnlhQGJpdGNvaW4tYWZ0ZXIubGlmZT6JAU4EEwEKADgWIQSoR9AE25Fh
BxHKag3+dWcG6DPg0QUCZ+A+YwIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAK
CRD+dWcG6DPg0coSCACbu3/tqMTwWTqRzXedl6VTGng+qeYfA5NYUaRgZeQYcVWM
sUi4dTAthBUxU3axfcu3V/Vkonn/Hrghdjh94lfpNsgdBNi3c2elI1rHT3Yobkj+
ZsMEj91VlqV81uPFzfq8a/Pp7RIDhy1FJbIunmjnpD3GeJ7vVt76OOcyjV5hkGR0
YJ4JX9O1OOC6wqgR2HVCvXTw/3JhNbj4TS8wr7GGsVWwiotAwZw506vspQRBqeYB
T5Wo2lpEQtagWzIHtgy4A2iAoLQ45E0T1lkr+mZa3V7sucS6W/UXI7HTvqC7wbku
jef6Hxwzzw83TWqPkd4wywuHsDZ3+DTcIDaqP/ROuQENBGfgPmMBCAC69Y2n2Ogi
T7i4Pm4J0cQxLaqwvox3GWSRuBG0QlhsBr0ER5j5fRRDH85P/WyTcvs4/9mIZsSl
JyQH/Lfetr/76pFCyc2zhKxxS1miG3RWOuM7BOKbRjjiieBa6XAiyWStKp2ij8a/
kpqqgulLe1Tiq2SRPA8etqHGd7oR02fbEvzmsgiVqFOz3/tozp2jdC7zCKnp+XFZ
xMKqhIMgfZAxRmVl/qImH944ffcJU6M+qjEL3ENXpuDXpMSWI/indlbK06+R/UPA
hOxCOUSRPTeHzhQrJYUgH6Q6Q/cijpTQHVQFFqLXRKGgK7oE1QhmiNGNBeCVF7DP
hpcWrnUkY/xFABEBAAGJATYEGAEKACAWIQSoR9AE25FhBxHKag3+dWcG6DPg0QUC
Z+A+YwIbDAAKCRD+dWcG6DPg0ToJB/4t2V4FMqd2q00Sd+HmttZoAWNuklui8wO4
nrjfh3Rt0ZBYYk+egZXzPx8lr42Ec8T4h24oJPovMlDu1xN9seQDbVaYC1ICVsnp
6/yfh+elYT5egaAxm9oP9+lQHBB/qZNKrfAssMuVQOrVh5E+XxSz+KG28dQnCYUT
L0k5PCO1f4Jz4XZd5AunVbMQ4J1JawUDoEb/w3Mn9ALDMsdAcOYC6pGhFtV88cqu
IO/ekQV+M8LpRwyh+CiPzgqtN3Z09wHLXFUJYBixXrYbXxAbSqe0PhqAhEKApk2c
4vVkSTAi+bNpkt0QgJ194iTyK20jVw3/roq7sUtDD4FrUoQb7llP
=6EE4
-----END PGP PUBLIC KEY BLOCK-----

18
tests/conftest.py Normal file
View File

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

View File

@@ -33,7 +33,8 @@ def _active_source_without_strings(module) -> str:
if isinstance(node.value, str) and hasattr(node, "end_lineno"):
self.spans.append((node.lineno, node.end_lineno))
self.generic_visit(node)
s = _S(); s.visit(tree)
s = _S()
s.visit(tree)
drop = set()
for a, b in s.spans:
drop.update(range(a, b + 1))
@@ -45,12 +46,13 @@ def _active_source_without_strings(module) -> str:
def main(pkg: str) -> int:
from PyQt6.QtWidgets import QApplication, QDialog, QWidget
app = QApplication.instance() or QApplication(sys.argv)
_app = QApplication.instance() or QApplication(sys.argv)
wu = importlib.import_module(pkg + ".gui.qt.window_utils")
# top_level_of: returns the top-level container of a child widget
w = QWidget(); child = QWidget(w)
w = QWidget()
child = QWidget(w)
assert wu.top_level_of(child) is w
assert wu.top_level_of(None) is None
print("[OK] top_level_of")

11949
tests/karen7

File diff suppressed because one or more lines are too long

View File

@@ -31,7 +31,7 @@ N = 8 # number of servers
def main():
we_mod = importlib.import_module(f"{PKG}.core.willexecutors")
W = we_mod.Willexecutors
we_cls = we_mod.Willexecutors
# ---- 1) ping_servers_parallel: time ~= slowest, not sum ----
def slow_get_info(url, we, **kwargs):
@@ -43,8 +43,8 @@ def main():
we["status"] = 200
return we
orig_get_info = W.get_info_task
W.get_info_task = staticmethod(slow_get_info)
orig_get_info = we_cls.get_info_task
we_cls.get_info_task = staticmethod(slow_get_info)
try:
wes = {}
for i in range(N):
@@ -57,7 +57,7 @@ def main():
seen.append((url, ok))
start = time.time()
W.ping_servers_parallel(wes, on_each=on_each, max_workers=N)
we_cls.ping_servers_parallel(wes, on_each=on_each, max_workers=N)
elapsed = time.time() - start
# Sequential would take ~ N * SLOW. Parallel must be far less.
@@ -81,15 +81,15 @@ def main():
assert we["status"] == "KO", (url, we)
print("[OK] ping results written back into the willexecutors mapping")
finally:
W.get_info_task = orig_get_info
we_cls.get_info_task = orig_get_info
# ---- 2) push_transactions_parallel: time ~= slowest, not sum ----
def slow_push(we, **kwargs):
time.sleep(SLOW)
return "fail" not in we["url"]
orig_push = W.push_transactions_to_willexecutor
W.push_transactions_to_willexecutor = staticmethod(slow_push)
orig_push = we_cls.push_transactions_to_willexecutor
we_cls.push_transactions_to_willexecutor = staticmethod(slow_push)
try:
wes = {}
for i in range(N):
@@ -106,7 +106,7 @@ def main():
pushed.append((url, ok))
start = time.time()
results = W.push_transactions_parallel(wes, on_each=on_each_push,
results = we_cls.push_transactions_parallel(wes, on_each=on_each_push,
max_workers=N)
elapsed = time.time() - start
@@ -117,11 +117,11 @@ def main():
f"(sequential would be ~{sequential:.2f}s)")
assert len(results) == N, results
for url, (ok, exc) in results.items():
for url, (ok, _exc) in results.items():
assert ok == ("good" in url), (url, ok)
print("[OK] push results correct for every server")
finally:
W.push_transactions_to_willexecutor = orig_push
we_cls.push_transactions_to_willexecutor = orig_push
# ---- 2b) global deadline: a hung server must not block past `deadline` ----
def hanging_push(we, **kwargs):
@@ -129,8 +129,8 @@ def main():
time.sleep(10)
return True
orig_push2 = W.push_transactions_to_willexecutor
W.push_transactions_to_willexecutor = staticmethod(hanging_push)
orig_push2 = we_cls.push_transactions_to_willexecutor
we_cls.push_transactions_to_willexecutor = staticmethod(hanging_push)
try:
wes = {
"https://fast.example": {
@@ -146,7 +146,7 @@ def main():
return True
time.sleep(10)
return True
W.push_transactions_to_willexecutor = staticmethod(fast_or_hang)
we_cls.push_transactions_to_willexecutor = staticmethod(fast_or_hang)
timed_out = []
@@ -154,7 +154,7 @@ def main():
timed_out.append(url)
start = time.time()
W.push_transactions_parallel(
we_cls.push_transactions_parallel(
wes, max_workers=2, deadline=1.0, on_timeout=on_timeout
)
elapsed = time.time() - start
@@ -163,7 +163,7 @@ def main():
print(f"[OK] global deadline enforced: returned in {elapsed:.1f}s, "
f"hung server reported via on_timeout")
finally:
W.push_transactions_to_willexecutor = orig_push2
we_cls.push_transactions_to_willexecutor = orig_push2
# ---- 2c) on_tick is fired periodically from the CALLING thread ----
# The elapsed-time counter is driven by an on_tick callback called from the
@@ -175,8 +175,8 @@ def main():
time.sleep(SLOW * 6) # ~3s, long enough for several ticks
return True
orig_push3 = W.push_transactions_to_willexecutor
W.push_transactions_to_willexecutor = staticmethod(slow_push2)
orig_push3 = we_cls.push_transactions_to_willexecutor
we_cls.push_transactions_to_willexecutor = staticmethod(slow_push2)
try:
wes = {
"https://tick.example": {
@@ -191,7 +191,7 @@ def main():
ticks.append(time.time())
tick_threads.add(threading.current_thread())
W.push_transactions_parallel(
we_cls.push_transactions_parallel(
wes, max_workers=1, on_tick=on_tick, tick_interval=0.5
)
# ~3s push with 0.5s ticks => at least a few ticks.
@@ -202,7 +202,7 @@ def main():
)
print(f"[OK] on_tick fired {len(ticks)} times from the calling thread")
finally:
W.push_transactions_to_willexecutor = orig_push3
we_cls.push_transactions_to_willexecutor = orig_push3
# ---- 2d) check_transactions_parallel: parallel + deadline + on_tick ----
# Pressing "Check" verifies each will-executor still holds its tx. This used
@@ -214,8 +214,8 @@ def main():
time.sleep(SLOW)
return {"tx": "ok"} if "good" in url else None
orig_check = W.check_transaction
W.check_transaction = staticmethod(slow_check)
orig_check = we_cls.check_transaction
we_cls.check_transaction = staticmethod(slow_check)
try:
targets = []
for i in range(N):
@@ -228,7 +228,7 @@ def main():
checked.append((wid, res))
start = time.time()
results = W.check_transactions_parallel(
results = we_cls.check_transactions_parallel(
targets, on_each=on_each_check, max_workers=N
)
elapsed = time.time() - start
@@ -239,7 +239,7 @@ def main():
print(f"[OK] check parallel: {elapsed:.2f}s for {N} servers "
f"(sequential would be ~{sequential:.2f}s)")
finally:
W.check_transaction = orig_check
we_cls.check_transaction = orig_check
# 2d-bis) global deadline + on_tick from the calling thread
def hanging_check(txid, url, **kwargs):
@@ -248,8 +248,8 @@ def main():
time.sleep(10)
return {"tx": "ok"}
orig_check2 = W.check_transaction
W.check_transaction = staticmethod(hanging_check)
orig_check2 = we_cls.check_transaction
we_cls.check_transaction = staticmethod(hanging_check)
try:
targets = [
("idf", "https://fast.example"),
@@ -268,7 +268,7 @@ def main():
tick_threads.add(threading.current_thread())
start = time.time()
W.check_transactions_parallel(
we_cls.check_transactions_parallel(
targets, max_workers=2, deadline=2.0,
on_timeout=on_timeout_check, on_tick=on_tick_check,
tick_interval=0.5,
@@ -282,7 +282,7 @@ def main():
print(f"[OK] check global deadline enforced ({elapsed:.1f}s), on_tick "
f"fired {len(ticks)}x from the calling thread")
finally:
W.check_transaction = orig_check2
we_cls.check_transaction = orig_check2
# ---- 3) the wizard's loop_push must use the parallel helper ----
# The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push.

View File

@@ -17,8 +17,8 @@ import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
# Same colors as BalBuildWillDialog
COLOR_WARNING = "#cfa808"

View File

@@ -17,10 +17,15 @@ import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import ( # noqa: E402
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
)
from PyQt6.QtCore import Qt # noqa: E402
from PyQt6.QtWidgets import ( # noqa: E402
QApplication,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
COLOR_OK = "#05ad05"

View File

@@ -21,8 +21,8 @@ import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
COLOR_ERROR = "#ff0000"
COLOR_OK = "#05ad05"

View File

@@ -20,12 +20,17 @@ import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import ( # noqa: E402
QApplication, QWidget, QHBoxLayout, QPushButton, QComboBox, QLineEdit,
QLabel,
)
from PyQt6.QtCore import QSize # noqa: E402
from PyQt6.QtGui import QIcon, QPixmap # noqa: E402
from PyQt6.QtCore import QSize, Qt # noqa: E402
from PyQt6.QtWidgets import ( # noqa: E402
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QWidget,
)
ICON_PATH = os.path.join(os.path.dirname(__file__), "..", "bal", "icons",
"wizard.png")

View File

@@ -27,12 +27,19 @@ import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import ( # noqa: E402
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QComboBox,
QLineEdit, QSpinBox, QLabel,
)
from PyQt6.QtGui import QFontMetrics # noqa: E402
from PyQt6.QtCore import Qt # noqa: E402
from PyQt6.QtGui import QFontMetrics # noqa: E402
from PyQt6.QtWidgets import ( # noqa: E402
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QSpinBox,
QToolButton,
QVBoxLayout,
QWidget,
)
def _char_w():

792
tests/samanta7 Normal file

File diff suppressed because one or more lines are too long

View File

@@ -21,18 +21,21 @@ Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
"""
import sys
import os
import copy
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 (
WillItem, Will,
NotCompleteWillException, HeirNotFoundException, NoHeirsException,
TxFeesChangedException, WillExpiredException,
HeirNotFoundException,
NoHeirsException,
NotCompleteWillException,
TxFeesChangedException,
Will,
WillExpiredException,
WillItem,
)
from bal.core.util import Util
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0.
_VALID_TX_HEX = (
@@ -55,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": "",
@@ -64,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:
@@ -115,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.

View File

@@ -26,30 +26,28 @@ def main():
from PyQt6.QtWidgets import QApplication # noqa
_app = QApplication.instance() or QApplication([])
results = {}
# 1) Core modules import (these must be GUI-free).
bal = imp_core("bal", "core.plugin_base")
util = imp_core("util", "core.util")
heirs = imp_core("heirs", "core.heirs")
will = imp_core("will", "core.will")
we = imp_core("willexecutors", "core.willexecutors")
_we = imp_core("willexecutors", "core.willexecutors")
# 2) GUI module imports.
qt = imp_gui()
# 3) Behaviour checks (pure logic, must be identical across versions).
BalTimestamp = bal.BalTimestamp
assert BalTimestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
assert BalTimestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
assert str(BalTimestamp("7d")) == "7d", "BalTimestamp str"
bal_timestamp = bal.BalTimestamp
assert bal_timestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
assert bal_timestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
assert str(bal_timestamp("7d")) == "7d", "BalTimestamp str"
Util = util.Util
assert Util.is_perc("50%") is True
assert Util.is_perc("100") is False
assert Util.text_to_hex("BAL") == "42414c"
assert Util.hex_to_text("42414c") == "BAL"
assert Util.int_locktime(days=1) == 86400
util_cls = util.Util
assert util_cls.is_perc("50%") is True
assert util_cls.is_perc("100") is False
assert util_cls.text_to_hex("BAL") == "42414c"
assert util_cls.hex_to_text("42414c") == "BAL"
assert util_cls.int_locktime(days=1) == 86400
# heirs constants must keep the same column layout (very delicate!)
assert heirs.HEIR_ADDRESS == 0

View File

@@ -27,19 +27,19 @@ Run:
tests/test_anticipate_manual_locktime.py -q
"""
import sys
import os
import copy
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
import pytest # noqa: E402
import pytest # noqa: E402 # pyright: ignore[reportMissingImports]
from bal.core.util import copy_structure # noqa: E402
from bal.core.will import ( # noqa: E402
WillItem,
Will,
NotCompleteWillException,
Will,
WillExpiredException,
WillItem,
)
# A valid serialized tx (1 input + 1 output, version 2).
@@ -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)

View File

@@ -22,13 +22,13 @@ whether a fix is needed. Run:
python3 -m pytest tests/test_anticipate_past_locktime.py -q
"""
import sys
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.util import Util, LOCKTIME_THRESHOLD
from bal.core.util import LOCKTIME_THRESHOLD, Util
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,570 @@
#!/usr/bin/env python3
"""Tests for the "Rebuild automatically on new transactions" (AUTO_REBUILD)
feature.
Covers:
* the persisted ``bal_auto_rebuild`` configuration key exists and defaults
to OFF (False), and can be enabled and read back;
* the event wiring: ``Plugin._wallet_activity`` schedules the rebuild only
for the matching wallet and only when the setting is enabled;
* ``BalWindow.schedule_auto_rebuild`` debounces through ``QTimer`` and the
re-entrancy / cooldown guards;
* ``BalWindow.maybe_auto_rebuild`` reproduces the wizard's close-time flow:
- no-op when the will is still valid;
- rebuild + sign + push when a new UTXO invalidates the will (no on-chain
invalidation, the rebuilt tx is anticipated to mine before the old);
- on-chain invalidation when the check-alive threshold is already in the
past (CheckAliveError);
- on-chain invalidation when the will is already expired;
- on-chain invalidation when the anticipated locktime would fall before
the check-alive threshold (and no sign/push in that case).
Run:
source "$BAL_HOME/electrum/env/bin/activate"
QT_QPA_PLATFORM=offscreen python3 tests/test_auto_rebuild_on_new_tx.py
"""
import os
import sys
import tempfile
import time
import unittest.mock as mock
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from electrum import bitcoin, crypto # noqa: E402
from electrum.descriptor import parse_descriptor # noqa: E402
from electrum.transaction import ( # noqa: E402
PartialTxInput,
PartialTxOutput,
TxOutpoint,
)
from electrum.util import bfh # noqa: E402
from PyQt6.QtWidgets import QApplication # noqa: E402
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import bal.gui.qt.window as window_mod # noqa: E402
from bal.core.heirs import Heirs # noqa: E402
from bal.core.plugin_base import BalConfig, BalPlugin # noqa: E402
from bal.core.util import Util # noqa: E402
from bal.core.will import Will # noqa: E402
from bal.core.willexecutors import Willexecutors # noqa: E402
from bal.gui.qt.plugin import Plugin # noqa: E402
from bal.gui.qt.window import BalWindow # noqa: E402
CONFIG_KEY = "bal_auto_rebuild"
# --------------------------------------------------------------------------- #
# Fixtures
# --------------------------------------------------------------------------- #
PRIVKEY = bytes(range(32))
PUBKEY = crypto.privkey_to_pubkey(PRIVKEY)
ADDRESS = bitcoin.public_key_to_p2wpkh(PUBKEY)
SCRIPT = bitcoin.address_to_script(ADDRESS)
FUNDING_SATOSHIS = 500000
def make_funding_input(prevout_hex="11" * 32):
"""Return a fake wallet UTXO spendable by the will."""
utxo = PartialTxInput(prevout=TxOutpoint(bfh(prevout_hex), 0))
utxo.witness_utxo = PartialTxOutput.from_address_and_value(
ADDRESS, FUNDING_SATOSHIS
)
utxo._trusted_value_sats = FUNDING_SATOSHIS
utxo._TxInput__scriptpubkey = SCRIPT
utxo._TxInput__address = ADDRESS
return utxo
class FakeDB:
def __init__(self):
self._data = {}
def get(self, key, default=None):
return self._data.get(key, default)
def put(self, key, value):
self._data[key] = value
def get_transaction(self, txid):
return None
def commit(self):
pass
class FakeWallet:
def __init__(self, utxos):
self.db = FakeDB()
self.adb = None
self.network = None
self._utxos = list(utxos)
self._dust = 546
self._change_addresses = [ADDRESS]
self.labels = {}
self.save_db_calls = 0
def save_db(self):
self.save_db_calls += 1
def dust_threshold(self):
return self._dust
def has_keystore_encryption(self):
return False
def set_label(self, txid, label):
self.labels[txid] = label
def get_all_labels(self):
return dict(self.labels)
def get_label_for_txid(self, txid):
return self.labels.get(txid, "")
def get_utxos(self):
return list(self._utxos)
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
return self._change_addresses
def add_input_info(self, txin, only_der_suffix=False):
pass
def add_output_info(self, txout, only_der_suffix=False):
pass
def get_tx_info(self, tx):
class _TxInfo:
def __init__(self):
class _MinedStatus:
def height(self):
return 0
self.tx_mined_status = _MinedStatus()
return _TxInfo()
def get_transaction(self, txid):
return None
def sign_transaction(self, tx, password=None, ignore_warnings=True):
descriptor = parse_descriptor(f"wpkh({PUBKEY.hex()})")
for txin in tx.inputs():
if txin.script_descriptor is None:
txin.script_descriptor = descriptor
if txin.value_sats() is None:
txin._trusted_value_sats = FUNDING_SATOSHIS
tx.sign({PUBKEY: PRIVKEY})
class FakeConfig:
def __init__(self):
self._data = {}
self._tmpdir = tempfile.mkdtemp(prefix="bal-test-")
def electrum_path(self):
return self._tmpdir
def user_dir(self):
return self._tmpdir
def get(self, key, default=None):
return self._data.get(key, default)
def set_key(self, key, value, save=True):
self._data[key] = value
class FakeWindow:
def __init__(self, wallet):
self.wallet = wallet
self.messages = []
self.warnings = []
self.errors = []
def get_decimal_point(self):
return 0
def show_message(self, text):
self.messages.append(str(text))
def show_warning(self, text, parent=None, title=None):
self.warnings.append(str(text))
def show_error(self, text):
self.errors.append(str(text))
def show_critical(self, text):
self.errors.append(str(text))
def update_status(self):
pass
def make_controller(utxos=None):
"""Build a fully-wired BalWindow without constructing the Qt tabs."""
utxos = [make_funding_input()] if utxos is None else utxos
config = FakeConfig()
wallet = FakeWallet(utxos)
window = FakeWindow(wallet)
plugin = BalPlugin(None, config, "bal")
plugin.get_window_title = lambda title: str(title)
plugin.get_decimal_point = window.get_decimal_point
plugin.NO_WILLEXECUTOR.set(True)
plugin.AUTO_REBUILD.set(True)
ctl = BalWindow.__new__(BalWindow)
ctl.bal_plugin = plugin
ctl.window = window
ctl.wallet = wallet
ctl.will = {}
ctl.willitems = {}
ctl.willexecutors = {}
ctl.will_settings = plugin.WILL_SETTINGS.get()
Util.fix_will_settings_tx_fees(ctl.will_settings)
ctl.heirs = Heirs(wallet)
ctl.heirs["alice"] = [ADDRESS, "100000", "1y"]
ctl.heirs["bob"] = [ADDRESS, "100%", "1y"]
ctl.no_willexecutor = True
ctl.disable_plugin = False
ctl.ok = True
ctl.update_all = lambda: None
ctl._schedule_history_refresh = lambda: None
ctl._auto_rebuild_running = False
ctl._auto_rebuild_cooldown_until = 0.0
return ctl
def _no_willexecutors():
"""Force an empty will-executor list (offline tests)."""
return mock.patch.object(
Willexecutors,
"get_willexecutors",
return_value={},
)
def _single(controller):
"""Return (txid, WillItem) for the controller's single will item."""
assert len(controller.willitems) == 1, controller.willitems
return next(iter(controller.willitems.items()))
def _item_spending(controller, *prevout_hexes):
"""Return the will item whose tx spends exactly the given prevouts."""
wanted = sorted(h for h in prevout_hexes)
items = [
item
for item in controller.willitems.values()
if sorted(i.prevout.txid.hex() for i in item.tx.inputs()) == wanted
]
assert len(items) == 1, controller.willitems
return items[0]
# --------------------------------------------------------------------------- #
# Config key
# --------------------------------------------------------------------------- #
def test_auto_rebuild_config_defaults_off():
"""bal_auto_rebuild must default to OFF (False) when not yet stored."""
cfg = FakeConfig()
rebuild = BalConfig(cfg, CONFIG_KEY, False)
assert rebuild.get() is False
def test_auto_rebuild_config_can_be_enabled():
"""Once enabled and persisted, bal_auto_rebuild reads back True."""
cfg = FakeConfig()
rebuild = BalConfig(cfg, CONFIG_KEY, False)
rebuild.set(True)
assert rebuild.get() is True
assert BalConfig(cfg, CONFIG_KEY, False).get() is True
# --------------------------------------------------------------------------- #
# Event wiring (Plugin._wallet_activity)
# --------------------------------------------------------------------------- #
def test_wallet_activity_schedules_only_matching_wallet():
plugin = Plugin.__new__(Plugin)
plugin.AUTO_REBUILD = BalConfig(FakeConfig(), CONFIG_KEY, True)
wallet_a = FakeWallet([make_funding_input()])
wallet_b = FakeWallet([make_funding_input()])
scheduled = []
class _Win:
wallet = wallet_a
ok = True
disable_plugin = False
def schedule_auto_rebuild(self):
scheduled.append(self)
win = _Win()
plugin.bal_windows = {"a": win}
plugin._wallet_activity(wallet_b)
assert scheduled == [], "a different wallet must not schedule a rebuild"
plugin._wallet_activity(wallet_a)
assert scheduled == [win], "the matching wallet must schedule a rebuild"
def test_wallet_activity_skips_when_disabled():
plugin = Plugin.__new__(Plugin)
plugin.AUTO_REBUILD = BalConfig(FakeConfig(), CONFIG_KEY, False)
wallet_obj = FakeWallet([make_funding_input()])
scheduled = []
class _Win:
wallet = wallet_obj
ok = True
disable_plugin = False
def schedule_auto_rebuild(self):
scheduled.append(self)
plugin.bal_windows = {"a": _Win()}
plugin._wallet_activity(wallet_obj)
assert scheduled == [], "AUTO_REBUILD off must not schedule anything"
# --------------------------------------------------------------------------- #
# Scheduling / guards
# --------------------------------------------------------------------------- #
def test_schedule_auto_rebuild_debounces():
ctl = make_controller()
with mock.patch.object(window_mod.QTimer, "singleShot") as single_shot:
ctl.schedule_auto_rebuild()
single_shot.assert_called_once_with(
ctl._AUTO_REBUILD_DEBOUNCE_MS, ctl._run_auto_rebuild
)
def test_auto_rebuild_guards():
with _no_willexecutors():
ctl = make_controller()
ctl.prepare_will()
assert ctl._auto_rebuild_allowed() is True
# Re-entrancy guard.
ctl._auto_rebuild_running = True
assert ctl._auto_rebuild_allowed() is False
ctl._auto_rebuild_running = False
# Cooldown guard.
ctl._auto_rebuild_cooldown_until = time.time() + 100
assert ctl._auto_rebuild_allowed() is False
ctl._auto_rebuild_cooldown_until = 0.0
assert ctl._auto_rebuild_allowed() is True
# Disabled / inactive guards.
ctl.disable_plugin = True
assert ctl._auto_rebuild_allowed() is False
ctl.disable_plugin = False
ctl.ok = False
assert ctl._auto_rebuild_allowed() is False
def test_run_auto_rebuild_spawns_worker_when_allowed():
with _no_willexecutors():
ctl = make_controller()
ctl.prepare_will()
started = []
class FakeThread:
def __init__(self, target, daemon=None):
self.target = target
def start(self):
started.append(self.target)
with mock.patch.object(window_mod.threading, "Thread", FakeThread):
ctl._run_auto_rebuild()
assert len(started) == 1, "the worker thread must be spawned"
# --------------------------------------------------------------------------- #
# maybe_auto_rebuild behaviour
# --------------------------------------------------------------------------- #
def test_auto_rebuild_noop_when_disabled():
with _no_willexecutors():
ctl = make_controller()
ctl.prepare_will()
ctl.bal_plugin.AUTO_REBUILD.set(False)
txid_before, _ = _single(ctl)
result = ctl.maybe_auto_rebuild()
assert result is False
txid_after, _ = _single(ctl)
assert txid_after == txid_before, "disabled flow must not touch the will"
def test_auto_rebuild_noop_without_will():
with _no_willexecutors():
ctl = make_controller()
assert not ctl.willitems
result = ctl.maybe_auto_rebuild()
assert result is False
def test_auto_rebuild_noop_when_will_valid():
with _no_willexecutors():
ctl = make_controller()
ctl.prepare_will()
txid_before, _ = _single(ctl)
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
ctl, "_auto_sign_save_push"
) as sign:
result = ctl.maybe_auto_rebuild()
assert result is False
txid_after, _ = _single(ctl)
assert txid_after == txid_before, "a valid will must not be rebuilt"
inv.assert_not_called()
sign.assert_not_called()
def test_auto_rebuild_rebuilds_and_pushes_on_new_utxo():
with _no_willexecutors():
ctl = make_controller()
# A relative delivery recipe keeps the will coherent after the rebuild
# anticipates the locktime by one day (an absolute recipe would read the
# anticipated tx as a postpone, see check_willexecutors_and_heirs).
ctl.will_settings["locktime"] = "1y"
ctl.prepare_will()
old_txid, old_item = _single(ctl)
old_locktime = int(old_item.tx.locktime)
# An incoming payment adds a second UTXO -> the will no longer covers
# the whole wallet (NotCompleteWillException).
ctl.wallet._utxos.append(make_funding_input("22" * 32))
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
ctl, "push_transactions_to_willexecutors"
) as push, mock.patch.object(ctl, "_save_will_to_history") as history:
result = ctl.maybe_auto_rebuild()
assert result is True, "a stale will must be rebuilt"
assert inv.call_count == 0, "a plain rebuild must not invalidate on-chain"
push.assert_called_once()
history.assert_called_once()
# The rebuilt will now spends BOTH wallet UTXOs (BAL keeps the previous
# single-input transaction alongside it in the will).
new_item = _item_spending(ctl, "11" * 32, "22" * 32)
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
# The new locktime must be at most the old one, so the new tx can be mined
# before the previous will.
assert int(new_item.tx.locktime) <= old_locktime
assert new_item.get_status("COMPLETE"), "passwordless rebuild must sign"
assert new_item.get_status("VALID")
# The rebuilt will is still valid now: no further work.
assert ctl.check_will() is True
def test_auto_rebuild_invalidates_when_threshold_passed():
with _no_willexecutors():
ctl = make_controller()
ctl.prepare_will()
# ADVANCED mode with a check-alive threshold already in the past.
ctl.bal_plugin.USER_TYPE.set("advanced")
ctl.will_settings["threshold"] = int(time.time()) - 3600
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
ctl, "_auto_sign_save_push"
) as sign:
result = ctl.maybe_auto_rebuild()
assert result is True
inv.assert_called_once()
sign.assert_not_called()
def test_auto_rebuild_invalidates_when_locktime_expired():
with _no_willexecutors():
ctl = make_controller()
ctl.prepare_will()
txid, item = _single(ctl)
# Move the frozen delivery date into the past: "too late to
# anticipate" -> the old will must be invalidated on-chain.
item.tx.locktime = int(time.time()) - 2 * 86400
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
ctl, "_auto_sign_save_push"
) as sign:
result = ctl.maybe_auto_rebuild()
assert result is True
inv.assert_called_once()
sign.assert_not_called()
def test_auto_rebuild_invalidates_when_anticipation_crosses_threshold():
with _no_willexecutors():
ctl = make_controller()
now = time.time()
delivery = int(now + 3 * 86400)
ctl.will_settings["locktime"] = delivery
# ADVANCED mode: the check-alive threshold sits 12h before delivery, so
# an anticipated (delivery - 1 day) locktime falls BEFORE it.
ctl.bal_plugin.USER_TYPE.set("advanced")
ctl.will_settings["threshold"] = delivery - 12 * 3600
ctl.prepare_will()
old_txid, _ = _single(ctl)
ctl.wallet._utxos.append(make_funding_input("22" * 32))
# The rebuild itself anticipates the delivery date by one day ONLY when
# the rebuilt transactions keep the same real amounts (Will.check_anticipate,
# same coins + same heirs). Real amounts are re-computed against the
# wallet balance, so a new UTXO normally changes them and the rebuilt
# will keeps the old locktime. Force the anticipating branch here to
# exercise the "anticipated locktime crosses the threshold" handling.
with mock.patch.object(
Will, "check_anticipate", return_value=delivery - 86400
):
with mock.patch.object(
ctl, "_auto_invalidate_will"
) as inv, mock.patch.object(ctl, "_auto_sign_save_push") as sign:
result = ctl.maybe_auto_rebuild()
assert result is True
inv.assert_called_once(), (
"an anticipated locktime below the threshold must invalidate on-chain"
)
sign.assert_not_called(), (
"after an invalidation the rebuilt will must NOT be signed/pushed "
"(the wizard stops and waits for the invalidation to confirm)"
)
new_item = _item_spending(ctl, "11" * 32, "22" * 32)
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
assert int(new_item.tx.locktime) == delivery - 86400, (
"the rebuilt locktime must be anticipated by one day"
)
def _run_all():
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
for fn in tests:
print(f"{fn.__name__} ... ", end="", flush=True)
fn()
print("OK")
print(f"\n{len(tests)} tests passed")
if __name__ == "__main__":
app = QApplication.instance() or QApplication([])
_run_all()

View File

@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""Tests for the headless auto-rebuild flow (``bal_will_autorebuild``).
The CLI equivalent of the GUI AUTO_REBUILD feature:
``BalController.auto_rebuild`` runs the wizard's close-time flow in a single
call. Everything is exercised offline against a fake signing wallet (the same
fixtures the GUI tests use), so no wallet, network or Qt is needed.
Covers:
* no-op when the will is still valid (``valid``);
* rebuild + sign + push when a new UTXO invalidates the will (``rebuilt``,
no on-chain invalidation: the rebuilt tx is anticipated to mine before
the old one);
* ``needs_signing`` when the wallet is encrypted;
* on-chain invalidation when the will is already expired (``expired``);
* on-chain invalidation when the anticipated locktime crosses the check-alive
threshold (``anticipation_crossed``) - and no sign/push in that case.
The ``no_heirs`` and ``threshold_passed`` paths live in
``test_cli_controller_offline.py``.
Run:
source "$BAL_HOME/electrum/env/bin/activate"
python3 tests/test_cli_autorebuild.py
"""
import os
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))
from electrum import bitcoin, crypto
from electrum.descriptor import parse_descriptor
from electrum.simple_config import SimpleConfig
from electrum.transaction import PartialTxInput, PartialTxOutput, TxOutpoint
from electrum.util import bfh
from bal.cli.controller import BalController
from bal.core.will import Will
from bal.core.willexecutors import Willexecutors
PRIVKEY = bytes(range(32))
PUBKEY = crypto.privkey_to_pubkey(PRIVKEY)
ADDRESS = bitcoin.public_key_to_p2wpkh(PUBKEY)
SCRIPT = bitcoin.address_to_script(ADDRESS)
FUNDING_SATOSHIS = 500000
def make_funding_input(prevout_hex="11" * 32):
"""Return a fake wallet UTXO spendable by the will."""
utxo = PartialTxInput(prevout=TxOutpoint(bfh(prevout_hex), 0))
utxo.witness_utxo = PartialTxOutput.from_address_and_value(
ADDRESS, FUNDING_SATOSHIS
)
utxo._trusted_value_sats = FUNDING_SATOSHIS
utxo._TxInput__scriptpubkey = SCRIPT
utxo._TxInput__address = ADDRESS
return utxo
class FakeDB:
def __init__(self):
self._data = {}
def get(self, key, default=None):
return self._data.get(key, default)
def put(self, key, value):
self._data[key] = value
def get_dict(self, key):
return self._data.setdefault(key, {})
def get_transaction(self, txid):
return None
def add_transaction(self, tx, *args, **kwargs):
pass
class FakeWallet:
def __init__(self, utxos, encrypted=False):
self.db = FakeDB()
self.adb = None
self.network = None
self._utxos = list(utxos)
self._dust = 546
self._change_addresses = [ADDRESS]
self._encrypted = encrypted
self.labels = {}
def save_db(self):
pass
def dust_threshold(self):
return self._dust
def has_keystore_encryption(self):
return self._encrypted
def set_label(self, txid, label):
self.labels[txid] = label
def get_utxos(self):
return list(self._utxos)
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
return self._change_addresses
def add_input_info(self, txin, only_der_suffix=False):
pass
def add_output_info(self, txout, only_der_suffix=False):
pass
def get_tx_info(self, tx):
class _TxInfo:
def __init__(self):
class _MinedStatus:
def height(self):
return 0
self.tx_mined_status = _MinedStatus()
return _TxInfo()
def get_transaction(self, txid):
return None
def sign_transaction(self, tx, password=None, ignore_warnings=True):
descriptor = parse_descriptor(f"wpkh({PUBKEY.hex()})")
for txin in tx.inputs():
if txin.script_descriptor is None:
txin.script_descriptor = descriptor
if txin.value_sats() is None:
txin._trusted_value_sats = FUNDING_SATOSHIS
tx.sign({PUBKEY: PRIVKEY})
class _Plugin:
"""Real ``bal.cli.plugin.Plugin`` with an isolated config directory."""
def __init__(self):
self.tmpdir = tempfile.mkdtemp(prefix="bal_cli_autorebuild_")
from bal.cli.plugin import Plugin as RealPlugin
self.config = SimpleConfig(
{"electrum_path": self.tmpdir},
read_user_config_function=lambda path: {},
)
self.plugin = RealPlugin(None, self.config, "bal")
def __enter__(self):
return self.plugin
def __exit__(self, *exc):
shutil.rmtree(self.tmpdir, ignore_errors=True)
def _no_willexecutors():
"""Force an empty will-executor list (offline tests)."""
return mock.patch.object(
Willexecutors,
"get_willexecutors",
return_value={},
)
def _make_controller(plugin, wallet):
c = BalController(plugin, wallet)
c.will_settings["locktime"] = "1y"
c.heirs_add("alice", ADDRESS, "100000")
c.heirs_add("bob", ADDRESS, "100%")
return c
def _single(controller):
"""Return (txid, WillItem) for the controller's single will item."""
assert len(controller.willitems) == 1, controller.willitems
return next(iter(controller.willitems.items()))
def _item_spending(controller, *prevout_hexes):
"""Return the will item whose tx spends exactly the given prevouts."""
wanted = sorted(h for h in prevout_hexes)
items = [
item
for item in controller.willitems.values()
if sorted(i.prevout.txid.hex() for i in item.tx.inputs()) == wanted
]
assert len(items) == 1, controller.willitems
return items[0]
# --------------------------------------------------------------------------- #
# auto_rebuild behaviour
# --------------------------------------------------------------------------- #
def test_auto_rebuild_noop_when_will_valid():
with _no_willexecutors():
with _Plugin() as plugin:
plugin.NO_WILLEXECUTOR.set(True)
wallet = FakeWallet([make_funding_input()])
c = _make_controller(plugin, wallet)
c.prepare_will()
txid_before, _ = _single(c)
result = c.auto_rebuild()
assert result["result"] == "valid", result
assert next(iter(c.willitems)) == txid_before, (
"a valid will must not be rebuilt"
)
def test_auto_rebuild_rebuilds_and_pushes_on_new_utxo():
with _no_willexecutors():
with _Plugin() as plugin:
plugin.NO_WILLEXECUTOR.set(True)
wallet = FakeWallet([make_funding_input()])
c = _make_controller(plugin, wallet)
c.prepare_will()
old_txid, old_item = _single(c)
old_locktime = int(old_item.tx.locktime)
# An incoming payment adds a second UTXO -> the will no longer
# covers the whole wallet (NotCompleteWillException).
wallet._utxos.append(make_funding_input("22" * 32))
result = c.auto_rebuild()
assert result["result"] == "rebuilt", result
assert result["push"] == {}
new_item = _item_spending(c, "11" * 32, "22" * 32)
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
assert int(new_item.tx.locktime) <= old_locktime, (
"the rebuilt tx must be anticipatable before the old will"
)
assert new_item.get_status("COMPLETE"), "passwordless rebuild must sign"
assert new_item.get_status("VALID")
# The rebuilt will is still valid now: no further work.
assert c.check_will() is True
def test_auto_rebuild_encrypted_wallet_requires_manual_signing():
with _no_willexecutors():
with _Plugin() as plugin:
plugin.NO_WILLEXECUTOR.set(True)
wallet = FakeWallet([make_funding_input()], encrypted=True)
c = _make_controller(plugin, wallet)
c.prepare_will()
wallet._utxos.append(make_funding_input("22" * 32))
result = c.auto_rebuild()
assert result["result"] == "needs_signing", result
assert result["will"]["count"] == 2
assert not any(w.get_status("COMPLETE") for w in c.willitems.values()), (
"an encrypted wallet must never be signed without the password"
)
def test_auto_rebuild_invalidates_when_locktime_expired():
with _no_willexecutors():
with _Plugin() as plugin:
plugin.NO_WILLEXECUTOR.set(True)
wallet = FakeWallet([make_funding_input()])
c = _make_controller(plugin, wallet)
c.prepare_will()
_, item = _single(c)
# Move the frozen delivery date into the past: "too late to
# anticipate" -> the old will must be invalidated on-chain.
item.tx.locktime = int(time.time()) - 2 * 86400
result = c.auto_rebuild()
assert result["result"] == "invalidated", result
assert result["reason"] == "expired"
assert result["invalidation_tx"]["txid"] is not None
assert result["invalidation_tx"]["tx"]
assert not any(w.get_status("COMPLETE") for w in c.willitems.values())
def test_auto_rebuild_invalidates_when_anticipation_crosses_threshold():
with _no_willexecutors():
with _Plugin() as plugin:
now = time.time()
delivery = int(now + 3 * 86400)
# ADVANCED mode: the check-alive threshold sits 12h before delivery,
# so an anticipated (delivery - 1 day) locktime falls BEFORE it.
plugin.USER_TYPE.set("advanced")
wallet = FakeWallet([make_funding_input()])
plugin.NO_WILLEXECUTOR.set(True)
c = _make_controller(plugin, wallet)
c.will_settings["locktime"] = delivery
c.will_settings["threshold"] = delivery - 12 * 3600
c.prepare_will()
old_txid, _ = _single(c)
wallet._utxos.append(make_funding_input("22" * 32))
# Force the anticipating branch (see the GUI test for the rationale:
# with a new UTXO the real amounts change, so the natural rebuild
# keeps the old locktime).
with mock.patch.object(
Will, "check_anticipate", return_value=delivery - 86400
):
result = c.auto_rebuild()
assert result["result"] == "invalidated", result
assert result["reason"] == "anticipation_crossed"
assert not any(w.get_status("COMPLETE") for w in c.willitems.values()), (
"after an invalidation the rebuilt will must NOT be signed/pushed "
"(the wizard stops and waits for the invalidation to confirm)"
)
new_item = _item_spending(c, "11" * 32, "22" * 32)
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
assert int(new_item.tx.locktime) == delivery - 86400, (
"the rebuilt locktime must be anticipated by one day"
)
def _run_all():
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
for fn in tests:
print(f"{fn.__name__} ... ", end="", flush=True)
fn()
print("OK")
print(f"\n{len(tests)} tests passed")
if __name__ == "__main__":
_run_all()

View File

@@ -0,0 +1,150 @@
"""
Test: BAL plugin CLI commands are registered with Electrum.
Verifies that importing the plugin through Electrum's own plugin loader
(``Plugins(config, cmd_only=True)``, the exact code path ``run_electrum`` uses
to pre-parse the command line) registers every ``bal_*`` command with
``electrum.commands`` (``known_commands`` + the ``Commands`` class).
It also asserts the basic contract enforced by ``plugin_command``: each command
is a coroutine and carries the expected flags (all ``bal_*`` commands require a
daemon/network, i.e. the ``'n'`` flag; the wallet-bound ones the ``'w'`` flag;
signing also ``'p'``).
Run:
source "$BAL_HOME/electrum/env/bin/activate"
python3 tests/test_cli_commands_registered.py
"""
import inspect
import tempfile
from electrum import commands as electrum_commands
from electrum.plugin import Plugins
from electrum.simple_config import SimpleConfig
# The full command table lives in PLAN_CMDLINE_PLUGIN.md section 6; new commands
# added in later phases must be appended here so the registration test keeps
# proving the whole list is wired up.
EXPECTED_COMMANDS = {
# Settings (no wallet required)
"bal_settings_list": {
"requires_network": True,
"requires_wallet": False,
"requires_password": False,
},
"bal_settings_get": {
"requires_network": True,
"requires_wallet": False,
"requires_password": False,
},
"bal_settings_set": {
"requires_network": True,
"requires_wallet": False,
"requires_password": False,
},
"bal_settings_reset": {
"requires_network": True,
"requires_wallet": False,
"requires_password": False,
},
# Heirs
"bal_heirs_list": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_show": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_add": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_update": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_delete": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_import": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
# Will-Executors
"bal_willexecutors_list": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_show": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_add": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_update": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_select": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_delete": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_ping": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_download": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_import": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
# Will
"bal_will_status": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_check": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_prepare": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_autorebuild": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_sign": {"requires_network": True, "requires_wallet": True, "requires_password": True},
"bal_will_broadcast": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_import_merge": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_invalidate": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_check_executor": {"requires_network": True, "requires_wallet": True, "requires_password": False},
}
def _isolated_config(**overrides):
"""A throwaway SimpleConfig that never touches the real Electrum config.
A fresh ``electrum_path`` temp dir keeps every write isolated, so running
the tests cannot pollute the user's config files. The bal plugin is
enabled because ``Plugins(cmd_only=True)`` skips any plugin that is not
explicitly enabled (electrum.plugin.Plugins.find_directory_plugins).
"""
opts = {"electrum_path": tempfile.mkdtemp(prefix="bal_test_")}
opts.update(overrides)
cfg = SimpleConfig(opts)
cfg.enable_plugin("bal")
return cfg
def test_commands_registered():
cfg = _isolated_config()
Plugins(cfg, cmd_only=True)
for name, flags in EXPECTED_COMMANDS.items():
assert name in electrum_commands.known_commands, f"{name} not registered"
cmd = electrum_commands.known_commands[name]
assert cmd.name == name
assert cmd.requires_network is flags["requires_network"]
assert cmd.requires_wallet is flags["requires_wallet"]
assert cmd.requires_password is flags["requires_password"]
def test_commands_are_coroutines():
cfg = _isolated_config()
Plugins(cfg, cmd_only=True)
for name in EXPECTED_COMMANDS:
func = getattr(electrum_commands.Commands, name, None)
assert func is not None, f"{name} missing from Commands"
assert inspect.iscoroutinefunction(func), f"{name} is not a coroutine"
def test_no_duplicate_registration():
"""Loading the plugin twice must not raise "Command name bal_... already
exists" (the guard in bal/__init__._register_cli_commands)."""
cfg = _isolated_config()
plugins = Plugins(cfg, cmd_only=True)
plugins.maybe_load_plugin_init_method("bal") # already imported -> no-op
for name in EXPECTED_COMMANDS:
assert name in electrum_commands.known_commands
def test_command_docstrings_document_all_args():
"""Every parameter/option must carry an ``arg:TYPE:NAME:DESC`` line (the
CLI parser prints "undocumented argument ..." otherwise)."""
cfg = _isolated_config()
Plugins(cfg, cmd_only=True)
for name in EXPECTED_COMMANDS:
cmd = electrum_commands.known_commands[name]
for varname in list(cmd.params) + list(cmd.options):
if varname in ("wallet", "wallet_path", "plugin", "password"):
continue
assert varname in cmd.arg_descriptions, (
f"{name}: undocumented argument {varname}"
)
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All CLI registration tests passed")

View File

@@ -0,0 +1,288 @@
"""
Offline tests for the headless ``bal.cli.controller.BalController``.
These run without a wallet, a network or Qt: the controller is exercised
against a ``FakeWallet`` plus a real ``bal.cli.plugin.Plugin`` backed by an
isolated in-memory ``SimpleConfig``. Only the flows that never touch the
network (settings/heirs/willexecutors CRUD, status snapshots, error mapping)
are covered here; build/sign/push flows need a live wallet and network and are
exercised by the group tests instead.
Run:
source electrum/env/bin/activate
python3 tests/test_cli_controller_offline.py
"""
import os
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))
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"
class FakeDB:
def __init__(self):
self._data = {}
def get(self, key, default=None):
return self._data.get(key, default)
def put(self, key, value):
self._data[key] = value
def get_dict(self, key):
return self._data.setdefault(key, {})
def get_transaction(self, txid):
return None
def add_transaction(self, tx, *args, **kwargs):
pass
class FakeWallet:
def __init__(self):
self.db = FakeDB()
self.network = None
self.adb = None
self._dust = 500
def save_db(self):
pass
def dust_threshold(self):
return self._dust
def has_keystore_encryption(self):
return False
def set_label(self, txid, text):
pass
def get_utxos(self):
return []
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
return [VALID_ADDRESS]
class Plugin:
"""Real ``bal.cli.plugin.Plugin`` with an isolated config directory."""
def __init__(self):
self.tmpdir = tempfile.mkdtemp(prefix="bal_cli_test_")
from bal.cli.plugin import Plugin as RealPlugin
self.config = SimpleConfig(
{"electrum_path": self.tmpdir},
read_user_config_function=lambda path: {},
)
self.plugin = RealPlugin(None, self.config, "bal")
def __enter__(self):
return self.plugin
def __exit__(self, *exc):
shutil.rmtree(self.tmpdir, ignore_errors=True)
def _make_controller(plugin):
return BalController(plugin, FakeWallet())
def test_controller_init_empty():
with Plugin() as plugin:
c = _make_controller(plugin)
assert c.willitems == {}
assert c.will == {}
assert c.heirs == {}
assert isinstance(c.will_settings, dict)
assert "baltx_fees" in c.will_settings
# Fresh config: no stored will-executors. On mainnet the default
# WILLEXECUTORS table is keyed by "mainnet" while chainname is
# "bitcoin", so nothing is injected either.
assert c.willexecutors == {}
assert c.no_willexecutor is False
def test_settings_roundtrip():
with Plugin() as plugin:
c = _make_controller(plugin)
listing = c.settings_list()
assert "BAL_TX_FEES" in listing or "TX_FEES" in listing
tx_key = "BAL_TX_FEES" if "BAL_TX_FEES" in listing else "TX_FEES"
assert c.settings_get(tx_key)["value"] == 100
c.settings_set("bal_tx_fees", "150")
assert c.settings_get("bal_tx_fees")["value"] == 150
assert c.settings_get("TX_FEES")["value"] == 150
c.settings_set("bal_no_willexecutor", "true")
assert c.settings_get("bal_no_willexecutor")["value"] is True
c.settings_reset("bal_tx_fees")
assert c.settings_get("bal_tx_fees")["value"] == 100
def test_settings_unknown_key():
with Plugin() as plugin:
c = _make_controller(plugin)
try:
c.settings_get("bal_does_not_exist")
raise AssertionError("expected UserFacingException")
except UserFacingException as e:
assert "Unknown BAL setting" in str(e)
def test_heirs_crud():
with Plugin() as plugin:
c = _make_controller(plugin)
c.heirs_add("alice", VALID_ADDRESS, "100000")
assert c.heirs["alice"][0] == VALID_ADDRESS
assert c.heirs["alice"][1] == "100000"
c.heirs_update("alice", amount="200000")
assert c.heirs["alice"][1] == "200000"
assert c.heirs_show("alice")["value"][1] == "200000"
assert "alice" in c.heirs_list()
c.heirs_delete(["alice"])
assert "alice" not in c.heirs_list()
def test_heirs_add_op_return():
with Plugin() as plugin:
c = _make_controller(plugin)
c.heirs_add("note", "OP_RETURN:6a0242414c", "100000")
assert c.heirs["note"][1] == "0"
def test_willexecutors_crud():
with Plugin() as plugin:
c = _make_controller(plugin)
assert c.willexecutors == {}
new_url = "https://executor.example.invalid"
c.willexecutors_add(new_url, address="", base_fee=250)
assert c.willexecutors_show(new_url)["willexecutor"]["base_fee"] == 250
assert c.willexecutors_show(new_url)["willexecutor"]["selected"] is False
c.willexecutors_update(new_url, base_fee="300", info="Example executor")
assert c.willexecutors_show(new_url)["willexecutor"]["base_fee"] == 300
c.willexecutors_select([new_url], select=True)
assert c.willexecutors_show(new_url)["willexecutor"]["selected"] is True
renamed = "https://executor2.example.invalid"
c.willexecutors_update(new_url, rename_to=renamed)
assert renamed in c.willexecutors
assert new_url not in c.willexecutors
assert c.willexecutors_delete([renamed]) == {"deleted": [renamed]}
assert renamed not in c.willexecutors
def test_will_status_empty():
with Plugin() as plugin:
c = _make_controller(plugin)
status = c.will_status()
assert status["count"] == 0
assert status["items"] == []
def test_will_check_no_heirs_raises():
with Plugin() as plugin:
c = _make_controller(plugin)
try:
c.will_check()
raise AssertionError("expected UserFacingException")
except UserFacingException as e:
assert "heir" in str(e).lower()
def test_auto_rebuild_no_heirs():
with Plugin() as plugin:
c = _make_controller(plugin)
assert c.auto_rebuild() == {"result": "no_heirs"}
def test_auto_rebuild_threshold_passed_invalidates():
with Plugin() as plugin:
c = _make_controller(plugin)
c.heirs_add("alice", VALID_ADDRESS, "100000")
plugin.USER_TYPE.set("advanced")
c.will_settings["threshold"] = int(time.time()) - 3600
result = c.auto_rebuild()
assert result["result"] == "invalidated"
assert result["reason"] == "threshold_passed"
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
# ------------------------------------------------------------------ #
def main():
failures = 0
for name, fn in sorted(globals().items()):
if not name.startswith("test_") or not callable(fn):
continue
print(f" {name}")
try:
fn()
except Exception as e:
failures += 1
print(f" [FAIL] {name}: {e!r}")
if failures:
print(f"[FAIL] {failures} test(s) failed")
sys.exit(1)
print("[OK] All offline controller tests passed")
if __name__ == "__main__":
main()

View 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")

View File

@@ -0,0 +1,249 @@
"""
Tests for ``bal.core.checkalive`` (pure, GUI-free).
Covers the CheckAliveError exception and the BASIC/ADVANCED date_to_check
policy (``resolve_date_to_check`` / ``check_alive_expired``).
Run:
source "$BAL_HOME/electrum/env/bin/activate"
python3 tests/test_core_checkalive.py
"""
import sys
import time
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.checkalive import ( # noqa: E402 (path insert above)
CheckAliveError,
check_alive_expired,
resolve_date_to_check,
resolve_guard_threshold,
)
# ------------------------------------------------------------------ #
# CheckAliveError
# ------------------------------------------------------------------ #
def test_check_alive_error_default():
err = CheckAliveError(1000000)
assert err.timestamp_to_check == 1000000
def test_check_alive_error_str():
err = CheckAliveError(1000000)
s = str(err)
assert "Check alive expired" in s
assert "1970" in s
def test_check_alive_error_subclass():
assert issubclass(CheckAliveError, Exception)
# ------------------------------------------------------------------ #
# resolve_date_to_check
# ------------------------------------------------------------------ #
def test_basic_mode_uses_now():
fake_now = 1_800_000_000.0
assert resolve_date_to_check(True, {}, now=fake_now) == fake_now
def test_advanced_mode_uses_threshold_absolute():
threshold = time.time() + 5 * 86400
settings = {"threshold": threshold}
result = resolve_date_to_check(False, settings)
assert abs(result - threshold) < 1
def test_advanced_mode_parses_relative_threshold():
# A relative threshold means "N days BEFORE the delivery": it resolves
# against the stored locktime (backwards), not forward from now.
from datetime import datetime, timedelta, timezone
fake_now = 1_800_000_000.0
locktime = fake_now + 90 * 86400
settings = {"threshold": "30d", "locktime": locktime}
result = resolve_date_to_check(False, settings, now=fake_now)
# date_to_check = (locktime, midnight-normalised) - 30 days.
expected = (datetime.fromtimestamp(locktime, tz=timezone.utc)
.replace(hour=0, minute=0, second=0, microsecond=0)
- timedelta(days=30)).timestamp()
assert abs(result - expected) < 1
# 90d delivery with a 30d window: the window starts 60 days after now.
assert result > fake_now
def test_advanced_mode_relative_threshold_anchored_to_locktime():
"""A relative threshold never drifts with the clock: re-resolving it a day
later, with the same fixed absolute locktime, yields the same date."""
fake_now = 1_800_000_000.0
locktime = fake_now + 90 * 86400
settings = {"threshold": "30d", "locktime": locktime}
first = resolve_date_to_check(False, settings, now=fake_now)
# Next day: same stored settings (the fixed delivery), a later clock.
second = resolve_date_to_check(False, settings, now=fake_now + 86400)
assert first == second
def test_advanced_mode_relative_threshold_with_relative_locktime():
"""A relative locktime is resolved against 'now' first, then the relative
threshold counts N days back from it (matches the settings widget)."""
from datetime import datetime, timezone
from bal.core.plugin_base import BalTimestamp
fake_now = 1_800_000_000.0
settings = {"threshold": "30d", "locktime": "90d"}
result = resolve_date_to_check(False, settings, now=fake_now)
# Recompute the expected value with the same resolution rules:
# locktime = now + 90d (midnight-normalised), threshold = locktime - 30d.
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc))
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
assert abs(result - expected) < 1
assert result > fake_now
def test_advanced_mode_relative_threshold_no_locktime_falls_back():
# Without a locktime reference, fall back to the legacy forward resolution.
settings = {"threshold": "30d"}
result = resolve_date_to_check(False, settings)
assert result > time.time()
def test_advanced_mode_relative_locktime_anchored_to_built_tx():
"""A RELATIVE stored locktime is anchored to the built will's frozen
delivery date (built_locktime), not to "now": an unchanged will must not
read as expired as the clock advances (the karen7 daily-invalidate bug)."""
frozen = 1817424000 # frozen tx locktime (2027-08-05 00:00 UTC), built 2026-08-05
settings = {"threshold": "30d", "locktime": "2y"}
# On build day the frozen delivery is authoritative: date_to_check is
# frozen - 30d and NEVER drifts, however much later the clock gets.
first = resolve_date_to_check(
False, settings, now=1_800_000_000.0, built_locktime=frozen
)
assert abs(first - (frozen - 30 * 86400)) < 1
later = resolve_date_to_check(
False, settings, now=1_800_000_000.0 + 10 * 86400, built_locktime=frozen
)
assert first == later
# The check window must start BEFORE the frozen delivery (never expired).
assert first < frozen
def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
"""Without a built will there is no anchor: keeps the legacy now-based
resolution (a moving target, used only before the first build)."""
from datetime import datetime, timezone
from bal.core.plugin_base import BalTimestamp
fake_now = 1_800_000_000.0
settings = {"threshold": "30d", "locktime": "90d"}
result = resolve_date_to_check(False, settings, now=fake_now)
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc))
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
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
# ------------------------------------------------------------------ #
def test_basic_mode_never_expired():
assert check_alive_expired(True, 1_000_000_000.0) is False
assert check_alive_expired(True, time.time() - 10_000) is False
def test_advanced_expired_when_past():
assert check_alive_expired(False, time.time() - 10_000) is True
def test_advanced_not_expired_when_future():
assert check_alive_expired(False, time.time() + 10_000) is False
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All core checkalive tests passed")

View File

@@ -9,22 +9,34 @@ Run:
python3 tests/test_core_heirs.py
"""
import sys
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.heirs import (
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
HEIR_DUST_AMOUNT, TRANSACTION_LABEL,
create_op_return_script,
HEIR_ADDRESS,
HEIR_AMOUNT,
HEIR_DUST_AMOUNT,
HEIR_LOCKTIME,
HEIR_REAL_AMOUNT,
OP_RETURN_PREFIX,
TRANSACTION_LABEL,
AliasNotFoundException,
NotAnAddress, AmountNotValid, LocktimeNotValid,
HeirExpiredException, HeirAmountIsDustException,
NoHeirsException, WillExecutorFeeException,
AmountNotValid,
BalanceTooLowException,
HeirAmountIsDustException,
Heirs,
LocktimeNotValid,
NoHeirsException,
NotAnAddress,
WillExecutorFeeException,
create_op_return_script,
get_op_return_hex,
is_op_return_address,
validate_op_return_hex,
)
from bal.core.util import Util
# ------------------------------------------------------------------ #
# Constants
@@ -68,7 +80,7 @@ def test_op_return_empty():
def test_op_return_too_big():
try:
create_op_return_script("ab" * 81) # 81 bytes > max 80
assert False, "expected ValueError"
raise AssertionError("expected ValueError")
except ValueError:
pass
@@ -156,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)
# ------------------------------------------------------------------ #
@@ -177,13 +215,13 @@ def test_validate_amount():
# Invalid
try:
Heirs.validate_amount("0.000000001")
assert False, "expected AmountNotValid"
raise AssertionError("expected AmountNotValid")
except AmountNotValid:
pass
try:
Heirs.validate_amount("-1")
assert False, "expected AmountNotValid"
raise AssertionError("expected AmountNotValid")
except AmountNotValid:
pass
@@ -207,7 +245,7 @@ def test_validate_locktime_expired():
past = int(time.time()) - 86400 # yesterday
try:
Heirs.validate_locktime(str(past), timestamp_to_check=past + 1)
assert False, "expected LocktimeNotValid"
raise AssertionError("expected LocktimeNotValid")
except LocktimeNotValid:
pass
@@ -258,9 +296,99 @@ def test_validate_removes_invalid():
assert "alice" in result or True # may or may not pass address check
# ------------------------------------------------------------------ #
# OP_RETURN helpers
# ------------------------------------------------------------------ #
def test_op_return_prefix_constant():
assert OP_RETURN_PREFIX == "OP_RETURN:"
def test_is_op_return_address():
assert is_op_return_address("OP_RETURN:48656c6c6f")
assert not is_op_return_address("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq")
assert not is_op_return_address("")
assert not is_op_return_address("OP_RETURN")
assert not is_op_return_address("OP_RETURNX:")
def test_get_op_return_hex():
assert get_op_return_hex("OP_RETURN:48656c6c6f") == "48656c6c6f"
assert get_op_return_hex("bc1q...") is None
assert get_op_return_hex("") is None
def test_validate_op_return_hex_valid():
validate_op_return_hex("48656c6c6f")
def test_validate_op_return_hex_invalid():
try:
validate_op_return_hex("nothex!!")
raise AssertionError("expected NotAnAddress")
except NotAnAddress:
pass
def test_validate_op_return_hex_too_long():
try:
validate_op_return_hex("ab" * 81)
raise AssertionError("expected NotAnAddress")
except NotAnAddress:
pass
def test_validate_op_return_hex_empty():
validate_op_return_hex("")
def test_validate_address_op_return():
addr = "OP_RETURN:48656c6c6f"
result = Heirs.validate_address(addr)
assert result == addr
def test_validate_heir_op_return():
k = "test_op_return"
v = ["OP_RETURN:48656c6c6f", "0", "30d"]
result = Heirs.validate_heir(k, v)
assert result[0] == "OP_RETURN:48656c6c6f"
assert result[1] == "0"
# ------------------------------------------------------------------ #
# Heirs class OP_RETURN integration
# ------------------------------------------------------------------ #
def test_heirs_fixed_percent_skips_op_return():
class FakeWallet:
class FakeDB:
def __init__(self):
self._data = {}
def get(self, key, default=None):
return self._data.get(key, default)
def put(self, key, value):
self._data[key] = value
def __init__(self):
self.db = self.FakeDB()
self.dust_threshold = lambda: 500
wallet = FakeWallet()
heirs = Heirs(wallet)
heirs["op_ret"] = ["OP_RETURN:48656c6c6f", "0", "9999999999"]
heirs["normal"] = ["addr1", "10000", "9999999999"]
fixed_h, fixed_amt, perc_h, perc_amt, fixed_with_dust = (
heirs.fixed_percent_lists_amount(0, 500)
)
assert "op_ret" in fixed_h
assert "normal" in fixed_h
assert fixed_h["op_ret"][HEIR_REAL_AMOUNT] == 0
assert fixed_h["normal"][HEIR_REAL_AMOUNT] == 10000
assert fixed_amt == 10000 # OP_RETURN adds 0
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print(f"[OK] All heirs tests passed")
print("[OK] All heirs tests passed")

View File

@@ -8,19 +8,20 @@ Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py
"""
import sys
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.heirs import (
Heirs, create_op_return_script, reduce_outputs,
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
HEIR_AMOUNT,
Heirs,
create_op_return_script,
reduce_outputs,
)
from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ #
# Heirs db-dependent methods
# ------------------------------------------------------------------ #
@@ -139,6 +140,7 @@ def test_prepare_lists_mixed_dust_continues():
"dust": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", "1%", "30d"],
})
raised = False
result = None
try:
result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
except HeirAmountIsDustException:
@@ -165,6 +167,7 @@ def test_prepare_lists_multi_locktime_continues():
"late_valid": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 5000, "60d"],
})
raised = False
result = None
try:
result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
except HeirAmountIsDustException:
@@ -188,7 +191,7 @@ def test_validate_address_invalid():
from bal.core.heirs import NotAnAddress
try:
Heirs.validate_address("bad")
assert False, "should have raised"
raise AssertionError("should have raised")
except NotAnAddress:
pass

Some files were not shown because too many files have changed in this diff Show More