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