Files
seedsigner/src/seedsigner/views/psbt_views.py
T

545 lines
21 KiB
Python

import logging
from typing import List
from embit.psbt import PSBT
from embit import script
from embit.networks import NETWORKS
from seedsigner.controller import Controller
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants
from seedsigner.models.encode_qr import EncodeQR
from seedsigner.models.psbt_parser import PSBTParser
from seedsigner.models.qr_type import QRType
from seedsigner.models.settings import SettingsConstants
from seedsigner.gui.screens.psbt_screens import PSBTOverviewScreen, PSBTMathScreen, PSBTAddressDetailsScreen, PSBTChangeDetailsScreen, PSBTFinalizeScreen
from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, QRDisplayScreen)
from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination
logger = logging.getLogger(__name__)
class PSBTSelectSeedView(View):
SCAN_SEED = ("Scan a seed", FontAwesomeIconConstants.QRCODE)
TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD)
TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD)
button_data = []
def run(self):
# Note: we can't just autoroute to the PSBT Overview because we might have a
# multisig where we want to sign with more than one key on this device.
if not self.controller.psbt:
# Shouldn't be able to get here
raise Exception("No PSBT currently loaded")
seeds = self.controller.storage.seeds
for seed in seeds:
button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
if not PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)):
# Doesn't look like this seed can sign the current PSBT
button_str += " (?)"
self.button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue"))
self.button_data.append(self.SCAN_SEED)
self.button_data.append(self.TYPE_12WORD)
self.button_data.append(self.TYPE_24WORD)
if self.controller.psbt_seed:
if PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=self.controller.psbt_seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)):
# skip the seed prompt if a seed was previous selected and has matching input fingerprint
return Destination(PSBTOverviewView)
selected_menu_num = self.run_screen(
ButtonListScreen,
title="Select Signer",
is_button_text_centered=False,
button_data=self.button_data
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if len(seeds) > 0 and selected_menu_num < len(seeds):
# User selected one of the n seeds
self.controller.psbt_seed = self.controller.get_seed(selected_menu_num)
return Destination(PSBTOverviewView)
# The remaining flows are a sub-flow; resume PSBT flow once the seed is loaded.
self.controller.resume_main_flow = Controller.FLOW__PSBT
if self.button_data[selected_menu_num] == self.SCAN_SEED:
from seedsigner.views.scan_views import ScanView
return Destination(ScanView)
elif self.button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]:
from seedsigner.views.seed_views import SeedMnemonicEntryView
if self.button_data[selected_menu_num] == self.TYPE_12WORD:
self.controller.storage.init_pending_mnemonic(num_words=12)
else:
self.controller.storage.init_pending_mnemonic(num_words=24)
return Destination(SeedMnemonicEntryView)
class PSBTOverviewView(View):
def __init__(self):
super().__init__()
self.loading_screen = None
if not self.controller.psbt_parser or self.controller.psbt_parser.seed != self.controller.psbt_seed:
# The PSBTParser takes a while to read the PSBT. Run the loading screen while
# we wait.
from seedsigner.gui.screens.screen import LoadingScreenThread
self.loading_screen = LoadingScreenThread(text="Parsing PSBT...")
self.loading_screen.start()
try:
self.controller.psbt_parser = PSBTParser(
self.controller.psbt,
seed=self.controller.psbt_seed,
network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)
)
except Exception as e:
self.loading_screen.stop()
raise e
def run(self):
psbt_parser = self.controller.psbt_parser
change_data = psbt_parser.change_data
"""
change_data = [
{
'address': 'bc1q............',
'amount': 397621401,
'fingerprint': ['22bde1a9', '73c5da0a'],
'derivation_path': ['m/48h/1h/0h/2h/1/0', 'm/48h/1h/0h/2h/1/0']
}, {},
]
"""
num_change_outputs = 0
num_self_transfer_outputs = 0
for change_output in change_data:
# print(f"""{change_output["derivation_path"][0]}""")
if change_output["derivation_path"][0].split("/")[-2] == "1":
num_change_outputs += 1
else:
num_self_transfer_outputs += 1
# Everything is set. Stop the loading screen
if self.loading_screen:
self.loading_screen.stop()
# Run the overview screen
selected_menu_num = self.run_screen(
PSBTOverviewScreen,
spend_amount=psbt_parser.spend_amount,
change_amount=psbt_parser.change_amount,
fee_amount=psbt_parser.fee_amount,
num_inputs=psbt_parser.num_inputs,
num_self_transfer_outputs=num_self_transfer_outputs,
num_change_outputs=num_change_outputs,
destination_addresses=psbt_parser.destination_addresses
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
self.controller.psbt_seed = None
return Destination(BackStackView)
# expecting p2sh (legacy multisig) and p2pkh to have no policy set
# skip change warning and psbt math view
if psbt_parser.policy == None:
return Destination(PSBTUnsupportedScriptTypeWarningView)
elif psbt_parser.change_amount == 0:
return Destination(PSBTNoChangeWarningView)
else:
return Destination(PSBTMathView)
class PSBTUnsupportedScriptTypeWarningView(View):
def run(self):
selected_menu_num = WarningScreen(
status_headline="Unsupported Script Type!",
text="PSBT has unsupported input script type, please verify your change addresses.",
button_data=["Continue"],
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
# Only one exit point
# skip PSBTMathView
return Destination(
PSBTAddressDetailsView, view_args={"address_num": 0},
skip_current_view=True, # Prevent going BACK to WarningViews
)
class PSBTNoChangeWarningView(View):
def run(self):
selected_menu_num = WarningScreen(
status_headline="Full Spend!",
text="This PSBT spends its entire input value. No change is coming back to your wallet.",
button_data=["Continue"],
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
# Only one exit point
return Destination(
PSBTMathView,
skip_current_view=True, # Prevent going BACK to WarningViews
)
class PSBTMathView(View):
"""
Follows the Overview pictogram. Shows:
+ total input value
- recipients' value
- fees
-------------------
+ change value
"""
def run(self):
psbt_parser: PSBTParser = self.controller.psbt_parser
if not psbt_parser:
# Should not be able to get here
return Destination(MainMenuView)
selected_menu_num = self.run_screen(
PSBTMathScreen,
input_amount=psbt_parser.input_amount,
num_inputs=psbt_parser.num_inputs,
spend_amount=psbt_parser.spend_amount,
num_recipients=psbt_parser.num_destinations,
fee_amount=psbt_parser.fee_amount,
change_amount=psbt_parser.change_amount,
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if len(psbt_parser.destination_addresses) > 0:
return Destination(PSBTAddressDetailsView, view_args={"address_num": 0})
else:
# This is a self-transfer
return Destination(PSBTChangeDetailsView, view_args={"change_address_num": 0})
class PSBTAddressDetailsView(View):
"""
Shows the recipient's address and amount they will receive
"""
NEXT = "Next"
button_data = []
def __init__(self, address_num):
super().__init__()
self.address_num = address_num
def run(self):
psbt_parser: PSBTParser = self.controller.psbt_parser
if not psbt_parser:
# Should not be able to get here
return Destination(MainMenuView)
title = "Will Send"
if psbt_parser.num_destinations > 1:
title += f" (#{self.address_num + 1})"
if self.address_num < psbt_parser.num_destinations - 1:
self.NEXT = "Next Recipient"
else:
self.NEXT = "Next"
self.button_data.append(self.NEXT)
selected_menu_num = self.run_screen(
PSBTAddressDetailsScreen,
title=title,
button_data=self.button_data,
address=psbt_parser.destination_addresses[self.address_num],
amount=psbt_parser.destination_amounts[self.address_num],
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if self.button_data[selected_menu_num] == self.NEXT:
if self.address_num < len(psbt_parser.destination_addresses) - 1:
# Show the next receive addr
return Destination(PSBTAddressDetailsView, view_args={"address_num": self.address_num + 1})
elif psbt_parser.change_amount > 0:
# Move on to display change
return Destination(PSBTChangeDetailsView, view_args={"change_address_num": 0})
else:
# There's no change output to verify. Move on to sign the PSBT.
return Destination(PSBTFinalizeView)
class PSBTChangeDetailsView(View):
NEXT = "Next"
VERIFY_MULTISIG = "Verify Multisig Change"
button_data = [NEXT]
def __init__(self, change_address_num):
super().__init__()
self.change_address_num = change_address_num
def run(self):
psbt_parser: PSBTParser = self.controller.psbt_parser
if not psbt_parser:
# Should not be able to get here
return Destination(MainMenuView)
# Can we verify this change addr?
change_data = psbt_parser.get_change_data(change_num=self.change_address_num)
"""
change_data:
{
'address': 'bc1q............',
'amount': 397621401,
'fingerprint': ['22bde1a9', '73c5da0a'],
'derivation_path': ['m/48h/1h/0h/2h/1/0', 'm/48h/1h/0h/2h/1/0']
}
"""
# Single-sig verification is easy. We expect to find a single fingerprint
# and derivation path.
seed_fingerprint = self.controller.psbt_seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
if seed_fingerprint not in change_data.get("fingerprint"):
# TODO: Something is wrong with this psbt(?). Reroute to warning?
return Destination(NotYetImplementedView)
i = change_data.get("fingerprint").index(seed_fingerprint)
derivation_path = change_data.get("derivation_path")[i]
# 'm/84h/1h/0h/1/0' would be a change addr while 'm/84h/1h/0h/0/0' is a self-receive
is_change_derivation_path = int(derivation_path.split("/")[-2]) == 1
derivation_path_addr_index = int(derivation_path.split("/")[-1])
if is_change_derivation_path:
title = "Your Change"
self.VERIFY_MULTISIG = "Verify Multisig Change"
else:
title = "Self-Transfer"
self.VERIFY_MULTISIG = "Verify Multisig Addr"
# if psbt_parser.num_change_outputs > 1:
# title += f" (#{self.change_address_num + 1})"
is_change_addr_verified = False
if psbt_parser.is_multisig:
# if the known-good multisig descriptor is already onboard:
if self.controller.multisig_wallet_descriptor:
is_change_addr_verified = psbt_parser.verify_multisig_output(self.controller.multisig_wallet_descriptor, change_num=self.change_address_num)
self.button_data = [self.NEXT]
else:
# Have the Screen offer to load in the multisig descriptor.
self.button_data = [self.VERIFY_MULTISIG, self.NEXT]
else:
# Single sig
try:
if is_change_derivation_path:
loading_screen_text = "Verifying Change..."
else:
loading_screen_text = "Verifying Self-Transfer..."
from seedsigner.gui.screens.screen import LoadingScreenThread
loading_screen = LoadingScreenThread(text=loading_screen_text)
loading_screen.start()
# convert change address to script pubkey to get script type
pubkey = script.address_to_scriptpubkey(change_data["address"])
script_type = pubkey.script_type()
# extract derivation path to get wallet and change derivation
change_path = '/'.join(derivation_path.split("/")[-2:])
wallet_path = '/'.join(derivation_path.split("/")[:-2])
xpub = self.controller.psbt_seed.get_xpub(
wallet_path=wallet_path,
network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)
)
# take script type and call script method to generate address from seed / derivation
xpub_key = xpub.derive(change_path).key
network = self.settings.get_value(SettingsConstants.SETTING__NETWORK)
scriptcall = getattr(script, script_type)
if script_type == "p2sh":
# single sig only so p2sh is always p2sh-p2wpkh
calc_address = script.p2sh(script.p2wpkh(xpub_key)).address(
network=NETWORKS[SettingsConstants.map_network_to_embit(network)]
)
else:
# single sig so this handles p2wpkh and p2wpkh (and p2tr in the future)
calc_address = scriptcall(xpub_key).address(
network=NETWORKS[SettingsConstants.map_network_to_embit(network)]
)
if change_data["address"] == calc_address:
is_change_addr_verified = True
self.button_data = [self.NEXT]
finally:
loading_screen.stop()
if is_change_addr_verified == False and (not psbt_parser.is_multisig or self.controller.multisig_wallet_descriptor is not None):
return Destination(PSBTAddressVerificationFailedView, view_args=dict(is_change=is_change_derivation_path, is_multisig=psbt_parser.is_multisig), clear_history=True)
selected_menu_num = self.run_screen(
PSBTChangeDetailsScreen,
title=title,
button_data=self.button_data,
address=change_data.get("address"),
amount=change_data.get("amount"),
is_multisig=psbt_parser.is_multisig,
fingerprint=seed_fingerprint,
derivation_path=derivation_path,
is_change_derivation_path=is_change_derivation_path,
derivation_path_addr_index=derivation_path_addr_index,
is_change_addr_verified=is_change_addr_verified,
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
elif self.button_data[selected_menu_num] == self.NEXT:
if self.change_address_num < psbt_parser.num_change_outputs - 1:
return Destination(PSBTChangeDetailsView, view_args={"change_address_num": self.change_address_num + 1})
else:
# There's no more change to verify. Move on to sign the PSBT.
return Destination(PSBTFinalizeView)
elif self.button_data[selected_menu_num] == self.VERIFY_MULTISIG:
from seedsigner.views.seed_views import LoadMultisigWalletDescriptorView
self.controller.resume_main_flow = Controller.FLOW__PSBT
return Destination(LoadMultisigWalletDescriptorView)
class PSBTAddressVerificationFailedView(View):
def __init__(self, is_change: bool = True, is_multisig: bool = False):
super().__init__()
self.is_change = is_change
self.is_multisig = is_multisig
def run(self):
if self.is_multisig:
title = "Caution"
text = f"""PSBT's {"change" if self.is_change else "self-transfer"} address could not be verified with your multisig wallet descriptor."""
else:
title = "Suspicious PSBT"
text = f"""PSBT's {"change" if self.is_change else "self-transfer"} address could not be generated from your seed."""
DireWarningScreen(
title=title,
status_headline="Address Verification Failed",
text=text,
button_data=["Discard PSBT"],
show_back_button=False,
).display()
# We're done with this PSBT. Route back to MainMenuView which always
# clears all ephemeral data (except in-memory seeds).
return Destination(MainMenuView, clear_history=True)
class PSBTFinalizeView(View):
"""
"""
APPROVE_PSBT = "Approve PSBT"
button_data = [APPROVE_PSBT]
def run(self):
psbt_parser: PSBTParser = self.controller.psbt_parser
psbt: PSBT = self.controller.psbt
if not psbt_parser:
# Should not be able to get here
return Destination(MainMenuView)
selected_menu_num = self.run_screen(
PSBTFinalizeScreen,
button_data=self.button_data
)
if self.button_data[selected_menu_num] == self.APPROVE_PSBT:
# Sign PSBT
sig_cnt = PSBTParser.sig_count(psbt)
psbt.sign_with(psbt_parser.root)
trimmed_psbt = PSBTParser.trim(psbt)
if sig_cnt == PSBTParser.sig_count(trimmed_psbt):
# Signing failed / didn't do anything
# TODO: Reserved for Nick. Are there different failure scenarios that we can detect?
# Would be nice to alter the message on the next screen w/more detail.
return Destination(PSBTSigningErrorView)
else:
self.controller.psbt = trimmed_psbt
return Destination(PSBTSignedQRDisplayView)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
class PSBTSignedQRDisplayView(View):
def run(self):
qr_encoder = EncodeQR(
psbt=self.controller.psbt,
qr_type=QRType.PSBT__UR2, # All coordinators (as of 2022-08) use this format
qr_density=self.settings.get_value(SettingsConstants.SETTING__QR_DENSITY),
wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE),
)
self.run_screen(QRDisplayScreen, qr_encoder=qr_encoder)
# We're done with this PSBT. Route back to MainMenuView which always
# clears all ephemeral data (except in-memory seeds).
return Destination(MainMenuView, clear_history=True)
class PSBTSigningErrorView(View):
def run(self):
psbt_parser: PSBTParser = self.controller.psbt_parser
if not psbt_parser:
# Should not be able to get here
return Destination(MainMenuView)
# Just a WarningScreen here; only use DireWarningScreen for true security risks.
selected_menu_num = WarningScreen(
title="PSBT Error",
status_icon_name=SeedSignerCustomIconConstants.CIRCLE_EXCLAMATION,
status_headline="Signing Failed",
text="Signing with this seed did not add a valid signature.",
button_data=["Select Diff Seed"],
).display()
if selected_menu_num == 0:
return Destination(PSBTSelectSeedView, clear_history=True)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)