forked from bitcoinafterlife/bal-electrum-plugin
feat(will): invalidate signed will on postpone + add Server status column
Postpone safety (Strategy B): - A signed/sent will carries an immutable locktime; postponing the delivery time previously did nothing, so a will-executor could still broadcast the old (earlier-locktime) transaction and execute the inheritance too early. - core/will.py: add WillPostponedException and detect postpone by comparing the requested locktime against w.tx.locktime (the locktime frozen in the signed transaction) instead of the in-memory heir entry, which is updated together with the new value and would always compare equal. - gui/qt/dialogs.py (BalBuildWillDialog.task_phase1, the real path used by Tools -> Prepare): handle WillPostponedException before NotCompleteWill; return (None, tx) to trigger sign + broadcast of the invalidation, then the user presses Prepare again to rebuild/re-sign/re-send (two explicit steps). - gui/qt/window.py: mirror the branch in build_inheritance_transaction with an explanatory message; wording aligned to the 'Prepare' button. - gui/qt/common.py: export WillPostponedException. - A postpone on a will that was never signed/sent just rebuilds (no on-chain fee). Server status column: - gui/qt/lists.py: add a dedicated 'Server' column to PreviewList with an always-readable label and a tooltip (will-executor URL + state). - gui/qt/theme.py: add server_status_text() and server_status_tooltip(), reusing the existing status flags. - gui/qt/common.py: export the new theme helpers. Docs: update README.md, bal/README.md and CHANGELOG_REFACTOR.md. Tests: 182 passed; smoke + external-zip OK; ruff has no new real findings.
This commit is contained in:
committed by
steal
parent
4e027d9e8b
commit
343046ed34
@@ -1,2 +1,23 @@
|
||||
# BalPlugin
|
||||
Bitcoin After Life Electrum Plugin
|
||||
|
||||
Free and decentralized Bitcoin inheritance support for Electrum: build
|
||||
time-locked "will" transactions that transfer your funds to your heirs if you
|
||||
stop refreshing them (dead-man's switch), optionally relayed by will-executor
|
||||
servers.
|
||||
|
||||
## Key behaviours
|
||||
|
||||
- **Anticipate / postpone safety**: changing the delivery time of an
|
||||
already-signed will is handled safely. Postponing a signed/sent will first
|
||||
asks you to invalidate the old transaction on-chain (so a will-executor can
|
||||
never broadcast the earlier-locktime transaction and execute the inheritance
|
||||
too early), then lets you rebuild and re-send the new one via
|
||||
**Tools → Prepare**.
|
||||
- **"Server" column**: the will transaction list shows whether each transaction
|
||||
is actually stored on the will-executor servers
|
||||
(`Confirmed on server`, `Sent (not checked)`, `Send failed`,
|
||||
`Not on server`, `Signed (not sent)`, `Not sent`), with a tooltip showing the
|
||||
will-executor URL.
|
||||
|
||||
See the top-level [`README.md`](../README.md) for installation and testing.
|
||||
|
||||
@@ -671,14 +671,41 @@ class Will:
|
||||
their = will[wid].heirs[wheir]
|
||||
if heir := heirs.get(wheir, None):
|
||||
|
||||
if (
|
||||
heir[0] == their[0]
|
||||
and heir[1] == their[1]
|
||||
and Util.parse_locktime_string(heir[2])
|
||||
>= Util.parse_locktime_string(their[2])
|
||||
):
|
||||
count = heirs_found.get(wheir, 0)
|
||||
heirs_found[wheir] = count + 1
|
||||
if heir[0] == their[0] and heir[1] == their[1]:
|
||||
# The requested (possibly new) locktime for this heir.
|
||||
new_locktime = Util.parse_locktime_string(heir[2])
|
||||
# IMPORTANT: compare against the locktime that is
|
||||
# actually frozen inside the already-signed Bitcoin
|
||||
# transaction (w.tx.locktime), NOT against their[2].
|
||||
# their[2] is the heir entry stored in the will item,
|
||||
# which is updated in memory together with the new
|
||||
# heirs dict when the user postpones, so it would
|
||||
# always equal new_locktime and the postpone would go
|
||||
# undetected. w.tx.locktime is immutable once signed
|
||||
# and is exactly what the will-executors hold.
|
||||
tx_locktime = int(w.tx.locktime)
|
||||
if new_locktime == tx_locktime:
|
||||
# Unchanged: this heir is still coherent.
|
||||
count = heirs_found.get(wheir, 0)
|
||||
heirs_found[wheir] = count + 1
|
||||
elif new_locktime > tx_locktime and (
|
||||
w.get_status("COMPLETE") or w.get_status("PUSHED")
|
||||
):
|
||||
# POSTPONE of an already signed/sent will: the
|
||||
# old pre-signed tx must be invalidated on-chain
|
||||
# first, otherwise a will-executor could
|
||||
# broadcast the earlier-locktime tx and execute
|
||||
# the inheritance too early.
|
||||
raise WillPostponedException(
|
||||
f"{wheir}: locktime postponed "
|
||||
f"{tx_locktime}->{new_locktime} "
|
||||
f"on a signed/sent will"
|
||||
)
|
||||
# new_locktime < tx_locktime (anticipate) is left to
|
||||
# check_will_expired -> WillExpiredException.
|
||||
# new_locktime > tx_locktime on a will that was never
|
||||
# signed/sent falls through here -> a plain rebuild via
|
||||
# HeirNotFoundException (no on-chain fee needed).
|
||||
else:
|
||||
_logger.debug(
|
||||
f"heir not present transaction is not valid:{wheir} {wid}, {w}"
|
||||
@@ -912,6 +939,21 @@ class HeirNotFoundException(NotCompleteWillException):
|
||||
pass
|
||||
|
||||
|
||||
class WillPostponedException(NotCompleteWillException):
|
||||
"""An already signed/sent will is being postponed.
|
||||
|
||||
When a will that has already been signed (``COMPLETE``) and/or pushed to
|
||||
will-executors (``PUSHED``) gets its locktime moved to a LATER date, the
|
||||
previously committed coins must be invalidated on-chain BEFORE rebuilding
|
||||
the new inheritance. Otherwise a will-executor could broadcast the old
|
||||
(earlier-locktime) transaction and execute the inheritance too early to
|
||||
collect the fees. Invalidating spends the same UTXOs now, permanently
|
||||
voiding the old pre-signed transaction.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WillexecutorChangeException(NotCompleteWillException):
|
||||
pass
|
||||
|
||||
|
||||
@@ -69,11 +69,11 @@ from ...core.will import (AmountException, HeirChangeException,
|
||||
NotCompleteWillException, NoWillExecutorNotPresent,
|
||||
TxFeesChangedException, Will,
|
||||
WillexecutorChangeException, WillExecutorNotPresent,
|
||||
WillExpiredException, WillItem)
|
||||
WillExpiredException, WillItem, WillPostponedException)
|
||||
from ...core.willexecutors import Willexecutors
|
||||
|
||||
# --- Presentation helpers ---
|
||||
from .theme import status_color
|
||||
from .theme import server_status_text, server_status_tooltip, status_color
|
||||
from .window_utils import (bring_to_front, show_modal, show_on_top,
|
||||
stop_thread, top_level_of)
|
||||
|
||||
|
||||
@@ -595,6 +595,20 @@ class BalBuildWillDialog(BalDialog):
|
||||
return None, Will.invalidate_will(
|
||||
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte
|
||||
)
|
||||
except WillPostponedException as e:
|
||||
# An already signed/sent will is being postponed. Like an expired
|
||||
# will, the previously committed coins must be invalidated on-chain
|
||||
# FIRST (otherwise a will-executor could broadcast the old,
|
||||
# earlier-locktime tx and execute the inheritance too early). We
|
||||
# return (None, tx) so phase 2 asks the user to sign and broadcast
|
||||
# the invalidation; afterwards the user presses Prepare again to
|
||||
# rebuild the new (postponed) inheritance.
|
||||
_logger.debug(f"postponed {e}")
|
||||
self.msg_set_checking(_("Postponed: invalidating old will"))
|
||||
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1)
|
||||
return None, Will.invalidate_will(
|
||||
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte
|
||||
)
|
||||
except NoHeirsException as e:
|
||||
_logger.debug("no heirs")
|
||||
self.msg_set_checking("No Heirs")
|
||||
|
||||
@@ -228,12 +228,14 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
TXID = enum.auto()
|
||||
WILLEXECUTOR = enum.auto()
|
||||
STATUS = enum.auto()
|
||||
SERVER = enum.auto()
|
||||
|
||||
headers = {
|
||||
Columns.LOCKTIME: _("Locktime"),
|
||||
Columns.TXID: _("Txid"),
|
||||
Columns.WILLEXECUTOR: _("Will-Executor"),
|
||||
Columns.STATUS: _("Status"),
|
||||
Columns.SERVER: _("Server"),
|
||||
}
|
||||
|
||||
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000
|
||||
@@ -385,6 +387,9 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
if len(bal_tx.status) > 53:
|
||||
status = "...{}".format(status[-50:])
|
||||
labels[self.Columns.STATUS] = status
|
||||
# Dedicated, always-readable label describing whether the inheritance
|
||||
# transaction is actually stored on the will-executor servers.
|
||||
labels[self.Columns.SERVER] = server_status_text(bal_tx)
|
||||
|
||||
items = []
|
||||
for e in labels:
|
||||
@@ -398,6 +403,13 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
|
||||
items[-1].setBackground(QColor(status_color(bal_tx)))
|
||||
|
||||
# Tooltip on the Server column: shows the will-executor URL (if any)
|
||||
# plus the current server state, so the user can always inspect details.
|
||||
try:
|
||||
items[self.Columns.SERVER].setToolTip(server_status_tooltip(bal_tx))
|
||||
except Exception as tip_err:
|
||||
_logger.debug(f"server tooltip error: {tip_err}")
|
||||
|
||||
row_count = self.model().rowCount()
|
||||
self.model().insertRow(row_count, items)
|
||||
if txid == current_key:
|
||||
|
||||
@@ -57,3 +57,41 @@ def status_color(will_item) -> str:
|
||||
return "#2bc8ed" # blue - signed
|
||||
else:
|
||||
return _DEFAULT_COLOR
|
||||
|
||||
|
||||
def server_status_text(will_item) -> str:
|
||||
"""Return a short, human-readable label describing the state of a will
|
||||
item on the will-executor servers (the online inheritance backup).
|
||||
|
||||
This is shown in the dedicated "Server" column of the transaction list so
|
||||
the user always knows whether each inheritance transaction is actually
|
||||
stored on the will-executor servers, regardless of the row colour.
|
||||
"""
|
||||
from electrum.i18n import _
|
||||
|
||||
if will_item.get_status("CHECK_FAIL") and not will_item.get_status("CHECKED"):
|
||||
return _("Not on server")
|
||||
if will_item.get_status("CHECKED"):
|
||||
return _("Confirmed on server")
|
||||
if will_item.get_status("PUSH_FAIL"):
|
||||
return _("Send failed")
|
||||
if will_item.get_status("PUSHED"):
|
||||
return _("Sent (not checked)")
|
||||
if will_item.get_status("COMPLETE"):
|
||||
return _("Signed (not sent)")
|
||||
return _("Not sent")
|
||||
|
||||
|
||||
def server_status_tooltip(will_item) -> str:
|
||||
"""Return a detailed tooltip for the "Server" column, including the
|
||||
will-executor URL (if any) and the current server state."""
|
||||
from electrum.i18n import _
|
||||
|
||||
url = None
|
||||
we = getattr(will_item, "we", None)
|
||||
if we:
|
||||
url = we.get("url")
|
||||
state = server_status_text(will_item)
|
||||
if url:
|
||||
return "{}: {}\n{}".format(_("Will-Executor"), url, state)
|
||||
return "{}\n{}".format(_("No will-executor"), state)
|
||||
|
||||
@@ -529,6 +529,29 @@ class BalWindow:
|
||||
return
|
||||
except NoHeirsException:
|
||||
return
|
||||
except WillPostponedException as e:
|
||||
# The will was already signed/sent and is being postponed.
|
||||
# We do NOT rebuild automatically: the user must first sign and
|
||||
# broadcast the invalidation tx (so the old, earlier-locktime tx
|
||||
# can never be used by a will-executor), then press "Prepare"
|
||||
# again
|
||||
# to create the new postponed inheritance.
|
||||
_logger.info(f"will postponed: {e}")
|
||||
self.show_message(
|
||||
_(
|
||||
"This inheritance was already signed/sent to "
|
||||
"will-executors and you are postponing it.\n\n"
|
||||
"The previously committed coins must be invalidated "
|
||||
"on-chain FIRST, otherwise a will-executor could "
|
||||
"broadcast the old (earlier) transaction and execute "
|
||||
"the inheritance too early.\n\n"
|
||||
"Please sign and broadcast the invalidation transaction "
|
||||
"now, then press 'Prepare' again to create the new "
|
||||
"(postponed) inheritance."
|
||||
)
|
||||
)
|
||||
self.invalidate_will()
|
||||
return
|
||||
except NotCompleteWillException as e:
|
||||
_logger.info("{}:{}".format(type(e), e))
|
||||
message = False
|
||||
|
||||
Reference in New Issue
Block a user