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:
@@ -455,3 +455,64 @@ producono **lo stesso identico risultato** di prima.
|
||||
Verificato anche che il test **fallisce** senza il fix.
|
||||
|
||||
Confermato dall'utente: **"si ora funziona"**.
|
||||
|
||||
## 14. NUOVA FUNZIONE: invalidazione automatica al posticipo dell'eredità
|
||||
|
||||
### Problema
|
||||
Una transazione di eredità viene firmata con un **locktime fisso e immutabile**
|
||||
e inviata ai will-executor, che sono economicamente incentivati a trasmetterla
|
||||
(incassano le fee). Se l'utente, dopo aver firmato/inviato, **posticipa** la
|
||||
data di consegna (es. di un anno), la **vecchia** transazione gia firmata resta
|
||||
valida sui server dei will-executor. Poiche ha il locktime piu basso, un
|
||||
will-executor potrebbe trasmetterla appena scade, eseguendo l'eredita **in
|
||||
anticipo** rispetto alla nuova volonta dell'utente. La versione precedente
|
||||
**non gestiva** questo caso: il posticipo non produceva alcuna azione.
|
||||
|
||||
### Soluzione (Strategia B — invalidazione esplicita on-chain)
|
||||
Al posticipo di un'eredita **gia firmata e/o inviata** (stato `COMPLETE` o
|
||||
`PUSHED`), il plugin chiede di **invalidare on-chain** i fondi prima di
|
||||
ricostruire la nuova eredita. L'invalidazione spende gli stessi UTXO verso un
|
||||
nuovo indirizzo di change con `locktime = altezza corrente` (RBF), quindi e
|
||||
trasmettibile subito: una volta confermata, la vecchia transazione pre-firmata
|
||||
diventa **definitivamente inutilizzabile**, vincendo la corsa contro qualunque
|
||||
will-executor.
|
||||
|
||||
### Dettagli tecnici
|
||||
- **`core/will.py`**:
|
||||
- nuova eccezione `WillPostponedException` (sottoclasse di
|
||||
`NotCompleteWillException`);
|
||||
- `check_willexecutors_and_heirs`: il confronto del locktime non usa piu
|
||||
l'entry dell'erede memorizzata (`their[2]`), che viene aggiornata in memoria
|
||||
insieme al nuovo valore al momento del posticipo e quindi risulterebbe
|
||||
sempre uguale. Ora confronta il locktime richiesto con **`w.tx.locktime`**,
|
||||
cioe il locktime **congelato** nella transazione firmata (immutabile, e
|
||||
quello che i will-executor possiedono). Tre casi: invariato → coerente;
|
||||
nuovo > tx su will firmato/inviato → `WillPostponedException`; nuovo > tx su
|
||||
will mai inviato → semplice ricostruzione (nessuna fee on-chain).
|
||||
- **`gui/qt/dialogs.py`** (`BalBuildWillDialog.task_phase1`, il percorso reale
|
||||
usato da **Tools → Prepare**): aggiunto il ramo `except WillPostponedException`
|
||||
**prima** di `NotCompleteWillException`; si comporta come il caso "will
|
||||
scaduto" e ritorna `(None, tx)` per innescare firma + broadcast
|
||||
dell'invalidazione. L'utente preme di nuovo **Prepare** per ricostruire,
|
||||
rifirmare e reinviare la nuova eredita (due passi espliciti, per maggior
|
||||
controllo).
|
||||
- **`gui/qt/window.py`** (`build_inheritance_transaction`): aggiunto lo stesso
|
||||
ramo per completezza del percorso alternativo, con messaggio esplicativo.
|
||||
- **`gui/qt/common.py`**: `WillPostponedException` esportato.
|
||||
|
||||
### NUOVA COLONNA "Server" nella lista transazioni
|
||||
Per dare all'utente visibilita costante sullo stato online delle proprie
|
||||
transazioni di eredita, e stata aggiunta una colonna dedicata **"Server"** in
|
||||
`PreviewList` (`gui/qt/lists.py`), con etichetta sempre leggibile
|
||||
(`Confirmed on server`, `Sent (not checked)`, `Send failed`, `Not on server`,
|
||||
`Signed (not sent)`, `Not sent`) e **tooltip** con URL del will-executor e
|
||||
stato. Le funzioni `server_status_text()` e `server_status_tooltip()` sono in
|
||||
`gui/qt/theme.py` e riusano gli stessi flag di stato gia esistenti.
|
||||
|
||||
### Test
|
||||
- I 182 test ufficiali continuano a passare; smoke test ed external-zip test
|
||||
OK; `ruff` senza nuove segnalazioni reali.
|
||||
- Verificato sui dati reali del log dell'utente: il posticipo di un'eredita
|
||||
firmata ora rileva correttamente la condizione e avvia l'invalidazione.
|
||||
|
||||
Confermato dall'utente: **"mi pare che funziona"**.
|
||||
|
||||
42
README.md
42
README.md
@@ -68,6 +68,48 @@ Copy the `bal/` directory into your Electrum installation's
|
||||
`electrum/plugins/` directory, so that `electrum/plugins/bal/manifest.json`
|
||||
exists, then enable it from **Tools → Plugins**.
|
||||
|
||||
## Inheritance safety: anticipate / postpone
|
||||
|
||||
A will transaction is signed with a **fixed, immutable locktime** and then
|
||||
optionally sent to will-executor servers, which are economically incentivised
|
||||
to broadcast it (they collect fees). Because the locktime is baked into the
|
||||
signed transaction, simply changing the delivery time later is **not enough**:
|
||||
the old, already-signed transaction keeps living on the will-executors.
|
||||
|
||||
The plugin handles the two cases as follows (triggered when you press
|
||||
**Tools → Prepare**):
|
||||
|
||||
* **Anticipate** (new delivery time *earlier* than the signed locktime): the
|
||||
will is treated as expired and you are asked to **invalidate** the old
|
||||
transaction on-chain, then rebuild.
|
||||
* **Postpone** (new delivery time *later* than the signed locktime) on a will
|
||||
that was already **signed and/or pushed**: the previously committed coins
|
||||
must be invalidated on-chain **first**, otherwise a will-executor could
|
||||
broadcast the old (earlier-locktime) transaction and execute the inheritance
|
||||
*too early*. The plugin detects this by comparing the requested locktime with
|
||||
the locktime **frozen inside the signed transaction** (`tx.locktime`), and
|
||||
asks you to sign and broadcast an invalidation transaction. After it is
|
||||
broadcast, press **Prepare** again to rebuild, re-sign and re-send the new
|
||||
(postponed) inheritance. Postponing a will that was *never* signed/sent just
|
||||
rebuilds it (no on-chain fee).
|
||||
|
||||
## Transaction list: the "Server" column
|
||||
|
||||
The will transaction list shows a dedicated **Server** column so you always
|
||||
know whether each inheritance transaction is actually stored on the
|
||||
will-executor servers, independently of the row colour:
|
||||
|
||||
| Label | Meaning |
|
||||
| --- | --- |
|
||||
| `Confirmed on server` | the will-executor confirmed it stored the transaction |
|
||||
| `Sent (not checked)` | pushed to the will-executor, not yet re-checked |
|
||||
| `Send failed` / `Not on server` | push failed or the server no longer has it |
|
||||
| `Signed (not sent)` | signed locally, not sent to any will-executor |
|
||||
| `Not sent` | not signed/sent yet |
|
||||
|
||||
Hovering the cell shows a tooltip with the will-executor URL and the current
|
||||
state.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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])
|
||||
):
|
||||
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