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:
GenSpark AI Developer
2026-06-15 23:05:59 +00:00
committed by steal
parent 081e46515f
commit e477c5aa5b
8 changed files with 160 additions and 4 deletions

View File

@@ -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()