diff --git a/CHANGELOG_REFACTOR.md b/CHANGELOG_REFACTOR.md index ecb1694..019ecf1 100644 --- a/CHANGELOG_REFACTOR.md +++ b/CHANGELOG_REFACTOR.md @@ -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"**. diff --git a/README.md b/README.md index 6d5f70c..5e1449c 100644 --- a/README.md +++ b/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 diff --git a/bal/README.md b/bal/README.md index 5896ad7..9cb3127 100644 --- a/bal/README.md +++ b/bal/README.md @@ -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. diff --git a/bal/core/will.py b/bal/core/will.py index f4a5f33..1576c6e 100644 --- a/bal/core/will.py +++ b/bal/core/will.py @@ -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 diff --git a/bal/gui/qt/common.py b/bal/gui/qt/common.py index 53126de..90dd77b 100644 --- a/bal/gui/qt/common.py +++ b/bal/gui/qt/common.py @@ -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) diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index f187bf2..37cf798 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -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") diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py index 1d1f867..5032a0f 100644 --- a/bal/gui/qt/lists.py +++ b/bal/gui/qt/lists.py @@ -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: diff --git a/bal/gui/qt/theme.py b/bal/gui/qt/theme.py index cea29a4..33951a4 100644 --- a/bal/gui/qt/theme.py +++ b/bal/gui/qt/theme.py @@ -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) diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index f3e4cb6..ef2164d 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -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