From 887a8a7f23a93da69299dac7d50c18d850bea521 Mon Sep 17 00:00:00 2001 From: Gabriel Levcovitz Date: Tue, 25 Feb 2025 18:57:08 -0300 Subject: [PATCH] feat(sighash): implement sighash range --- hathor/transaction/scripts/opcode.py | 35 +- hathor/transaction/scripts/p2pkh.py | 31 +- hathor/transaction/scripts/script_context.py | 6 +- hathor/transaction/scripts/sighash.py | 36 +- tests/tx/scripts/test_p2pkh.py | 92 +++++- tests/tx/scripts/test_script_context.py | 41 ++- ...est_sighash.py => test_sighash_bitmask.py} | 2 +- tests/tx/scripts/test_sighash_range.py | 312 ++++++++++++++++++ tests/tx/scripts/test_tx_sighash.py | 62 +++- tests/tx/test_scripts.py | 43 ++- 10 files changed, 629 insertions(+), 31 deletions(-) rename tests/tx/scripts/{test_sighash.py => test_sighash_bitmask.py} (99%) create mode 100644 tests/tx/scripts/test_sighash_range.py diff --git a/hathor/transaction/scripts/opcode.py b/hathor/transaction/scripts/opcode.py index 22230c25f..e787a07c4 100644 --- a/hathor/transaction/scripts/opcode.py +++ b/hathor/transaction/scripts/opcode.py @@ -45,7 +45,7 @@ ) from hathor.transaction.scripts.execute import Stack, binary_to_int, decode_opn, get_data_value, get_script_op from hathor.transaction.scripts.script_context import ScriptContext -from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashBitmask +from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashBitmask, SighashRange from hathor.transaction.util import bytes_to_int @@ -655,6 +655,38 @@ def op_sighash_bitmask(context: ScriptContext) -> None: context.set_sighash(sighash) +def op_sighash_range(context: ScriptContext) -> None: + """Pop four items from the stack, constructing a sighash range and setting it in the script context.""" + if len(context.stack) < 4: + raise MissingStackItems(f'OP_SIGHASH_RANGE: expected 4 elements on stack, has {len(context.stack)}') + + output_end = context.stack.pop() + output_start = context.stack.pop() + input_end = context.stack.pop() + input_start = context.stack.pop() + assert isinstance(output_end, bytes) + assert isinstance(output_start, bytes) + assert isinstance(input_end, bytes) + assert isinstance(input_start, bytes) + + try: + sighash = SighashRange( + input_start=bytes_to_int(input_start), + input_end=bytes_to_int(input_end), + output_start=bytes_to_int(output_start), + output_end=bytes_to_int(output_end), + ) + except pydantic.ValidationError as e: + raise CustomSighashModelInvalid('Could not construct sighash range.') from e + + if context.extras.input_index not in sighash.get_input_indexes(): + raise InputNotSelectedError( + f'Input at index {context.extras.input_index} must select itself when using a custom sighash.' + ) + + context.set_sighash(sighash) + + def op_max_inputs_outputs(context: ScriptContext) -> None: """Pop two items from the stack, constructing an inputs and outputs limit and setting it in the script context.""" if len(context.stack) < 2: @@ -707,6 +739,7 @@ def execute_op_code(opcode: Opcode, context: ScriptContext) -> None: case Opcode.OP_CHECKDATASIG: op_checkdatasig(context) case Opcode.OP_FIND_P2PKH: op_find_p2pkh(context) case Opcode.OP_SIGHASH_BITMASK: op_sighash_bitmask(context) + case Opcode.OP_SIGHASH_RANGE: op_sighash_range(context) case Opcode.OP_MAX_INPUTS_OUTPUTS: op_max_inputs_outputs(context) case _: raise ScriptError(f'unknown opcode: {opcode}') diff --git a/hathor/transaction/scripts/p2pkh.py b/hathor/transaction/scripts/p2pkh.py index 76cc412c5..8e1d9446c 100644 --- a/hathor/transaction/scripts/p2pkh.py +++ b/hathor/transaction/scripts/p2pkh.py @@ -15,12 +15,20 @@ import struct from typing import Any, Optional +from typing_extensions import assert_never + from hathor.crypto.util import decode_address, get_address_b58_from_public_key_hash from hathor.transaction.scripts.base_script import BaseScript from hathor.transaction.scripts.construct import get_pushdata, re_compile from hathor.transaction.scripts.hathor_script import HathorScript from hathor.transaction.scripts.opcode import Opcode -from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashBitmask +from hathor.transaction.scripts.sighash import ( + InputsOutputsLimit, + SighashAll, + SighashBitmask, + SighashRange, + SighashType, +) class P2PKH(BaseScript): @@ -97,7 +105,7 @@ def create_input_data( public_key_bytes: bytes, signature: bytes, *, - sighash: SighashBitmask | None = None, + sighash: SighashType = SighashAll(), inputs_outputs_limit: InputsOutputsLimit | None = None ) -> bytes: """ @@ -108,10 +116,21 @@ def create_input_data( """ s = HathorScript() - if sighash: - s.pushData(sighash.inputs) - s.pushData(sighash.outputs) - s.addOpcode(Opcode.OP_SIGHASH_BITMASK) + match sighash: + case SighashAll(): + pass + case SighashBitmask(): + s.pushData(sighash.inputs) + s.pushData(sighash.outputs) + s.addOpcode(Opcode.OP_SIGHASH_BITMASK) + case SighashRange(): + s.pushData(sighash.input_start) + s.pushData(sighash.input_end) + s.pushData(sighash.output_start) + s.pushData(sighash.output_end) + s.addOpcode(Opcode.OP_SIGHASH_RANGE) + case _: + assert_never(sighash) if inputs_outputs_limit: s.pushData(inputs_outputs_limit.max_inputs) diff --git a/hathor/transaction/scripts/script_context.py b/hathor/transaction/scripts/script_context.py index a414671b8..edb779e05 100644 --- a/hathor/transaction/scripts/script_context.py +++ b/hathor/transaction/scripts/script_context.py @@ -20,7 +20,7 @@ from hathor.transaction import Transaction from hathor.transaction.exceptions import ScriptError from hathor.transaction.scripts.execute import ScriptExtras, Stack -from hathor.transaction.scripts.sighash import SighashAll, SighashBitmask, SighashType +from hathor.transaction.scripts.sighash import SighashAll, SighashBitmask, SighashRange, SighashType class ScriptContext: @@ -52,7 +52,7 @@ def get_tx_sighash_data(self, tx: Transaction) -> bytes: match self._sighash: case SighashAll(): return tx.get_sighash_all_data() - case SighashBitmask(): + case SighashBitmask() | SighashRange(): data = tx.get_custom_sighash_data(self._sighash) return hashlib.sha256(data).digest() case _: @@ -63,7 +63,7 @@ def get_selected_outputs(self) -> set[int]: match self._sighash: case SighashAll(): return set(range(self._settings.MAX_NUM_OUTPUTS)) - case SighashBitmask(): + case SighashBitmask() | SighashRange(): return set(self._sighash.get_output_indexes()) case _: assert_never(self._sighash) diff --git a/hathor/transaction/scripts/sighash.py b/hathor/transaction/scripts/sighash.py index 01565b72e..91461c73d 100644 --- a/hathor/transaction/scripts/sighash.py +++ b/hathor/transaction/scripts/sighash.py @@ -14,9 +14,9 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import TypeAlias +from typing import Any, TypeAlias -from pydantic import Field +from pydantic import Field, validator from typing_extensions import override from hathor.utils.pydantic import BaseModel @@ -60,7 +60,37 @@ def _get_indexes(bitmask: int) -> list[int]: return [index for index in range(8) if (bitmask >> index) & 1] -SighashType: TypeAlias = SighashAll | SighashBitmask +class SighashRange(CustomSighash): + """A model representing the sighash range type config. Range ends are not inclusive.""" + input_start: int = Field(ge=0, le=255) + input_end: int = Field(ge=0, le=255) + output_start: int = Field(ge=0, le=255) + output_end: int = Field(ge=0, le=255) + + @validator('input_end') + def _validate_input_end(cls, input_end: int, values: dict[str, Any]) -> int: + if input_end < values['input_start']: + raise ValueError('input_end must be greater than or equal to input_start.') + + return input_end + + @validator('output_end') + def _validate_output_end(cls, output_end: int, values: dict[str, Any]) -> int: + if output_end < values['output_start']: + raise ValueError('output_end must be greater than or equal to output_start.') + + return output_end + + @override + def get_input_indexes(self) -> list[int]: + return list(range(self.input_start, self.input_end)) + + @override + def get_output_indexes(self) -> list[int]: + return list(range(self.output_start, self.output_end)) + + +SighashType: TypeAlias = SighashAll | SighashBitmask | SighashRange class InputsOutputsLimit(BaseModel): diff --git a/tests/tx/scripts/test_p2pkh.py b/tests/tx/scripts/test_p2pkh.py index 9c11b4feb..ae6ed5c51 100644 --- a/tests/tx/scripts/test_p2pkh.py +++ b/tests/tx/scripts/test_p2pkh.py @@ -13,7 +13,7 @@ # limitations under the License. from hathor.transaction.scripts import P2PKH, Opcode -from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashBitmask +from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashAll, SighashBitmask, SighashRange def test_create_input_data_simple() -> None: @@ -29,6 +29,19 @@ def test_create_input_data_simple() -> None: ]) +def test_create_input_data_with_sighash_all() -> None: + pub_key = b'my_pub_key' + signature = b'my_signature' + data = P2PKH.create_input_data(public_key_bytes=pub_key, signature=signature, sighash=SighashAll()) + + assert data == bytes([ + len(signature), + *signature, + len(pub_key), + *pub_key + ]) + + def test_create_input_data_with_sighash_bitmask() -> None: pub_key = b'my_pub_key' signature = b'my_signature' @@ -50,6 +63,38 @@ def test_create_input_data_with_sighash_bitmask() -> None: ]) +def test_create_input_data_with_sighash_range() -> None: + pub_key = b'my_pub_key' + signature = b'my_signature' + input_start = 123 + input_end = 145 + output_start = 10 + output_end = 20 + sighash = SighashRange( + input_start=input_start, + input_end=input_end, + output_start=output_start, + output_end=output_end, + ) + data = P2PKH.create_input_data(public_key_bytes=pub_key, signature=signature, sighash=sighash) + + assert data == bytes([ + 1, + input_start, + 1, + input_end, + 1, + output_start, + 1, + output_end, + Opcode.OP_SIGHASH_RANGE, + len(signature), + *signature, + len(pub_key), + *pub_key + ]) + + def test_create_input_data_with_inputs_outputs_limit() -> None: pub_key = b'my_pub_key' signature = b'my_signature' @@ -103,3 +148,48 @@ def test_create_input_data_with_sighash_bitmask_and_inputs_outputs_limit() -> No len(pub_key), *pub_key ]) + + +def test_create_input_data_with_sighash_range_and_inputs_outputs_limit() -> None: + pub_key = b'my_pub_key' + signature = b'my_signature' + input_start = 123 + input_end = 145 + output_start = 10 + output_end = 20 + max_inputs = 2 + max_outputs = 3 + sighash = SighashRange( + input_start=input_start, + input_end=input_end, + output_start=output_start, + output_end=output_end, + ) + limit = InputsOutputsLimit(max_inputs=max_inputs, max_outputs=max_outputs) + data = P2PKH.create_input_data( + public_key_bytes=pub_key, + signature=signature, + sighash=sighash, + inputs_outputs_limit=limit + ) + + assert data == bytes([ + 1, + input_start, + 1, + input_end, + 1, + output_start, + 1, + output_end, + Opcode.OP_SIGHASH_RANGE, + 1, + max_inputs, + 1, + max_outputs, + Opcode.OP_MAX_INPUTS_OUTPUTS, + len(signature), + *signature, + len(pub_key), + *pub_key + ]) diff --git a/tests/tx/scripts/test_script_context.py b/tests/tx/scripts/test_script_context.py index bba18ae0f..11fc19dd3 100644 --- a/tests/tx/scripts/test_script_context.py +++ b/tests/tx/scripts/test_script_context.py @@ -21,7 +21,7 @@ from hathor.transaction import Transaction, TxInput, TxOutput from hathor.transaction.exceptions import ScriptError from hathor.transaction.scripts.script_context import ScriptContext -from hathor.transaction.scripts.sighash import SighashAll, SighashBitmask +from hathor.transaction.scripts.sighash import SighashAll, SighashBitmask, SighashRange @pytest.mark.parametrize(['max_num_outputs'], [(99,), (255,)]) @@ -95,3 +95,42 @@ def test_sighash_bitmask(outputs_bitmask: int, selected_outputs: set[int]) -> No assert str(e.value) == 'Cannot modify sighash after it is already set.' assert context.get_selected_outputs() == selected_outputs + + +@pytest.mark.parametrize( + ['output_start', 'output_end', 'selected_outputs'], + [ + (100, 100, set()), + (0, 1, {0}), + (1, 2, {1}), + (0, 2, {0, 1}), + ] +) +def test_sighash_range(output_start: int, output_end: int, selected_outputs: set[int]) -> None: + settings = Mock() + settings.MAX_NUM_INPUTS = 88 + settings.MAX_NUM_OUTPUTS = 99 + + context = ScriptContext(settings=settings, stack=Mock(), logs=[], extras=Mock()) + tx = Transaction( + inputs=[ + TxInput(tx_id=b'tx1', index=0, data=b''), + TxInput(tx_id=b'tx2', index=1, data=b''), + ], + outputs=[ + TxOutput(value=11, script=b''), + TxOutput(value=22, script=b''), + ] + ) + + sighash_range = SighashRange(input_start=0, input_end=2, output_start=output_start, output_end=output_end) + context.set_sighash(sighash_range) + + data = tx.get_custom_sighash_data(sighash_range) + assert context.get_tx_sighash_data(tx) == hashlib.sha256(data).digest() + + with pytest.raises(ScriptError) as e: + context.set_sighash(Mock()) + + assert str(e.value) == 'Cannot modify sighash after it is already set.' + assert context.get_selected_outputs() == selected_outputs diff --git a/tests/tx/scripts/test_sighash.py b/tests/tx/scripts/test_sighash_bitmask.py similarity index 99% rename from tests/tx/scripts/test_sighash.py rename to tests/tx/scripts/test_sighash_bitmask.py index d78af971f..4038d7df8 100644 --- a/tests/tx/scripts/test_sighash.py +++ b/tests/tx/scripts/test_sighash_bitmask.py @@ -30,7 +30,7 @@ from tests.utils import add_blocks_unlock_reward, create_tokens, get_genesis_key -class SighashTest(unittest.TestCase): +class SighashBitmaskTest(unittest.TestCase): def setUp(self) -> None: super().setUp() self.manager1: HathorManager = self.create_peer('testnet', unlock_wallet=True, wallet_index=True) diff --git a/tests/tx/scripts/test_sighash_range.py b/tests/tx/scripts/test_sighash_range.py new file mode 100644 index 000000000..58657e1c3 --- /dev/null +++ b/tests/tx/scripts/test_sighash_range.py @@ -0,0 +1,312 @@ +# Copyright 2023 Hathor Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import cast +from unittest.mock import patch + +import pytest + +from hathor.crypto.util import decode_address +from hathor.exception import InvalidNewTransaction +from hathor.manager import HathorManager +from hathor.transaction import Transaction, TxInput, TxOutput +from hathor.transaction.exceptions import InputOutputMismatch, InvalidInputData, InvalidScriptError +from hathor.transaction.scripts.p2pkh import P2PKH +from hathor.transaction.scripts.sighash import InputsOutputsLimit, SighashRange +from hathor.transaction.static_metadata import TransactionStaticMetadata +from hathor.util import not_none +from tests import unittest +from tests.utils import add_blocks_unlock_reward, create_tokens, get_genesis_key + + +class SighashRangeTest(unittest.TestCase): + def setUp(self) -> None: + super().setUp() + self.manager1: HathorManager = self.create_peer('testnet', unlock_wallet=True, wallet_index=True) + self.manager2: HathorManager = self.create_peer('testnet', unlock_wallet=True, wallet_index=True) + + # 1 is Alice + assert self.manager1.wallet + self.address1_b58 = self.manager1.wallet.get_unused_address() + self.private_key1 = self.manager1.wallet.get_private_key(self.address1_b58) + self.address1 = decode_address(self.address1_b58) + + # 2 is Bob + assert self.manager2.wallet + self.address2_b58 = self.manager2.wallet.get_unused_address() + self.address2 = decode_address(self.address2_b58) + + self.genesis_private_key = get_genesis_key() + self.genesis_block = self.manager1.tx_storage.get_transaction(self._settings.GENESIS_BLOCK_HASH) + + # Add some blocks so we can spend the genesis outputs + add_blocks_unlock_reward(self.manager1) + + @patch('hathor.transaction.scripts.opcode.is_opcode_valid', lambda _: True) + def test_sighash_range(self) -> None: + # Create a new test token + token_creation_tx = create_tokens(self.manager1, self.address1_b58) + token_uid = token_creation_tx.tokens[0] + token_creation_utxo = token_creation_tx.outputs[0] + genesis_utxo = self.genesis_block.outputs[0] + parents = self.manager1.get_new_tx_parents() + + # Alice creates an input spending all created test tokens + tokens_input = TxInput(not_none(token_creation_tx.hash), 0, b'') + + # Alice creates an output sending half genesis HTR to herself + alice_output_script = P2PKH.create_output_script(self.address1) + htr_output = TxOutput(int(genesis_utxo.value / 2), alice_output_script) + + # Alice creates an atomic swap tx that's missing only Bob's input, with half genesis HTR + atomic_swap_tx = Transaction( + weight=1, + inputs=[tokens_input], + outputs=[htr_output], + parents=parents, + tokens=[token_uid], + storage=self.manager1.tx_storage, + timestamp=token_creation_tx.timestamp + 1 + ) + self.manager1.cpu_mining_service.resolve(atomic_swap_tx) + + # Alice signs her input using sighash range, instead of sighash_all. + sighash_range = SighashRange(input_start=0, input_end=1, output_start=0, output_end=1) + data_to_sign1 = atomic_swap_tx.get_custom_sighash_data(sighash_range) + assert self.manager1.wallet + public_bytes1, signature1 = self.manager1.wallet.get_input_aux_data(data_to_sign1, self.private_key1) + tokens_input.data = P2PKH.create_input_data( + public_key_bytes=public_bytes1, + signature=signature1, + sighash=sighash_range, + ) + + # At this point, the tx is partial. The inputs are valid, but they're mismatched with outputs + self.manager1.verification_service.verifiers.tx.verify_inputs(atomic_swap_tx) + with pytest.raises(InputOutputMismatch): + self.manager1.verification_service.verify(atomic_swap_tx) + + # Alice sends the tx bytes to Bob, represented here by cloning the tx + atomic_swap_tx_clone = cast(Transaction, atomic_swap_tx.clone()) + self.manager1.cpu_mining_service.resolve(atomic_swap_tx_clone) + + # Bob creates an input spending all genesis HTR and adds it to the atomic swap tx + htr_input = TxInput(not_none(self.genesis_block.hash), 0, b'') + atomic_swap_tx_clone.inputs.append(htr_input) + + # Bob adds an output to receive all test tokens + bob_output_script = P2PKH.create_output_script(self.address2) + tokens_output = TxOutput(token_creation_utxo.value, bob_output_script, 1) + atomic_swap_tx_clone.outputs.append(tokens_output) + + # Bob adds a change output for his HTR + htr_output = TxOutput(int(genesis_utxo.value / 2), bob_output_script) + atomic_swap_tx_clone.outputs.append(htr_output) + + # Bob signs his input using sighash_all to complete the tx + data_to_sign2 = atomic_swap_tx_clone.get_sighash_all() + assert self.manager2.wallet + public_bytes2, signature2 = self.manager2.wallet.get_input_aux_data(data_to_sign2, self.genesis_private_key) + htr_input.data = P2PKH.create_input_data(public_bytes2, signature2) + + static_metadata = TransactionStaticMetadata.create_from_storage( + atomic_swap_tx_clone, self._settings, self.manager1.tx_storage + ) + atomic_swap_tx_clone.set_static_metadata(static_metadata) + + # The atomic swap tx is now completed and valid, and can be propagated + self.manager1.verification_service.verify(atomic_swap_tx_clone) + self.manager1.propagate_tx(atomic_swap_tx_clone, fails_silently=False) + + @patch('hathor.transaction.scripts.opcode.is_opcode_valid', lambda _: True) + def test_sighash_range_with_limit(self) -> None: + # Create a new test token + token_creation_tx = create_tokens(self.manager1, self.address1_b58) + token_uid = token_creation_tx.tokens[0] + token_creation_utxo = token_creation_tx.outputs[0] + genesis_utxo = self.genesis_block.outputs[0] + parents = self.manager1.get_new_tx_parents() + + # Alice creates an input spending all created test tokens + tokens_input = TxInput(not_none(token_creation_tx.hash), 0, b'') + + # Alice creates an output sending half genesis HTR to herself + alice_output_script = P2PKH.create_output_script(self.address1) + htr_output = TxOutput(int(genesis_utxo.value / 2), alice_output_script) + + # Alice creates an atomic swap tx that's missing only Bob's input, with half genesis HTR + atomic_swap_tx = Transaction( + weight=1, + inputs=[tokens_input], + outputs=[htr_output], + parents=parents, + tokens=[token_uid], + storage=self.manager1.tx_storage, + timestamp=token_creation_tx.timestamp + 1 + ) + self.manager1.cpu_mining_service.resolve(atomic_swap_tx) + + # Alice signs her input using sighash range, instead of sighash_all. + # She also sets max inputs and max outputs limits, including one output for change. + sighash_range = SighashRange(input_start=0, input_end=1, output_start=0, output_end=1) + data_to_sign1 = atomic_swap_tx.get_custom_sighash_data(sighash_range) + assert self.manager1.wallet + public_bytes1, signature1 = self.manager1.wallet.get_input_aux_data(data_to_sign1, self.private_key1) + tokens_input.data = P2PKH.create_input_data( + public_key_bytes=public_bytes1, + signature=signature1, + sighash=sighash_range, + inputs_outputs_limit=InputsOutputsLimit(max_inputs=2, max_outputs=3) + ) + + # At this point, the tx is partial. The inputs are valid, but they're mismatched with outputs + self.manager1.verification_service.verifiers.tx.verify_inputs(atomic_swap_tx) + with pytest.raises(InputOutputMismatch): + self.manager1.verification_service.verify(atomic_swap_tx) + + # Alice sends the tx bytes to Bob, represented here by cloning the tx + atomic_swap_tx_clone = cast(Transaction, atomic_swap_tx.clone()) + self.manager1.cpu_mining_service.resolve(atomic_swap_tx_clone) + + # Bob creates an input spending all genesis HTR and adds it to the atomic swap tx + htr_input = TxInput(not_none(self.genesis_block.hash), 0, b'') + atomic_swap_tx_clone.inputs.append(htr_input) + + # Bob adds an output to receive all test tokens + bob_output_script = P2PKH.create_output_script(self.address2) + tokens_output = TxOutput(token_creation_utxo.value, bob_output_script, 1) + atomic_swap_tx_clone.outputs.append(tokens_output) + + # Bob adds two change outputs for his HTR, which violates the maximum tx outputs set by Alice + htr_output1 = TxOutput(int(genesis_utxo.value / 4), bob_output_script) + htr_output2 = TxOutput(int(genesis_utxo.value / 4), bob_output_script) + atomic_swap_tx_clone.outputs.append(htr_output1) + atomic_swap_tx_clone.outputs.append(htr_output2) + + # Bob signs his input using sighash_all to complete the tx + data_to_sign2 = atomic_swap_tx_clone.get_sighash_all() + assert self.manager2.wallet + public_bytes2, signature2 = self.manager2.wallet.get_input_aux_data(data_to_sign2, self.genesis_private_key) + htr_input.data = P2PKH.create_input_data(public_bytes2, signature2) + + # The atomic swap tx is not valid and cannot be propagated + with pytest.raises(InvalidInputData) as e: + self.manager1.verification_service.verify(atomic_swap_tx_clone) + + self.assertEqual(str(e.value), "Maximum number of outputs exceeded (4 > 3).") + + with pytest.raises(InvalidNewTransaction): + self.manager1.propagate_tx(atomic_swap_tx_clone, fails_silently=False) + + @patch('hathor.transaction.scripts.opcode.is_opcode_valid', lambda _: True) + def test_sighash_range_input_not_selected(self) -> None: + # Create a new test token + token_creation_tx = create_tokens(self.manager1, self.address1_b58) + token_uid = token_creation_tx.tokens[0] + parents = self.manager1.get_new_tx_parents() + + # Alice creates an input spending all created test tokens + tokens_input = TxInput(not_none(token_creation_tx.hash), 0, b'') + + # Alice creates an input spending all genesis HTR + genesis_input = TxInput(not_none(self.genesis_block.hash), 0, b'') + + # Alice creates an atomic swap tx + atomic_swap_tx = Transaction( + weight=1, + inputs=[tokens_input, genesis_input], + outputs=[], + parents=parents, + tokens=[token_uid], + storage=self.manager1.tx_storage, + timestamp=token_creation_tx.timestamp + 1 + ) + self.manager1.cpu_mining_service.resolve(atomic_swap_tx) + + # Alice signs her token input using sighash range, instead of sighash_all. + sighash_range = SighashRange(input_start=0, input_end=1, output_start=0, output_end=0) + data_to_sign1 = atomic_swap_tx.get_custom_sighash_data(sighash_range) + assert self.manager1.wallet + public_bytes1, signature1 = self.manager1.wallet.get_input_aux_data(data_to_sign1, self.private_key1) + tokens_input.data = P2PKH.create_input_data( + public_key_bytes=public_bytes1, + signature=signature1, + sighash=sighash_range, + ) + + # Alice signs her genesis input using the same sighash, so the genesis input is not selected in the range. + public_bytes1, signature1 = self.manager1.wallet.get_input_aux_data(data_to_sign1, self.genesis_private_key) + genesis_input.data = P2PKH.create_input_data( + public_key_bytes=public_bytes1, + signature=signature1, + sighash=sighash_range, + ) + + # The inputs are invalid, since one of them doesn't select itself. + with pytest.raises(InvalidInputData) as e: + self.manager1.verification_service.verifiers.tx.verify_inputs(atomic_swap_tx) + + self.assertEqual(str(e.value), 'Input at index 1 must select itself when using a custom sighash.') + + with pytest.raises(InvalidInputData) as e: + self.manager1.verification_service.verify(atomic_swap_tx) + + self.assertEqual(str(e.value), 'Input at index 1 must select itself when using a custom sighash.') + + @patch('hathor.transaction.scripts.opcode.is_opcode_valid', lambda _: True) + def test_sighash_range_nonexistent_input(self) -> None: + # Create a new test token + token_creation_tx = create_tokens(self.manager1, self.address1_b58) + token_uid = token_creation_tx.tokens[0] + genesis_utxo = self.genesis_block.outputs[0] + parents = self.manager1.get_new_tx_parents() + + # Alice creates an input spending all created test tokens + tokens_input = TxInput(not_none(token_creation_tx.hash), 0, b'') + + # Alice creates an output sending half genesis HTR to herself + alice_output_script = P2PKH.create_output_script(self.address1) + htr_output = TxOutput(int(genesis_utxo.value / 2), alice_output_script) + + # Alice creates an atomic swap tx that's missing only Bob's input, with half genesis HTR + atomic_swap_tx = Transaction( + weight=1, + inputs=[tokens_input], + outputs=[htr_output], + parents=parents, + tokens=[token_uid], + storage=self.manager1.tx_storage, + timestamp=token_creation_tx.timestamp + 1 + ) + self.manager1.cpu_mining_service.resolve(atomic_swap_tx) + + # Alice signs her input using sighash range, instead of sighash_all. + sighash_range = SighashRange(input_start=0, input_end=1, output_start=0, output_end=1) + data_to_sign1 = atomic_swap_tx.get_custom_sighash_data(sighash_range) + assert self.manager1.wallet + public_bytes1, signature1 = self.manager1.wallet.get_input_aux_data(data_to_sign1, self.private_key1) + tokens_input.data = P2PKH.create_input_data( + public_key_bytes=public_bytes1, + signature=signature1, + sighash=SighashRange(input_start=0, input_end=2, output_start=0, output_end=1), + ) + + # The input is invalid, since it selects a nonexistent input + with pytest.raises(InvalidScriptError) as e: + self.manager1.verification_service.verifiers.tx.verify_inputs(atomic_swap_tx) + + assert str(e.value) == 'Custom sighash selected nonexistent input/output.' + + with pytest.raises(InvalidScriptError): + self.manager1.verification_service.verify(atomic_swap_tx) diff --git a/tests/tx/scripts/test_tx_sighash.py b/tests/tx/scripts/test_tx_sighash.py index 516038b9d..915165450 100644 --- a/tests/tx/scripts/test_tx_sighash.py +++ b/tests/tx/scripts/test_tx_sighash.py @@ -15,7 +15,7 @@ from unittest.mock import patch from hathor.transaction import Transaction, TxInput, TxOutput -from hathor.transaction.scripts.sighash import SighashBitmask +from hathor.transaction.scripts.sighash import SighashBitmask, SighashRange def test_get_sighash_bitmask() -> None: @@ -60,18 +60,52 @@ def test_get_sighash_bitmask() -> None: tx.get_custom_sighash_data(SighashBitmask(inputs=0b1101_1010, outputs=0b1110_0010)) mock.assert_called_once_with( - inputs=[ - inputs[1], - inputs[3], - inputs[4], - inputs[6], - inputs[7], - ], - outputs=[ - outputs[1], - outputs[5], - outputs[6], - outputs[7], - ] + inputs=[inputs[1], inputs[3], inputs[4], inputs[6], inputs[7]], + outputs=[outputs[1], outputs[5], outputs[6], outputs[7]] ) mock.reset_mock() + + tx.get_custom_sighash_data(SighashBitmask(inputs=0b1111_1111, outputs=0b1111_1111)) + mock.assert_called_once_with(inputs=inputs, outputs=outputs) + mock.reset_mock() + + +def test_get_sighash_range() -> None: + inputs = [ + TxInput(tx_id=b'tx1', index=0, data=b''), + TxInput(tx_id=b'tx2', index=1, data=b''), + TxInput(tx_id=b'tx3', index=1, data=b''), + TxInput(tx_id=b'tx4', index=1, data=b''), + TxInput(tx_id=b'tx5', index=1, data=b''), + TxInput(tx_id=b'tx6', index=1, data=b''), + TxInput(tx_id=b'tx7', index=1, data=b''), + TxInput(tx_id=b'tx8', index=1, data=b''), + ] + outputs = [ + TxOutput(value=11, script=b''), + TxOutput(value=22, script=b''), + TxOutput(value=33, script=b''), + TxOutput(value=44, script=b''), + TxOutput(value=55, script=b''), + TxOutput(value=66, script=b''), + TxOutput(value=77, script=b''), + TxOutput(value=88, script=b''), + ] + tx = Transaction(inputs=inputs, outputs=outputs) + + with patch.object(tx, '_get_sighash') as mock: + tx.get_custom_sighash_data(SighashRange(input_start=34, input_end=34, output_start=123, output_end=123)) + mock.assert_called_once_with(inputs=[], outputs=[]) + mock.reset_mock() + + tx.get_custom_sighash_data(SighashRange(input_start=0, input_end=1, output_start=0, output_end=0)) + mock.assert_called_once_with(inputs=inputs[0:1], outputs=[]) + mock.reset_mock() + + tx.get_custom_sighash_data(SighashRange(input_start=2, input_end=7, output_start=4, output_end=8)) + mock.assert_called_once_with(inputs=inputs[2:7], outputs=outputs[4:8]) + mock.reset_mock() + + tx.get_custom_sighash_data(SighashRange(input_start=0, input_end=8, output_start=0, output_end=8)) + mock.assert_called_once_with(inputs=inputs, outputs=outputs) + mock.reset_mock() diff --git a/tests/tx/test_scripts.py b/tests/tx/test_scripts.py index 76b96feda..a0ec23e48 100644 --- a/tests/tx/test_scripts.py +++ b/tests/tx/test_scripts.py @@ -64,9 +64,10 @@ op_pushdata, op_pushdata1, op_sighash_bitmask, + op_sighash_range, ) from hathor.transaction.scripts.script_context import ScriptContext -from hathor.transaction.scripts.sighash import SighashBitmask +from hathor.transaction.scripts.sighash import SighashBitmask, SighashRange from hathor.transaction.storage import TransactionMemoryStorage from hathor.wallet import HDWallet from tests import unittest @@ -1029,6 +1030,46 @@ def test_op_sighash_bitmask(self) -> None: ) ) + def test_op_sighash_range(self) -> None: + context = Mock(spec_set=ScriptContext) + with self.assertRaises(MissingStackItems): + context.stack = [] + op_sighash_range(context) + + with self.assertRaises(MissingStackItems): + context.stack = [b'', b'', b''] + op_sighash_range(context) + + with self.assertRaises(AssertionError): + context.stack = [10, 20, 30, 40] + op_sighash_range(context) + + context.stack = [bytes([1, 2]), bytes([3, 5]), bytes([5, 6]), bytes([7, 8])] + context.extras = Mock(spec_set=ScriptExtras) + + with self.assertRaises(CustomSighashModelInvalid): + op_sighash_range(context) + + context.stack = [bytes([10]), bytes([20]), bytes([30]), bytes([40])] + context.extras.input_index = 3 + + with self.assertRaises(InputNotSelectedError): + op_sighash_range(context) + + context.stack = [bytes([10]), bytes([20]), bytes([30]), bytes([40])] + context.extras.input_index = 15 + op_sighash_range(context) + + self.assertEqual(context.stack, []) + context.set_sighash.assert_called_once_with( + SighashRange( + input_start=10, + input_end=20, + output_start=30, + output_end=40, + ) + ) + def test_op_max_inputs_outputs(self) -> None: context = Mock(spec_set=ScriptContext) with self.assertRaises(MissingStackItems):