6 Commits

Author SHA1 Message Date
f3deccb626 gui+cli+core: anticipated-delivery warning, task #67 amount checks, CLI tools
GUI - "Building your will" row (owner request):
- when a rebuild anticipates the delivery date by one day, show
  "Anticipated - new delivery date: <date>" on the "Building your will" row
  instead of a plain "Ok";
- the detection compares the will's minimum locktime BEFORE and AFTER the
  rebuild, so it also covers relative recipes ("1y"/"30d") that the previous
  stored-vs-built comparison missed;
- the sign explanation stays on its own "Signing your will" row so it no
  longer overwrites the anticipation warning.

Task #67 - "Checking variables" stuck on "Wait":
- core: Will.check_amounts only requires a fixed amount to be funded when it
  is > 0, and only requires percentages to total 100% when the will actually
  has percentage heirs; fixed-only / empty-wallet wills no longer raise;
- gui: the amount warning is written on the "Checking variables" row itself
  (COLOR_WARNING) instead of being overwritten by the next row; the redundant
  "adjustment of the amounts" sentence was dropped.

CLI - will-executor helpers:
- willexecutors_update accepts promo_code;
- willexecutors_select supports bulk apply with only_valid;
- willexecutors_export supports selected/valid subsets;
- import overwrites existing records with sane defaults;
- raise NoServersForChainError with a clear user-facing message; map
  NotCompleteWill / HeirChange / WillexecutorChange in _user_facing.

Tests and docs:
- tests for the anticipation row, the CLI helpers and the amount checks;
- i18n catalogs refreshed; CHANGELOG section 67; agent task memory updated.
2026-10-15 21:55:03 -04:00
c9f3f77a15 i18n(es_ES): fix 'Ok' translation from 'Aceptar' to 'Hecho' in wizard status messages 2026-10-13 18:31:47 -04:00
3d9757fe2d core+gui+cli: network-fee invalidation, will-tree fixes, i18n refresh
- #64: the on-chain invalidation now pays the current network fee through a
  configurable fee-policy setting (default eta:2); baltx_fees is only the
  offline fallback. The change output is dust-guarded: coins are appended until
  the change clears the wallet dust threshold.
- #65: a will item whose father is missing/stale is handled defensively in
  check_invalidated and discarded by add_willtree instead of raising KeyError.
- #66: children are stored as a list (dict accepted for older data/tests).
- Refresh the translation catalogs and fix stale source line references.
- New tests for the invalidation fee policy, the dust guard and the father
  link handling.
2026-10-12 15:16:50 -04:00
346aeee62b Merge remote-tracking branch 'origin/main' 2026-10-08 19:01:23 -04:00
3b2eed37cc i18n: fix Babel format-string error on the heirs wizard text
The literal '%' in "100% of wallet balance" was detected as a Python
format specifier (%o in English vs %d in the translations), so
pybabel compile failed and the catalogs could not be built. Reword to
put '100%' at the end of the sentence and refresh es_ES/it_IT.
2026-10-08 19:00:51 -04:00
0c60462163 Merge pull request 'i18n review fixes (#63)' (#10) from fix/i18n-review into main
Reviewed-on: #10
2026-10-08 20:43:21 +00:00
20 changed files with 3076 additions and 1370 deletions

View File

@@ -485,8 +485,8 @@ ROW LABELS and, for each, a variable STATUS text.
|--------------------------------------------------------------------------------|--------|-------------| |--------------------------------------------------------------------------------|--------|-------------|
| Check Alive Threshold Passed: you have to Invalidate your old Will | RED | CheckAliveError + an invalidate tx exists (~559) | | Check Alive Threshold Passed: you have to Invalidate your old Will | RED | CheckAliveError + an invalidate tx exists (~559) |
| No Heirs | RED | NoHeirsException (~564) | | No Heirs | RED | NoHeirsException (~564) |
| Ok | GREEN | check_amounts passed (~579) | | Ok | GREEN | check_amounts passed |
| In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts | (warning popup) | AmountException (~583) | | In the inheritance process, the entire wallet will always be fully emptied. | ORANGE | AmountException (warning on the row itself, not a popup) |
### ROW 2 — "Checking your will" outcomes (this is where "Heir not found" / "New" appear) ### ROW 2 — "Checking your will" outcomes (this is where "Heir not found" / "New" appear)
| Status text shown | Color | When (code) | Triggers rebuild? | | Status text shown | Color | When (code) | Triggers rebuild? |

View File

@@ -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).

View File

