diff --git a/src/seedsigner/gui/screens/psbt_screens.py b/src/seedsigner/gui/screens/psbt_screens.py index 330742d5a..502c3921a 100644 --- a/src/seedsigner/gui/screens/psbt_screens.py +++ b/src/seedsigner/gui/screens/psbt_screens.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +import math from PIL import Image, ImageDraw, ImageFilter from typing import List import time @@ -21,6 +22,7 @@ class PSBTOverviewScreen(ButtonListScreen): num_self_transfer_outputs: int = 0 num_change_outputs: int = 0 destination_addresses: List[str] = None + has_op_return: bool = False def __post_init__(self): @@ -143,6 +145,9 @@ def truncate_destination_addr(addr): destination_column.append(f"fee") + if self.has_op_return: + destination_column.append("OP_RETURN") + if self.num_change_outputs > 0: for i in range(0, self.num_change_outputs): destination_column.append("change") @@ -681,6 +686,57 @@ def __post_init__(self): +@dataclass +class PSBTOpReturnScreen(ButtonListScreen): + op_return_data: bytes = None + + def __post_init__(self): + # Customize defaults + self.is_bottom_list = True + + super().__post_init__() + + try: + # Simple case: display human-readable text + self.components.append(TextArea( + text=self.op_return_data.decode(errors="strict"), # "strict" is a good enough heuristic to decide if it's human readable + font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE, + is_text_centered=True, + allow_text_overflow=True, + screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, + height=self.buttons[0].screen_y - self.top_nav.height - 2*GUIConstants.COMPONENT_PADDING, + )) + return + except UnicodeDecodeError: + # Contains data that can't be converted to UTF-8; probably encoded and not + # meant to be human readable. + font = Fonts.get_font(GUIConstants.FIXED_WIDTH_FONT_NAME, size=GUIConstants.BODY_FONT_SIZE) + (left, top, right, bottom) = font.getbbox("X", anchor="ls") + chars_per_line = int((self.canvas_width - 2*GUIConstants.EDGE_PADDING) / (right - left)) + decoded_str = self.op_return_data.hex() + num_lines = math.ceil(len(decoded_str) / chars_per_line) + text = "" + for i in range(num_lines): + text += (decoded_str[i*chars_per_line:(i+1)*chars_per_line]) + "\n" + text = text[:-1] + + label = TextArea( + text="raw hex data", + font_color=GUIConstants.LABEL_FONT_COLOR, + font_size=GUIConstants.LABEL_FONT_SIZE, + screen_y=self.top_nav.height, + ) + self.components.append(label) + + self.components.append(TextArea( + text=text, + font_name=GUIConstants.FIXED_WIDTH_FONT_NAME, + font_size=GUIConstants.BODY_FONT_SIZE, + screen_y=label.screen_y + label.height + GUIConstants.COMPONENT_PADDING, + )) + + + @dataclass class PSBTFinalizeScreen(ButtonListScreen): def __post_init__(self): diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index 9fe35c1c7..89e42daa2 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -1511,6 +1511,8 @@ def __post_init__(self): end_y = renderer.canvas_height - GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT - GUIConstants.COMPONENT_PADDING message_height = end_y - start_y + # TODO: Pass the full message in from the View so that this Screen doesn't need to + # interact with the Controller here. self.sign_message_data = Controller.get_instance().sign_message_data if "paged_message" not in self.sign_message_data: paged = reflow_text_into_pages( diff --git a/src/seedsigner/models/psbt_parser.py b/src/seedsigner/models/psbt_parser.py index a20c13cc1..8faba3fe0 100644 --- a/src/seedsigner/models/psbt_parser.py +++ b/src/seedsigner/models/psbt_parser.py @@ -10,6 +10,11 @@ from seedsigner.models.settings import SettingsConstants +class OPCODES: + OP_RETURN = 106 + OP_PUSHDATA1 = 76 + + class PSBTParser(): def __init__(self, p: PSBT, seed: Seed, network: str = SettingsConstants.MAINNET): @@ -26,6 +31,7 @@ def __init__(self, p: PSBT, seed: Seed, network: str = SettingsConstants.MAINNET self.num_inputs = 0 self.destination_addresses = [] self.destination_amounts = [] + self.op_return_data: bytes = None self.root = None @@ -169,7 +175,11 @@ def _parse_outputs(self): if sc.data == self.psbt.tx.vout[i].script_pubkey.data: is_change = True - if is_change: + if self.psbt.tx.vout[i].script_pubkey.data[0] == OPCODES.OP_RETURN: + # The data is written as: OP_RETURN + OP_PUSHDATA1 + len(payload) + payload + self.op_return_data = self.psbt.tx.vout[i].script_pubkey.data[3:] + + elif is_change: addr = self.psbt.tx.vout[i].script_pubkey.address(NETWORKS[SettingsConstants.map_network_to_embit(self.network)]) fingerprints = [] derivation_paths = [] @@ -252,21 +262,22 @@ def _get_policy(scope, scriptpubkey, xpubs): # expected multisig script = None - if "p2wsh" in script_type and scope.witness_script is not None: - script = scope.witness_script - - elif "p2sh" in script_type and scope.redeem_script is not None: - script = scope.redeem_script - - if script is not None: - m, n, pubkeys = PSBTParser._parse_multisig(script) - - # check pubkeys are derived from cosigners - try: - cosigners = PSBTParser._get_cosigners(pubkeys, scope.bip32_derivations, xpubs) - policy.update({"m": m, "n": n, "cosigners": cosigners}) - except: - policy.update({"m": m, "n": n}) + if script_type: + if "p2wsh" in script_type and scope.witness_script is not None: + script = scope.witness_script + + elif "p2sh" in script_type and scope.redeem_script is not None: + script = scope.redeem_script + + if script is not None: + m, n, pubkeys = PSBTParser._parse_multisig(script) + + # check pubkeys are derived from cosigners + try: + cosigners = PSBTParser._get_cosigners(pubkeys, scope.bip32_derivations, xpubs) + policy.update({"m": m, "n": n, "cosigners": cosigners}) + except: + policy.update({"m": m, "n": n}) return policy diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index 18e403127..acacfd8c5 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -7,7 +7,7 @@ from seedsigner.models.encode_qr import UrPsbtQrEncoder from seedsigner.models.psbt_parser import PSBTParser from seedsigner.models.settings import SettingsConstants -from seedsigner.gui.screens.psbt_screens import PSBTOverviewScreen, PSBTMathScreen, PSBTAddressDetailsScreen, PSBTChangeDetailsScreen, PSBTFinalizeScreen +from seedsigner.gui.screens.psbt_screens import PSBTOpReturnScreen, PSBTOverviewScreen, PSBTMathScreen, PSBTAddressDetailsScreen, PSBTChangeDetailsScreen, PSBTFinalizeScreen from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, QRDisplayScreen) from seedsigner.views.view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination @@ -137,7 +137,8 @@ def run(self): 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 + destination_addresses=psbt_parser.destination_addresses, + has_op_return=psbt_parser.op_return_data is not None, ) if selected_menu_num == RET_CODE__BACK_BUTTON: @@ -276,12 +277,14 @@ def run(self): # Move on to display change return Destination(PSBTChangeDetailsView, view_args={"change_address_num": 0}) + elif psbt_parser.op_return_data: + return Destination(PSBTOpReturnView) + else: # There's no change output to verify. Move on to sign the PSBT. return Destination(PSBTFinalizeView) - class PSBTChangeDetailsView(View): NEXT = "Next" SKIP_VERIFICATION = "Skip Verificiation" @@ -417,6 +420,10 @@ def run(self): if self.change_address_num < psbt_parser.num_change_outputs - 1: return Destination(PSBTChangeDetailsView, view_args={"change_address_num": self.change_address_num + 1}) + + elif psbt_parser.op_return_data: + return Destination(PSBTOpReturnView) + else: # There's no more change to verify. Move on to sign the PSBT. return Destination(PSBTFinalizeView) @@ -456,6 +463,33 @@ def run(self): return Destination(MainMenuView, clear_history=True) +class PSBTOpReturnView(View): + """ + Shows the OP_RETURN data + """ + def run(self): + psbt_parser: PSBTParser = self.controller.psbt_parser + + if not psbt_parser: + # Should not be able to get here + raise Exception("Routing error") + + title = "OP_RETURN" + button_data = ["Next"] + + selected_menu_num = self.run_screen( + PSBTOpReturnScreen, + title=title, + button_data=button_data, + op_return_data=psbt_parser.op_return_data, + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + return Destination(PSBTFinalizeView) + + class PSBTFinalizeView(View): """ diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 278df3389..a0160bed9 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -1,11 +1,17 @@ import embit import os +import random import sys import time from unittest.mock import Mock, patch, MagicMock from seedsigner.helpers import embit_utils -from seedsigner.models.settings import Settings +from embit import compact +from embit.psbt import PSBT, OutputScope +from embit.script import Script + +from seedsigner.helpers import embit_utils +from seedsigner.models.psbt_parser import OPCODES, PSBTParser # Prevent importing modules w/Raspi hardware dependencies. @@ -22,8 +28,6 @@ from seedsigner.controller import Controller from seedsigner.gui.renderer import Renderer from seedsigner.gui.toast import BaseToastOverlayManagerThread, RemoveSDCardToastManagerThread, SDCardStateChangeToastManagerThread -from seedsigner.hardware.buttons import HardwareButtons -from seedsigner.hardware.camera import Camera from seedsigner.hardware.microsd import MicroSD from seedsigner.models.decode_qr import DecodeQR from seedsigner.models.qr_type import QRType @@ -86,6 +90,34 @@ def test_generate_screenshots(target_locale): controller.psbt = decoder.get_psbt() controller.psbt_seed = seed_12b + def add_op_return_to_psbt(psbt: PSBT, raw_payload_data: bytes): + data = (compact.to_bytes(OPCODES.OP_RETURN) + + compact.to_bytes(OPCODES.OP_PUSHDATA1) + + compact.to_bytes(len(raw_payload_data)) + + raw_payload_data) + script = Script(data) + output = OutputScope() + output.script_pubkey = script + output.value = 0 + psbt.outputs.append(output) + return psbt.to_string() + + # Prep a PSBT with a human-readable OP_RETURN + raw_payload_data = "Chancellor on the brink of third bailout for banks".encode() + psbt = PSBT.from_base64(BASE64_PSBT_1) + + # Simplify the output side + output = psbt.outputs[-1] + psbt.outputs.clear() + psbt.outputs.append(output) + assert len(psbt.outputs) == 1 + BASE64_PSBT_WITH_OP_RETURN_TEXT = add_op_return_to_psbt(psbt, raw_payload_data) + + # Prep a PSBT with a (repeatably) random 80-byte OP_RETURN + random.seed(6102) + BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES = add_op_return_to_psbt(PSBT.from_base64(BASE64_PSBT_1), random.randbytes(80)) + + # Multisig wallet descriptor for the multisig in the above PSBT MULTISIG_WALLET_DESCRIPTOR = """wsh(sortedmulti(1,[22bde1a9/48h/1h/0h/2h]tpubDFfsBrmpj226ZYiRszYi2qK6iGvh2vkkghfGB2YiRUVY4rqqedHCFEgw12FwDkm7rUoVtq9wLTKc6BN2sxswvQeQgp7m8st4FP8WtP8go76/{0,1}/*,[73c5da0a/48h/1h/0h/2h]tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ/{0,1}/*))#3jhtf6yx""" controller.multisig_wallet_descriptor = embit.descriptor.Descriptor.from_string(MULTISIG_WALLET_DESCRIPTOR) @@ -193,6 +225,11 @@ def test_generate_screenshots(target_locale): (NotYetImplementedView, {}, "PSBTChangeDetailsView_multisig_unverified"), # Must manually re-run this below (psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), "PSBTChangeDetailsView_multisig_verified"), + + (NotYetImplementedView, {}, "PSBTOverviewView_op_return"), # Placeholder + (NotYetImplementedView, {}, "PSBTOpReturnView_text"), # Placeholder + (NotYetImplementedView, {}, "PSBTOpReturnView_raw_hex_data"), # Placeholder + (psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=False), "PSBTAddressVerificationFailedView_singlesig_change"), (psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=False), "PSBTAddressVerificationFailedView_singlesig_selftransfer"), (psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=True), "PSBTAddressVerificationFailedView_multisig_change"), @@ -241,7 +278,9 @@ def test_generate_screenshots(target_locale): readme = f"""# SeedSigner Screenshots\n""" - def screencap_view(view_cls: View, view_name: str, view_args: dict={}, toast_thread: BaseToastOverlayManagerThread = None): + def screencap_view(view_cls: View, view_args: dict = {}, view_name: str = None, toast_thread: BaseToastOverlayManagerThread = None): + if not view_name: + view_name = view_cls.__name__ screenshot_renderer.set_screenshot_filename(f"{view_name}.png") try: print(f"Running {view_name}") @@ -266,7 +305,6 @@ def screencap_view(view_cls: View, view_name: str, view_args: dict={}, toast_thr if toast_thread: toast_thread.stop() - for section_name, screenshot_list in screenshot_sections.items(): subdir = section_name.lower().replace(" ", "_") screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, subdir)) @@ -289,25 +327,41 @@ def screencap_view(view_cls: View, view_name: str, view_args: dict={}, toast_thr view_name = view_cls.__name__ toast_thread = None - screencap_view(view_cls, view_name, view_args, toast_thread=toast_thread) + screencap_view(view_cls, view_args=view_args, view_name=view_name, toast_thread=toast_thread) readme += """
{view_name} |