Compare commits
7 Commits
fix/i18n-r
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
f3deccb626
|
|||
|
c9f3f77a15
|
|||
|
3d9757fe2d
|
|||
|
346aeee62b
|
|||
|
3b2eed37cc
|
|||
| 0c60462163 | |||
| a81443ff67 |
@@ -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) |
|
||||
| No Heirs | RED | NoHeirsException (~564) |
|
||||
| Ok | GREEN | check_amounts passed (~579) |
|
||||
| In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts | (warning popup) | AmountException (~583) |
|
||||
| Ok | GREEN | check_amounts passed |
|
||||
| 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)
|
||||
| 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).
|
||||
286
CHANGELOG.md
286
CHANGELOG.md
@@ -3587,20 +3587,37 @@ delivery; when it is not, the plugin raises an explicit error (see below).
|
||||
covers every catalog's translation of "New", the push helper does not
|
||||
modify the will item, old transient fields dropped on load.
|
||||
|
||||
**Verification:** extended release test list (as above) plus the new test
|
||||
files: 489 passed, 1 failed (pre-existing: `test_group_e_mock_karen7::test_e5`
|
||||
needs `tests/karen7`, not in the repository; `main` 827a1d1 gives 456 passed,
|
||||
same failure). `ruff check bal/ tests/`: the same 3 pre-existing I001 as on
|
||||
`main` (`dialogs.py`, `test_core_animated_qr.py`, `test_core_qr_transfer.py`),
|
||||
none new.
|
||||
**Scope:** this entry contains ONLY the i18n review fixes. The other fixes
|
||||
prepared in the same session (invalidation fee, Check `KeyError`, cascade,
|
||||
deselected executor) are not part of it and will be proposed separately.
|
||||
|
||||
**Outcome:** DONE (pending the owner's test of the ZIP and Truman's review).
|
||||
**Verification (on `main` 827a1d1):**
|
||||
|
||||
- New test file `tests/test_core_status_language_neutral.py`: 5 of 7 fail on
|
||||
`main`, 7 of 7 pass with this change.
|
||||
- Extended release test list (as above) plus the new test file: 446 passed,
|
||||
1 failed (`main`: 439 passed, same failure). The failure is pre-existing:
|
||||
`test_group_e_mock_karen7::test_e5` needs `tests/karen7`, a wallet file that
|
||||
is not in the repository.
|
||||
- Whole `tests/` directory: 609 passed, 26 failed, 3 collection errors
|
||||
(`main`: 602 passed, and the SAME 26 failures and 3 errors, test by test).
|
||||
All pre-existing and unrelated: 9 need `tests/karen7`; 21 in
|
||||
`test_auto_rebuild_on_new_tx.py`, `test_cli_autorebuild.py` and
|
||||
`test_cli_controller_offline.py` fail only when the whole directory runs
|
||||
together (they pass when each file runs alone); 3 audio tests need an audio
|
||||
device. `test_gui_export_dialogs.py::test_audio_import_receive_wiring` hangs
|
||||
forever, also on `main`, so it was deselected.
|
||||
- `ruff check bal/ tests/`: the same 3 pre-existing I001 as on `main`
|
||||
(`dialogs.py`, `test_core_animated_qr.py`, `test_core_qr_transfer.py`),
|
||||
none new.
|
||||
|
||||
**Outcome:** DONE (pending Truman's review).
|
||||
|
||||
---
|
||||
|
||||
## 64. Invalidation pays the current network fee (2 blocks) instead of `baltx_fees`
|
||||
## 64. Invalidation pays the current network fee, configurable via a fee-policy setting
|
||||
|
||||
**Date:** 2026-09-26 (ported to `main` 827a1d1 on 2026-10-07)
|
||||
**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
|
||||
@@ -3609,29 +3626,46 @@ 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).
|
||||
before (unchanged). The fee policy is exposed as an ADVANCED-only setting.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/will.py`
|
||||
- New constant `INVALIDATION_FEE_ETA_BLOCKS = 2` (owner decision: confirm
|
||||
within 2 blocks, Electrum's own default target).
|
||||
- New `Will.get_invalidation_fee_rate(wallet, fallback_fee_per_byte)`:
|
||||
returns Electrum's live estimate for 2 blocks
|
||||
(`network.fee_estimates.eta_target_to_fee`, sat/kvbyte → sat/vbyte), or
|
||||
the fallback (the will's `baltx_fees`, i.e. the previous behaviour) when
|
||||
there is no usable estimate: offline wallet, estimates not received yet,
|
||||
network error or an invalid value. Never raises.
|
||||
- `Will.invalidate_will`: uses that rate; the total fee is rounded UP to
|
||||
whole satoshis (network rates can be fractional); logs the chosen rate and
|
||||
its source (`network` / `fallback`). The `fees_per_byte` argument is now
|
||||
only the fallback, so every caller (Tools → Invalidate, wizard
|
||||
postpone/expired paths, automatic invalidation, CLI `bal_will_invalidate`)
|
||||
gets the new behaviour without being changed. Docstrings added.
|
||||
- `tests/test_core_invalidation_fee.py` (new): rate selection (2-block target,
|
||||
network value used as is also above `baltx_fees`, fractional rates, fallback
|
||||
when offline / no estimate / error / invalid values) and the fee actually
|
||||
deducted by `invalidate_will` (network vs fallback, rounding up).
|
||||
- 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
|
||||
@@ -3641,36 +3675,35 @@ enabled on the invalidation, so its fee can be raised from Electrum's History
|
||||
|
||||
**Verification:**
|
||||
|
||||
- `tests/test_core_invalidation_fee.py`: 12 passed (pytest and standalone).
|
||||
- `make-release.sh` test list + `tests/test_cli_*.py`: 435 passed, 1 failed.
|
||||
The failure is pre-existing and unrelated: `test_group_e_mock_karen7::test_e5`
|
||||
needs `tests/karen7`, a wallet file that is not in the repository.
|
||||
`tests/test_import_will_details.py`: 17 passed.
|
||||
- `ruff check` clean on the touched files.
|
||||
- Live, Electrum 4.8.2 testnet, offline daemon: `bal_will_invalidate` →
|
||||
"invalidation fee: 20 sat/vbyte (fallback), total 2200 sat" (unchanged
|
||||
behaviour when offline).
|
||||
- Real Electrum wallet + real `FeeTimeEstimates` (1 input, 110 vbytes):
|
||||
market 3 sat/vbyte → 330 sat; 1.5 → 165 sat; 0.4 → 110 sat (Electrum clamps
|
||||
to its 1 sat/vbyte minimum relay fee); 45 → 4950 sat; no 2-block estimate →
|
||||
fallback 20 sat/vbyte (2200 sat).
|
||||
- API checked in Electrum 4.7.2, 4.8.0 and 4.8.2
|
||||
(`FeeTimeEstimates.eta_target_to_fee`, `network.fee_estimates`).
|
||||
- `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`).
|
||||
|
||||
**Notes:** on regtest Electrum returns a fixed 150 sat/vbyte when it has no
|
||||
estimate, so regtest invalidations will pay that rate.
|
||||
|
||||
**Outcome:** DONE (pending the owner's test of the ZIP).
|
||||
**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-09-27
|
||||
**Date:** 2026-10-10
|
||||
|
||||
**Symptom (owner's mainnet wallet, 23 will items, Electrum 4.8.2):** the Check
|
||||
wizard stopped at "Checking your will" with `Error: 'a65a682f…'`, and the
|
||||
window refresh logged "error while updating window" with the same txid:
|
||||
**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
|
||||
@@ -3678,38 +3711,27 @@ File "bal/core/will.py", line 657, in check_invalidated
|
||||
KeyError: 'a65a682f2975f0dffd9eccce9392833f227badf617fc67815674736d594d4115'
|
||||
```
|
||||
|
||||
**Root cause:** every will item stores its parent will tx in `father`, which is
|
||||
persisted in the wallet (`"Father"` in `WillItem.to_dict`). `Will.add_willtree`
|
||||
only ever SET it (`if not father: father = ...`) and never cleared it, so when
|
||||
the parent tx was later removed from the will (rebuild, merge, replacement) the
|
||||
child kept pointing to a txid that no longer exists. `check_invalidated` then
|
||||
indexed the will with it. Reproduced on unmodified `main` (not related to #64).
|
||||
**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`: resets `father` on every item before rebuilding the
|
||||
links, so the tree is always recomputed from the transactions themselves
|
||||
(a stale value loaded from the wallet is discarded). Docstring added.
|
||||
- `Will.check_invalidated`: defensive lookup - a father that is not (or no
|
||||
longer) in the will is treated as "no father" instead of raising.
|
||||
- `tests/test_core_willtree_father.py` (new): 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. 4 of the 6 tests fail on `main` and pass with the fix; the 2
|
||||
"normal behaviour" tests pass on both.
|
||||
- `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:** make-release test list + `tests/test_cli_*.py` +
|
||||
`tests/test_import_will_details.py`: 458 passed, 1 failed (pre-existing,
|
||||
unrelated: `test_group_e_mock_karen7::test_e5` needs the missing `tests/karen7`
|
||||
wallet file). `ruff check` clean on the touched files.
|
||||
|
||||
**Found while writing the tests, NOT fixed here (reported to the owner):**
|
||||
`Will.set_invalidate` iterates `children.items()`, but `add_willtree` builds
|
||||
`children` as a LIST, so invalidating an item that has child will txs raises
|
||||
`AttributeError: 'list' object has no attribute 'items'` (reproduced on
|
||||
`main`). The existing tests in `test_core_will_invalidate.py` set `children` to
|
||||
dicts by hand, so they do not see it.
|
||||
**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).
|
||||
|
||||
@@ -3717,68 +3739,84 @@ dicts by hand, so they do not see it.
|
||||
|
||||
## 66. Invalidation cascade crashed on real will trees (`children` is a list)
|
||||
|
||||
**Date:** 2026-09-27
|
||||
**Date:** 2026-10-10
|
||||
|
||||
**Problem (found while fixing #65, reproduced on `main`):**
|
||||
**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
|
||||
set `children` to dicts by hand, so they never saw it.
|
||||
`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
|
||||
(list from `add_willtree`, dict from older data/tests) and only recurses into
|
||||
children that are still in the will. `tests/test_core_willtree_father.py`: new
|
||||
test `test_set_invalidate_cascades_to_children_built_by_add_willtree` (fails
|
||||
on `main`, passes with the fix).
|
||||
(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:** 459 passed, 1 failed (pre-existing missing `tests/karen7`);
|
||||
`ruff check` clean.
|
||||
**Verification:** `tests/test_core_willtree_father.py` passes; `ruff check`
|
||||
clean on the touched files.
|
||||
|
||||
**Outcome:** DONE (included in the owner's test ZIP of 2026-09-27).
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## 67. Deselected will-executor: its tx can be deleted and is no longer pushed
|
||||
## 67. "Checking variables" stuck on "Wait" + redundant amount-adjustment wording
|
||||
|
||||
**Date:** 2026-09-27
|
||||
**Date:** 2026-10-14
|
||||
|
||||
**Problem (owner, mainnet wallet):** the will had a signed tx for
|
||||
`we.divinaly.support` whose push had failed ("Push failed", "Not on server").
|
||||
After deselecting that executor, Check still reported the will as valid, the
|
||||
row stayed, and "delete" in the context menu was disabled (valid + signed
|
||||
txs were never deletable). The broadcast also still targeted the deselected
|
||||
executor, because each will item keeps its own build-time copy of the
|
||||
executor record (`selected` = True).
|
||||
**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.
|
||||
|
||||
**What changed (owner chose option "B"):**
|
||||
**Root cause (two bugs):**
|
||||
|
||||
- `bal/gui/qt/lists.py` - `_can_delete`: a valid, signed tx is now deletable
|
||||
when its will-executor NEVER received it (not PUSHED and not CHECKED). It
|
||||
stays protected when the executor holds it (deleting it locally would not
|
||||
cancel it on the server) and when it has no executor (backup tx).
|
||||
- `bal/core/willexecutors.py` - `get_willexecutor_transactions(...,
|
||||
current_willexecutors=None)`: when the current executor list is given, an
|
||||
executor that is no longer selected (or no longer listed) is skipped.
|
||||
- `bal/gui/qt/window.py` (manual broadcast) and `bal/cli/controller.py`
|
||||
(`bal_will_broadcast`) pass the current list read from the config. The
|
||||
wizard already filtered by the current selection.
|
||||
- Tests: `tests/test_gui_will_menu.py` (+3: deletable when never received,
|
||||
protected when pushed/checked, protected backup tx) and new
|
||||
`tests/test_core_deselected_executor.py` (4).
|
||||
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`).
|
||||
|
||||
**Not changed (proposal for Truman, option "A"):** Check still does not treat
|
||||
a tx of a deselected executor as "not coherent" (no automatic rebuild).
|
||||
**What changed:**
|
||||
|
||||
**Verification:** 483 passed, 1 failed (pre-existing missing `tests/karen7`);
|
||||
`ruff check` clean on the touched files. Live (Electrum 4.8.2 testnet, CLI):
|
||||
will built for 2 executors, one deselected → `bal_will_broadcast` targets only
|
||||
the selected one (before the fix it targeted both).
|
||||
- `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).
|
||||
|
||||
**Porting note (entries 64-67):** first written on `main` 4a33116 as
|
||||
58-61 (numbers then taken by the i18n and Check Alive work), re-applied on
|
||||
`main` 827a1d1 on 2026-10-07 together with #63: extended release test list
|
||||
489 passed, 1 pre-existing failure (missing `tests/karen7`); `ruff` no new
|
||||
errors.
|
||||
|
||||
@@ -238,24 +238,26 @@ async def willexecutors_update(
|
||||
wallet,
|
||||
"willexecutors_update",
|
||||
url,
|
||||
address,
|
||||
base_fee,
|
||||
info,
|
||||
promo_code,
|
||||
rename_to,
|
||||
address=address,
|
||||
base_fee=base_fee,
|
||||
info=info,
|
||||
promo_code=promo_code,
|
||||
rename_to=rename_to,
|
||||
)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
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: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)
|
||||
@@ -298,12 +300,13 @@ async def willexecutors_import(self, path, wallet=None, plugin=None):
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
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"))
|
||||
if isinstance(e, will_mod.NoWillExecutorNotPresent):
|
||||
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):
|
||||
return UserFacingException(
|
||||
_(
|
||||
@@ -121,6 +142,21 @@ def _user_facing(e):
|
||||
"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
|
||||
|
||||
|
||||
@@ -352,6 +388,7 @@ class BalController:
|
||||
self.date_to_check = resolve_date_to_check(
|
||||
self.plugin.is_basic_mode(), self.will_settings,
|
||||
built_locktime=_new_locktime,
|
||||
creation_date=Will.get_creation_time(self.willitems),
|
||||
)
|
||||
self.willexecutors = Willexecutors.get_willexecutors(
|
||||
self.plugin, update=False, task=False
|
||||
@@ -417,6 +454,7 @@ class BalController:
|
||||
will_locktime=Will.get_min_locktime(
|
||||
willitems, default_value=self.date_to_check
|
||||
),
|
||||
fee_policy_descriptor=self.plugin.INVALIDATION_FEE_POLICY.get(),
|
||||
)
|
||||
if tx is not None:
|
||||
try:
|
||||
@@ -471,6 +509,11 @@ class BalController:
|
||||
self.wallet.dust_threshold(),
|
||||
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:
|
||||
raise _user_facing(e) from e
|
||||
|
||||
@@ -767,11 +810,7 @@ class BalController:
|
||||
if not willitems:
|
||||
raise UserFacingException(_("No transaction matches the given txids"))
|
||||
|
||||
# Skip executors the user deselected after building the will (the
|
||||
# controller's list is read from the config for every command).
|
||||
willexecutors = Willexecutors.get_willexecutor_transactions(
|
||||
willitems, force=force, current_willexecutors=self.willexecutors
|
||||
)
|
||||
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
|
||||
if not willexecutors:
|
||||
return {}
|
||||
|
||||
@@ -952,6 +991,8 @@ class BalController:
|
||||
heir.append(locktime)
|
||||
else:
|
||||
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):
|
||||
heir[2] = "0"
|
||||
return Heirs.validate_heir(heir[0], heir[1:])
|
||||
@@ -1037,7 +1078,7 @@ class BalController:
|
||||
return self.willexecutors_show(url)
|
||||
|
||||
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:
|
||||
raise UserFacingException(_("Will-Executor not found: {}").format(url))
|
||||
we = self.willexecutors[url]
|
||||
@@ -1048,6 +1089,8 @@ class BalController:
|
||||
we["base_fee"] = int(base_fee)
|
||||
if info is not None:
|
||||
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 in self.willexecutors:
|
||||
raise UserFacingException(
|
||||
@@ -1068,17 +1111,38 @@ class BalController:
|
||||
Willexecutors.save(self.plugin, self.willexecutors)
|
||||
return {"deleted": deleted}
|
||||
|
||||
def willexecutors_select(self, urls=None, select=True):
|
||||
"""Select/deselect one, many or all will-executors."""
|
||||
selected = {}
|
||||
targets = urls if urls is not None else list(self.willexecutors)
|
||||
def willexecutors_select(self, urls=None, select=True, only_valid=False):
|
||||
"""Select or deselect will-executors.
|
||||
|
||||
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:
|
||||
if url not in self.willexecutors:
|
||||
continue
|
||||
self.willexecutors[url]["selected"] = bool(select)
|
||||
selected[url] = bool(select)
|
||||
we = self.willexecutors[url]
|
||||
if only_valid:
|
||||
is_valid = Willexecutors.is_valid(
|
||||
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)
|
||||
return selected
|
||||
return {
|
||||
url: bool(self.willexecutors[url].get("selected")) for url in targets
|
||||
}
|
||||
|
||||
def willexecutors_ping(self, urls=None):
|
||||
"""Ping the (selected) will-executor servers and refresh their info.
|
||||
@@ -1128,10 +1192,12 @@ class BalController:
|
||||
|
||||
result = {}
|
||||
last_error = None
|
||||
any_server_reached = False
|
||||
try:
|
||||
resp = Willexecutors.send_request(
|
||||
"get", url, timeout=10, max_retries=1, retry_sleep=1
|
||||
)
|
||||
any_server_reached = True
|
||||
if not isinstance(resp, dict):
|
||||
last_error = "invalid response format"
|
||||
_logger.warning(
|
||||
@@ -1157,12 +1223,17 @@ class BalController:
|
||||
last_error = str(e)
|
||||
_logger.error(f"fetch_will_executors_list: {url} -> {type(e).__name__}: {e}")
|
||||
|
||||
if not result and not basic:
|
||||
raise UserFacingException(
|
||||
_("Could not reach the configured welist server.\nServer: {}\nError: {}").format(
|
||||
url, last_error or "empty response"
|
||||
)
|
||||
if not result:
|
||||
if not basic:
|
||||
# Advanced mode: always raise with full details.
|
||||
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
|
||||
|
||||
def willexecutors_download(self):
|
||||
@@ -1182,7 +1253,9 @@ class BalController:
|
||||
raise UserFacingException(
|
||||
_("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.setdefault("selected", False)
|
||||
we.setdefault("status", "New")
|
||||
@@ -1191,9 +1264,30 @@ class BalController:
|
||||
Willexecutors.save(self.plugin, self.willexecutors)
|
||||
return {"imported": len(data), "total": len(self.willexecutors)}
|
||||
|
||||
def willexecutors_export(self, path):
|
||||
write_json_file(path, self.willexecutors)
|
||||
return {"exported": len(self.willexecutors), "path": path}
|
||||
def willexecutors_export(self, path, subset=None):
|
||||
"""Export the will-executors to ``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
|
||||
@@ -1293,11 +1387,3 @@ class BalController:
|
||||
)
|
||||
return value
|
||||
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.INVALIDATE = BalConfig(config, "bal_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.SAVE_TXS = BalConfig(config, "bal_save_txs", True)
|
||||
|
||||
|
||||
222
bal/core/will.py
222
bal/core/will.py
@@ -30,6 +30,7 @@ import math
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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.transaction import (
|
||||
PartialTransaction,
|
||||
@@ -58,6 +59,11 @@ MIN_BLOCK = 1
|
||||
# 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__)
|
||||
|
||||
|
||||
@@ -463,37 +469,46 @@ class Will:
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def get_invalidation_fee_rate(wallet, fallback_fee_per_byte):
|
||||
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`` is ``"network"`` when the rate
|
||||
comes from Electrum's live fee estimate, ``"fallback"`` otherwise.
|
||||
``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 for a confirmation within ``INVALIDATION_FEE_ETA_BLOCKS`` 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.
|
||||
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 estimate is read from ``network.fee_estimates.eta_target_to_fee``
|
||||
(Electrum 4.7.2 / 4.8.x), which returns sat/kvbyte and already clamps
|
||||
the value between Electrum's conservative minimum relay fee (1
|
||||
sat/vbyte) and its maximum dynamic fee. This method never raises.
|
||||
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 estimate - normally the will's ``baltx_fees``.
|
||||
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
|
||||
if network is not None:
|
||||
try:
|
||||
fee_per_kb = network.fee_estimates.eta_target_to_fee(
|
||||
INVALIDATION_FEE_ETA_BLOCKS
|
||||
)
|
||||
fee_per_kb = policy.fee_per_kb(network)
|
||||
except Exception as e:
|
||||
_logger.warning(f"invalidation fee: no network estimate ({e!r})")
|
||||
_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``.
|
||||
@@ -502,76 +517,140 @@ class Will:
|
||||
and not isinstance(fee_per_kb, bool)
|
||||
and fee_per_kb > 0
|
||||
):
|
||||
return fee_per_kb / 1000, "network"
|
||||
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
|
||||
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 every coin used by the valid will items back to
|
||||
one of the wallet's own change addresses, so the pre-signed
|
||||
inheritance transactions held by the will-executors become unusable.
|
||||
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 for a confirmation within
|
||||
``INVALIDATION_FEE_ETA_BLOCKS`` blocks (see
|
||||
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 network estimate is
|
||||
``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")
|
||||
will_only_valid = Will.only_valid_list(will)
|
||||
inputs = Will.get_all_inputs(will_only_valid)
|
||||
# Invalidating only the VALID items is not enough. A will rebuilt after
|
||||
# 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:
|
||||
utxos = Util.get_available_utxos(wallet, history_label, will_locktime)
|
||||
else:
|
||||
utxos = wallet.get_utxos()
|
||||
filtered_inputs = []
|
||||
prevout_to_spend = []
|
||||
current_height = Util.get_current_height(wallet.network)
|
||||
for prevout_str, ws in inputs.items():
|
||||
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 = []
|
||||
utxo_by_prevout = {}
|
||||
for utxo in utxos:
|
||||
if utxo.is_coinbase_output() and utxo.block_height < current_height+100:
|
||||
_logger.debug("is not mature coinbase output")
|
||||
continue
|
||||
utxo_str = utxo.prevout.to_str()
|
||||
if utxo_str in prevout_to_spend:
|
||||
balance += utxo.value_sats()
|
||||
utxo_to_spend.append(utxo)
|
||||
utxo_by_prevout[utxo.prevout.to_str()] = utxo
|
||||
# Candidate coins: the wallet UTXOs that fund one of the will txs.
|
||||
candidates = []
|
||||
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))
|
||||
if len(utxo_to_spend) > 0:
|
||||
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.is_change = True
|
||||
locktime = current_height
|
||||
tx = PartialTransaction.from_io(
|
||||
utxo_to_spend, [out], locktime=locktime, version=2
|
||||
)
|
||||
tx.set_rbf(True)
|
||||
fee_rate, fee_source = Will.get_invalidation_fee_rate(
|
||||
wallet, fees_per_byte
|
||||
)
|
||||
# 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.
|
||||
fee = int(math.ceil(tx.estimated_size() * fee_rate))
|
||||
_logger.info(
|
||||
f"invalidation fee: {fee_rate} sat/vbyte ({fee_source}), "
|
||||
f"total {fee} sat"
|
||||
)
|
||||
if balance - fee > 0:
|
||||
if balance - fee >= dust:
|
||||
out = PartialTxOutput.from_address_and_value(
|
||||
change_addresses[0], balance - fee
|
||||
)
|
||||
@@ -583,9 +662,26 @@ class Will:
|
||||
_logger.debug(f"invalidation tx: {tx}")
|
||||
return tx
|
||||
|
||||
else:
|
||||
_logger.debug(f"balance({balance}) - fee({fee}) <=0")
|
||||
pass
|
||||
# The selected coins cannot cover the fee while leaving a
|
||||
# spendable (non-dust) change: keep spending the next-oldest
|
||||
# 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:
|
||||
_logger.debug("len utxo_to_spend <=0")
|
||||
pass
|
||||
@@ -727,11 +823,19 @@ class Will:
|
||||
for utxo in all_utxos:
|
||||
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(
|
||||
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%")
|
||||
|
||||
for url, wex in willexecutors.items():
|
||||
@@ -739,7 +843,7 @@ class Will:
|
||||
if max_fee is not None and int(wex["base_fee"]) > max_fee:
|
||||
raise WillExecutorFeeTooHighException(wex, max_fee)
|
||||
temp_balance = wallet_balance - int(wex["base_fee"])
|
||||
if fixed_amount >= temp_balance:
|
||||
if fixed_amount > 0 and fixed_amount >= temp_balance:
|
||||
raise FixedAmountException(
|
||||
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
|
||||
)
|
||||
|
||||
@@ -230,19 +230,12 @@ class Willexecutors:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_willexecutor_transactions(will, force=False, current_willexecutors=None):
|
||||
def get_willexecutor_transactions(will, force=False):
|
||||
"""Group the valid, signed, not-yet-pushed will txs by will-executor.
|
||||
|
||||
Returns ``{url: executor_record}`` where each record gets ``txs`` (the
|
||||
raw txs, one per line) and ``txsids``.
|
||||
|
||||
``current_willexecutors`` is the CURRENT executor list from the config
|
||||
(``{url: record}``). When given, an executor that is no longer in it or
|
||||
no longer selected is skipped. WHY: every will item keeps its own copy
|
||||
of the executor record taken at build time (``selected`` = True), so
|
||||
without this check a deselected executor would still receive the will.
|
||||
``None`` keeps the previous behaviour (only the item's own copy).
|
||||
|
||||
The returned records are COPIES: the will items are never modified.
|
||||
"""
|
||||
willexecutors = {}
|
||||
@@ -252,12 +245,6 @@ class Willexecutors:
|
||||
if not willitem.get_status("PUSHED") or force:
|
||||
if willexecutor := willitem.we:
|
||||
url = willexecutor["url"]
|
||||
if current_willexecutors is not None and not (
|
||||
Willexecutors.is_selected(
|
||||
current_willexecutors.get(url)
|
||||
)
|
||||
):
|
||||
continue
|
||||
if willexecutor and Willexecutors.is_selected(willexecutor):
|
||||
if url not in willexecutors:
|
||||
# Work on a COPY of the record: the item's
|
||||
|
||||
@@ -358,7 +358,8 @@ class BalWizardWidget(QWidget):
|
||||
class BalWizardHeirsWidget(BalWizardWidget):
|
||||
title = N_("Bitcoin After Life Heirs")
|
||||
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):
|
||||
@@ -690,11 +691,22 @@ class BalBuildWillDialog(BalDialog):
|
||||
# Manual next-steps hint (Sign / Broadcast) shown to the user after the
|
||||
# dialog finishes; None when nothing is left to do.
|
||||
self._next_steps_hint = None
|
||||
# Set to True by _sync_locktime_to_built_txs when the delivery date was
|
||||
# automatically anticipated during a rebuild. Used to explain to the
|
||||
# user WHY signing is being requested (otherwise the sign prompt appears
|
||||
# without any reason, as the owner reported).
|
||||
# Set to True by _build_success_report when the rebuild moved the
|
||||
# delivery date one day earlier than the previous will. Used both to
|
||||
# show the "Anticipated" warning on the "Building your will" row and to
|
||||
# explain WHY signing is being requested (otherwise the sign prompt
|
||||
# appears without any reason, as the owner reported).
|
||||
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
|
||||
# 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
|
||||
@@ -746,6 +758,7 @@ class BalBuildWillDialog(BalDialog):
|
||||
self.bal_window.willitems,
|
||||
default_value=self.bal_window.date_to_check,
|
||||
),
|
||||
fee_policy_descriptor=self.bal_window.bal_plugin.INVALIDATION_FEE_POLICY.get(),
|
||||
)
|
||||
if tx:
|
||||
_logger.debug(
|
||||
@@ -787,18 +800,32 @@ class BalBuildWillDialog(BalDialog):
|
||||
_logger.debug("variables ok")
|
||||
self.msg_set_status(_("Checking variables"), varrow, _("Ok"), self.COLOR_OK)
|
||||
except AmountException:
|
||||
self.msg_set_checking(
|
||||
self.msg_warning(
|
||||
# The amount check is a WARNING, not a hard failure: the build can
|
||||
# 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, "
|
||||
"the entire wallet will always be fully emptied. \n"
|
||||
"Your settings require an adjustment of the amounts"
|
||||
)
|
||||
)
|
||||
"the entire wallet will always be fully emptied."
|
||||
),
|
||||
self.COLOR_WARNING,
|
||||
)
|
||||
except WillExecutorFeeTooHighException as e:
|
||||
self.msg_set_checking(
|
||||
self.msg_warning(_("Will-executor fee too high: {}").format(e))
|
||||
# Same reasoning as the AmountException branch above: show the
|
||||
# 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()
|
||||
@@ -841,6 +868,7 @@ class BalBuildWillDialog(BalDialog):
|
||||
self.bal_window.willitems,
|
||||
default_value=self.bal_window.date_to_check,
|
||||
),
|
||||
fee_policy_descriptor=self.bal_window.bal_plugin.INVALIDATION_FEE_POLICY.get(),
|
||||
)
|
||||
except NoHeirsException:
|
||||
_logger.debug("no heirs")
|
||||
@@ -894,6 +922,18 @@ class BalBuildWillDialog(BalDialog):
|
||||
|
||||
if have_to_build:
|
||||
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:
|
||||
txs = self.bal_window.build_will()
|
||||
if not txs:
|
||||
@@ -1082,6 +1122,39 @@ class BalBuildWillDialog(BalDialog):
|
||||
# auto-anticipated) transactions, otherwise the next Check would wrongly
|
||||
# ask to invalidate. See _sync_locktime_to_built_txs for the full why.
|
||||
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())
|
||||
# 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
|
||||
@@ -1174,10 +1247,10 @@ class BalBuildWillDialog(BalDialog):
|
||||
f"sync delivery date to built tx locktime: "
|
||||
f"{current} -> {min_locktime}"
|
||||
)
|
||||
# Remember that we anticipated the date, so the later sign
|
||||
# prompt can explain WHY signing is needed
|
||||
# (see on_success_phase1).
|
||||
self._date_was_anticipated = True
|
||||
# NOTE: this only SYNCS the stored date. Whether the rebuild
|
||||
# ANTICIPATED the delivery is decided in _build_success_report
|
||||
# by comparing the previous and the rebuilt locktimes (the only
|
||||
# format-agnostic signal, see there).
|
||||
# update_setting_widgets stores the value, persists it and
|
||||
# refreshes the date widgets in all panels/wizard (the .ics
|
||||
# calendar too).
|
||||
@@ -1621,12 +1694,12 @@ class BalBuildWillDialog(BalDialog):
|
||||
# If the will was rebuilt with an automatically anticipated
|
||||
# delivery date, explain WHY we are now asking to sign:
|
||||
# otherwise the sign prompt appears with no reason (owner
|
||||
# feedback). The note is shown on the "Building your will"
|
||||
# row (orange warning) before the modal password prompt
|
||||
# opens, so the user can read it.
|
||||
# feedback). This note goes on its OWN "Signing your will" row
|
||||
# so it does NOT overwrite the "Anticipated - new delivery
|
||||
# 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:
|
||||
self.msg_set_building(
|
||||
"<b>{}</b>".format(
|
||||
self.msg_set_signing(
|
||||
_(
|
||||
"The delivery date was automatically moved "
|
||||
"one day earlier so the updated will can "
|
||||
@@ -1635,7 +1708,6 @@ class BalBuildWillDialog(BalDialog):
|
||||
"the change."
|
||||
)
|
||||
)
|
||||
)
|
||||
password = self.bal_window.get_wallet_password(
|
||||
_("Sign your will"), parent=self
|
||||
)
|
||||
|
||||
@@ -93,27 +93,13 @@ def _can_broadcast(will_item):
|
||||
|
||||
|
||||
def _can_delete(will_item):
|
||||
"""True if a will transaction may be deleted.
|
||||
"""True if a will transaction may be deleted (invalid or unsigned).
|
||||
|
||||
Deletable:
|
||||
* invalid or not fully signed transactions (unchanged rule);
|
||||
* valid AND fully signed transactions that their will-executor NEVER
|
||||
received: not PUSHED and not CHECKED (e.g. "Push failed" /
|
||||
"Not on server", or never sent).
|
||||
|
||||
Not deletable: a valid, signed transaction that the will-executor holds
|
||||
(PUSHED or CHECKED). WHY: deleting it only on this PC would not remove it
|
||||
from the executor's server, so the user would wrongly believe that
|
||||
inheritance was cancelled; that case needs an on-chain invalidation.
|
||||
Signed transactions without a will-executor (backup txs) also stay
|
||||
protected, as before.
|
||||
Valid AND fully-signed transactions are never deletable: removing them
|
||||
would silently drop a committed inheritance.
|
||||
"""
|
||||
if not will_item:
|
||||
return False
|
||||
if not will_item.get_status("VALID") or not will_item.get_status("COMPLETE"):
|
||||
return True
|
||||
return bool(getattr(will_item, "we", None)) and not (
|
||||
will_item.get_status("PUSHED") or will_item.get_status("CHECKED")
|
||||
return bool(will_item) and (
|
||||
not will_item.get_status("VALID") or not will_item.get_status("COMPLETE")
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ One :class:`bal.gui.qt.window.BalWindow` is created per top-level wallet window
|
||||
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.plugin import hook
|
||||
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_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_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)
|
||||
# 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
|
||||
@@ -911,6 +915,78 @@ class Plugin(BalPlugin, EventListener):
|
||||
)
|
||||
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 #
|
||||
# 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
|
||||
# follow the (default) checkbox state again.
|
||||
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
|
||||
# editable-dates state is not in this list, but hide filters are)
|
||||
# reflects the reset values.
|
||||
|
||||
@@ -835,11 +835,11 @@ class BalWindow:
|
||||
self.window.wallet.dust_threshold(),
|
||||
max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
|
||||
)
|
||||
except AmountException as e:
|
||||
except AmountException:
|
||||
self.show_warning(
|
||||
_(
|
||||
"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{}"
|
||||
).format(e)
|
||||
"In the inheritance process, the entire wallet will always be fully emptied."
|
||||
)
|
||||
)
|
||||
except WillExecutorFeeTooHighException as e:
|
||||
self.show_error(
|
||||
@@ -1089,6 +1089,7 @@ class BalWindow:
|
||||
will_locktime=Will.get_min_locktime(
|
||||
willitems, default_value=self.date_to_check
|
||||
),
|
||||
fee_policy_descriptor=self.bal_plugin.INVALIDATION_FEE_POLICY.get(),
|
||||
)
|
||||
msg = _("Calculating Transactions")
|
||||
self.waiting_dialog = BalWaitingDialog(
|
||||
@@ -1424,6 +1425,7 @@ class BalWindow:
|
||||
willitems,
|
||||
default_value=getattr(self, "date_to_check", None),
|
||||
),
|
||||
fee_policy_descriptor=self.bal_plugin.INVALIDATION_FEE_POLICY.get(),
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error("auto invalidate: could not build tx: {}".format(e))
|
||||
@@ -1632,15 +1634,7 @@ class BalWindow:
|
||||
willitems = {
|
||||
t: willitems[t] for t in txids if t in willitems
|
||||
}
|
||||
# Read the CURRENT executor list from the config, so an executor the
|
||||
# user deselected after building the will is not contacted anymore.
|
||||
willexecutors = Willexecutors.get_willexecutor_transactions(
|
||||
willitems,
|
||||
force=force,
|
||||
current_willexecutors=Willexecutors.get_willexecutors(
|
||||
self.bal_plugin, update=False, task=False
|
||||
),
|
||||
)
|
||||
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
|
||||
|
||||
def getMsg(willexecutors):
|
||||
msg = _("Broadcasting Transactions to Will-Executors:") + "\n"
|
||||
|
||||
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.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.util import Util
|
||||
from bal.core.will import (
|
||||
HeirChangeException,
|
||||
NotCompleteWillException,
|
||||
WillexecutorChangeException,
|
||||
)
|
||||
|
||||
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
|
||||
|
||||
@@ -102,6 +107,13 @@ def _make_controller(plugin):
|
||||
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():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
@@ -194,6 +206,179 @@ def test_willexecutors_crud():
|
||||
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():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
"""
|
||||
Tests for ``Willexecutors.get_willexecutor_transactions`` with the CURRENT
|
||||
executor selection (``current_willexecutors``).
|
||||
|
||||
Every will item keeps its own copy of the executor record taken at build time
|
||||
(``selected`` = True). Without the current selection, an executor the user
|
||||
deselected after building the will would still receive the will.
|
||||
|
||||
Run (standalone or with pytest):
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||
python3 tests/test_core_deselected_executor.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.willexecutors import Willexecutors
|
||||
|
||||
|
||||
class FakeItem:
|
||||
"""Minimal will item: statuses, a tx and the build-time executor copy."""
|
||||
|
||||
def __init__(self, url, **flags):
|
||||
self._status = {"VALID": True, "COMPLETE": True}
|
||||
self._status.update(flags)
|
||||
self.we = {"url": url, "selected": True}
|
||||
self.tx = f"rawtx-{url}"
|
||||
|
||||
def get_status(self, name):
|
||||
return self._status.get(name, False)
|
||||
|
||||
|
||||
A = "https://we.a.example"
|
||||
B = "https://we.b.example"
|
||||
|
||||
|
||||
def _will():
|
||||
return {"tx_a": FakeItem(A), "tx_b": FakeItem(B)}
|
||||
|
||||
|
||||
def test_without_current_list_previous_behaviour():
|
||||
assert set(Willexecutors.get_willexecutor_transactions(_will())) == {A, B}
|
||||
|
||||
|
||||
def test_deselected_executor_is_skipped():
|
||||
current = {A: {"selected": True}, B: {"selected": False}}
|
||||
out = Willexecutors.get_willexecutor_transactions(
|
||||
_will(), current_willexecutors=current
|
||||
)
|
||||
assert set(out) == {A}
|
||||
assert out[A]["txsids"] == ["tx_a"]
|
||||
|
||||
|
||||
def test_executor_removed_from_list_is_skipped():
|
||||
current = {A: {"selected": True}}
|
||||
out = Willexecutors.get_willexecutor_transactions(
|
||||
_will(), current_willexecutors=current
|
||||
)
|
||||
assert set(out) == {A}
|
||||
|
||||
|
||||
def test_already_pushed_still_excluded_unless_forced():
|
||||
will = {"tx_a": FakeItem(A, PUSHED=True)}
|
||||
current = {A: {"selected": True}}
|
||||
assert Willexecutors.get_willexecutor_transactions(
|
||||
will, current_willexecutors=current
|
||||
) == {}
|
||||
assert set(
|
||||
Willexecutors.get_willexecutor_transactions(
|
||||
will, force=True, current_willexecutors=current
|
||||
)
|
||||
) == {A}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All deselected-executor tests passed")
|
||||
@@ -3,10 +3,11 @@ 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 for a confirmation within
|
||||
``INVALIDATION_FEE_ETA_BLOCKS`` (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 network estimate is available.
|
||||
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
|
||||
@@ -25,9 +26,15 @@ 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, Will, WillItem
|
||||
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")
|
||||
@@ -55,19 +62,21 @@ _BALTX_FEES = 20
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _wallet_with_estimate(fee_per_kb):
|
||||
"""Wallet whose network returns ``fee_per_kb`` (sat/kvbyte) for any ETA.
|
||||
def _wallet_with_eta_estimate(fee_per_kb):
|
||||
"""Wallet whose ETA fee lookup returns ``fee_per_kb`` (sat/kvbyte).
|
||||
|
||||
``fee_per_kb`` may also be an Exception instance: the estimate call then
|
||||
raises it, to simulate a broken network object.
|
||||
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_target_to_fee.side_effect = fee_per_kb
|
||||
network.fee_estimates.eta_to_fee.side_effect = fee_per_kb
|
||||
else:
|
||||
network.fee_estimates.eta_target_to_fee.return_value = fee_per_kb
|
||||
network.fee_estimates.eta_to_fee.return_value = fee_per_kb
|
||||
wallet.network = network
|
||||
return wallet
|
||||
|
||||
@@ -99,7 +108,7 @@ def _make_utxo():
|
||||
return utxo
|
||||
|
||||
|
||||
def _invalidation_output_value(wallet, estimated_size=200):
|
||||
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):
|
||||
@@ -114,44 +123,83 @@ def _invalidation_output_value(wallet, estimated_size=200):
|
||||
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)
|
||||
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
|
||||
# Will.get_invalidation_fee_rate - policy selection
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_target_is_two_blocks():
|
||||
"""The owner's decision: confirm within 2 blocks."""
|
||||
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
|
||||
wallet = _wallet_with_estimate(3000)
|
||||
Will.get_invalidation_fee_rate(wallet, _BALTX_FEES)
|
||||
wallet.network.fee_estimates.eta_target_to_fee.assert_called_once_with(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_estimate(3000)
|
||||
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (3.0, "network")
|
||||
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_estimate(45000)
|
||||
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (45.0, "network")
|
||||
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_estimate(1500)
|
||||
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (1.5, "network")
|
||||
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): previous behaviour, 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) == (
|
||||
@@ -162,7 +210,7 @@ def test_offline_wallet_falls_back_to_baltx_fees():
|
||||
|
||||
def test_missing_estimate_falls_back():
|
||||
"""Estimates not received yet: Electrum returns None."""
|
||||
wallet = _wallet_with_estimate(None)
|
||||
wallet = _wallet_with_eta_estimate(None)
|
||||
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
|
||||
_BALTX_FEES,
|
||||
"fallback",
|
||||
@@ -171,23 +219,31 @@ def test_missing_estimate_falls_back():
|
||||
|
||||
def test_estimate_error_falls_back_and_does_not_raise():
|
||||
"""A broken network object must never break the invalidation."""
|
||||
wallet = _wallet_with_estimate(RuntimeError("server gone"))
|
||||
wallet = _wallet_with_eta_estimate(RuntimeError("server gone"))
|
||||
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
|
||||
_BALTX_FEES,
|
||||
"fallback",
|
||||
)
|
||||
|
||||
|
||||
def test_non_integer_or_invalid_estimates_fall_back():
|
||||
"""Only a positive int (Electrum's sat/kvbyte type) is trusted."""
|
||||
for bad in (0, -1000, True, "3000", 3.5, MagicMock()):
|
||||
wallet = _wallet_with_estimate(bad)
|
||||
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,
|
||||
@@ -201,19 +257,28 @@ def test_no_wallet_falls_back():
|
||||
|
||||
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_estimate(3000)
|
||||
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_estimate(None)
|
||||
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_estimate(1500)
|
||||
wallet = _wallet_with_eta_estimate(1500)
|
||||
value = _invalidation_output_value(wallet, estimated_size=201)
|
||||
assert value == _BALANCE - 302
|
||||
assert isinstance(value, int)
|
||||
|
||||
@@ -47,6 +47,24 @@ _VALID_TX_HEX = (
|
||||
# The prevout string that _VALID_TX_HEX spends (input 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_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
|
||||
|
||||
@@ -55,8 +73,9 @@ _CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None):
|
||||
"""Create a WillItem from _VALID_TX_HEX with a known input value.
|
||||
def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None,
|
||||
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
|
||||
``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:
|
||||
heirs.update(extra_heirs)
|
||||
item = WillItem({
|
||||
"tx": _VALID_TX_HEX,
|
||||
"tx": tx_hex,
|
||||
"heirs": heirs,
|
||||
"willexecutor": None,
|
||||
"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.is_coinbase_output.return_value = is_coinbase
|
||||
utxo.block_height = 1
|
||||
utxo.block_txpos = 0
|
||||
utxo.value_sats.return_value = value_sats
|
||||
return utxo
|
||||
|
||||
|
||||
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.get_utxos.return_value = utxos
|
||||
wallet.get_change_addresses_for_new_transaction.return_value = [change_addr]
|
||||
wallet.network = MagicMock()
|
||||
wallet.network = None
|
||||
return wallet
|
||||
|
||||
|
||||
@@ -292,6 +319,77 @@ class TestInvalidateWill:
|
||||
second_call_value = mock_out.call_args_list[1][0][1]
|
||||
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):
|
||||
"""The first from_io call uses the full balance (before fee deduction)
|
||||
to estimate the fee."""
|
||||
@@ -353,6 +451,152 @@ class TestInvalidateWill:
|
||||
second_call_value = mock_out.call_args_list[1][0][1]
|
||||
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
|
||||
|
||||
@@ -386,7 +386,9 @@ def test_insufficient_funds_warns():
|
||||
|
||||
assert not will, "no wallet funds must not build a will"
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -92,31 +92,6 @@ def test_can_delete_none_false():
|
||||
assert _can_delete(None) is False
|
||||
|
||||
|
||||
def _signed_valid(we="https://we.example", **flags):
|
||||
"""A VALID + COMPLETE (signed) item, optionally with a will-executor."""
|
||||
item = FakeWillItem(VALID=True, COMPLETE=True, **flags)
|
||||
item.we = {"url": we, "selected": True} if we else None
|
||||
return item
|
||||
|
||||
|
||||
def test_can_delete_signed_but_never_received_by_executor():
|
||||
"""Push failed / "Not on server": the executor does not hold it."""
|
||||
assert _can_delete(_signed_valid(PUSH_FAIL=True, CHECK_FAIL=True)) is True
|
||||
assert _can_delete(_signed_valid()) is True # signed, never sent
|
||||
|
||||
|
||||
def test_can_delete_signed_and_pushed_false():
|
||||
"""The executor holds it: deleting locally would not cancel it."""
|
||||
assert _can_delete(_signed_valid(PUSHED=True)) is False
|
||||
assert _can_delete(_signed_valid(PUSHED=True, CHECKED=True)) is False
|
||||
assert _can_delete(_signed_valid(CHECKED=True)) is False
|
||||
|
||||
|
||||
def test_can_delete_signed_backup_tx_without_executor_false():
|
||||
"""Signed txs without a will-executor (backup) stay protected."""
|
||||
assert _can_delete(_signed_valid(we=None)) is False
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# _apply_select_all
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
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
|
||||
(WILL_SETTINGS["locktime"]) in lockstep with the BUILT transactions' fixed
|
||||
locktime when the core AUTOMATICALLY anticipates it (one day earlier than
|
||||
stored).
|
||||
``_sync_locktime_to_built_txs`` is the post-build sync that keeps the plugin's
|
||||
stored delivery date (WILL_SETTINGS["locktime"]) in lockstep with the BUILT
|
||||
transactions' fixed locktime when the core AUTOMATICALLY anticipates it (one
|
||||
day earlier than stored).
|
||||
|
||||
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
|
||||
@@ -14,8 +15,14 @@ anchoring every relative recipe against the built transactions
|
||||
timestamp). Only a genuine automatic anticipation on an ABSOLUTE stored date
|
||||
moves the stored value.
|
||||
|
||||
The method is exercised with a lightweight fake ``self`` (no Qt event loop, no
|
||||
Electrum wallet) by calling it as an unbound method.
|
||||
WHY THE FLAG IS NOT SET HERE ANY MORE: because a relative recipe is anchored to
|
||||
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:
|
||||
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):
|
||||
"""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(
|
||||
tx=SimpleNamespace(locktime=tx_locktime),
|
||||
get_status=lambda name: True,
|
||||
heirs={},
|
||||
)
|
||||
|
||||
|
||||
@@ -57,7 +66,6 @@ def _make_dialog(will_settings, tx_locktimes, recorded):
|
||||
will_settings=will_settings,
|
||||
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["threshold"] == "30d"
|
||||
assert recorded == [], "a relative recipe must never be rewritten"
|
||||
assert fake._date_was_anticipated is False
|
||||
|
||||
|
||||
def test_relative_threshold_preserved():
|
||||
@@ -108,12 +115,13 @@ def test_absolute_locktime_unchanged_on_equal():
|
||||
recorded,
|
||||
)
|
||||
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
|
||||
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
|
||||
recorded = []
|
||||
fake = _call_sync(
|
||||
@@ -122,7 +130,6 @@ def test_anticipation_sets_flag_and_moves_earlier():
|
||||
recorded,
|
||||
)
|
||||
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():
|
||||
@@ -137,7 +144,6 @@ def test_stored_earlier_than_built_never_moved_later():
|
||||
recorded,
|
||||
)
|
||||
assert fake.bal_window.will_settings["locktime"] == stored
|
||||
assert fake._date_was_anticipated is False
|
||||
|
||||
|
||||
def test_multiple_txs_uses_minimum_locktime():
|
||||
@@ -182,6 +188,76 @@ def test_relative_locktime_stays_coherent_via_anchor():
|
||||
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__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
|
||||
Reference in New Issue
Block a user