@@ -3612,3 +3612,211 @@ deselected executor) are not part of it and will be proposed separately.
none new. none new.
**Outcome:** DONE (pending Truman's review). **Outcome:** DONE (pending Truman's review).
---
## 64. Invalidation pays the current network fee, configurable via a fee-policy setting
**Date:** 2026-10-10
**Goal (owner request):** the will's `baltx_fees` (default 20 sat/vbyte) is
deliberately high because the inheritance transactions may be broadcast by the
will-executors years from now, when network fees may be much higher. The
on-chain INVALIDATION, instead, is sent TODAY: it spends the will's coins back
to the wallet so the old pre-signed inheritance transactions become unusable.
It must therefore pay the current network fee, not the inheritance rate. After
the invalidation the plugin rebuilds the new inheritance with `baltx_fees` as
before (unchanged). The fee policy is exposed as an ADVANCED-only setting.
**What changed:**
- `bal/core/will.py`
- New constants `INVALIDATION_FEE_ETA_BLOCKS = 2` and
`INVALIDATION_FEE_POLICY_DEFAULT = "eta:2"` (owner decision: confirm within
2 blocks, Electrum's own default target).
- New `Will.get_invalidation_fee_rate(wallet, fallback_fee_per_byte,
fee_policy_descriptor=None)`: builds an Electrum `FeePolicy` from the
descriptor (Feerate / ETA / Mempool) and returns its rate in sat/vbyte,
together with the `FeeMethod` name (`"feerate"` / `"eta"` / `"mempool"`) or
`"fallback"`. The fallback (the will's `baltx_fees`, i.e. the previous
behaviour) is used when no usable rate is available: offline wallet,
estimates not received yet, network error or an invalid rate value. A
malformed stored descriptor is handled by Electrum's `FeePolicy`, which
falls back to the default `eta:2` policy (network rate), not to
`baltx_fees`. Never raises.
- `Will.invalidate_will` gained a `fee_policy_descriptor` argument and uses
that rate; the total fee is rounded UP to whole satoshis (network rates can
be fractional); it logs the chosen rate and its source. `fees_per_byte` is
now only the fallback, so every caller keeps working. The change output must
stay at or above the wallet dust threshold: coins are appended until the
change clears dust, and if it never does the invalidation returns `None`
(instead of an unrelayable sub-dust output). Docstrings added.
- `bal/core/plugin_base.py`: new `INVALIDATION_FEE_POLICY` BalConfig
(`bal_invalidation_fee_policy`, default `"eta:2"`).
- `bal/gui/qt/plugin.py`: new ADVANCED-only row 16 in the settings dialog,
reusing Electrum's standard fee widget (`FeeSlider` + `FeeComboBox`) for the
Feerate / ETA / Mempool choice, with a Help button and a per-row reset. The
chosen descriptor is persisted; the dialog-wide "Reset to Default Setting"
also restores it.
- `bal/gui/qt/window.py`, `bal/gui/qt/dialogs.py`, `bal/cli/controller.py`:
every `Will.invalidate_will` call site passes
`fee_policy_descriptor=...INVALIDATION_FEE_POLICY.get()`, so the Tools →
Invalidate action, the wizard postpone/expired paths, the automatic
invalidation and the CLI (`bal_will_invalidate`) all get the new behaviour.
- `bal/locale/bal.pot` and the `es_ES` / `it_IT` catalogs refreshed
(`pybabel extract` / `update`, Babel 2.10.3 to match the existing template) so
the new settings-dialog strings are translatable.
**Why no special case for an already-expired will:** discussed with the owner
and intentionally left out. Before its locktime the old inheritance tx cannot
enter a block or the mempool, so the invalidation has no competitor. RBF stays
enabled on the invalidation, so its fee can be raised from Electrum's History
("Increase fee") if it ever gets stuck.
**Verification:**
- `tests/test_core_invalidation_fee.py` (new, 17 tests): rate selection
(2-block target, network value used as is also above `baltx_fees`, fractional
rates, fallback when offline / no estimate / error / invalid values / invalid
descriptor / no wallet) and the fee actually deducted by `invalidate_will`
(network vs fallback, fixed feerate offline, rounding up).
- `tests/test_settings_invalidation_fee.py` (new, 7 tests): the row is hidden
in BASIC and visible in ADVANCED; choosing a Feerate / ETA policy persists
the descriptor; "Reset to Default Setting" restores `"eta:2"`.
- `tests/test_core_will_invalidate.py`: its mock wallet now has `network=None`
so the pure fee arithmetic (`fee = size * fees_per_byte`) still exercises the
fallback path; the policy-based selection is covered by the new file. Four new
tests cover the dust guard: sub-dust change returns `None`, change exactly at
the threshold is accepted, the fallback stops when the change is still dust,
and it succeeds once the change clears dust.
- Selected suite (core + GUI + settings + translations): 423 passed.
- `ruff check` on the touched files: no new violations (only the pre-existing
I001 in `dialogs.py`).
**Outcome:** DONE (pending the owner's test of the release ZIP).
---
## 65. Check crashed with `KeyError: '<txid>'` (stale "father" link in the will tree)
**Date:** 2026-10-10
**Symptom (owner's mainnet wallet, Electrum 4.8.x):** the Check wizard stopped
at "Checking your will" with `Error: 'a65a682f…'`, and the window refresh
logged "error while updating window" with the same txid:
```
File "bal/core/will.py", line 657, in check_invalidated
or willtree[w.father].get_status("CONFIRMED")
KeyError: 'a65a682f2975f0dffd9eccce9392833f227badf617fc67815674736d594d4115'
```
**Root cause:** `father` is persisted in the wallet (the `"Father"` field of
`WillItem.to_dict`) and was only ever set, never cleared. When the father tx was
later removed from the will (rebuild, merge, replacement) the child kept
pointing to a txid that no longer exists, and `check_invalidated` crashed with a
`KeyError` on that txid (the Check wizard and the window refresh both failed).
**What changed:**
- `bal/core/will.py`
- `Will.add_willtree`: clears `father` on every item before rebuilding the
links, so the tree is always recomputed from scratch from the transactions
themselves. Docstring added explaining WHY.
- `Will.check_invalidated`: a father that is not (or no longer) part of the
will is treated as "no father" instead of raising `KeyError`.
- `tests/test_core_willtree_father.py` (new, 7 tests): stale father discarded /
replaced by the real parent, no crash in `check_invalidated` (with and
without the tree rebuild), real parent/child link built, child of an
unconfirmed parent still skipped, and the `set_invalidate` cascade (see #66).
**Verification:** the new test file plus the core/CLI suites pass; `ruff check`
clean on the touched files.
**Outcome:** DONE (pending the owner's test of the ZIP).
---
## 66. Invalidation cascade crashed on real will trees (`children` is a list)
**Date:** 2026-10-10
**Problem (found while fixing #65, reproduced on real data):**
`Will.set_invalidate` iterated `children.items()`, but `Will.add_willtree`
builds `children` as a LIST of `[child_id, input_idx, prevout_idx]`, so
invalidating a will tx that has child will txs raised
`AttributeError: 'list' object has no attribute 'items'`. The existing tests in
`test_core_will_invalidate.py` set `children` to dicts by hand, so they never
saw it.
**What changed:** `bal/core/will.py` - `set_invalidate` accepts both shapes
(the list from `add_willtree`, a dict from older data/tests) and only recurses
into children that are still present in the will.
`tests/test_core_willtree_father.py`: new test
`test_set_invalidate_cascades_to_children_built_by_add_willtree`.
**Verification:** `tests/test_core_willtree_father.py` passes; `ruff check`
clean on the touched files.
**Outcome:** DONE.
---
## 67. "Checking variables" stuck on "Wait" + redundant amount-adjustment wording
**Date:** 2026-10-14
**Symptom (owner report):** in the "Building Will" dialog the "Checking
variables" row always stayed on "Wait" and never became "Ok", even though the
will itself was fine ("Checking your will" showed "Ok"). It should show its
result before the "Checking your will" row is written.
**Root cause (two bugs):**
1. UI - when `Will.check_amounts` raised `AmountException` /
`WillExecutorFeeTooHighException`, `BalBuildWillDialog.task_phase1` wrote the
warning to the **"Checking your will"** row (`msg_set_checking(...)`) and the
very next `self.msg_set_checking()` call immediately overwrote it. So on an
amount warning the warning was lost AND the "Checking variables" row kept its
initial "Wait" forever (the success path set "Ok" only when the amount check
passed).
2. Core - `Will.check_amounts` had two false positives that made perfectly valid
wills raise:
- `fixed_amount >= wallet_balance` also fired when `fixed_amount == 0`
(all-percentage or empty/fully-committed wallet: `0 >= 0`);
- `perc_amount != 100` also fired when the will has NO percentage heirs at
all (a fixed-only will reports `perc_amount == 0`).
**What changed:**
- `bal/gui/qt/dialogs.py` (`task_phase1`, AmountException branch): the warning is
now written on the **"Checking variables"** row itself with `COLOR_WARNING`, so
the row always reaches a real outcome and the warning is never lost. The
wording was also reduced to the first sentence only - "In the inheritance
process, the entire wallet will always be fully emptied." - dropping
"Your settings require an adjustment of the amounts" (owner request).
- `bal/gui/qt/window.py` (`build_inheritance_transaction`, AmountException
branch): same message reduction; the appended exception detail (`.format(e)`)
was removed with the sentence it belonged to.
- `bal/core/will.py` (`check_amounts`): the fixed-amount checks now only apply
when `fixed_amount > 0`, and the 100% check only when the will actually has
percentage heirs (`perc_heirs`). Valid fixed-only / empty-wallet wills no
longer raise.
- i18n catalogs refreshed (`pybabel extract` + `update`); the new unified msgid
"In the inheritance process, the entire wallet will always be fully emptied."
is translated in `it_IT` and `es_ES`; the old
"...adjustment of the amounts.{}" entry is now obsolete.
**Verification:**
- `Will.check_amounts` matrix (fixed-only/percentage/empty wallet) behaves as
expected; core/offline suites plus `test_gui_prepare_will_history.py`,
`test_sync_locktime_built_txs.py`, `test_rebuild_on_close_setting.py`,
`test_translations.py` and the smoke test pass.
- Offscreen repro of `task_phase1`: "Checking variables" shows green "Ok" on
success and the (shortened) warning on the row on `AmountException` - never
"Wait".
- `py_compile` clean; `ruff check` adds no new violations (only the pre-existing
`I001` in `dialogs.py`).
**Outcome:** DONE (pending the owner's test of the ZIP).

View File

@@ -238,24 +238,26 @@ async def willexecutors_update(
wallet, wallet,
"willexecutors_update", "willexecutors_update",
url, url,
address, address=address,
base_fee, base_fee=base_fee,
info, info=info,
promo_code, promo_code=promo_code,
rename_to, rename_to=rename_to,
) )
@plugin_command("nw", plugin_name) @plugin_command("nw", plugin_name)
async def willexecutors_select( async def willexecutors_select(
self, url, value=True, wallet=None, plugin=None self, url=None, value=True, only_valid=False, wallet=None, plugin=None
): ):
"""Select (or deselect) a will-executor. """Select (or deselect) a will-executor, or bulk-apply a selection.
arg:str:url:The will-executor URL. arg:str:url:The will-executor URL; omit to act on every executor.
arg:bool:value:True to select, False to deselect. arg:bool:value:True to select, False to deselect.
arg:bool:only_valid:When selecting/deselecting all, only change the valid executors (the invalid ones are deselected/left alone).
""" """
return _call(plugin, wallet, "willexecutors_select", [url], value) urls = [url] if url is not None else None
return _call(plugin, wallet, "willexecutors_select", urls, value, only_valid)
@plugin_command("nw", plugin_name) @plugin_command("nw", plugin_name)
@@ -298,12 +300,13 @@ async def willexecutors_import(self, path, wallet=None, plugin=None):
@plugin_command("nw", plugin_name) @plugin_command("nw", plugin_name)
async def willexecutors_export(self, path, wallet=None, plugin=None): async def willexecutors_export(self, path, subset=None, wallet=None, plugin=None):
"""Export the will-executors to a JSON file. """Export the will-executors to a JSON file.
arg:str:path:Destination file path. arg:str:path:Destination file path.
arg:str:subset:Optional filter: ``selected`` (only selected executors) or ``valid`` (only valid ones); omit to export all.
""" """
return _call(plugin, wallet, "willexecutors_export", path) return _call(plugin, wallet, "willexecutors_export", path, subset)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #

View File

@@ -88,6 +88,27 @@ def _user_facing(e):
return UserFacingException(_("Will-Executor not present")) return UserFacingException(_("Will-Executor not present"))
if isinstance(e, will_mod.NoWillExecutorNotPresent): if isinstance(e, will_mod.NoWillExecutorNotPresent):
return UserFacingException(_("No backup transaction or will-executor selected")) return UserFacingException(_("No backup transaction or will-executor selected"))
if isinstance(e, will_mod.HeirChangeException):
return UserFacingException(_("The heirs changed: a new will must be prepared."))
if isinstance(e, will_mod.WillexecutorChangeException):
return UserFacingException(
_("A will-executor changed: a new will must be prepared.")
)
if isinstance(e, will_mod.NotCompleteWillException):
# ``WillException.__init__`` stores the detail in ``self.msg`` but calls
# ``Exception.__init__(self)`` without it, so ``e.args`` is always empty;
# the default sentinel message is "WillException".
detail = getattr(e, "msg", None)
if detail and detail != "WillException":
return UserFacingException(
_(
"The wallet contains funds that the current will does not "
"cover yet: a new will must be prepared."
)
)
return UserFacingException(
_("The will contains no valid transaction: a new will must be prepared.")
)
if isinstance(e, will_mod.AmountException): if isinstance(e, will_mod.AmountException):
return UserFacingException( return UserFacingException(
_( _(
@@ -121,6 +142,21 @@ def _user_facing(e):
"but less than the locktime" "but less than the locktime"
) )
) )
if isinstance(e, Willexecutors.NoServersForChainError):
if e.url:
return UserFacingException(
_(
"Could not reach the configured welist server.\n\n"
"Server: {url}\n"
"Error: {reason}\n\n"
"Please verify the welist server URL in the plugin settings."
).format(url=e.url, reason=e.reason or "empty response")
)
return UserFacingException(
_("No active will-executor found for the {chain} network.").format(
chain=e.chain
)
)
return e return e
@@ -352,6 +388,7 @@ class BalController:
self.date_to_check = resolve_date_to_check( self.date_to_check = resolve_date_to_check(
self.plugin.is_basic_mode(), self.will_settings, self.plugin.is_basic_mode(), self.will_settings,
built_locktime=_new_locktime, built_locktime=_new_locktime,
creation_date=Will.get_creation_time(self.willitems),
) )
self.willexecutors = Willexecutors.get_willexecutors( self.willexecutors = Willexecutors.get_willexecutors(
self.plugin, update=False, task=False self.plugin, update=False, task=False
@@ -417,6 +454,7 @@ class BalController:
will_locktime=Will.get_min_locktime( will_locktime=Will.get_min_locktime(
willitems, default_value=self.date_to_check willitems, default_value=self.date_to_check
), ),
fee_policy_descriptor=self.plugin.INVALIDATION_FEE_POLICY.get(),
) )
if tx is not None: if tx is not None:
try: try:
@@ -471,6 +509,11 @@ class BalController:
self.wallet.dust_threshold(), self.wallet.dust_threshold(),
max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(), max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(),
) )
except will_mod.AmountException as e:
# Mirror the GUI (BalWindow.build_inheritance_transaction): an
# amount mismatch is only a WARNING here, the flow continues (the
# rebuild/normalisation below reconciles the shares).
_logger.warning(f"prepare will: check_amounts: {e}")
except Exception as e: except Exception as e:
raise _user_facing(e) from e raise _user_facing(e) from e
@@ -948,6 +991,8 @@ class BalController:
heir.append(locktime) heir.append(locktime)
else: else:
heir.append(self.will_settings["locktime"]) heir.append(self.will_settings["locktime"])
if not self.plugin.ENABLE_MULTIVERSE.get():
heir[3] = self.will_settings["locktime"]
if is_op_return_address(address): if is_op_return_address(address):
heir[2] = "0" heir[2] = "0"
return Heirs.validate_heir(heir[0], heir[1:]) return Heirs.validate_heir(heir[0], heir[1:])
@@ -1033,7 +1078,7 @@ class BalController:
return self.willexecutors_show(url) return self.willexecutors_show(url)
def willexecutors_update(self, url, address=None, base_fee=None, info=None, def willexecutors_update(self, url, address=None, base_fee=None, info=None,
rename_to=None): promo_code=None, rename_to=None):
if url not in self.willexecutors: if url not in self.willexecutors:
raise UserFacingException(_("Will-Executor not found: {}").format(url)) raise UserFacingException(_("Will-Executor not found: {}").format(url))
we = self.willexecutors[url] we = self.willexecutors[url]
@@ -1044,6 +1089,8 @@ class BalController:
we["base_fee"] = int(base_fee) we["base_fee"] = int(base_fee)
if info is not None: if info is not None:
we["info"] = info.strip() or "New Will Executor" we["info"] = info.strip() or "New Will Executor"
if promo_code is not None:
we["promo_code"] = promo_code
if rename_to and rename_to != url: if rename_to and rename_to != url:
if rename_to in self.willexecutors: if rename_to in self.willexecutors:
raise UserFacingException( raise UserFacingException(
@@ -1064,17 +1111,38 @@ class BalController:
Willexecutors.save(self.plugin, self.willexecutors) Willexecutors.save(self.plugin, self.willexecutors)
return {"deleted": deleted} return {"deleted": deleted}
def willexecutors_select(self, urls=None, select=True): def willexecutors_select(self, urls=None, select=True, only_valid=False):
"""Select/deselect one, many or all will-executors.""" """Select or deselect will-executors.
selected = {}
targets = urls if urls is not None else list(self.willexecutors) Mirrors the GUI's single-toggle and ``set_select_all(select,
only_valid)`` bulk actions. ``urls`` is the set of records to act on
(all of them when omitted); with ``only_valid`` the selection is
restricted by validity: selecting sets the valid ones to True and the
invalid ones to False, while deselecting only clears the invalid ones.
"""
targets = (
list(self.willexecutors)
if urls is None
else [url for url in urls if url in self.willexecutors]
)
for url in targets: for url in targets:
if url not in self.willexecutors: we = self.willexecutors[url]
continue if only_valid:
self.willexecutors[url]["selected"] = bool(select) is_valid = Willexecutors.is_valid(
selected[url] = bool(select) we,
max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(),
dust=self.wallet.dust_threshold(),
)
if select:
we["selected"] = bool(is_valid)
elif not is_valid:
we["selected"] = False
else:
we["selected"] = bool(select)
Willexecutors.save(self.plugin, self.willexecutors) Willexecutors.save(self.plugin, self.willexecutors)
return selected return {
url: bool(self.willexecutors[url].get("selected")) for url in targets
}
def willexecutors_ping(self, urls=None): def willexecutors_ping(self, urls=None):
"""Ping the (selected) will-executor servers and refresh their info. """Ping the (selected) will-executor servers and refresh their info.
@@ -1124,10 +1192,12 @@ class BalController:
result = {} result = {}
last_error = None last_error = None
any_server_reached = False
try: try:
resp = Willexecutors.send_request( resp = Willexecutors.send_request(
"get", url, timeout=10, max_retries=1, retry_sleep=1 "get", url, timeout=10, max_retries=1, retry_sleep=1
) )
any_server_reached = True
if not isinstance(resp, dict): if not isinstance(resp, dict):
last_error = "invalid response format" last_error = "invalid response format"
_logger.warning( _logger.warning(
@@ -1153,12 +1223,17 @@ class BalController:
last_error = str(e) last_error = str(e)
_logger.error(f"fetch_will_executors_list: {url} -> {type(e).__name__}: {e}") _logger.error(f"fetch_will_executors_list: {url} -> {type(e).__name__}: {e}")
if not result and not basic: if not result:
raise UserFacingException( if not basic:
_("Could not reach the configured welist server.\nServer: {}\nError: {}").format( # Advanced mode: always raise with full details.
url, last_error or "empty response" raise Willexecutors.NoServersForChainError(
) chainname,
url=url,
reason=last_error or "empty response",
) )
if any_server_reached:
# Basic mode: the server answered but has no data for this chain.
raise Willexecutors.NoServersForChainError(chainname)
return result return result
def willexecutors_download(self): def willexecutors_download(self):
@@ -1178,7 +1253,9 @@ class BalController:
raise UserFacingException( raise UserFacingException(
_("Invalid entry {} in will-executors file").format(url) _("Invalid entry {} in will-executors file").format(url)
) )
if url not in self.willexecutors: # Mirror the GUI import (``willexecutors_list.update(data)``):
# existing URLs are overwritten with the imported record, missing
# fields get sane defaults.
we = dict(we) we = dict(we)
we.setdefault("selected", False) we.setdefault("selected", False)
we.setdefault("status", "New") we.setdefault("status", "New")
@@ -1187,9 +1264,30 @@ class BalController:
Willexecutors.save(self.plugin, self.willexecutors) Willexecutors.save(self.plugin, self.willexecutors)
return {"imported": len(data), "total": len(self.willexecutors)} return {"imported": len(data), "total": len(self.willexecutors)}
def willexecutors_export(self, path): def willexecutors_export(self, path, subset=None):
write_json_file(path, self.willexecutors) """Export the will-executors to ``path``.
return {"exported": len(self.willexecutors), "path": path}
``subset`` mirrors the GUI export filter: ``"selected"`` writes only
the selected records, ``"valid"`` only the valid ones, anything else
(or ``None``) writes every record.
"""
data = self.willexecutors
if subset == "selected":
data = {
url: we for url, we in data.items() if Willexecutors.is_selected(we)
}
elif subset == "valid":
data = {
url: we
for url, we in data.items()
if Willexecutors.is_valid(
we,
max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(),
dust=self.wallet.dust_threshold(),
)
}
write_json_file(path, data)
return {"exported": len(data), "path": path}
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Settings # Settings
@@ -1289,11 +1387,3 @@ class BalController:
) )
return value return value
return str(raw) return str(raw)
def get_decimal_point(plugin):
"""Standalone helper returning Electrum's configured BTC decimal point."""
try:
return plugin.config.BTC_AMOUNTS_DECIMAL_POINT
except AttributeError:
return 8

View File

@@ -251,6 +251,24 @@ class BalPlugin(BasePlugin):
self.TX_FEES = BalConfig(config, "bal_tx_fees", 100) self.TX_FEES = BalConfig(config, "bal_tx_fees", 100)
self.INVALIDATE = BalConfig(config, "bal_invalidate", True) self.INVALIDATE = BalConfig(config, "bal_invalidate", True)
self.ASK_INVALIDATE = BalConfig(config, "bal_ask_invalidate", True) self.ASK_INVALIDATE = BalConfig(config, "bal_ask_invalidate", True)
# INVALIDATION_FEE_POLICY (advanced): the fee rate the on-chain
# INVALIDATION transaction pays. It is expressed as an Electrum
# ``FeePolicy`` descriptor "<method>:<value>", the same format Electrum
# uses for its own ``FEE_POLICY`` setting, so the standard fee slider
# (Feerate / ETA / Mempool) can edit it directly:
# * "eta:2" - fee estimated for confirmation within 2 blocks
# (the default: Electrum's own default target);
# * "mempool:N" - fee targeting a depth of N vbytes in the mempool;
# * "feerate:N" - a fixed rate of N sat/kvbyte (works offline).
# WHY it is separate from the will's ``baltx_fees``: the will is built
# with a deliberately high rate because the will-executors may broadcast
# the inheritance years from now, while the invalidation is sent TODAY
# and must pay the current network fee. ``baltx_fees`` remains only the
# fallback used when no estimate is available.
self.INVALIDATION_FEE_POLICY = BalConfig(
config, "bal_invalidation_fee_policy", "eta:2"
)
self.PREVIEW = BalConfig(config, "bal_preview", True) self.PREVIEW = BalConfig(config, "bal_preview", True)
self.SAVE_TXS = BalConfig(config, "bal_save_txs", True) self.SAVE_TXS = BalConfig(config, "bal_save_txs", True)

View File

@@ -26,9 +26,11 @@ The status flags themselves (the source of truth) stay here; only the mapping
"status -> colour" now lives in the GUI layer. No behaviour changed. "status -> colour" now lives in the GUI layer. No behaviour changed.
""" """
import math
from datetime import datetime, timezone from datetime import datetime, timezone
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
from electrum.fee_policy import FeePolicy
from electrum.logging import Logger, get_logger from electrum.logging import Logger, get_logger
from electrum.transaction import ( from electrum.transaction import (
PartialTransaction, PartialTransaction,
@@ -50,6 +52,18 @@ from .willexecutors import Willexecutors
MIN_LOCKTIME = 1 MIN_LOCKTIME = 1
MIN_BLOCK = 1 MIN_BLOCK = 1
# Confirmation target (in blocks) for the on-chain INVALIDATION transaction.
# WHY: the invalidation is sent TODAY (it spends the will's coins so the old
# pre-signed inheritance txs become unusable), so it must pay the CURRENT
# network fee for a quick confirmation - not ``baltx_fees``, which is a
# deliberately high rate reserved for inheritance txs that may be broadcast
# years from now. 2 blocks is Electrum's own default target (owner decision).
INVALIDATION_FEE_ETA_BLOCKS = 2
# Default fee policy for the invalidation tx, as an Electrum ``FeePolicy``
# descriptor ("<method>:<value>"). The user can change it from the plugin's
# graphical settings, which uses Electrum's standard fee slider (Feerate / ETA /
# Mempool). "eta:2" = fee estimated for confirmation within 2 blocks.
INVALIDATION_FEE_POLICY_DEFAULT = f"eta:{INVALIDATION_FEE_ETA_BLOCKS}"
_logger = get_logger(__name__) _logger = get_logger(__name__)
@@ -68,6 +82,22 @@ class Will:
# build a tree with parent transactions # build a tree with parent transactions
@staticmethod @staticmethod
def add_willtree(will): def add_willtree(will):
"""(Re)build the parent/child links between the will transactions.
Every item gets ``children`` (the will txs spending its outputs) and,
when it spends the output of another will tx, a ``father``.
WHY the reset below: ``father`` is persisted in the wallet (the
``"Father"`` field of ``WillItem.to_dict``) and was only ever set,
never cleared. When the father tx was later removed from the will
(rebuild, merge, replacement) the child kept pointing to a txid that
no longer exists, and ``check_invalidated`` crashed with a
``KeyError`` on that txid (reported on a real wallet: the Check
wizard and the window refresh both failed). The tree is therefore
always recomputed from scratch from the transactions themselves.
"""
for willid in will:
will[willid].father = None
for willid in will: for willid in will:
will[willid].children = Will.get_children(will, willid) will[willid].children = Will.get_children(will, willid)
for child in will[willid].children: for child in will[willid].children:
@@ -438,47 +468,189 @@ class Will:
out[inp.prevout.to_str()] = inp out[inp.prevout.to_str()] = inp
return out return out
@staticmethod
def get_invalidation_fee_rate(
wallet, fallback_fee_per_byte, fee_policy_descriptor=None
):
"""Return ``(fee_rate, source)`` for the on-chain invalidation tx.
``fee_rate`` is in sat/vbyte. ``source`` names where the rate came from:
the ``FeeMethod`` used by the policy (``"feerate"``, ``"eta"``,
``"mempool"``) or ``"fallback"`` when no usable rate was available.
WHY: the invalidation is broadcast NOW, so it pays the current network
fee according to the user's chosen ``FeePolicy`` (default ``"eta:2"``,
i.e. a confirmation within 2 blocks). The will's ``baltx_fees`` is kept
only as a fallback, used when no estimate is available (offline wallet,
estimates not received yet, server error), which is exactly the
behaviour of previous versions.
The rate is computed by Electrum's own ``FeePolicy`` (the module behind
the standard fee slider), so the graphical settings can offer Feerate /
ETA / Mempool. It returns sat/kvbyte and already clamps dynamic
estimates between Electrum's conservative minimum relay fee and its
maximum dynamic fee; a fixed "feerate" policy needs no network at all.
This method never raises.
Args:
wallet: The Electrum wallet (its ``network`` may be ``None``).
fallback_fee_per_byte: Rate (sat/vbyte) used when there is no
usable rate - normally the will's ``baltx_fees``.
fee_policy_descriptor: Electrum fee policy descriptor (e.g.
``"eta:2"``). ``None`` uses ``INVALIDATION_FEE_POLICY_DEFAULT``.
"""
descriptor = fee_policy_descriptor or INVALIDATION_FEE_POLICY_DEFAULT
# FeePolicy.parse handles malformed descriptors internally (it logs a
# warning and falls back to "eta:2"), so this never raises.
policy = FeePolicy(descriptor)
network = getattr(wallet, "network", None) if wallet else None
fee_per_kb = None
try:
fee_per_kb = policy.fee_per_kb(network)
except Exception as e:
_logger.warning(f"invalidation fee: no estimate ({e!r})")
fee_per_kb = None
# Accept only a real positive integer (Electrum's type for sat/kvbyte);
# ``bool`` is excluded explicitly because it is a subclass of ``int``.
if (
isinstance(fee_per_kb, int)
and not isinstance(fee_per_kb, bool)
and fee_per_kb > 0
):
return fee_per_kb / 1000, policy.method.name.lower()
return fallback_fee_per_byte, "fallback"
@staticmethod
def _invalidation_age_key(utxo):
"""Sort key for invalidation coins: smaller = in the wallet longest.
"Oldest" is approximated by the funding transaction's confirmation
height (confirmed coins with the lowest height were received first).
Unconfirmed, local or unknown-height coins have no mined position and
are treated as the newest. Deterministic tie-breakers: position within
the block, then the prevout string.
"""
height = getattr(utxo, "block_height", None)
if isinstance(height, int) and height > 0:
txpos = getattr(utxo, "block_txpos", 0) or 0
return (0, height, txpos, utxo.prevout.to_str())
return (1, 0, 0, utxo.prevout.to_str())
@staticmethod @staticmethod
def invalidate_will(will, wallet, fees_per_byte, history_label=None, def invalidate_will(will, wallet, fees_per_byte, history_label=None,
will_locktime=None): will_locktime=None, fee_policy_descriptor=None):
"""Build the (unsigned) on-chain invalidation transaction.
The transaction spends, per will transaction, only the OLDEST funding
coin (the one that has been in the wallet longest): spending a single
input already makes the pre-signed transaction unspendable, while the
newer coins stay available for the rebuilt (postponed) will. Both VALID
and REPLACED will items are considered, because a REPLACED item's
pre-signed transaction may have been pushed to the will-executors and
may spend different coins. The outputs go back to one of the wallet's
own change addresses. When the chosen coins would not cover the fee
while leaving a change output at or above the dust threshold, additional
(newer) candidate coins are added as fallback inputs; if the change
still cannot clear dust once the coins are exhausted, no invalidation
transaction is returned.
Fee: the CURRENT network fee according to the user's fee policy
(``fee_policy_descriptor``, default ``"eta:2"``; see
:meth:`get_invalidation_fee_rate`). ``fees_per_byte`` (the will's
``baltx_fees``) is only the FALLBACK used when no usable rate is
available. RBF stays enabled, so the fee can still be raised from
Electrum's History ("Increase fee") if the transaction gets stuck.
Returns the ``PartialTransaction``, or ``None`` when there is nothing
to invalidate or the balance does not cover the fee.
"""
_logger.debug("invalidate tx in will module") _logger.debug("invalidate tx in will module")
will_only_valid = Will.only_valid_list(will) # Invalidating only the VALID items is not enough. A will rebuilt after
inputs = Will.get_all_inputs(will_only_valid) # an heir change (or a rebuild) supersedes the previously signed
# transaction: the old item is kept but marked REPLACED, which CLEARS
# VALID (see set_status). That old transaction may have been pushed to
# the will-executors and may spend DIFFERENT coins than the current
# valid one, so its funding UTXOs must be spent too, otherwise a
# will-executor could still broadcast it from a UTXO left unspent.
# REPLACED items therefore join the VALID ones; inputs that are already
# spent are naturally dropped by the wallet-UTXO filter below.
will_to_invalidate = {
wid: wi
for wid, wi in will.items()
if wi.get_status("VALID") or wi.get_status("REPLACED")
}
if history_label is not None and will_locktime is not None: if history_label is not None and will_locktime is not None:
utxos = Util.get_available_utxos(wallet, history_label, will_locktime) utxos = Util.get_available_utxos(wallet, history_label, will_locktime)
else: else:
utxos = wallet.get_utxos() utxos = wallet.get_utxos()
filtered_inputs = []
prevout_to_spend = []
current_height = Util.get_current_height(wallet.network) current_height = Util.get_current_height(wallet.network)
for prevout_str, ws in inputs.items(): utxo_by_prevout = {}
for w in ws:
if w[0] not in filtered_inputs:
filtered_inputs.append(w[0])
if prevout_str not in prevout_to_spend:
prevout_to_spend.append(prevout_str)
balance = 0
utxo_to_spend = []
for utxo in utxos: for utxo in utxos:
if utxo.is_coinbase_output() and utxo.block_height < current_height+100: if utxo.is_coinbase_output() and utxo.block_height < current_height+100:
_logger.debug("is not mature coinbase output") _logger.debug("is not mature coinbase output")
continue continue
utxo_str = utxo.prevout.to_str() utxo_by_prevout[utxo.prevout.to_str()] = utxo
if utxo_str in prevout_to_spend: # Candidate coins: the wallet UTXOs that fund one of the will txs.
balance += utxo.value_sats() candidates = []
utxo_to_spend.append(utxo) seen = set()
for wi in will_to_invalidate.values():
for txin in wi.tx.inputs():
prevout_str = txin.prevout.to_str()
if prevout_str in seen or prevout_str not in utxo_by_prevout:
continue
seen.add(prevout_str)
candidates.append(utxo_by_prevout[prevout_str])
# Spend, per will transaction, only its OLDEST funding coin (the one
# that has been in the wallet longest): spending a single input is
# enough to make the pre-signed transaction unspendable, and the newer
# coins stay available for the rebuilt (postponed) will.
chosen = {} # prevout_str -> UTXO, one (the oldest) per will transaction
for wi in will_to_invalidate.values():
item_candidates = [
utxo_by_prevout[txin.prevout.to_str()]
for txin in wi.tx.inputs()
if txin.prevout.to_str() in utxo_by_prevout
]
if item_candidates:
oldest = min(item_candidates, key=Will._invalidation_age_key)
chosen[oldest.prevout.to_str()] = oldest
utxo_to_spend = list(chosen.values())
balance = sum(u.value_sats() for u in utxo_to_spend)
_logger.debug("utxo to spend: {}".format(utxo_to_spend)) _logger.debug("utxo to spend: {}".format(utxo_to_spend))
if len(utxo_to_spend) > 0: if len(utxo_to_spend) > 0:
change_addresses = wallet.get_change_addresses_for_new_transaction() change_addresses = wallet.get_change_addresses_for_new_transaction()
locktime = current_height
fee_rate, fee_source = Will.get_invalidation_fee_rate(
wallet, fees_per_byte, fee_policy_descriptor
)
_logger.info(
f"invalidation fee: {fee_rate} sat/vbyte ({fee_source})"
)
# The change output must stay at or above the dust threshold,
# otherwise the invalidation transaction would be non-standard and
# could not be relayed.
try:
dust = int(wallet.dust_threshold())
except (TypeError, ValueError):
dust = 0
# Remaining candidate coins, oldest first, for the fee fallback.
remaining = sorted(
(c for c in candidates if c.prevout.to_str() not in chosen),
key=Will._invalidation_age_key,
)
while True:
# The network rate can be fractional (e.g. 1.5 sat/vbyte): round
# the total fee UP to whole satoshis so the output value is an
# integer and the rate is never below the one requested.
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance) out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
out.is_change = True out.is_change = True
locktime = current_height
tx = PartialTransaction.from_io( tx = PartialTransaction.from_io(
utxo_to_spend, [out], locktime=locktime, version=2 utxo_to_spend, [out], locktime=locktime, version=2
) )
tx.set_rbf(True) tx.set_rbf(True)
fee = tx.estimated_size() * fees_per_byte fee = int(math.ceil(tx.estimated_size() * fee_rate))
if balance - fee > 0: if balance - fee >= dust:
out = PartialTxOutput.from_address_and_value( out = PartialTxOutput.from_address_and_value(
change_addresses[0], balance - fee change_addresses[0], balance - fee
) )
@@ -490,9 +662,26 @@ class Will:
_logger.debug(f"invalidation tx: {tx}") _logger.debug(f"invalidation tx: {tx}")
return tx return tx
else: # The selected coins cannot cover the fee while leaving a
_logger.debug(f"balance({balance}) - fee({fee}) <=0") # spendable (non-dust) change: keep spending the next-oldest
pass # will coin until the invalidation is economically viable or no
# coin is left.
if not remaining:
_logger.debug(
"balance({})-fee({})<dust({}), no more will coins".format(
balance, fee, dust
)
)
break
extra = remaining.pop(0)
_logger.debug(
"balance({})-fee({})<dust({}): adding {}".format(
balance, fee, dust, extra.prevout.to_str()
)
)
chosen[extra.prevout.to_str()] = extra
utxo_to_spend = list(chosen.values())
balance += extra.value_sats()
else: else:
_logger.debug("len utxo_to_spend <=0") _logger.debug("len utxo_to_spend <=0")
pass pass
@@ -562,9 +751,15 @@ class Will:
def set_invalidate(wid, will=None): def set_invalidate(wid, will=None):
will = will if will is not None else {} will = will if will is not None else {}
will[wid].set_status("INVALIDATED", True) will[wid].set_status("INVALIDATED", True)
if will[wid].children: children = will[wid].children or []
for c in will[wid].children.items(): # add_willtree builds a LIST of [child_id, input_idx, prevout_idx];
Will.set_invalidate(c[0], will) # a dict keyed by child id is still accepted (older data / tests).
child_ids = children.keys() if isinstance(children, dict) else [
c[0] for c in children
]
for child_id in child_ids:
if child_id in will:
Will.set_invalidate(child_id, will)
@staticmethod @staticmethod
def check_tx_height(tx, wallet): def check_tx_height(tx, wallet):
@@ -575,10 +770,15 @@ class Will:
@staticmethod @staticmethod
def check_invalidated(willtree, utxos_list, wallet): def check_invalidated(willtree, utxos_list, wallet):
for wid, w in willtree.items(): for wid, w in willtree.items():
# Defensive: a father that is not (or no longer) part of the will
# is treated as "no father" instead of raising KeyError, so the
# item's inputs are checked against the wallet UTXOs like any
# root transaction. See add_willtree for the root cause.
father = willtree.get(w.father) if w.father else None
if ( if (
not w.father father is None
or willtree[w.father].get_status("CONFIRMED") or father.get_status("CONFIRMED")
or willtree[w.father].get_status("MEMPOOL") or father.get_status("MEMPOOL")
): ):
for inp in w.tx.inputs(): for inp in w.tx.inputs():
inp_str = Util.utxo_to_str(inp) inp_str = Util.utxo_to_str(inp)
@@ -623,11 +823,19 @@ class Will:
for utxo in all_utxos: for utxo in all_utxos:
wallet_balance += utxo.value_sats() wallet_balance += utxo.value_sats()
if fixed_amount >= wallet_balance: if fixed_amount > 0 and fixed_amount >= wallet_balance:
# Only a REAL fixed amount needs to be funded: with no fixed heirs
# (all percentages) fixed_amount is 0 and ``0 >= 0`` would wrongly
# flag an empty/fully-committed wallet as an amount error.
raise FixedAmountException( raise FixedAmountException(
f"Fixed amount({fixed_amount}) >= {wallet_balance}" f"Fixed amount({fixed_amount}) >= {wallet_balance}"
) )
if perc_amount != 100: if perc_heirs and perc_amount != 100:
# Percentages only have to add up to 100% when the will actually
# uses percentage heirs. A will made ONLY of fixed-amount heirs has
# no percentages, so perc_amount is 0 - which used to raise here and
# left "Checking variables" stuck on "Wait" for every fixed-only
# will (the build normalises the fixed shares anyway).
raise PercAmountException(f"Perc amount({perc_amount}) =! 100%") raise PercAmountException(f"Perc amount({perc_amount}) =! 100%")
for url, wex in willexecutors.items(): for url, wex in willexecutors.items():
@@ -635,7 +843,7 @@ class Will:
if max_fee is not None and int(wex["base_fee"]) > max_fee: if max_fee is not None and int(wex["base_fee"]) > max_fee:
raise WillExecutorFeeTooHighException(wex, max_fee) raise WillExecutorFeeTooHighException(wex, max_fee)
temp_balance = wallet_balance - int(wex["base_fee"]) temp_balance = wallet_balance - int(wex["base_fee"])
if fixed_amount >= temp_balance: if fixed_amount > 0 and fixed_amount >= temp_balance:
raise FixedAmountException( raise FixedAmountException(
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}" f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
) )

View File

@@ -358,7 +358,8 @@ class BalWizardWidget(QWidget):
class BalWizardHeirsWidget(BalWizardWidget): class BalWizardHeirsWidget(BalWizardWidget):
title = N_("Bitcoin After Life Heirs") title = N_("Bitcoin After Life Heirs")
message = N_( message = N_(
"Please add your heirs\n remember that 100% of wallet balance will be spent" "Please add your heirs\n"
" remember that the wallet balance will be spent at 100%"
) )
def get_content(self): def get_content(self):
@@ -690,11 +691,22 @@ class BalBuildWillDialog(BalDialog):
# Manual next-steps hint (Sign / Broadcast) shown to the user after the # Manual next-steps hint (Sign / Broadcast) shown to the user after the
# dialog finishes; None when nothing is left to do. # dialog finishes; None when nothing is left to do.
self._next_steps_hint = None self._next_steps_hint = None
# Set to True by _sync_locktime_to_built_txs when the delivery date was # Set to True by _build_success_report when the rebuild moved the
# automatically anticipated during a rebuild. Used to explain to the # delivery date one day earlier than the previous will. Used both to
# user WHY signing is being requested (otherwise the sign prompt appears # show the "Anticipated" warning on the "Building your will" row and to
# without any reason, as the owner reported). # explain WHY signing is being requested (otherwise the sign prompt
# appears without any reason, as the owner reported).
self._date_was_anticipated = False self._date_was_anticipated = False
# The anticipated locktime (UNIX timestamp) captured by
# _build_success_report, formatted into the "Anticipated - new
# delivery date: ..." warning. None when the last build did not
# anticipate the delivery.
self._anticipated_locktime = None
# The will's minimum locktime captured BEFORE the rebuild (see
# task_phase1). _build_success_report compares it with the rebuilt one
# to tell whether the delivery was anticipated by one day. None when no
# build ran yet.
self._previous_min_locktime = None
# Set to True right after we broadcast an automatic invalidation # Set to True right after we broadcast an automatic invalidation
# transaction (the "postpone" path). On the very next phase-1 re-check # transaction (the "postpone" path). On the very next phase-1 re-check
# Electrum may not have seen the invalidation tx yet, so it would still # Electrum may not have seen the invalidation tx yet, so it would still
@@ -746,6 +758,7 @@ class BalBuildWillDialog(BalDialog):
self.bal_window.willitems, self.bal_window.willitems,
default_value=self.bal_window.date_to_check, default_value=self.bal_window.date_to_check,
), ),
fee_policy_descriptor=self.bal_window.bal_plugin.INVALIDATION_FEE_POLICY.get(),
) )
if tx: if tx:
_logger.debug( _logger.debug(
@@ -787,18 +800,32 @@ class BalBuildWillDialog(BalDialog):
_logger.debug("variables ok") _logger.debug("variables ok")
self.msg_set_status(_("Checking variables"), varrow, _("Ok"), self.COLOR_OK) self.msg_set_status(_("Checking variables"), varrow, _("Ok"), self.COLOR_OK)
except AmountException: except AmountException:
self.msg_set_checking( # The amount check is a WARNING, not a hard failure: the build can
self.msg_warning( # still normalise the shares (see BalWindow.build_inheritance_
# transaction, which shows the same warning and continues). Report
# it on the "Checking variables" row itself, with the warning colour.
# Previously this was routed to msg_set_checking(), i.e. the
# "Checking your will" row, and then immediately overwritten by the
# msg_set_checking() call below - so the warning was lost AND the
# "Checking variables" row stayed stuck on its initial "Wait".
self.msg_set_status(
_("Checking variables"),
varrow,
_( _(
"In the inheritance process, " "In the inheritance process, "
"the entire wallet will always be fully emptied. \n" "the entire wallet will always be fully emptied."
"Your settings require an adjustment of the amounts" ),
) self.COLOR_WARNING,
)
) )
except WillExecutorFeeTooHighException as e: except WillExecutorFeeTooHighException as e:
self.msg_set_checking( # Same reasoning as the AmountException branch above: show the
self.msg_warning(_("Will-executor fee too high: {}").format(e)) # warning on the "Checking variables" row so it is neither lost nor
# left stuck on "Wait".
self.msg_set_status(
_("Checking variables"),
varrow,
_("Will-executor fee too high: {}").format(e),
self.COLOR_WARNING,
) )
self.msg_set_checking() self.msg_set_checking()
@@ -841,6 +868,7 @@ class BalBuildWillDialog(BalDialog):
self.bal_window.willitems, self.bal_window.willitems,
default_value=self.bal_window.date_to_check, default_value=self.bal_window.date_to_check,
), ),
fee_policy_descriptor=self.bal_window.bal_plugin.INVALIDATION_FEE_POLICY.get(),
) )
except NoHeirsException: except NoHeirsException:
_logger.debug("no heirs") _logger.debug("no heirs")
@@ -894,6 +922,18 @@ class BalBuildWillDialog(BalDialog):
if have_to_build: if have_to_build:
self.msg_set_building() self.msg_set_building()
# Capture the delivery date of the CURRENT (previous) will BEFORE
# the rebuild overwrites it, so _build_success_report can tell
# whether the rebuild ANTICIPATED the delivery by one day. The core
# anticipates by moving the rebuilt txs one day EARLIER than the
# previous will that spends the same coins (Will.check_anticipate /
# Will.search_anticipate), so comparing the two is the only reliable
# signal: a relative recipe ("1y"/"30d") is anchored to the built tx,
# which is why comparing the built locktime with the STORED value
# (see _sync_locktime_to_built_txs) misses the anticipation.
self._previous_min_locktime = Will.get_min_locktime(
self.bal_window.willitems
)
try: try:
txs = self.bal_window.build_will() txs = self.bal_window.build_will()
if not txs: if not txs:
@@ -1082,6 +1122,39 @@ class BalBuildWillDialog(BalDialog):
# auto-anticipated) transactions, otherwise the next Check would wrongly # auto-anticipated) transactions, otherwise the next Check would wrongly
# ask to invalidate. See _sync_locktime_to_built_txs for the full why. # ask to invalidate. See _sync_locktime_to_built_txs for the full why.
self._sync_locktime_to_built_txs() self._sync_locktime_to_built_txs()
# Detect an automatic anticipation by comparing the will's delivery date
# BEFORE the rebuild (captured in task_phase1) with the one AFTER it:
# the core anticipates by moving the rebuilt txs one day earlier than
# the previous will that spends the same coins (Will.check_anticipate).
# This is format-agnostic: it works whether the stored recipe is an
# absolute timestamp or a relative "1y"/"30d" recipe (anchoring makes a
# relative recipe unusable for a stored-vs-built comparison).
new_min_locktime = Will.get_min_locktime(self.bal_window.willitems)
previous_min_locktime = self._previous_min_locktime
self._date_was_anticipated = (
previous_min_locktime is not None
and new_min_locktime is not None
and int(new_min_locktime) < int(previous_min_locktime)
)
self._anticipated_locktime = (
int(new_min_locktime) if self._date_was_anticipated else None
)
# Owner request: when the rebuild anticipated the delivery, do NOT show
# a plain "Ok" on the "Building your will" row - show a warning that
# names the anticipation and the NEW delivery date, so the user is told
# the inheritance will be delivered one day earlier. The sign step then
# explains WHY signing is requested on its OWN row ("Signing your
# will"), leaving this warning visible.
if self._anticipated_locktime is not None:
new_date = BalTimestamp._safe_fromtimestamp(
self._anticipated_locktime
).strftime("%Y-%m-%d")
self.msg_set_building(
self.msg_alert(
_("Anticipated - new delivery date: {}").format(new_date)
)
)
else:
self.msg_set_building(self.msg_ok()) self.msg_set_building(self.msg_ok())
# List EACH heir on its OWN line, green + bold (owner request: revert the # List EACH heir on its OWN line, green + bold (owner request: revert the
# one-line "Heirs: a, b, c" form of v0.4.6). Heir names can be long, and # one-line "Heirs: a, b, c" form of v0.4.6). Heir names can be long, and
@@ -1174,10 +1247,10 @@ class BalBuildWillDialog(BalDialog):
f"sync delivery date to built tx locktime: " f"sync delivery date to built tx locktime: "
f"{current} -> {min_locktime}" f"{current} -> {min_locktime}"
) )
# Remember that we anticipated the date, so the later sign # NOTE: this only SYNCS the stored date. Whether the rebuild
# prompt can explain WHY signing is needed # ANTICIPATED the delivery is decided in _build_success_report
# (see on_success_phase1). # by comparing the previous and the rebuilt locktimes (the only
self._date_was_anticipated = True # format-agnostic signal, see there).
# update_setting_widgets stores the value, persists it and # update_setting_widgets stores the value, persists it and
# refreshes the date widgets in all panels/wizard (the .ics # refreshes the date widgets in all panels/wizard (the .ics
# calendar too). # calendar too).
@@ -1621,12 +1694,12 @@ class BalBuildWillDialog(BalDialog):
# If the will was rebuilt with an automatically anticipated # If the will was rebuilt with an automatically anticipated
# delivery date, explain WHY we are now asking to sign: # delivery date, explain WHY we are now asking to sign:
# otherwise the sign prompt appears with no reason (owner # otherwise the sign prompt appears with no reason (owner
# feedback). The note is shown on the "Building your will" # feedback). This note goes on its OWN "Signing your will" row
# row (orange warning) before the modal password prompt # so it does NOT overwrite the "Anticipated - new delivery
# opens, so the user can read it. # date" warning already shown on the "Building your will" row,
# and it is readable before the modal password prompt opens.
if self._date_was_anticipated: if self._date_was_anticipated:
self.msg_set_building( self.msg_set_signing(
"<b>{}</b>".format(
_( _(
"The delivery date was automatically moved " "The delivery date was automatically moved "
"one day earlier so the updated will can " "one day earlier so the updated will can "
@@ -1635,7 +1708,6 @@ class BalBuildWillDialog(BalDialog):
"the change." "the change."
) )
) )
)
password = self.bal_window.get_wallet_password( password = self.bal_window.get_wallet_password(
_("Sign your will"), parent=self _("Sign your will"), parent=self
) )

