Compare commits
1 Commits
0c60462163
...
fix/i18n-r
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bb9fe812c |
214
CHANGELOG.md
214
CHANGELOG.md
@@ -3587,28 +3587,198 @@ delivery; when it is not, the plugin raises an explicit error (see below).
|
|||||||
covers every catalog's translation of "New", the push helper does not
|
covers every catalog's translation of "New", the push helper does not
|
||||||
modify the will item, old transient fields dropped on load.
|
modify the will item, old transient fields dropped on load.
|
||||||
|
|
||||||
**Scope:** this entry contains ONLY the i18n review fixes. The other fixes
|
**Verification:** extended release test list (as above) plus the new test
|
||||||
prepared in the same session (invalidation fee, Check `KeyError`, cascade,
|
files: 489 passed, 1 failed (pre-existing: `test_group_e_mock_karen7::test_e5`
|
||||||
deselected executor) are not part of it and will be proposed separately.
|
needs `tests/karen7`, not in the repository; `main` 827a1d1 gives 456 passed,
|
||||||
|
same failure). `ruff check bal/ tests/`: the same 3 pre-existing I001 as on
|
||||||
|
`main` (`dialogs.py`, `test_core_animated_qr.py`, `test_core_qr_transfer.py`),
|
||||||
|
none new.
|
||||||
|
|
||||||
**Verification (on `main` 827a1d1):**
|
**Outcome:** DONE (pending the owner's test of the ZIP and Truman's review).
|
||||||
|
|
||||||
- New test file `tests/test_core_status_language_neutral.py`: 5 of 7 fail on
|
---
|
||||||
`main`, 7 of 7 pass with this change.
|
|
||||||
- Extended release test list (as above) plus the new test file: 446 passed,
|
|
||||||
1 failed (`main`: 439 passed, same failure). The failure is pre-existing:
|
|
||||||
`test_group_e_mock_karen7::test_e5` needs `tests/karen7`, a wallet file that
|
|
||||||
is not in the repository.
|
|
||||||
- Whole `tests/` directory: 609 passed, 26 failed, 3 collection errors
|
|
||||||
(`main`: 602 passed, and the SAME 26 failures and 3 errors, test by test).
|
|
||||||
All pre-existing and unrelated: 9 need `tests/karen7`; 21 in
|
|
||||||
`test_auto_rebuild_on_new_tx.py`, `test_cli_autorebuild.py` and
|
|
||||||
`test_cli_controller_offline.py` fail only when the whole directory runs
|
|
||||||
together (they pass when each file runs alone); 3 audio tests need an audio
|
|
||||||
device. `test_gui_export_dialogs.py::test_audio_import_receive_wiring` hangs
|
|
||||||
forever, also on `main`, so it was deselected.
|
|
||||||
- `ruff check bal/ tests/`: the same 3 pre-existing I001 as on `main`
|
|
||||||
(`dialogs.py`, `test_core_animated_qr.py`, `test_core_qr_transfer.py`),
|
|
||||||
none new.
|
|
||||||
|
|
||||||
**Outcome:** DONE (pending Truman's review).
|
## 64. Invalidation pays the current network fee (2 blocks) instead of `baltx_fees`
|
||||||
|
|
||||||
|
**Date:** 2026-09-26 (ported to `main` 827a1d1 on 2026-10-07)
|
||||||
|
|
||||||
|
**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).
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
|
||||||
|
- `bal/core/will.py`
|
||||||
|
- New constant `INVALIDATION_FEE_ETA_BLOCKS = 2` (owner decision: confirm
|
||||||
|
within 2 blocks, Electrum's own default target).
|
||||||
|
- New `Will.get_invalidation_fee_rate(wallet, fallback_fee_per_byte)`:
|
||||||
|
returns Electrum's live estimate for 2 blocks
|
||||||
|
(`network.fee_estimates.eta_target_to_fee`, sat/kvbyte → sat/vbyte), or
|
||||||
|
the fallback (the will's `baltx_fees`, i.e. the previous behaviour) when
|
||||||
|
there is no usable estimate: offline wallet, estimates not received yet,
|
||||||
|
network error or an invalid value. Never raises.
|
||||||
|
- `Will.invalidate_will`: uses that rate; the total fee is rounded UP to
|
||||||
|
whole satoshis (network rates can be fractional); logs the chosen rate and
|
||||||
|
its source (`network` / `fallback`). The `fees_per_byte` argument is now
|
||||||
|
only the fallback, so every caller (Tools → Invalidate, wizard
|
||||||
|
postpone/expired paths, automatic invalidation, CLI `bal_will_invalidate`)
|
||||||
|
gets the new behaviour without being changed. Docstrings added.
|
||||||
|
- `tests/test_core_invalidation_fee.py` (new): rate selection (2-block target,
|
||||||
|
network value used as is also above `baltx_fees`, fractional rates, fallback
|
||||||
|
when offline / no estimate / error / invalid values) and the fee actually
|
||||||
|
deducted by `invalidate_will` (network vs fallback, rounding up).
|
||||||
|
|
||||||
|
**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`: 12 passed (pytest and standalone).
|
||||||
|
- `make-release.sh` test list + `tests/test_cli_*.py`: 435 passed, 1 failed.
|
||||||
|
The failure is pre-existing and unrelated: `test_group_e_mock_karen7::test_e5`
|
||||||
|
needs `tests/karen7`, a wallet file that is not in the repository.
|
||||||
|
`tests/test_import_will_details.py`: 17 passed.
|
||||||
|
- `ruff check` clean on the touched files.
|
||||||
|
- Live, Electrum 4.8.2 testnet, offline daemon: `bal_will_invalidate` →
|
||||||
|
"invalidation fee: 20 sat/vbyte (fallback), total 2200 sat" (unchanged
|
||||||
|
behaviour when offline).
|
||||||
|
- Real Electrum wallet + real `FeeTimeEstimates` (1 input, 110 vbytes):
|
||||||
|
market 3 sat/vbyte → 330 sat; 1.5 → 165 sat; 0.4 → 110 sat (Electrum clamps
|
||||||
|
to its 1 sat/vbyte minimum relay fee); 45 → 4950 sat; no 2-block estimate →
|
||||||
|
fallback 20 sat/vbyte (2200 sat).
|
||||||
|
- API checked in Electrum 4.7.2, 4.8.0 and 4.8.2
|
||||||
|
(`FeeTimeEstimates.eta_target_to_fee`, `network.fee_estimates`).
|
||||||
|
|
||||||
|
**Notes:** on regtest Electrum returns a fixed 150 sat/vbyte when it has no
|
||||||
|
estimate, so regtest invalidations will pay that rate.
|
||||||
|
|
||||||
|
**Outcome:** DONE (pending the owner's test of the ZIP).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 65. Check crashed with `KeyError: '<txid>'` (stale "father" link in the will tree)
|
||||||
|
|
||||||
|
**Date:** 2026-09-27
|
||||||
|
|
||||||
|
**Symptom (owner's mainnet wallet, 23 will items, Electrum 4.8.2):** the Check
|
||||||
|
wizard stopped at "Checking your will" with `Error: 'a65a682f…'`, and the
|
||||||
|
window refresh logged "error while updating window" with the same txid:
|
||||||
|
|
||||||
|
```
|
||||||
|
File "bal/core/will.py", line 657, in check_invalidated
|
||||||
|
or willtree[w.father].get_status("CONFIRMED")
|
||||||
|
KeyError: 'a65a682f2975f0dffd9eccce9392833f227badf617fc67815674736d594d4115'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Root cause:** every will item stores its parent will tx in `father`, which is
|
||||||
|
persisted in the wallet (`"Father"` in `WillItem.to_dict`). `Will.add_willtree`
|
||||||
|
only ever SET it (`if not father: father = ...`) and never cleared it, so when
|
||||||
|
the parent tx was later removed from the will (rebuild, merge, replacement) the
|
||||||
|
child kept pointing to a txid that no longer exists. `check_invalidated` then
|
||||||
|
indexed the will with it. Reproduced on unmodified `main` (not related to #64).
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
|
||||||
|
- `bal/core/will.py`
|
||||||
|
- `Will.add_willtree`: resets `father` on every item before rebuilding the
|
||||||
|
links, so the tree is always recomputed from the transactions themselves
|
||||||
|
(a stale value loaded from the wallet is discarded). Docstring added.
|
||||||
|
- `Will.check_invalidated`: defensive lookup - a father that is not (or no
|
||||||
|
longer) in the will is treated as "no father" instead of raising.
|
||||||
|
- `tests/test_core_willtree_father.py` (new): stale father discarded / replaced
|
||||||
|
by the real parent, no crash in `check_invalidated` (with and without the
|
||||||
|
tree rebuild), real parent/child link built, child of an unconfirmed parent
|
||||||
|
still skipped. 4 of the 6 tests fail on `main` and pass with the fix; the 2
|
||||||
|
"normal behaviour" tests pass on both.
|
||||||
|
|
||||||
|
**Verification:** make-release test list + `tests/test_cli_*.py` +
|
||||||
|
`tests/test_import_will_details.py`: 458 passed, 1 failed (pre-existing,
|
||||||
|
unrelated: `test_group_e_mock_karen7::test_e5` needs the missing `tests/karen7`
|
||||||
|
wallet file). `ruff check` clean on the touched files.
|
||||||
|
|
||||||
|
**Found while writing the tests, NOT fixed here (reported to the owner):**
|
||||||
|
`Will.set_invalidate` iterates `children.items()`, but `add_willtree` builds
|
||||||
|
`children` as a LIST, so invalidating an item that has child will txs raises
|
||||||
|
`AttributeError: 'list' object has no attribute 'items'` (reproduced on
|
||||||
|
`main`). The existing tests in `test_core_will_invalidate.py` set `children` to
|
||||||
|
dicts by hand, so they do not see it.
|
||||||
|
|
||||||
|
**Outcome:** DONE (pending the owner's test of the ZIP).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 66. Invalidation cascade crashed on real will trees (`children` is a list)
|
||||||
|
|
||||||
|
**Date:** 2026-09-27
|
||||||
|
|
||||||
|
**Problem (found while fixing #65, reproduced on `main`):**
|
||||||
|
`Will.set_invalidate` iterated `children.items()`, but `Will.add_willtree`
|
||||||
|
builds `children` as a LIST of `[child_id, input_idx, prevout_idx]`, so
|
||||||
|
invalidating a will tx that has child will txs raised
|
||||||
|
`AttributeError: 'list' object has no attribute 'items'`. The existing tests
|
||||||
|
set `children` to dicts by hand, so they never saw it.
|
||||||
|
|
||||||
|
**What changed:** `bal/core/will.py` - `set_invalidate` accepts both shapes
|
||||||
|
(list from `add_willtree`, dict from older data/tests) and only recurses into
|
||||||
|
children that are still in the will. `tests/test_core_willtree_father.py`: new
|
||||||
|
test `test_set_invalidate_cascades_to_children_built_by_add_willtree` (fails
|
||||||
|
on `main`, passes with the fix).
|
||||||
|
|
||||||
|
**Verification:** 459 passed, 1 failed (pre-existing missing `tests/karen7`);
|
||||||
|
`ruff check` clean.
|
||||||
|
|
||||||
|
**Outcome:** DONE (included in the owner's test ZIP of 2026-09-27).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 67. Deselected will-executor: its tx can be deleted and is no longer pushed
|
||||||
|
|
||||||
|
**Date:** 2026-09-27
|
||||||
|
|
||||||
|
**Problem (owner, mainnet wallet):** the will had a signed tx for
|
||||||
|
`we.divinaly.support` whose push had failed ("Push failed", "Not on server").
|
||||||
|
After deselecting that executor, Check still reported the will as valid, the
|
||||||
|
row stayed, and "delete" in the context menu was disabled (valid + signed
|
||||||
|
txs were never deletable). The broadcast also still targeted the deselected
|
||||||
|
executor, because each will item keeps its own build-time copy of the
|
||||||
|
executor record (`selected` = True).
|
||||||
|
|
||||||
|
**What changed (owner chose option "B"):**
|
||||||
|
|
||||||
|
- `bal/gui/qt/lists.py` - `_can_delete`: a valid, signed tx is now deletable
|
||||||
|
when its will-executor NEVER received it (not PUSHED and not CHECKED). It
|
||||||
|
stays protected when the executor holds it (deleting it locally would not
|
||||||
|
cancel it on the server) and when it has no executor (backup tx).
|
||||||
|
- `bal/core/willexecutors.py` - `get_willexecutor_transactions(...,
|
||||||
|
current_willexecutors=None)`: when the current executor list is given, an
|
||||||
|
executor that is no longer selected (or no longer listed) is skipped.
|
||||||
|
- `bal/gui/qt/window.py` (manual broadcast) and `bal/cli/controller.py`
|
||||||
|
(`bal_will_broadcast`) pass the current list read from the config. The
|
||||||
|
wizard already filtered by the current selection.
|
||||||
|
- Tests: `tests/test_gui_will_menu.py` (+3: deletable when never received,
|
||||||
|
protected when pushed/checked, protected backup tx) and new
|
||||||
|
`tests/test_core_deselected_executor.py` (4).
|
||||||
|
|
||||||
|
**Not changed (proposal for Truman, option "A"):** Check still does not treat
|
||||||
|
a tx of a deselected executor as "not coherent" (no automatic rebuild).
|
||||||
|
|
||||||
|
**Verification:** 483 passed, 1 failed (pre-existing missing `tests/karen7`);
|
||||||
|
`ruff check` clean on the touched files. Live (Electrum 4.8.2 testnet, CLI):
|
||||||
|
will built for 2 executors, one deselected → `bal_will_broadcast` targets only
|
||||||
|
the selected one (before the fix it targeted both).
|
||||||
|
|
||||||
|
**Outcome:** DONE (pending the owner's test of the ZIP).
|
||||||
|
|
||||||
|
**Porting note (entries 64-67):** first written on `main` 4a33116 as
|
||||||
|
58-61 (numbers then taken by the i18n and Check Alive work), re-applied on
|
||||||
|
`main` 827a1d1 on 2026-10-07 together with #63: extended release test list
|
||||||
|
489 passed, 1 pre-existing failure (missing `tests/karen7`); `ruff` no new
|
||||||
|
errors.
|
||||||
|
|||||||
@@ -767,7 +767,11 @@ class BalController:
|
|||||||
if not willitems:
|
if not willitems:
|
||||||
raise UserFacingException(_("No transaction matches the given txids"))
|
raise UserFacingException(_("No transaction matches the given txids"))
|
||||||
|
|
||||||
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
|
# Skip executors the user deselected after building the will (the
|
||||||
|
# controller's list is read from the config for every command).
|
||||||
|
willexecutors = Willexecutors.get_willexecutor_transactions(
|
||||||
|
willitems, force=force, current_willexecutors=self.willexecutors
|
||||||
|
)
|
||||||
if not willexecutors:
|
if not willexecutors:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|||||||
118
bal/core/will.py
118
bal/core/will.py
@@ -26,6 +26,7 @@ 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
|
||||||
@@ -50,6 +51,13 @@ 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
|
||||||
_logger = get_logger(__name__)
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -68,6 +76,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,9 +462,68 @@ 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):
|
||||||
|
"""Return ``(fee_rate, source)`` for the on-chain invalidation tx.
|
||||||
|
|
||||||
|
``fee_rate`` is in sat/vbyte. ``source`` is ``"network"`` when the rate
|
||||||
|
comes from Electrum's live fee estimate, ``"fallback"`` otherwise.
|
||||||
|
|
||||||
|
WHY: the invalidation is broadcast NOW, so it pays the current network
|
||||||
|
fee for a confirmation within ``INVALIDATION_FEE_ETA_BLOCKS`` blocks.
|
||||||
|
The will's ``baltx_fees`` is kept only as a fallback, used when no
|
||||||
|
estimate is available (offline wallet, estimates not received yet,
|
||||||
|
server error), which is exactly the behaviour of previous versions.
|
||||||
|
|
||||||
|
The estimate is read from ``network.fee_estimates.eta_target_to_fee``
|
||||||
|
(Electrum 4.7.2 / 4.8.x), which returns sat/kvbyte and already clamps
|
||||||
|
the value between Electrum's conservative minimum relay fee (1
|
||||||
|
sat/vbyte) and its maximum dynamic fee. This method never raises.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
wallet: The Electrum wallet (its ``network`` may be ``None``).
|
||||||
|
fallback_fee_per_byte: Rate (sat/vbyte) used when there is no
|
||||||
|
usable estimate - normally the will's ``baltx_fees``.
|
||||||
|
"""
|
||||||
|
network = getattr(wallet, "network", None) if wallet else None
|
||||||
|
fee_per_kb = None
|
||||||
|
if network is not None:
|
||||||
|
try:
|
||||||
|
fee_per_kb = network.fee_estimates.eta_target_to_fee(
|
||||||
|
INVALIDATION_FEE_ETA_BLOCKS
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(f"invalidation fee: no network 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, "network"
|
||||||
|
return fallback_fee_per_byte, "fallback"
|
||||||
|
|
||||||
@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):
|
||||||
|
"""Build the (unsigned) on-chain invalidation transaction.
|
||||||
|
|
||||||
|
The transaction spends every coin used by the valid will items back to
|
||||||
|
one of the wallet's own change addresses, so the pre-signed
|
||||||
|
inheritance transactions held by the will-executors become unusable.
|
||||||
|
|
||||||
|
Fee: the CURRENT network fee for a confirmation within
|
||||||
|
``INVALIDATION_FEE_ETA_BLOCKS`` blocks (see
|
||||||
|
:meth:`get_invalidation_fee_rate`). ``fees_per_byte`` (the will's
|
||||||
|
``baltx_fees``) is only the FALLBACK used when no network estimate 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)
|
will_only_valid = Will.only_valid_list(will)
|
||||||
inputs = Will.get_all_inputs(will_only_valid)
|
inputs = Will.get_all_inputs(will_only_valid)
|
||||||
@@ -477,7 +560,17 @@ class Will:
|
|||||||
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_rate, fee_source = Will.get_invalidation_fee_rate(
|
||||||
|
wallet, fees_per_byte
|
||||||
|
)
|
||||||
|
# The network rate can be fractional (e.g. 1.5 sat/vbyte): round
|
||||||
|
# the total fee UP to whole satoshis so the output value is an
|
||||||
|
# integer and the rate is never below the one requested.
|
||||||
|
fee = int(math.ceil(tx.estimated_size() * fee_rate))
|
||||||
|
_logger.info(
|
||||||
|
f"invalidation fee: {fee_rate} sat/vbyte ({fee_source}), "
|
||||||
|
f"total {fee} sat"
|
||||||
|
)
|
||||||
if balance - fee > 0:
|
if balance - fee > 0:
|
||||||
out = PartialTxOutput.from_address_and_value(
|
out = PartialTxOutput.from_address_and_value(
|
||||||
change_addresses[0], balance - fee
|
change_addresses[0], balance - fee
|
||||||
@@ -562,9 +655,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 +674,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)
|
||||||
|
|||||||
@@ -230,12 +230,19 @@ class Willexecutors:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_willexecutor_transactions(will, force=False):
|
def get_willexecutor_transactions(will, force=False, current_willexecutors=None):
|
||||||
"""Group the valid, signed, not-yet-pushed will txs by will-executor.
|
"""Group the valid, signed, not-yet-pushed will txs by will-executor.
|
||||||
|
|
||||||
Returns ``{url: executor_record}`` where each record gets ``txs`` (the
|
Returns ``{url: executor_record}`` where each record gets ``txs`` (the
|
||||||
raw txs, one per line) and ``txsids``.
|
raw txs, one per line) and ``txsids``.
|
||||||
|
|
||||||
|
``current_willexecutors`` is the CURRENT executor list from the config
|
||||||
|
(``{url: record}``). When given, an executor that is no longer in it or
|
||||||
|
no longer selected is skipped. WHY: every will item keeps its own copy
|
||||||
|
of the executor record taken at build time (``selected`` = True), so
|
||||||
|
without this check a deselected executor would still receive the will.
|
||||||
|
``None`` keeps the previous behaviour (only the item's own copy).
|
||||||
|
|
||||||
The returned records are COPIES: the will items are never modified.
|
The returned records are COPIES: the will items are never modified.
|
||||||
"""
|
"""
|
||||||
willexecutors = {}
|
willexecutors = {}
|
||||||
@@ -245,6 +252,12 @@ class Willexecutors:
|
|||||||
if not willitem.get_status("PUSHED") or force:
|
if not willitem.get_status("PUSHED") or force:
|
||||||
if willexecutor := willitem.we:
|
if willexecutor := willitem.we:
|
||||||
url = willexecutor["url"]
|
url = willexecutor["url"]
|
||||||
|
if current_willexecutors is not None and not (
|
||||||
|
Willexecutors.is_selected(
|
||||||
|
current_willexecutors.get(url)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
continue
|
||||||
if willexecutor and Willexecutors.is_selected(willexecutor):
|
if willexecutor and Willexecutors.is_selected(willexecutor):
|
||||||
if url not in willexecutors:
|
if url not in willexecutors:
|
||||||
# Work on a COPY of the record: the item's
|
# Work on a COPY of the record: the item's
|
||||||
|
|||||||
@@ -93,13 +93,27 @@ def _can_broadcast(will_item):
|
|||||||
|
|
||||||
|
|
||||||
def _can_delete(will_item):
|
def _can_delete(will_item):
|
||||||
"""True if a will transaction may be deleted (invalid or unsigned).
|
"""True if a will transaction may be deleted.
|
||||||
|
|
||||||
Valid AND fully-signed transactions are never deletable: removing them
|
Deletable:
|
||||||
would silently drop a committed inheritance.
|
* invalid or not fully signed transactions (unchanged rule);
|
||||||
|
* valid AND fully signed transactions that their will-executor NEVER
|
||||||
|
received: not PUSHED and not CHECKED (e.g. "Push failed" /
|
||||||
|
"Not on server", or never sent).
|
||||||
|
|
||||||
|
Not deletable: a valid, signed transaction that the will-executor holds
|
||||||
|
(PUSHED or CHECKED). WHY: deleting it only on this PC would not remove it
|
||||||
|
from the executor's server, so the user would wrongly believe that
|
||||||
|
inheritance was cancelled; that case needs an on-chain invalidation.
|
||||||
|
Signed transactions without a will-executor (backup txs) also stay
|
||||||
|
protected, as before.
|
||||||
"""
|
"""
|
||||||
return bool(will_item) and (
|
if not will_item:
|
||||||
not will_item.get_status("VALID") or not will_item.get_status("COMPLETE")
|
return False
|
||||||
|
if not will_item.get_status("VALID") or not will_item.get_status("COMPLETE"):
|
||||||
|
return True
|
||||||
|
return bool(getattr(will_item, "we", None)) and not (
|
||||||
|
will_item.get_status("PUSHED") or will_item.get_status("CHECKED")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1632,7 +1632,15 @@ class BalWindow:
|
|||||||
willitems = {
|
willitems = {
|
||||||
t: willitems[t] for t in txids if t in willitems
|
t: willitems[t] for t in txids if t in willitems
|
||||||
}
|
}
|
||||||
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
|
# Read the CURRENT executor list from the config, so an executor the
|
||||||
|
# user deselected after building the will is not contacted anymore.
|
||||||
|
willexecutors = Willexecutors.get_willexecutor_transactions(
|
||||||
|
willitems,
|
||||||
|
force=force,
|
||||||
|
current_willexecutors=Willexecutors.get_willexecutors(
|
||||||
|
self.bal_plugin, update=False, task=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def getMsg(willexecutors):
|
def getMsg(willexecutors):
|
||||||
msg = _("Broadcasting Transactions to Will-Executors:") + "\n"
|
msg = _("Broadcasting Transactions to Will-Executors:") + "\n"
|
||||||
|
|||||||
82
tests/test_core_deselected_executor.py
Normal file
82
tests/test_core_deselected_executor.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
Tests for ``Willexecutors.get_willexecutor_transactions`` with the CURRENT
|
||||||
|
executor selection (``current_willexecutors``).
|
||||||
|
|
||||||
|
Every will item keeps its own copy of the executor record taken at build time
|
||||||
|
(``selected`` = True). Without the current selection, an executor the user
|
||||||
|
deselected after building the will would still receive the will.
|
||||||
|
|
||||||
|
Run (standalone or with pytest):
|
||||||
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||||
|
python3 tests/test_core_deselected_executor.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from bal.core.willexecutors import Willexecutors
|
||||||
|
|
||||||
|
|
||||||
|
class FakeItem:
|
||||||
|
"""Minimal will item: statuses, a tx and the build-time executor copy."""
|
||||||
|
|
||||||
|
def __init__(self, url, **flags):
|
||||||
|
self._status = {"VALID": True, "COMPLETE": True}
|
||||||
|
self._status.update(flags)
|
||||||
|
self.we = {"url": url, "selected": True}
|
||||||
|
self.tx = f"rawtx-{url}"
|
||||||
|
|
||||||
|
def get_status(self, name):
|
||||||
|
return self._status.get(name, False)
|
||||||
|
|
||||||
|
|
||||||
|
A = "https://we.a.example"
|
||||||
|
B = "https://we.b.example"
|
||||||
|
|
||||||
|
|
||||||
|
def _will():
|
||||||
|
return {"tx_a": FakeItem(A), "tx_b": FakeItem(B)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_current_list_previous_behaviour():
|
||||||
|
assert set(Willexecutors.get_willexecutor_transactions(_will())) == {A, B}
|
||||||
|
|
||||||
|
|
||||||
|
def test_deselected_executor_is_skipped():
|
||||||
|
current = {A: {"selected": True}, B: {"selected": False}}
|
||||||
|
out = Willexecutors.get_willexecutor_transactions(
|
||||||
|
_will(), current_willexecutors=current
|
||||||
|
)
|
||||||
|
assert set(out) == {A}
|
||||||
|
assert out[A]["txsids"] == ["tx_a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_executor_removed_from_list_is_skipped():
|
||||||
|
current = {A: {"selected": True}}
|
||||||
|
out = Willexecutors.get_willexecutor_transactions(
|
||||||
|
_will(), current_willexecutors=current
|
||||||
|
)
|
||||||
|
assert set(out) == {A}
|
||||||
|
|
||||||
|
|
||||||
|
def test_already_pushed_still_excluded_unless_forced():
|
||||||
|
will = {"tx_a": FakeItem(A, PUSHED=True)}
|
||||||
|
current = {A: {"selected": True}}
|
||||||
|
assert Willexecutors.get_willexecutor_transactions(
|
||||||
|
will, current_willexecutors=current
|
||||||
|
) == {}
|
||||||
|
assert set(
|
||||||
|
Willexecutors.get_willexecutor_transactions(
|
||||||
|
will, force=True, current_willexecutors=current
|
||||||
|
)
|
||||||
|
) == {A}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All deselected-executor tests passed")
|
||||||
231
tests/test_core_invalidation_fee.py
Normal file
231
tests/test_core_invalidation_fee.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
"""
|
||||||
|
Tests for the fee of the on-chain INVALIDATION transaction.
|
||||||
|
|
||||||
|
The invalidation is broadcast TODAY (it spends the will's coins so the old
|
||||||
|
pre-signed inheritance transactions become unusable), therefore it must pay the
|
||||||
|
CURRENT network fee for a confirmation within
|
||||||
|
``INVALIDATION_FEE_ETA_BLOCKS`` (2) blocks. The will's ``baltx_fees`` (a
|
||||||
|
deliberately high rate meant for inheritance transactions broadcast years from
|
||||||
|
now) is only the FALLBACK used when no network estimate is available.
|
||||||
|
|
||||||
|
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.transaction import Transaction
|
||||||
|
|
||||||
|
from bal.core.will import INVALIDATION_FEE_ETA_BLOCKS, 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_estimate(fee_per_kb):
|
||||||
|
"""Wallet whose network returns ``fee_per_kb`` (sat/kvbyte) for any ETA.
|
||||||
|
|
||||||
|
``fee_per_kb`` may also be an Exception instance: the estimate call then
|
||||||
|
raises it, to simulate a broken network object.
|
||||||
|
"""
|
||||||
|
wallet = MagicMock()
|
||||||
|
wallet.get_change_addresses_for_new_transaction.return_value = [_CHANGE_ADDR]
|
||||||
|
network = MagicMock()
|
||||||
|
if isinstance(fee_per_kb, Exception):
|
||||||
|
network.fee_estimates.eta_target_to_fee.side_effect = fee_per_kb
|
||||||
|
else:
|
||||||
|
network.fee_estimates.eta_target_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):
|
||||||
|
"""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)
|
||||||
|
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
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_target_is_two_blocks():
|
||||||
|
"""The owner's decision: confirm within 2 blocks."""
|
||||||
|
assert INVALIDATION_FEE_ETA_BLOCKS == 2
|
||||||
|
wallet = _wallet_with_estimate(3000)
|
||||||
|
Will.get_invalidation_fee_rate(wallet, _BALTX_FEES)
|
||||||
|
wallet.network.fee_estimates.eta_target_to_fee.assert_called_once_with(2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_estimate_is_used():
|
||||||
|
"""A live estimate (sat/kvbyte) becomes the rate in sat/vbyte."""
|
||||||
|
wallet = _wallet_with_estimate(3000)
|
||||||
|
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (3.0, "network")
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_estimate_higher_than_baltx_fees_is_used_as_is():
|
||||||
|
"""No cap: if the market is above baltx_fees, the market rate is used."""
|
||||||
|
wallet = _wallet_with_estimate(45000)
|
||||||
|
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (45.0, "network")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fractional_estimate_is_kept():
|
||||||
|
"""Electrum estimates may be fractional (e.g. 1.5 sat/vbyte)."""
|
||||||
|
wallet = _wallet_with_estimate(1500)
|
||||||
|
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (1.5, "network")
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_wallet_falls_back_to_baltx_fees():
|
||||||
|
"""No network object (offline): previous behaviour, 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_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_estimate(RuntimeError("server gone"))
|
||||||
|
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
|
||||||
|
_BALTX_FEES,
|
||||||
|
"fallback",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_integer_or_invalid_estimates_fall_back():
|
||||||
|
"""Only a positive int (Electrum's sat/kvbyte type) is trusted."""
|
||||||
|
for bad in (0, -1000, True, "3000", 3.5, MagicMock()):
|
||||||
|
wallet = _wallet_with_estimate(bad)
|
||||||
|
assert Will.get_invalidation_fee_rate(wallet, _BALTX_FEES) == (
|
||||||
|
_BALTX_FEES,
|
||||||
|
"fallback",
|
||||||
|
), bad
|
||||||
|
|
||||||
|
|
||||||
|
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_estimate(3000)
|
||||||
|
assert _invalidation_output_value(wallet) == _BALANCE - 600
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalidation_offline_pays_baltx_fees():
|
||||||
|
"""Offline: unchanged behaviour, 200 * 20 = 4000 sat."""
|
||||||
|
wallet = _wallet_with_estimate(None)
|
||||||
|
assert _invalidation_output_value(wallet) == _BALANCE - 4000
|
||||||
|
|
||||||
|
|
||||||
|
def test_fractional_fee_is_rounded_up_to_whole_satoshis():
|
||||||
|
"""1.5 sat/vbyte on 201 vbytes = 301.5 -> 302 sat (never below the rate)."""
|
||||||
|
wallet = _wallet_with_estimate(1500)
|
||||||
|
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")
|
||||||
145
tests/test_core_willtree_father.py
Normal file
145
tests/test_core_willtree_father.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
Tests for the parent/child tree of the will transactions (``father`` links).
|
||||||
|
|
||||||
|
Bug reproduced from a real wallet (Electrum log, Check wizard):
|
||||||
|
|
||||||
|
File "bal/core/will.py", in check_invalidated
|
||||||
|
or willtree[w.father].get_status("CONFIRMED")
|
||||||
|
KeyError: 'a65a682f…'
|
||||||
|
|
||||||
|
``father`` is persisted in the wallet (``"Father"``) and was never cleared, so
|
||||||
|
after the father tx was removed from the will the child pointed to a txid that
|
||||||
|
no longer exists. ``Will.add_willtree`` now rebuilds the links from scratch and
|
||||||
|
``Will.check_invalidated`` treats a missing father as "no father".
|
||||||
|
|
||||||
|
Run (standalone or with pytest):
|
||||||
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||||
|
python3 tests/test_core_willtree_father.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from electrum.transaction import Transaction
|
||||||
|
|
||||||
|
from bal.core.will import Will, WillItem
|
||||||
|
|
||||||
|
# WillItem parses a raw tx and calls add_info_from_wallet; no live wallet here.
|
||||||
|
_patcher = patch.object(Transaction, "add_info_from_wallet")
|
||||||
|
_patcher.start()
|
||||||
|
|
||||||
|
# A valid serialized transaction (1 input, 1 output) reused by the core tests.
|
||||||
|
_VALID_TX_HEX = (
|
||||||
|
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||||
|
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||||
|
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||||
|
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||||
|
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||||
|
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||||
|
"42146f11ef8414ae929feaafc388ac00000000"
|
||||||
|
)
|
||||||
|
# The input of _VALID_TX_HEX spends this outpoint (little-endian in the hex).
|
||||||
|
_FUNDING_ID = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a"
|
||||||
|
# PARENT: _VALID_TX_HEX itself. CHILD: the same tx with its input re-pointed to
|
||||||
|
# the parent's txid, so the child spends the parent's output 0.
|
||||||
|
_PARENT_ID = Transaction(_VALID_TX_HEX).txid()
|
||||||
|
_CHILD_TX_HEX = _VALID_TX_HEX.replace(
|
||||||
|
bytes.fromhex(_FUNDING_ID)[::-1].hex(), bytes.fromhex(_PARENT_ID)[::-1].hex()
|
||||||
|
)
|
||||||
|
# A txid that is not in the will (the "removed father" of the bug report).
|
||||||
|
_MISSING_ID = "a65a682f2975f0dffd9eccce9392833f227badf617fc67815674736d594d4115"
|
||||||
|
|
||||||
|
|
||||||
|
def _item(tx_hex=_VALID_TX_HEX, father=None):
|
||||||
|
"""WillItem built from ``tx_hex``, optionally with a stored father."""
|
||||||
|
data = {"tx": tx_hex, "heirs": {}, "willexecutor": None}
|
||||||
|
if father is not None:
|
||||||
|
data["Father"] = father
|
||||||
|
return WillItem(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _wallet_without_adb():
|
||||||
|
"""Wallet whose check_invalidated path skips on-chain lookups."""
|
||||||
|
wallet = MagicMock()
|
||||||
|
wallet.adb = None
|
||||||
|
return wallet
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_father_is_discarded_by_add_willtree():
|
||||||
|
child = _item(father=_MISSING_ID)
|
||||||
|
will = {child._id: child}
|
||||||
|
Will.add_willtree(will)
|
||||||
|
assert child.father is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_invalidated_does_not_crash_on_stale_father():
|
||||||
|
"""The exact failure of the bug report, now without KeyError."""
|
||||||
|
child = _item(father=_MISSING_ID)
|
||||||
|
will = {child._id: child}
|
||||||
|
Will.add_willtree(will)
|
||||||
|
Will.check_invalidated(will, [], _wallet_without_adb())
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_invalidated_tolerates_missing_father_without_add_willtree():
|
||||||
|
"""Defensive path: even if the tree was not rebuilt, no KeyError."""
|
||||||
|
child = _item(father=_MISSING_ID)
|
||||||
|
will = {child._id: child}
|
||||||
|
Will.check_invalidated(will, [], _wallet_without_adb())
|
||||||
|
assert child.father == _MISSING_ID
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_parent_child_link_is_built():
|
||||||
|
parent = _item()
|
||||||
|
child = _item(_CHILD_TX_HEX)
|
||||||
|
will = {_PARENT_ID: parent, child._id: child}
|
||||||
|
Will.add_willtree(will)
|
||||||
|
assert child.father == _PARENT_ID
|
||||||
|
assert [c[0] for c in parent.children] == [child._id]
|
||||||
|
assert parent.father is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_father_is_replaced_by_the_real_parent():
|
||||||
|
parent = _item()
|
||||||
|
child = _item(_CHILD_TX_HEX, father=_MISSING_ID)
|
||||||
|
will = {_PARENT_ID: parent, child._id: child}
|
||||||
|
Will.add_willtree(will)
|
||||||
|
assert child.father == _PARENT_ID
|
||||||
|
|
||||||
|
|
||||||
|
def test_child_of_unconfirmed_parent_is_not_checked_against_utxos():
|
||||||
|
"""Existing rule kept: a child of a VALID (not mined) parent is skipped."""
|
||||||
|
parent = _item()
|
||||||
|
child = _item(_CHILD_TX_HEX)
|
||||||
|
will = {_PARENT_ID: parent, child._id: child}
|
||||||
|
Will.add_willtree(will)
|
||||||
|
wallet = MagicMock()
|
||||||
|
# Funding not spent on-chain: nothing gets invalidated, we only observe
|
||||||
|
# which items had their inputs checked.
|
||||||
|
with patch.object(Will, "check_tx_height", return_value=-2) as height, \
|
||||||
|
patch.object(Will, "_funding_really_spent", return_value=False):
|
||||||
|
Will.check_invalidated({child._id: child, _PARENT_ID: parent}, [], wallet)
|
||||||
|
# Only the parent (a root) looked up its height; the child was skipped.
|
||||||
|
checked = [call.args[0] for call in height.call_args_list]
|
||||||
|
assert checked == [parent.tx]
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_invalidate_cascades_to_children_built_by_add_willtree():
|
||||||
|
"""add_willtree builds children as a LIST: invalidation must cascade."""
|
||||||
|
parent = _item()
|
||||||
|
child = _item(_CHILD_TX_HEX)
|
||||||
|
will = {parent._id: parent, child._id: child}
|
||||||
|
Will.add_willtree(will)
|
||||||
|
Will.set_invalidate(parent._id, will)
|
||||||
|
assert parent.get_status("INVALIDATED") is True
|
||||||
|
assert child.get_status("INVALIDATED") is True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All will-tree father tests passed")
|
||||||
@@ -92,6 +92,31 @@ def test_can_delete_none_false():
|
|||||||
assert _can_delete(None) is False
|
assert _can_delete(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def _signed_valid(we="https://we.example", **flags):
|
||||||
|
"""A VALID + COMPLETE (signed) item, optionally with a will-executor."""
|
||||||
|
item = FakeWillItem(VALID=True, COMPLETE=True, **flags)
|
||||||
|
item.we = {"url": we, "selected": True} if we else None
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_delete_signed_but_never_received_by_executor():
|
||||||
|
"""Push failed / "Not on server": the executor does not hold it."""
|
||||||
|
assert _can_delete(_signed_valid(PUSH_FAIL=True, CHECK_FAIL=True)) is True
|
||||||
|
assert _can_delete(_signed_valid()) is True # signed, never sent
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_delete_signed_and_pushed_false():
|
||||||
|
"""The executor holds it: deleting locally would not cancel it."""
|
||||||
|
assert _can_delete(_signed_valid(PUSHED=True)) is False
|
||||||
|
assert _can_delete(_signed_valid(PUSHED=True, CHECKED=True)) is False
|
||||||
|
assert _can_delete(_signed_valid(CHECKED=True)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_delete_signed_backup_tx_without_executor_false():
|
||||||
|
"""Signed txs without a will-executor (backup) stay protected."""
|
||||||
|
assert _can_delete(_signed_valid(we=None)) is False
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# _apply_select_all
|
# _apply_select_all
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
Reference in New Issue
Block a user