Compare commits
1 Commits
fix/i18n-r
...
fix/i18n-r
| Author | SHA1 | Date | |
|---|---|---|---|
| a81443ff67 |
214
CHANGELOG.md
214
CHANGELOG.md
@@ -3587,198 +3587,28 @@ 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.
|
||||||
|
|
||||||
**Verification:** extended release test list (as above) plus the new test
|
**Scope:** this entry contains ONLY the i18n review fixes. The other fixes
|
||||||
files: 489 passed, 1 failed (pre-existing: `test_group_e_mock_karen7::test_e5`
|
prepared in the same session (invalidation fee, Check `KeyError`, cascade,
|
||||||
needs `tests/karen7`, not in the repository; `main` 827a1d1 gives 456 passed,
|
deselected executor) are not part of it and will be proposed separately.
|
||||||
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.
|
|
||||||
|
|
||||||
**Outcome:** DONE (pending the owner's test of the ZIP and Truman's review).
|
**Verification (on `main` 827a1d1):**
|
||||||
|
|
||||||
---
|
- New test file `tests/test_core_status_language_neutral.py`: 5 of 7 fail on
|
||||||
|
`main`, 7 of 7 pass with this change.
|
||||||
|
- Extended release test list (as above) plus the new test file: 446 passed,
|
||||||
|
1 failed (`main`: 439 passed, same failure). The failure is pre-existing:
|
||||||
|
`test_group_e_mock_karen7::test_e5` needs `tests/karen7`, a wallet file that
|
||||||
|
is not in the repository.
|
||||||
|
- Whole `tests/` directory: 609 passed, 26 failed, 3 collection errors
|
||||||
|
(`main`: 602 passed, and the SAME 26 failures and 3 errors, test by test).
|
||||||
|
All pre-existing and unrelated: 9 need `tests/karen7`; 21 in
|
||||||
|
`test_auto_rebuild_on_new_tx.py`, `test_cli_autorebuild.py` and
|
||||||
|
`test_cli_controller_offline.py` fail only when the whole directory runs
|
||||||
|
together (they pass when each file runs alone); 3 audio tests need an audio
|
||||||
|
device. `test_gui_export_dialogs.py::test_audio_import_receive_wiring` hangs
|
||||||
|
forever, also on `main`, so it was deselected.
|
||||||
|
- `ruff check bal/ tests/`: the same 3 pre-existing I001 as on `main`
|
||||||
|
(`dialogs.py`, `test_core_animated_qr.py`, `test_core_qr_transfer.py`),
|
||||||
|
none new.
|
||||||
|
|
||||||
## 64. Invalidation pays the current network fee (2 blocks) instead of `baltx_fees`
|
**Outcome:** DONE (pending Truman's review).
|
||||||
|
|
||||||
**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,11 +767,7 @@ class BalController:
|
|||||||
if not willitems:
|
if not willitems:
|
||||||
raise UserFacingException(_("No transaction matches the given txids"))
|
raise UserFacingException(_("No transaction matches the given txids"))
|
||||||
|
|
||||||
# Skip executors the user deselected after building the will (the
|
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
|
||||||
# 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,7 +26,6 @@ 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
|
||||||
@@ -51,13 +50,6 @@ 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__)
|
||||||
|
|
||||||
|
|
||||||
@@ -76,22 +68,6 @@ 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:
|
||||||
@@ -462,68 +438,9 @@ 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)
|
||||||
@@ -560,17 +477,7 @@ 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_rate, fee_source = Will.get_invalidation_fee_rate(
|
fee = tx.estimated_size() * fees_per_byte
|
||||||
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
|
||||||
@@ -655,15 +562,9 @@ 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)
|
||||||
children = will[wid].children or []
|
if will[wid].children:
|
||||||
# add_willtree builds a LIST of [child_id, input_idx, prevout_idx];
|
for c in will[wid].children.items():
|
||||||
# a dict keyed by child id is still accepted (older data / tests).
|
Will.set_invalidate(c[0], will)
|
||||||
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):
|
||||||
@@ -674,15 +575,10 @@ 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 (
|
||||||
father is None
|
not w.father
|
||||||
or father.get_status("CONFIRMED")
|
or willtree[w.father].get_status("CONFIRMED")
|
||||||
or father.get_status("MEMPOOL")
|
or willtree[w.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,19 +230,12 @@ class Willexecutors:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_willexecutor_transactions(will, force=False, current_willexecutors=None):
|
def get_willexecutor_transactions(will, force=False):
|
||||||
"""Group the valid, signed, not-yet-pushed will txs by will-executor.
|
"""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 = {}
|
||||||
@@ -252,12 +245,6 @@ 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,27 +93,13 @@ def _can_broadcast(will_item):
|
|||||||
|
|
||||||
|
|
||||||
def _can_delete(will_item):
|
def _can_delete(will_item):
|
||||||
"""True if a will transaction may be deleted.
|
"""True if a will transaction may be deleted (invalid or unsigned).
|
||||||
|
|
||||||
Deletable:
|
Valid AND fully-signed transactions are never deletable: removing them
|
||||||
* invalid or not fully signed transactions (unchanged rule);
|
would silently drop a committed inheritance.
|
||||||
* 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.
|
|
||||||
"""
|
"""
|
||||||
if not will_item:
|
return bool(will_item) and (
|
||||||
return False
|
not will_item.get_status("VALID") or not will_item.get_status("COMPLETE")
|
||||||
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,15 +1632,7 @@ 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
|
||||||
}
|
}
|
||||||
# Read the CURRENT executor list from the config, so an executor the
|
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
|
||||||
# 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"
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for ``Willexecutors.get_willexecutor_transactions`` with the CURRENT
|
|
||||||
executor selection (``current_willexecutors``).
|
|
||||||
|
|
||||||
Every will item keeps its own copy of the executor record taken at build time
|
|
||||||
(``selected`` = True). Without the current selection, an executor the user
|
|
||||||
deselected after building the will would still receive the will.
|
|
||||||
|
|
||||||
Run (standalone or with pytest):
|
|
||||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
|
||||||
python3 tests/test_core_deselected_executor.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
|
||||||
|
|
||||||
from bal.core.willexecutors import Willexecutors
|
|
||||||
|
|
||||||
|
|
||||||
class FakeItem:
|
|
||||||
"""Minimal will item: statuses, a tx and the build-time executor copy."""
|
|
||||||
|
|
||||||
def __init__(self, url, **flags):
|
|
||||||
self._status = {"VALID": True, "COMPLETE": True}
|
|
||||||
self._status.update(flags)
|
|
||||||
self.we = {"url": url, "selected": True}
|
|
||||||
self.tx = f"rawtx-{url}"
|
|
||||||
|
|
||||||
def get_status(self, name):
|
|
||||||
return self._status.get(name, False)
|
|
||||||
|
|
||||||
|
|
||||||
A = "https://we.a.example"
|
|
||||||
B = "https://we.b.example"
|
|
||||||
|
|
||||||
|
|
||||||
def _will():
|
|
||||||
return {"tx_a": FakeItem(A), "tx_b": FakeItem(B)}
|
|
||||||
|
|
||||||
|
|
||||||
def test_without_current_list_previous_behaviour():
|
|
||||||
assert set(Willexecutors.get_willexecutor_transactions(_will())) == {A, B}
|
|
||||||
|
|
||||||
|
|
||||||
def test_deselected_executor_is_skipped():
|
|
||||||
current = {A: {"selected": True}, B: {"selected": False}}
|
|
||||||
out = Willexecutors.get_willexecutor_transactions(
|
|
||||||
_will(), current_willexecutors=current
|
|
||||||
)
|
|
||||||
assert set(out) == {A}
|
|
||||||
assert out[A]["txsids"] == ["tx_a"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_executor_removed_from_list_is_skipped():
|
|
||||||
current = {A: {"selected": True}}
|
|
||||||
out = Willexecutors.get_willexecutor_transactions(
|
|
||||||
_will(), current_willexecutors=current
|
|
||||||
)
|
|
||||||
assert set(out) == {A}
|
|
||||||
|
|
||||||
|
|
||||||
def test_already_pushed_still_excluded_unless_forced():
|
|
||||||
will = {"tx_a": FakeItem(A, PUSHED=True)}
|
|
||||||
current = {A: {"selected": True}}
|
|
||||||
assert Willexecutors.get_willexecutor_transactions(
|
|
||||||
will, current_willexecutors=current
|
|
||||||
) == {}
|
|
||||||
assert set(
|
|
||||||
Willexecutors.get_willexecutor_transactions(
|
|
||||||
will, force=True, current_willexecutors=current
|
|
||||||
)
|
|
||||||
) == {A}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
for name in sorted(dir()):
|
|
||||||
if name.startswith("test_"):
|
|
||||||
globals()[name]()
|
|
||||||
print(f" [OK] {name}")
|
|
||||||
print("[OK] All deselected-executor tests passed")
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
"""
|
|
||||||
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")
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
"""
|
|
||||||
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,31 +92,6 @@ 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