View File

@@ -14,6 +14,8 @@ One :class:`bal.gui.qt.window.BalWindow` is created per top-level wallet window
and cached in ``self.bal_windows``. and cached in ``self.bal_windows``.
""" """
from electrum.fee_policy import FeeMethod, FeePolicy
from electrum.gui.qt.fee_slider import FeeComboBox, FeeSlider
from electrum.gui.qt.main_window import StatusBarButton from electrum.gui.qt.main_window import StatusBarButton
from electrum.plugin import hook from electrum.plugin import hook
from electrum.util import EventListener, event_listener from electrum.util import EventListener, event_listener
@@ -594,7 +596,9 @@ class Plugin(BalPlugin, EventListener):
lbl_save_history, heir_save_history, help_save_history, lbl_save_history, heir_save_history, help_save_history,
lbl_history_label, edit_history_label, help_history_label, lbl_history_label, edit_history_label, help_history_label,
reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10, reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10,
reset_btn_11, reset_btn_12, reset_btn_auto_sign): reset_btn_11, reset_btn_12, reset_btn_auto_sign,
lbl_invalidation_fee, invalidation_fee_box,
help_invalidation_fee, btn_reset_invalidation_fee):
w.setVisible(not basic) w.setVisible(not basic)
# Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on # Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on
# a real USER TYPE change (not inside update_all/CHECK), so pressing # a real USER TYPE change (not inside update_all/CHECK), so pressing
@@ -911,6 +915,78 @@ class Plugin(BalPlugin, EventListener):
) )
grid.addWidget(reset_btn_auto_rebuild, 15, 3) grid.addWidget(reset_btn_auto_rebuild, 15, 3)
# ----------------------------------------------------------------- #
# Invalidation fee policy (ADVANCED only): which fee rate the on-chain #
# invalidation transaction pays. It reuses Electrum's standard fee #
# widget (the same Feerate / ETA / Mempool slider used in the send #
# tab), so the user picks a fixed rate, a block target or a mempool #
# depth. The chosen policy is persisted as an Electrum "FeePolicy" #
# descriptor ("<method>:<value>"). #
# ----------------------------------------------------------------- #
invalidation_fee_policy = FeePolicy(self.INVALIDATION_FEE_POLICY.get())
settings_network = (
getattr(window, "network", None) if window is not None else None
)
def on_invalidation_fee_change(_fee_rate):
"""Persist the fee policy descriptor chosen with the slider."""
self.INVALIDATION_FEE_POLICY.set(
invalidation_fee_policy.get_descriptor()
)
invalidation_fee_slider = FeeSlider(
parent=d,
network=settings_network,
fee_policy=invalidation_fee_policy,
callback=on_invalidation_fee_change,
)
invalidation_fee_combo = FeeComboBox(invalidation_fee_slider)
# Do NOT call invalidation_fee_slider.update() here: the slider and combo
# already initialise themselves before their signals are connected, and
# update() fires moved() -> on_invalidation_fee_change, which would
# persist (and snap) the descriptor just for opening the dialog.
invalidation_fee_box = QWidget()
invalidation_fee_box_layout = QHBoxLayout(invalidation_fee_box)
invalidation_fee_box_layout.setContentsMargins(0, 0, 0, 0)
invalidation_fee_box_layout.addWidget(invalidation_fee_slider, 1)
invalidation_fee_box_layout.addWidget(invalidation_fee_combo)
def reset_invalidation_fee():
"""Restore the fee policy to its default ("eta:2").
The live ``FeePolicy`` object is mutated in place (method + value)
so the slider keeps working with the new defaults; the stored
descriptor and the combo are then re-synced.
"""
default_policy = FeePolicy(self.INVALIDATION_FEE_POLICY.default)
invalidation_fee_policy.method = default_policy.method
invalidation_fee_policy.value = default_policy.value
self.INVALIDATION_FEE_POLICY.set(self.INVALIDATION_FEE_POLICY.default)
invalidation_fee_combo.setCurrentIndex(
FeeMethod.slider_index_of_method(default_policy.method)
)
invalidation_fee_slider.update()
lbl_invalidation_fee = QLabel(_("Invalidation fee policy"))
help_invalidation_fee = HelpButton(_(
"Fee rate the on-chain invalidation transaction pays.\n"
"The invalidation is sent TODAY (it spends the will's coins so the "
"old inheritance transactions become unusable), so it should pay "
"the current network fee - not the will's mining fee rate.\n\n"
"Feerate: a fixed rate (sat/vByte).\n"
"ETA: fee based on the estimated confirmation time (blocks).\n"
"Mempool: fee targeting a depth in the memory pool.\n"
"Only used in ADVANCED mode."
))
btn_reset_invalidation_fee = QPushButton("\u21ba")
btn_reset_invalidation_fee.setFixedWidth(24)
btn_reset_invalidation_fee.setToolTip(_("Reset to default"))
btn_reset_invalidation_fee.clicked.connect(reset_invalidation_fee)
grid.addWidget(_hide_if_basic(lbl_invalidation_fee), 16, 0)
grid.addWidget(_hide_if_basic(invalidation_fee_box), 16, 1)
grid.addWidget(_hide_if_basic(help_invalidation_fee), 16, 2)
grid.addWidget(_hide_if_basic(btn_reset_invalidation_fee), 16, 3)
# ----------------------------------------------------------------- # # ----------------------------------------------------------------- #
# Group C / C4b: "Reset" button that restores the dialog settings to # # Group C / C4b: "Reset" button that restores the dialog settings to #
# their factory defaults. It only resets the settings exposed by THIS # # their factory defaults. It only resets the settings exposed by THIS #
@@ -968,6 +1044,8 @@ class Plugin(BalPlugin, EventListener):
# reset restores SAVE_HISTORY to its default, so the field must # reset restores SAVE_HISTORY to its default, so the field must
# follow the (default) checkbox state again. # follow the (default) checkbox state again.
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get())) edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
# Reset the invalidation fee policy to its default ("eta:2") too.
reset_invalidation_fee()
# Refresh the open BAL windows so any dependent view (e.g. the # Refresh the open BAL windows so any dependent view (e.g. the
# editable-dates state is not in this list, but hide filters are) # editable-dates state is not in this list, but hide filters are)
# reflects the reset values. # reflects the reset values.

