diff --git a/extras/custom_checks.sh b/extras/custom_checks.sh index 7fcf2e796..f47e33574 100644 --- a/extras/custom_checks.sh +++ b/extras/custom_checks.sh @@ -115,6 +115,48 @@ function check_do_not_import_twisted_reactor_directly() { return 0 } +function check_inherit_from_nc_user_exception() { + PATTERN='class .*\((NCUserException|NCFail)\)' + + if grep -RIE "$PATTERN" "hathor"; then + echo 'do not inherit from NCUserException or NCFail, this is only meant to be inherited by blueprint exceptions, created by users.' + echo 'we may allow blueprints to catch these exceptions at some point.' + return 1 + fi + return 0 +} + +function check_raise_nc_user_exception() { + PATTERN='raise (NCUserException|NCFail)' + + if grep -RIE "$PATTERN" "hathor" | grep -v '# skip-raise-nc-user-exception'; then + echo 'do not raise NCUserException or NCFail, this is only meant to be raised by blueprints, created by users.' + echo 'we may allow blueprints to catch these exceptions at some point.' + return 1 + fi + return 0 +} + +function check_inherit_from_nc_transaction_fail() { + PATTERN='class .*\(__NCTransactionFail__\)' + + if grep -RIE "$PATTERN" "${SOURCE_DIRS[@]}" | grep -v '# skip-inherit-from-nc-tx-fail'; then + echo 'do not inherit from __NCTransactionFail__, this is only meant to be inherited in the error_handling module.' + return 1 + fi + return 0 +} + +function check_raise_nc_unhandled_exception() { + PATTERN='raise (__NCUnhandledInternalException__|__NCUnhandledUserException__)' + + if grep -RIE "$PATTERN" "${SOURCE_DIRS[@]}" | grep -v '# skip-raise-nc-unhandled-exception'; then + echo 'do not raise __NCUnhandledInternalException__ or __NCUnhandledUserException__, this is only meant to be raised by the error_handling module.' + return 1 + fi + return 0 +} + # List of functions to be executed checks=( check_version_match @@ -123,6 +165,10 @@ checks=( check_do_not_import_tests_in_hathor check_do_not_import_from_hathor_in_entrypoints check_do_not_import_twisted_reactor_directly + check_inherit_from_nc_user_exception + check_raise_nc_user_exception + check_inherit_from_nc_transaction_fail + check_raise_nc_unhandled_exception ) # Initialize a variable to track if any check fails diff --git a/hathor/cli/events_simulator/scenario.py b/hathor/cli/events_simulator/scenario.py index 93d560dd7..73133754b 100644 --- a/hathor/cli/events_simulator/scenario.py +++ b/hathor/cli/events_simulator/scenario.py @@ -298,7 +298,7 @@ def initialize(self, ctx: Context) -> None: def fail(self, ctx: Context) -> None: # This will not be emitted because the tx will fail. self.syscall.emit_event(b'test event on fail') - raise NCFail + raise NCFail # skip-raise-nc-user-exception @public def call_another(self, ctx: Context, contract_id: ContractId) -> None: diff --git a/hathor/consensus/block_consensus.py b/hathor/consensus/block_consensus.py index 1ed212435..17683f54a 100644 --- a/hathor/consensus/block_consensus.py +++ b/hathor/consensus/block_consensus.py @@ -125,7 +125,8 @@ def execute_nano_contracts(self, block: Block) -> None: def _nc_execute_calls(self, block: Block, *, is_reorg: bool) -> None: """Internal method to execute the method calls for transactions confirmed by this block. """ - from hathor.nanocontracts import NC_EXECUTION_FAIL_ID, NCFail + from hathor.nanocontracts import NC_EXECUTION_FAIL_ID + from hathor.nanocontracts.error_handling import __NCTransactionFail__ from hathor.nanocontracts.types import Address assert self._settings.ENABLE_NANO_CONTRACTS @@ -194,10 +195,15 @@ def _nc_execute_calls(self, block: Block, *, is_reorg: bool) -> None: continue runner = self._runner_factory.create(block_storage=block_storage, seed=seed_hasher.digest()) - exception_and_tb: tuple[NCFail, str] | None = None + exception_and_tb: tuple[__NCTransactionFail__, str] | None = None + try: runner.execute_from_tx(tx) - except NCFail as e: + except __NCTransactionFail__ as e: + # These are the exception types that will make an NC transaction fail. + # Any other exception will bubble up and crash the full node. + # See the `error_handling` module for more information. + kwargs: dict[str, Any] = {} if tx.name: kwargs['__name'] = tx.name diff --git a/hathor/manager.py b/hathor/manager.py index 0712053b0..f5a668de5 100644 --- a/hathor/manager.py +++ b/hathor/manager.py @@ -44,7 +44,6 @@ from hathor.feature_activation.bit_signaling_service import BitSignalingService from hathor.mining import BlockTemplate, BlockTemplates from hathor.mining.cpu_mining_service import CpuMiningService -from hathor.nanocontracts.exception import NanoContractDoesNotExist from hathor.nanocontracts.runner import Runner from hathor.nanocontracts.runner.runner import RunnerFactory from hathor.nanocontracts.storage import NCBlockStorage, NCContractStorage @@ -406,11 +405,7 @@ def get_nc_storage(self, block: Block, nc_id: VertexId) -> NCContractStorage: """Return a contract storage with the contract state at a given block.""" from hathor.nanocontracts.types import ContractId, VertexId as NCVertexId block_storage = self.get_nc_block_storage(block) - try: - contract_storage = block_storage.get_contract_storage(ContractId(NCVertexId(nc_id))) - except KeyError: - raise NanoContractDoesNotExist(nc_id.hex()) - return contract_storage + return block_storage.get_contract_storage(ContractId(NCVertexId(nc_id))) def get_best_block_nc_storage(self, nc_id: VertexId) -> NCContractStorage: """Return a contract storage with the contract state at the best block.""" diff --git a/hathor/nanocontracts/blueprint.py b/hathor/nanocontracts/blueprint.py index c164562af..8c508665b 100644 --- a/hathor/nanocontracts/blueprint.py +++ b/hathor/nanocontracts/blueprint.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any, final from hathor.nanocontracts.blueprint_env import BlueprintEnvironment +from hathor.nanocontracts.error_handling import internal_code_called_from_user_code from hathor.nanocontracts.exception import BlueprintSyntaxError from hathor.nanocontracts.nc_types.utils import pretty_type from hathor.nanocontracts.types import NC_FALLBACK_METHOD, NC_INITIALIZE_METHOD, NC_METHOD_TYPE_ATTR, NCMethodType @@ -131,14 +132,16 @@ class MyBlueprint(Blueprint): def __init__(self, env: BlueprintEnvironment) -> None: self.__env = env - @final @property + @internal_code_called_from_user_code + @final def syscall(self) -> BlueprintEnvironment: """Return the syscall provider for the current contract.""" return self.__env - @final @property + @internal_code_called_from_user_code + @final def log(self) -> NCLogger: """Return the logger for the current contract.""" return self.syscall.__log__ diff --git a/hathor/nanocontracts/blueprint_env.py b/hathor/nanocontracts/blueprint_env.py index 6bc093a61..ca2430700 100644 --- a/hathor/nanocontracts/blueprint_env.py +++ b/hathor/nanocontracts/blueprint_env.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any, Optional, Sequence, final +from hathor.nanocontracts.error_handling import internal_code_called_from_user_code from hathor.nanocontracts.storage import NCContractStorage from hathor.nanocontracts.types import Amount, BlueprintId, ContractId, NCAction, TokenUid @@ -45,17 +46,20 @@ def __init__( # XXX: we could replace dict|None with a Cache that can be disabled, cleared, limited, etc self.__cache__: dict[str, Any] | None = None if disable_cache else {} - @final @property + @internal_code_called_from_user_code + @final def rng(self) -> NanoRNG: """Return an RNG for the current contract.""" return self.__runner.syscall_get_rng() + @internal_code_called_from_user_code @final def get_contract_id(self) -> ContractId: """Return the current contract id.""" return self.__runner.get_current_contract_id() + @internal_code_called_from_user_code @final def get_blueprint_id(self, contract_id: Optional[ContractId] = None) -> BlueprintId: """Return the blueprint id of a nano contract. By default, it returns for the current contract.""" @@ -63,6 +67,7 @@ def get_blueprint_id(self, contract_id: Optional[ContractId] = None) -> Blueprin contract_id = self.get_contract_id() return self.__runner.get_blueprint_id(contract_id) + @internal_code_called_from_user_code def get_balance_before_current_call( self, token_uid: Optional[TokenUid] = None, @@ -78,6 +83,7 @@ def get_balance_before_current_call( balance = self.__runner.get_balance_before_current_call(contract_id, token_uid) return Amount(balance.value) + @internal_code_called_from_user_code def get_current_balance( self, token_uid: Optional[TokenUid] = None, @@ -93,6 +99,7 @@ def get_current_balance( balance = self.__runner.get_current_balance(contract_id, token_uid) return Amount(balance.value) + @internal_code_called_from_user_code @final def can_mint_before_current_call( self, @@ -110,6 +117,7 @@ def can_mint_before_current_call( balance = self.__runner.get_balance_before_current_call(contract_id, token_uid) return balance.can_mint + @internal_code_called_from_user_code @final def can_mint( self, @@ -127,6 +135,7 @@ def can_mint( balance = self.__runner.get_current_balance(contract_id, token_uid) return balance.can_mint + @internal_code_called_from_user_code @final def can_melt_before_current_call( self, @@ -144,6 +153,7 @@ def can_melt_before_current_call( balance = self.__runner.get_balance_before_current_call(contract_id, token_uid) return balance.can_melt + @internal_code_called_from_user_code @final def can_melt( self, @@ -161,6 +171,7 @@ def can_melt( balance = self.__runner.get_current_balance(contract_id, token_uid) return balance.can_melt + @internal_code_called_from_user_code @final def call_public_method( self, @@ -173,6 +184,7 @@ def call_public_method( """Call a public method of another contract.""" return self.__runner.syscall_call_another_contract_public_method(nc_id, method_name, actions, args, kwargs) + @internal_code_called_from_user_code @final def proxy_call_public_method( self, @@ -185,6 +197,7 @@ def proxy_call_public_method( """Execute a proxy call to a public method of another blueprint.""" return self.__runner.syscall_proxy_call_public_method(blueprint_id, method_name, actions, args, kwargs) + @internal_code_called_from_user_code @final def proxy_call_public_method_nc_args( self, @@ -196,26 +209,31 @@ def proxy_call_public_method_nc_args( """Execute a proxy call to a public method of another blueprint.""" return self.__runner.syscall_proxy_call_public_method_nc_args(blueprint_id, method_name, actions, nc_args) + @internal_code_called_from_user_code @final def call_view_method(self, nc_id: ContractId, method_name: str, *args: Any, **kwargs: Any) -> Any: """Call a view method of another contract.""" return self.__runner.syscall_call_another_contract_view_method(nc_id, method_name, args, kwargs) + @internal_code_called_from_user_code @final def revoke_authorities(self, token_uid: TokenUid, *, revoke_mint: bool, revoke_melt: bool) -> None: """Revoke authorities from this nano contract.""" self.__runner.syscall_revoke_authorities(token_uid=token_uid, revoke_mint=revoke_mint, revoke_melt=revoke_melt) + @internal_code_called_from_user_code @final def mint_tokens(self, token_uid: TokenUid, amount: int) -> None: """Mint tokens and add them to the balance of this nano contract.""" self.__runner.syscall_mint_tokens(token_uid=token_uid, amount=amount) + @internal_code_called_from_user_code @final def melt_tokens(self, token_uid: TokenUid, amount: int) -> None: """Melt tokens by removing them from the balance of this nano contract.""" self.__runner.syscall_melt_tokens(token_uid=token_uid, amount=amount) + @internal_code_called_from_user_code @final def create_contract( self, @@ -228,11 +246,13 @@ def create_contract( """Create a new contract.""" return self.__runner.syscall_create_another_contract(blueprint_id, salt, actions, args, kwargs) + @internal_code_called_from_user_code @final def emit_event(self, data: bytes) -> None: """Emit a custom event from a Nano Contract.""" self.__runner.syscall_emit_event(data) + @internal_code_called_from_user_code @final def create_token( self, @@ -251,6 +271,7 @@ def create_token( melt_authority, ) + @internal_code_called_from_user_code @final def change_blueprint(self, blueprint_id: BlueprintId) -> None: """Change the blueprint of this contract.""" diff --git a/hathor/nanocontracts/context.py b/hathor/nanocontracts/context.py index 2002472fa..1ead2c312 100644 --- a/hathor/nanocontracts/context.py +++ b/hathor/nanocontracts/context.py @@ -20,7 +20,8 @@ from typing import TYPE_CHECKING, Any, Sequence, final from hathor.crypto.util import get_address_b58_from_bytes -from hathor.nanocontracts.exception import NCFail, NCInvalidContext +from hathor.nanocontracts.error_handling import NCInternalException +from hathor.nanocontracts.exception import NCInvalidContext from hathor.nanocontracts.types import Address, ContractId, NCAction, TokenUid from hathor.nanocontracts.vertex_data import VertexData from hathor.transaction.exceptions import TxValidationError @@ -108,7 +109,7 @@ def get_single_action(self, token_uid: TokenUid) -> NCAction: """Get exactly one action for the provided token, and fail otherwise.""" actions = self.actions.get(token_uid) if actions is None or len(actions) != 1: - raise NCFail(f'expected exactly 1 action for token {token_uid.hex()}') + raise NCInternalException(f'expected exactly 1 action for token {token_uid.hex()}') return actions[0] def copy(self) -> Context: diff --git a/hathor/nanocontracts/error_handling.py b/hathor/nanocontracts/error_handling.py new file mode 100644 index 000000000..99cc0cc9c --- /dev/null +++ b/hathor/nanocontracts/error_handling.py @@ -0,0 +1,136 @@ +# 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 functools +from typing import Callable, ParamSpec, TypeVar, final + +""" +This module defines error handling for Nano Contract execution. + +It contains four specific exception types that inherit from `BaseException`, NOT from `Exception`. +This allows them to pass through `except Exception` blocks which is essential in user code boundaries. + +It also contains two decorators, `@internal_code_called_from_user_code` and `@user_code_called_from_internal_code` +that MUST be used in user code boundaries. +They are responsible for handling and wrapping the exceptions accordingly. + +The four exception types are: + +1. NCInternalException +2. NCUserException +3. __NCUnhandledInternalException__ +4. __NCUnhandledUserException__ + +When raised, __NCUnhandledInternalException__ will crash the full node. All the others will just fail +a Nano Contract transaction. They are described in more detail below. + +Underscores are used in exception names to signal they require special care. +""" + +T = TypeVar('T') +P = ParamSpec('P') + + +class __NCTransactionFail__(BaseException): + """A super type for all exceptions that fail an NC transaction execution.""" + + +class NCInternalException(__NCTransactionFail__): # skip-inherit-from-nc-tx-fail + """ + This exception represents known internal errors that can happen during contract execution, + such as raising when a contract balance is insufficient to fulfill an action. + + It may be raised directly or subclassed by Hathor internal code. When raised, it will fail the NC transaction. + It cannot be raised or subclassed by user code in blueprints. + """ + + +class NCUserException(__NCTransactionFail__): # skip-inherit-from-nc-tx-fail + """ + This exception represents known user errors that can happen during contract execution, + such as raising when the business rule of a blueprint is violated. + + It may be raised directly or subclassed by user code in blueprints. When raised, it will fail the NC transaction. + It cannot be raised or subclassed by Hathor internal code. + """ + + +@final +class __NCUnhandledInternalException__(BaseException): + """ + This exception represents unhandled internal errors that can happen during contract execution, + such as an AssertionError. + + It cannot be raised directly or subclassed by any code. When raised, + it will reach the consensus entrypoint and crash the full node. + """ + + +@final +class __NCUnhandledUserException__(__NCTransactionFail__): # skip-inherit-from-nc-tx-fail + """ + This exception represents unhandled user errors that can happen during contract execution, + such as when a blueprint code divides by zero, raising ZeroDivisionError. + + It cannot be raised directly or subclassed by any code. When raised, it will fail the NC transaction. + """ + + +def internal_code_called_from_user_code(f: Callable[P, T]) -> Callable[P, T]: + """ + Mark a function/method as internal Hathor code that may be called from user code in blueprints, such as syscalls. + It wraps exceptions accordingly. + """ + @functools.wraps(f) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + try: + return f(*args, **kwargs) + except Exception as e: + # Unhandled exceptions are considered bugs in our code, such as AssertionError. + # They are bubbled up wrapped in an __NCUnhandledInternalException__ which does not inherit from Exception + # and will eventually crash the full node when it reaches the consensus entrypoint. + raise __NCUnhandledInternalException__ from e # skip-raise-nc-unhandled-exception + except BaseException: + # All other exceptions are bubbled up untouched. + # This includes the four exception classes defined in this file. + # If they reach another user code boundary, they will continue to bubble up. + # If they are unhandled, they will reach the consensus entrypoint crashing the full node. + raise + + return wrapper + + +def user_code_called_from_internal_code(f: Callable[P, T]) -> Callable[P, T]: + """ + Mark a function/method as user code from blueprints that is called from internal Hathor code, + such as when we call `exec()`. + It wraps exceptions accordingly. + """ + @functools.wraps(f) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + try: + return f(*args, **kwargs) + except Exception as e: + # Unhandled exceptions may be bugs in user code, such as ZeroDivisionError. + # They are bubbled up wrapped in an __NCUnhandledUserException__ which does not inherit from Exception + # and will eventually fail the NC transaction when it reaches the consensus entrypoint. + raise __NCUnhandledUserException__ from e # skip-raise-nc-unhandled-exception + except BaseException: + # All other exceptions are bubbled up untouched. + # This includes the four exception classes defined in this file. + # If they reach another user code boundary, they will continue to bubble up. + # If they are unhandled, they will reach the consensus entrypoint crashing the full node. + raise + + return wrapper diff --git a/hathor/nanocontracts/exception.py b/hathor/nanocontracts/exception.py index ac23763a4..c48555107 100644 --- a/hathor/nanocontracts/exception.py +++ b/hathor/nanocontracts/exception.py @@ -12,137 +12,136 @@ # See the License for the specific language governing permissions and # limitations under the License. -from hathor.exception import HathorError -from hathor.transaction.exceptions import TxValidationError +from typing import TypeAlias +from hathor.nanocontracts.error_handling import NCInternalException, NCUserException -class BlueprintSyntaxError(SyntaxError): - """Raised when a blueprint contains invalid syntax.""" - pass +""" +This module contains exceptions related to Nano Contracts. +IMPORTANT: Exception handling during contract execution is critical. It's essential to choose the right type when +subclassing an NC-related exception. Read the error_handling module for more information. -class NCError(HathorError): - """Base exception for nano contract's exceptions.""" - pass +Exceptions that are raised during Nano Contract execution will either: +- Fail the transaction and become part of the blockchain state when they inherit from NCInternalException. +- Be considered a bug and crash the full node when they do not inherit from NCInternalException. -class NCTxValidationError(TxValidationError): - pass +When they are raised outside contract execution, such as during verification, they must either inherit from +HathorError or be an __NCTransactionFail__ to make sure the transaction fails verification. +""" -class NCInvalidSignature(NCTxValidationError): +class BlueprintSyntaxError(NCInternalException): + """Raised when a blueprint contains invalid syntax.""" pass -class NCInvalidPubKey(NCTxValidationError): +class NCInvalidSignature(NCInternalException): pass -class NCFail(NCError): - """Raised by Blueprint's methods to fail execution.""" - - -class NanoContractDoesNotExist(NCFail): +class NCInvalidPubKey(NCInternalException): pass -class BlueprintDoesNotExist(NCFail): +class NanoContractDoesNotExist(NCInternalException): pass -class NCSerializationError(NCFail): +class BlueprintDoesNotExist(NCInternalException): pass -class NCSerializationArgTooLong(NCSerializationError): +class NCSerializationError(NCInternalException): pass -class NCSerializationTypeError(NCSerializationError): +class NCSerializationArgTooLong(NCSerializationError): pass -class NCViewMethodError(NCFail): +class NCViewMethodError(NCInternalException): """Raised when a view method changes the state of the contract.""" pass -class NCMethodNotFound(NCFail): +class NCMethodNotFound(NCInternalException): """Raised when a method is not found in a nano contract.""" pass -class NCInsufficientFunds(NCFail): +class NCInsufficientFunds(NCInternalException): """Raised when there is not enough funds to withdrawal from a nano contract.""" pass -class NCAttributeError(NCFail): +class NCAttributeError(NCInternalException): pass -class NCInvalidContext(NCFail): +class NCInvalidContext(NCInternalException): """Raised when trying to run a method with an invalid context.""" pass -class NCRecursionError(NCFail): +class NCRecursionError(NCInternalException): """Raised when recursion gets too deep.""" -class NCNumberOfCallsExceeded(NCFail): +class NCNumberOfCallsExceeded(NCInternalException): """Raised when the total number of calls have been exceeded.""" -class NCInvalidContractId(NCFail): +class NCInvalidContractId(NCInternalException): """Raised when a contract call is invalid.""" -class NCInvalidMethodCall(NCFail): +class NCInvalidMethodCall(NCInternalException): """Raised when a contract calls another contract's invalid method.""" -class NCInvalidInitializeMethodCall(NCFail): +class NCInvalidInitializeMethodCall(NCInternalException): """Raised when a contract calls another contract's initialize method.""" -class NCInvalidPublicMethodCallFromView(NCFail): +class NCInvalidPublicMethodCallFromView(NCInternalException): """Raised when a contract calls another contract's initialize method.""" -class NCAlreadyInitializedContractError(NCFail): +class NCAlreadyInitializedContractError(NCInternalException): """Raised when one tries to initialize a contract that has already been initialized.""" -class NCUninitializedContractError(NCFail): +class NCUninitializedContractError(NCInternalException): """Raised when a contract calls a method from an uninitialized contract.""" -class NCInvalidAction(NCFail): +class NCInvalidAction(NCInternalException): """Raised when an action is invalid.""" pass -class NCInvalidSyscall(NCFail): +class NCInvalidSyscall(NCInternalException): """Raised when a syscall is invalid.""" pass -class NCTokenAlreadyExists(NCFail): +class NCTokenAlreadyExists(NCInternalException): """Raised when one tries to create a duplicated token.""" -class NCForbiddenAction(NCFail): +class NCForbiddenAction(NCInternalException): """Raised when an action is forbidden on a method.""" pass -class UnknownFieldType(NCError): +class UnknownFieldType(NCInternalException): """Raised when there is no field available for a given type.""" pass -class NCContractCreationNotFound(NCError): +class NCContractCreationNotFound(NCInternalException): """Raised when a nano contract creation transaction is not found. This error might also happen when the transaction is at the mempool or when it fails execution.""" @@ -163,38 +162,44 @@ class NCContractCreationVoided(NCContractCreationNotFound): pass -class OCBInvalidScript(NCError): +class OCBInvalidScript(NCInternalException): """Raised when an On-Chain Blueprint script does not pass our script restrictions check. """ pass -class OCBInvalidBlueprintVertexType(NCError): +class OCBInvalidBlueprintVertexType(NCInternalException): """Raised when a vertex that is not an OnChainBlueprint is used as a blueprint-id. """ pass -class OCBBlueprintNotConfirmed(NCError): +class OCBBlueprintNotConfirmed(NCInternalException): """Raised when trying to use an OnChainBlueprint that is not confirmed by a block in the current best chain. """ -class OCBPubKeyNotAllowed(NCError): +class OCBPubKeyNotAllowed(NCInternalException): """Raised when an OnChainBlueprint transaction uses a pubkey that is not explicitly allowed in the settings. """ -class OCBOutOfFuelDuringLoading(NCError): +class OCBOutOfFuelDuringLoading(NCInternalException): """Raised when loading an On-chain Blueprint and the execution exceeds the fuel limit. """ -class OCBOutOfMemoryDuringLoading(NCError): +class OCBOutOfMemoryDuringLoading(NCInternalException): """Raised when loading an On-chain Blueprint and the execution exceeds the memory limit. """ -class NCDisabledBuiltinError(NCError): +class NCDisabledBuiltinError(NCInternalException): """Raised when a disabled builtin is used during creation or execution of a nanocontract. """ + + +""" +Just a type alias for compatibility. Represents an exception that may only be raised from user code in blueprints. +""" +NCFail: TypeAlias = NCUserException diff --git a/hathor/nanocontracts/metered_exec.py b/hathor/nanocontracts/metered_exec.py index 01b73a65d..488d59f5a 100644 --- a/hathor/nanocontracts/metered_exec.py +++ b/hathor/nanocontracts/metered_exec.py @@ -19,6 +19,7 @@ from structlog import get_logger from hathor.nanocontracts.custom_builtins import EXEC_BUILTINS +from hathor.nanocontracts.error_handling import NCInternalException, user_code_called_from_internal_code from hathor.nanocontracts.on_chain_blueprint import PYTHON_CODE_COMPAT_VERSION logger = get_logger() @@ -34,11 +35,11 @@ FUEL_COST_MAP = [1] * 256 -class OutOfFuelError(RuntimeError): +class OutOfFuelError(NCInternalException): pass -class OutOfMemoryError(MemoryError): +class OutOfMemoryError(NCInternalException): pass @@ -63,7 +64,7 @@ def exec(self, source: str, /) -> dict[str, Any]: '__builtins__': EXEC_BUILTINS, } # XXX: calling compile now makes the exec step consume less fuel - code = compile( + code = user_code_called_from_internal_code(compile)( source=source, filename='', mode='exec', @@ -72,8 +73,10 @@ def exec(self, source: str, /) -> dict[str, Any]: optimize=0, _feature_version=PYTHON_CODE_COMPAT_VERSION[1], ) + # XXX: SECURITY: `code` and `env` need the proper restrictions by this point - exec(code, env) + user_code_called_from_internal_code(exec)(code, env) + del env['__builtins__'] return env @@ -97,5 +100,7 @@ def call(self, func: Callable[_P, _T], /, *args: _P.args, **kwargs: _P.kwargs) - optimize=0, _feature_version=PYTHON_CODE_COMPAT_VERSION[1], ) - exec(code, env) + + user_code_called_from_internal_code(exec)(code, env) + return cast(_T, env['__result__']) diff --git a/hathor/nanocontracts/method.py b/hathor/nanocontracts/method.py index 94dd6c0d2..242716d89 100644 --- a/hathor/nanocontracts/method.py +++ b/hathor/nanocontracts/method.py @@ -22,7 +22,7 @@ from typing_extensions import Self, assert_never, override from hathor.nanocontracts import Context -from hathor.nanocontracts.exception import NCFail, NCSerializationArgTooLong, NCSerializationError +from hathor.nanocontracts.exception import NCSerializationArgTooLong, NCSerializationError from hathor.nanocontracts.nc_types import ( NCType, VarUint32NCType, @@ -38,17 +38,25 @@ MAX_BYTES_SERIALIZED_ARG: int = 1000 +""" +The whole serialization system makes extensive use of these three exception types which may be +triggered by user input. Any other exception type will not be handled and therefore will +be considered a bug, crashing the full node. Ideally, we should refactor all serialization +to use specific internal exception types. For now, we simply handle them here. +""" +POSSIBLE_SERIALIZATION_EXCEPTIONS = (SerializationError, ValueError, TypeError) + def _deserialize_map_exception(nc_type: NCType[T], data: bytes) -> T: """ Internal handy method to deserialize `bytes` to `T` while mapping the exceptions.""" deserializer = Deserializer.build_bytes_deserializer(data) try: value = nc_type.deserialize(deserializer) + deserializer.finalize() except MaxBytesExceededError as e: raise NCSerializationArgTooLong from e - except SerializationError as e: + except POSSIBLE_SERIALIZATION_EXCEPTIONS as e: raise NCSerializationError from e - deserializer.finalize() return value @@ -57,11 +65,11 @@ def _serialize_map_exception(nc_type: NCType[T], value: T) -> bytes: serializer = Serializer.build_bytes_serializer() try: nc_type.serialize(serializer, value) + return bytes(serializer.finalize()) except MaxBytesExceededError as e: raise NCSerializationArgTooLong from e - except SerializationError as e: + except POSSIBLE_SERIALIZATION_EXCEPTIONS as e: raise NCSerializationError from e - return bytes(serializer.finalize()) class _ArgsNCType(NCType): @@ -150,7 +158,7 @@ def serialize_args_bytes(self, args: tuple[Any, ...] | list[Any]) -> bytes: return _serialize_map_exception(self.args, args) def deserialize_args_bytes(self, data: bytes) -> tuple[Any, ...]: - """ Shortcut to deserialize args directly from bytes instead of using a deserilizer. + """ Shortcut to deserialize args directly from bytes instead of using a deserializer. """ return _deserialize_map_exception(self.args, data) @@ -251,6 +259,14 @@ def from_callable(cls, method: Callable) -> Self: make_nc_type_for_return_type(method_signature.return_annotation), ) + @classmethod + def from_callable_handled(cls, method: Callable) -> Self: + """Same as `from_callable` but mapping serialization exceptions to NC exceptions.""" + try: + return cls.from_callable(method) + except POSSIBLE_SERIALIZATION_EXCEPTIONS as e: + raise NCSerializationError from e + def serialize_args_bytes(self, args: tuple[Any, ...] | list[Any]) -> bytes: """ Shortcut to serialize args directly to a bytes instead of using a serializer. """ @@ -259,10 +275,7 @@ def serialize_args_bytes(self, args: tuple[Any, ...] | list[Any]) -> bytes: def deserialize_args_bytes(self, data: bytes) -> tuple[Any, ...]: """ Shortcut to deserialize args directly from bytes instead of using a deserializer. """ - try: - return _deserialize_map_exception(self.args, data) - except Exception as e: - raise NCFail from e + return _deserialize_map_exception(self.args, data) def serialize_return_bytes(self, return_value: Any) -> bytes: """ Shortcut to serialize a return value directly to a bytes instead of using a serializer. diff --git a/hathor/nanocontracts/nc_exec_logs.py b/hathor/nanocontracts/nc_exec_logs.py index e88f8387f..d048c3c32 100644 --- a/hathor/nanocontracts/nc_exec_logs.py +++ b/hathor/nanocontracts/nc_exec_logs.py @@ -25,7 +25,12 @@ from pydantic import Field, validator from typing_extensions import override -from hathor.nanocontracts import NCFail +from hathor.nanocontracts.error_handling import ( + NCInternalException, + __NCTransactionFail__, + __NCUnhandledUserException__, + internal_code_called_from_user_code, +) from hathor.nanocontracts.runner import CallInfo, CallRecord, CallType from hathor.nanocontracts.types import ContractId from hathor.reactor import ReactorProtocol @@ -217,18 +222,22 @@ class NCLogger: __entries__: list[NCCallBeginEntry | NCLogEntry | NCCallEndEntry] = field(default_factory=list) __events__: list[NCEvent] = field(default_factory=list) + @internal_code_called_from_user_code def debug(self, message: str, **kwargs: Any) -> None: """Create a new DEBUG log entry.""" self.__log__(NCLogLevel.DEBUG, message, **kwargs) + @internal_code_called_from_user_code def info(self, message: str, **kwargs: Any) -> None: """Create a new INFO log entry.""" self.__log__(NCLogLevel.INFO, message, **kwargs) + @internal_code_called_from_user_code def warn(self, message: str, **kwargs: Any) -> None: """Create a new WARN log entry.""" self.__log__(NCLogLevel.WARN, message, **kwargs) + @internal_code_called_from_user_code def error(self, message: str, **kwargs: Any) -> None: """Create a new ERROR log entry.""" self.__log__(NCLogLevel.ERROR, message, **kwargs) @@ -236,7 +245,7 @@ def error(self, message: str, **kwargs: Any) -> None: def __emit_event__(self, data: bytes) -> None: """Emit a custom event from a Nano Contract.""" if len(data) > MAX_EVENT_SIZE: - raise ValueError(f'event data cannot be larger than {MAX_EVENT_SIZE} bytes, is {len(data)}') + raise NCInternalException(f'event data cannot be larger than {MAX_EVENT_SIZE} bytes, is {len(data)}') self.__events__.append(NCEvent(nc_id=self.__nc_id__, data=data)) def __log__(self, level: NCLogLevel, message: str, **kwargs: Any) -> None: @@ -268,7 +277,12 @@ def __init__(self, *, settings: HathorSettings, path: str, config: NCLogConfig) self._path = Path(path).joinpath(NC_EXEC_LOGS_DIR) self._config = config - def save_logs(self, tx: Transaction, call_info: CallInfo, exception_and_tb: tuple[NCFail, str] | None) -> None: + def save_logs( + self, + tx: Transaction, + call_info: CallInfo, + exception_and_tb: tuple[__NCTransactionFail__, str] | None, + ) -> None: """Persist new NC execution logs.""" assert tx.is_nano_contract() meta = tx.get_metadata() @@ -290,9 +304,8 @@ def save_logs(self, tx: Transaction, call_info: CallInfo, exception_and_tb: tupl if exception is None: # don't save when there's no exception return - assert isinstance(exception, NCFail) - if not exception.__cause__ or isinstance(exception.__cause__, NCFail): - # don't save when it's a simple NCFail or caused by a NCFail + if not isinstance(exception, __NCUnhandledUserException__): + # don't save when it's not an __NCUnhandledUserException__ return case _: assert_never(self._config) diff --git a/hathor/nanocontracts/resources/state.py b/hathor/nanocontracts/resources/state.py index d44362368..28fdc0fe6 100644 --- a/hathor/nanocontracts/resources/state.py +++ b/hathor/nanocontracts/resources/state.py @@ -22,6 +22,7 @@ from hathor.cli.openapi_files.register import register_resource from hathor.crypto.util import decode_address from hathor.nanocontracts.api_arguments_parser import parse_nc_method_call +from hathor.nanocontracts.error_handling import NCInternalException from hathor.nanocontracts.exception import NanoContractDoesNotExist from hathor.nanocontracts.nc_types import make_nc_type_for_field_type from hathor.nanocontracts.types import ContractId, VertexId @@ -217,7 +218,7 @@ def render_GET(self, request: 'Request') -> bytes: value = runner.call_view_method(nc_id_bytes, method_name, *method_args) if type(value) is bytes: value = value.hex() - except Exception as e: + except (Exception, NCInternalException) as e: calls[call_info] = NCValueErrorResponse(errmsg=repr(e)) else: calls[call_info] = NCValueSuccessResponse(value=value) diff --git a/hathor/nanocontracts/rng.py b/hathor/nanocontracts/rng.py index d4f3ffb2f..837e48a9f 100644 --- a/hathor/nanocontracts/rng.py +++ b/hathor/nanocontracts/rng.py @@ -17,6 +17,7 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms from hathor.difficulty import Hash +from hathor.nanocontracts.error_handling import internal_code_called_from_user_code T = TypeVar('T') @@ -37,6 +38,7 @@ def __init__(self, seed: bytes) -> None: self.__encryptor = cipher.encryptor() @property + @internal_code_called_from_user_code def seed(self) -> Hash: """Return the seed used to create the RNG.""" return self.__seed diff --git a/hathor/nanocontracts/runner/runner.py b/hathor/nanocontracts/runner/runner.py index c8124cb8e..68ab6b25c 100644 --- a/hathor/nanocontracts/runner/runner.py +++ b/hathor/nanocontracts/runner/runner.py @@ -24,9 +24,9 @@ from hathor.nanocontracts.blueprint import Blueprint from hathor.nanocontracts.blueprint_env import BlueprintEnvironment from hathor.nanocontracts.context import Context +from hathor.nanocontracts.error_handling import NCInternalException from hathor.nanocontracts.exception import ( NCAlreadyInitializedContractError, - NCFail, NCForbiddenAction, NCInvalidContext, NCInvalidContractId, @@ -169,7 +169,7 @@ def execute_from_tx(self, tx: Transaction) -> None: # Fail execution if seqnum is invalid. self._last_call_info = self._build_call_info(contract_id) # TODO: Set the seqnum in this case? - raise NCFail(f'invalid seqnum (diff={diff})') + raise NCInternalException(f'invalid seqnum (diff={diff})') self.block_storage.set_address_seqnum(Address(nano_header.nc_address), nano_header.nc_seqnum) vertex_metadata = tx.get_metadata() @@ -553,7 +553,7 @@ def _execute_public_method_call( raise NCInvalidMethodCall(f'method `{method_name}` is not a public method') match nc_args: case NCRawArgs(args_bytes): - parser = Method.from_callable(method) + parser = Method.from_callable_handled(method) args = parser.deserialize_args_bytes(args_bytes) kwargs = {} case NCParsedArgs(): @@ -582,17 +582,11 @@ def _execute_public_method_call( rules.nc_callee_execution_rule(changes_tracker) self._handle_index_update(action) - try: - # Although the context is immutable, we're passing a copy to the blueprint method as an added precaution. - # This ensures that, even if the blueprint method attempts to exploit or alter the context, it cannot - # impact the original context. Since the runner relies on the context for other critical checks, any - # unauthorized modification would pose a serious security risk. - ret = self._metered_executor.call(method, ctx.copy(), *args, **kwargs) - except NCFail: - raise - except Exception as e: - # Convert any other exception to NCFail. - raise NCFail from e + # Although the context is immutable, we're passing a copy to the blueprint method as an added precaution. + # This ensures that, even if the blueprint method attempts to exploit or alter the context, it cannot + # impact the original context. Since the runner relies on the context for other critical checks, any + # unauthorized modification would pose a serious security risk. + ret = self._metered_executor.call(method, ctx.copy(), *args, **kwargs) if len(self._call_info.change_trackers[contract_id]) > 1: call_record.changes_tracker.commit() @@ -813,7 +807,7 @@ def syscall_create_another_contract( ) -> tuple[ContractId, Any]: """Create a contract from another contract.""" if not salt: - raise Exception('invalid salt') + raise NCInternalException('invalid salt') assert self._call_info is not None last_call_record = self.get_current_call_record() diff --git a/hathor/nanocontracts/runner/types.py b/hathor/nanocontracts/runner/types.py index bc3a2263b..601b1082c 100644 --- a/hathor/nanocontracts/runner/types.py +++ b/hathor/nanocontracts/runner/types.py @@ -22,6 +22,7 @@ from hathor.nanocontracts.context import Context from hathor.nanocontracts.exception import NCNumberOfCallsExceeded, NCRecursionError, NCSerializationError +from hathor.nanocontracts.method import POSSIBLE_SERIALIZATION_EXCEPTIONS from hathor.nanocontracts.storage import NCChangesTracker, NCContractStorage from hathor.nanocontracts.types import BlueprintId, ContractId, TokenUid, VertexId @@ -282,7 +283,7 @@ def try_parse_as(self, arg_types: tuple[type, ...]) -> tuple[Any, ...] | None: try: args_parser = ArgsOnly.from_arg_types(arg_types) return args_parser.deserialize_args_bytes(self.args_bytes) - except (NCSerializationError, TypeError): + except (NCSerializationError, *POSSIBLE_SERIALIZATION_EXCEPTIONS): return None diff --git a/hathor/p2p/sync_v2/blockchain_streaming_client.py b/hathor/p2p/sync_v2/blockchain_streaming_client.py index 295e59c7e..82b4d25e1 100644 --- a/hathor/p2p/sync_v2/blockchain_streaming_client.py +++ b/hathor/p2p/sync_v2/blockchain_streaming_client.py @@ -17,6 +17,7 @@ from structlog import get_logger from twisted.internet.defer import Deferred +from hathor.nanocontracts.error_handling import __NCTransactionFail__ from hathor.p2p.sync_v2.exception import ( BlockNotConnectedToPreviousBlock, InvalidVertexError, @@ -127,7 +128,7 @@ def handle_blocks(self, blk: Block) -> None: if self.tx_storage.can_validate_full(blk): try: self.vertex_handler.on_new_block(blk, deps=[]) - except HathorError: + except (HathorError, __NCTransactionFail__): self.fails(InvalidVertexError(blk.hash.hex())) return else: diff --git a/hathor/p2p/sync_v2/transaction_streaming_client.py b/hathor/p2p/sync_v2/transaction_streaming_client.py index fd7cea879..bbc4f4db8 100644 --- a/hathor/p2p/sync_v2/transaction_streaming_client.py +++ b/hathor/p2p/sync_v2/transaction_streaming_client.py @@ -18,6 +18,7 @@ from structlog import get_logger from twisted.internet.defer import Deferred, inlineCallbacks +from hathor.nanocontracts.error_handling import __NCTransactionFail__ from hathor.p2p.sync_v2.exception import ( InvalidVertexError, StreamingError, @@ -230,7 +231,7 @@ def _execute_and_prepare_next(self) -> Generator[Any, Any, bool]: try: yield self.sync_agent.on_block_complete(blk, vertex_list) - except HathorError as e: + except (HathorError, __NCTransactionFail__) as e: self.fails(InvalidVertexError(repr(e))) return False diff --git a/hathor/vertex_handler/vertex_handler.py b/hathor/vertex_handler/vertex_handler.py index afc2fa0e0..5344c35b8 100644 --- a/hathor/vertex_handler/vertex_handler.py +++ b/hathor/vertex_handler/vertex_handler.py @@ -24,6 +24,7 @@ from hathor.exception import HathorError, InvalidNewTransaction from hathor.execution_manager import ExecutionManager from hathor.feature_activation.feature_service import FeatureService +from hathor.nanocontracts.error_handling import __NCTransactionFail__ from hathor.profiler import get_cpu_profiler from hathor.pubsub import HathorEvents, PubSubManager from hathor.reactor import ReactorProtocol @@ -176,7 +177,7 @@ def _validate_vertex( if not metadata.validation.is_fully_connected(): try: self._verification_service.validate_full(vertex, reject_locked_reward=reject_locked_reward) - except HathorError as e: + except (HathorError, __NCTransactionFail__) as e: raise InvalidNewTransaction(f'full validation failed: {str(e)}') from e return True diff --git a/tests/nanocontracts/test_contract_upgrade.py b/tests/nanocontracts/test_contract_upgrade.py index 35322a513..7e3c3caf5 100644 --- a/tests/nanocontracts/test_contract_upgrade.py +++ b/tests/nanocontracts/test_contract_upgrade.py @@ -1,4 +1,5 @@ from hathor.nanocontracts import Blueprint, Context, fallback, public +from hathor.nanocontracts.error_handling import NCInternalException from hathor.nanocontracts.exception import BlueprintDoesNotExist, NCFail, NCInvalidSyscall, NCMethodNotFound from hathor.nanocontracts.runner.types import NCArgs from hathor.nanocontracts.types import BlueprintId, ContractId, NCAction @@ -155,7 +156,7 @@ def test_basic(self) -> None: assert code2_contract.counter == 0 assert proxy_contract.counter == 1 - with self.assertRaises(NCFail): + with self.assertRaises(NCInternalException): self.runner.call_public_method(proxy_id, 'upgrade', ctx, self.code3_bp_id, 'on_upgrade_fail') assert proxy_storage.get_blueprint_id() == self.proxy_bp_id assert proxy_contract.counter == 1 diff --git a/tests/nanocontracts/test_execution_verification.py b/tests/nanocontracts/test_execution_verification.py index 86007994e..5afc6ecb0 100644 --- a/tests/nanocontracts/test_execution_verification.py +++ b/tests/nanocontracts/test_execution_verification.py @@ -15,10 +15,12 @@ import pytest from hathor.nanocontracts import Blueprint, Context, public +from hathor.nanocontracts.error_handling import __NCUnhandledUserException__ from hathor.nanocontracts.exception import ( BlueprintDoesNotExist, NCFail, NCMethodNotFound, + NCSerializationError, NCUninitializedContractError, ) from hathor.nanocontracts.method import ArgsOnly @@ -53,13 +55,13 @@ def test_method_not_found(self) -> None: self.runner.call_public_method(self.contract_id, 'not_found', self.create_context()) def test_empty_args(self) -> None: - with pytest.raises(NCFail) as e: + with pytest.raises(__NCUnhandledUserException__) as e: self.runner.create_contract(self.contract_id, self.blueprint_id, self.create_context()) assert isinstance(e.value.__cause__, TypeError) assert e.value.__cause__.args[0] == "MyBlueprint.initialize() missing 1 required positional argument: 'a'" def test_too_many_args(self) -> None: - with pytest.raises(NCFail) as e: + with pytest.raises(__NCUnhandledUserException__) as e: self.runner.create_contract(self.contract_id, self.blueprint_id, self.create_context(), 123, 456) assert isinstance(e.value.__cause__, TypeError) assert e.value.__cause__.args[0] == "MyBlueprint.initialize() takes 3 positional arguments but 4 were given" @@ -74,7 +76,7 @@ def test_wrong_arg_type_raw(self) -> None: args_bytes = args_parser.serialize_args_bytes(('abc',)) nc_args = NCRawArgs(args_bytes) - with pytest.raises(NCFail) as e: + with pytest.raises(NCSerializationError) as e: self.runner.create_contract_with_nc_args( self.contract_id, self.blueprint_id, self.create_context(), nc_args ) diff --git a/tests/nanocontracts/test_fallback_method.py b/tests/nanocontracts/test_fallback_method.py index 9d57593ed..1298a9c43 100644 --- a/tests/nanocontracts/test_fallback_method.py +++ b/tests/nanocontracts/test_fallback_method.py @@ -19,7 +19,8 @@ from hathor.conf.settings import HATHOR_TOKEN_UID from hathor.nanocontracts import NC_EXECUTION_FAIL_ID, Blueprint, Context, NCFail, public -from hathor.nanocontracts.exception import NCError, NCInvalidMethodCall +from hathor.nanocontracts.error_handling import NCInternalException +from hathor.nanocontracts.exception import NCInvalidMethodCall from hathor.nanocontracts.method import ArgsOnly from hathor.nanocontracts.nc_exec_logs import NCCallBeginEntry, NCCallEndEntry from hathor.nanocontracts.runner.types import CallType, NCArgs, NCParsedArgs, NCRawArgs @@ -137,7 +138,7 @@ def test_fallback_args_kwargs_success(self) -> None: ] def test_cannot_call_fallback_directly(self) -> None: - with pytest.raises(NCError, match='method `fallback` is not a public method'): + with pytest.raises(NCInternalException, match='method `fallback` is not a public method'): self.runner.call_public_method(self.contract_id, 'fallback', self.ctx) def test_cannot_call_another_fallback_directly(self) -> None: @@ -206,5 +207,5 @@ def test_dag_fallback(self) -> None: manager=self.manager, tx_id=nc3.hash, block_id=b11.hash, - reason='NCFail: unsupported args: 00', + reason='NCUserException: unsupported args: 00', ) diff --git a/tests/nanocontracts/test_get_contract.py b/tests/nanocontracts/test_get_contract.py index e0796be94..d448e3b56 100644 --- a/tests/nanocontracts/test_get_contract.py +++ b/tests/nanocontracts/test_get_contract.py @@ -109,7 +109,7 @@ def test_get_readwrite_contract(self) -> None: contract.counter += 2 self.assertEqual(contract.counter, 2) - # one more tim to check it added to 2 (and not to 0) + # one more time to check it added to 2 (and not to 0) contract.counter += 3 self.assertEqual(contract.counter, 5) diff --git a/tests/nanocontracts/test_invalid_value_assignment.py b/tests/nanocontracts/test_invalid_value_assignment.py index 9de4c2165..025a62a64 100644 --- a/tests/nanocontracts/test_invalid_value_assignment.py +++ b/tests/nanocontracts/test_invalid_value_assignment.py @@ -1,6 +1,6 @@ from hathor.conf import HathorSettings from hathor.nanocontracts import Blueprint, Context, public -from hathor.nanocontracts.exception import NCFail +from hathor.nanocontracts.error_handling import __NCUnhandledUserException__ from hathor.nanocontracts.nc_types import make_nc_type_for_arg_type as make_nc_type from hathor.nanocontracts.types import ContractId, TokenUid, VertexId from tests.nanocontracts.blueprints.unittest import BlueprintTestCase @@ -44,5 +44,5 @@ def test_get_readwrite_contract(self) -> None: # XXX: the invalid_assign should fail as soon as put_obj is called, which makes this call fail with a NCFail, # in the case where it doesn't fail immediately (and it's left to fail on commit), the exception raised # will be a `TypeError` when commit is called. - with self.assertRaises(NCFail): + with self.assertRaises(__NCUnhandledUserException__): self.runner.call_public_method(self.nc_id, 'invalid_assign', self.create_context()) diff --git a/tests/nanocontracts/test_method_parser.py b/tests/nanocontracts/test_method_parser.py index ecac69d9a..40bb39c21 100644 --- a/tests/nanocontracts/test_method_parser.py +++ b/tests/nanocontracts/test_method_parser.py @@ -3,7 +3,7 @@ from typing import Any, Optional, TypeVar from hathor.nanocontracts.context import Context -from hathor.nanocontracts.exception import NCSerializationArgTooLong +from hathor.nanocontracts.exception import NCSerializationArgTooLong, NCSerializationError from hathor.nanocontracts.method import MAX_BYTES_SERIALIZED_ARG, Method from hathor.nanocontracts.types import SignedData, public from tests import unittest @@ -61,7 +61,7 @@ def _run_test_parser(self, method_parser: Method, data: T) -> None: self.assertEqual(type(args_in), type(args_out)) def test_type_str_wrong_type(self) -> None: - with self.assertRaises(TypeError): + with self.assertRaises(NCSerializationError): self._run_test(MyBlueprint.method_str, b'') def test_type_str_empty(self) -> None: @@ -127,15 +127,15 @@ def test_type_int_positive(self) -> None: self._run_test(MyBlueprint.method_int, 100) def test_type_int_too_big(self) -> None: - with self.assertRaises(ValueError): + with self.assertRaises(NCSerializationError): self._run_test(MyBlueprint.method_int, 2**223) def test_type_int_too_small(self) -> None: - with self.assertRaises(ValueError): + with self.assertRaises(NCSerializationError): self._run_test(MyBlueprint.method_int, -2**223 - 1) def test_type_int_wrong_type(self) -> None: - with self.assertRaises(TypeError): + with self.assertRaises(NCSerializationError): self._run_test(MyBlueprint.method_int, 1.) def test_type_int(self) -> None: @@ -165,7 +165,7 @@ def bar(self, i: int) -> None: -2**224, ] for invalid_value in invalid_values: - with self.assertRaises(ValueError): + with self.assertRaises(NCSerializationError): self._run_test(Foo.bar, invalid_value) def test_type_bool_false(self) -> None: diff --git a/tests/nanocontracts/test_nc_exec_logs.py b/tests/nanocontracts/test_nc_exec_logs.py index e6b54a879..814601675 100644 --- a/tests/nanocontracts/test_nc_exec_logs.py +++ b/tests/nanocontracts/test_nc_exec_logs.py @@ -382,7 +382,7 @@ def test_nc_fail(self) -> None: error_tb = result.entries[b2.hash][0].error_traceback assert error_tb is not None assert error_tb.startswith('Traceback (most recent call last):') - assert error_tb.endswith('hathor.nanocontracts.exception.NCFail: some fail\n') + assert error_tb.endswith('hathor.nanocontracts.error_handling.NCUserException: some fail\n') def test_value_error(self) -> None: self._prepare() @@ -436,7 +436,7 @@ def test_value_error(self) -> None: The above exception was the direct cause of the following exception:\n Traceback (most recent call last): """) in error_tb - assert error_tb.endswith('hathor.nanocontracts.exception.NCFail\n') + assert error_tb.endswith('hathor.nanocontracts.error_handling.__NCUnhandledUserException__\n') def test_reexecution_on_reorgs(self) -> None: self._prepare() diff --git a/tests/nanocontracts/test_seqnum.py b/tests/nanocontracts/test_seqnum.py index 6d3a3b11c..1ada92f51 100644 --- a/tests/nanocontracts/test_seqnum.py +++ b/tests/nanocontracts/test_seqnum.py @@ -74,7 +74,7 @@ def test_seqnum_fail_after_success(self) -> None: manager=self.manager, tx_id=tx3.hash, block_id=b32.hash, - reason='NCFail: invalid seqnum (diff=0)' + reason='NCInternalException: invalid seqnum (diff=0)' ) def test_seqnum_fail_after_fail(self) -> None: @@ -120,7 +120,7 @@ def test_seqnum_fail_after_fail(self) -> None: manager=self.manager, tx_id=tx2.hash, block_id=b31.hash, - reason='NCFail: oops' + reason='NCUserException: oops' ) tx2_nano_header = tx2.get_nano_header() @@ -133,7 +133,7 @@ def test_seqnum_fail_after_fail(self) -> None: manager=self.manager, tx_id=tx3.hash, block_id=b32.hash, - reason='NCFail: invalid seqnum (diff=0)' + reason='NCInternalException: invalid seqnum (diff=0)' ) def test_seqnum_fail_after_skip(self) -> None: @@ -185,7 +185,7 @@ def test_seqnum_fail_after_skip(self) -> None: manager=self.manager, tx_id=tx1.hash, block_id=b31.hash, - reason='NCFail: oops' + reason='NCUserException: oops' ) tx2_nano_header = tx2.get_nano_header() @@ -198,7 +198,7 @@ def test_seqnum_fail_after_skip(self) -> None: manager=self.manager, tx_id=tx3.hash, block_id=b32.hash, - reason='NCFail: invalid seqnum (diff=0)' + reason='NCInternalException: invalid seqnum (diff=0)' ) def test_seqnum_fail_max_jump(self) -> None: @@ -234,7 +234,7 @@ def test_seqnum_fail_max_jump(self) -> None: manager=self.manager, tx_id=tx2.hash, block_id=b31.hash, - reason='NCFail: invalid seqnum (diff=11)' + reason='NCInternalException: invalid seqnum (diff=11)' ) nc1_nano_header = nc1.get_nano_header() @@ -301,7 +301,7 @@ def test_invalid_block(self) -> None: manager=self.manager, tx_id=tx3.hash, block_id=b32.hash, - reason='NCFail: invalid seqnum (diff=0)' + reason='NCInternalException: invalid seqnum (diff=0)' ) def test_circular_dependency(self) -> None: @@ -356,7 +356,7 @@ def test_circular_dependency(self) -> None: manager=self.manager, tx_id=nc3.hash, block_id=b11.hash, - reason='NCFail: invalid seqnum (diff=-1)' + reason='NCInternalException: invalid seqnum (diff=-1)' ) def test_timestamp_rule(self) -> None: @@ -411,7 +411,7 @@ def test_timestamp_rule(self) -> None: manager=self.manager, tx_id=nc2.hash, block_id=b12.hash, - reason='NCFail: invalid seqnum (diff=-1)' + reason='NCInternalException: invalid seqnum (diff=-1)' ) def test_multiple_txs_same_seqnum(self) -> None: diff --git a/tests/nanocontracts/test_violations.py b/tests/nanocontracts/test_violations.py index c17cb0cac..77e439de2 100644 --- a/tests/nanocontracts/test_violations.py +++ b/tests/nanocontracts/test_violations.py @@ -1,6 +1,6 @@ from hathor.nanocontracts import Blueprint, public from hathor.nanocontracts.context import Context -from hathor.nanocontracts.exception import NCFail +from hathor.nanocontracts.error_handling import __NCUnhandledUserException__ from hathor.nanocontracts.types import NCDepositAction from tests.nanocontracts.blueprints.unittest import BlueprintTestCase @@ -48,7 +48,7 @@ def test_modify_actions(self) -> None: ) self.runner.create_contract(self.contract_id, self.blueprint_id, context) - with self.assertRaises(NCFail) as cm: + with self.assertRaises(__NCUnhandledUserException__) as cm: self.runner.call_public_method(self.contract_id, 'modify_actions', context) exc = cm.exception self.assertIsInstance(exc.__cause__, TypeError) @@ -61,7 +61,7 @@ def test_modify_vertex(self) -> None: timestamp=self.now ) self.runner.create_contract(self.contract_id, self.blueprint_id, context) - with self.assertRaises(NCFail) as cm: + with self.assertRaises(__NCUnhandledUserException__) as cm: self.runner.call_public_method(self.contract_id, 'modify_vertex', context) exc = cm.exception self.assertIsInstance(exc.__cause__, TypeError) @@ -75,7 +75,7 @@ def test_assign_non_declared_attribute(self) -> None: ) self.runner.create_contract(self.contract_id, self.blueprint_id, context) self.runner.call_public_method(self.contract_id, 'assign_declared_attribute', context) - with self.assertRaises(NCFail) as cm: + with self.assertRaises(__NCUnhandledUserException__) as cm: self.runner.call_public_method(self.contract_id, 'assign_non_declared_attribute', context) exc = cm.exception self.assertIsInstance(exc.__cause__, AttributeError) diff --git a/tests/unittest.py b/tests/unittest.py index b97d7eb8c..bcb35a2a1 100644 --- a/tests/unittest.py +++ b/tests/unittest.py @@ -9,6 +9,7 @@ from typing import Any, Callable, Collection, Iterable, Iterator, Optional from unittest import main as ut_main +import pytest from structlog import get_logger from twisted.trial import unittest @@ -20,6 +21,7 @@ from hathor.event import EventManager from hathor.event.storage import EventStorage from hathor.manager import HathorManager +from hathor.nanocontracts.error_handling import NCInternalException, NCUserException from hathor.nanocontracts.nc_exec_logs import NCLogConfig from hathor.p2p.peer import PrivatePeer from hathor.p2p.sync_v2.agent import NodeBlockSync @@ -447,18 +449,16 @@ def assertV2SyncedProgress(self, node_sync: NodeBlockSync) -> None: self.assertEqual(node_sync.synced_block, node_sync.peer_best_block) @contextmanager - def assertNCFail(self, class_name: str, pattern: str | re.Pattern[str] | None = None) -> Iterator[BaseException]: + def assertNCFail(self, class_name: str, pattern: str | re.Pattern[str] | None = None) -> Iterator[None]: """Assert that a NCFail is raised and it has the expected class name and str(exc) format. """ - from hathor.nanocontracts.exception import NCFail + with pytest.raises((NCInternalException, NCUserException)) as e: + yield - with self.assertRaises(NCFail) as cm: - yield cm - - self.assertEqual(cm.exception.__class__.__name__, class_name) + self.assertEqual(e.value.__class__.__name__, class_name) if pattern is not None: - actual = str(cm.exception) + actual = str(e.value) if isinstance(pattern, re.Pattern): assert pattern.match(actual) else: