diff --git a/hathor/consensus/block_consensus.py b/hathor/consensus/block_consensus.py index 1ed212435..9862b1bd1 100644 --- a/hathor/consensus/block_consensus.py +++ b/hathor/consensus/block_consensus.py @@ -86,13 +86,9 @@ def execute_nano_contracts(self, block: Block) -> None: """Execute the method calls for transactions confirmed by this block handling reorgs.""" # If we reach this point, Nano Contracts must be enabled. assert self._settings.ENABLE_NANO_CONTRACTS + assert not block.is_genesis meta = block.get_metadata() - - if block.is_genesis: - self._nc_initialize_genesis(block) - return - if meta.voided_by: # If the block is voided, skip execution. return @@ -248,8 +244,8 @@ def _nc_execute_calls(self, block: Block, *, is_reorg: bool) -> None: tx=tx.hash.hex(), execution=tx_meta.nc_execution.value) match tx_meta.nc_execution: - case NCExecutionState.PENDING: - assert False # should never happen + case NCExecutionState.PENDING: # pragma: no cover + assert False, 'unexpected pending state' # should never happen case NCExecutionState.SUCCESS: assert tx_meta.voided_by is None case NCExecutionState.FAILURE: @@ -257,7 +253,7 @@ def _nc_execute_calls(self, block: Block, *, is_reorg: bool) -> None: case NCExecutionState.SKIPPED: assert tx_meta.voided_by assert NC_EXECUTION_FAIL_ID not in tx_meta.voided_by - case _: + case _: # pragma: no cover assert_never(tx_meta.nc_execution) def nc_update_metadata(self, tx: Transaction, runner: 'Runner') -> None: @@ -423,7 +419,7 @@ def update_voided_info(self, block: Block) -> None: self.update_voided_by_from_parents(block) else: - # Either eveyone has the same score or there is a winner. + # Either everyone has the same score or there is a winner. valid_heads = [] for head in heads: diff --git a/hathor/verification/transaction_verifier.py b/hathor/verification/transaction_verifier.py index 3a961a6ac..ab969ea96 100644 --- a/hathor/verification/transaction_verifier.py +++ b/hathor/verification/transaction_verifier.py @@ -117,8 +117,6 @@ def verify_sigops_input(self, tx: Transaction, enable_checkdatasig_count: bool = def verify_inputs(self, tx: Transaction, *, skip_script: bool = False) -> None: """Verify inputs signatures and ownership and all inputs actually exist""" - from hathor.transaction.storage.exceptions import TransactionDoesNotExist - spent_outputs: set[tuple[VertexId, int]] = set() for input_tx in tx.inputs: if len(input_tx.data) > self._settings.MAX_INPUT_DATA_SIZE: @@ -126,13 +124,8 @@ def verify_inputs(self, tx: Transaction, *, skip_script: bool = False) -> None: len(input_tx.data), self._settings.MAX_INPUT_DATA_SIZE )) - try: - spent_tx = tx.get_spent_tx(input_tx) - if input_tx.index >= len(spent_tx.outputs): - raise InexistentInput('Output spent by this input does not exist: {} index {}'.format( - input_tx.tx_id.hex(), input_tx.index)) - except TransactionDoesNotExist: - raise InexistentInput('Input tx does not exist: {}'.format(input_tx.tx_id.hex())) + spent_tx = tx.get_spent_tx(input_tx) + assert input_tx.index < len(spent_tx.outputs) if tx.timestamp <= spent_tx.timestamp: raise TimestampError('tx={} timestamp={}, spent_tx={} timestamp={}'.format( diff --git a/hathor/verification/verification_service.py b/hathor/verification/verification_service.py index 1207da71d..303715966 100644 --- a/hathor/verification/verification_service.py +++ b/hathor/verification/verification_service.py @@ -128,7 +128,7 @@ def verify_basic( assert type(vertex) is OnChainBlueprint assert self._settings.ENABLE_NANO_CONTRACTS self._verify_basic_on_chain_blueprint(vertex, params) - case _: + case _: # pragma: no cover assert_never(vertex.version) if vertex.is_nano_contract(): @@ -192,9 +192,8 @@ def verify(self, vertex: BaseTransaction, params: VerificationParams) -> None: self._verify_token_creation_tx(vertex, params) case TxVersion.ON_CHAIN_BLUEPRINT: assert type(vertex) is OnChainBlueprint - # TODO: on-chain blueprint verifications self._verify_tx(vertex, params) - case _: + case _: # pragma: no cover assert_never(vertex.version) if vertex.is_nano_contract(): @@ -297,7 +296,7 @@ def verify_without_storage(self, vertex: BaseTransaction, params: VerificationPa case TxVersion.ON_CHAIN_BLUEPRINT: assert type(vertex) is OnChainBlueprint self._verify_without_storage_on_chain_blueprint(vertex, params) - case _: + case _: # pragma: no cover assert_never(vertex.version) if vertex.is_nano_contract(): diff --git a/hathor/verification/vertex_verifier.py b/hathor/verification/vertex_verifier.py index 546158aee..8cd66c65b 100644 --- a/hathor/verification/vertex_verifier.py +++ b/hathor/verification/vertex_verifier.py @@ -216,9 +216,9 @@ def get_allowed_headers(self, vertex: BaseTransaction) -> set[type[VertexBaseHea case NanoContractsSetting.FEATURE_ACTIVATION: if self._feature_service.is_feature_active(vertex=vertex, feature=Feature.NANO_CONTRACTS): allowed_headers.add(NanoHeader) - case _ as unreachable: + case _ as unreachable: # pragma: no cover assert_never(unreachable) - case _: + case _: # pragma: no cover assert_never(vertex.version) return allowed_headers diff --git a/tests/nanocontracts/on_chain_blueprints/test_script_restrictions.py b/tests/nanocontracts/on_chain_blueprints/test_script_restrictions.py index 1c7e5daf7..1a5c1753f 100644 --- a/tests/nanocontracts/on_chain_blueprints/test_script_restrictions.py +++ b/tests/nanocontracts/on_chain_blueprints/test_script_restrictions.py @@ -51,11 +51,7 @@ def _create_on_chain_blueprint(self, nc_code: str) -> OnChainBlueprint: self._ocb_mine(blueprint) return blueprint - def _test_forbid_syntax( - self, - code: str, - syntax_errors: tuple[str, ...], - ) -> None: + def _test_forbid_syntax(self, code: str, *, syntax_errors: tuple[str, ...]) -> None: blueprint = self._create_on_chain_blueprint(code) with self.assertRaises(InvalidNewTransaction) as cm: self.manager.vertex_handler.on_new_relayed_vertex(blueprint) @@ -65,6 +61,10 @@ def _test_forbid_syntax( # The first error is always the one that makes the tx fail assert cm.exception.__cause__.__cause__.args[0] == syntax_errors[0] + self._test_expected_syntax_errors(code, syntax_errors=syntax_errors) + + def _test_expected_syntax_errors(self, code: str, *, syntax_errors: tuple[str, ...],) -> None: + blueprint = self._create_on_chain_blueprint(code) rules = self.manager.verification_service.verifiers.on_chain_blueprint.blueprint_code_rules() errors = [] for rule in rules: @@ -334,6 +334,44 @@ def test_forbid_match_dunder(self) -> None: ), ) + # These are allowed: + + self._test_expected_syntax_errors( + dedent(''' + match 123: + case int(): + pass + '''), + syntax_errors=(), + ) + + self._test_expected_syntax_errors( + dedent(''' + match 123: + case int(real=real): + pass + '''), + syntax_errors=(), + ) + + self._test_expected_syntax_errors( + dedent(''' + match 123: + case {}: + pass + '''), + syntax_errors=(), + ) + + self._test_expected_syntax_errors( + dedent(''' + match 123: + case {'real': 123}: + pass + '''), + syntax_errors=(), + ) + def test_forbid_async_fn(self) -> None: self._test_forbid_syntax( 'async def foo():\n ...', @@ -367,6 +405,15 @@ def test_forbid_await_syntax(self) -> None: ), ) + def test_invalid_python_syntax(self) -> None: + code = 'x ++= 1' + blueprint = self._create_on_chain_blueprint(code) + with self.assertRaises(InvalidNewTransaction) as cm: + self.manager.vertex_handler.on_new_relayed_vertex(blueprint) + assert isinstance(cm.exception.__cause__, OCBInvalidScript) + assert isinstance(cm.exception.__cause__.__cause__, SyntaxError) + assert cm.exception.args[0] == 'full validation failed: Could not correctly parse the script' + def test_blueprint_type_not_a_class(self) -> None: blueprint = self._create_on_chain_blueprint('''__blueprint__ = "Bet"''') with self.assertRaises(InvalidNewTransaction) as cm: diff --git a/tests/nanocontracts/test_consensus.py b/tests/nanocontracts/test_consensus.py index eb87a0ae1..8463cb792 100644 --- a/tests/nanocontracts/test_consensus.py +++ b/tests/nanocontracts/test_consensus.py @@ -1417,3 +1417,59 @@ def test_nc_consensus_voided_tx_propagation_to_blocks(self) -> None: self.assertIsNone(b33.get_metadata().voided_by) self.assertIsNone(b34.get_metadata().voided_by) self.assertIsNone(b50.get_metadata().voided_by) + + def test_reorg_nc_with_conflict(self) -> None: + dag_builder = TestDAGBuilder.from_manager(self.manager) + artifacts = dag_builder.build_from_str(f''' + blockchain genesis b[1..33] + blockchain b31 a[32..34] + b30 < dummy + + nc1.nc_id = "{self.myblueprint_id.hex()}" + nc1.nc_method = initialize("00") + + # nc2 will fail because nc1.counter is 0 + nc2.nc_id = nc1 + nc2.nc_method = fail_on_zero() + + # nc2 has a conflict with tx2 + tx1.out[0] <<< nc2 + tx1.out[0] <<< tx2 + + nc1 <-- b31 + nc2 <-- b32 + + # we want to include tx2, but it can't be confirmed by b32 + # otherwise that block would be confirming conflicts + tx2 < b32 + + # a34 will generate a reorg, reexecuting nc2. + b33 < a32 + nc2 <-- a33 + ''') + + b31, b32, b33 = artifacts.get_typed_vertices(['b31', 'b32', 'b33'], Block) + a32, a33, a34 = artifacts.get_typed_vertices(['a32', 'a33', 'a34'], Block) + nc2, tx2 = artifacts.get_typed_vertices(['nc2', 'tx2'], Transaction) + + artifacts.propagate_with(self.manager, up_to='b33') + + assert nc2.get_metadata().nc_execution is NCExecutionState.FAILURE + assert nc2.get_metadata().voided_by == {nc2.hash, NC_EXECUTION_FAIL_ID} + assert nc2.get_metadata().conflict_with == [tx2.hash] + assert nc2.get_metadata().first_block == b32.hash + + assert tx2.get_metadata().voided_by == {tx2.hash} + assert tx2.get_metadata().conflict_with == [nc2.hash] + assert tx2.get_metadata().first_block is None + + artifacts.propagate_with(self.manager) + + assert nc2.get_metadata().nc_execution is NCExecutionState.FAILURE + assert nc2.get_metadata().voided_by == {nc2.hash, NC_EXECUTION_FAIL_ID} + assert nc2.get_metadata().conflict_with == [tx2.hash] + assert nc2.get_metadata().first_block == a33.hash + + assert tx2.get_metadata().voided_by == {tx2.hash} + assert tx2.get_metadata().conflict_with == [nc2.hash] + assert tx2.get_metadata().first_block is None diff --git a/tests/nanocontracts/test_restricted_ocb.py b/tests/nanocontracts/test_restricted_ocb.py new file mode 100644 index 000000000..05d63a215 --- /dev/null +++ b/tests/nanocontracts/test_restricted_ocb.py @@ -0,0 +1,184 @@ +# Copyright 2025 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. + +import pytest +from cryptography.exceptions import InvalidSignature + +from hathor.exception import InvalidNewTransaction +from hathor.nanocontracts import OnChainBlueprint +from hathor.nanocontracts.exception import NCInvalidSignature +from hathor.nanocontracts.types import BlueprintId, VertexId +from hathor.transaction import Transaction +from hathor.util import not_none +from hathor.wallet import KeyPair +from tests import unittest +from tests.dag_builder.builder import TestDAGBuilder + + +class TestRestrictedOCB(unittest.TestCase): + def test_ocb_address_allowed(self) -> None: + manager = self.create_peer_from_builder(self.get_builder()) + dag_builder = TestDAGBuilder.from_manager(manager) + private_key = unittest.OCB_TEST_PRIVKEY.hex() + password = unittest.OCB_TEST_PASSWORD.hex() + + artifacts = dag_builder.build_from_str(f""" + blockchain genesis b[1..11] + b10 < dummy + + ocb.ocb_private_key = "{private_key}" + ocb.ocb_password = "{password}" + + nc.nc_id = ocb + nc.nc_method = initialize(0) + + ocb <-- b11 + b11 < nc + + ocb.ocb_code = test_blueprint1.py, TestBlueprint1 + """) + + artifacts.propagate_with(manager) + ocb = artifacts.get_typed_vertex('ocb', OnChainBlueprint) + nc = artifacts.get_typed_vertex('nc', Transaction) + + assert nc.is_nano_contract() + + assert ocb.get_blueprint_class().__name__ == 'TestBlueprint1' + assert nc.get_nano_header().nc_id == ocb.hash + blueprint_class = manager.tx_storage.get_blueprint_class(BlueprintId(VertexId(ocb.hash))) + assert blueprint_class.__name__ == 'TestBlueprint1' + + def test_ocb_address_not_allowed(self) -> None: + manager = self.create_peer_from_builder(self.get_builder()) + dag_builder = TestDAGBuilder.from_manager(manager) + password = b'abc' + key_pair = KeyPair.create(password=password) + + artifacts = dag_builder.build_from_str(f""" + blockchain genesis b[1..11] + b10 < dummy + + ocb.ocb_private_key = "{not_none(key_pair.private_key_bytes).hex()}" + ocb.ocb_password = "{password.hex()}" + + ocb.ocb_code = test_blueprint1.py, TestBlueprint1 + dummy < ocb + """) + + artifacts.propagate_with(manager, up_to='dummy') + + with pytest.raises(Exception) as e: + artifacts.propagate_with(manager, up_to='ocb') + + assert isinstance(e.value.__cause__, InvalidNewTransaction) + assert e.value.__cause__.args[0] == ( + f'full validation failed: nc_pubkey with address {key_pair.address} is not allowed' + ) + + def test_ocb_unrestricted(self) -> None: + builder = self.get_builder() \ + .set_settings(self._settings._replace(NC_ON_CHAIN_BLUEPRINT_RESTRICTED=False)) + manager = self.create_peer_from_builder(builder) + dag_builder = TestDAGBuilder.from_manager(manager) + password = b'abc' + key_pair = KeyPair.create(password=password) + + artifacts = dag_builder.build_from_str(f""" + blockchain genesis b[1..11] + b10 < dummy + + ocb.ocb_private_key = "{not_none(key_pair.private_key_bytes).hex()}" + ocb.ocb_password = "{password.hex()}" + + nc.nc_id = ocb + nc.nc_method = initialize(0) + + ocb <-- b11 + b11 < nc + + ocb.ocb_code = test_blueprint1.py, TestBlueprint1 + """) + + artifacts.propagate_with(manager) + ocb = artifacts.get_typed_vertex('ocb', OnChainBlueprint) + nc = artifacts.get_typed_vertex('nc', Transaction) + + assert nc.is_nano_contract() + + assert ocb.get_blueprint_class().__name__ == 'TestBlueprint1' + assert nc.get_nano_header().nc_id == ocb.hash + blueprint_class = manager.tx_storage.get_blueprint_class(BlueprintId(VertexId(ocb.hash))) + assert blueprint_class.__name__ == 'TestBlueprint1' + + def test_ocb_invalid_pubkey(self) -> None: + builder = self.get_builder() \ + .set_settings(self._settings._replace(NC_ON_CHAIN_BLUEPRINT_RESTRICTED=False)) + manager = self.create_peer_from_builder(builder) + dag_builder = TestDAGBuilder.from_manager(manager) + private_key = unittest.OCB_TEST_PRIVKEY.hex() + password = unittest.OCB_TEST_PASSWORD.hex() + + artifacts = dag_builder.build_from_str(f""" + blockchain genesis b[1..11] + b10 < dummy + + ocb.ocb_private_key = "{private_key}" + ocb.ocb_password = "{password}" + + ocb.ocb_code = test_blueprint1.py, TestBlueprint1 + dummy < ocb + """) + + artifacts.propagate_with(manager, up_to='dummy') + ocb = artifacts.get_typed_vertex('ocb', OnChainBlueprint) + + # Remove a byte to make the pubkey invalid + ocb.nc_pubkey = ocb.nc_pubkey[:-1] + + with pytest.raises(Exception) as e: + artifacts.propagate_with(manager, up_to='ocb') + + assert isinstance(e.value.__cause__, InvalidNewTransaction) + assert e.value.__cause__.args[0] == 'full validation failed: nc_pubkey is not a public key' + + def test_ocb_invalid_signature(self) -> None: + manager = self.create_peer_from_builder(self.get_builder()) + dag_builder = TestDAGBuilder.from_manager(manager) + private_key = unittest.OCB_TEST_PRIVKEY.hex() + password = unittest.OCB_TEST_PASSWORD.hex() + + artifacts = dag_builder.build_from_str(f""" + blockchain genesis b[1..11] + b10 < dummy + + ocb.ocb_private_key = "{private_key}" + ocb.ocb_password = "{password}" + + ocb.ocb_code = test_blueprint1.py, TestBlueprint1 + dummy < ocb + """) + + artifacts.propagate_with(manager, up_to='dummy') + ocb = artifacts.get_typed_vertex('ocb', OnChainBlueprint) + + # Remove a byte to make the signature invalid + ocb.nc_signature = ocb.nc_signature[:-1] + + with pytest.raises(Exception) as e: + artifacts.propagate_with(manager, up_to='ocb') + + assert isinstance(e.value.__cause__, InvalidNewTransaction) + assert isinstance(e.value.__cause__.__cause__, NCInvalidSignature) + assert isinstance(e.value.__cause__.__cause__.__cause__, InvalidSignature)