View File

@@ -835,11 +835,11 @@ class BalWindow:
self.window.wallet.dust_threshold(), self.window.wallet.dust_threshold(),
max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(), max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
) )
except AmountException as e: except AmountException:
self.show_warning( self.show_warning(
_( _(
"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{}" "In the inheritance process, the entire wallet will always be fully emptied."
).format(e) )
) )
except WillExecutorFeeTooHighException as e: except WillExecutorFeeTooHighException as e:
self.show_error( self.show_error(
@@ -1089,6 +1089,7 @@ class BalWindow:
will_locktime=Will.get_min_locktime( will_locktime=Will.get_min_locktime(
willitems, default_value=self.date_to_check willitems, default_value=self.date_to_check
), ),
fee_policy_descriptor=self.bal_plugin.INVALIDATION_FEE_POLICY.get(),
) )
msg = _("Calculating Transactions") msg = _("Calculating Transactions")
self.waiting_dialog = BalWaitingDialog( self.waiting_dialog = BalWaitingDialog(
@@ -1424,6 +1425,7 @@ class BalWindow:
willitems, willitems,
default_value=getattr(self, "date_to_check", None), default_value=getattr(self, "date_to_check", None),
), ),
fee_policy_descriptor=self.bal_plugin.INVALIDATION_FEE_POLICY.get(),
) )
except Exception as e: except Exception as e:
_logger.error("auto invalidate: could not build tx: {}".format(e)) _logger.error("auto invalidate: could not build tx: {}".format(e))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -25,9 +25,14 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum.simple_config import SimpleConfig from electrum.simple_config import SimpleConfig
from electrum.util import UserFacingException from electrum.util import UserFacingException
from bal.cli.controller import BalController from bal.cli.controller import BalController, _user_facing
from bal.core.heirs import Heirs from bal.core.heirs import Heirs
from bal.core.util import Util from bal.core.util import Util
from bal.core.will import (
HeirChangeException,
NotCompleteWillException,
WillexecutorChangeException,
)
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf" VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
@@ -102,6 +107,13 @@ def _make_controller(plugin):
return BalController(plugin, FakeWallet()) return BalController(plugin, FakeWallet())
def _tmp_path():
"""Return the path of a fresh temporary JSON file (caller removes it)."""
fd, path = tempfile.mkstemp(prefix="bal_cli_", suffix=".json")
os.close(fd)
return path
def test_controller_init_empty(): def test_controller_init_empty():
with Plugin() as plugin: with Plugin() as plugin:
c = _make_controller(plugin) c = _make_controller(plugin)
@@ -194,6 +206,179 @@ def test_willexecutors_crud():
assert renamed not in c.willexecutors assert renamed not in c.willexecutors
def test_willexecutors_update_accepts_promo_code():
"""Regression: the command transport used to forward six positional args to
a five-parameter controller method, raising TypeError. ``promo_code`` must
also be applied to the record instead of being silently dropped.
"""
with Plugin() as plugin:
c = _make_controller(plugin)
url = "https://executor-promo.example.invalid"
c.willexecutors_add(url, address="", base_fee=250)
# Positional call mirroring the transport layer: (url, address,
# base_fee, info, promo_code, rename_to).
result = c.willexecutors_update(
url, "", "300", "Promo executor", "PROMO10", None
)
assert result["willexecutor"]["base_fee"] == 300
assert result["willexecutor"]["info"] == "Promo executor"
assert c.willexecutors[url]["promo_code"] == "PROMO10"
def test_heirs_locktime_forced_when_multiverse_disabled():
"""When multiverse is disabled the heir locktime follows the will locktime,
exactly like the GUI's ``set_heir`` (which forces ``heir[3]``)."""
with Plugin() as plugin:
plugin.WILL_SETTINGS.set(
{"threshold": "-150d", "locktime": "2y", "baltx_fees": 20}
)
plugin.ENABLE_MULTIVERSE.set(False)
c = _make_controller(plugin)
c.heirs_add("bob", VALID_ADDRESS, "100000", locktime="30d")
assert c.heirs["bob"][2] == "2y"
def test_heirs_locktime_kept_when_multiverse_enabled():
with Plugin() as plugin:
plugin.WILL_SETTINGS.set({"threshold": "-150d", "locktime": "2y", "baltx_fees": 20})
plugin.ENABLE_MULTIVERSE.set(True)
c = _make_controller(plugin)
c.heirs_add("carol", VALID_ADDRESS, "100000", locktime="30d")
assert c.heirs["carol"][2] == "30d"
def test_user_facing_maps_not_complete_will():
"""The check flow must translate every NotCompleteWillException subtype to a
user-facing message instead of leaking the raw internal exception.
"""
assert "heirs changed" in str(_user_facing(HeirChangeException())).lower()
assert "will-executor changed" in str(
_user_facing(WillexecutorChangeException())
).lower()
assert "no valid transaction" in str(
_user_facing(NotCompleteWillException())
).lower()
assert "does not cover" in str(
_user_facing(NotCompleteWillException("utxo"))
).lower()
def test_willexecutors_delete_missing_is_noop():
with Plugin() as plugin:
c = _make_controller(plugin)
assert c.willexecutors_delete(["https://missing.example.invalid"]) == {
"deleted": []
}
def test_willexecutors_select_only_valid():
with Plugin() as plugin:
c = _make_controller(plugin)
valid_url = "https://valid.example.invalid"
invalid_url = "https://invalid.example.invalid"
# Valid: real address, base_fee within [dust, max_fee].
c.willexecutors_add(valid_url, address=VALID_ADDRESS, base_fee=1000)
# Invalid: no address.
c.willexecutors_add(invalid_url, address="", base_fee=1000)
c.willexecutors_select(None, select=True, only_valid=True)
assert c.willexecutors[valid_url]["selected"] is True
assert c.willexecutors[invalid_url]["selected"] is False
# Select everything, then deselect only-valid: the valid one keeps its
# state, only the invalid one is cleared.
c.willexecutors_select(None, select=True)
c.willexecutors_select(None, select=False, only_valid=True)
assert c.willexecutors[valid_url]["selected"] is True
assert c.willexecutors[invalid_url]["selected"] is False
def test_willexecutors_import_overwrites_existing():
import json
with Plugin() as plugin:
c = _make_controller(plugin)
url = "https://existing.example.invalid"
c.willexecutors_add(url, address="", base_fee=100, info="Old")
path = _tmp_path()
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(
{
url: {
"address": "",
"base_fee": 999,
"info": "New",
"selected": True,
}
},
f,
)
result = c.willexecutors_import(path)
assert result["imported"] == 1
# The imported record replaces the existing one (GUI behaviour).
assert c.willexecutors[url]["base_fee"] == 999
assert c.willexecutors[url]["info"] == "New"
assert c.willexecutors[url]["selected"] is True
finally:
os.remove(path)
def test_willexecutors_export_subsets():
import json
with Plugin() as plugin:
c = _make_controller(plugin)
selected_url = "https://selected.example.invalid"
other_url = "https://other.example.invalid"
invalid_url = "https://invalid.example.invalid"
c.willexecutors_add(selected_url, address=VALID_ADDRESS, base_fee=1000)
c.willexecutors_add(other_url, address=VALID_ADDRESS, base_fee=1000)
c.willexecutors_add(invalid_url, address="", base_fee=1000)
c.willexecutors_select([selected_url], select=True)
def exported(subset):
path = _tmp_path()
try:
c.willexecutors_export(path, subset)
with open(path, encoding="utf-8") as f:
return json.load(f)
finally:
os.remove(path)
assert set(exported(None)) == {selected_url, other_url, invalid_url}
assert set(exported("selected")) == {selected_url}
# "valid" keeps both real-address records and drops the address-less one.
assert set(exported("valid")) == {selected_url, other_url}
def test_user_facing_maps_no_servers_for_chain():
from bal.core.willexecutors import Willexecutors
advanced = Willexecutors.NoServersForChainError(
"bitcoin", url="https://welist.example/", reason="boom"
)
assert "https://welist.example/" in str(_user_facing(advanced))
basic = Willexecutors.NoServersForChainError("bitcoin")
assert "bitcoin" in str(_user_facing(basic))
def test_prepare_will_warns_on_amount_exception():
from bal.core import will as will_mod
with Plugin() as plugin:
plugin.NO_WILLEXECUTOR.set(True)
c = _make_controller(plugin)
c.heirs["alice"] = [VALID_ADDRESS, "100%", "2y"]
with mock.patch.object(
will_mod.Will,
"check_amounts",
side_effect=will_mod.FixedAmountException("too much"),
), mock.patch.object(will_mod.Will, "is_will_valid", return_value=True):
result = c.prepare_will()
# The amount mismatch is a warning, not an abort: the flow completes.
assert result["result"] == "coherent"
def test_will_status_empty(): def test_will_status_empty():
with Plugin() as plugin: with Plugin() as plugin:
c = _make_controller(plugin) c = _make_controller(plugin)

