1
0

Timelock Recovery Extension (#9589)

* Timelock Recovery Extension

* Timelock Recovery Extension tests

* Use fee_policy instead of fee_est

Following 3f327eea07

* making tx with base_tx

Following ab14c3e138

* move plugin metadata from __init__.py to manifest.json

* removing json large indentation

* timelock recovery icon

* timelock recovery plugin: fix typos

* timelock recovery plugin: use menu instead of status bar.

The status bar should be used for displaying status. For example,
hardware wallet plugins use it because their connection status is
changing and needs to be displayed.

* timelock recovery plugin: ask for password only once

* timelock recovery plugin: ask whether to create cancellation tx in the initial window

* remove unnecessary code.

(calling run_hook from a plugin does not make sense)

* show alert and cancellation address at the end.

skip unnecessary dialog

* timelock recovery plugin: do not show transactions one by one.

Set the fee policy in the first dialog, and use the same fee
policy for all tx. We could add 3 sliders to this dialog, if
different fees are needed, but I think this really isn't
really necessary.

* simplify default_wallet for tests

All the lightning-related stuff is irrelevant for
this plugin.

Also use a different destination address
for the test recovery-plan (an address
that does not belong to the same wallet).

* Fee selection should be above fee calculation

also show fee calculation result with "fee: " label.

* hide Sign and Broadcast buttons during view

* recalculate cancellation transaction

The checkbox could be clicked after the fee rate
has been set. Calling update_transactions() may seem
inefficient, but it's the simplest way to avoid such edge-cases.

Also set the context's cancellation transaction to None when the
checkbox is unset.

* use context.cancellation_tx instead of checkbox value

context.cancellation_tx will be None iff the checkbox was unset

* hide cancellation address if not used

* init monospace font correctly

* timelock recovery plugin: add input info at signing time.

Fixes trezor exception: 'Missing previous tx'

* timelock recovery: remove unused parameters

* avoid saving the tx in a separate var

fixing the assertions

* avoid caching recovery & cancellation inputs

* timelock recovery: separate help window from agreement.

move agreement at the end of the flow, rephrase it

* do not cache alert_tx_outputs

* do not crash when not enough funds

not enough funds can happen
when multiple addresses are specified
in payto_e, with an amount larger
than the wallet has - so we set
the payto_e color to red.

It can also happen when the user
selects a really high fee, but this
is not common in a "recovery"
wallet with significant funds.

* If files not saved - ask before closing

* move the checkbox above the save buttons

people read the text from top to
bottom and may not understand
why the buttons are disabled

---------

Co-authored-by: f321x <f321x@tutamail.com>
Co-authored-by: ThomasV <thomasv@electrum.org>
This commit is contained in:
Oren
2025-04-22 11:02:01 +03:00
committed by GitHub
parent c12eb31d88
commit 2fb0dd066f
11 changed files with 3081 additions and 1 deletions

View File

@@ -1118,8 +1118,17 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
*,
external_keypairs: Mapping[bytes, bytes] = None,
payment_identifier: PaymentIdentifier = None,
show_sign_button: bool = True,
show_broadcast_button: bool = True,
):
show_transaction(tx, parent=self, external_keypairs=external_keypairs, payment_identifier=payment_identifier)
show_transaction(
tx,
parent=self,
external_keypairs=external_keypairs,
payment_identifier=payment_identifier,
show_sign_button=show_sign_button,
show_broadcast_button=show_broadcast_button,
)
def show_lightning_transaction(self, tx_item):
from .lightning_tx_dialog import LightningTxDialog

View File

