Compare commits
6 Commits
fix/i18n-r
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
f3deccb626
|
|||
|
c9f3f77a15
|
|||
|
3d9757fe2d
|
|||
|
346aeee62b
|
|||
|
3b2eed37cc
|
|||
| 0c60462163 |
@@ -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).
|
|
||||||
208
CHANGELOG.md
208
CHANGELOG.md
@@ -3612,3 +3612,211 @@ deselected executor) are not part of it and will be proposed separately.
|
|||||||
none new.
|
none new.
|
||||||
|
|
||||||
**Outcome:** DONE (pending Truman's review).
|
**Outcome:** DONE (pending Truman's review).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 64. Invalidation pays the current network fee, configurable via a fee-policy setting
|
||||||
|
|
||||||
|
**Date:** 2026-10-10
|
||||||
|
|
||||||
|
**Goal (owner request):** the will's `baltx_fees` (default 20 sat/vbyte) is
|
||||||
|
deliberately high because the inheritance transactions may be broadcast by the
|
||||||
|
will-executors years from now, when network fees may be much higher. The
|
||||||
|
on-chain INVALIDATION, instead, is sent TODAY: it spends the will's coins back
|
||||||
|
to the wallet so the old pre-signed inheritance transactions become unusable.
|
||||||
|
It must therefore pay the current network fee, not the inheritance rate. After
|
||||||
|
the invalidation the plugin rebuilds the new inheritance with `baltx_fees` as
|
||||||
|
before (unchanged). The fee policy is exposed as an ADVANCED-only setting.
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
|
||||||
|
- `bal/core/will.py`
|
||||||
|
- New constants `INVALIDATION_FEE_ETA_BLOCKS = 2` and
|
||||||
|
`INVALIDATION_FEE_POLICY_DEFAULT = "eta:2"` (owner decision: confirm within
|
||||||
|
2 blocks, Electrum's own default target).
|
||||||
|
- New `Will.get_invalidation_fee_rate(wallet, fallback_fee_per_byte,
|
||||||
|
fee_policy_descriptor=None)`: builds an Electrum `FeePolicy` from the
|
||||||
|
descriptor (Feerate / ETA / Mempool) and returns its rate in sat/vbyte,
|
||||||
|
together with the `FeeMethod` name (`"feerate"` / `"eta"` / `"mempool"`) or
|
||||||
|
`"fallback"`. The fallback (the will's `baltx_fees`, i.e. the previous
|
||||||
|
behaviour) is used when no usable rate is available: offline wallet,
|
||||||
|
estimates not received yet, network error or an invalid rate value. A
|
||||||
|
malformed stored descriptor is handled by Electrum's `FeePolicy`, which
|
||||||
|
falls back to the default `eta:2` policy (network rate), not to
|
||||||
|
`baltx_fees`. Never raises.
|
||||||
|
- `Will.invalidate_will` gained a `fee_policy_descriptor` argument and uses
|
||||||
|
that rate; the total fee is rounded UP to whole satoshis (network rates can
|
||||||
|
be fractional); it logs the chosen rate and its source. `fees_per_byte` is
|
||||||
|
now only the fallback, so every caller keeps working. The change output must
|
||||||
|
stay at or above the wallet dust threshold: coins are appended until the
|
||||||
|
change clears dust, and if it never does the invalidation returns `None`
|
||||||
|
(instead of an unrelayable sub-dust output). Docstrings added.
|
||||||
|
- `bal/core/plugin_base.py`: new `INVALIDATION_FEE_POLICY` BalConfig
|
||||||
|
(`bal_invalidation_fee_policy`, default `"eta:2"`).
|
||||||
|
- `bal/gui/qt/plugin.py`: new ADVANCED-only row 16 in the settings dialog,
|
||||||
|
reusing Electrum's standard fee widget (`FeeSlider` + `FeeComboBox`) for the
|
||||||
|
Feerate / ETA / Mempool choice, with a Help button and a per-row reset. The
|
||||||
|
chosen descriptor is persisted; the dialog-wide "Reset to Default Setting"
|
||||||
|
also restores it.
|
||||||
|
- `bal/gui/qt/window.py`, `bal/gui/qt/dialogs.py`, `bal/cli/controller.py`:
|
||||||
|
every `Will.invalidate_will` call site passes
|
||||||
|
`fee_policy_descriptor=...INVALIDATION_FEE_POLICY.get()`, so the Tools →
|
||||||
|
Invalidate action, the wizard postpone/expired paths, the automatic
|
||||||
|
invalidation and the CLI (`bal_will_invalidate`) all get the new behaviour.
|
||||||
|
- `bal/locale/bal.pot` and the `es_ES` / `it_IT` catalogs refreshed
|
||||||
|
(`pybabel extract` / `update`, Babel 2.10.3 to match the existing template) so
|
||||||
|
the new settings-dialog strings are translatable.
|
||||||
|
|
||||||
|
**Why no special case for an already-expired will:** discussed with the owner
|
||||||
|
and intentionally left out. Before its locktime the old inheritance tx cannot
|
||||||
|
enter a block or the mempool, so the invalidation has no competitor. RBF stays
|
||||||
|
enabled on the invalidation, so its fee can be raised from Electrum's History
|
||||||
|
("Increase fee") if it ever gets stuck.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
- `tests/test_core_invalidation_fee.py` (new, 17 tests): rate selection
|
||||||
|
(2-block target, network value used as is also above `baltx_fees`, fractional
|
||||||
|
rates, fallback when offline / no estimate / error / invalid values / invalid
|
||||||
|
descriptor / no wallet) and the fee actually deducted by `invalidate_will`
|
||||||
|
(network vs fallback, fixed feerate offline, rounding up).
|
||||||
|
- `tests/test_settings_invalidation_fee.py` (new, 7 tests): the row is hidden
|
||||||
|
in BASIC and visible in ADVANCED; choosing a Feerate / ETA policy persists
|
||||||
|
the descriptor; "Reset to Default Setting" restores `"eta:2"`.
|
||||||
|
- `tests/test_core_will_invalidate.py`: its mock wallet now has `network=None`
|
||||||
|
so the pure fee arithmetic (`fee = size * fees_per_byte`) still exercises the
|
||||||
|
fallback path; the policy-based selection is covered by the new file. Four new
|
||||||
|
tests cover the dust guard: sub-dust change returns `None`, change exactly at
|
||||||
|
the threshold is accepted, the fallback stops when the change is still dust,
|
||||||
|
and it succeeds once the change clears dust.
|
||||||
|
- Selected suite (core + GUI + settings + translations): 423 passed.
|
||||||
|
- `ruff check` on the touched files: no new violations (only the pre-existing
|
||||||
|
I001 in `dialogs.py`).
|
||||||
|
|
||||||
|
**Outcome:** DONE (pending the owner's test of the release ZIP).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 65. Check crashed with `KeyError: '<txid>'` (stale "father" link in the will tree)
|
||||||
|
|
||||||
|
**Date:** 2026-10-10
|
||||||
|
|
||||||
|
**Symptom (owner's mainnet wallet, Electrum 4.8.x):** the Check wizard stopped
|
||||||
|
at "Checking your will" with `Error: 'a65a682f…'`, and the window refresh
|
||||||
|
logged "error while updating window" with the same txid:
|
||||||
|
|
||||||
|
```
|
||||||
|
File "bal/core/will.py", line 657, in check_invalidated
|
||||||
|
or willtree[w.father].get_status("CONFIRMED")
|
||||||
|
KeyError: 'a65a682f2975f0dffd9eccce9392833f227badf617fc67815674736d594d4115'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Root cause:** `father` is persisted in the wallet (the `"Father"` field of
|
||||||
|
`WillItem.to_dict`) and was only ever set, never cleared. When the father tx was
|
||||||
|
later removed from the will (rebuild, merge, replacement) the child kept
|
||||||
|
pointing to a txid that no longer exists, and `check_invalidated` crashed with a
|
||||||
|
`KeyError` on that txid (the Check wizard and the window refresh both failed).
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
|
||||||
|
- `bal/core/will.py`
|
||||||
|
- `Will.add_willtree`: clears `father` on every item before rebuilding the
|
||||||
|
links, so the tree is always recomputed from scratch from the transactions
|
||||||
|
themselves. Docstring added explaining WHY.
|
||||||
|
- `Will.check_invalidated`: a father that is not (or no longer) part of the
|
||||||
|
will is treated as "no father" instead of raising `KeyError`.
|
||||||
|
- `tests/test_core_willtree_father.py` (new, 7 tests): stale father discarded /
|
||||||
|
replaced by the real parent, no crash in `check_invalidated` (with and
|
||||||
|
without the tree rebuild), real parent/child link built, child of an
|
||||||
|
unconfirmed parent still skipped, and the `set_invalidate` cascade (see #66).
|
||||||
|
|
||||||
|
**Verification:** the new test file plus the core/CLI suites pass; `ruff check`
|
||||||
|
clean on the touched files.
|
||||||
|
|
||||||
|
**Outcome:** DONE (pending the owner's test of the ZIP).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 66. Invalidation cascade crashed on real will trees (`children` is a list)
|
||||||
|
|
||||||
|
**Date:** 2026-10-10
|
||||||
|
|
||||||
|
**Problem (found while fixing #65, reproduced on real data):**
|
||||||
|
`Will.set_invalidate` iterated `children.items()`, but `Will.add_willtree`
|
||||||
|
builds `children` as a LIST of `[child_id, input_idx, prevout_idx]`, so
|
||||||
|
invalidating a will tx that has child will txs raised
|
||||||
|
`AttributeError: 'list' object has no attribute 'items'`. The existing tests in
|
||||||
|
`test_core_will_invalidate.py` set `children` to dicts by hand, so they never
|
||||||
|
saw it.
|
||||||
|
|
||||||
|
**What changed:** `bal/core/will.py` - `set_invalidate` accepts both shapes
|
||||||
|
(the list from `add_willtree`, a dict from older data/tests) and only recurses
|
||||||
|
into children that are still present in the will.
|
||||||
|
`tests/test_core_willtree_father.py`: new test
|
||||||
|
`test_set_invalidate_cascades_to_children_built_by_add_willtree`.
|
||||||
|
|
||||||
|
**Verification:** `tests/test_core_willtree_father.py` passes; `ruff check`
|
||||||
|
clean on the touched files.
|
||||||
|
|
||||||
|
**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
|
||||||
@@ -417,6 +454,7 @@ class BalController:
|
|||||||
will_locktime=Will.get_min_locktime(
|
will_locktime=Will.get_min_locktime(
|
||||||
willitems, default_value=self.date_to_check
|
willitems, default_value=self.date_to_check
|
||||||
),
|
),
|
||||||
|
fee_policy_descriptor=self.plugin.INVALIDATION_FEE_POLICY.get(),
|
||||||
)
|
)
|
||||||
if tx is not None:
|
if tx is not None:
|
||||||
try:
|
try:
|
||||||
@@ -471,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
|
||||||
|
|
||||||
@@ -948,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:])
|
||||||
@@ -1033,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]
|
||||||
@@ -1044,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(
|
||||||
@@ -1064,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.
|
||||||
@@ -1124,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(
|
||||||
@@ -1153,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):
|
||||||
@@ -1178,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")
|
||||||
@@ -1187,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
|
||||||
@@ -1289,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
|
|
||||||
|
|||||||
@@ -251,6 +251,24 @@ class BalPlugin(BasePlugin):
|
|||||||
self.TX_FEES = BalConfig(config, "bal_tx_fees", 100)
|
self.TX_FEES = BalConfig(config, "bal_tx_fees", 100)
|
||||||
self.INVALIDATE = BalConfig(config, "bal_invalidate", True)
|
self.INVALIDATE = BalConfig(config, "bal_invalidate", True)
|
||||||
self.ASK_INVALIDATE = BalConfig(config, "bal_ask_invalidate", True)
|
self.ASK_INVALIDATE = BalConfig(config, "bal_ask_invalidate", True)
|
||||||
|
|
||||||
|
# INVALIDATION_FEE_POLICY (advanced): the fee rate the on-chain
|
||||||
|
# INVALIDATION transaction pays. It is expressed as an Electrum
|
||||||
|
# ``FeePolicy`` descriptor "<method>:<value>", the same format Electrum
|
||||||
|
# uses for its own ``FEE_POLICY`` setting, so the standard fee slider
|
||||||
|
# (Feerate / ETA / Mempool) can edit it directly:
|
||||||
|
# * "eta:2" - fee estimated for confirmation within 2 blocks
|
||||||
|
# (the default: Electrum's own default target);
|
||||||
|
# * "mempool:N" - fee targeting a depth of N vbytes in the mempool;
|
||||||
|
# * "feerate:N" - a fixed rate of N sat/kvbyte (works offline).
|
||||||
|
# WHY it is separate from the will's ``baltx_fees``: the will is built
|
||||||
|
# with a deliberately high rate because the will-executors may broadcast
|
||||||
|
# the inheritance years from now, while the invalidation is sent TODAY
|
||||||
|
# and must pay the current network fee. ``baltx_fees`` remains only the
|
||||||
|
# fallback used when no estimate is available.
|
||||||
|
self.INVALIDATION_FEE_POLICY = BalConfig(
|
||||||
|
config, "bal_invalidation_fee_policy", "eta:2"
|
||||||
|
)
|
||||||
self.PREVIEW = BalConfig(config, "bal_preview", True)
|
self.PREVIEW = BalConfig(config, "bal_preview", True)
|
||||||
self.SAVE_TXS = BalConfig(config, "bal_save_txs", True)
|
self.SAVE_TXS = BalConfig(config, "bal_save_txs", True)
|
||||||
|
|
||||||
|
|||||||
272
bal/core/will.py
272
bal/core/will.py
@@ -26,9 +26,11 @@ The status flags themselves (the source of truth) stay here; only the mapping
|
|||||||
"status -> colour" now lives in the GUI layer. No behaviour changed.
|
"status -> colour" now lives in the GUI layer. No behaviour changed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
||||||
|
from electrum.fee_policy import FeePolicy
|
||||||
from electrum.logging import Logger, get_logger
|
from electrum.logging import Logger, get_logger
|
||||||
from electrum.transaction import (
|
from electrum.transaction import (
|
||||||
PartialTransaction,
|
PartialTransaction,
|
||||||
@@ -50,6 +52,18 @@ from .willexecutors import Willexecutors
|
|||||||
|
|
||||||
MIN_LOCKTIME = 1
|
MIN_LOCKTIME = 1
|
||||||
MIN_BLOCK = 1
|
MIN_BLOCK = 1
|
||||||
|
# Confirmation target (in blocks) for the on-chain INVALIDATION transaction.
|
||||||
|
# WHY: the invalidation is sent TODAY (it spends the will's coins so the old
|
||||||
|
# pre-signed inheritance txs become unusable), so it must pay the CURRENT
|
||||||
|
# network fee for a quick confirmation - not ``baltx_fees``, which is a
|
||||||
|
# deliberately high rate reserved for inheritance txs that may be broadcast
|
||||||
|
# years from now. 2 blocks is Electrum's own default target (owner decision).
|
||||||
|
INVALIDATION_FEE_ETA_BLOCKS = 2
|
||||||
|
# Default fee policy for the invalidation tx, as an Electrum ``FeePolicy``
|
||||||
|
# descriptor ("<method>:<value>"). The user can change it from the plugin's
|
||||||
|
# graphical settings, which uses Electrum's standard fee slider (Feerate / ETA /
|
||||||
|
# Mempool). "eta:2" = fee estimated for confirmation within 2 blocks.
|
||||||
|
INVALIDATION_FEE_POLICY_DEFAULT = f"eta:{INVALIDATION_FEE_ETA_BLOCKS}"
|
||||||
_logger = get_logger(__name__)
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -68,6 +82,22 @@ class Will:
|
|||||||
# build a tree with parent transactions
|
# build a tree with parent transactions
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def add_willtree(will):
|
def add_willtree(will):
|
||||||
|
"""(Re)build the parent/child links between the will transactions.
|
||||||
|
|
||||||
|
Every item gets ``children`` (the will txs spending its outputs) and,
|
||||||
|
when it spends the output of another will tx, a ``father``.
|
||||||
|
|
||||||
|
WHY the reset below: ``father`` is persisted in the wallet (the
|
||||||
|
``"Father"`` field of ``WillItem.to_dict``) and was only ever set,
|
||||||
|
never cleared. When the father tx was later removed from the will
|
||||||
|
(rebuild, merge, replacement) the child kept pointing to a txid that
|
||||||
|
no longer exists, and ``check_invalidated`` crashed with a
|
||||||
|
``KeyError`` on that txid (reported on a real wallet: the Check
|
||||||
|
wizard and the window refresh both failed). The tree is therefore
|
||||||
|
always recomputed from scratch from the transactions themselves.
|
||||||
|
"""
|
||||||
|
for willid in will:
|
||||||
|
will[willid].father = None
|
||||||
for willid in will:
|
for willid in will:
|
||||||
will[willid].children = Will.get_children(will, willid)
|
will[willid].children = Will.get_children(will, willid)
|
||||||
for child in will[willid].children:
|
for child in will[willid].children:
|
||||||
@@ -438,47 +468,189 @@ class Will:
|
|||||||
out[inp.prevout.to_str()] = inp
|
out[inp.prevout.to_str()] = inp
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_invalidation_fee_rate(
|
||||||
|
wallet, fallback_fee_per_byte, fee_policy_descriptor=None
|
||||||
|
):
|
||||||
|
"""Return ``(fee_rate, source)`` for the on-chain invalidation tx.
|
||||||
|
|
||||||
|
``fee_rate`` is in sat/vbyte. ``source`` names where the rate came from:
|
||||||
|
the ``FeeMethod`` used by the policy (``"feerate"``, ``"eta"``,
|
||||||
|
``"mempool"``) or ``"fallback"`` when no usable rate was available.
|
||||||
|
|
||||||
|
WHY: the invalidation is broadcast NOW, so it pays the current network
|
||||||
|
fee according to the user's chosen ``FeePolicy`` (default ``"eta:2"``,
|
||||||
|
i.e. a confirmation within 2 blocks). The will's ``baltx_fees`` is kept
|
||||||
|
only as a fallback, used when no estimate is available (offline wallet,
|
||||||
|
estimates not received yet, server error), which is exactly the
|
||||||
|
behaviour of previous versions.
|
||||||
|
|
||||||
|
The rate is computed by Electrum's own ``FeePolicy`` (the module behind
|
||||||
|
the standard fee slider), so the graphical settings can offer Feerate /
|
||||||
|
ETA / Mempool. It returns sat/kvbyte and already clamps dynamic
|
||||||
|
estimates between Electrum's conservative minimum relay fee and its
|
||||||
|
maximum dynamic fee; a fixed "feerate" policy needs no network at all.
|
||||||
|
This method never raises.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
wallet: The Electrum wallet (its ``network`` may be ``None``).
|
||||||
|
fallback_fee_per_byte: Rate (sat/vbyte) used when there is no
|
||||||
|
usable rate - normally the will's ``baltx_fees``.
|
||||||
|
fee_policy_descriptor: Electrum fee policy descriptor (e.g.
|
||||||
|
``"eta:2"``). ``None`` uses ``INVALIDATION_FEE_POLICY_DEFAULT``.
|
||||||
|
"""
|
||||||
|
descriptor = fee_policy_descriptor or INVALIDATION_FEE_POLICY_DEFAULT
|
||||||
|
# FeePolicy.parse handles malformed descriptors internally (it logs a
|
||||||
|
# warning and falls back to "eta:2"), so this never raises.
|
||||||
|
policy = FeePolicy(descriptor)
|
||||||
|
network = getattr(wallet, "network", None) if wallet else None
|
||||||
|
fee_per_kb = None
|
||||||
|
try:
|
||||||
|
fee_per_kb = policy.fee_per_kb(network)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(f"invalidation fee: no estimate ({e!r})")
|
||||||
|
fee_per_kb = None
|
||||||
|
# Accept only a real positive integer (Electrum's type for sat/kvbyte);
|
||||||
|
# ``bool`` is excluded explicitly because it is a subclass of ``int``.
|
||||||
|
if (
|
||||||
|
isinstance(fee_per_kb, int)
|
||||||
|
and not isinstance(fee_per_kb, bool)
|
||||||
|
and fee_per_kb > 0
|
||||||
|
):
|
||||||
|
return fee_per_kb / 1000, policy.method.name.lower()
|
||||||
|
return fallback_fee_per_byte, "fallback"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _invalidation_age_key(utxo):
|
||||||
|
"""Sort key for invalidation coins: smaller = in the wallet longest.
|
||||||
|
|
||||||
|
"Oldest" is approximated by the funding transaction's confirmation
|
||||||
|
height (confirmed coins with the lowest height were received first).
|
||||||
|
Unconfirmed, local or unknown-height coins have no mined position and
|
||||||
|
are treated as the newest. Deterministic tie-breakers: position within
|
||||||
|
the block, then the prevout string.
|
||||||
|
"""
|
||||||
|
height = getattr(utxo, "block_height", None)
|
||||||
|
if isinstance(height, int) and height > 0:
|
||||||
|
txpos = getattr(utxo, "block_txpos", 0) or 0
|
||||||
|
return (0, height, txpos, utxo.prevout.to_str())
|
||||||
|
return (1, 0, 0, utxo.prevout.to_str())
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def invalidate_will(will, wallet, fees_per_byte, history_label=None,
|
def invalidate_will(will, wallet, fees_per_byte, history_label=None,
|
||||||
will_locktime=None):
|
will_locktime=None, fee_policy_descriptor=None):
|
||||||
|
"""Build the (unsigned) on-chain invalidation transaction.
|
||||||
|
|
||||||
|
The transaction spends, per will transaction, only the OLDEST funding
|
||||||
|
coin (the one that has been in the wallet longest): spending a single
|
||||||
|
input already makes the pre-signed transaction unspendable, while the
|
||||||
|
newer coins stay available for the rebuilt (postponed) will. Both VALID
|
||||||
|
and REPLACED will items are considered, because a REPLACED item's
|
||||||
|
pre-signed transaction may have been pushed to the will-executors and
|
||||||
|
may spend different coins. The outputs go back to one of the wallet's
|
||||||
|
own change addresses. When the chosen coins would not cover the fee
|
||||||
|
while leaving a change output at or above the dust threshold, additional
|
||||||
|
(newer) candidate coins are added as fallback inputs; if the change
|
||||||
|
still cannot clear dust once the coins are exhausted, no invalidation
|
||||||
|
transaction is returned.
|
||||||
|
|
||||||
|
Fee: the CURRENT network fee according to the user's fee policy
|
||||||
|
(``fee_policy_descriptor``, default ``"eta:2"``; see
|
||||||
|
:meth:`get_invalidation_fee_rate`). ``fees_per_byte`` (the will's
|
||||||
|
``baltx_fees``) is only the FALLBACK used when no usable rate is
|
||||||
|
available. RBF stays enabled, so the fee can still be raised from
|
||||||
|
Electrum's History ("Increase fee") if the transaction gets stuck.
|
||||||
|
|
||||||
|
|
||||||
|
Returns the ``PartialTransaction``, or ``None`` when there is nothing
|
||||||
|
to invalidate or the balance does not cover the fee.
|
||||||
|
"""
|
||||||
_logger.debug("invalidate tx in will module")
|
_logger.debug("invalidate tx in will module")
|
||||||
will_only_valid = Will.only_valid_list(will)
|
# Invalidating only the VALID items is not enough. A will rebuilt after
|
||||||
inputs = Will.get_all_inputs(will_only_valid)
|
# an heir change (or a rebuild) supersedes the previously signed
|
||||||
|
# transaction: the old item is kept but marked REPLACED, which CLEARS
|
||||||
|
# VALID (see set_status). That old transaction may have been pushed to
|
||||||
|
# the will-executors and may spend DIFFERENT coins than the current
|
||||||
|
# valid one, so its funding UTXOs must be spent too, otherwise a
|
||||||
|
# will-executor could still broadcast it from a UTXO left unspent.
|
||||||
|
# REPLACED items therefore join the VALID ones; inputs that are already
|
||||||
|
# spent are naturally dropped by the wallet-UTXO filter below.
|
||||||
|
will_to_invalidate = {
|
||||||
|
wid: wi
|
||||||
|
for wid, wi in will.items()
|
||||||
|
if wi.get_status("VALID") or wi.get_status("REPLACED")
|
||||||
|
}
|
||||||
if history_label is not None and will_locktime is not None:
|
if history_label is not None and will_locktime is not None:
|
||||||
utxos = Util.get_available_utxos(wallet, history_label, will_locktime)
|
utxos = Util.get_available_utxos(wallet, history_label, will_locktime)
|
||||||
else:
|
else:
|
||||||
utxos = wallet.get_utxos()
|
utxos = wallet.get_utxos()
|
||||||
filtered_inputs = []
|
|
||||||
prevout_to_spend = []
|
|
||||||
current_height = Util.get_current_height(wallet.network)
|
current_height = Util.get_current_height(wallet.network)
|
||||||
for prevout_str, ws in inputs.items():
|
utxo_by_prevout = {}
|
||||||
for w in ws:
|
|
||||||
if w[0] not in filtered_inputs:
|
|
||||||
filtered_inputs.append(w[0])
|
|
||||||
if prevout_str not in prevout_to_spend:
|
|
||||||
prevout_to_spend.append(prevout_str)
|
|
||||||
balance = 0
|
|
||||||
utxo_to_spend = []
|
|
||||||
for utxo in utxos:
|
for utxo in utxos:
|
||||||
if utxo.is_coinbase_output() and utxo.block_height < current_height+100:
|
if utxo.is_coinbase_output() and utxo.block_height < current_height+100:
|
||||||
_logger.debug("is not mature coinbase output")
|
_logger.debug("is not mature coinbase output")
|
||||||
continue
|
continue
|
||||||
utxo_str = utxo.prevout.to_str()
|
utxo_by_prevout[utxo.prevout.to_str()] = utxo
|
||||||
if utxo_str in prevout_to_spend:
|
# Candidate coins: the wallet UTXOs that fund one of the will txs.
|
||||||
balance += utxo.value_sats()
|
candidates = []
|
||||||
utxo_to_spend.append(utxo)
|
seen = set()
|
||||||
|
for wi in will_to_invalidate.values():
|
||||||
|
for txin in wi.tx.inputs():
|
||||||
|
prevout_str = txin.prevout.to_str()
|
||||||
|
if prevout_str in seen or prevout_str not in utxo_by_prevout:
|
||||||
|
continue
|
||||||
|
seen.add(prevout_str)
|
||||||
|
candidates.append(utxo_by_prevout[prevout_str])
|
||||||
|
# Spend, per will transaction, only its OLDEST funding coin (the one
|
||||||
|
# that has been in the wallet longest): spending a single input is
|
||||||
|
# enough to make the pre-signed transaction unspendable, and the newer
|
||||||
|
# coins stay available for the rebuilt (postponed) will.
|
||||||
|
chosen = {} # prevout_str -> UTXO, one (the oldest) per will transaction
|
||||||
|
for wi in will_to_invalidate.values():
|
||||||
|
item_candidates = [
|
||||||
|
utxo_by_prevout[txin.prevout.to_str()]
|
||||||
|
for txin in wi.tx.inputs()
|
||||||
|
if txin.prevout.to_str() in utxo_by_prevout
|
||||||
|
]
|
||||||
|
if item_candidates:
|
||||||
|
oldest = min(item_candidates, key=Will._invalidation_age_key)
|
||||||
|
chosen[oldest.prevout.to_str()] = oldest
|
||||||
|
utxo_to_spend = list(chosen.values())
|
||||||
|
balance = sum(u.value_sats() for u in utxo_to_spend)
|
||||||
_logger.debug("utxo to spend: {}".format(utxo_to_spend))
|
_logger.debug("utxo to spend: {}".format(utxo_to_spend))
|
||||||
if len(utxo_to_spend) > 0:
|
if len(utxo_to_spend) > 0:
|
||||||
change_addresses = wallet.get_change_addresses_for_new_transaction()
|
change_addresses = wallet.get_change_addresses_for_new_transaction()
|
||||||
|
locktime = current_height
|
||||||
|
fee_rate, fee_source = Will.get_invalidation_fee_rate(
|
||||||
|
wallet, fees_per_byte, fee_policy_descriptor
|
||||||
|
)
|
||||||
|
_logger.info(
|
||||||
|
f"invalidation fee: {fee_rate} sat/vbyte ({fee_source})"
|
||||||
|
)
|
||||||
|
# The change output must stay at or above the dust threshold,
|
||||||
|
# otherwise the invalidation transaction would be non-standard and
|
||||||
|
# could not be relayed.
|
||||||
|
try:
|
||||||
|
dust = int(wallet.dust_threshold())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
dust = 0
|
||||||
|
# Remaining candidate coins, oldest first, for the fee fallback.
|
||||||
|
remaining = sorted(
|
||||||
|
(c for c in candidates if c.prevout.to_str() not in chosen),
|
||||||
|
key=Will._invalidation_age_key,
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
# The network rate can be fractional (e.g. 1.5 sat/vbyte): round
|
||||||
|
# the total fee UP to whole satoshis so the output value is an
|
||||||
|
# integer and the rate is never below the one requested.
|
||||||
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
|
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
|
||||||
out.is_change = True
|
out.is_change = True
|
||||||
locktime = current_height
|
|
||||||
tx = PartialTransaction.from_io(
|
tx = PartialTransaction.from_io(
|
||||||
utxo_to_spend, [out], locktime=locktime, version=2
|
utxo_to_spend, [out], locktime=locktime, version=2
|
||||||
)
|
)
|
||||||
tx.set_rbf(True)
|
tx.set_rbf(True)
|
||||||
fee = tx.estimated_size() * fees_per_byte
|
fee = int(math.ceil(tx.estimated_size() * fee_rate))
|
||||||
if balance - fee > 0:
|
if balance - fee >= dust:
|
||||||
out = PartialTxOutput.from_address_and_value(
|
out = PartialTxOutput.from_address_and_value(
|
||||||
change_addresses[0], balance - fee
|
change_addresses[0], balance - fee
|
||||||
)
|
)
|
||||||
@@ -490,9 +662,26 @@ class Will:
|
|||||||
_logger.debug(f"invalidation tx: {tx}")
|
_logger.debug(f"invalidation tx: {tx}")
|
||||||
return tx
|
return tx
|
||||||
|
|
||||||
else:
|
# The selected coins cannot cover the fee while leaving a
|
||||||
_logger.debug(f"balance({balance}) - fee({fee}) <=0")
|
# spendable (non-dust) change: keep spending the next-oldest
|
||||||
pass
|
# will coin until the invalidation is economically viable or no
|
||||||
|
# coin is left.
|
||||||
|
if not remaining:
|
||||||
|
_logger.debug(
|
||||||
|
"balance({})-fee({})<dust({}), no more will coins".format(
|
||||||
|
balance, fee, dust
|
||||||
|
)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
extra = remaining.pop(0)
|
||||||
|
_logger.debug(
|
||||||
|
"balance({})-fee({})<dust({}): adding {}".format(
|
||||||
|
balance, fee, dust, extra.prevout.to_str()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
chosen[extra.prevout.to_str()] = extra
|
||||||
|
utxo_to_spend = list(chosen.values())
|
||||||
|
balance += extra.value_sats()
|
||||||
else:
|
else:
|
||||||
_logger.debug("len utxo_to_spend <=0")
|
_logger.debug("len utxo_to_spend <=0")
|
||||||
pass
|
pass
|
||||||
@@ -562,9 +751,15 @@ class Will:
|
|||||||
def set_invalidate(wid, will=None):
|
def set_invalidate(wid, will=None):
|
||||||
will = will if will is not None else {}
|
will = will if will is not None else {}
|
||||||
will[wid].set_status("INVALIDATED", True)
|
will[wid].set_status("INVALIDATED", True)
|
||||||
if will[wid].children:
|
children = will[wid].children or []
|
||||||
for c in will[wid].children.items():
|
# add_willtree builds a LIST of [child_id, input_idx, prevout_idx];
|
||||||
Will.set_invalidate(c[0], will)
|
# a dict keyed by child id is still accepted (older data / tests).
|
||||||
|
child_ids = children.keys() if isinstance(children, dict) else [
|
||||||
|
c[0] for c in children
|
||||||
|
]
|
||||||
|
for child_id in child_ids:
|
||||||
|
if child_id in will:
|
||||||
|
Will.set_invalidate(child_id, will)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check_tx_height(tx, wallet):
|
def check_tx_height(tx, wallet):
|
||||||
@@ -575,10 +770,15 @@ class Will:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def check_invalidated(willtree, utxos_list, wallet):
|
def check_invalidated(willtree, utxos_list, wallet):
|
||||||
for wid, w in willtree.items():
|
for wid, w in willtree.items():
|
||||||
|
# Defensive: a father that is not (or no longer) part of the will
|
||||||
|
# is treated as "no father" instead of raising KeyError, so the
|
||||||
|
# item's inputs are checked against the wallet UTXOs like any
|
||||||
|
# root transaction. See add_willtree for the root cause.
|
||||||
|
father = willtree.get(w.father) if w.father else None
|
||||||
if (
|
if (
|
||||||
not w.father
|
father is None
|
||||||
or willtree[w.father].get_status("CONFIRMED")
|
or father.get_status("CONFIRMED")
|
||||||
or willtree[w.father].get_status("MEMPOOL")
|
or father.get_status("MEMPOOL")
|
||||||
):
|
):
|
||||||
for inp in w.tx.inputs():
|
for inp in w.tx.inputs():
|
||||||
inp_str = Util.utxo_to_str(inp)
|
inp_str = Util.utxo_to_str(inp)
|
||||||
@@ -623,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():
|
||||||
@@ -635,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}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -358,7 +358,8 @@ class BalWizardWidget(QWidget):
|
|||||||
class BalWizardHeirsWidget(BalWizardWidget):
|
class BalWizardHeirsWidget(BalWizardWidget):
|
||||||
title = N_("Bitcoin After Life Heirs")
|
title = N_("Bitcoin After Life Heirs")
|
||||||
message = N_(
|
message = N_(
|
||||||
"Please add your heirs\n remember that 100% of wallet balance will be spent"
|
"Please add your heirs\n"
|
||||||
|
" remember that the wallet balance will be spent at 100%"
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_content(self):
|
def get_content(self):
|
||||||
@@ -690,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
|
||||||
@@ -746,6 +758,7 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
self.bal_window.willitems,
|
self.bal_window.willitems,
|
||||||
default_value=self.bal_window.date_to_check,
|
default_value=self.bal_window.date_to_check,
|
||||||
),
|
),
|
||||||
|
fee_policy_descriptor=self.bal_window.bal_plugin.INVALIDATION_FEE_POLICY.get(),
|
||||||
)
|
)
|
||||||
if tx:
|
if tx:
|
||||||
_logger.debug(
|
_logger.debug(
|
||||||
@@ -787,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()
|
||||||
@@ -841,6 +868,7 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
self.bal_window.willitems,
|
self.bal_window.willitems,
|
||||||
default_value=self.bal_window.date_to_check,
|
default_value=self.bal_window.date_to_check,
|
||||||
),
|
),
|
||||||
|
fee_policy_descriptor=self.bal_window.bal_plugin.INVALIDATION_FEE_POLICY.get(),
|
||||||
)
|
)
|
||||||
except NoHeirsException:
|
except NoHeirsException:
|
||||||
_logger.debug("no heirs")
|
_logger.debug("no heirs")
|
||||||
@@ -894,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:
|
||||||
@@ -1082,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
|
||||||
@@ -1174,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).
|
||||||
@@ -1621,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 "
|
||||||
@@ -1635,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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ One :class:`bal.gui.qt.window.BalWindow` is created per top-level wallet window
|
|||||||
and cached in ``self.bal_windows``.
|
and cached in ``self.bal_windows``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from electrum.fee_policy import FeeMethod, FeePolicy
|
||||||
|
from electrum.gui.qt.fee_slider import FeeComboBox, FeeSlider
|
||||||
from electrum.gui.qt.main_window import StatusBarButton
|
from electrum.gui.qt.main_window import StatusBarButton
|
||||||
from electrum.plugin import hook
|
from electrum.plugin import hook
|
||||||
from electrum.util import EventListener, event_listener
|
from electrum.util import EventListener, event_listener
|
||||||
@@ -594,7 +596,9 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
lbl_save_history, heir_save_history, help_save_history,
|
lbl_save_history, heir_save_history, help_save_history,
|
||||||
lbl_history_label, edit_history_label, help_history_label,
|
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_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10,
|
||||||
reset_btn_11, reset_btn_12, reset_btn_auto_sign):
|
reset_btn_11, reset_btn_12, reset_btn_auto_sign,
|
||||||
|
lbl_invalidation_fee, invalidation_fee_box,
|
||||||
|
help_invalidation_fee, btn_reset_invalidation_fee):
|
||||||
w.setVisible(not basic)
|
w.setVisible(not basic)
|
||||||
# Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on
|
# 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
|
# a real USER TYPE change (not inside update_all/CHECK), so pressing
|
||||||
@@ -911,6 +915,78 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
)
|
)
|
||||||
grid.addWidget(reset_btn_auto_rebuild, 15, 3)
|
grid.addWidget(reset_btn_auto_rebuild, 15, 3)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- #
|
||||||
|
# Invalidation fee policy (ADVANCED only): which fee rate the on-chain #
|
||||||
|
# invalidation transaction pays. It reuses Electrum's standard fee #
|
||||||
|
# widget (the same Feerate / ETA / Mempool slider used in the send #
|
||||||
|
# tab), so the user picks a fixed rate, a block target or a mempool #
|
||||||
|
# depth. The chosen policy is persisted as an Electrum "FeePolicy" #
|
||||||
|
# descriptor ("<method>:<value>"). #
|
||||||
|
# ----------------------------------------------------------------- #
|
||||||
|
invalidation_fee_policy = FeePolicy(self.INVALIDATION_FEE_POLICY.get())
|
||||||
|
settings_network = (
|
||||||
|
getattr(window, "network", None) if window is not None else None
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_invalidation_fee_change(_fee_rate):
|
||||||
|
"""Persist the fee policy descriptor chosen with the slider."""
|
||||||
|
self.INVALIDATION_FEE_POLICY.set(
|
||||||
|
invalidation_fee_policy.get_descriptor()
|
||||||
|
)
|
||||||
|
|
||||||
|
invalidation_fee_slider = FeeSlider(
|
||||||
|
parent=d,
|
||||||
|
network=settings_network,
|
||||||
|
fee_policy=invalidation_fee_policy,
|
||||||
|
callback=on_invalidation_fee_change,
|
||||||
|
)
|
||||||
|
invalidation_fee_combo = FeeComboBox(invalidation_fee_slider)
|
||||||
|
# Do NOT call invalidation_fee_slider.update() here: the slider and combo
|
||||||
|
# already initialise themselves before their signals are connected, and
|
||||||
|
# update() fires moved() -> on_invalidation_fee_change, which would
|
||||||
|
# persist (and snap) the descriptor just for opening the dialog.
|
||||||
|
invalidation_fee_box = QWidget()
|
||||||
|
invalidation_fee_box_layout = QHBoxLayout(invalidation_fee_box)
|
||||||
|
invalidation_fee_box_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
invalidation_fee_box_layout.addWidget(invalidation_fee_slider, 1)
|
||||||
|
invalidation_fee_box_layout.addWidget(invalidation_fee_combo)
|
||||||
|
|
||||||
|
def reset_invalidation_fee():
|
||||||
|
"""Restore the fee policy to its default ("eta:2").
|
||||||
|
|
||||||
|
The live ``FeePolicy`` object is mutated in place (method + value)
|
||||||
|
so the slider keeps working with the new defaults; the stored
|
||||||
|
descriptor and the combo are then re-synced.
|
||||||
|
"""
|
||||||
|
default_policy = FeePolicy(self.INVALIDATION_FEE_POLICY.default)
|
||||||
|
invalidation_fee_policy.method = default_policy.method
|
||||||
|
invalidation_fee_policy.value = default_policy.value
|
||||||
|
self.INVALIDATION_FEE_POLICY.set(self.INVALIDATION_FEE_POLICY.default)
|
||||||
|
invalidation_fee_combo.setCurrentIndex(
|
||||||
|
FeeMethod.slider_index_of_method(default_policy.method)
|
||||||
|
)
|
||||||
|
invalidation_fee_slider.update()
|
||||||
|
|
||||||
|
lbl_invalidation_fee = QLabel(_("Invalidation fee policy"))
|
||||||
|
help_invalidation_fee = HelpButton(_(
|
||||||
|
"Fee rate the on-chain invalidation transaction pays.\n"
|
||||||
|
"The invalidation is sent TODAY (it spends the will's coins so the "
|
||||||
|
"old inheritance transactions become unusable), so it should pay "
|
||||||
|
"the current network fee - not the will's mining fee rate.\n\n"
|
||||||
|
"Feerate: a fixed rate (sat/vByte).\n"
|
||||||
|
"ETA: fee based on the estimated confirmation time (blocks).\n"
|
||||||
|
"Mempool: fee targeting a depth in the memory pool.\n"
|
||||||
|
"Only used in ADVANCED mode."
|
||||||
|
))
|
||||||
|
btn_reset_invalidation_fee = QPushButton("\u21ba")
|
||||||
|
btn_reset_invalidation_fee.setFixedWidth(24)
|
||||||
|
btn_reset_invalidation_fee.setToolTip(_("Reset to default"))
|
||||||
|
btn_reset_invalidation_fee.clicked.connect(reset_invalidation_fee)
|
||||||
|
grid.addWidget(_hide_if_basic(lbl_invalidation_fee), 16, 0)
|
||||||
|
grid.addWidget(_hide_if_basic(invalidation_fee_box), 16, 1)
|
||||||
|
grid.addWidget(_hide_if_basic(help_invalidation_fee), 16, 2)
|
||||||
|
grid.addWidget(_hide_if_basic(btn_reset_invalidation_fee), 16, 3)
|
||||||
|
|
||||||
# ----------------------------------------------------------------- #
|
# ----------------------------------------------------------------- #
|
||||||
# Group C / C4b: "Reset" button that restores the dialog settings to #
|
# Group C / C4b: "Reset" button that restores the dialog settings to #
|
||||||
# their factory defaults. It only resets the settings exposed by THIS #
|
# their factory defaults. It only resets the settings exposed by THIS #
|
||||||
@@ -968,6 +1044,8 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
# reset restores SAVE_HISTORY to its default, so the field must
|
# reset restores SAVE_HISTORY to its default, so the field must
|
||||||
# follow the (default) checkbox state again.
|
# follow the (default) checkbox state again.
|
||||||
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
|
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
|
||||||
|
# Reset the invalidation fee policy to its default ("eta:2") too.
|
||||||
|
reset_invalidation_fee()
|
||||||
# Refresh the open BAL windows so any dependent view (e.g. the
|
# Refresh the open BAL windows so any dependent view (e.g. the
|
||||||
# editable-dates state is not in this list, but hide filters are)
|
# editable-dates state is not in this list, but hide filters are)
|
||||||
# reflects the reset values.
|
# reflects the reset values.
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -1089,6 +1089,7 @@ class BalWindow:
|
|||||||
will_locktime=Will.get_min_locktime(
|
will_locktime=Will.get_min_locktime(
|
||||||
willitems, default_value=self.date_to_check
|
willitems, default_value=self.date_to_check
|
||||||
),
|
),
|
||||||
|
fee_policy_descriptor=self.bal_plugin.INVALIDATION_FEE_POLICY.get(),
|
||||||
)
|
)
|
||||||
msg = _("Calculating Transactions")
|
msg = _("Calculating Transactions")
|
||||||
self.waiting_dialog = BalWaitingDialog(
|
self.waiting_dialog = BalWaitingDialog(
|
||||||
@@ -1424,6 +1425,7 @@ class BalWindow:
|
|||||||
willitems,
|
willitems,
|
||||||
default_value=getattr(self, "date_to_check", None),
|
default_value=getattr(self, "date_to_check", None),
|
||||||
),
|
),
|
||||||
|
fee_policy_descriptor=self.bal_plugin.INVALIDATION_FEE_POLICY.get(),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error("auto invalidate: could not build tx: {}".format(e))
|
_logger.error("auto invalidate: could not build tx: {}".format(e))
|
||||||
|
|||||||
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)
|
||||||
|
|||||||
296
tests/test_core_invalidation_fee.py
Normal file
296
tests/test_core_invalidation_fee.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
"""
|
||||||
|
Tests for the fee of the on-chain INVALIDATION transaction.
|
||||||
|
|
||||||
|
The invalidation is broadcast TODAY (it spends the will's coins so the old
|
||||||
|
pre-signed inheritance transactions become unusable), therefore it must pay the
|
||||||
|
CURRENT network fee chosen with an Electrum ``FeePolicy`` (Feerate / ETA /
|
||||||
|
Mempool; default ``"eta:2"``, i.e. confirm within 2 blocks). The will's
|
||||||
|
``baltx_fees`` (a deliberately high rate meant for inheritance transactions
|
||||||
|
broadcast years from now) is only the FALLBACK used when no usable rate is
|
||||||
|
available.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
* Will.get_invalidation_fee_rate() - rate selection and fallback rules
|
||||||
|
* Will.invalidate_will() - the chosen rate is really used for the fee
|
||||||
|
|
||||||
|
Run (standalone or with pytest):
|
||||||
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||||
|
python3 tests/test_core_invalidation_fee.py
|
||||||
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||||
|
python3 -m pytest tests/test_core_invalidation_fee.py -q
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from electrum.fee_policy import FeePolicy, FeeTimeEstimates
|
||||||
|
from electrum.transaction import Transaction
|
||||||
|
|
||||||
|
from bal.core.will import (
|
||||||
|
INVALIDATION_FEE_ETA_BLOCKS,
|
||||||
|
INVALIDATION_FEE_POLICY_DEFAULT,
|
||||||
|
Will,
|
||||||
|
WillItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
# WillItem parses a raw tx and calls add_info_from_wallet; no live wallet here.
|
||||||
|
_patcher = patch.object(Transaction, "add_info_from_wallet")
|
||||||
|
_patcher.start()
|
||||||
|
|
||||||
|
# A valid serialized transaction (1 input, 1 output) reused by the core tests.
|
||||||
|
_VALID_TX_HEX = (
|
||||||
|
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||||
|
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||||
|
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||||
|
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||||
|
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||||
|
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||||
|
"42146f11ef8414ae929feaafc388ac00000000"
|
||||||
|
)
|
||||||
|
# The outpoint spent by _VALID_TX_HEX (input 0).
|
||||||
|
_PREVOUT_STR = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0"
|
||||||
|
_CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
|
||||||
|
_BALANCE = 1000000
|
||||||
|
# The will's baltx_fees used as the fallback rate in every test (sat/vbyte).
|
||||||
|
_BALTX_FEES = 20
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _wallet_with_eta_estimate(fee_per_kb):
|
||||||
|
"""Wallet whose ETA fee lookup returns ``fee_per_kb`` (sat/kvbyte).
|
||||||
|
|
||||||
|
Electrum's ``FeePolicy`` (method ``eta``) resolves the target through
|
||||||
|
``network.fee_estimates.eta_to_fee``. ``fee_per_kb`` may also be an
|
||||||
|
Exception instance: the call then raises it (broken network object), or a
|
||||||
|
non-int-convertible object to exercise the invalid-value path.
|
||||||
|
"""
|
||||||
|
wallet = MagicMock()
|
||||||
|
wallet.get_change_addresses_for_new_transaction.return_value = [_CHANGE_ADDR]
|
||||||
|
network = MagicMock()
|
||||||
|
if isinstance(fee_per_kb, Exception):
|
||||||
|
network.fee_estimates.eta_to_fee.side_effect = fee_per_kb
|
||||||
|
else:
|
||||||
|
network.fee_estimates.eta_to_fee.return_value = fee_per_kb
|
||||||
|
wallet.network = network
|
||||||
|
return wallet
|
||||||
|
|
||||||
|
|
||||||
|
def _make_will():
|
||||||
|
"""One VALID will item spending _PREVOUT_STR with a known input value."""
|
||||||
|
item = WillItem({
|
||||||
|
"tx": _VALID_TX_HEX,
|
||||||
|
"heirs": {"alice": ["addr_alice", 5000, "30d"]},
|
||||||
|
"willexecutor": None,
|
||||||
|
"status": "",
|
||||||
|
"description": "",
|
||||||
|
"time": 0,
|
||||||
|
"change": "",
|
||||||
|
"baltx_fees": _BALTX_FEES,
|
||||||
|
})
|
||||||
|
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
|
item.tx.inputs()[0]._TxInput__value_sats = _BALANCE
|
||||||
|
return {"willtxid1": item}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_utxo():
|
||||||
|
"""Wallet-side UTXO matching the will input."""
|
||||||
|
utxo = MagicMock()
|
||||||
|
utxo.prevout.to_str.return_value = _PREVOUT_STR
|
||||||
|
utxo.is_coinbase_output.return_value = False
|
||||||
|
utxo.block_height = 1
|
||||||
|
utxo.value_sats.return_value = _BALANCE
|
||||||
|
return utxo
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidation_output_value(wallet, estimated_size=200, descriptor=None):
|
||||||
|
"""Run Will.invalidate_will and return the value of the final output.
|
||||||
|
|
||||||
|
Electrum's tx building is mocked (as in test_core_will_invalidate.py):
|
||||||
|
``estimated_size`` is the vsize the fee is computed from.
|
||||||
|
"""
|
||||||
|
wallet.get_utxos.return_value = [_make_utxo()]
|
||||||
|
mock_output = MagicMock()
|
||||||
|
mock_tx = MagicMock()
|
||||||
|
mock_tx.estimated_size.return_value = estimated_size
|
||||||
|
with patch("bal.core.will.Util.get_current_height", return_value=800000), \
|
||||||
|
patch("electrum.transaction.PartialTxOutput.from_address_and_value",
|
||||||
|
return_value=mock_output) as mock_out, \
|
||||||
|
patch("electrum.transaction.PartialTransaction.from_io",
|
||||||
|
return_value=mock_tx):
|
||||||
|
result = Will.invalidate_will(
|
||||||
|
_make_will(), wallet, _BALTX_FEES,
|
||||||
|
fee_policy_descriptor=descriptor,
|
||||||
|
)
|
||||||
|
assert result is mock_tx
|
||||||
|
# 1st call: full balance (size estimation); 2nd call: balance - fee.
|
||||||
|
return mock_out.call_args_list[1][0][1]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Will.get_invalidation_fee_rate - policy selection
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_default_policy_is_two_block_eta():
|
||||||
|
"""The owner's decision: default fee policy confirms within 2 blocks.
|
||||||
|
|
||||||
|
Uses a REAL ``FeeTimeEstimates`` holding data only for the 2-block target,
|
||||||
|
proving the default policy really asks Electrum for target 2.
|
||||||
|
"""
|
||||||
|
assert INVALIDATION_FEE_ETA_BLOCKS == 2
|
||||||
|
assert INVALIDATION_FEE_POLICY_DEFAULT == "eta:2"
|
||||||
|
network = MagicMock()
|
||||||
|
estimates = FeeTimeEstimates()
|
||||||
|
estimates.set_data(2, 3000)
|
||||||
|
network.fee_estimates = estimates
|
||||||
|
wallet = MagicMock()
|
||||||
|
wallet.network = network
|
||||||
|
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (3.0, "eta")
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_policy_descriptor_is_parsed_by_electrum_fee_policy():
|
||||||
|
"""The setting is a real Electrum FeePolicy descriptor."""
|
||||||
|
policy = FeePolicy(INVALIDATION_FEE_POLICY_DEFAULT)
|
||||||
|
assert policy.method.name.lower() == "eta"
|
||||||
|
assert policy.value == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_estimate_is_used():
|
||||||
|
"""A live estimate (sat/kvbyte) becomes the rate in sat/vbyte."""
|
||||||
|
wallet = _wallet_with_eta_estimate(3000)
|
||||||
|
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (3.0, "eta")
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_estimate_higher_than_baltx_fees_is_used_as_is():
|
||||||
|
"""No cap: if the market is above baltx_fees, the market rate is used."""
|
||||||
|
wallet = _wallet_with_eta_estimate(45000)
|
||||||
|
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (45.0, "eta")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fractional_estimate_is_kept():
|
||||||
|
"""Electrum estimates may be fractional (e.g. 1.5 sat/vbyte)."""
|
||||||
|
wallet = _wallet_with_eta_estimate(1500)
|
||||||
|
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (1.5, "eta")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixed_feerate_policy_works_offline():
|
||||||
|
"""A fixed 'feerate' policy needs no network at all."""
|
||||||
|
wallet = MagicMock()
|
||||||
|
wallet.network = None
|
||||||
|
assert Will.get_invalidation_fee_rate(
|
||||||
|
wallet, _BALTX_FEES, "feerate:5000"
|
||||||
|
) == (5.0, "feerate")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mempool_policy_uses_depth_estimate():
|
||||||
|
"""A 'mempool' policy asks Electrum for a depth-based estimate."""
|
||||||
|
wallet = MagicMock()
|
||||||
|
network = MagicMock()
|
||||||
|
network.mempool_fees.depth_to_fee.return_value = 3000
|
||||||
|
wallet.network = network
|
||||||
|
assert Will.get_invalidation_fee_rate(
|
||||||
|
wallet, _BALTX_FEES, "mempool:1000000"
|
||||||
|
) == (3.0, "mempool")
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_wallet_falls_back_to_baltx_fees():
|
||||||
|
"""No network object (offline) with a dynamic policy: baltx_fees."""
|
||||||
|
wallet = MagicMock()
|
||||||
|
wallet.network = None
|
||||||
|
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
|
||||||
|
_BALTX_FEES,
|
||||||
|
"fallback",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_estimate_falls_back():
|
||||||
|
"""Estimates not received yet: Electrum returns None."""
|
||||||
|
wallet = _wallet_with_eta_estimate(None)
|
||||||
|
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
|
||||||
|
_BALTX_FEES,
|
||||||
|
"fallback",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_error_falls_back_and_does_not_raise():
|
||||||
|
"""A broken network object must never break the invalidation."""
|
||||||
|
wallet = _wallet_with_eta_estimate(RuntimeError("server gone"))
|
||||||
|
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
|
||||||
|
_BALTX_FEES,
|
||||||
|
"fallback",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_positive_or_unconvertible_estimates_fall_back():
|
||||||
|
"""Only a positive integer rate is trusted."""
|
||||||
|
for bad in (0, -1000, object()):
|
||||||
|
wallet = _wallet_with_eta_estimate(bad)
|
||||||
|
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
|
||||||
|
_BALTX_FEES,
|
||||||
|
"fallback",
|
||||||
|
), bad
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_policy_descriptor_uses_the_default():
|
||||||
|
"""A corrupt stored descriptor must not break anything: default is used."""
|
||||||
|
wallet = _wallet_with_eta_estimate(3000)
|
||||||
|
assert Will.get_invalidation_fee_rate(
|
||||||
|
wallet, _BALTX_FEES, "not-a-policy"
|
||||||
|
) == (3.0, "eta")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_wallet_falls_back():
|
||||||
|
assert Will.get_invalidation_fee_rate(None, _BALTX_FEES) == (
|
||||||
|
_BALTX_FEES,
|
||||||
|
"fallback",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Will.invalidate_will uses the chosen rate
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_invalidation_pays_network_fee_not_baltx_fees():
|
||||||
|
"""3 sat/vbyte on 200 vbytes = 600 sat (not 200 * 20 = 4000 sat)."""
|
||||||
|
wallet = _wallet_with_eta_estimate(3000)
|
||||||
|
assert _invalidation_output_value(wallet) == _BALANCE - 600
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalidation_offline_pays_baltx_fees():
|
||||||
|
"""Offline: unchanged behaviour, 200 * 20 = 4000 sat."""
|
||||||
|
wallet = _wallet_with_eta_estimate(None)
|
||||||
|
assert _invalidation_output_value(wallet) == _BALANCE - 4000
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalidation_uses_fixed_feerate_policy_offline():
|
||||||
|
"""A fixed feerate pays exactly that rate even without a network."""
|
||||||
|
wallet = MagicMock()
|
||||||
|
wallet.network = None
|
||||||
|
assert _invalidation_output_value(
|
||||||
|
wallet, descriptor="feerate:5000"
|
||||||
|
) == _BALANCE - 1000 # 200 vbytes * 5 sat/vbyte
|
||||||
|
|
||||||
|
|
||||||
|
def test_fractional_fee_is_rounded_up_to_whole_satoshis():
|
||||||
|
"""1.5 sat/vbyte on 201 vbytes = 301.5 -> 302 sat (never below the rate)."""
|
||||||
|
wallet = _wallet_with_eta_estimate(1500)
|
||||||
|
value = _invalidation_output_value(wallet, estimated_size=201)
|
||||||
|
assert value == _BALANCE - 302
|
||||||
|
assert isinstance(value, int)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Main
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All invalidation fee tests passed")
|
||||||
@@ -47,6 +47,24 @@ _VALID_TX_HEX = (
|
|||||||
# The prevout string that _VALID_TX_HEX spends (input 0).
|
# The prevout string that _VALID_TX_HEX spends (input 0).
|
||||||
_PREVOUT_STR = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0"
|
_PREVOUT_STR = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0"
|
||||||
|
|
||||||
|
# A second transaction identical to _VALID_TX_HEX but spending a DIFFERENT
|
||||||
|
# prevout. It stands for a superseded (REPLACED) will transaction that commits
|
||||||
|
# different coins than the current valid one.
|
||||||
|
_REPLACED_TX_HEX = "0100000001ff" + _VALID_TX_HEX[len("01000000012a"):]
|
||||||
|
_REPLACED_PREVOUT_STR = _PREVOUT_STR[:-4] + "ff:0"
|
||||||
|
|
||||||
|
# A 2-input transaction: version 2 with two parallel inputs. Built from
|
||||||
|
# _VALID_TX_HEX by duplicating its (298-hex) input with a DIFFERENT prevout
|
||||||
|
# (first byte "ff", so the second input spends _REPLACED_PREVOUT_STR) and
|
||||||
|
# keeping the unchanged outputs.
|
||||||
|
_INPUT_LEN = 298
|
||||||
|
_TWO_INPUT_TX_HEX = (
|
||||||
|
"0100000002"
|
||||||
|
+ _VALID_TX_HEX[10:10 + _INPUT_LEN]
|
||||||
|
+ "ff" + _VALID_TX_HEX[10 + 2:10 + _INPUT_LEN]
|
||||||
|
+ _VALID_TX_HEX[10 + _INPUT_LEN:]
|
||||||
|
)
|
||||||
|
|
||||||
# Change address for the invalidation output.
|
# Change address for the invalidation output.
|
||||||
_CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
|
_CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
|
||||||
|
|
||||||
@@ -55,8 +73,9 @@ _CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None):
|
def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None,
|
||||||
"""Create a WillItem from _VALID_TX_HEX with a known input value.
|
tx_hex=_VALID_TX_HEX):
|
||||||
|
"""Create a WillItem from ``tx_hex`` with a known input value.
|
||||||
|
|
||||||
The input's ``_trusted_value_sats`` is set so that
|
The input's ``_trusted_value_sats`` is set so that
|
||||||
``invalidate_will`` can read the balance from it.
|
``invalidate_will`` can read the balance from it.
|
||||||
@@ -65,7 +84,7 @@ def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None):
|
|||||||
if extra_heirs:
|
if extra_heirs:
|
||||||
heirs.update(extra_heirs)
|
heirs.update(extra_heirs)
|
||||||
item = WillItem({
|
item = WillItem({
|
||||||
"tx": _VALID_TX_HEX,
|
"tx": tx_hex,
|
||||||
"heirs": heirs,
|
"heirs": heirs,
|
||||||
"willexecutor": None,
|
"willexecutor": None,
|
||||||
"status": "",
|
"status": "",
|
||||||
@@ -93,16 +112,24 @@ def _make_utxo(prevout_str=None, value_sats=1000000, is_coinbase=False):
|
|||||||
utxo.prevout.to_str.return_value = prevout_str
|
utxo.prevout.to_str.return_value = prevout_str
|
||||||
utxo.is_coinbase_output.return_value = is_coinbase
|
utxo.is_coinbase_output.return_value = is_coinbase
|
||||||
utxo.block_height = 1
|
utxo.block_height = 1
|
||||||
|
utxo.block_txpos = 0
|
||||||
utxo.value_sats.return_value = value_sats
|
utxo.value_sats.return_value = value_sats
|
||||||
return utxo
|
return utxo
|
||||||
|
|
||||||
|
|
||||||
def _mock_wallet(utxos, change_addr=_CHANGE_ADDR):
|
def _mock_wallet(utxos, change_addr=_CHANGE_ADDR):
|
||||||
"""Create a mock wallet with the given UTXOs and change address."""
|
"""Create a mock wallet with the given UTXOs and change address.
|
||||||
|
|
||||||
|
``network`` is left ``None`` so ``Will.invalidate_will`` uses the
|
||||||
|
``fees_per_byte`` FALLBACK instead of Electrum's live estimate: these tests
|
||||||
|
exercise the pure fee arithmetic (``fee = size * fees_per_byte``). The
|
||||||
|
policy-based rate selection has its own tests in
|
||||||
|
``tests/test_core_invalidation_fee.py``.
|
||||||
|
"""
|
||||||
wallet = MagicMock()
|
wallet = MagicMock()
|
||||||
wallet.get_utxos.return_value = utxos
|
wallet.get_utxos.return_value = utxos
|
||||||
wallet.get_change_addresses_for_new_transaction.return_value = [change_addr]
|
wallet.get_change_addresses_for_new_transaction.return_value = [change_addr]
|
||||||
wallet.network = MagicMock()
|
wallet.network = None
|
||||||
return wallet
|
return wallet
|
||||||
|
|
||||||
|
|
||||||
@@ -292,6 +319,77 @@ class TestInvalidateWill:
|
|||||||
second_call_value = mock_out.call_args_list[1][0][1]
|
second_call_value = mock_out.call_args_list[1][0][1]
|
||||||
assert second_call_value == 998000
|
assert second_call_value == 998000
|
||||||
|
|
||||||
|
def test_replaced_item_inputs_are_invalidated(self):
|
||||||
|
"""A REPLACED (superseded) item's funding UTXO is also spent.
|
||||||
|
|
||||||
|
Regression: a will rebuilt after an heir change supersedes the old
|
||||||
|
signed transaction, which is kept but marked REPLACED (VALID cleared).
|
||||||
|
The old transaction may have been pushed to the will-executors and may
|
||||||
|
spend DIFFERENT coins than the current valid one, so its UTXO must be
|
||||||
|
included in the invalidation. Otherwise it would stay unspent and a
|
||||||
|
will-executor could still broadcast the superseded transaction.
|
||||||
|
"""
|
||||||
|
valid_item = _make_willitem(value_sats=1000000)
|
||||||
|
replaced_item = _make_willitem(
|
||||||
|
value_sats=2000000, tx_hex=_REPLACED_TX_HEX
|
||||||
|
)
|
||||||
|
replaced_item.set_status("REPLACED", True)
|
||||||
|
assert replaced_item.get_status("VALID") is False
|
||||||
|
|
||||||
|
will = {"valid": valid_item, "replaced": replaced_item}
|
||||||
|
wallet = _mock_wallet([
|
||||||
|
_make_utxo(),
|
||||||
|
_make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000),
|
||||||
|
])
|
||||||
|
|
||||||
|
result, mock_from_io, _ = _run_invalidate(will, wallet)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
|
||||||
|
assert _PREVOUT_STR in spent
|
||||||
|
assert _REPLACED_PREVOUT_STR in spent
|
||||||
|
|
||||||
|
def test_replaced_item_contributes_balance(self):
|
||||||
|
"""The balance of a REPLACED item is included in the invalidation."""
|
||||||
|
valid_item = _make_willitem(value_sats=1000000)
|
||||||
|
replaced_item = _make_willitem(
|
||||||
|
value_sats=2000000, tx_hex=_REPLACED_TX_HEX
|
||||||
|
)
|
||||||
|
replaced_item.set_status("REPLACED", True)
|
||||||
|
|
||||||
|
will = {"valid": valid_item, "replaced": replaced_item}
|
||||||
|
wallet = _mock_wallet([
|
||||||
|
_make_utxo(),
|
||||||
|
_make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000),
|
||||||
|
])
|
||||||
|
|
||||||
|
_, _, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
|
||||||
|
|
||||||
|
# Balance = 1000000 + 2000000 = 3000000, fee = 200 * 10 = 2000
|
||||||
|
second_call_value = mock_out.call_args_list[1][0][1]
|
||||||
|
assert second_call_value == 2998000
|
||||||
|
|
||||||
|
def test_invalidated_item_inputs_are_not_invalidated(self):
|
||||||
|
"""A purely INVALIDATED item (already neutralised) is still excluded."""
|
||||||
|
valid_item = _make_willitem(value_sats=1000000)
|
||||||
|
invalid_item = _make_willitem(
|
||||||
|
value_sats=2000000, tx_hex=_REPLACED_TX_HEX, valid=False
|
||||||
|
)
|
||||||
|
assert invalid_item.get_status("INVALIDATED") is True
|
||||||
|
|
||||||
|
will = {"valid": valid_item, "invalid": invalid_item}
|
||||||
|
wallet = _mock_wallet([
|
||||||
|
_make_utxo(),
|
||||||
|
_make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000),
|
||||||
|
])
|
||||||
|
|
||||||
|
result, mock_from_io, _ = _run_invalidate(will, wallet)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
|
||||||
|
assert _PREVOUT_STR in spent
|
||||||
|
assert _REPLACED_PREVOUT_STR not in spent
|
||||||
|
|
||||||
def test_first_from_io_uses_full_balance(self):
|
def test_first_from_io_uses_full_balance(self):
|
||||||
"""The first from_io call uses the full balance (before fee deduction)
|
"""The first from_io call uses the full balance (before fee deduction)
|
||||||
to estimate the fee."""
|
to estimate the fee."""
|
||||||
@@ -353,6 +451,152 @@ class TestInvalidateWill:
|
|||||||
second_call_value = mock_out.call_args_list[1][0][1]
|
second_call_value = mock_out.call_args_list[1][0][1]
|
||||||
assert second_call_value == 1000000
|
assert second_call_value == 1000000
|
||||||
|
|
||||||
|
def test_invalidation_spends_oldest_input_per_tx(self):
|
||||||
|
"""For each will tx, only its OLDEST funding coin is spent.
|
||||||
|
|
||||||
|
A will item whose transaction has two inputs (old and new coin) must
|
||||||
|
spend only the coin that has been in the wallet longest, leaving the
|
||||||
|
newer coin unspent for the rebuilt (postponed) will.
|
||||||
|
"""
|
||||||
|
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
|
||||||
|
will = {"willtxid1": item}
|
||||||
|
old_utxo = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000000)
|
||||||
|
old_utxo.block_height = 1
|
||||||
|
new_utxo = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000)
|
||||||
|
new_utxo.block_height = 500 # newer than the old coin
|
||||||
|
wallet = _mock_wallet([old_utxo, new_utxo])
|
||||||
|
|
||||||
|
result, mock_from_io, _ = _run_invalidate(will, wallet)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
|
||||||
|
assert spent == [_PREVOUT_STR]
|
||||||
|
|
||||||
|
def test_invalidation_fallback_appends_next_oldest(self):
|
||||||
|
"""If the oldest coin cannot cover the fee, the next-oldest is appended.
|
||||||
|
|
||||||
|
The invalidation must not be dropped just because the single oldest
|
||||||
|
coin is small: it keeps adding will coins, oldest first, until the
|
||||||
|
balance covers the estimated fee.
|
||||||
|
"""
|
||||||
|
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
|
||||||
|
will = {"willtxid1": item}
|
||||||
|
old_utxo = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000)
|
||||||
|
old_utxo.block_height = 1
|
||||||
|
new_utxo = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=1000000)
|
||||||
|
new_utxo.block_height = 500
|
||||||
|
wallet = _mock_wallet([old_utxo, new_utxo])
|
||||||
|
|
||||||
|
# estimated_size (200) * fees_per_byte (10) = 2000 > balance (1000)
|
||||||
|
result, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
|
||||||
|
assert _PREVOUT_STR in spent
|
||||||
|
assert _REPLACED_PREVOUT_STR in spent
|
||||||
|
# balance = 1000 + 1000000, fee = 2000
|
||||||
|
final_value = mock_out.call_args[0][1]
|
||||||
|
assert final_value == 999000
|
||||||
|
|
||||||
|
def test_invalidation_treats_unconfirmed_as_newest(self):
|
||||||
|
"""An unconfirmed (unknown-height) coin is never chosen as the oldest."""
|
||||||
|
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
|
||||||
|
will = {"willtxid1": item}
|
||||||
|
confirmed = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000000)
|
||||||
|
confirmed.block_height = 10
|
||||||
|
unconfirmed = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000)
|
||||||
|
unconfirmed.block_height = None
|
||||||
|
wallet = _mock_wallet([unconfirmed, confirmed])
|
||||||
|
|
||||||
|
result, mock_from_io, _ = _run_invalidate(will, wallet)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
|
||||||
|
assert spent == [_PREVOUT_STR]
|
||||||
|
|
||||||
|
def test_invalidation_same_height_order_is_deterministic(self):
|
||||||
|
"""Coins at the same height are ordered by block position, not list order."""
|
||||||
|
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
|
||||||
|
will = {"willtxid1": item}
|
||||||
|
later = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000000)
|
||||||
|
later.block_height = 10
|
||||||
|
later.block_txpos = 5
|
||||||
|
earlier = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000)
|
||||||
|
earlier.block_height = 10
|
||||||
|
earlier.block_txpos = 1
|
||||||
|
wallet = _mock_wallet([later, earlier])
|
||||||
|
|
||||||
|
result, mock_from_io, _ = _run_invalidate(will, wallet)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
|
||||||
|
assert spent == [_REPLACED_PREVOUT_STR] # block_txpos 1 < 5 => older
|
||||||
|
|
||||||
|
def test_sub_dust_change_returns_none(self):
|
||||||
|
"""A change output below the dust limit is never created.
|
||||||
|
|
||||||
|
Regression: previously any positive change (balance - fee > 0) was
|
||||||
|
accepted, which could produce a non-standard (unrelayable) output and
|
||||||
|
leave the will un-invalidated.
|
||||||
|
"""
|
||||||
|
item = _make_willitem(value_sats=2200)
|
||||||
|
will = {"willtxid1": item}
|
||||||
|
wallet = _mock_wallet([_make_utxo(value_sats=2200)])
|
||||||
|
wallet.dust_threshold.return_value = 546
|
||||||
|
|
||||||
|
# fee = estimated_size (200) * fees_per_byte (10) = 2000;
|
||||||
|
# balance - fee = 200 < dust (546) -> no transaction.
|
||||||
|
result, _, _ = _run_invalidate(will, wallet, fees_per_byte=10)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_change_equal_to_dust_is_allowed(self):
|
||||||
|
"""Change exactly at the dust threshold is accepted (>= comparison)."""
|
||||||
|
item = _make_willitem(value_sats=2546)
|
||||||
|
will = {"willtxid1": item}
|
||||||
|
wallet = _mock_wallet([_make_utxo(value_sats=2546)])
|
||||||
|
wallet.dust_threshold.return_value = 546
|
||||||
|
|
||||||
|
# balance - fee = 2546 - 2000 = 546 == dust -> transaction.
|
||||||
|
result, _, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert mock_out.call_args_list[1][0][1] == 546
|
||||||
|
|
||||||
|
def test_fallback_stops_when_change_still_dust(self):
|
||||||
|
"""Coins are appended until the change clears dust; else returns None."""
|
||||||
|
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
|
||||||
|
will = {"willtxid1": item}
|
||||||
|
old_utxo = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000)
|
||||||
|
old_utxo.block_height = 1
|
||||||
|
new_utxo = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=1200)
|
||||||
|
new_utxo.block_height = 500
|
||||||
|
wallet = _mock_wallet([old_utxo, new_utxo])
|
||||||
|
wallet.dust_threshold.return_value = 546
|
||||||
|
|
||||||
|
# fee = 2000; total balance = 1000 + 1200 = 2200;
|
||||||
|
# change = 200 < dust (546) and no coin is left -> None.
|
||||||
|
result, _, _ = _run_invalidate(will, wallet, fees_per_byte=10)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_fallback_succeeds_once_change_clears_dust(self):
|
||||||
|
"""Appending the next coin yields a change above dust -> transaction."""
|
||||||
|
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
|
||||||
|
will = {"willtxid1": item}
|
||||||
|
old_utxo = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000)
|
||||||
|
old_utxo.block_height = 1
|
||||||
|
new_utxo = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2546)
|
||||||
|
new_utxo.block_height = 500
|
||||||
|
wallet = _mock_wallet([old_utxo, new_utxo])
|
||||||
|
wallet.dust_threshold.return_value = 546
|
||||||
|
|
||||||
|
# fee = 2000; total balance = 3546; change = 1546 >= dust -> transaction.
|
||||||
|
result, _, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert mock_out.call_args_list[-1][0][1] == 1546
|
||||||
|
|
||||||
|
|
||||||
# ================================================================== #
|
# ================================================================== #
|
||||||
# Will.set_invalidate - status flag cascade
|
# Will.set_invalidate - status flag cascade
|
||||||
|
|||||||
145
tests/test_core_willtree_father.py
Normal file
145
tests/test_core_willtree_father.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
Tests for the parent/child tree of the will transactions (``father`` links).
|
||||||
|
|
||||||
|
Bug reproduced from a real wallet (Electrum log, Check wizard):
|
||||||
|
|
||||||
|
File "bal/core/will.py", in check_invalidated
|
||||||
|
or willtree[w.father].get_status("CONFIRMED")
|
||||||
|
KeyError: 'a65a682f…'
|
||||||
|
|
||||||
|
``father`` is persisted in the wallet (``"Father"``) and was never cleared, so
|
||||||
|
after the father tx was removed from the will the child pointed to a txid that
|
||||||
|
no longer exists. ``Will.add_willtree`` now rebuilds the links from scratch and
|
||||||
|
``Will.check_invalidated`` treats a missing father as "no father".
|
||||||
|
|
||||||
|
Run (standalone or with pytest):
|
||||||
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||||
|
python3 tests/test_core_willtree_father.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from electrum.transaction import Transaction
|
||||||
|
|
||||||
|
from bal.core.will import Will, WillItem
|
||||||
|
|
||||||
|
# WillItem parses a raw tx and calls add_info_from_wallet; no live wallet here.
|
||||||
|
_patcher = patch.object(Transaction, "add_info_from_wallet")
|
||||||
|
_patcher.start()
|
||||||
|
|
||||||
|
# A valid serialized transaction (1 input, 1 output) reused by the core tests.
|
||||||
|
_VALID_TX_HEX = (
|
||||||
|
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||||
|
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||||
|
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||||
|
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||||
|
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||||
|
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||||
|
"42146f11ef8414ae929feaafc388ac00000000"
|
||||||
|
)
|
||||||
|
# The input of _VALID_TX_HEX spends this outpoint (little-endian in the hex).
|
||||||
|
_FUNDING_ID = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a"
|
||||||
|
# PARENT: _VALID_TX_HEX itself. CHILD: the same tx with its input re-pointed to
|
||||||
|
# the parent's txid, so the child spends the parent's output 0.
|
||||||
|
_PARENT_ID = Transaction(_VALID_TX_HEX).txid()
|
||||||
|
_CHILD_TX_HEX = _VALID_TX_HEX.replace(
|
||||||
|
bytes.fromhex(_FUNDING_ID)[::-1].hex(), bytes.fromhex(_PARENT_ID)[::-1].hex()
|
||||||
|
)
|
||||||
|
# A txid that is not in the will (the "removed father" of the bug report).
|
||||||
|
_MISSING_ID = "a65a682f2975f0dffd9eccce9392833f227badf617fc67815674736d594d4115"
|
||||||
|
|
||||||
|
|
||||||
|
def _item(tx_hex=_VALID_TX_HEX, father=None):
|
||||||
|
"""WillItem built from ``tx_hex``, optionally with a stored father."""
|
||||||
|
data = {"tx": tx_hex, "heirs": {}, "willexecutor": None}
|
||||||
|
if father is not None:
|
||||||
|
data["Father"] = father
|
||||||
|
return WillItem(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _wallet_without_adb():
|
||||||
|
"""Wallet whose check_invalidated path skips on-chain lookups."""
|
||||||
|
wallet = MagicMock()
|
||||||
|
wallet.adb = None
|
||||||
|
return wallet
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_father_is_discarded_by_add_willtree():
|
||||||
|
child = _item(father=_MISSING_ID)
|
||||||
|
will = {child._id: child}
|
||||||
|
Will.add_willtree(will)
|
||||||
|
assert child.father is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_invalidated_does_not_crash_on_stale_father():
|
||||||
|
"""The exact failure of the bug report, now without KeyError."""
|
||||||
|
child = _item(father=_MISSING_ID)
|
||||||
|
will = {child._id: child}
|
||||||
|
Will.add_willtree(will)
|
||||||
|
Will.check_invalidated(will, [], _wallet_without_adb())
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_invalidated_tolerates_missing_father_without_add_willtree():
|
||||||
|
"""Defensive path: even if the tree was not rebuilt, no KeyError."""
|
||||||
|
child = _item(father=_MISSING_ID)
|
||||||
|
will = {child._id: child}
|
||||||
|
Will.check_invalidated(will, [], _wallet_without_adb())
|
||||||
|
assert child.father == _MISSING_ID
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_parent_child_link_is_built():
|
||||||
|
parent = _item()
|
||||||
|
child = _item(_CHILD_TX_HEX)
|
||||||
|
will = {_PARENT_ID: parent, child._id: child}
|
||||||
|
Will.add_willtree(will)
|
||||||
|
assert child.father == _PARENT_ID
|
||||||
|
assert [c[0] for c in parent.children] == [child._id]
|
||||||
|
assert parent.father is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_father_is_replaced_by_the_real_parent():
|
||||||
|
parent = _item()
|
||||||
|
child = _item(_CHILD_TX_HEX, father=_MISSING_ID)
|
||||||
|
will = {_PARENT_ID: parent, child._id: child}
|
||||||
|
Will.add_willtree(will)
|
||||||
|
assert child.father == _PARENT_ID
|
||||||
|
|
||||||
|
|
||||||
|
def test_child_of_unconfirmed_parent_is_not_checked_against_utxos():
|
||||||
|
"""Existing rule kept: a child of a VALID (not mined) parent is skipped."""
|
||||||
|
parent = _item()
|
||||||
|
child = _item(_CHILD_TX_HEX)
|
||||||
|
will = {_PARENT_ID: parent, child._id: child}
|
||||||
|
Will.add_willtree(will)
|
||||||
|
wallet = MagicMock()
|
||||||
|
# Funding not spent on-chain: nothing gets invalidated, we only observe
|
||||||
|
# which items had their inputs checked.
|
||||||
|
with patch.object(Will, "check_tx_height", return_value=-2) as height, \
|
||||||
|
patch.object(Will, "_funding_really_spent", return_value=False):
|
||||||
|
Will.check_invalidated({child._id: child, _PARENT_ID: parent}, [], wallet)
|
||||||
|
# Only the parent (a root) looked up its height; the child was skipped.
|
||||||
|
checked = [call.args[0] for call in height.call_args_list]
|
||||||
|
assert checked == [parent.tx]
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_invalidate_cascades_to_children_built_by_add_willtree():
|
||||||
|
"""add_willtree builds children as a LIST: invalidation must cascade."""
|
||||||
|
parent = _item()
|
||||||
|
child = _item(_CHILD_TX_HEX)
|
||||||
|
will = {parent._id: parent, child._id: child}
|
||||||
|
Will.add_willtree(will)
|
||||||
|
Will.set_invalidate(parent._id, will)
|
||||||
|
assert parent.get_status("INVALIDATED") is True
|
||||||
|
assert child.get_status("INVALIDATED") is True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All will-tree father tests passed")
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
208
tests/test_settings_invalidation_fee.py
Normal file
208
tests/test_settings_invalidation_fee.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"""
|
||||||
|
Tests for the "Invalidation fee policy" settings-dialog row (ADVANCED only).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- The row is hidden in BASIC mode and visible in ADVANCED mode (like the
|
||||||
|
other advanced-only rows).
|
||||||
|
- The Electrum fee widget (``FeeSlider`` + ``FeeComboBox``) is bound to the
|
||||||
|
``INVALIDATION_FEE_POLICY`` config: choosing a Feerate / ETA / Mempool
|
||||||
|
policy persists its descriptor (``"<method>:<value>"``).
|
||||||
|
- "Reset to Default Setting" restores the policy to its default ("eta:2").
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
QT_QPA_PLATFORM=offscreen python3 tests/test_settings_invalidation_fee.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QApplication, QComboBox, QLabel, QPushButton
|
||||||
|
|
||||||
|
_app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
|
from electrum.fee_policy import FeeMethod, FeePolicy
|
||||||
|
from electrum.gui.qt.fee_slider import FeeComboBox, FeeSlider
|
||||||
|
from electrum.simple_config import SimpleConfig
|
||||||
|
|
||||||
|
import bal.gui.qt.plugin as plugin_mod
|
||||||
|
from bal.gui.qt.plugin import Plugin
|
||||||
|
|
||||||
|
LABEL_TEXT = "Invalidation fee policy"
|
||||||
|
|
||||||
|
|
||||||
|
def _isolated_config(**overrides):
|
||||||
|
"""An in-memory SimpleConfig that never touches the real Electrum config."""
|
||||||
|
opts = {"electrum_path": tempfile.mkdtemp(prefix="bal_test_")}
|
||||||
|
opts.update(overrides)
|
||||||
|
return SimpleConfig(opts)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_dialog(user_type, config=None, **config_overrides):
|
||||||
|
"""Build the plugin settings dialog for *user_type* and return it.
|
||||||
|
|
||||||
|
*config* (optional) lets a test pass a pre-populated SimpleConfig; overrides
|
||||||
|
are used to build one otherwise. Note that overrides become command-line
|
||||||
|
options, which ``set()`` refuses to modify - use *config* when the test needs
|
||||||
|
a mutable stored value.
|
||||||
|
"""
|
||||||
|
cfg = config if config is not None else _isolated_config(**config_overrides)
|
||||||
|
plugin = Plugin(None, cfg, "bal")
|
||||||
|
plugin.get_window_title = lambda s: s
|
||||||
|
plugin.read_file = lambda *a: b""
|
||||||
|
plugin.broadcast_transactions = lambda *a, **k: None
|
||||||
|
plugin.update_all = lambda *a, **k: None
|
||||||
|
plugin.USER_TYPE.set(user_type)
|
||||||
|
captured = {}
|
||||||
|
with patch.object(
|
||||||
|
plugin_mod, "show_modal", lambda d: captured.setdefault("d", d) or True
|
||||||
|
):
|
||||||
|
plugin.settings_dialog(None)
|
||||||
|
return plugin, captured["d"]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_label(dialog):
|
||||||
|
"""Return the "Invalidation fee policy" QLabel (wrapped by _hide_if_basic)."""
|
||||||
|
for lbl in dialog.findChildren(QLabel):
|
||||||
|
if lbl.text() == LABEL_TEXT:
|
||||||
|
return lbl
|
||||||
|
raise AssertionError("invalidation fee policy label not found")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Visibility
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_row_hidden_in_basic_and_visible_in_advanced():
|
||||||
|
_plugin, basic = _build_dialog("basic")
|
||||||
|
assert _find_label(basic).isHidden() is True
|
||||||
|
basic.close()
|
||||||
|
|
||||||
|
_plugin, advanced = _build_dialog("advanced")
|
||||||
|
assert _find_label(advanced).isHidden() is False
|
||||||
|
advanced.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _user_type_combo(dialog):
|
||||||
|
"""Return the BASIC/ADVANCED selector combo of the settings dialog."""
|
||||||
|
for combo in dialog.findChildren(QComboBox):
|
||||||
|
if combo.itemText(0) == "BASIC" and combo.itemText(1) == "ADVANCED":
|
||||||
|
return combo
|
||||||
|
raise AssertionError("user type combo not found")
|
||||||
|
|
||||||
|
|
||||||
|
def test_row_follows_live_mode_switch():
|
||||||
|
"""The row must show/hide when the mode is toggled at runtime (not only
|
||||||
|
the initial creation-time visibility)."""
|
||||||
|
# BASIC -> ADVANCED (with the confirmation phrase).
|
||||||
|
plugin, dialog = _build_dialog("basic")
|
||||||
|
combo = _user_type_combo(dialog)
|
||||||
|
with patch(
|
||||||
|
"PyQt6.QtWidgets.QInputDialog.getText",
|
||||||
|
return_value=("at My Risk", True),
|
||||||
|
):
|
||||||
|
combo.setCurrentIndex(1)
|
||||||
|
assert plugin.USER_TYPE.get() == "advanced"
|
||||||
|
assert _find_label(dialog).isHidden() is False
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
# ADVANCED -> BASIC (no confirmation needed).
|
||||||
|
plugin, dialog = _build_dialog("advanced")
|
||||||
|
combo = _user_type_combo(dialog)
|
||||||
|
combo.setCurrentIndex(0)
|
||||||
|
assert plugin.USER_TYPE.get() == "basic"
|
||||||
|
assert _find_label(dialog).isHidden() is True
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Persistence
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_default_policy_is_eta_two():
|
||||||
|
plugin, dialog = _build_dialog("advanced")
|
||||||
|
assert plugin.INVALIDATION_FEE_POLICY.default == "eta:2"
|
||||||
|
assert plugin.INVALIDATION_FEE_POLICY.get() == "eta:2"
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_hand_edited_policy_is_not_rewritten_on_open():
|
||||||
|
"""Opening the dialog must not normalize (snap/persist) a hand-edited
|
||||||
|
descriptor; only an explicit user change should do that."""
|
||||||
|
# "eta:3" is not a slider target (FEE_ETA_TARGETS = [1008, 144, 25, 10,
|
||||||
|
# 5, 2, 1]), so a naive slider.update() would snap it to "eta:2". The value
|
||||||
|
# is written to the isolated config (not passed as an override, which would
|
||||||
|
# become a non-modifiable command-line option and hide the bug).
|
||||||
|
config = _isolated_config()
|
||||||
|
config.set_key("bal_invalidation_fee_policy", "eta:3")
|
||||||
|
plugin, dialog = _build_dialog("advanced", config=config)
|
||||||
|
assert plugin.INVALIDATION_FEE_POLICY.get() == "eta:3"
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_choosing_a_fixed_feerate_is_persisted():
|
||||||
|
plugin, dialog = _build_dialog("advanced")
|
||||||
|
combo = dialog.findChildren(FeeComboBox)[0]
|
||||||
|
slider = dialog.findChildren(FeeSlider)[0]
|
||||||
|
|
||||||
|
combo.setCurrentIndex(FeeMethod.slider_index_of_method(FeeMethod.FEERATE))
|
||||||
|
slider.fee_policy.value = 5000 # sat/kvB
|
||||||
|
slider.update()
|
||||||
|
|
||||||
|
assert plugin.INVALIDATION_FEE_POLICY.get() == "feerate:5000"
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_choosing_an_eta_target_is_persisted():
|
||||||
|
plugin, dialog = _build_dialog("advanced")
|
||||||
|
combo = dialog.findChildren(FeeComboBox)[0]
|
||||||
|
slider = dialog.findChildren(FeeSlider)[0]
|
||||||
|
|
||||||
|
combo.setCurrentIndex(FeeMethod.slider_index_of_method(FeeMethod.ETA))
|
||||||
|
slider.fee_policy.value = 10 # blocks (valid FixedTargets entry)
|
||||||
|
slider.update()
|
||||||
|
|
||||||
|
assert plugin.INVALIDATION_FEE_POLICY.get() == "eta:10"
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Reset to default
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_reset_to_default_restores_eta_two():
|
||||||
|
plugin, dialog = _build_dialog("advanced")
|
||||||
|
combo = dialog.findChildren(FeeComboBox)[0]
|
||||||
|
slider = dialog.findChildren(FeeSlider)[0]
|
||||||
|
|
||||||
|
combo.setCurrentIndex(FeeMethod.slider_index_of_method(FeeMethod.FEERATE))
|
||||||
|
slider.fee_policy.value = 5000
|
||||||
|
slider.update()
|
||||||
|
assert plugin.INVALIDATION_FEE_POLICY.get() == "feerate:5000"
|
||||||
|
|
||||||
|
reset_btn = [
|
||||||
|
b for b in dialog.findChildren(QPushButton)
|
||||||
|
if b.text() == "Reset to Default Setting"
|
||||||
|
][0]
|
||||||
|
reset_btn.click()
|
||||||
|
|
||||||
|
assert plugin.INVALIDATION_FEE_POLICY.get() == "eta:2"
|
||||||
|
assert slider.fee_policy.get_descriptor() == "eta:2"
|
||||||
|
assert FeePolicy("eta:2").method == FeeMethod.ETA
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Main
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All invalidation fee settings tests passed")
|
||||||
@@ -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