View File

@@ -0,0 +1,296 @@
"""
Tests for the fee of the on-chain INVALIDATION transaction.
The invalidation is broadcast TODAY (it spends the will's coins so the old
pre-signed inheritance transactions become unusable), therefore it must pay the
CURRENT network fee chosen with an Electrum ``FeePolicy`` (Feerate / ETA /
Mempool; default ``"eta:2"``, i.e. confirm within 2 blocks). The will's
``baltx_fees`` (a deliberately high rate meant for inheritance transactions
broadcast years from now) is only the FALLBACK used when no usable rate is
available.
Covers:
* Will.get_invalidation_fee_rate() - rate selection and fallback rules
* Will.invalidate_will() - the chosen rate is really used for the fee
Run (standalone or with pytest):
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 tests/test_core_invalidation_fee.py
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_core_invalidation_fee.py -q
"""
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum.fee_policy import FeePolicy, FeeTimeEstimates
from electrum.transaction import Transaction
from bal.core.will import (
INVALIDATION_FEE_ETA_BLOCKS,
INVALIDATION_FEE_POLICY_DEFAULT,
Will,
WillItem,
)
# WillItem parses a raw tx and calls add_info_from_wallet; no live wallet here.
_patcher = patch.object(Transaction, "add_info_from_wallet")
_patcher.start()
# A valid serialized transaction (1 input, 1 output) reused by the core tests.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
# The outpoint spent by _VALID_TX_HEX (input 0).
_PREVOUT_STR = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0"
_CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
_BALANCE = 1000000
# The will's baltx_fees used as the fallback rate in every test (sat/vbyte).
_BALTX_FEES = 20
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _wallet_with_eta_estimate(fee_per_kb):
"""Wallet whose ETA fee lookup returns ``fee_per_kb`` (sat/kvbyte).
Electrum's ``FeePolicy`` (method ``eta``) resolves the target through
``network.fee_estimates.eta_to_fee``. ``fee_per_kb`` may also be an
Exception instance: the call then raises it (broken network object), or a
non-int-convertible object to exercise the invalid-value path.
"""
wallet = MagicMock()
wallet.get_change_addresses_for_new_transaction.return_value = [_CHANGE_ADDR]
network = MagicMock()
if isinstance(fee_per_kb, Exception):
network.fee_estimates.eta_to_fee.side_effect = fee_per_kb
else:
network.fee_estimates.eta_to_fee.return_value = fee_per_kb
wallet.network = network
return wallet
def _make_will():
"""One VALID will item spending _PREVOUT_STR with a known input value."""
item = WillItem({
"tx": _VALID_TX_HEX,
"heirs": {"alice": ["addr_alice", 5000, "30d"]},
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": _BALTX_FEES,
})
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
item.tx.inputs()[0]._TxInput__value_sats = _BALANCE
return {"willtxid1": item}
def _make_utxo():
"""Wallet-side UTXO matching the will input."""
utxo = MagicMock()
utxo.prevout.to_str.return_value = _PREVOUT_STR
utxo.is_coinbase_output.return_value = False
utxo.block_height = 1
utxo.value_sats.return_value = _BALANCE
return utxo
def _invalidation_output_value(wallet, estimated_size=200, descriptor=None):
"""Run Will.invalidate_will and return the value of the final output.
Electrum's tx building is mocked (as in test_core_will_invalidate.py):
``estimated_size`` is the vsize the fee is computed from.
"""
wallet.get_utxos.return_value = [_make_utxo()]
mock_output = MagicMock()
mock_tx = MagicMock()
mock_tx.estimated_size.return_value = estimated_size
with patch("bal.core.will.Util.get_current_height", return_value=800000), \
patch("electrum.transaction.PartialTxOutput.from_address_and_value",
return_value=mock_output) as mock_out, \
patch("electrum.transaction.PartialTransaction.from_io",
return_value=mock_tx):
result = Will.invalidate_will(
_make_will(), wallet, _BALTX_FEES,
fee_policy_descriptor=descriptor,
)
assert result is mock_tx
# 1st call: full balance (size estimation); 2nd call: balance - fee.
return mock_out.call_args_list[1][0][1]
# ------------------------------------------------------------------ #
# Will.get_invalidation_fee_rate - policy selection
# ------------------------------------------------------------------ #
def test_default_policy_is_two_block_eta():
"""The owner's decision: default fee policy confirms within 2 blocks.
Uses a REAL ``FeeTimeEstimates`` holding data only for the 2-block target,
proving the default policy really asks Electrum for target 2.
"""
assert INVALIDATION_FEE_ETA_BLOCKS == 2
assert INVALIDATION_FEE_POLICY_DEFAULT == "eta:2"
network = MagicMock()
estimates = FeeTimeEstimates()
estimates.set_data(2, 3000)
network.fee_estimates = estimates
wallet = MagicMock()
wallet.network = network
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (3.0, "eta")
def test_default_policy_descriptor_is_parsed_by_electrum_fee_policy():
"""The setting is a real Electrum FeePolicy descriptor."""
policy = FeePolicy(INVALIDATION_FEE_POLICY_DEFAULT)
assert policy.method.name.lower() == "eta"
assert policy.value == 2
def test_network_estimate_is_used():
"""A live estimate (sat/kvbyte) becomes the rate in sat/vbyte."""
wallet = _wallet_with_eta_estimate(3000)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (3.0, "eta")
def test_network_estimate_higher_than_baltx_fees_is_used_as_is():
"""No cap: if the market is above baltx_fees, the market rate is used."""
wallet = _wallet_with_eta_estimate(45000)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (45.0, "eta")
def test_fractional_estimate_is_kept():
"""Electrum estimates may be fractional (e.g. 1.5 sat/vbyte)."""
wallet = _wallet_with_eta_estimate(1500)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (1.5, "eta")
def test_fixed_feerate_policy_works_offline():
"""A fixed 'feerate' policy needs no network at all."""
wallet = MagicMock()
wallet.network = None
assert Will.get_invalidation_fee_rate(
wallet, _BALTX_FEES, "feerate:5000"
) == (5.0, "feerate")
def test_mempool_policy_uses_depth_estimate():
"""A 'mempool' policy asks Electrum for a depth-based estimate."""
wallet = MagicMock()
network = MagicMock()
network.mempool_fees.depth_to_fee.return_value = 3000
wallet.network = network
assert Will.get_invalidation_fee_rate(
wallet, _BALTX_FEES, "mempool:1000000"
) == (3.0, "mempool")
def test_offline_wallet_falls_back_to_baltx_fees():
"""No network object (offline) with a dynamic policy: baltx_fees."""
wallet = MagicMock()
wallet.network = None
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
)
def test_missing_estimate_falls_back():
"""Estimates not received yet: Electrum returns None."""
wallet = _wallet_with_eta_estimate(None)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
)
def test_estimate_error_falls_back_and_does_not_raise():
"""A broken network object must never break the invalidation."""
wallet = _wallet_with_eta_estimate(RuntimeError("server gone"))
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
)
def test_non_positive_or_unconvertible_estimates_fall_back():
"""Only a positive integer rate is trusted."""
for bad in (0, -1000, object()):
wallet = _wallet_with_eta_estimate(bad)
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
), bad
def test_invalid_policy_descriptor_uses_the_default():
"""A corrupt stored descriptor must not break anything: default is used."""
wallet = _wallet_with_eta_estimate(3000)
assert Will.get_invalidation_fee_rate(
wallet, _BALTX_FEES, "not-a-policy"
) == (3.0, "eta")
def test_no_wallet_falls_back():
assert Will.get_invalidation_fee_rate(None, _BALTX_FEES) == (
_BALTX_FEES,
"fallback",
)
# ------------------------------------------------------------------ #
# Will.invalidate_will uses the chosen rate
# ------------------------------------------------------------------ #
def test_invalidation_pays_network_fee_not_baltx_fees():
"""3 sat/vbyte on 200 vbytes = 600 sat (not 200 * 20 = 4000 sat)."""
wallet = _wallet_with_eta_estimate(3000)
assert _invalidation_output_value(wallet) == _BALANCE - 600
def test_invalidation_offline_pays_baltx_fees():
"""Offline: unchanged behaviour, 200 * 20 = 4000 sat."""
wallet = _wallet_with_eta_estimate(None)
assert _invalidation_output_value(wallet) == _BALANCE - 4000
def test_invalidation_uses_fixed_feerate_policy_offline():
"""A fixed feerate pays exactly that rate even without a network."""
wallet = MagicMock()
wallet.network = None
assert _invalidation_output_value(
wallet, descriptor="feerate:5000"
) == _BALANCE - 1000 # 200 vbytes * 5 sat/vbyte
def test_fractional_fee_is_rounded_up_to_whole_satoshis():
"""1.5 sat/vbyte on 201 vbytes = 301.5 -> 302 sat (never below the rate)."""
wallet = _wallet_with_eta_estimate(1500)
value = _invalidation_output_value(wallet, estimated_size=201)
assert value == _BALANCE - 302
assert isinstance(value, int)
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All invalidation fee tests passed")

