* Timelock Recovery Extension * Timelock Recovery Extension tests * Use fee_policy instead of fee_est Following3f327eea07* making tx with base_tx Followingab14c3e138* 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>
1278 lines
60 KiB
Python
1278 lines
60 KiB
Python
'''
|
|
|
|
Timelock Recovery
|
|
|
|
Copyright:
|
|
2025 Oren <orenz0@protonmail.com>
|
|
|
|
Distributed under the MIT software license, see the accompanying
|
|
file LICENCE or http://www.opensource.org/licenses/mit-license.php
|
|
|
|
'''
|
|
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import uuid
|
|
import json
|
|
import hashlib
|
|
from datetime import datetime
|
|
from functools import partial
|
|
from typing import TYPE_CHECKING, Any, List, Optional, Tuple
|
|
from decimal import Decimal
|
|
|
|
import qrcode
|
|
from PyQt6.QtPrintSupport import QPrinter
|
|
from PyQt6.QtCore import Qt, QRectF, QMarginsF
|
|
from PyQt6.QtGui import (QImage, QPainter, QFont, QIntValidator, QAction,
|
|
QPageSize, QPageLayout, QFontMetrics)
|
|
from PyQt6.QtWidgets import (QVBoxLayout, QHBoxLayout, QLabel, QMenu, QCheckBox, QToolButton,
|
|
QPushButton, QLineEdit, QScrollArea, QGridLayout, QFileDialog, QMessageBox)
|
|
|
|
from electrum import constants, version
|
|
from electrum.gui.common_qt.util import draw_qr, get_font_id
|
|
from electrum.gui.qt.paytoedit import PayToEdit
|
|
from electrum.payment_identifier import PaymentIdentifierType
|
|
from electrum.plugin import hook
|
|
from electrum.i18n import _
|
|
from electrum.transaction import PartialTxOutput
|
|
from electrum.util import NotEnoughFunds, make_dir
|
|
from electrum.gui.qt.util import ColorScheme, WindowModalDialog, Buttons, HelpLabel
|
|
from electrum.gui.qt.util import read_QIcon_from_bytes, read_QPixmap_from_bytes, WaitingDialog
|
|
from electrum.fee_policy import FeePolicy
|
|
from electrum.gui.qt.fee_slider import FeeSlider, FeeComboBox
|
|
|
|
from .timelock_recovery import TimelockRecoveryPlugin, TimelockRecoveryContext
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from electrum.gui.qt import ElectrumGui
|
|
from electrum.gui.qt.main_window import ElectrumWindow
|
|
|
|
|
|
AGREEMENT_TEXT = "I understand that the Timelock Recovery plan will be broken if I keep using this wallet"
|
|
MIN_LOCKTIME_DAYS = 2
|
|
# 0xFFFF * 512 seconds = 388.36 days.
|
|
MAX_LOCKTIME_DAYS = 388
|
|
|
|
|
|
def selectable_label(text: str) -> QLabel:
|
|
label = QLabel(text)
|
|
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
return label
|
|
|
|
|
|
class FontManager:
|
|
def __init__(self, font_name: str, resolution: int):
|
|
pixels_per_point = resolution / 72.0
|
|
self.header_font = QFont(font_name, 8)
|
|
self.header_line_spacing = QFontMetrics(self.header_font).lineSpacing() * pixels_per_point
|
|
self.title_font = QFont(font_name, 18, QFont.Weight.Bold)
|
|
self.title_line_spacing = QFontMetrics(self.title_font).height() * pixels_per_point
|
|
self.subtitle_font = QFont(font_name, 10)
|
|
self.subtitle_line_spacing = QFontMetrics(self.subtitle_font).height() * pixels_per_point
|
|
self.title_small_font = QFont(font_name, 16, QFont.Weight.Bold)
|
|
self.title_small_line_spacing = QFontMetrics(self.title_small_font).height() * pixels_per_point
|
|
self.body_font = QFont(font_name, 9)
|
|
self.body_small_font = QFont(font_name, 8)
|
|
self.body_small_line_spacing = QFontMetrics(self.body_small_font).lineSpacing() * pixels_per_point
|
|
|
|
|
|
class Plugin(TimelockRecoveryPlugin):
|
|
base_dir: str
|
|
_init_qt_received: bool
|
|
font_name: str
|
|
small_logo_bytes: bytes
|
|
large_logo_bytes: bytes
|
|
intro_text: str
|
|
|
|
def __init__(self, parent, config, name: str):
|
|
TimelockRecoveryPlugin.__init__(self, parent, config, name)
|
|
self.base_dir = os.path.join(config.electrum_path(), 'timelock_recovery')
|
|
make_dir(self.base_dir)
|
|
|
|
self._init_qt_received = False
|
|
self.font_name = 'Monospace'
|
|
self.small_logo_bytes = self.read_file("timelock_recovery_60.png")
|
|
self.large_logo_bytes = self.read_file("timelock_recovery_820.png")
|
|
self.intro_text = self.read_file("intro.txt").decode('utf-8')
|
|
plugin_metadata: Optional[dict] = parent.get_metadata('timelock_recovery')
|
|
self.plugin_version: str = plugin_metadata['version'] if plugin_metadata else 'unknown'
|
|
|
|
@hook
|
|
def load_wallet(self, wallet, window):
|
|
if self._init_qt_received: # only need/want the first signal
|
|
return
|
|
self._init_qt_received = True
|
|
# load custom fonts (note: here, and not in __init__, as it needs the QApplication to be created)
|
|
if get_font_id('PTMono-Regular.ttf') >= 0 and get_font_id('PTMono-Bold.ttf') >= 0:
|
|
self.font_name = 'PT Mono'
|
|
|
|
@hook
|
|
def init_menubar(self, window):
|
|
m = window.wallet_menu.addAction('Timelock Recovery', lambda: self.setup_dialog(window))
|
|
icon = read_QIcon_from_bytes(self.read_file('timelock_recovery_60.png'))
|
|
m.setIcon(icon)
|
|
|
|
def setup_dialog(self, main_window: 'ElectrumWindow') -> bool:
|
|
context = TimelockRecoveryContext(main_window.wallet)
|
|
context.main_window = main_window
|
|
return self.create_plan_dialog(context)
|
|
|
|
def create_intro_dialog(self, context: TimelockRecoveryContext) -> bool:
|
|
intro_dialog = WindowModalDialog(context.main_window, "Timelock Recovery")
|
|
intro_dialog.setContentsMargins(11, 11, 1, 1)
|
|
|
|
# Create an HBox layout. The logo will be on the left and the rest of the dialog on the right.
|
|
hbox_layout = QHBoxLayout(intro_dialog)
|
|
|
|
# Create the logo label.
|
|
logo_label = QLabel()
|
|
|
|
# Set the logo label pixmap.
|
|
logo_label.setPixmap(read_QPixmap_from_bytes(self.small_logo_bytes))
|
|
|
|
# Align the logo label to the top left.
|
|
logo_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
|
|
|
# Create a VBox layout for the main contents of the dialog.
|
|
vbox_layout = QVBoxLayout()
|
|
|
|
# Populate the HBox layout with spacing between the two columns.
|
|
hbox_layout.addWidget(logo_label)
|
|
hbox_layout.addSpacing(16)
|
|
hbox_layout.addLayout(vbox_layout)
|
|
|
|
title_label = QLabel(_("What Is Timelock Recovery?"))
|
|
vbox_layout.addWidget(title_label)
|
|
|
|
intro_label = QLabel(self.intro_text)
|
|
intro_label.setWordWrap(True)
|
|
intro_label.setTextFormat(Qt.TextFormat.RichText)
|
|
intro_label.setOpenExternalLinks(True)
|
|
intro_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
|
|
|
intro_wrapper = QScrollArea()
|
|
intro_wrapper.setWidget(intro_label)
|
|
intro_wrapper.setWidgetResizable(True)
|
|
intro_wrapper.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
|
|
intro_wrapper.setFrameStyle(0)
|
|
intro_wrapper.setMinimumHeight(200)
|
|
|
|
vbox_layout.addWidget(intro_wrapper)
|
|
|
|
close_button = QPushButton(_("Close"), intro_dialog)
|
|
close_button.clicked.connect(intro_dialog.close)
|
|
vbox_layout.addLayout(Buttons(close_button))
|
|
|
|
# Add stretches to the end of the layouts to prevent the contents from spreading when the dialog is enlarged.
|
|
hbox_layout.addStretch(1)
|
|
vbox_layout.addStretch(1)
|
|
|
|
return bool(intro_dialog.exec())
|
|
|
|
|
|
def create_plan_dialog(self, context: TimelockRecoveryContext) -> bool:
|
|
plan_dialog = WindowModalDialog(context.main_window, "Timelock Recovery")
|
|
plan_dialog.setContentsMargins(11, 11, 1, 1)
|
|
plan_dialog.resize(800, plan_dialog.height())
|
|
|
|
fee_policy = FeePolicy(context.main_window.config.FEE_POLICY)
|
|
create_cancel_cb = QCheckBox('', checked=False)
|
|
alert_tx_label = QLabel('')
|
|
recovery_tx_label = QLabel('')
|
|
cancellation_tx_label = QLabel('')
|
|
|
|
if not context.get_alert_address():
|
|
plan_dialog.show_error(''.join([
|
|
_("No more addresses in your wallet."), " ",
|
|
_("You are using a non-deterministic wallet, which cannot create new addresses."), " ",
|
|
_("If you want to create new addresses, use a deterministic wallet instead."),
|
|
]))
|
|
plan_dialog.close()
|
|
return
|
|
|
|
plan_grid = QGridLayout()
|
|
plan_grid.setSpacing(8)
|
|
grid_row = 0
|
|
|
|
help_button = QPushButton(_("Help"))
|
|
help_button.clicked.connect(lambda: self.create_intro_dialog(context))
|
|
|
|
next_button = QPushButton(_("Next"), plan_dialog)
|
|
next_button.clicked.connect(plan_dialog.close)
|
|
next_button.clicked.connect(lambda: self.start_plan(context))
|
|
next_button.setEnabled(False)
|
|
|
|
payto_e = PayToEdit(context.main_window.send_tab) # Reuse configuration from send tab
|
|
payto_e.toggle_paytomany()
|
|
|
|
context.timelock_days = 90
|
|
timelock_days_widget = QLineEdit()
|
|
timelock_days_widget.setValidator(QIntValidator(2, 388))
|
|
timelock_days_widget.setText(str(context.timelock_days))
|
|
|
|
def update_transactions():
|
|
is_valid = self._validate_input_values(
|
|
context=context,
|
|
payto_e=payto_e,
|
|
timelock_days_widget=timelock_days_widget,
|
|
)
|
|
if not is_valid:
|
|
view_alert_tx_button.setEnabled(False)
|
|
view_recovery_tx_button.setEnabled(False)
|
|
view_cancellation_tx_button.setEnabled(False)
|
|
next_button.setEnabled(False)
|
|
return
|
|
try:
|
|
context.alert_tx = context.make_unsigned_alert_tx(fee_policy)
|
|
assert all(tx_input.is_segwit() for tx_input in context.alert_tx.inputs())
|
|
alert_tx_label.setText(_("Fee: {}").format(self.config.format_amount_and_units(context.alert_tx.get_fee())))
|
|
context.recovery_tx = context.make_unsigned_recovery_tx(fee_policy)
|
|
assert all(tx_input.is_segwit() for tx_input in context.recovery_tx.inputs())
|
|
recovery_tx_label.setText(_("Fee: {}").format(self.config.format_amount_and_units(context.recovery_tx.get_fee())))
|
|
if create_cancel_cb.isChecked():
|
|
context.cancellation_tx = context.make_unsigned_cancellation_tx(fee_policy)
|
|
assert all(tx_input.is_segwit() for tx_input in context.cancellation_tx.inputs())
|
|
cancellation_tx_label.setText(_("Fee: {}").format(self.config.format_amount_and_units(context.cancellation_tx.get_fee())))
|
|
else:
|
|
context.cancellation_tx = None
|
|
except NotEnoughFunds:
|
|
view_alert_tx_button.setEnabled(False)
|
|
view_recovery_tx_button.setEnabled(False)
|
|
view_cancellation_tx_button.setEnabled(False)
|
|
payto_e.setStyleSheet(ColorScheme.RED.as_stylesheet(True))
|
|
payto_e.setToolTip("Not enough funds to create the transactions.")
|
|
next_button.setEnabled(False)
|
|
return
|
|
view_alert_tx_button.setEnabled(True)
|
|
view_recovery_tx_button.setEnabled(True)
|
|
view_cancellation_tx_button.setEnabled(True)
|
|
payto_e.setStyleSheet(ColorScheme.GREEN.as_stylesheet(True))
|
|
payto_e.setToolTip("")
|
|
next_button.setEnabled(True)
|
|
|
|
|
|
payto_e.paymentIdentifierChanged.connect(update_transactions)
|
|
timelock_days_widget.textChanged.connect(update_transactions)
|
|
|
|
plan_grid.addWidget(HelpLabel(
|
|
_("Recipient of the funds"),
|
|
(
|
|
_("Recipient of the funds, after the cancellation time window has expired")
|
|
+ "\n\n"
|
|
+ _("This field must contain a single Bitcoin address, or multiple lines in the format: 'address, amount'.") + "\n"
|
|
+ "\n"
|
|
+ _("If multiple lines are used, at least one line must be set to 'max', using the '!' special character.") + "\n"
|
|
+ _("Integers weights can also be used in conjunction with '!', "
|
|
"e.g. set one amount to '2!' and another to '3!' to split your coins 40-60.")
|
|
),
|
|
), grid_row, 0)
|
|
plan_grid.addWidget(payto_e, grid_row, 1, 1, 4)
|
|
grid_row += 1
|
|
|
|
plan_grid.addWidget(HelpLabel(
|
|
_("Cancellation time-window (days)"),
|
|
(
|
|
_("After broadcasting the Alert Transaction, you have a limited time to cancel the transaction.") + "\n"
|
|
+ _("Value must be between {} and {} days.").format(MIN_LOCKTIME_DAYS, MAX_LOCKTIME_DAYS)
|
|
)
|
|
), grid_row, 0)
|
|
plan_grid.addWidget(timelock_days_widget, grid_row, 1)
|
|
grid_row += 1
|
|
plan_grid.addWidget(HelpLabel(
|
|
_('Create a cancellation transaction'),
|
|
'\n'.join([
|
|
_(
|
|
"If the Alert transaction is has been broadcast against your intention," +
|
|
" you will be able to broadcast the Cancellation transaction within {} days," +
|
|
" to invalidate the Recovery transaction and keep the funds in this wallet" +
|
|
" - without the need to restore the seed of this wallet (i.e. in case you have split or hidden it)."
|
|
).format(context.timelock_days),
|
|
_(
|
|
"However, if the seed of this wallet is lost, broadcasting the Cancellation transaction" +
|
|
" might lock the funds on this wallet forever."
|
|
)
|
|
])
|
|
), grid_row, 0)
|
|
plan_grid.addWidget(create_cancel_cb, grid_row, 1, 1, 4)
|
|
grid_row += 1
|
|
|
|
fee_slider = FeeSlider(
|
|
parent=plan_dialog, network=context.main_window.network,
|
|
fee_policy=fee_policy,
|
|
callback=lambda x: update_transactions()
|
|
)
|
|
|
|
fee_combo = FeeComboBox(fee_slider)
|
|
plan_grid.addWidget(QLabel('Fee policy'), grid_row, 0)
|
|
plan_grid.addWidget(fee_slider, grid_row, 1)
|
|
plan_grid.addWidget(fee_combo, grid_row, 2)
|
|
grid_row += 1
|
|
|
|
plan_grid.addWidget(QLabel('Alert transaction'), grid_row, 0)
|
|
plan_grid.addWidget(alert_tx_label, grid_row, 1, 1, 3)
|
|
view_alert_tx_button = QPushButton(_('View'))
|
|
view_alert_tx_button.clicked.connect(lambda: context.main_window.show_transaction(context.alert_tx, show_sign_button=False, show_broadcast_button=False))
|
|
plan_grid.addWidget(view_alert_tx_button, grid_row, 4)
|
|
grid_row += 1
|
|
|
|
plan_grid.addWidget(QLabel('Recovery transaction'), grid_row, 0)
|
|
plan_grid.addWidget(recovery_tx_label, grid_row, 1, 1, 3)
|
|
view_recovery_tx_button = QPushButton(_('View'))
|
|
view_recovery_tx_button.clicked.connect(lambda: context.main_window.show_transaction(context.recovery_tx, show_sign_button=False, show_broadcast_button=False))
|
|
plan_grid.addWidget(view_recovery_tx_button, grid_row, 4)
|
|
grid_row += 1
|
|
|
|
cancellation_label = QLabel('Cancellation transaction')
|
|
plan_grid.addWidget(cancellation_label, grid_row, 0)
|
|
plan_grid.addWidget(cancellation_tx_label, grid_row, 1, 1, 3)
|
|
view_cancellation_tx_button = QPushButton(_('View'))
|
|
view_cancellation_tx_button.clicked.connect(lambda: context.main_window.show_transaction(context.cancellation_tx, show_sign_button=False, show_broadcast_button=False))
|
|
plan_grid.addWidget(view_cancellation_tx_button, grid_row, 4)
|
|
grid_row += 1
|
|
|
|
plan_grid.setRowStretch(grid_row, 1) # Make sure the grid does not stretch
|
|
# Create an HBox layout. The logo will be on the left and the rest of the dialog on the right.
|
|
hbox_layout = QHBoxLayout(plan_dialog)
|
|
|
|
def on_cb_change(x):
|
|
cancellation_label.setVisible(x)
|
|
cancellation_tx_label.setVisible(x)
|
|
view_cancellation_tx_button.setVisible(x)
|
|
update_transactions()
|
|
create_cancel_cb.stateChanged.connect(on_cb_change)
|
|
|
|
logo_label = QLabel()
|
|
logo_label.setPixmap(read_QPixmap_from_bytes(self.small_logo_bytes))
|
|
logo_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
|
|
|
# Create a VBox layout for the main contents of the dialog.
|
|
vbox_layout = QVBoxLayout()
|
|
vbox_layout.addLayout(plan_grid, stretch=1)
|
|
vbox_layout.addLayout(Buttons(help_button, next_button))
|
|
|
|
# Populate the HBox layout.
|
|
hbox_layout.addWidget(logo_label)
|
|
hbox_layout.addSpacing(16)
|
|
hbox_layout.addLayout(vbox_layout, stretch=1)
|
|
|
|
# initialize
|
|
on_cb_change(False)
|
|
|
|
return bool(plan_dialog.exec())
|
|
|
|
def _validate_input_values(
|
|
self,
|
|
context: TimelockRecoveryContext,
|
|
payto_e: PayToEdit,
|
|
timelock_days_widget: QLineEdit) -> bool:
|
|
context.timelock_days = None
|
|
try:
|
|
timelock_days_str = timelock_days_widget.text()
|
|
timelock_days = int(timelock_days_str)
|
|
if str(timelock_days) != timelock_days_str or timelock_days < MIN_LOCKTIME_DAYS or timelock_days > MAX_LOCKTIME_DAYS:
|
|
raise ValueError("Timelock Days value not in range.")
|
|
context.timelock_days = timelock_days
|
|
timelock_days_widget.setStyleSheet(None)
|
|
timelock_days_widget.setToolTip("")
|
|
except ValueError:
|
|
timelock_days_widget.setStyleSheet(ColorScheme.RED.as_stylesheet(True))
|
|
timelock_days_widget.setToolTip("Value must be between {} and {} days.".format(MIN_LOCKTIME_DAYS, MAX_LOCKTIME_DAYS))
|
|
return False
|
|
pi = payto_e.payment_identifier
|
|
if not pi:
|
|
return False
|
|
if not pi.is_valid():
|
|
# Don't make background red - maybe the user did not complete typing yet.
|
|
payto_e.setStyleSheet(ColorScheme.RED.as_stylesheet(True) if '\n' in pi.text.strip() else '')
|
|
payto_e.setToolTip((pi.get_error() or "Invalid address.") if pi.text else "")
|
|
return False
|
|
elif pi.is_multiline():
|
|
if not pi.is_multiline_max():
|
|
payto_e.setStyleSheet(ColorScheme.RED.as_stylesheet(True))
|
|
payto_e.setToolTip("At least one line must be set to max spend ('!' in the amount column).")
|
|
return False
|
|
context.outputs = pi.multiline_outputs
|
|
else:
|
|
if not pi.is_available() or pi.type != PaymentIdentifierType.SPK or not pi.spk_is_address:
|
|
payto_e.setStyleSheet(ColorScheme.RED.as_stylesheet(True))
|
|
payto_e.setToolTip("Invalid address type - must be a Bitcoin address.")
|
|
return False
|
|
scriptpubkey, is_address = pi.parse_output(pi.text.strip())
|
|
if not is_address:
|
|
payto_e.setStyleSheet(ColorScheme.RED.as_stylesheet(True))
|
|
payto_e.setToolTip("Must be a valid address, not a script.")
|
|
return False
|
|
context.outputs = [PartialTxOutput(scriptpubkey=scriptpubkey, value='!')]
|
|
return True
|
|
|
|
def start_plan(self, context: TimelockRecoveryContext):
|
|
main_window = context.main_window
|
|
wallet = main_window.wallet
|
|
password = main_window.get_password()
|
|
|
|
def task():
|
|
wallet.sign_transaction(context.alert_tx, password, ignore_warnings=True)
|
|
context.add_input_info()
|
|
wallet.sign_transaction(context.recovery_tx, password, ignore_warnings=True)
|
|
if context.cancellation_tx is not None:
|
|
wallet.sign_transaction(context.cancellation_tx, password, ignore_warnings=True)
|
|
|
|
def on_success(result):
|
|
self.create_download_dialog(context)
|
|
def on_failure(exc_info):
|
|
main_window.on_error(exc_info)
|
|
msg = _('Signing transaction...')
|
|
WaitingDialog(main_window, msg, task, on_success, on_failure)
|
|
|
|
|
|
def create_download_dialog(self, context: TimelockRecoveryContext) -> bool:
|
|
context.recovery_plan_id = str(uuid.uuid4())
|
|
context.recovery_plan_created_at = datetime.now().astimezone()
|
|
download_dialog = WindowModalDialog(context.main_window, "Timelock Recovery - Download")
|
|
download_dialog.setContentsMargins(11, 11, 1, 1)
|
|
download_dialog.resize(800, download_dialog.height())
|
|
|
|
# Create an HBox layout. The logo will be on the left and the rest of the dialog on the right.
|
|
hbox_layout = QHBoxLayout(download_dialog)
|
|
|
|
# Create the logo label
|
|
logo_label = QLabel()
|
|
logo_label.setPixmap(read_QPixmap_from_bytes(self.small_logo_bytes))
|
|
logo_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
|
|
|
# Create a VBox layout for the main contents
|
|
vbox_layout = QVBoxLayout()
|
|
|
|
# Create and populate the grid
|
|
grid = QGridLayout()
|
|
grid.setSpacing(8)
|
|
grid.setColumnStretch(3, 1)
|
|
|
|
line_number = 0
|
|
|
|
# Add Recovery Plan ID row
|
|
grid.addWidget(HelpLabel(
|
|
_("Recovery Plan ID"),
|
|
_("Unique identifier for this recovery plan"),
|
|
), 0, 0)
|
|
grid.addWidget(selectable_label(context.recovery_plan_id), line_number, 1, 1, 4)
|
|
line_number += 1
|
|
# Add Creation Date row
|
|
grid.addWidget(HelpLabel(
|
|
_("Created At"),
|
|
_("Date and time when this recovery plan was created"),
|
|
), 1, 0)
|
|
grid.addWidget(selectable_label(context.recovery_plan_created_at.strftime("%Y-%m-%d %H:%M:%S %Z (%z)")), line_number, 1, 1, 4)
|
|
line_number += 1
|
|
|
|
grid.addWidget(HelpLabel(
|
|
_("Alert Address"),
|
|
_("This address in your wallet will receive the funds when the Alert Transaction is broadcast."),
|
|
), line_number, 0)
|
|
alert_address = context.get_alert_address()
|
|
grid.addWidget(selectable_label(alert_address), line_number, 1, 1, 3)
|
|
copy_button = QPushButton(_("Copy"))
|
|
copy_button.clicked.connect(lambda: context.main_window.do_copy(alert_address))
|
|
grid.addWidget(copy_button, line_number, 4)
|
|
line_number += 1
|
|
|
|
if context.cancellation_tx is not None:
|
|
cancellation_address = context.get_cancellation_address()
|
|
grid.addWidget(HelpLabel(
|
|
_("Cancellation Address"),
|
|
_("This address in your wallet will receive the funds when the Cancellation transaction is broadcast."),
|
|
), line_number, 0)
|
|
grid.addWidget(selectable_label(cancellation_address), line_number, 1, 1, 3)
|
|
copy_button2 = QPushButton(_("Copy"))
|
|
copy_button2.clicked.connect(lambda: context.main_window.do_copy(cancellation_address))
|
|
grid.addWidget(copy_button2, line_number, 4)
|
|
line_number += 1
|
|
|
|
grid.addWidget(HelpLabel(
|
|
_("Alert Transaction ID"),
|
|
_("ID of the Alert transaction"),
|
|
), line_number, 0)
|
|
grid.addWidget(selectable_label(context.alert_tx.txid()), line_number, 1, 1, 3)
|
|
line_number += 1
|
|
|
|
grid.addWidget(HelpLabel(
|
|
_("Recovery Transaction ID"),
|
|
_("ID of the Recovery transaction"),
|
|
), line_number, 0)
|
|
grid.addWidget(selectable_label(context.recovery_tx.txid()), line_number, 1, 1, 4)
|
|
line_number += 1
|
|
|
|
if context.cancellation_tx is not None:
|
|
grid.addWidget(HelpLabel(
|
|
_("Cancellation Transaction ID"),
|
|
_("ID of the Cancellation transaction"),
|
|
), line_number, 0)
|
|
grid.addWidget(selectable_label(context.cancellation_tx.txid()), line_number, 1, 1, 4)
|
|
line_number += 1
|
|
|
|
grid.setRowStretch(line_number, 1)
|
|
# Create butttons
|
|
recovery_menu = QMenu()
|
|
action = QAction('Save as PDF', recovery_menu)
|
|
action.triggered.connect(partial(self._save_recovery_plan_pdf, context, download_dialog))
|
|
recovery_menu.addAction(action)
|
|
action = QAction('Save as JSON', recovery_menu)
|
|
action.triggered.connect(partial(self._save_recovery_plan_json, context, download_dialog))
|
|
recovery_menu.addAction(action)
|
|
recovery_button = QToolButton()
|
|
recovery_button.setText(_("Save Recovery Plan"))
|
|
recovery_button.setMenu(recovery_menu)
|
|
recovery_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
|
# Save Cancellation Plan button row (if applicable)
|
|
cancellation_menu = QMenu()
|
|
action = QAction('Save as PDF', cancellation_menu)
|
|
action.triggered.connect(partial(self._save_cancellation_plan_pdf, context, download_dialog))
|
|
cancellation_menu.addAction(action)
|
|
action = QAction('Save as JSON', cancellation_menu)
|
|
action.triggered.connect(partial(self._save_cancellation_plan_json, context, download_dialog))
|
|
cancellation_menu.addAction(action)
|
|
cancellation_button = QToolButton()
|
|
cancellation_button.setText(_("Save Cancellation Plan"))
|
|
cancellation_button.setMenu(cancellation_menu)
|
|
cancellation_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
|
# Add layouts to main vbox
|
|
vbox_layout.addLayout(grid)
|
|
vbox_layout.addStretch()
|
|
download_hbox = QHBoxLayout()
|
|
download_hbox.addWidget(recovery_button)
|
|
if context.cancellation_tx is not None:
|
|
download_hbox.addWidget(cancellation_button)
|
|
# agree checkbox
|
|
def on_agreement(b):
|
|
recovery_button.setEnabled(bool(b))
|
|
cancellation_button.setEnabled(bool(b))
|
|
on_agreement(False)
|
|
agree_cb = QCheckBox(AGREEMENT_TEXT)
|
|
agree_cb.stateChanged.connect(on_agreement)
|
|
vbox_layout.addWidget(agree_cb)
|
|
vbox_layout.addStretch()
|
|
vbox_layout.addLayout(download_hbox)
|
|
close_button = QPushButton(_("Close"), download_dialog)
|
|
def on_close():
|
|
if context.cancellation_tx is not None and not context.cancellation_plan_saved:
|
|
if not context.recovery_plan_saved:
|
|
is_sure = download_dialog.question(
|
|
_("Are you sure you want to close this dialog without saving any of the files?"),
|
|
title=_("Close"),
|
|
icon=QMessageBox.Icon.Question
|
|
)
|
|
if not is_sure:
|
|
return
|
|
else:
|
|
is_sure = download_dialog.question(
|
|
_("Are you sure you want to close this dialog without saving the cancellation-plan?"),
|
|
title=_("Close"),
|
|
icon=QMessageBox.Icon.Question
|
|
)
|
|
if not is_sure:
|
|
return
|
|
elif not context.recovery_plan_saved:
|
|
is_sure = download_dialog.question(
|
|
_("Are you sure you want to close this dialog without saving the recovery-plan?"),
|
|
title=_("Close"),
|
|
icon=QMessageBox.Icon.Question
|
|
)
|
|
if not is_sure:
|
|
return
|
|
download_dialog.close()
|
|
close_button.clicked.connect(on_close)
|
|
vbox_layout.addLayout(Buttons(close_button))
|
|
# Populate the HBox layout.
|
|
hbox_layout.addWidget(logo_label)
|
|
hbox_layout.addSpacing(16)
|
|
hbox_layout.addLayout(vbox_layout, stretch=1)
|
|
|
|
return bool(download_dialog.exec())
|
|
|
|
@classmethod
|
|
def _checksum(cls, json_data: dict[str, Any]) -> str:
|
|
# Assumes the values have a consistent json representation (not a key-value
|
|
# object whose fields can be ordered in multiple ways).
|
|
return hashlib.sha256(json.dumps(
|
|
sorted(json_data.items()),
|
|
skipkeys=False, ensure_ascii=True, check_circular=True,
|
|
allow_nan=True, cls=None, indent=None, separators=(',', ':'),
|
|
default=None, sort_keys=False,
|
|
).encode()).hexdigest()
|
|
|
|
def _save_recovery_plan_json(self, context: TimelockRecoveryContext, download_dialog: WindowModalDialog):
|
|
try:
|
|
# Open a Save As dialog to get the file path
|
|
file_path, _selected_filter = QFileDialog.getSaveFileName(
|
|
download_dialog,
|
|
_("Save Recovery Plan JSON..."),
|
|
os.path.join(self.base_dir, "timelock-recovery-plan-{}.json".format(context.recovery_plan_id)),
|
|
_("JSON files (*.json)")
|
|
)
|
|
if not file_path:
|
|
return
|
|
with open(file_path, "w") as json_file:
|
|
json_data = {
|
|
"kind": "timelock-recovery-plan",
|
|
"id": context.recovery_plan_id,
|
|
"created_at": context.recovery_plan_created_at.isoformat(),
|
|
"plugin_version": self.plugin_version,
|
|
"wallet_kind": "Electrum",
|
|
"wallet_version": version.ELECTRUM_VERSION,
|
|
"wallet_name": context.wallet_name,
|
|
"timelock_days": context.timelock_days,
|
|
"anchor_amount_sats": context.ANCHOR_OUTPUT_AMOUNT_SATS,
|
|
"anchor_addresses": [output.address for output in context.outputs],
|
|
"alert_address": context.get_alert_address(),
|
|
"alert_inputs": [tx_input.prevout.to_str() for tx_input in context.alert_tx.inputs()],
|
|
"alert_tx": context.alert_tx.serialize().upper(),
|
|
"alert_txid": context.alert_tx.txid(),
|
|
"alert_fee": context.alert_tx.get_fee(),
|
|
"alert_weight": context.alert_tx.estimated_weight(),
|
|
"recovery_tx": context.recovery_tx.serialize().upper(),
|
|
"recovery_txid": context.recovery_tx.txid(),
|
|
"recovery_fee": context.recovery_tx.get_fee(),
|
|
"recovery_weight": context.recovery_tx.estimated_weight(),
|
|
"recovery_outputs": [[tx_output.address, tx_output.value] for tx_output in context.recovery_tx.outputs()],
|
|
}
|
|
# Simple checksum to ensure the file is not corrupted by foolish users
|
|
json_data["checksum"] = self._checksum(json_data)
|
|
json.dump(json_data, json_file, indent=2)
|
|
download_dialog.show_message(_("File saved successfully"))
|
|
context.recovery_plan_saved = True
|
|
except Exception as e:
|
|
self.logger.exception(repr(e))
|
|
download_dialog.show_error(_("Error saving file"))
|
|
|
|
def _save_cancellation_plan_json(self, context: TimelockRecoveryContext, download_dialog: WindowModalDialog):
|
|
try:
|
|
# Open a Save As dialog to get the file path
|
|
file_path, _selected_filter = QFileDialog.getSaveFileName(
|
|
download_dialog,
|
|
_("Save Cancellation Plan JSON..."),
|
|
os.path.join(self.base_dir, "timelock-cancellation-plan-{}.json".format(context.recovery_plan_id)),
|
|
_("JSON files (*.json)")
|
|
)
|
|
if not file_path:
|
|
return
|
|
with open(file_path, "w") as f:
|
|
json_data = {
|
|
"kind": "timelock-cancellation-plan",
|
|
"id": context.recovery_plan_id,
|
|
"created_at": context.recovery_plan_created_at.isoformat(),
|
|
"plugin_version": self.plugin_version,
|
|
"wallet_kind": "Electrum",
|
|
"wallet_version": version.ELECTRUM_VERSION,
|
|
"wallet_name": context.wallet_name,
|
|
"timelock_days": context.timelock_days,
|
|
"alert_txid": context.alert_tx.txid(),
|
|
"cancellation_address": context.get_cancellation_address(),
|
|
"cancellation_tx": context.cancellation_tx.serialize().upper(),
|
|
"cancellation_txid": context.cancellation_tx.txid(),
|
|
"cancellation_fee": context.cancellation_tx.get_fee(),
|
|
"cancellation_weight": context.cancellation_tx.estimated_weight(),
|
|
"cancellation_amount": context.cancellation_tx.output_value(),
|
|
}
|
|
# Simple checksum to ensure the file is not corrupted by foolish users
|
|
json_data["checksum"] = self._checksum(json_data)
|
|
json.dump(json_data, f, indent=2)
|
|
download_dialog.show_message(_("File saved successfully"))
|
|
context.cancellation_plan_saved = True
|
|
except Exception as e:
|
|
self.logger.exception(repr(e))
|
|
download_dialog.show_error(_("Error saving file"))
|
|
|
|
def _create_pdf_printer(self, file_path: str) -> QPrinter:
|
|
printer = QPrinter()
|
|
printer.setResolution(600)
|
|
printer.setPageSize(QPageSize(QPageSize.PageSizeId.A4))
|
|
printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat)
|
|
printer.setOutputFileName(file_path)
|
|
printer.setPageMargins(QMarginsF(20, 20, 20, 20), QPageLayout.Unit.Point)
|
|
return printer
|
|
|
|
def _paint_scaled_logo(self, painter: QPainter, page_width: int, current_height: float) -> int:
|
|
logo_pixmap = read_QPixmap_from_bytes(self.large_logo_bytes)
|
|
logo_size = int(page_width / 10)
|
|
scaled_logo = logo_pixmap.scaled(
|
|
logo_size,
|
|
logo_size,
|
|
Qt.AspectRatioMode.KeepAspectRatio,
|
|
Qt.TransformationMode.SmoothTransformation
|
|
)
|
|
# Center the logo horizontally and draw at current_height
|
|
logo_x = (page_width - scaled_logo.width()) / 2
|
|
painter.drawPixmap(int(logo_x), int(current_height), scaled_logo)
|
|
return scaled_logo.height()
|
|
|
|
def _save_recovery_plan_pdf(self, context: TimelockRecoveryContext, download_dialog: WindowModalDialog):
|
|
# Open a Save As dialog to get the file path
|
|
file_path, _selected_filter = QFileDialog.getSaveFileName(
|
|
download_dialog,
|
|
_("Save Recovery Plan PDF..."),
|
|
os.path.join(self.base_dir, "timelock-recovery-plan-{}.pdf".format(context.recovery_plan_id)),
|
|
_("PDF files (*.pdf)")
|
|
)
|
|
if not file_path:
|
|
return
|
|
|
|
painter = QPainter()
|
|
temp_file_path: Optional[str] = None
|
|
|
|
try:
|
|
with tempfile.NamedTemporaryFile(dir=os.path.dirname(file_path), prefix=f"{os.path.basename(file_path)}-", delete=False) as temp_file:
|
|
temp_file_path = temp_file.name
|
|
printer = self._create_pdf_printer(temp_file_path)
|
|
if not painter.begin(printer):
|
|
return
|
|
self._paint_recovery_plan_pdf(context, painter, printer)
|
|
painter.end()
|
|
shutil.move(temp_file_path, file_path)
|
|
download_dialog.show_message(_("File saved successfully"))
|
|
context.recovery_plan_saved = True
|
|
except (IOError, MemoryError) as e:
|
|
self.logger.exception(repr(e))
|
|
download_dialog.show_error(_("Error saving file"))
|
|
if temp_file_path is not None and os.path.exists(temp_file_path):
|
|
os.remove(temp_file_path)
|
|
finally:
|
|
if painter.isActive():
|
|
painter.end()
|
|
|
|
def _paint_recovery_plan_pdf(self, context: TimelockRecoveryContext, painter: QPainter, printer: QPrinter):
|
|
font_manager = FontManager(self.font_name, printer.resolution())
|
|
|
|
# Get page dimensions
|
|
page_rect = printer.pageRect(QPrinter.Unit.DevicePixel)
|
|
page_width = page_rect.width()
|
|
page_height = page_rect.height()
|
|
|
|
current_height = 0
|
|
page_number = 1
|
|
|
|
# Header
|
|
painter.setFont(font_manager.header_font)
|
|
painter.drawText(
|
|
QRectF(0, 0, page_width, font_manager.header_line_spacing + 20),
|
|
Qt.AlignmentFlag.AlignHCenter,
|
|
f"Recovery-Guide Date: {context.recovery_plan_created_at.strftime('%Y-%m-%d %H:%M:%S %Z (%z)')} ID: {context.recovery_plan_id} Page: {page_number}",
|
|
)
|
|
current_height += font_manager.header_line_spacing + 40
|
|
|
|
current_height += self._paint_scaled_logo(painter, page_width, current_height) + 40
|
|
|
|
# Title
|
|
painter.setFont(font_manager.title_font)
|
|
painter.drawText(QRectF(0, current_height, page_width, font_manager.title_line_spacing + 20), Qt.AlignmentFlag.AlignHCenter, "Timelock-Recovery Guide")
|
|
current_height += font_manager.title_line_spacing + 20
|
|
|
|
# Subtitle
|
|
painter.setFont(font_manager.subtitle_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.subtitle_line_spacing + 20), Qt.AlignmentFlag.AlignCenter,
|
|
f"Electrum Version: {version.ELECTRUM_VERSION} - Plugin Version: {self.plugin_version}"
|
|
)
|
|
current_height += font_manager.subtitle_line_spacing + 60
|
|
|
|
# Main content
|
|
recovery_tx_outputs = context.recovery_tx.outputs()
|
|
painter.setFont(font_manager.body_font)
|
|
intro_text = (
|
|
f"This document will guide you through the process of recovering the funds on wallet: {context.wallet_name}. "
|
|
f"The process will take at least {context.timelock_days} days, and will eventually send the following amount "
|
|
f"to the following {'address' if len(recovery_tx_outputs) == 1 else 'addresses'}:\n\n"
|
|
+ '\n'.join(f'• {output.address}: {context.main_window.config.format_amount_and_units(output.value)}' for output in recovery_tx_outputs) + "\n\n"
|
|
f"Before proceeding, MAKE SURE THAT YOU HAVE ACCESS TO THE {'WALLET OF THIS ADDRESS' if len(recovery_tx_outputs) == 1 else 'WALLETS OF THESE ADDRESSES'}, "
|
|
f"OR TRUST THE {'OWNER OF THIS ADDRESS' if len(recovery_tx_outputs) == 1 else 'OWNERS OF THESE ADDRESSES'}. "
|
|
"The simplest way to do so is to send a small amount to the address, and then trying "
|
|
"to send all funds from that wallet to a different wallet. Also important: make sure that the "
|
|
"seed-phrase of this wallet has not been compromised, or else a malicious actor could steal "
|
|
"the funds the moment they reach their destination.\n\n"
|
|
"For more information, visit: https://timelockrecovery.com\n"
|
|
)
|
|
|
|
drawn_rect = painter.drawText(
|
|
QRectF(0, current_height, page_width, page_height - current_height),
|
|
Qt.TextFlag.TextWordWrap,
|
|
intro_text,
|
|
)
|
|
current_height += drawn_rect.height() + 20
|
|
|
|
# Step 1
|
|
painter.setFont(font_manager.title_small_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.title_small_line_spacing + 20),Qt.AlignmentFlag.AlignLeft,
|
|
"Step 1 - Broadcasting the Alert transaction",
|
|
)
|
|
current_height += font_manager.title_small_line_spacing + 20
|
|
|
|
painter.setFont(font_manager.body_font)
|
|
# Calculate number of anchors
|
|
num_anchors = len(context.alert_tx.outputs()) - 1
|
|
|
|
# Split alert tx into parts if needed
|
|
alert_raw = context.alert_tx.serialize().upper()
|
|
if len(alert_raw) < 2300:
|
|
alert_raw_parts = [alert_raw]
|
|
else:
|
|
alert_raw_parts = []
|
|
for i in range(0, len(alert_raw), 2100):
|
|
alert_raw_parts.append(alert_raw[i:i+2100])
|
|
|
|
# Step 1 explanation text
|
|
step1_text = (
|
|
f"The first step is to broadcast the Alert transaction. "
|
|
f"This transaction will keep most funds in the same wallet {context.wallet_name}, "
|
|
)
|
|
|
|
if num_anchors > 0:
|
|
step1_text += (
|
|
f"except for 600 sats that will be sent to "
|
|
f"{'each of the following addresses' if num_anchors > 1 else 'the following address'} "
|
|
f"(and can be used in case you need to accelerate the transaction via Child-Pay-For-Parent, "
|
|
f"as we'll explain later):\n"
|
|
)
|
|
for output in context.alert_tx.outputs():
|
|
if output.address != context.get_alert_address() and output.value == context.ANCHOR_OUTPUT_AMOUNT_SATS:
|
|
step1_text += f"• {output.address}\n"
|
|
else:
|
|
step1_text += "except for a small fee.\n"
|
|
|
|
step1_text += (
|
|
f"\nTo broadcast the Alert transaction, "
|
|
f"{'scan the QR code on the next page' if len(alert_raw_parts) <= 1 else f'scan the QR codes on the next {len(alert_raw_parts)} pages, concatenate the contents of the QR codes (without spaces),'} "
|
|
f"and paste the content in one of the following Bitcoin block-explorer websites:\n"
|
|
"• https://mempool.space/tx/push\n"
|
|
"• https://blockstream.info/tx/push\n"
|
|
"• https://coinb.in/#broadcast\n\n"
|
|
f"You should then see a success message for broadcasting transaction-id: {context.alert_tx.txid()}"
|
|
)
|
|
|
|
drawn_rect = painter.drawText(
|
|
QRectF(0, current_height, page_width, page_height - current_height),
|
|
Qt.TextFlag.TextWordWrap,
|
|
step1_text
|
|
)
|
|
current_height += drawn_rect.height() + 20
|
|
|
|
# Generate QR pages for alert tx parts
|
|
for i, alert_part in enumerate(alert_raw_parts):
|
|
# Add new page
|
|
printer.newPage()
|
|
page_number += 1
|
|
current_height = 20
|
|
|
|
# Header
|
|
painter.setFont(font_manager.header_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.header_line_spacing),
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
f"Recovery-Guide Date: {context.recovery_plan_created_at.strftime('%Y-%m-%d %H:%M:%S %Z (%z)')} ID: {context.recovery_plan_id} Page: {page_number}"
|
|
)
|
|
current_height += font_manager.header_line_spacing + 20
|
|
|
|
# Title
|
|
painter.setFont(font_manager.title_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.title_line_spacing),
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
"Alert Transaction"
|
|
)
|
|
current_height += font_manager.title_line_spacing + 20
|
|
|
|
# Transaction ID
|
|
painter.setFont(font_manager.subtitle_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.subtitle_line_spacing),
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
f"Transaction Id: {context.alert_tx.txid()}"
|
|
)
|
|
current_height += font_manager.subtitle_line_spacing + 20
|
|
|
|
# Part number if multiple parts
|
|
if len(alert_raw_parts) > 1:
|
|
painter.setFont(font_manager.subtitle_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.subtitle_line_spacing),
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
f"Part {i+1} of {len(alert_raw_parts)}"
|
|
)
|
|
current_height += font_manager.subtitle_line_spacing + 20
|
|
|
|
# QR Code
|
|
qr = qrcode.main.QRCode(
|
|
error_correction=qrcode.constants.ERROR_CORRECT_Q,
|
|
)
|
|
qr.add_data(alert_part)
|
|
qr.make()
|
|
qr_image = self._paint_qr(qr)
|
|
|
|
# Calculate QR position to center it
|
|
qr_width = int(page_width * 0.6)
|
|
qr_x = (page_width - qr_width) / 2
|
|
painter.drawImage(QRectF(qr_x, current_height, qr_width, qr_width), qr_image)
|
|
current_height += qr_width + 40
|
|
|
|
# Raw text below QR
|
|
painter.setFont(font_manager.body_font)
|
|
painter.drawText(
|
|
QRectF(20, current_height, page_width, page_height - current_height),
|
|
Qt.TextFlag.TextWrapAnywhere,
|
|
alert_part
|
|
)
|
|
|
|
printer.newPage()
|
|
page_number += 1
|
|
current_height = 20
|
|
# Header
|
|
painter.setFont(font_manager.header_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.header_line_spacing),
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
f"Recovery-Guide Date: {context.recovery_plan_created_at.strftime('%Y-%m-%d %H:%M:%S %Z (%z)')} ID: {context.recovery_plan_id} Page: {page_number}"
|
|
)
|
|
current_height += font_manager.header_line_spacing + 20
|
|
|
|
# Step 2 page
|
|
painter.setFont(font_manager.title_small_font)
|
|
painter.drawText(QRectF(20, current_height, page_width, font_manager.title_small_line_spacing), Qt.AlignmentFlag.AlignLeft, "Step 2 - Waiting for the Alert transaction confirmation")
|
|
current_height += font_manager.title_small_line_spacing + 20
|
|
|
|
painter.setFont(font_manager.body_font)
|
|
painter.drawText(QRectF(20, current_height, page_width, font_manager.subtitle_line_spacing), Qt.AlignmentFlag.AlignLeft, "You can follow the Alert transaction via any of the following links:")
|
|
current_height += font_manager.subtitle_line_spacing + 20
|
|
|
|
# QR codes and links for transaction tracking
|
|
for link in [f"https://mempool.space/tx/{context.alert_tx.txid()}", f"https://blockstream.info/tx/{context.alert_tx.txid()}"]:
|
|
qr = qrcode.main.QRCode(
|
|
error_correction=qrcode.constants.ERROR_CORRECT_H,
|
|
)
|
|
qr.add_data(link)
|
|
qr.make()
|
|
qr_image = self._paint_qr(qr)
|
|
|
|
qr_width = int(page_width * 0.2)
|
|
qr_x = (page_width - qr_width) / 2
|
|
painter.drawImage(QRectF(qr_x, current_height, qr_width, qr_width), qr_image)
|
|
current_height += qr_width + 20
|
|
|
|
painter.setFont(font_manager.body_small_font)
|
|
painter.drawText(QRectF(0, current_height, page_width, font_manager.body_small_line_spacing), Qt.AlignmentFlag.AlignCenter, link)
|
|
current_height += font_manager.body_small_line_spacing + 20
|
|
|
|
# Explanation text
|
|
painter.setFont(font_manager.body_font)
|
|
explanation_text = (
|
|
"Please wait for a while until the transaction is marked as \"confirmed\" (number of confirmations greater than 0). "
|
|
"The time that takes a transaction to confirm depends on the fee that it pays, compared to the fee that other "
|
|
"pending transactions are willing to pay. At the time this document was created, it was hard to predict what a "
|
|
"reasonable fee would be today. If the transaction is not confirmed after 24 hours, you may try paying to a "
|
|
"Transaction Acceleration service, such as the one offered by: https://mempool.space.com ."
|
|
)
|
|
if len(context.outputs) > 0:
|
|
explanation_text += (
|
|
f" Another solution, which may be cheaper but requires more technical skill, would be to use"
|
|
f"{' one of the wallets that receive 600 sats (addresses mentioned in Step 1),' if len(context.outputs) > 1 else ' the wallet that receive 600 sats (address mentioned in Step 1),'}"
|
|
" and send a high-fee transaction that includes that 600 sats UTXO (this transaction could also be from the"
|
|
" wallet to itself). For more information, visit: https://timelockrecovery.com ."
|
|
)
|
|
|
|
drawn_rect = painter.drawText(QRectF(20, current_height, page_width, page_height - current_height), Qt.TextFlag.TextWordWrap, explanation_text)
|
|
current_height += drawn_rect.height() + 40
|
|
|
|
# Step 3 header
|
|
painter.setFont(font_manager.title_small_font)
|
|
painter.drawText(QRectF(20, current_height, page_width, font_manager.title_small_line_spacing), Qt.AlignmentFlag.AlignLeft, "Step 3 - Broadcasting the Recovery transaction")
|
|
current_height += font_manager.title_small_line_spacing + 20
|
|
|
|
# Split recovery transaction if needed
|
|
recovery_raw = context.recovery_tx.serialize().upper()
|
|
recovery_raw_parts = [recovery_raw[i:i+2100] for i in range(0, len(recovery_raw), 2100)] if len(recovery_raw) > 2300 else [recovery_raw]
|
|
|
|
# Step 3 explanation
|
|
painter.setFont(font_manager.body_font)
|
|
step3_text = (
|
|
f"Approximately {context.timelock_days} days after the Alert transaction has been confirmed, you "
|
|
"will be able to broadcast the second Recovery transaction that will send the funds to the final"
|
|
f"{' destinations,' if len(recovery_tx_outputs) > 1 else ' destination,'} mentioned on the first page. This can be done using the same websites mentioned in Step 1, but "
|
|
f"this time you will need to {'scan the QR code on page ' + str(page_number + 1) if len(recovery_raw_parts) <= 1 else 'scan the QR codes on pages ' + str(page_number + 1) + '-' + str(page_number + len(recovery_raw_parts)) + ' and concatenate their content (without spaces)'}. If this transaction remains unconfirmed for a "
|
|
"long time, you should use the Transaction Acceleration service mentioned on Step 2, or use the "
|
|
"Child-Pay-For-Parent technique."
|
|
)
|
|
painter.drawText(QRectF(20, current_height, page_width, page_height - current_height), Qt.TextFlag.TextWordWrap, step3_text)
|
|
|
|
# Recovery transaction pages
|
|
for i, recovery_part in enumerate(recovery_raw_parts):
|
|
printer.newPage()
|
|
page_number += 1
|
|
current_height = 20
|
|
|
|
# Header
|
|
painter.setFont(font_manager.header_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.header_line_spacing),
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
f"Recovery-Guide Date: {context.recovery_plan_created_at.strftime('%Y-%m-%d %H:%M:%S %Z (%z)')} ID: {context.recovery_plan_id} Page: {page_number}"
|
|
)
|
|
current_height += font_manager.header_line_spacing + 20
|
|
|
|
# Title
|
|
painter.setFont(font_manager.title_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.title_line_spacing),
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
"Recovery Transaction"
|
|
)
|
|
current_height += font_manager.title_line_spacing + 20
|
|
|
|
# Transaction ID
|
|
painter.setFont(font_manager.subtitle_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.subtitle_line_spacing),
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
f"Transaction Id: {context.recovery_tx.txid()}"
|
|
)
|
|
current_height += font_manager.subtitle_line_spacing + 20
|
|
|
|
# Part number if multiple parts
|
|
if len(recovery_raw_parts) > 1:
|
|
painter.setFont(font_manager.subtitle_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.subtitle_line_spacing),
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
f"Part {i+1} of {len(recovery_raw_parts)}"
|
|
)
|
|
current_height += font_manager.subtitle_line_spacing + 20
|
|
|
|
# QR Code
|
|
qr = qrcode.main.QRCode(
|
|
error_correction=qrcode.constants.ERROR_CORRECT_Q,
|
|
)
|
|
qr.add_data(recovery_part)
|
|
qr.make()
|
|
qr_image = self._paint_qr(qr)
|
|
|
|
# Calculate QR position to center it
|
|
qr_width = int(page_width * 0.6)
|
|
qr_x = (page_width - qr_width) / 2
|
|
painter.drawImage(QRectF(qr_x, current_height, qr_width, qr_width), qr_image)
|
|
current_height += qr_width + 40
|
|
|
|
# Raw text below QR
|
|
painter.setFont(font_manager.body_font)
|
|
painter.drawText(
|
|
QRectF(20, current_height, page_width, page_height - current_height),
|
|
Qt.TextFlag.TextWrapAnywhere,
|
|
recovery_part
|
|
)
|
|
|
|
def _save_cancellation_plan_pdf(self, context: TimelockRecoveryContext, download_dialog: WindowModalDialog):
|
|
# Open a Save As dialog to get the file path
|
|
file_path, _selected_filter = QFileDialog.getSaveFileName(
|
|
download_dialog,
|
|
_("Save Cancellation Plan PDF..."),
|
|
os.path.join(self.base_dir, "timelock-cancellation-plan-{}.pdf".format(context.recovery_plan_id)),
|
|
_("PDF files (*.pdf)")
|
|
)
|
|
if not file_path:
|
|
return
|
|
|
|
painter = QPainter()
|
|
temp_file_path: Optional[str] = None
|
|
|
|
try:
|
|
with tempfile.NamedTemporaryFile(dir=os.path.dirname(file_path), prefix=f"{os.path.basename(file_path)}-", delete=False) as temp_file:
|
|
temp_file_path = temp_file.name
|
|
printer = self._create_pdf_printer(temp_file_path)
|
|
if not painter.begin(printer):
|
|
return
|
|
self._paint_cancellation_plan_pdf(context, painter, printer)
|
|
painter.end()
|
|
shutil.move(temp_file_path, file_path)
|
|
download_dialog.show_message(_("File saved successfully"))
|
|
context.cancellation_plan_saved = True
|
|
except (IOError, MemoryError) as e:
|
|
self.logger.exception(repr(e))
|
|
download_dialog.show_error(_("Error saving file"))
|
|
if temp_file_path is not None and os.path.exists(temp_file_path):
|
|
os.remove(temp_file_path)
|
|
finally:
|
|
if painter.isActive():
|
|
painter.end()
|
|
|
|
def _paint_cancellation_plan_pdf(self, context: TimelockRecoveryContext, painter: QPainter, printer: QPrinter):
|
|
cancellation_raw = context.cancellation_tx.serialize().upper()
|
|
if len(cancellation_raw) > 2300:
|
|
# Splitting the cancellation transaction into multiple QR codes is not implemented
|
|
# because it is unexpected to happen anyways.
|
|
raise Exception("Cancellation transaction is too large to be saved as a single QR code")
|
|
|
|
font_manager = FontManager(self.font_name, printer.resolution())
|
|
|
|
# Get page dimensions
|
|
page_rect = printer.pageRect(QPrinter.Unit.DevicePixel)
|
|
page_width = page_rect.width()
|
|
page_height = page_rect.height()
|
|
|
|
current_height = 0
|
|
page_number = 1
|
|
|
|
# Header
|
|
painter.setFont(font_manager.header_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.header_line_spacing),
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
f"Cancellation-Guide Date: {context.recovery_plan_created_at.strftime('%Y-%m-%d %H:%M:%S %Z (%z)')} ID: {context.recovery_plan_id} Page: {page_number}"
|
|
)
|
|
current_height += font_manager.header_line_spacing + 40
|
|
|
|
current_height += self._paint_scaled_logo(painter, page_width, current_height) + 40
|
|
|
|
# Title
|
|
painter.setFont(font_manager.title_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.title_line_spacing),
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
"Timelock-Recovery Cancellation Guide"
|
|
)
|
|
current_height += font_manager.title_line_spacing + 20
|
|
|
|
# Subtitle
|
|
painter.setFont(font_manager.subtitle_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.subtitle_line_spacing + 20), Qt.AlignmentFlag.AlignCenter,
|
|
f"Electrum Version: {version.ELECTRUM_VERSION} - Plugin Version: {self.plugin_version}"
|
|
)
|
|
current_height += font_manager.subtitle_line_spacing + 60
|
|
|
|
# Main text
|
|
painter.setFont(font_manager.body_font)
|
|
explanation_text = (
|
|
f"This document is intended solely for the eyes of the owner of wallet: {context.wallet_name}. "
|
|
f"The Recovery Guide (the other document) will allow to transfer the funds from this wallet to "
|
|
f"a different wallet within {context.timelock_days} days. To prevent this from happening accidentally "
|
|
f"or maliciously by someone who found that document, you should periodically check if the Alert "
|
|
f"transaction has been broadcast, using a Bitcoin block-explorer website such as:"
|
|
)
|
|
drawn_rect = painter.drawText(
|
|
QRectF(20, current_height, page_width - 40, page_height),
|
|
Qt.TextFlag.TextWordWrap,
|
|
explanation_text
|
|
)
|
|
current_height += drawn_rect.height() + 40
|
|
|
|
# QR codes and links for transaction tracking
|
|
for link in [f"https://mempool.space/tx/{context.alert_tx.txid()}", f"https://blockstream.info/tx/{context.alert_tx.txid()}"]:
|
|
qr = qrcode.main.QRCode(
|
|
error_correction=qrcode.constants.ERROR_CORRECT_H,
|
|
)
|
|
qr.add_data(link)
|
|
qr.make()
|
|
qr_image = self._paint_qr(qr)
|
|
|
|
qr_width = int(page_width * 0.2)
|
|
qr_x = (page_width - qr_width) / 2
|
|
painter.drawImage(QRectF(qr_x, current_height, qr_width, qr_width), qr_image)
|
|
current_height += qr_width + 20
|
|
|
|
painter.setFont(font_manager.body_small_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.body_small_line_spacing),
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
link
|
|
)
|
|
current_height += font_manager.body_small_line_spacing + 20
|
|
|
|
# Watch tower text
|
|
painter.setFont(font_manager.body_font)
|
|
drawn_rect = painter.drawText(
|
|
QRectF(20, current_height, page_width - 40, page_height - current_height),
|
|
Qt.TextFlag.TextWordWrap,
|
|
"It is also recommended to use a Watch-Tower service that will notify you immediately if the"
|
|
" Alert transaction has been broadcast. For more details, visit: https://timelockrecovery.com ."
|
|
)
|
|
current_height += drawn_rect.height() + 40
|
|
|
|
# Cancellation transaction section
|
|
cancellation_text = (
|
|
"In case the Alert transaction has been broadcast, and you want to stop the funds from "
|
|
"leaving this wallet, you can scan the QR code on page 2, and broadcast "
|
|
"the content using one of the following Bitcoin block-explorer websites:\n\n"
|
|
"• https://mempool.space/tx/push\n"
|
|
"• https://blockstream.info/tx/push\n"
|
|
"• https://coinb.in/#broadcast\n\n"
|
|
"If the transaction is not confirmed within reasonable time due to a low fee, you will have "
|
|
"to access the wallet and use Replace-By-Fee/Child-Pay-For-Parent to move the funds to a new "
|
|
"address on your wallet. (you can also pay to an Acceleration Service such as the one offered "
|
|
"by https://mempool.space)\n\n"
|
|
f"IMPORTANT NOTICE: If you lost the keys to access wallet {context.wallet_name} - do not broadcast the "
|
|
"transaction on page 2! In this case it is recommended to destroy all copies of this document."
|
|
)
|
|
painter.drawText(
|
|
QRectF(20, current_height, page_width - 40, page_height),
|
|
Qt.TextFlag.TextWordWrap,
|
|
cancellation_text
|
|
)
|
|
|
|
# New page for cancellation transaction
|
|
printer.newPage()
|
|
page_number += 1
|
|
current_height = 20
|
|
|
|
# Header
|
|
painter.setFont(font_manager.header_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.header_line_spacing),
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
f"Cancellation-Guide Date: {context.recovery_plan_created_at.strftime('%Y-%m-%d %H:%M:%S %Z (%z)')} ID: {context.recovery_plan_id} Page: {page_number}"
|
|
)
|
|
current_height += font_manager.header_line_spacing + 20
|
|
|
|
# Cancellation transaction title
|
|
painter.setFont(font_manager.title_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.title_line_spacing),
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
"Cancellation Transaction"
|
|
)
|
|
current_height += font_manager.title_line_spacing + 20
|
|
|
|
# Transaction ID
|
|
painter.setFont(font_manager.subtitle_font)
|
|
painter.drawText(
|
|
QRectF(0, current_height, page_width, font_manager.subtitle_line_spacing),
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
f"Transaction Id: {context.cancellation_tx.txid()}"
|
|
)
|
|
current_height += font_manager.subtitle_line_spacing + 20
|
|
|
|
# QR Code for cancellation transaction
|
|
qr = qrcode.main.QRCode(
|
|
error_correction=qrcode.constants.ERROR_CORRECT_Q,
|
|
)
|
|
qr.add_data(cancellation_raw)
|
|
qr.make()
|
|
qr_image = self._paint_qr(qr)
|
|
|
|
qr_width = int(page_width * 0.6)
|
|
qr_x = (page_width - qr_width) / 2
|
|
painter.drawImage(QRectF(qr_x, current_height, qr_width, qr_width), qr_image)
|
|
current_height += qr_width + 40
|
|
|
|
# Raw transaction text
|
|
painter.setFont(font_manager.body_font)
|
|
painter.drawText(
|
|
QRectF(20, current_height, page_width - 40, page_height),
|
|
Qt.TextFlag.TextWrapAnywhere,
|
|
cancellation_raw
|
|
)
|
|
|
|
@classmethod
|
|
def _paint_qr(cls, qr: qrcode.main.QRCode) -> QImage:
|
|
k = len(qr.get_matrix())
|
|
base_img = QImage(k * 5, k * 5, QImage.Format.Format_ARGB32)
|
|
draw_qr(qr=qr, paint_device=base_img)
|
|
return base_img
|