gui+cli+core: anticipated-delivery warning, task #67 amount checks, CLI tools
GUI - "Building your will" row (owner request):
- when a rebuild anticipates the delivery date by one day, show
"Anticipated - new delivery date: <date>" on the "Building your will" row
instead of a plain "Ok";
- the detection compares the will's minimum locktime BEFORE and AFTER the
rebuild, so it also covers relative recipes ("1y"/"30d") that the previous
stored-vs-built comparison missed;
- the sign explanation stays on its own "Signing your will" row so it no
longer overwrites the anticipation warning.
Task #67 - "Checking variables" stuck on "Wait":
- core: Will.check_amounts only requires a fixed amount to be funded when it
is > 0, and only requires percentages to total 100% when the will actually
has percentage heirs; fixed-only / empty-wallet wills no longer raise;
- gui: the amount warning is written on the "Checking variables" row itself
(COLOR_WARNING) instead of being overwritten by the next row; the redundant
"adjustment of the amounts" sentence was dropped.
CLI - will-executor helpers:
- willexecutors_update accepts promo_code;
- willexecutors_select supports bulk apply with only_valid;
- willexecutors_export supports selected/valid subsets;
- import overwrites existing records with sane defaults;
- raise NoServersForChainError with a clear user-facing message; map
NotCompleteWill / HeirChange / WillexecutorChange in _user_facing.
Tests and docs:
- tests for the anticipation row, the CLI helpers and the amount checks;
- i18n catalogs refreshed; CHANGELOG section 67; agent task memory updated.
This commit is contained in:
@@ -485,8 +485,8 @@ ROW LABELS and, for each, a variable STATUS text.
|
|||||||
|--------------------------------------------------------------------------------|--------|-------------|
|
|--------------------------------------------------------------------------------|--------|-------------|
|
||||||
| Check Alive Threshold Passed: you have to Invalidate your old Will | RED | CheckAliveError + an invalidate tx exists (~559) |
|
| Check Alive Threshold Passed: you have to Invalidate your old Will | RED | CheckAliveError + an invalidate tx exists (~559) |
|
||||||
| No Heirs | RED | NoHeirsException (~564) |
|
| No Heirs | RED | NoHeirsException (~564) |
|
||||||
| Ok | GREEN | check_amounts passed (~579) |
|
| Ok | GREEN | check_amounts passed |
|
||||||
| In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts | (warning popup) | AmountException (~583) |
|
| In the inheritance process, the entire wallet will always be fully emptied. | ORANGE | AmountException (warning on the row itself, not a popup) |
|
||||||
|
|
||||||
### ROW 2 — "Checking your will" outcomes (this is where "Heir not found" / "New" appear)
|
### ROW 2 — "Checking your will" outcomes (this is where "Heir not found" / "New" appear)
|
||||||
| Status text shown | Color | When (code) | Triggers rebuild? |
|
| Status text shown | Color | When (code) | Triggers rebuild? |
|
||||||
|
|||||||
@@ -1,231 +0,0 @@
|
|||||||
# 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).
|
|
||||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -3759,3 +3759,64 @@ into children that are still present in the will.
|
|||||||
clean on the touched files.
|
clean on the touched files.
|
||||||
|
|
||||||
**Outcome:** DONE.
|
**Outcome:** DONE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 67. "Checking variables" stuck on "Wait" + redundant amount-adjustment wording
|
||||||
|
|
||||||
|
**Date:** 2026-10-14
|
||||||
|
|
||||||
|
**Symptom (owner report):** in the "Building Will" dialog the "Checking
|
||||||
|
variables" row always stayed on "Wait" and never became "Ok", even though the
|
||||||
|
will itself was fine ("Checking your will" showed "Ok"). It should show its
|
||||||
|
result before the "Checking your will" row is written.
|
||||||
|
|
||||||
|
**Root cause (two bugs):**
|
||||||
|
|
||||||
|
1. UI - when `Will.check_amounts` raised `AmountException` /
|
||||||
|
`WillExecutorFeeTooHighException`, `BalBuildWillDialog.task_phase1` wrote the
|
||||||
|
warning to the **"Checking your will"** row (`msg_set_checking(...)`) and the
|
||||||
|
very next `self.msg_set_checking()` call immediately overwrote it. So on an
|
||||||
|
amount warning the warning was lost AND the "Checking variables" row kept its
|
||||||
|
initial "Wait" forever (the success path set "Ok" only when the amount check
|
||||||
|
passed).
|
||||||
|
2. Core - `Will.check_amounts` had two false positives that made perfectly valid
|
||||||
|
wills raise:
|
||||||
|
- `fixed_amount >= wallet_balance` also fired when `fixed_amount == 0`
|
||||||
|
(all-percentage or empty/fully-committed wallet: `0 >= 0`);
|
||||||
|
- `perc_amount != 100` also fired when the will has NO percentage heirs at
|
||||||
|
all (a fixed-only will reports `perc_amount == 0`).
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
|
||||||
|
- `bal/gui/qt/dialogs.py` (`task_phase1`, AmountException branch): the warning is
|
||||||
|
now written on the **"Checking variables"** row itself with `COLOR_WARNING`, so
|
||||||
|
the row always reaches a real outcome and the warning is never lost. The
|
||||||
|
wording was also reduced to the first sentence only - "In the inheritance
|
||||||
|
process, the entire wallet will always be fully emptied." - dropping
|
||||||
|
"Your settings require an adjustment of the amounts" (owner request).
|
||||||
|
- `bal/gui/qt/window.py` (`build_inheritance_transaction`, AmountException
|
||||||
|
branch): same message reduction; the appended exception detail (`.format(e)`)
|
||||||
|
was removed with the sentence it belonged to.
|
||||||
|
- `bal/core/will.py` (`check_amounts`): the fixed-amount checks now only apply
|
||||||
|
when `fixed_amount > 0`, and the 100% check only when the will actually has
|
||||||
|
percentage heirs (`perc_heirs`). Valid fixed-only / empty-wallet wills no
|
||||||
|
longer raise.
|
||||||
|
- i18n catalogs refreshed (`pybabel extract` + `update`); the new unified msgid
|
||||||
|
"In the inheritance process, the entire wallet will always be fully emptied."
|
||||||
|
is translated in `it_IT` and `es_ES`; the old
|
||||||
|
"...adjustment of the amounts.{}" entry is now obsolete.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
- `Will.check_amounts` matrix (fixed-only/percentage/empty wallet) behaves as
|
||||||
|
expected; core/offline suites plus `test_gui_prepare_will_history.py`,
|
||||||
|
`test_sync_locktime_built_txs.py`, `test_rebuild_on_close_setting.py`,
|
||||||
|
`test_translations.py` and the smoke test pass.
|
||||||
|
- Offscreen repro of `task_phase1`: "Checking variables" shows green "Ok" on
|
||||||
|
success and the (shortened) warning on the row on `AmountException` - never
|
||||||
|
"Wait".
|
||||||
|
- `py_compile` clean; `ruff check` adds no new violations (only the pre-existing
|
||||||
|
`I001` in `dialogs.py`).
|
||||||
|
|
||||||
|
**Outcome:** DONE (pending the owner's test of the ZIP).
|
||||||
|
|||||||
@@ -238,24 +238,26 @@ async def willexecutors_update(
|
|||||||
wallet,
|
wallet,
|
||||||
"willexecutors_update",
|
"willexecutors_update",
|
||||||
url,
|
url,
|
||||||
address,
|
address=address,
|
||||||
base_fee,
|
base_fee=base_fee,
|
||||||
info,
|
info=info,
|
||||||
promo_code,
|
promo_code=promo_code,
|
||||||
rename_to,
|
rename_to=rename_to,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@plugin_command("nw", plugin_name)
|
@plugin_command("nw", plugin_name)
|
||||||
async def willexecutors_select(
|
async def willexecutors_select(
|
||||||
self, url, value=True, wallet=None, plugin=None
|
self, url=None, value=True, only_valid=False, wallet=None, plugin=None
|
||||||
):
|
):
|
||||||
"""Select (or deselect) a will-executor.
|
"""Select (or deselect) a will-executor, or bulk-apply a selection.
|
||||||
|
|
||||||
arg:str:url:The will-executor URL.
|
arg:str:url:The will-executor URL; omit to act on every executor.
|
||||||
arg:bool:value:True to select, False to deselect.
|
arg:bool:value:True to select, False to deselect.
|
||||||
|
arg:bool:only_valid:When selecting/deselecting all, only change the valid executors (the invalid ones are deselected/left alone).
|
||||||
"""
|
"""
|
||||||
return _call(plugin, wallet, "willexecutors_select", [url], value)
|
urls = [url] if url is not None else None
|
||||||
|
return _call(plugin, wallet, "willexecutors_select", urls, value, only_valid)
|
||||||
|
|
||||||
|
|
||||||
@plugin_command("nw", plugin_name)
|
@plugin_command("nw", plugin_name)
|
||||||
@@ -298,12 +300,13 @@ async def willexecutors_import(self, path, wallet=None, plugin=None):
|
|||||||
|
|
||||||
|
|
||||||
@plugin_command("nw", plugin_name)
|
@plugin_command("nw", plugin_name)
|
||||||
async def willexecutors_export(self, path, wallet=None, plugin=None):
|
async def willexecutors_export(self, path, subset=None, wallet=None, plugin=None):
|
||||||
"""Export the will-executors to a JSON file.
|
"""Export the will-executors to a JSON file.
|
||||||
|
|
||||||
arg:str:path:Destination file path.
|
arg:str:path:Destination file path.
|
||||||
|
arg:str:subset:Optional filter: ``selected`` (only selected executors) or ``valid`` (only valid ones); omit to export all.
|
||||||
"""
|
"""
|
||||||
return _call(plugin, wallet, "willexecutors_export", path)
|
return _call(plugin, wallet, "willexecutors_export", path, subset)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -88,6 +88,27 @@ def _user_facing(e):
|
|||||||
return UserFacingException(_("Will-Executor not present"))
|
return UserFacingException(_("Will-Executor not present"))
|
||||||
if isinstance(e, will_mod.NoWillExecutorNotPresent):
|
if isinstance(e, will_mod.NoWillExecutorNotPresent):
|
||||||
return UserFacingException(_("No backup transaction or will-executor selected"))
|
return UserFacingException(_("No backup transaction or will-executor selected"))
|
||||||
|
if isinstance(e, will_mod.HeirChangeException):
|
||||||
|
return UserFacingException(_("The heirs changed: a new will must be prepared."))
|
||||||
|
if isinstance(e, will_mod.WillexecutorChangeException):
|
||||||
|
return UserFacingException(
|
||||||
|
_("A will-executor changed: a new will must be prepared.")
|
||||||
|
)
|
||||||
|
if isinstance(e, will_mod.NotCompleteWillException):
|
||||||
|
# ``WillException.__init__`` stores the detail in ``self.msg`` but calls
|
||||||
|
# ``Exception.__init__(self)`` without it, so ``e.args`` is always empty;
|
||||||
|
# the default sentinel message is "WillException".
|
||||||
|
detail = getattr(e, "msg", None)
|
||||||
|
if detail and detail != "WillException":
|
||||||
|
return UserFacingException(
|
||||||
|
_(
|
||||||
|
"The wallet contains funds that the current will does not "
|
||||||
|
"cover yet: a new will must be prepared."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return UserFacingException(
|
||||||
|
_("The will contains no valid transaction: a new will must be prepared.")
|
||||||
|
)
|
||||||
if isinstance(e, will_mod.AmountException):
|
if isinstance(e, will_mod.AmountException):
|
||||||
return UserFacingException(
|
return UserFacingException(
|
||||||
_(
|
_(
|
||||||
@@ -121,6 +142,21 @@ def _user_facing(e):
|
|||||||
"but less than the locktime"
|
"but less than the locktime"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if isinstance(e, Willexecutors.NoServersForChainError):
|
||||||
|
if e.url:
|
||||||
|
return UserFacingException(
|
||||||
|
_(
|
||||||
|
"Could not reach the configured welist server.\n\n"
|
||||||
|
"Server: {url}\n"
|
||||||
|
"Error: {reason}\n\n"
|
||||||
|
"Please verify the welist server URL in the plugin settings."
|
||||||
|
).format(url=e.url, reason=e.reason or "empty response")
|
||||||
|
)
|
||||||
|
return UserFacingException(
|
||||||
|
_("No active will-executor found for the {chain} network.").format(
|
||||||
|
chain=e.chain
|
||||||
|
)
|
||||||
|
)
|
||||||
return e
|
return e
|
||||||
|
|
||||||
|
|
||||||
@@ -352,6 +388,7 @@ class BalController:
|
|||||||
self.date_to_check = resolve_date_to_check(
|
self.date_to_check = resolve_date_to_check(
|
||||||
self.plugin.is_basic_mode(), self.will_settings,
|
self.plugin.is_basic_mode(), self.will_settings,
|
||||||
built_locktime=_new_locktime,
|
built_locktime=_new_locktime,
|
||||||
|
creation_date=Will.get_creation_time(self.willitems),
|
||||||
)
|
)
|
||||||
self.willexecutors = Willexecutors.get_willexecutors(
|
self.willexecutors = Willexecutors.get_willexecutors(
|
||||||
self.plugin, update=False, task=False
|
self.plugin, update=False, task=False
|
||||||
@@ -472,6 +509,11 @@ class BalController:
|
|||||||
self.wallet.dust_threshold(),
|
self.wallet.dust_threshold(),
|
||||||
max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(),
|
max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(),
|
||||||
)
|
)
|
||||||
|
except will_mod.AmountException as e:
|
||||||
|
# Mirror the GUI (BalWindow.build_inheritance_transaction): an
|
||||||
|
# amount mismatch is only a WARNING here, the flow continues (the
|
||||||
|
# rebuild/normalisation below reconciles the shares).
|
||||||
|
_logger.warning(f"prepare will: check_amounts: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise _user_facing(e) from e
|
raise _user_facing(e) from e
|
||||||
|
|
||||||
@@ -949,6 +991,8 @@ class BalController:
|
|||||||
heir.append(locktime)
|
heir.append(locktime)
|
||||||
else:
|
else:
|
||||||
heir.append(self.will_settings["locktime"])
|
heir.append(self.will_settings["locktime"])
|
||||||
|
if not self.plugin.ENABLE_MULTIVERSE.get():
|
||||||
|
heir[3] = self.will_settings["locktime"]
|
||||||
if is_op_return_address(address):
|
if is_op_return_address(address):
|
||||||
heir[2] = "0"
|
heir[2] = "0"
|
||||||
return Heirs.validate_heir(heir[0], heir[1:])
|
return Heirs.validate_heir(heir[0], heir[1:])
|
||||||
@@ -1034,7 +1078,7 @@ class BalController:
|
|||||||
return self.willexecutors_show(url)
|
return self.willexecutors_show(url)
|
||||||
|
|
||||||
def willexecutors_update(self, url, address=None, base_fee=None, info=None,
|
def willexecutors_update(self, url, address=None, base_fee=None, info=None,
|
||||||
rename_to=None):
|
promo_code=None, rename_to=None):
|
||||||
if url not in self.willexecutors:
|
if url not in self.willexecutors:
|
||||||
raise UserFacingException(_("Will-Executor not found: {}").format(url))
|
raise UserFacingException(_("Will-Executor not found: {}").format(url))
|
||||||
we = self.willexecutors[url]
|
we = self.willexecutors[url]
|
||||||
@@ -1045,6 +1089,8 @@ class BalController:
|
|||||||
we["base_fee"] = int(base_fee)
|
we["base_fee"] = int(base_fee)
|
||||||
if info is not None:
|
if info is not None:
|
||||||
we["info"] = info.strip() or "New Will Executor"
|
we["info"] = info.strip() or "New Will Executor"
|
||||||
|
if promo_code is not None:
|
||||||
|
we["promo_code"] = promo_code
|
||||||
if rename_to and rename_to != url:
|
if rename_to and rename_to != url:
|
||||||
if rename_to in self.willexecutors:
|
if rename_to in self.willexecutors:
|
||||||
raise UserFacingException(
|
raise UserFacingException(
|
||||||
@@ -1065,17 +1111,38 @@ class BalController:
|
|||||||
Willexecutors.save(self.plugin, self.willexecutors)
|
Willexecutors.save(self.plugin, self.willexecutors)
|
||||||
return {"deleted": deleted}
|
return {"deleted": deleted}
|
||||||
|
|
||||||
def willexecutors_select(self, urls=None, select=True):
|
def willexecutors_select(self, urls=None, select=True, only_valid=False):
|
||||||
"""Select/deselect one, many or all will-executors."""
|
"""Select or deselect will-executors.
|
||||||
selected = {}
|
|
||||||
targets = urls if urls is not None else list(self.willexecutors)
|
Mirrors the GUI's single-toggle and ``set_select_all(select,
|
||||||
|
only_valid)`` bulk actions. ``urls`` is the set of records to act on
|
||||||
|
(all of them when omitted); with ``only_valid`` the selection is
|
||||||
|
restricted by validity: selecting sets the valid ones to True and the
|
||||||
|
invalid ones to False, while deselecting only clears the invalid ones.
|
||||||
|
"""
|
||||||
|
targets = (
|
||||||
|
list(self.willexecutors)
|
||||||
|
if urls is None
|
||||||
|
else [url for url in urls if url in self.willexecutors]
|
||||||
|
)
|
||||||
for url in targets:
|
for url in targets:
|
||||||
if url not in self.willexecutors:
|
we = self.willexecutors[url]
|
||||||
continue
|
if only_valid:
|
||||||
self.willexecutors[url]["selected"] = bool(select)
|
is_valid = Willexecutors.is_valid(
|
||||||
selected[url] = bool(select)
|
we,
|
||||||
|
max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(),
|
||||||
|
dust=self.wallet.dust_threshold(),
|
||||||
|
)
|
||||||
|
if select:
|
||||||
|
we["selected"] = bool(is_valid)
|
||||||
|
elif not is_valid:
|
||||||
|
we["selected"] = False
|
||||||
|
else:
|
||||||
|
we["selected"] = bool(select)
|
||||||
Willexecutors.save(self.plugin, self.willexecutors)
|
Willexecutors.save(self.plugin, self.willexecutors)
|
||||||
return selected
|
return {
|
||||||
|
url: bool(self.willexecutors[url].get("selected")) for url in targets
|
||||||
|
}
|
||||||
|
|
||||||
def willexecutors_ping(self, urls=None):
|
def willexecutors_ping(self, urls=None):
|
||||||
"""Ping the (selected) will-executor servers and refresh their info.
|
"""Ping the (selected) will-executor servers and refresh their info.
|
||||||
@@ -1125,10 +1192,12 @@ class BalController:
|
|||||||
|
|
||||||
result = {}
|
result = {}
|
||||||
last_error = None
|
last_error = None
|
||||||
|
any_server_reached = False
|
||||||
try:
|
try:
|
||||||
resp = Willexecutors.send_request(
|
resp = Willexecutors.send_request(
|
||||||
"get", url, timeout=10, max_retries=1, retry_sleep=1
|
"get", url, timeout=10, max_retries=1, retry_sleep=1
|
||||||
)
|
)
|
||||||
|
any_server_reached = True
|
||||||
if not isinstance(resp, dict):
|
if not isinstance(resp, dict):
|
||||||
last_error = "invalid response format"
|
last_error = "invalid response format"
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
@@ -1154,12 +1223,17 @@ class BalController:
|
|||||||
last_error = str(e)
|
last_error = str(e)
|
||||||
_logger.error(f"fetch_will_executors_list: {url} -> {type(e).__name__}: {e}")
|
_logger.error(f"fetch_will_executors_list: {url} -> {type(e).__name__}: {e}")
|
||||||
|
|
||||||
if not result and not basic:
|
if not result:
|
||||||
raise UserFacingException(
|
if not basic:
|
||||||
_("Could not reach the configured welist server.\nServer: {}\nError: {}").format(
|
# Advanced mode: always raise with full details.
|
||||||
url, last_error or "empty response"
|
raise Willexecutors.NoServersForChainError(
|
||||||
)
|
chainname,
|
||||||
|
url=url,
|
||||||
|
reason=last_error or "empty response",
|
||||||
)
|
)
|
||||||
|
if any_server_reached:
|
||||||
|
# Basic mode: the server answered but has no data for this chain.
|
||||||
|
raise Willexecutors.NoServersForChainError(chainname)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def willexecutors_download(self):
|
def willexecutors_download(self):
|
||||||
@@ -1179,7 +1253,9 @@ class BalController:
|
|||||||
raise UserFacingException(
|
raise UserFacingException(
|
||||||
_("Invalid entry {} in will-executors file").format(url)
|
_("Invalid entry {} in will-executors file").format(url)
|
||||||
)
|
)
|
||||||
if url not in self.willexecutors:
|
# Mirror the GUI import (``willexecutors_list.update(data)``):
|
||||||
|
# existing URLs are overwritten with the imported record, missing
|
||||||
|
# fields get sane defaults.
|
||||||
we = dict(we)
|
we = dict(we)
|
||||||
we.setdefault("selected", False)
|
we.setdefault("selected", False)
|
||||||
we.setdefault("status", "New")
|
we.setdefault("status", "New")
|
||||||
@@ -1188,9 +1264,30 @@ class BalController:
|
|||||||
Willexecutors.save(self.plugin, self.willexecutors)
|
Willexecutors.save(self.plugin, self.willexecutors)
|
||||||
return {"imported": len(data), "total": len(self.willexecutors)}
|
return {"imported": len(data), "total": len(self.willexecutors)}
|
||||||
|
|
||||||
def willexecutors_export(self, path):
|
def willexecutors_export(self, path, subset=None):
|
||||||
write_json_file(path, self.willexecutors)
|
"""Export the will-executors to ``path``.
|
||||||
return {"exported": len(self.willexecutors), "path": path}
|
|
||||||
|
``subset`` mirrors the GUI export filter: ``"selected"`` writes only
|
||||||
|
the selected records, ``"valid"`` only the valid ones, anything else
|
||||||
|
(or ``None``) writes every record.
|
||||||
|
"""
|
||||||
|
data = self.willexecutors
|
||||||
|
if subset == "selected":
|
||||||
|
data = {
|
||||||
|
url: we for url, we in data.items() if Willexecutors.is_selected(we)
|
||||||
|
}
|
||||||
|
elif subset == "valid":
|
||||||
|
data = {
|
||||||
|
url: we
|
||||||
|
for url, we in data.items()
|
||||||
|
if Willexecutors.is_valid(
|
||||||
|
we,
|
||||||
|
max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(),
|
||||||
|
dust=self.wallet.dust_threshold(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
write_json_file(path, data)
|
||||||
|
return {"exported": len(data), "path": path}
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Settings
|
# Settings
|
||||||
@@ -1290,11 +1387,3 @@ class BalController:
|
|||||||
)
|
)
|
||||||
return value
|
return value
|
||||||
return str(raw)
|
return str(raw)
|
||||||
|
|
||||||
|
|
||||||
def get_decimal_point(plugin):
|
|
||||||
"""Standalone helper returning Electrum's configured BTC decimal point."""
|
|
||||||
try:
|
|
||||||
return plugin.config.BTC_AMOUNTS_DECIMAL_POINT
|
|
||||||
except AttributeError:
|
|
||||||
return 8
|
|
||||||
|
|||||||
@@ -823,11 +823,19 @@ class Will:
|
|||||||
for utxo in all_utxos:
|
for utxo in all_utxos:
|
||||||
wallet_balance += utxo.value_sats()
|
wallet_balance += utxo.value_sats()
|
||||||
|
|
||||||
if fixed_amount >= wallet_balance:
|
if fixed_amount > 0 and fixed_amount >= wallet_balance:
|
||||||
|
# Only a REAL fixed amount needs to be funded: with no fixed heirs
|
||||||
|
# (all percentages) fixed_amount is 0 and ``0 >= 0`` would wrongly
|
||||||
|
# flag an empty/fully-committed wallet as an amount error.
|
||||||
raise FixedAmountException(
|
raise FixedAmountException(
|
||||||
f"Fixed amount({fixed_amount}) >= {wallet_balance}"
|
f"Fixed amount({fixed_amount}) >= {wallet_balance}"
|
||||||
)
|
)
|
||||||
if perc_amount != 100:
|
if perc_heirs and perc_amount != 100:
|
||||||
|
# Percentages only have to add up to 100% when the will actually
|
||||||
|
# uses percentage heirs. A will made ONLY of fixed-amount heirs has
|
||||||
|
# no percentages, so perc_amount is 0 - which used to raise here and
|
||||||
|
# left "Checking variables" stuck on "Wait" for every fixed-only
|
||||||
|
# will (the build normalises the fixed shares anyway).
|
||||||
raise PercAmountException(f"Perc amount({perc_amount}) =! 100%")
|
raise PercAmountException(f"Perc amount({perc_amount}) =! 100%")
|
||||||
|
|
||||||
for url, wex in willexecutors.items():
|
for url, wex in willexecutors.items():
|
||||||
@@ -835,7 +843,7 @@ class Will:
|
|||||||
if max_fee is not None and int(wex["base_fee"]) > max_fee:
|
if max_fee is not None and int(wex["base_fee"]) > max_fee:
|
||||||
raise WillExecutorFeeTooHighException(wex, max_fee)
|
raise WillExecutorFeeTooHighException(wex, max_fee)
|
||||||
temp_balance = wallet_balance - int(wex["base_fee"])
|
temp_balance = wallet_balance - int(wex["base_fee"])
|
||||||
if fixed_amount >= temp_balance:
|
if fixed_amount > 0 and fixed_amount >= temp_balance:
|
||||||
raise FixedAmountException(
|
raise FixedAmountException(
|
||||||
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
|
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -691,11 +691,22 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
# Manual next-steps hint (Sign / Broadcast) shown to the user after the
|
# Manual next-steps hint (Sign / Broadcast) shown to the user after the
|
||||||
# dialog finishes; None when nothing is left to do.
|
# dialog finishes; None when nothing is left to do.
|
||||||
self._next_steps_hint = None
|
self._next_steps_hint = None
|
||||||
# Set to True by _sync_locktime_to_built_txs when the delivery date was
|
# Set to True by _build_success_report when the rebuild moved the
|
||||||
# automatically anticipated during a rebuild. Used to explain to the
|
# delivery date one day earlier than the previous will. Used both to
|
||||||
# user WHY signing is being requested (otherwise the sign prompt appears
|
# show the "Anticipated" warning on the "Building your will" row and to
|
||||||
# without any reason, as the owner reported).
|
# explain WHY signing is being requested (otherwise the sign prompt
|
||||||
|
# appears without any reason, as the owner reported).
|
||||||
self._date_was_anticipated = False
|
self._date_was_anticipated = False
|
||||||
|
# The anticipated locktime (UNIX timestamp) captured by
|
||||||
|
# _build_success_report, formatted into the "Anticipated - new
|
||||||
|
# delivery date: ..." warning. None when the last build did not
|
||||||
|
# anticipate the delivery.
|
||||||
|
self._anticipated_locktime = None
|
||||||
|
# The will's minimum locktime captured BEFORE the rebuild (see
|
||||||
|
# task_phase1). _build_success_report compares it with the rebuilt one
|
||||||
|
# to tell whether the delivery was anticipated by one day. None when no
|
||||||
|
# build ran yet.
|
||||||
|
self._previous_min_locktime = None
|
||||||
# Set to True right after we broadcast an automatic invalidation
|
# Set to True right after we broadcast an automatic invalidation
|
||||||
# transaction (the "postpone" path). On the very next phase-1 re-check
|
# transaction (the "postpone" path). On the very next phase-1 re-check
|
||||||
# Electrum may not have seen the invalidation tx yet, so it would still
|
# Electrum may not have seen the invalidation tx yet, so it would still
|
||||||
@@ -789,18 +800,32 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
_logger.debug("variables ok")
|
_logger.debug("variables ok")
|
||||||
self.msg_set_status(_("Checking variables"), varrow, _("Ok"), self.COLOR_OK)
|
self.msg_set_status(_("Checking variables"), varrow, _("Ok"), self.COLOR_OK)
|
||||||
except AmountException:
|
except AmountException:
|
||||||
self.msg_set_checking(
|
# The amount check is a WARNING, not a hard failure: the build can
|
||||||
self.msg_warning(
|
# still normalise the shares (see BalWindow.build_inheritance_
|
||||||
|
# transaction, which shows the same warning and continues). Report
|
||||||
|
# it on the "Checking variables" row itself, with the warning colour.
|
||||||
|
# Previously this was routed to msg_set_checking(), i.e. the
|
||||||
|
# "Checking your will" row, and then immediately overwritten by the
|
||||||
|
# msg_set_checking() call below - so the warning was lost AND the
|
||||||
|
# "Checking variables" row stayed stuck on its initial "Wait".
|
||||||
|
self.msg_set_status(
|
||||||
|
_("Checking variables"),
|
||||||
|
varrow,
|
||||||
_(
|
_(
|
||||||
"In the inheritance process, "
|
"In the inheritance process, "
|
||||||
"the entire wallet will always be fully emptied. \n"
|
"the entire wallet will always be fully emptied."
|
||||||
"Your settings require an adjustment of the amounts"
|
),
|
||||||
)
|
self.COLOR_WARNING,
|
||||||
)
|
|
||||||
)
|
)
|
||||||
except WillExecutorFeeTooHighException as e:
|
except WillExecutorFeeTooHighException as e:
|
||||||
self.msg_set_checking(
|
# Same reasoning as the AmountException branch above: show the
|
||||||
self.msg_warning(_("Will-executor fee too high: {}").format(e))
|
# warning on the "Checking variables" row so it is neither lost nor
|
||||||
|
# left stuck on "Wait".
|
||||||
|
self.msg_set_status(
|
||||||
|
_("Checking variables"),
|
||||||
|
varrow,
|
||||||
|
_("Will-executor fee too high: {}").format(e),
|
||||||
|
self.COLOR_WARNING,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.msg_set_checking()
|
self.msg_set_checking()
|
||||||
@@ -897,6 +922,18 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
|
|
||||||
if have_to_build:
|
if have_to_build:
|
||||||
self.msg_set_building()
|
self.msg_set_building()
|
||||||
|
# Capture the delivery date of the CURRENT (previous) will BEFORE
|
||||||
|
# the rebuild overwrites it, so _build_success_report can tell
|
||||||
|
# whether the rebuild ANTICIPATED the delivery by one day. The core
|
||||||
|
# anticipates by moving the rebuilt txs one day EARLIER than the
|
||||||
|
# previous will that spends the same coins (Will.check_anticipate /
|
||||||
|
# Will.search_anticipate), so comparing the two is the only reliable
|
||||||
|
# signal: a relative recipe ("1y"/"30d") is anchored to the built tx,
|
||||||
|
# which is why comparing the built locktime with the STORED value
|
||||||
|
# (see _sync_locktime_to_built_txs) misses the anticipation.
|
||||||
|
self._previous_min_locktime = Will.get_min_locktime(
|
||||||
|
self.bal_window.willitems
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
txs = self.bal_window.build_will()
|
txs = self.bal_window.build_will()
|
||||||
if not txs:
|
if not txs:
|
||||||
@@ -1085,6 +1122,39 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
# auto-anticipated) transactions, otherwise the next Check would wrongly
|
# auto-anticipated) transactions, otherwise the next Check would wrongly
|
||||||
# ask to invalidate. See _sync_locktime_to_built_txs for the full why.
|
# ask to invalidate. See _sync_locktime_to_built_txs for the full why.
|
||||||
self._sync_locktime_to_built_txs()
|
self._sync_locktime_to_built_txs()
|
||||||
|
# Detect an automatic anticipation by comparing the will's delivery date
|
||||||
|
# BEFORE the rebuild (captured in task_phase1) with the one AFTER it:
|
||||||
|
# the core anticipates by moving the rebuilt txs one day earlier than
|
||||||
|
# the previous will that spends the same coins (Will.check_anticipate).
|
||||||
|
# This is format-agnostic: it works whether the stored recipe is an
|
||||||
|
# absolute timestamp or a relative "1y"/"30d" recipe (anchoring makes a
|
||||||
|
# relative recipe unusable for a stored-vs-built comparison).
|
||||||
|
new_min_locktime = Will.get_min_locktime(self.bal_window.willitems)
|
||||||
|
previous_min_locktime = self._previous_min_locktime
|
||||||
|
self._date_was_anticipated = (
|
||||||
|
previous_min_locktime is not None
|
||||||
|
and new_min_locktime is not None
|
||||||
|
and int(new_min_locktime) < int(previous_min_locktime)
|
||||||
|
)
|
||||||
|
self._anticipated_locktime = (
|
||||||
|
int(new_min_locktime) if self._date_was_anticipated else None
|
||||||
|
)
|
||||||
|
# Owner request: when the rebuild anticipated the delivery, do NOT show
|
||||||
|
# a plain "Ok" on the "Building your will" row - show a warning that
|
||||||
|
# names the anticipation and the NEW delivery date, so the user is told
|
||||||
|
# the inheritance will be delivered one day earlier. The sign step then
|
||||||
|
# explains WHY signing is requested on its OWN row ("Signing your
|
||||||
|
# will"), leaving this warning visible.
|
||||||
|
if self._anticipated_locktime is not None:
|
||||||
|
new_date = BalTimestamp._safe_fromtimestamp(
|
||||||
|
self._anticipated_locktime
|
||||||
|
).strftime("%Y-%m-%d")
|
||||||
|
self.msg_set_building(
|
||||||
|
self.msg_alert(
|
||||||
|
_("Anticipated - new delivery date: {}").format(new_date)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
self.msg_set_building(self.msg_ok())
|
self.msg_set_building(self.msg_ok())
|
||||||
# List EACH heir on its OWN line, green + bold (owner request: revert the
|
# List EACH heir on its OWN line, green + bold (owner request: revert the
|
||||||
# one-line "Heirs: a, b, c" form of v0.4.6). Heir names can be long, and
|
# one-line "Heirs: a, b, c" form of v0.4.6). Heir names can be long, and
|
||||||
@@ -1177,10 +1247,10 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
f"sync delivery date to built tx locktime: "
|
f"sync delivery date to built tx locktime: "
|
||||||
f"{current} -> {min_locktime}"
|
f"{current} -> {min_locktime}"
|
||||||
)
|
)
|
||||||
# Remember that we anticipated the date, so the later sign
|
# NOTE: this only SYNCS the stored date. Whether the rebuild
|
||||||
# prompt can explain WHY signing is needed
|
# ANTICIPATED the delivery is decided in _build_success_report
|
||||||
# (see on_success_phase1).
|
# by comparing the previous and the rebuilt locktimes (the only
|
||||||
self._date_was_anticipated = True
|
# format-agnostic signal, see there).
|
||||||
# update_setting_widgets stores the value, persists it and
|
# update_setting_widgets stores the value, persists it and
|
||||||
# refreshes the date widgets in all panels/wizard (the .ics
|
# refreshes the date widgets in all panels/wizard (the .ics
|
||||||
# calendar too).
|
# calendar too).
|
||||||
@@ -1624,12 +1694,12 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
# If the will was rebuilt with an automatically anticipated
|
# If the will was rebuilt with an automatically anticipated
|
||||||
# delivery date, explain WHY we are now asking to sign:
|
# delivery date, explain WHY we are now asking to sign:
|
||||||
# otherwise the sign prompt appears with no reason (owner
|
# otherwise the sign prompt appears with no reason (owner
|
||||||
# feedback). The note is shown on the "Building your will"
|
# feedback). This note goes on its OWN "Signing your will" row
|
||||||
# row (orange warning) before the modal password prompt
|
# so it does NOT overwrite the "Anticipated - new delivery
|
||||||
# opens, so the user can read it.
|
# date" warning already shown on the "Building your will" row,
|
||||||
|
# and it is readable before the modal password prompt opens.
|
||||||
if self._date_was_anticipated:
|
if self._date_was_anticipated:
|
||||||
self.msg_set_building(
|
self.msg_set_signing(
|
||||||
"<b>{}</b>".format(
|
|
||||||
_(
|
_(
|
||||||
"The delivery date was automatically moved "
|
"The delivery date was automatically moved "
|
||||||
"one day earlier so the updated will can "
|
"one day earlier so the updated will can "
|
||||||
@@ -1638,7 +1708,6 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
"the change."
|
"the change."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
password = self.bal_window.get_wallet_password(
|
password = self.bal_window.get_wallet_password(
|
||||||
_("Sign your will"), parent=self
|
_("Sign your will"), parent=self
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -835,11 +835,11 @@ class BalWindow:
|
|||||||
self.window.wallet.dust_threshold(),
|
self.window.wallet.dust_threshold(),
|
||||||
max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
|
max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
|
||||||
)
|
)
|
||||||
except AmountException as e:
|
except AmountException:
|
||||||
self.show_warning(
|
self.show_warning(
|
||||||
_(
|
_(
|
||||||
"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{}"
|
"In the inheritance process, the entire wallet will always be fully emptied."
|
||||||
).format(e)
|
)
|
||||||
)
|
)
|
||||||
except WillExecutorFeeTooHighException as e:
|
except WillExecutorFeeTooHighException as e:
|
||||||
self.show_error(
|
self.show_error(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -25,9 +25,14 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
|||||||
from electrum.simple_config import SimpleConfig
|
from electrum.simple_config import SimpleConfig
|
||||||
from electrum.util import UserFacingException
|
from electrum.util import UserFacingException
|
||||||
|
|
||||||
from bal.cli.controller import BalController
|
from bal.cli.controller import BalController, _user_facing
|
||||||
from bal.core.heirs import Heirs
|
from bal.core.heirs import Heirs
|
||||||
from bal.core.util import Util
|
from bal.core.util import Util
|
||||||
|
from bal.core.will import (
|
||||||
|
HeirChangeException,
|
||||||
|
NotCompleteWillException,
|
||||||
|
WillexecutorChangeException,
|
||||||
|
)
|
||||||
|
|
||||||
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
|
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
|
||||||
|
|
||||||
@@ -102,6 +107,13 @@ def _make_controller(plugin):
|
|||||||
return BalController(plugin, FakeWallet())
|
return BalController(plugin, FakeWallet())
|
||||||
|
|
||||||
|
|
||||||
|
def _tmp_path():
|
||||||
|
"""Return the path of a fresh temporary JSON file (caller removes it)."""
|
||||||
|
fd, path = tempfile.mkstemp(prefix="bal_cli_", suffix=".json")
|
||||||
|
os.close(fd)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def test_controller_init_empty():
|
def test_controller_init_empty():
|
||||||
with Plugin() as plugin:
|
with Plugin() as plugin:
|
||||||
c = _make_controller(plugin)
|
c = _make_controller(plugin)
|
||||||
@@ -194,6 +206,179 @@ def test_willexecutors_crud():
|
|||||||
assert renamed not in c.willexecutors
|
assert renamed not in c.willexecutors
|
||||||
|
|
||||||
|
|
||||||
|
def test_willexecutors_update_accepts_promo_code():
|
||||||
|
"""Regression: the command transport used to forward six positional args to
|
||||||
|
a five-parameter controller method, raising TypeError. ``promo_code`` must
|
||||||
|
also be applied to the record instead of being silently dropped.
|
||||||
|
"""
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
url = "https://executor-promo.example.invalid"
|
||||||
|
c.willexecutors_add(url, address="", base_fee=250)
|
||||||
|
# Positional call mirroring the transport layer: (url, address,
|
||||||
|
# base_fee, info, promo_code, rename_to).
|
||||||
|
result = c.willexecutors_update(
|
||||||
|
url, "", "300", "Promo executor", "PROMO10", None
|
||||||
|
)
|
||||||
|
assert result["willexecutor"]["base_fee"] == 300
|
||||||
|
assert result["willexecutor"]["info"] == "Promo executor"
|
||||||
|
assert c.willexecutors[url]["promo_code"] == "PROMO10"
|
||||||
|
|
||||||
|
|
||||||
|
def test_heirs_locktime_forced_when_multiverse_disabled():
|
||||||
|
"""When multiverse is disabled the heir locktime follows the will locktime,
|
||||||
|
exactly like the GUI's ``set_heir`` (which forces ``heir[3]``)."""
|
||||||
|
with Plugin() as plugin:
|
||||||
|
plugin.WILL_SETTINGS.set(
|
||||||
|
{"threshold": "-150d", "locktime": "2y", "baltx_fees": 20}
|
||||||
|
)
|
||||||
|
plugin.ENABLE_MULTIVERSE.set(False)
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
c.heirs_add("bob", VALID_ADDRESS, "100000", locktime="30d")
|
||||||
|
assert c.heirs["bob"][2] == "2y"
|
||||||
|
|
||||||
|
|
||||||
|
def test_heirs_locktime_kept_when_multiverse_enabled():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
plugin.WILL_SETTINGS.set({"threshold": "-150d", "locktime": "2y", "baltx_fees": 20})
|
||||||
|
plugin.ENABLE_MULTIVERSE.set(True)
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
c.heirs_add("carol", VALID_ADDRESS, "100000", locktime="30d")
|
||||||
|
assert c.heirs["carol"][2] == "30d"
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_facing_maps_not_complete_will():
|
||||||
|
"""The check flow must translate every NotCompleteWillException subtype to a
|
||||||
|
user-facing message instead of leaking the raw internal exception.
|
||||||
|
"""
|
||||||
|
assert "heirs changed" in str(_user_facing(HeirChangeException())).lower()
|
||||||
|
assert "will-executor changed" in str(
|
||||||
|
_user_facing(WillexecutorChangeException())
|
||||||
|
).lower()
|
||||||
|
assert "no valid transaction" in str(
|
||||||
|
_user_facing(NotCompleteWillException())
|
||||||
|
).lower()
|
||||||
|
assert "does not cover" in str(
|
||||||
|
_user_facing(NotCompleteWillException("utxo"))
|
||||||
|
).lower()
|
||||||
|
|
||||||
|
def test_willexecutors_delete_missing_is_noop():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
assert c.willexecutors_delete(["https://missing.example.invalid"]) == {
|
||||||
|
"deleted": []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_willexecutors_select_only_valid():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
valid_url = "https://valid.example.invalid"
|
||||||
|
invalid_url = "https://invalid.example.invalid"
|
||||||
|
# Valid: real address, base_fee within [dust, max_fee].
|
||||||
|
c.willexecutors_add(valid_url, address=VALID_ADDRESS, base_fee=1000)
|
||||||
|
# Invalid: no address.
|
||||||
|
c.willexecutors_add(invalid_url, address="", base_fee=1000)
|
||||||
|
|
||||||
|
c.willexecutors_select(None, select=True, only_valid=True)
|
||||||
|
assert c.willexecutors[valid_url]["selected"] is True
|
||||||
|
assert c.willexecutors[invalid_url]["selected"] is False
|
||||||
|
|
||||||
|
# Select everything, then deselect only-valid: the valid one keeps its
|
||||||
|
# state, only the invalid one is cleared.
|
||||||
|
c.willexecutors_select(None, select=True)
|
||||||
|
c.willexecutors_select(None, select=False, only_valid=True)
|
||||||
|
assert c.willexecutors[valid_url]["selected"] is True
|
||||||
|
assert c.willexecutors[invalid_url]["selected"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_willexecutors_import_overwrites_existing():
|
||||||
|
import json
|
||||||
|
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
url = "https://existing.example.invalid"
|
||||||
|
c.willexecutors_add(url, address="", base_fee=100, info="Old")
|
||||||
|
path = _tmp_path()
|
||||||
|
try:
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
url: {
|
||||||
|
"address": "",
|
||||||
|
"base_fee": 999,
|
||||||
|
"info": "New",
|
||||||
|
"selected": True,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
result = c.willexecutors_import(path)
|
||||||
|
assert result["imported"] == 1
|
||||||
|
# The imported record replaces the existing one (GUI behaviour).
|
||||||
|
assert c.willexecutors[url]["base_fee"] == 999
|
||||||
|
assert c.willexecutors[url]["info"] == "New"
|
||||||
|
assert c.willexecutors[url]["selected"] is True
|
||||||
|
finally:
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_willexecutors_export_subsets():
|
||||||
|
import json
|
||||||
|
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
selected_url = "https://selected.example.invalid"
|
||||||
|
other_url = "https://other.example.invalid"
|
||||||
|
invalid_url = "https://invalid.example.invalid"
|
||||||
|
c.willexecutors_add(selected_url, address=VALID_ADDRESS, base_fee=1000)
|
||||||
|
c.willexecutors_add(other_url, address=VALID_ADDRESS, base_fee=1000)
|
||||||
|
c.willexecutors_add(invalid_url, address="", base_fee=1000)
|
||||||
|
c.willexecutors_select([selected_url], select=True)
|
||||||
|
|
||||||
|
def exported(subset):
|
||||||
|
path = _tmp_path()
|
||||||
|
try:
|
||||||
|
c.willexecutors_export(path, subset)
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
finally:
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
assert set(exported(None)) == {selected_url, other_url, invalid_url}
|
||||||
|
assert set(exported("selected")) == {selected_url}
|
||||||
|
# "valid" keeps both real-address records and drops the address-less one.
|
||||||
|
assert set(exported("valid")) == {selected_url, other_url}
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_facing_maps_no_servers_for_chain():
|
||||||
|
from bal.core.willexecutors import Willexecutors
|
||||||
|
|
||||||
|
advanced = Willexecutors.NoServersForChainError(
|
||||||
|
"bitcoin", url="https://welist.example/", reason="boom"
|
||||||
|
)
|
||||||
|
assert "https://welist.example/" in str(_user_facing(advanced))
|
||||||
|
basic = Willexecutors.NoServersForChainError("bitcoin")
|
||||||
|
assert "bitcoin" in str(_user_facing(basic))
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_will_warns_on_amount_exception():
|
||||||
|
from bal.core import will as will_mod
|
||||||
|
|
||||||
|
with Plugin() as plugin:
|
||||||
|
plugin.NO_WILLEXECUTOR.set(True)
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
c.heirs["alice"] = [VALID_ADDRESS, "100%", "2y"]
|
||||||
|
with mock.patch.object(
|
||||||
|
will_mod.Will,
|
||||||
|
"check_amounts",
|
||||||
|
side_effect=will_mod.FixedAmountException("too much"),
|
||||||
|
), mock.patch.object(will_mod.Will, "is_will_valid", return_value=True):
|
||||||
|
result = c.prepare_will()
|
||||||
|
# The amount mismatch is a warning, not an abort: the flow completes.
|
||||||
|
assert result["result"] == "coherent"
|
||||||
|
|
||||||
|
|
||||||
def test_will_status_empty():
|
def test_will_status_empty():
|
||||||
with Plugin() as plugin:
|
with Plugin() as plugin:
|
||||||
c = _make_controller(plugin)
|
c = _make_controller(plugin)
|
||||||
|
|||||||
@@ -386,7 +386,9 @@ def test_insufficient_funds_warns():
|
|||||||
|
|
||||||
assert not will, "no wallet funds must not build a will"
|
assert not will, "no wallet funds must not build a will"
|
||||||
assert ctl.window.warnings, "user must be warned about the balance"
|
assert ctl.window.warnings, "user must be warned about the balance"
|
||||||
assert any("adjustment" in w.lower() for w in ctl.window.warnings)
|
# The wording was shortened (task #67): the "adjustment of the amounts"
|
||||||
|
# sentence was dropped, the "fully emptied" note stays.
|
||||||
|
assert any("fully emptied" in w.lower() for w in ctl.window.warnings)
|
||||||
assert not ctl.willitems
|
assert not ctl.willitems
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs``.
|
Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs`` and the
|
||||||
|
"Building your will" row of ``BalBuildWillDialog._build_success_report``.
|
||||||
|
|
||||||
This is the post-build sync that keeps the plugin's stored delivery date
|
``_sync_locktime_to_built_txs`` is the post-build sync that keeps the plugin's
|
||||||
(WILL_SETTINGS["locktime"]) in lockstep with the BUILT transactions' fixed
|
stored delivery date (WILL_SETTINGS["locktime"]) in lockstep with the BUILT
|
||||||
locktime when the core AUTOMATICALLY anticipates it (one day earlier than
|
transactions' fixed locktime when the core AUTOMATICALLY anticipates it (one
|
||||||
stored).
|
day earlier than stored).
|
||||||
|
|
||||||
RELATIVE recipes ("90d" / "1y") are now PRESERVED: the daily-drift problem
|
RELATIVE recipes ("90d" / "1y") are now PRESERVED: the daily-drift problem
|
||||||
that once forced freezing them to absolute timestamps is solved at the root by
|
that once forced freezing them to absolute timestamps is solved at the root by
|
||||||
@@ -14,8 +15,14 @@ anchoring every relative recipe against the built transactions
|
|||||||
timestamp). Only a genuine automatic anticipation on an ABSOLUTE stored date
|
timestamp). Only a genuine automatic anticipation on an ABSOLUTE stored date
|
||||||
moves the stored value.
|
moves the stored value.
|
||||||
|
|
||||||
The method is exercised with a lightweight fake ``self`` (no Qt event loop, no
|
WHY THE FLAG IS NOT SET HERE ANY MORE: because a relative recipe is anchored to
|
||||||
Electrum wallet) by calling it as an unbound method.
|
the built tx, a stored-vs-built comparison cannot detect the anticipation for
|
||||||
|
it. ``_build_success_report`` therefore detects it by comparing the will's
|
||||||
|
minimum locktime BEFORE the rebuild with the one AFTER it (see there), which
|
||||||
|
works for both absolute and relative recipes.
|
||||||
|
|
||||||
|
The methods are exercised with a lightweight fake ``self`` (no Qt event loop,
|
||||||
|
no Electrum wallet) by calling them as unbound methods.
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
source "$BAL_HOME/electrum/env/bin/activate"
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
@@ -35,10 +42,12 @@ from bal.gui.qt.dialogs import BalBuildWillDialog # noqa: E402 (path insert ab
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def _fake_willitem(tx_locktime):
|
def _fake_willitem(tx_locktime):
|
||||||
"""Minimal stand-in for a WillItem: enough for Will.get_min_locktime."""
|
"""Minimal stand-in for a WillItem: enough for Will.get_min_locktime and
|
||||||
|
the heir listing in _build_success_report."""
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
tx=SimpleNamespace(locktime=tx_locktime),
|
tx=SimpleNamespace(locktime=tx_locktime),
|
||||||
get_status=lambda name: True,
|
get_status=lambda name: True,
|
||||||
|
heirs={},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -57,7 +66,6 @@ def _make_dialog(will_settings, tx_locktimes, recorded):
|
|||||||
will_settings=will_settings,
|
will_settings=will_settings,
|
||||||
update_setting_widgets=update_setting_widgets,
|
update_setting_widgets=update_setting_widgets,
|
||||||
),
|
),
|
||||||
_date_was_anticipated=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -83,7 +91,6 @@ def test_relative_locktime_preserved():
|
|||||||
assert fake.bal_window.will_settings["locktime"] == "90d"
|
assert fake.bal_window.will_settings["locktime"] == "90d"
|
||||||
assert fake.bal_window.will_settings["threshold"] == "30d"
|
assert fake.bal_window.will_settings["threshold"] == "30d"
|
||||||
assert recorded == [], "a relative recipe must never be rewritten"
|
assert recorded == [], "a relative recipe must never be rewritten"
|
||||||
assert fake._date_was_anticipated is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_relative_threshold_preserved():
|
def test_relative_threshold_preserved():
|
||||||
@@ -108,12 +115,13 @@ def test_absolute_locktime_unchanged_on_equal():
|
|||||||
recorded,
|
recorded,
|
||||||
)
|
)
|
||||||
assert fake.bal_window.will_settings["locktime"] == tx_locktime
|
assert fake.bal_window.will_settings["locktime"] == tx_locktime
|
||||||
assert fake._date_was_anticipated is False
|
assert fake.bal_window.will_settings["threshold"] == 1_700_000_000
|
||||||
|
|
||||||
|
|
||||||
def test_anticipation_sets_flag_and_moves_earlier():
|
def test_anticipation_moves_stored_date_earlier():
|
||||||
"""A real automatic anticipation of an ABSOLUTE stored date (built earlier
|
"""A real automatic anticipation of an ABSOLUTE stored date (built earlier
|
||||||
than stored) still moves the date earlier and flags the sign prompt."""
|
than stored) moves the stored date earlier so the next Check does not
|
||||||
|
mistake it for a postpone."""
|
||||||
tx_locktime = 1_700_000_000
|
tx_locktime = 1_700_000_000
|
||||||
recorded = []
|
recorded = []
|
||||||
fake = _call_sync(
|
fake = _call_sync(
|
||||||
@@ -122,7 +130,6 @@ def test_anticipation_sets_flag_and_moves_earlier():
|
|||||||
recorded,
|
recorded,
|
||||||
)
|
)
|
||||||
assert fake.bal_window.will_settings["locktime"] == tx_locktime
|
assert fake.bal_window.will_settings["locktime"] == tx_locktime
|
||||||
assert fake._date_was_anticipated is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_stored_earlier_than_built_never_moved_later():
|
def test_stored_earlier_than_built_never_moved_later():
|
||||||
@@ -137,7 +144,6 @@ def test_stored_earlier_than_built_never_moved_later():
|
|||||||
recorded,
|
recorded,
|
||||||
)
|
)
|
||||||
assert fake.bal_window.will_settings["locktime"] == stored
|
assert fake.bal_window.will_settings["locktime"] == stored
|
||||||
assert fake._date_was_anticipated is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_multiple_txs_uses_minimum_locktime():
|
def test_multiple_txs_uses_minimum_locktime():
|
||||||
@@ -182,6 +188,76 @@ def test_relative_locktime_stays_coherent_via_anchor():
|
|||||||
assert drifted > tx_locktime
|
assert drifted > tx_locktime
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# _build_success_report: the "Building your will" row
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _make_report_dialog(new_locktimes, previous_min_locktime):
|
||||||
|
"""Fake ``self`` for _build_success_report (no Qt event loop).
|
||||||
|
|
||||||
|
``_sync_locktime_to_built_txs`` is stubbed (it is exercised separately), so
|
||||||
|
the test drives the AFTER-build locktimes and the captured BEFORE-build
|
||||||
|
locktime directly; the message helpers capture what would be written on the
|
||||||
|
"Building your will" row.
|
||||||
|
"""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _set_building(status=None, row=None, color=None):
|
||||||
|
captured["build_status"] = status
|
||||||
|
|
||||||
|
fake = SimpleNamespace(
|
||||||
|
bal_window=SimpleNamespace(
|
||||||
|
willitems={
|
||||||
|
f"tx{i}": _fake_willitem(lt)
|
||||||
|
for i, lt in enumerate(new_locktimes)
|
||||||
|
},
|
||||||
|
wallet=SimpleNamespace(set_label=lambda *a, **k: None),
|
||||||
|
),
|
||||||
|
_previous_min_locktime=previous_min_locktime,
|
||||||
|
_sync_locktime_to_built_txs=lambda: None,
|
||||||
|
msg_set_building=_set_building,
|
||||||
|
msg_set_status=lambda *a, **k: None,
|
||||||
|
msg_alert=lambda e: "ALERT:{}".format(e),
|
||||||
|
msg_ok=lambda e=None: "OK",
|
||||||
|
)
|
||||||
|
return fake, captured
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_report_shows_anticipated_warning():
|
||||||
|
"""When the rebuild moved the delivery one day earlier than the previous
|
||||||
|
will, the "Building your will" row shows a warning naming the NEW delivery
|
||||||
|
date instead of a plain "Ok"."""
|
||||||
|
previous = 1_800_000_000 # 2027-01-15 (UTC)
|
||||||
|
rebuilt = previous - 86_400 # anticipated one day earlier
|
||||||
|
fake, captured = _make_report_dialog([rebuilt], previous)
|
||||||
|
BalBuildWillDialog._build_success_report(fake)
|
||||||
|
assert fake._date_was_anticipated is True
|
||||||
|
assert fake._anticipated_locktime == rebuilt
|
||||||
|
assert captured["build_status"] == (
|
||||||
|
"ALERT:Anticipated - new delivery date: 2027-01-14"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_report_shows_ok_when_not_anticipated():
|
||||||
|
"""Without anticipation the row keeps the plain "Ok" and the state stays
|
||||||
|
clean."""
|
||||||
|
previous = 1_800_000_000
|
||||||
|
fake, captured = _make_report_dialog([previous], previous)
|
||||||
|
BalBuildWillDialog._build_success_report(fake)
|
||||||
|
assert fake._date_was_anticipated is False
|
||||||
|
assert fake._anticipated_locktime is None
|
||||||
|
assert captured["build_status"] == "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_report_first_build_is_not_anticipated():
|
||||||
|
"""A first build (no previous will) is never flagged as anticipated."""
|
||||||
|
fake, captured = _make_report_dialog([1_800_000_000], None)
|
||||||
|
BalBuildWillDialog._build_success_report(fake)
|
||||||
|
assert fake._date_was_anticipated is False
|
||||||
|
assert fake._anticipated_locktime is None
|
||||||
|
assert captured["build_status"] == "OK"
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
for name in sorted(dir()):
|
for name in sorted(dir()):
|
||||||
if name.startswith("test_"):
|
if name.startswith("test_"):
|
||||||
|
|||||||
Reference in New Issue
Block a user