View File

@@ -47,6 +47,24 @@ _VALID_TX_HEX = (
# The prevout string that _VALID_TX_HEX spends (input 0). # The prevout string that _VALID_TX_HEX spends (input 0).
_PREVOUT_STR = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0" _PREVOUT_STR = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0"
# A second transaction identical to _VALID_TX_HEX but spending a DIFFERENT
# prevout. It stands for a superseded (REPLACED) will transaction that commits
# different coins than the current valid one.
_REPLACED_TX_HEX = "0100000001ff" + _VALID_TX_HEX[len("01000000012a"):]
_REPLACED_PREVOUT_STR = _PREVOUT_STR[:-4] + "ff:0"
# A 2-input transaction: version 2 with two parallel inputs. Built from
# _VALID_TX_HEX by duplicating its (298-hex) input with a DIFFERENT prevout
# (first byte "ff", so the second input spends _REPLACED_PREVOUT_STR) and
# keeping the unchanged outputs.
_INPUT_LEN = 298
_TWO_INPUT_TX_HEX = (
"0100000002"
+ _VALID_TX_HEX[10:10 + _INPUT_LEN]
+ "ff" + _VALID_TX_HEX[10 + 2:10 + _INPUT_LEN]
+ _VALID_TX_HEX[10 + _INPUT_LEN:]
)
# Change address for the invalidation output. # Change address for the invalidation output.
_CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs" _CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
@@ -55,8 +73,9 @@ _CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
# Helpers # Helpers
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None): def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None,
"""Create a WillItem from _VALID_TX_HEX with a known input value. tx_hex=_VALID_TX_HEX):
"""Create a WillItem from ``tx_hex`` with a known input value.
The input's ``_trusted_value_sats`` is set so that The input's ``_trusted_value_sats`` is set so that
``invalidate_will`` can read the balance from it. ``invalidate_will`` can read the balance from it.
@@ -65,7 +84,7 @@ def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None):
if extra_heirs: if extra_heirs:
heirs.update(extra_heirs) heirs.update(extra_heirs)
item = WillItem({ item = WillItem({
"tx": _VALID_TX_HEX, "tx": tx_hex,
"heirs": heirs, "heirs": heirs,
"willexecutor": None, "willexecutor": None,
"status": "", "status": "",
@@ -93,16 +112,24 @@ def _make_utxo(prevout_str=None, value_sats=1000000, is_coinbase=False):
utxo.prevout.to_str.return_value = prevout_str utxo.prevout.to_str.return_value = prevout_str
utxo.is_coinbase_output.return_value = is_coinbase utxo.is_coinbase_output.return_value = is_coinbase
utxo.block_height = 1 utxo.block_height = 1
utxo.block_txpos = 0
utxo.value_sats.return_value = value_sats utxo.value_sats.return_value = value_sats
return utxo return utxo
def _mock_wallet(utxos, change_addr=_CHANGE_ADDR): def _mock_wallet(utxos, change_addr=_CHANGE_ADDR):
"""Create a mock wallet with the given UTXOs and change address.""" """Create a mock wallet with the given UTXOs and change address.
``network`` is left ``None`` so ``Will.invalidate_will`` uses the
``fees_per_byte`` FALLBACK instead of Electrum's live estimate: these tests
exercise the pure fee arithmetic (``fee = size * fees_per_byte``). The
policy-based rate selection has its own tests in
``tests/test_core_invalidation_fee.py``.
"""
wallet = MagicMock() wallet = MagicMock()
wallet.get_utxos.return_value = utxos wallet.get_utxos.return_value = utxos
wallet.get_change_addresses_for_new_transaction.return_value = [change_addr] wallet.get_change_addresses_for_new_transaction.return_value = [change_addr]
wallet.network = MagicMock() wallet.network = None
return wallet return wallet
@@ -292,6 +319,77 @@ class TestInvalidateWill:
second_call_value = mock_out.call_args_list[1][0][1] second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 998000 assert second_call_value == 998000
def test_replaced_item_inputs_are_invalidated(self):
"""A REPLACED (superseded) item's funding UTXO is also spent.
Regression: a will rebuilt after an heir change supersedes the old
signed transaction, which is kept but marked REPLACED (VALID cleared).
The old transaction may have been pushed to the will-executors and may
spend DIFFERENT coins than the current valid one, so its UTXO must be
included in the invalidation. Otherwise it would stay unspent and a
will-executor could still broadcast the superseded transaction.
"""
valid_item = _make_willitem(value_sats=1000000)
replaced_item = _make_willitem(
value_sats=2000000, tx_hex=_REPLACED_TX_HEX
)
replaced_item.set_status("REPLACED", True)
assert replaced_item.get_status("VALID") is False
will = {"valid": valid_item, "replaced": replaced_item}
wallet = _mock_wallet([
_make_utxo(),
_make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000),
])
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert _PREVOUT_STR in spent
assert _REPLACED_PREVOUT_STR in spent
def test_replaced_item_contributes_balance(self):
"""The balance of a REPLACED item is included in the invalidation."""
valid_item = _make_willitem(value_sats=1000000)
replaced_item = _make_willitem(
value_sats=2000000, tx_hex=_REPLACED_TX_HEX
)
replaced_item.set_status("REPLACED", True)
will = {"valid": valid_item, "replaced": replaced_item}
wallet = _mock_wallet([
_make_utxo(),
_make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000),
])
_, _, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
# Balance = 1000000 + 2000000 = 3000000, fee = 200 * 10 = 2000
second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 2998000
def test_invalidated_item_inputs_are_not_invalidated(self):
"""A purely INVALIDATED item (already neutralised) is still excluded."""
valid_item = _make_willitem(value_sats=1000000)
invalid_item = _make_willitem(
value_sats=2000000, tx_hex=_REPLACED_TX_HEX, valid=False
)
assert invalid_item.get_status("INVALIDATED") is True
will = {"valid": valid_item, "invalid": invalid_item}
wallet = _mock_wallet([
_make_utxo(),
_make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000),
])
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert _PREVOUT_STR in spent
assert _REPLACED_PREVOUT_STR not in spent
def test_first_from_io_uses_full_balance(self): def test_first_from_io_uses_full_balance(self):
"""The first from_io call uses the full balance (before fee deduction) """The first from_io call uses the full balance (before fee deduction)
to estimate the fee.""" to estimate the fee."""
@@ -353,6 +451,152 @@ class TestInvalidateWill:
second_call_value = mock_out.call_args_list[1][0][1] second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 1000000 assert second_call_value == 1000000
def test_invalidation_spends_oldest_input_per_tx(self):
"""For each will tx, only its OLDEST funding coin is spent.
A will item whose transaction has two inputs (old and new coin) must
spend only the coin that has been in the wallet longest, leaving the
newer coin unspent for the rebuilt (postponed) will.
"""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
old_utxo = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000000)
old_utxo.block_height = 1
new_utxo = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000)
new_utxo.block_height = 500 # newer than the old coin
wallet = _mock_wallet([old_utxo, new_utxo])
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert spent == [_PREVOUT_STR]
def test_invalidation_fallback_appends_next_oldest(self):
"""If the oldest coin cannot cover the fee, the next-oldest is appended.
The invalidation must not be dropped just because the single oldest
coin is small: it keeps adding will coins, oldest first, until the
balance covers the estimated fee.
"""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
old_utxo = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000)
old_utxo.block_height = 1
new_utxo = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=1000000)
new_utxo.block_height = 500
wallet = _mock_wallet([old_utxo, new_utxo])
# estimated_size (200) * fees_per_byte (10) = 2000 > balance (1000)
result, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert _PREVOUT_STR in spent
assert _REPLACED_PREVOUT_STR in spent
# balance = 1000 + 1000000, fee = 2000
final_value = mock_out.call_args[0][1]
assert final_value == 999000
def test_invalidation_treats_unconfirmed_as_newest(self):
"""An unconfirmed (unknown-height) coin is never chosen as the oldest."""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
confirmed = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000000)
confirmed.block_height = 10
unconfirmed = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000)
unconfirmed.block_height = None
wallet = _mock_wallet([unconfirmed, confirmed])
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert spent == [_PREVOUT_STR]
def test_invalidation_same_height_order_is_deterministic(self):
"""Coins at the same height are ordered by block position, not list order."""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
later = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000000)
later.block_height = 10
later.block_txpos = 5
earlier = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2000000)
earlier.block_height = 10
earlier.block_txpos = 1
wallet = _mock_wallet([later, earlier])
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
spent = [u.prevout.to_str() for u in mock_from_io.call_args[0][0]]
assert spent == [_REPLACED_PREVOUT_STR] # block_txpos 1 < 5 => older
def test_sub_dust_change_returns_none(self):
"""A change output below the dust limit is never created.
Regression: previously any positive change (balance - fee > 0) was
accepted, which could produce a non-standard (unrelayable) output and
leave the will un-invalidated.
"""
item = _make_willitem(value_sats=2200)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo(value_sats=2200)])
wallet.dust_threshold.return_value = 546
# fee = estimated_size (200) * fees_per_byte (10) = 2000;
# balance - fee = 200 < dust (546) -> no transaction.
result, _, _ = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is None
def test_change_equal_to_dust_is_allowed(self):
"""Change exactly at the dust threshold is accepted (>= comparison)."""
item = _make_willitem(value_sats=2546)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo(value_sats=2546)])
wallet.dust_threshold.return_value = 546
# balance - fee = 2546 - 2000 = 546 == dust -> transaction.
result, _, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is not None
assert mock_out.call_args_list[1][0][1] == 546
def test_fallback_stops_when_change_still_dust(self):
"""Coins are appended until the change clears dust; else returns None."""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
old_utxo = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000)
old_utxo.block_height = 1
new_utxo = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=1200)
new_utxo.block_height = 500
wallet = _mock_wallet([old_utxo, new_utxo])
wallet.dust_threshold.return_value = 546
# fee = 2000; total balance = 1000 + 1200 = 2200;
# change = 200 < dust (546) and no coin is left -> None.
result, _, _ = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is None
def test_fallback_succeeds_once_change_clears_dust(self):
"""Appending the next coin yields a change above dust -> transaction."""
item = _make_willitem(value_sats=1000000, tx_hex=_TWO_INPUT_TX_HEX)
will = {"willtxid1": item}
old_utxo = _make_utxo(prevout_str=_PREVOUT_STR, value_sats=1000)
old_utxo.block_height = 1
new_utxo = _make_utxo(prevout_str=_REPLACED_PREVOUT_STR, value_sats=2546)
new_utxo.block_height = 500
wallet = _mock_wallet([old_utxo, new_utxo])
wallet.dust_threshold.return_value = 546
# fee = 2000; total balance = 3546; change = 1546 >= dust -> transaction.
result, _, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is not None
assert mock_out.call_args_list[-1][0][1] == 1546
# ================================================================== # # ================================================================== #
# Will.set_invalidate - status flag cascade # Will.set_invalidate - status flag cascade

