diff --git a/CHANGELOG_REFACTOR.md b/CHANGELOG_REFACTOR.md index 019ecf1..6791dc3 100644 --- a/CHANGELOG_REFACTOR.md +++ b/CHANGELOG_REFACTOR.md @@ -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. 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"**. diff --git a/bal/VERSION b/bal/VERSION index 0d91a54..a2268e2 100644 --- a/bal/VERSION +++ b/bal/VERSION @@ -1 +1 @@ -0.3.0 +0.3.1 \ No newline at end of file diff --git a/bal/__init__.py b/bal/__init__.py index e57a0ad..87c2b70 100644 --- a/bal/__init__.py +++ b/bal/__init__.py @@ -34,4 +34,4 @@ The plugin targets Electrum 4.7.2 (the last stable release exposing ``json_db.register_dict``) and PyQt6. """ -__version__ = "0.3.0" +__version__ = "0.3.1" diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py index e1ab7fd..1f674bf 100644 --- a/bal/core/plugin_base.py +++ b/bal/core/plugin_base.py @@ -91,7 +91,7 @@ class BalPlugin(BasePlugin): """ _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. default_app = { diff --git a/bal/core/will.py b/bal/core/will.py index 1576c6e..bdac8e2 100644 --- a/bal/core/will.py +++ b/bal/core/will.py @@ -420,6 +420,31 @@ class Will: _logger.debug("len utxo_to_spend <=0") 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 def is_new(will): for wid, w in will.items(): diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index 37cf798..c795905 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -708,6 +708,19 @@ class BalBuildWillDialog(BalDialog): self.msg_set_invalidating(self.msg_ok()) if not 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: _logger.error(f"fail to broadcast transaction:{e}") diff --git a/bal/manifest.json b/bal/manifest.json index 45903c8..3710ffc 100644 --- a/bal/manifest.json +++ b/bal/manifest.json @@ -1,7 +1,7 @@ { "name": "bal", "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.", "author": "Svatantrya", "licence": "MIT", diff --git a/tests/test_core_will.py b/tests/test_core_will.py index 54a244c..467ebbd 100644 --- a/tests/test_core_will.py +++ b/tests/test_core_will.py @@ -228,6 +228,79 @@ def test_will_check_tx_height(): # 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(): from bal.core.will import ( WillException, WillExpiredException, NotCompleteWillException, @@ -235,6 +308,7 @@ def test_exceptions(): WillexecutorChangeException, NoWillExecutorNotPresent, WillExecutorNotPresent, NoHeirsException, AmountException, PercAmountException, FixedAmountException, + WillPostponedException, ) assert issubclass(WillExpiredException, WillException) @@ -248,6 +322,9 @@ def test_exceptions(): assert issubclass(NoHeirsException, WillException) assert issubclass(PercAmountException, 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 exc = WillException()