@@ -411,6 +411,8 @@ def show_transaction(
external_keypairs: Mapping[bytes, bytes] = None,
payment_identifier: 'PaymentIdentifier' = None,
on_closed: Callable[[], None] = None,
show_sign_button: bool = True,
show_broadcast_button: bool = True,
):
try:
d = TxDialog(
@@ -421,6 +423,10 @@ def show_transaction(
payment_identifier=payment_identifier,
on_closed=on_closed,
)
if not show_sign_button:
d.sign_button.setVisible(False)
if not show_broadcast_button:
d.broadcast_button.setVisible(False)
except SerializationError as e:
_logger.exception('unable to deserialize the transaction')
parent.show_critical(_("Electrum was unable to deserialize the transaction:") + "\n" + str(e))

View File

@@ -0,0 +1,72 @@
Timelock Recovery is a mechanism which, in case of a catastrophic event
(death or loss of your master key), can send your Bitcoin to a secondary wallet of your choice
within a time-window (i.e. 90 days).
<br />
During that time-window, you can see that the Timelock Recovery mechanism has been triggered (a
transaction from your wallet to itself is created on the Bitcoin blockchain), and if this
has happened against your will, you can use your master key to cancel the process (by moving
the funds elsewhere before the time-window expires).
<br />
The implementation of Timelock Recovery is done with two transactions that are signed in advance,
but broadcast only when needed:
<ol>
<li>
An <i>Alert Transaction</i> which sends the funds from the wallet to itself (consolidating the UTXOs).
</li>
<li>
A <i>Recovery Transaction</i> which sends the funds to a secondary wallet of your choice and can
be added to the blockchain only X days after the <i>Alert Transaction</i> has been broadcast (and mined).
</li>
</ol>
Optionally, this extension will also let you sign-in-advance a <i>Cancellation Transaction</i> which can be
used to cancel the Timelock Recovery process, by broadcasting it before the time-window expires.
If the <i>Alert Transaction</i> has been broadcast, the Cancellation Transaction will send the funds again to
the same wallet, which would invalidate the <i>Recovery Transaction</i> (technically: the <i>Recovery Transaction</i>
will be seen as a transaction that is trying to spend a UTXO that has already been spent).
<br />
Timelock Recovery plans do not require any involvement of a third party.
However, two precautions should be taken:
<ol>
<li>
Due to the way Bitcoin transactions and UTXOs work, spending funds from the wallet might break
the entire Timelock Recovery plan.
</li>
<li>
Adding more funds to the wallet will not be covered by the Timelock Recovery plan.
</li>
</ol>
<br />
Therefore it is highly recommended not to use the wallet for any purpose after creating a
Timelock Recovery plan (other than long-term storage).
Instead, for daily purposes use a separate wallet (with a seed in a place that
your loved ones could easily find) and only after accumulating enough funds relevant for long-term
storage, move them to a new highly secured wallet (i.e. with a long passphrase that only you memorize) for
which you create a new Timelock Recovery plan (back to the daily-purpose wallet or to your inheritors' wallet).
<br />
Each accumulation should be done in a new highly secured wallet, but these are easy to create, i.e. you can
memorize a long passphrase and add a counter at the end (1 for the first accumlation, 2 for the second, etc.).
<br />
For more details, visit: <a target="_blank" href="https://timelockrecovery.com" rel="noopener noreferrer">https://timelockrecovery.com</a>.
<br />
Before we begin, please note:
<ol>
<li>
Please prepare in advance the addresses of your inheritors/backup-wallets.
</li>
<li>
Since we are preparing this recovery plan for the long future, it is hard
to estimate what the required mining fees will be.
If the fee is too low, your inheritors, who don't have access to the master
keys, will not be able to simply "replace-by-fee" and use a higher fee.
At the moment of writing this code (year 2025) this is not a big deal, because
there are acceleration-services, such as
<a target="_blank" href="https://mempool.space/accelerator" rel="noopener noreferrer">
mempool.space's accelerator
</a>, that allows to boost selected transactions for direct payment.
Just in case this service will not be available in the future, the
<i>Alert Transaction</i> will send a small amount of 600 sats to each
destination address. This will allow advance users to boost the
first transaction by spending their unmined UTXO in a mechanism called
Child-Pay-For-Parent.
</li>
</ol>

View File

@@ -0,0 +1,8 @@
{
"fullname": "Timelock Recovery Utility",
"description": "<br/>This plug-in allows you to create Timelock Recovery Plans for your wallet. See: <a href='https://timelockrecovery.com'>timelockrecovery.com</a>",
"author": "orenz0@protonmail.com",
"available_for": ["qt"],
"icon":"timelock_recovery_60.png",
"version": "0.1.0"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
from datetime import datetime
from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Tuple
from electrum.bitcoin import address_to_script
from electrum.plugin import BasePlugin
from electrum.transaction import PartialTxOutput, PartialTxInput, TxOutpoint
from electrum.util import bfh
if TYPE_CHECKING:
from electrum.gui.qt import ElectrumWindow
from electrum.transaction import PartialTransaction, TxOutput
from electrum.wallet import Abstract_Wallet
ALERT_ADDRESS_LABEL = "Timelock Recovery Alert Address"
CANCELLATION_ADDRESS_LABEL = "Timelock Recovery Cancellation Address"
class PartialTxInputWithFixedNsequence(PartialTxInput):
_fixed_nsequence: int
def __init__(self, *args, nsequence: int = 0xfffffffe, **kwargs):
self._fixed_nsequence = nsequence
super().__init__(*args, **kwargs)
@property
def nsequence(self) -> int:
return self._fixed_nsequence
@nsequence.setter
def nsequence(self, value: int):
pass # ignore override attempts
class TimelockRecoveryContext:
wallet: 'Abstract_Wallet'
wallet_name: str
main_window: Optional['ElectrumWindow'] = None
timelock_days: Optional[int] = None
cancellation_address: Optional[str] = None
outputs: Optional[List['PartialTxOutput']] = None
alert_tx: Optional['PartialTransaction'] = None
recovery_tx: Optional['PartialTransaction'] = None
cancellation_tx: Optional['PartialTransaction'] = None
recovery_plan_id: Optional[str] = None
recovery_plan_created_at: Optional[datetime] = None
_alert_address: Optional[str] = None
_cancellation_address: Optional[str] = None
recovery_plan_saved: bool = False
cancellation_plan_saved: bool = False
ANCHOR_OUTPUT_AMOUNT_SATS = 600
def __init__(self, wallet: 'Abstract_Wallet'):
self.wallet = wallet
self.wallet_name = str(self.wallet)
def _get_address_by_label(self, label: str) -> str:
unused_addresses = list(self.wallet.get_unused_addresses())
for addr in unused_addresses:
if self.wallet.get_label_for_address(addr) == label:
return addr
for addr in unused_addresses:
if not self.wallet.is_address_reserved(addr) and not self.wallet.get_label_for_address(addr):
self.wallet.set_label(addr, label)
return addr
if self.wallet.is_deterministic():
addr = self.wallet.create_new_address(False)
if addr:
self.wallet.set_label(addr, label)
return addr
return ''
def get_alert_address(self) -> str:
if self._alert_address is None:
self._alert_address = self._get_address_by_label(ALERT_ADDRESS_LABEL)
return self._alert_address
def get_cancellation_address(self) -> str:
if self._cancellation_address is None:
self._cancellation_address = self._get_address_by_label(CANCELLATION_ADDRESS_LABEL)
return self._cancellation_address
def make_unsigned_alert_tx(self, fee_policy) -> 'PartialTransaction':
alert_tx_outputs = [
PartialTxOutput(scriptpubkey=address_to_script(self.get_alert_address()), value='!'),
] + [
PartialTxOutput(scriptpubkey=output.scriptpubkey, value=self.ANCHOR_OUTPUT_AMOUNT_SATS)
for output in self.outputs
]
return self.wallet.make_unsigned_transaction(
coins=self.wallet.get_spendable_coins(confirmed_only=False),
outputs=alert_tx_outputs,
fee_policy=fee_policy,
is_sweep=False,
)
def _alert_tx_output(self) -> Tuple[int, 'TxOutput']:
tx_outputs: List[Tuple[int, 'TxOutput']] = [
(index, tx_output) for index, tx_output in enumerate(self.alert_tx.outputs())
if tx_output.address == self.get_alert_address() and tx_output.value != self.ANCHOR_OUTPUT_AMOUNT_SATS
]
if len(tx_outputs) != 1:
# Safety check - not expected to happen
raise ValueError(f"Expected 1 output from the Alert transaction to the Alert Address, but got {len(tx_outputs)}.")
return tx_outputs[0]
def _alert_tx_outpoint(self, out_idx: int) -> TxOutpoint:
return TxOutpoint(txid=bfh(self.alert_tx.txid()), out_idx=out_idx)
def make_unsigned_recovery_tx(self, fee_policy) -> 'PartialTransaction':
prevout_index, prevout = self._alert_tx_output()
nsequence: int = round(self.timelock_days * 24 * 60 * 60 / 512)
if nsequence > 0xFFFF:
# Safety check - not expected to happen
raise ValueError("Sequence number is too large")
nsequence += 0x00400000 # time based lock instead of block-height based lock
recovery_tx_input = PartialTxInputWithFixedNsequence(
prevout=self._alert_tx_outpoint(prevout_index),
nsequence=nsequence,
)
recovery_tx_input.witness_utxo = prevout
return self.wallet.make_unsigned_transaction(
coins=[recovery_tx_input],
outputs=[output for output in self.outputs if output.value != 0],
fee_policy=fee_policy,
is_sweep=False,
)
def add_input_info(self):
self.recovery_tx.inputs()[0].utxo = self.alert_tx
if self.cancellation_tx:
self.cancellation_tx.inputs()[0].utxo = self.alert_tx
def make_unsigned_cancellation_tx(self, fee_policy) -> 'PartialTransaction':
prevout_index, prevout = self._alert_tx_output()
cancellation_tx_input = PartialTxInput(
prevout=self._alert_tx_outpoint(prevout_index),
)
cancellation_tx_input.witness_utxo = prevout
return self.wallet.make_unsigned_transaction(
coins=[cancellation_tx_input],
outputs=[
PartialTxOutput(scriptpubkey=address_to_script(self.get_cancellation_address()), value='!'),
],
fee_policy=fee_policy,
is_sweep=False,
)
class TimelockRecoveryPlugin(BasePlugin):
def __init__(self, parent, config, name):
BasePlugin.__init__(self, parent, config, name)

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -0,0 +1,121 @@
from io import StringIO
import json
import os, sys
from electrum.bitcoin import address_to_script
from electrum.fee_policy import FixedFeePolicy
from electrum.plugins.timelock_recovery.timelock_recovery import TimelockRecoveryContext
from electrum.simple_config import SimpleConfig
from electrum.storage import WalletStorage
from electrum.transaction import PartialTxOutput
from electrum.wallet import Wallet
from electrum.wallet_db import WalletDB
from . import ElectrumTestCase
class TestTimelockRecovery(ElectrumTestCase):
TESTNET = True
def setUp(self):
super(TestTimelockRecovery, self).setUp()
self.config = SimpleConfig({'electrum_path': self.electrum_path})
self.wallet_path = os.path.join(self.electrum_path, "timelock_recovery_wallet")
self._saved_stdout = sys.stdout
self._stdout_buffer = StringIO()
sys.stdout = self._stdout_buffer
def tearDown(self):
super(TestTimelockRecovery, self).tearDown()
# Restore the "real" stdout
sys.stdout = self._saved_stdout
def _create_default_wallet(self):
with open(os.path.join(os.path.dirname(__file__), "test_timelock_recovery", "default_wallet"), "r") as f:
wallet_str = f.read()
storage = WalletStorage(self.wallet_path)
db = WalletDB(wallet_str, storage=storage, upgrade=True)
wallet = Wallet(db, config=self.config)
return wallet
async def test_get_alert_address(self):
wallet = self._create_default_wallet()
context = TimelockRecoveryContext(wallet)
alert_address = context.get_alert_address()
self.assertEqual(alert_address, 'tb1qchyc02y9mv4xths4je9puc4yzuxt8rfm26ef07')
async def test_get_cancellation_address(self):
wallet = self._create_default_wallet()
context = TimelockRecoveryContext(wallet)
context.get_alert_address()
cancellation_address = context.get_cancellation_address()
self.assertEqual(cancellation_address, 'tb1q6k5h4cz6ra8nzhg90xm9wldvadgh0fpttfthcg')
async def test_make_unsigned_alert_tx(self):
wallet = self._create_default_wallet()
context = TimelockRecoveryContext(wallet)
context.outputs = [
PartialTxOutput(scriptpubkey=address_to_script('tb1q4s8z6g5jqzllkgt8a4har94wl8tg0k9m8kv5zd'), value='!'),
]
alert_tx = context.make_unsigned_alert_tx(fee_policy=FixedFeePolicy(5000))
self.assertEqual(alert_tx.version, 2)
alert_tx_inputs = [tx_input.prevout.to_str() for tx_input in alert_tx.inputs()]
self.assertEqual(alert_tx_inputs, [
'59a9ff5fa62586f102b92504584f52e47f4ca0d5af061e99a0a3023fa70a70e2:1',
'778b01899d5ed48df03e406bc5babd1fdc8f1be4b7e5b9d20dd8caf24dd66ff4:1',
])
alert_tx_outputs = [(tx_output.address, tx_output.value) for tx_output in alert_tx.outputs()]
self.assertEqual(alert_tx_outputs, [
('tb1q4s8z6g5jqzllkgt8a4har94wl8tg0k9m8kv5zd', 600),
('tb1qchyc02y9mv4xths4je9puc4yzuxt8rfm26ef07', 743065),
])
self.assertEqual(alert_tx.txid(), '01c227f136c4490ec7cb0fe2ba5e44c436f58906b7fc29a83cb865d7e3bfaa60')
async def test_make_unsigned_recovery_tx(self):
wallet = self._create_default_wallet()
context = TimelockRecoveryContext(wallet)
context.outputs = [
PartialTxOutput(scriptpubkey=address_to_script('tb1q4s8z6g5jqzllkgt8a4har94wl8tg0k9m8kv5zd'), value='!'),
]
context.alert_tx = context.make_unsigned_alert_tx(fee_policy=FixedFeePolicy(5000))
context.timelock_days = 90
recovery_tx = context.make_unsigned_recovery_tx(fee_policy=FixedFeePolicy(5000))
self.assertEqual(recovery_tx.version, 2)
recovery_tx_inputs = [tx_input.prevout.to_str() for tx_input in recovery_tx.inputs()]
self.assertEqual(recovery_tx_inputs, [
'01c227f136c4490ec7cb0fe2ba5e44c436f58906b7fc29a83cb865d7e3bfaa60:1',
])
self.assertEqual(recovery_tx.inputs()[0].nsequence, 0x00403b54)
recovery_tx_outputs = [(tx_output.address, tx_output.value) for tx_output in recovery_tx.outputs()]
self.assertEqual(recovery_tx_outputs, [
('tb1q4s8z6g5jqzllkgt8a4har94wl8tg0k9m8kv5zd', 738065),
])
async def test_make_unsigned_cancellation_tx(self):
wallet = self._create_default_wallet()
context = TimelockRecoveryContext(wallet)
context.outputs = [
PartialTxOutput(scriptpubkey=address_to_script('tb1q4s8z6g5jqzllkgt8a4har94wl8tg0k9m8kv5zd'), value='!'),
]
context.alert_tx = context.make_unsigned_alert_tx(fee_policy=FixedFeePolicy(5000))
cancellation_tx = context.make_unsigned_cancellation_tx(fee_policy=FixedFeePolicy(6000))
self.assertEqual(cancellation_tx.version, 2)
cancellation_tx_inputs = [tx_input.prevout.to_str() for tx_input in cancellation_tx.inputs()]
self.assertEqual(cancellation_tx_inputs, [
'01c227f136c4490ec7cb0fe2ba5e44c436f58906b7fc29a83cb865d7e3bfaa60:1',
])
self.assertEqual(cancellation_tx.inputs()[0].nsequence, 0xfffffffd)
cancellation_tx_outputs = [(tx_output.address, tx_output.value) for tx_output in cancellation_tx.outputs()]
self.assertEqual(cancellation_tx_outputs, [
('tb1q6k5h4cz6ra8nzhg90xm9wldvadgh0fpttfthcg', 737065),
])

File diff suppressed because it is too large Load Diff