forked from bitcoinafterlife/bal-electrum-plugin
fix(will): prevent double invalidation when postponing a signed will
When postponing the delivery time of an already signed/sent will, the user was asked to sign the on-chain invalidation transaction twice before the new (postponed) will could be built. Root cause: after the invalidation tx was broadcast, on_success_invalidate restarted task_phase1 to rebuild the will, but the old will items were still marked COMPLETE/PUSHED with their original tx.locktime (the on-chain invalidation did not update the in-memory status). The postpone check therefore fired WillPostponedException a second time, requesting another invalidation. Fix: - Add Will.mark_invalidated_by_tx(will, tx): marks INVALIDATED every valid will item that spends a prevout consumed by the just-broadcast invalidation tx. Setting INVALIDATED clears the VALID flag, removing those items from only_valid_list so the postpone/expire check no longer fires. - Call it from loop_broadcast_invalidating after a successful broadcast (txid obtained) and persist via save_willitems. On the phase-1 restart the old will is no longer VALID, so the will is rebuilt directly: a single invalidation signature followed by the new will. Tests: add test_will_mark_invalidated_by_tx and test_will_mark_invalidated_by_tx_no_match plus the WillPostponedException hierarchy assertion. 184 tests pass; smoke and external-zip OK; ruff clean. Bump version to 0.3.1.
This commit is contained in:
committed by
steal
parent
081e46515f
commit
e477c5aa5b
@@ -516,3 +516,44 @@ stato. Le funzioni `server_status_text()` e `server_status_tooltip()` sono in
|
|||||||
firmata ora rileva correttamente la condizione e avvia l'invalidazione.
|
firmata ora rileva correttamente la condizione e avvia l'invalidazione.
|
||||||
|
|
||||||
Confermato dall'utente: **"mi pare che funziona"**.
|
Confermato dall'utente: **"mi pare che funziona"**.
|
||||||
|
|
||||||
|
## 15. BUGFIX: doppia invalidazione al posticipo dell'eredita (v0.3.1)
|
||||||
|
|
||||||
|
### Sintomo segnalato dall'utente
|
||||||
|
Posticipando il delivery time, il plugin chiedeva di firmare **due volte** la
|
||||||
|
transazione di invalidazione ("Invalidate your old will"), e solo dopo faceva
|
||||||
|
firmare la nuova eredita ("Prepare new will").
|
||||||
|
|
||||||
|
### Causa
|
||||||
|
Il percorso di posticipo era:
|
||||||
|
1. `task_phase1` rileva il posticipo -> `WillPostponedException` -> costruisce
|
||||||
|
la tx di invalidazione -> 1ª firma.
|
||||||
|
2. Dopo il broadcast, `on_success_invalidate` **riavvia** `task_phase1` per
|
||||||
|
ricostruire la nuova eredita.
|
||||||
|
3. **Ma** le will item vecchie erano ancora marcate `COMPLETE`/`PUSHED` con la
|
||||||
|
stessa `tx.locktime` di prima (l'invalidazione on-chain non aggiornava lo
|
||||||
|
stato in memoria), quindi la condizione del posticipo scattava di **nuovo**
|
||||||
|
-> `WillPostponedException` -> **2ª** firma di invalidazione.
|
||||||
|
4. Solo al terzo giro la will veniva finalmente ricostruita.
|
||||||
|
|
||||||
|
### Correzione
|
||||||
|
- **`core/will.py`**: nuovo metodo statico `Will.mark_invalidated_by_tx(will,
|
||||||
|
tx)` che marca come `INVALIDATED` ogni will valida che spende almeno uno dei
|
||||||
|
prevout consumati dalla tx di invalidazione appena trasmessa. Settare
|
||||||
|
`INVALIDATED` azzera automaticamente il flag `VALID` (logica gia esistente in
|
||||||
|
`WillItem.set_status`), cosi quelle will escono da `only_valid_list`.
|
||||||
|
- **`gui/qt/dialogs.py`** (`loop_broadcast_invalidating`): dopo il broadcast
|
||||||
|
**riuscito** (quando si ottiene il `txid`), si chiama `mark_invalidated_by_tx`
|
||||||
|
e si salva. Al riavvio di `task_phase1` le vecchie will non sono piu `VALID`,
|
||||||
|
quindi `WillPostponedException` non viene piu sollevata e la will viene
|
||||||
|
ricostruita direttamente. Risultato: **una sola** firma di invalidazione,
|
||||||
|
poi la new will.
|
||||||
|
|
||||||
|
### Test
|
||||||
|
- Aggiunti 2 test in `tests/test_core_will.py`
|
||||||
|
(`test_will_mark_invalidated_by_tx`, `test_will_mark_invalidated_by_tx_no_match`)
|
||||||
|
e l'assert di gerarchia per `WillPostponedException`.
|
||||||
|
- 184 test ufficiali passati; smoke test ed external-zip test OK; `ruff` senza
|
||||||
|
nuove segnalazioni.
|
||||||
|
|
||||||
|
Confermato dall'utente: **"confermo che funziona"**.
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.3.0
|
0.3.1
|
||||||
@@ -34,4 +34,4 @@ The plugin targets Electrum 4.7.2 (the last stable release exposing
|
|||||||
``json_db.register_dict``) and PyQt6.
|
``json_db.register_dict``) and PyQt6.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.3.0"
|
__version__ = "0.3.1"
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ class BalPlugin(BasePlugin):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
_version = None
|
_version = None
|
||||||
__version__ = "0.3.0" # AUTOMATICALLY GENERATED DO NOT EDIT
|
__version__ = "0.3.1" # AUTOMATICALLY GENERATED DO NOT EDIT
|
||||||
|
|
||||||
# Command used to open an .ics calendar file, per operating system.
|
# Command used to open an .ics calendar file, per operating system.
|
||||||
default_app = {
|
default_app = {
|
||||||
|
|||||||
@@ -420,6 +420,31 @@ class Will:
|
|||||||
_logger.debug("len utxo_to_spend <=0")
|
_logger.debug("len utxo_to_spend <=0")
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def mark_invalidated_by_tx(will, tx):
|
||||||
|
"""Mark as INVALIDATED every valid will item that spends at least one
|
||||||
|
of the prevouts consumed by ``tx`` (the on-chain invalidation tx that
|
||||||
|
was just broadcast).
|
||||||
|
|
||||||
|
Once the invalidation tx is broadcast, the previously signed/sent will
|
||||||
|
transactions that relied on those same UTXOs can no longer be mined, so
|
||||||
|
their will items must stop being VALID. Setting INVALIDATED clears the
|
||||||
|
VALID flag (see WillItem.set_status), which removes them from
|
||||||
|
only_valid_list and therefore prevents the postpone/expire check from
|
||||||
|
firing a *second* invalidation on the next pass.
|
||||||
|
|
||||||
|
Returns the list of will ids that were marked.
|
||||||
|
"""
|
||||||
|
spent_prevouts = {i.prevout.to_str() for i in tx.inputs()}
|
||||||
|
invalidated = []
|
||||||
|
for wid in Will.only_valid_list(will):
|
||||||
|
w = will[wid]
|
||||||
|
wi_prevouts = {i.prevout.to_str() for i in w.tx.inputs()}
|
||||||
|
if spent_prevouts & wi_prevouts:
|
||||||
|
Will.set_invalidate(wid, will)
|
||||||
|
invalidated.append(wid)
|
||||||
|
return invalidated
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_new(will):
|
def is_new(will):
|
||||||
for wid, w in will.items():
|
for wid, w in will.items():
|
||||||
|
|||||||
@@ -708,6 +708,19 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
self.msg_set_invalidating(self.msg_ok())
|
self.msg_set_invalidating(self.msg_ok())
|
||||||
if not txid:
|
if not txid:
|
||||||
_logger.debug(f"should not be none txid: {txid}")
|
_logger.debug(f"should not be none txid: {txid}")
|
||||||
|
else:
|
||||||
|
# The invalidation tx is now broadcast, so the old signed/sent
|
||||||
|
# will transactions spending those same UTXOs can no longer be
|
||||||
|
# mined. Mark them INVALIDATED (which clears their VALID flag)
|
||||||
|
# so the postpone/expire check does NOT fire a second
|
||||||
|
# invalidation when phase 1 is restarted to rebuild the new
|
||||||
|
# (postponed) will.
|
||||||
|
invalidated = Will.mark_invalidated_by_tx(
|
||||||
|
self.bal_window.willitems, tx
|
||||||
|
)
|
||||||
|
if invalidated:
|
||||||
|
_logger.debug(f"invalidated will items: {invalidated}")
|
||||||
|
self.bal_window.save_willitems()
|
||||||
|
|
||||||
except TxBroadcastError as e:
|
except TxBroadcastError as e:
|
||||||
_logger.error(f"fail to broadcast transaction:{e}")
|
_logger.error(f"fail to broadcast transaction:{e}")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "bal",
|
"name": "bal",
|
||||||
"fullname": "Bitcoin After Life",
|
"fullname": "Bitcoin After Life",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.",
|
"description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.",
|
||||||
"author": "Svatantrya",
|
"author": "Svatantrya",
|
||||||
"licence": "MIT",
|
"licence": "MIT",
|
||||||
|
|||||||
@@ -228,6 +228,79 @@ def test_will_check_tx_height():
|
|||||||
# Exception classes
|
# Exception classes
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_will_mark_invalidated_by_tx():
|
||||||
|
"""A valid will spending the same prevout as the invalidation tx must be
|
||||||
|
marked INVALIDATED (and therefore lose its VALID flag). This is what
|
||||||
|
prevents the postpone/expire check from firing a *second* invalidation
|
||||||
|
when phase 1 is restarted after a successful on-chain invalidation."""
|
||||||
|
class FakePrevout:
|
||||||
|
def __init__(self, s):
|
||||||
|
self._s = s
|
||||||
|
def to_str(self):
|
||||||
|
return self._s
|
||||||
|
|
||||||
|
class FakeInput:
|
||||||
|
def __init__(self, s):
|
||||||
|
self.prevout = FakePrevout(s)
|
||||||
|
|
||||||
|
class FakeTx:
|
||||||
|
def __init__(self, prevouts):
|
||||||
|
self._inputs = [FakeInput(p) for p in prevouts]
|
||||||
|
def inputs(self):
|
||||||
|
return self._inputs
|
||||||
|
|
||||||
|
# The real test will item spends this prevout (from _VALID_TX_HEX).
|
||||||
|
spent = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0"
|
||||||
|
|
||||||
|
# Will item that spends the same UTXO -> must be invalidated.
|
||||||
|
item_match = _make_willitem_blank()
|
||||||
|
item_match.set_status("COMPLETE", True)
|
||||||
|
# Will item that spends an unrelated UTXO -> must stay VALID.
|
||||||
|
item_other = _make_willitem_blank()
|
||||||
|
item_other.tx = FakeTx(["deadbeef:1"])
|
||||||
|
item_other.children = {}
|
||||||
|
|
||||||
|
will = {"match": item_match, "other": item_other}
|
||||||
|
inval_tx = FakeTx([spent])
|
||||||
|
|
||||||
|
invalidated = Will.mark_invalidated_by_tx(will, inval_tx)
|
||||||
|
|
||||||
|
assert "match" in invalidated
|
||||||
|
assert "other" not in invalidated
|
||||||
|
assert will["match"].get_status("INVALIDATED") is True
|
||||||
|
assert will["match"].get_status("VALID") is False
|
||||||
|
assert will["other"].get_status("VALID") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_will_mark_invalidated_by_tx_no_match():
|
||||||
|
"""If no valid will spends any of the invalidation tx's prevouts, nothing
|
||||||
|
is marked."""
|
||||||
|
class FakePrevout:
|
||||||
|
def __init__(self, s):
|
||||||
|
self._s = s
|
||||||
|
def to_str(self):
|
||||||
|
return self._s
|
||||||
|
|
||||||
|
class FakeInput:
|
||||||
|
def __init__(self, s):
|
||||||
|
self.prevout = FakePrevout(s)
|
||||||
|
|
||||||
|
class FakeTx:
|
||||||
|
def __init__(self, prevouts):
|
||||||
|
self._inputs = [FakeInput(p) for p in prevouts]
|
||||||
|
def inputs(self):
|
||||||
|
return self._inputs
|
||||||
|
|
||||||
|
item = _make_willitem_blank()
|
||||||
|
will = {"a": item}
|
||||||
|
inval_tx = FakeTx(["unrelated:9"])
|
||||||
|
|
||||||
|
invalidated = Will.mark_invalidated_by_tx(will, inval_tx)
|
||||||
|
|
||||||
|
assert invalidated == []
|
||||||
|
assert will["a"].get_status("VALID") is True
|
||||||
|
|
||||||
|
|
||||||
def test_exceptions():
|
def test_exceptions():
|
||||||
from bal.core.will import (
|
from bal.core.will import (
|
||||||
WillException, WillExpiredException, NotCompleteWillException,
|
WillException, WillExpiredException, NotCompleteWillException,
|
||||||
@@ -235,6 +308,7 @@ def test_exceptions():
|
|||||||
WillexecutorChangeException, NoWillExecutorNotPresent,
|
WillexecutorChangeException, NoWillExecutorNotPresent,
|
||||||
WillExecutorNotPresent, NoHeirsException,
|
WillExecutorNotPresent, NoHeirsException,
|
||||||
AmountException, PercAmountException, FixedAmountException,
|
AmountException, PercAmountException, FixedAmountException,
|
||||||
|
WillPostponedException,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert issubclass(WillExpiredException, WillException)
|
assert issubclass(WillExpiredException, WillException)
|
||||||
@@ -248,6 +322,9 @@ def test_exceptions():
|
|||||||
assert issubclass(NoHeirsException, WillException)
|
assert issubclass(NoHeirsException, WillException)
|
||||||
assert issubclass(PercAmountException, AmountException)
|
assert issubclass(PercAmountException, AmountException)
|
||||||
assert issubclass(FixedAmountException, AmountException)
|
assert issubclass(FixedAmountException, AmountException)
|
||||||
|
# WillPostponedException is a NotCompleteWillException but MUST be caught
|
||||||
|
# before it in task_phase1, so it triggers an on-chain invalidation.
|
||||||
|
assert issubclass(WillPostponedException, NotCompleteWillException)
|
||||||
|
|
||||||
# WillException default message
|
# WillException default message
|
||||||
exc = WillException()
|
exc = WillException()
|
||||||
|
|||||||
Reference in New Issue
Block a user