View File

@@ -0,0 +1,145 @@
"""
Tests for the parent/child tree of the will transactions (``father`` links).
Bug reproduced from a real wallet (Electrum log, Check wizard):
File "bal/core/will.py", in check_invalidated
or willtree[w.father].get_status("CONFIRMED")
KeyError: 'a65a682f…'
``father`` is persisted in the wallet (``"Father"``) and was never cleared, so
after the father tx was removed from the will the child pointed to a txid that
no longer exists. ``Will.add_willtree`` now rebuilds the links from scratch and
``Will.check_invalidated`` treats a missing father as "no father".
Run (standalone or with pytest):
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 tests/test_core_willtree_father.py
"""
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum.transaction import Transaction
from bal.core.will import Will, WillItem
# WillItem parses a raw tx and calls add_info_from_wallet; no live wallet here.
_patcher = patch.object(Transaction, "add_info_from_wallet")
_patcher.start()
# A valid serialized transaction (1 input, 1 output) reused by the core tests.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
# The input of _VALID_TX_HEX spends this outpoint (little-endian in the hex).
_FUNDING_ID = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a"
# PARENT: _VALID_TX_HEX itself. CHILD: the same tx with its input re-pointed to
# the parent's txid, so the child spends the parent's output 0.
_PARENT_ID = Transaction(_VALID_TX_HEX).txid()
_CHILD_TX_HEX = _VALID_TX_HEX.replace(
bytes.fromhex(_FUNDING_ID)[::-1].hex(), bytes.fromhex(_PARENT_ID)[::-1].hex()
)
# A txid that is not in the will (the "removed father" of the bug report).
_MISSING_ID = "a65a682f2975f0dffd9eccce9392833f227badf617fc67815674736d594d4115"
def _item(tx_hex=_VALID_TX_HEX, father=None):
"""WillItem built from ``tx_hex``, optionally with a stored father."""
data = {"tx": tx_hex, "heirs": {}, "willexecutor": None}
if father is not None:
data["Father"] = father
return WillItem(data)
def _wallet_without_adb():
"""Wallet whose check_invalidated path skips on-chain lookups."""
wallet = MagicMock()
wallet.adb = None
return wallet
def test_stale_father_is_discarded_by_add_willtree():
child = _item(father=_MISSING_ID)
will = {child._id: child}
Will.add_willtree(will)
assert child.father is None
def test_check_invalidated_does_not_crash_on_stale_father():
"""The exact failure of the bug report, now without KeyError."""
child = _item(father=_MISSING_ID)
will = {child._id: child}
Will.add_willtree(will)
Will.check_invalidated(will, [], _wallet_without_adb())
def test_check_invalidated_tolerates_missing_father_without_add_willtree():
"""Defensive path: even if the tree was not rebuilt, no KeyError."""
child = _item(father=_MISSING_ID)
will = {child._id: child}
Will.check_invalidated(will, [], _wallet_without_adb())
assert child.father == _MISSING_ID
def test_real_parent_child_link_is_built():
parent = _item()
child = _item(_CHILD_TX_HEX)
will = {_PARENT_ID: parent, child._id: child}
Will.add_willtree(will)
assert child.father == _PARENT_ID
assert [c[0] for c in parent.children] == [child._id]
assert parent.father is None
def test_stale_father_is_replaced_by_the_real_parent():
parent = _item()
child = _item(_CHILD_TX_HEX, father=_MISSING_ID)
will = {_PARENT_ID: parent, child._id: child}
Will.add_willtree(will)
assert child.father == _PARENT_ID
def test_child_of_unconfirmed_parent_is_not_checked_against_utxos():
"""Existing rule kept: a child of a VALID (not mined) parent is skipped."""
parent = _item()
child = _item(_CHILD_TX_HEX)
will = {_PARENT_ID: parent, child._id: child}
Will.add_willtree(will)
wallet = MagicMock()
# Funding not spent on-chain: nothing gets invalidated, we only observe
# which items had their inputs checked.
with patch.object(Will, "check_tx_height", return_value=-2) as height, \
patch.object(Will, "_funding_really_spent", return_value=False):
Will.check_invalidated({child._id: child, _PARENT_ID: parent}, [], wallet)
# Only the parent (a root) looked up its height; the child was skipped.
checked = [call.args[0] for call in height.call_args_list]
assert checked == [parent.tx]
def test_set_invalidate_cascades_to_children_built_by_add_willtree():
"""add_willtree builds children as a LIST: invalidation must cascade."""
parent = _item()
child = _item(_CHILD_TX_HEX)
will = {parent._id: parent, child._id: child}
Will.add_willtree(will)
Will.set_invalidate(parent._id, will)
assert parent.get_status("INVALIDATED") is True
assert child.get_status("INVALIDATED") is True
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All will-tree father tests passed")

