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 += """ """ readme += f"""""" readme += """
{view_name}

\n""" readme += "" - # 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) diff --git a/tests/test_flows_psbt.py b/tests/test_flows_psbt.py index 3f4873808..b2653b017 100644 --- a/tests/test_flows_psbt.py +++ b/tests/test_flows_psbt.py @@ -3,6 +3,8 @@ from seedsigner.views.view import MainMenuView from seedsigner.views import scan_views, seed_views, psbt_views + +# TODO: Cleanup: convert TAB spacing to SPACE class TestPSBTFlows(FlowTest): def test_scan_psbt_first_then_correct_seedqr_flow(self): @@ -53,6 +55,7 @@ def load_seed_into_decoder(view: scan_views.ScanView): FlowStep(psbt_views.PSBTSignedQRDisplayView), FlowStep(MainMenuView) ]) + def test_scan_multisig_psbt_seed_already_signed_flow(self): @@ -89,4 +92,46 @@ def load_seed_into_decoder(view: scan_views.ScanView): FlowStep(psbt_views.PSBTSignedQRDisplayView), FlowStep(MainMenuView), ]) - + + + def test_parse_and_display_op_return_content(self): + """ + PSBTs that include an OP_RETURN should be able to be parsed like any other + PSBT and route to the dedicated OP_RETURN View to display the content + """ + def load_psbt_into_decoder(view: scan_views.ScanView): + """ + PSBT Tx and Wallet Details + - Single Sig Wallet P2WPKH (Native Segwit) with no passphrase + - Regtest 0fb882ff m/84'/1'/0' tpubDCfk37PqcQx6nFtFVuYHvRLJHxvYj33NjHkKRyRmWyCjyJ64sYBXyVjsTHaLBp5GLhM91VBgJ8nKDWDu52J2xVRy64c7ybEjjyWQJuQGLcg + - 1 Input + - 99,992,460 sats + - 2 Outputs + - 1 Output back to self (bcrt1qvwkhakqhz7m7kmz6332avatsmdy32m644g86vv) of 99,992,296 sats + - 1 OP_RETURN: "Chancellor on the brink of third bailout" + - Fee 164 sats + """ + view.decoder.add_data("cHNidP8BAIYCAAAAATpQ10o+gKdZ8ThpKsbfHiHYn3NhvUrQ5DvW0ZWX8jKLAAAAAAD9////AujC9QUAAAAAFgAUY61+2BcXt+tsWoxV1nVw20kVb1UAAAAAAAAAACtqTChDaGFuY2VsbG9yIG9uIHRoZSBicmluayBvZiB0aGlyZCBiYWlsb3V0aQAAAE8BBDWHzwNXmUmVgAAAANRFa7R5gYD84Wbha3d1QnjgfYPOBw87on6cXS32WoyqAsPFtPxB7PRTdbujUnBPUVDh9YUBtwrl4nc0OcRNGvIyEA+4gv9UAACAAQAAgAAAAIAAAQB0AgAAAAGNFK/1X0fP5q+nu5XX7Tk2VRa0EL+jkGI9CHiJvsjZCgAAAAAA/f///wKMw/UFAAAAABYAFIpZMNnUU6cQt8Q0YpZ0pnvsSA5fAAAAAAAAAAAZakwWYml0Y29pbiBpcyBmcmVlIHNwZWVjaGgAAAABAR+Mw/UFAAAAABYAFIpZMNnUU6cQt8Q0YpZ0pnvsSA5fAQMEAQAAACIGAvxDI0eNI1oQ2AU69R7A0jf+hUdilWCgrWHgdzkqlaXMGA+4gv9UAACAAQAAgAAAAIAAAAAAAQAAAAAiAgK9qKtzGWyiRrpmupdA99NVLriz3GQy6cENbyD19sfl/hgPuIL/VAAAgAEAAIAAAACAAAAAAAIAAAAAAA==") + + def load_seed_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("114006021552133507590698063102151531110102551496") + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, before_run=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored + FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED), + FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder), + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, is_redirect=True), + FlowStep(psbt_views.PSBTOverviewView), + FlowStep(psbt_views.PSBTMathView), + FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), + + # Should route to display OP_RETURN content + FlowStep(psbt_views.PSBTOpReturnView, button_data_selection=0), + + # Should be able to sign the psbt + FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT), + FlowStep(psbt_views.PSBTSignedQRDisplayView), + FlowStep(MainMenuView) + ]) diff --git a/tests/test_psbt_parser.py b/tests/test_psbt_parser.py index cf6f8db20..99b20c238 100644 --- a/tests/test_psbt_parser.py +++ b/tests/test_psbt_parser.py @@ -112,3 +112,46 @@ def test_p2sh_legacy_multisig(): # And the self-transfer receive addr assert psbt_parser.verify_multisig_output(descriptor, 1) + + +def test_parse_op_return_content(): + """ + Should successfully parse the OP_RETURN content from a PSBT. + + PSBT Tx and Wallet Details + - Single Sig Wallet P2WPKH (Native Segwit) with no passphrase + - Regtest 0fb882ff m/84'/1'/0' tpubDCfk37PqcQx6nFtFVuYHvRLJHxvYj33NjHkKRyRmWyCjyJ64sYBXyVjsTHaLBp5GLhM91VBgJ8nKDWDu52J2xVRy64c7ybEjjyWQJuQGLcg + - 1 Input + - 99,992,460 sats + - 2 Outputs + - 1 Output back to self (bcrt1qvwkhakqhz7m7kmz6332avatsmdy32m644g86vv) of 99,992,296 sats + - 1 OP_RETURN: "Chancellor on the brink of third bailout" + - Fee 164 sats + """ + psbt_base64 = "cHNidP8BAIYCAAAAATpQ10o+gKdZ8ThpKsbfHiHYn3NhvUrQ5DvW0ZWX8jKLAAAAAAD9////AujC9QUAAAAAFgAUY61+2BcXt+tsWoxV1nVw20kVb1UAAAAAAAAAACtqTChDaGFuY2VsbG9yIG9uIHRoZSBicmluayBvZiB0aGlyZCBiYWlsb3V0aQAAAE8BBDWHzwNXmUmVgAAAANRFa7R5gYD84Wbha3d1QnjgfYPOBw87on6cXS32WoyqAsPFtPxB7PRTdbujUnBPUVDh9YUBtwrl4nc0OcRNGvIyEA+4gv9UAACAAQAAgAAAAIAAAQB0AgAAAAGNFK/1X0fP5q+nu5XX7Tk2VRa0EL+jkGI9CHiJvsjZCgAAAAAA/f///wKMw/UFAAAAABYAFIpZMNnUU6cQt8Q0YpZ0pnvsSA5fAAAAAAAAAAAZakwWYml0Y29pbiBpcyBmcmVlIHNwZWVjaGgAAAABAR+Mw/UFAAAAABYAFIpZMNnUU6cQt8Q0YpZ0pnvsSA5fAQMEAQAAACIGAvxDI0eNI1oQ2AU69R7A0jf+hUdilWCgrWHgdzkqlaXMGA+4gv9UAACAAQAAgAAAAIAAAAAAAQAAAAAiAgK9qKtzGWyiRrpmupdA99NVLriz3GQy6cENbyD19sfl/hgPuIL/VAAAgAEAAIAAAACAAAAAAAIAAAAAAA==" + + raw = a2b_base64(psbt_base64) + tx = psbt.PSBT.parse(raw) + + mnemonic = "model ensure search plunge galaxy firm exclude brain satoshi meadow cable roast".split() + pw = "" + seed = Seed(mnemonic, passphrase=pw) + + psbt_parser = PSBTParser(p=tx, seed=seed, network=SettingsConstants.REGTEST) + + # Remember to do the comparison as bytes + assert psbt_parser.op_return_data == "Chancellor on the brink of third bailout".encode() + + # PSBT is an internal self-spend to the its own receive addr, but the parser categorizes it as "change" + assert psbt_parser.change_data == [ + { + 'output_index': 0, + 'address': 'bcrt1qvwkhakqhz7m7kmz6332avatsmdy32m644g86vv', + 'amount': 99992296, + 'fingerprint': ['0fb882ff'], + 'derivation_path': ["m/84h/1h/0h/0/2"]} + ] + assert psbt_parser.spend_amount == 0 # This is a self-spend; no value being spent, other than the tx fee + assert psbt_parser.change_amount == 99992296 + assert psbt_parser.destination_addresses == [] + assert psbt_parser.destination_amounts == []