Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Enhancement] Parse psbts with OP_RETURN data & display payload #517

Merged
merged 15 commits into from
Jul 10, 2024
56 changes: 56 additions & 0 deletions src/seedsigner/gui/screens/psbt_screens.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from dataclasses import dataclass
import math
from PIL import Image, ImageDraw, ImageFilter
from typing import List
import time
Expand All @@ -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):
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions src/seedsigner/gui/screens/seed_screens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
43 changes: 27 additions & 16 deletions src/seedsigner/models/psbt_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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

Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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

Expand Down
40 changes: 37 additions & 3 deletions src/seedsigner/views/psbt_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
"""
Expand Down
72 changes: 63 additions & 9 deletions tests/screenshot_generator/generator.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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}")
Expand All @@ -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))
Expand All @@ -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 += """ <table align="left" style="border: 1px solid gray;">"""
readme += f"""<tr><td align="center">{view_name}<br/><br/><img src="{subdir}/{view_name}.png"></td></tr>"""
readme += """</table>\n"""

readme += "</td></tr></table>"

# many screens don't work, leaving a missing image, re-run here for now
# Re-render some screens that require more manual intervention / setup than the above
# scripting can support.
screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, "psbt_views"))

# Render the PSBTChangeDetailsView_multisig_unverified screenshot
decoder = DecodeQR()
decoder.add_data(BASE64_PSBT_1)
controller.psbt = decoder.get_psbt()
controller.psbt_seed = seed_12b
controller.multisig_wallet_descriptor = None
screencap_view(psbt_views.PSBTChangeDetailsView, 'PSBTChangeDetailsView_multisig_unverified', dict(change_address_num=0))
screencap_view(psbt_views.PSBTChangeDetailsView, view_name='PSBTChangeDetailsView_multisig_unverified', view_args=dict(change_address_num=0))

controller.psbt_seed = None
screencap_view(psbt_views.PSBTSelectSeedView, 'PSBTSelectSeedView', {})
screencap_view(psbt_views.PSBTSelectSeedView, view_name='PSBTSelectSeedView')

# Render OP_RETURN screens for real
controller.psbt_seed = seed_12b
decoder = DecodeQR()
decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_TEXT)
controller.psbt = decoder.get_psbt()
controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b)
screencap_view(psbt_views.PSBTOverviewView, view_name='PSBTOverviewView_op_return')
screencap_view(psbt_views.PSBTOpReturnView, view_name="PSBTOpReturnView_text")

decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES)
controller.psbt = decoder.get_psbt()
controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b)
screencap_view(psbt_views.PSBTOpReturnView, view_name="PSBTOpReturnView_raw_hex_data")

with open(os.path.join(screenshot_root, "README.md"), 'w') as readme_file:
readme_file.write(readme)
Loading
Loading