View File

@@ -386,7 +386,9 @@ def test_insufficient_funds_warns():
assert not will, "no wallet funds must not build a will" assert not will, "no wallet funds must not build a will"
assert ctl.window.warnings, "user must be warned about the balance" assert ctl.window.warnings, "user must be warned about the balance"
assert any("adjustment" in w.lower() for w in ctl.window.warnings) # The wording was shortened (task #67): the "adjustment of the amounts"
# sentence was dropped, the "fully emptied" note stays.
assert any("fully emptied" in w.lower() for w in ctl.window.warnings)
assert not ctl.willitems assert not ctl.willitems

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

View File

@@ -1,10 +1,11 @@
""" """
Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs``. Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs`` and the
"Building your will" row of ``BalBuildWillDialog._build_success_report``.
This is the post-build sync that keeps the plugin's stored delivery date ``_sync_locktime_to_built_txs`` is the post-build sync that keeps the plugin's
(WILL_SETTINGS["locktime"]) in lockstep with the BUILT transactions' fixed stored delivery date (WILL_SETTINGS["locktime"]) in lockstep with the BUILT
locktime when the core AUTOMATICALLY anticipates it (one day earlier than transactions' fixed locktime when the core AUTOMATICALLY anticipates it (one
stored). day earlier than stored).
RELATIVE recipes ("90d" / "1y") are now PRESERVED: the daily-drift problem RELATIVE recipes ("90d" / "1y") are now PRESERVED: the daily-drift problem
that once forced freezing them to absolute timestamps is solved at the root by that once forced freezing them to absolute timestamps is solved at the root by
@@ -14,8 +15,14 @@ anchoring every relative recipe against the built transactions
timestamp). Only a genuine automatic anticipation on an ABSOLUTE stored date timestamp). Only a genuine automatic anticipation on an ABSOLUTE stored date
moves the stored value. moves the stored value.
The method is exercised with a lightweight fake ``self`` (no Qt event loop, no WHY THE FLAG IS NOT SET HERE ANY MORE: because a relative recipe is anchored to
Electrum wallet) by calling it as an unbound method. the built tx, a stored-vs-built comparison cannot detect the anticipation for
it. ``_build_success_report`` therefore detects it by comparing the will's
minimum locktime BEFORE the rebuild with the one AFTER it (see there), which
works for both absolute and relative recipes.
The methods are exercised with a lightweight fake ``self`` (no Qt event loop,
no Electrum wallet) by calling them as unbound methods.
Run: Run:
source "$BAL_HOME/electrum/env/bin/activate" source "$BAL_HOME/electrum/env/bin/activate"
@@ -35,10 +42,12 @@ from bal.gui.qt.dialogs import BalBuildWillDialog # noqa: E402 (path insert ab
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _fake_willitem(tx_locktime): def _fake_willitem(tx_locktime):
"""Minimal stand-in for a WillItem: enough for Will.get_min_locktime.""" """Minimal stand-in for a WillItem: enough for Will.get_min_locktime and
the heir listing in _build_success_report."""
return SimpleNamespace( return SimpleNamespace(
tx=SimpleNamespace(locktime=tx_locktime), tx=SimpleNamespace(locktime=tx_locktime),
get_status=lambda name: True, get_status=lambda name: True,
heirs={},
) )
@@ -57,7 +66,6 @@ def _make_dialog(will_settings, tx_locktimes, recorded):
will_settings=will_settings, will_settings=will_settings,
update_setting_widgets=update_setting_widgets, update_setting_widgets=update_setting_widgets,
), ),
_date_was_anticipated=False,
) )
@@ -83,7 +91,6 @@ def test_relative_locktime_preserved():
assert fake.bal_window.will_settings["locktime"] == "90d" assert fake.bal_window.will_settings["locktime"] == "90d"
assert fake.bal_window.will_settings["threshold"] == "30d" assert fake.bal_window.will_settings["threshold"] == "30d"
assert recorded == [], "a relative recipe must never be rewritten" assert recorded == [], "a relative recipe must never be rewritten"
assert fake._date_was_anticipated is False
def test_relative_threshold_preserved(): def test_relative_threshold_preserved():
@@ -108,12 +115,13 @@ def test_absolute_locktime_unchanged_on_equal():
recorded, recorded,
) )
assert fake.bal_window.will_settings["locktime"] == tx_locktime assert fake.bal_window.will_settings["locktime"] == tx_locktime
assert fake._date_was_anticipated is False assert fake.bal_window.will_settings["threshold"] == 1_700_000_000
def test_anticipation_sets_flag_and_moves_earlier(): def test_anticipation_moves_stored_date_earlier():
"""A real automatic anticipation of an ABSOLUTE stored date (built earlier """A real automatic anticipation of an ABSOLUTE stored date (built earlier
than stored) still moves the date earlier and flags the sign prompt.""" than stored) moves the stored date earlier so the next Check does not
mistake it for a postpone."""
tx_locktime = 1_700_000_000 tx_locktime = 1_700_000_000
recorded = [] recorded = []
fake = _call_sync( fake = _call_sync(
@@ -122,7 +130,6 @@ def test_anticipation_sets_flag_and_moves_earlier():
recorded, recorded,
) )
assert fake.bal_window.will_settings["locktime"] == tx_locktime assert fake.bal_window.will_settings["locktime"] == tx_locktime
assert fake._date_was_anticipated is True
def test_stored_earlier_than_built_never_moved_later(): def test_stored_earlier_than_built_never_moved_later():
@@ -137,7 +144,6 @@ def test_stored_earlier_than_built_never_moved_later():
recorded, recorded,
) )
assert fake.bal_window.will_settings["locktime"] == stored assert fake.bal_window.will_settings["locktime"] == stored
assert fake._date_was_anticipated is False
def test_multiple_txs_uses_minimum_locktime(): def test_multiple_txs_uses_minimum_locktime():
@@ -182,6 +188,76 @@ def test_relative_locktime_stays_coherent_via_anchor():
assert drifted > tx_locktime assert drifted > tx_locktime
# ------------------------------------------------------------------ #
# _build_success_report: the "Building your will" row
# ------------------------------------------------------------------ #
def _make_report_dialog(new_locktimes, previous_min_locktime):
"""Fake ``self`` for _build_success_report (no Qt event loop).
``_sync_locktime_to_built_txs`` is stubbed (it is exercised separately), so
the test drives the AFTER-build locktimes and the captured BEFORE-build
locktime directly; the message helpers capture what would be written on the
"Building your will" row.
"""
captured = {}
def _set_building(status=None, row=None, color=None):
captured["build_status"] = status
fake = SimpleNamespace(
bal_window=SimpleNamespace(
willitems={
f"tx{i}": _fake_willitem(lt)
for i, lt in enumerate(new_locktimes)
},
wallet=SimpleNamespace(set_label=lambda *a, **k: None),
),
_previous_min_locktime=previous_min_locktime,
_sync_locktime_to_built_txs=lambda: None,
msg_set_building=_set_building,
msg_set_status=lambda *a, **k: None,
msg_alert=lambda e: "ALERT:{}".format(e),
msg_ok=lambda e=None: "OK",
)
return fake, captured
def test_build_report_shows_anticipated_warning():
"""When the rebuild moved the delivery one day earlier than the previous
will, the "Building your will" row shows a warning naming the NEW delivery
date instead of a plain "Ok"."""
previous = 1_800_000_000 # 2027-01-15 (UTC)
rebuilt = previous - 86_400 # anticipated one day earlier
fake, captured = _make_report_dialog([rebuilt], previous)
BalBuildWillDialog._build_success_report(fake)
assert fake._date_was_anticipated is True
assert fake._anticipated_locktime == rebuilt
assert captured["build_status"] == (
"ALERT:Anticipated - new delivery date: 2027-01-14"
)
def test_build_report_shows_ok_when_not_anticipated():
"""Without anticipation the row keeps the plain "Ok" and the state stays
clean."""
previous = 1_800_000_000
fake, captured = _make_report_dialog([previous], previous)
BalBuildWillDialog._build_success_report(fake)
assert fake._date_was_anticipated is False
assert fake._anticipated_locktime is None
assert captured["build_status"] == "OK"
def test_build_report_first_build_is_not_anticipated():
"""A first build (no previous will) is never flagged as anticipated."""
fake, captured = _make_report_dialog([1_800_000_000], None)
BalBuildWillDialog._build_success_report(fake)
assert fake._date_was_anticipated is False
assert fake._anticipated_locktime is None
assert captured["build_status"] == "OK"
if __name__ == "__main__": if __name__ == "__main__":
for name in sorted(dir()): for name in sorted(dir()):
if name.startswith("test_"): if name.startswith("test_"):