1280 lines
60 KiB
Python
1280 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
|
|
|
|
title_hbox = QHBoxLayout()
|
|
title_hbox.addWidget(QLabel(_('To create a recovery plan, enter a recipient and a cancellation time window')))
|
|
title_hbox.addStretch(1)
|
|
help_button = QPushButton(_("Help"))
|
|
help_button.clicked.connect(lambda: self.create_intro_dialog(context))
|
|
title_hbox.addWidget(help_button)
|
|
|
|
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)
|
|
|
|
plan_grid = QGridLayout()
|
|
plan_grid.setSpacing(8)
|
|
grid_row = 0
|
|
|
|
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(title_hbox)
|
|
vbox_layout.addStretch(1)
|
|
vbox_layout.addLayout(plan_grid, stretch=1)
|
|
vbox_layout.addLayout(Buttons(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
|
|
assert pi.spk and pi.spk_is_address
|
|
context.outputs = [PartialTxOutput(scriptpubkey=pi.spk, 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
|