diff --git a/hathor/manager.py b/hathor/manager.py index 4ab9ec23e..d48deb611 100644 --- a/hathor/manager.py +++ b/hathor/manager.py @@ -46,7 +46,6 @@ from hathor.feature_activation.feature_service import FeatureService 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 @@ -414,11 +413,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/exception.py b/hathor/nanocontracts/exception.py index b11730647..cfa467d9e 100644 --- a/hathor/nanocontracts/exception.py +++ b/hathor/nanocontracts/exception.py @@ -15,35 +15,35 @@ from hathor.exception import HathorError from hathor.transaction.exceptions import TxValidationError +""" +All exceptions in this module MUST inherit from NCFail so they're +correctly caught by the block consensus to fail NC transactions. +""" -class BlueprintSyntaxError(SyntaxError): - """Raised when a blueprint contains invalid syntax.""" - pass - -class NCError(HathorError): - """Base exception for nano contract's exceptions.""" - pass +class NCFail(HathorError): + """Raised by Blueprint's methods to fail execution.""" -class NCTxValidationError(TxValidationError): +class BlueprintSyntaxError(SyntaxError, NCFail): + """Raised when a blueprint contains invalid syntax.""" pass -class NCInvalidSignature(NCTxValidationError): +class NCTxValidationError(TxValidationError, NCFail): pass -class NCInvalidPubKey(NCTxValidationError): +class NCInvalidSignature(NCTxValidationError, NCFail): pass -class NCInvalidSeqnum(NCTxValidationError): +class NCInvalidPubKey(NCTxValidationError, NCFail): pass -class NCFail(NCError): - """Raised by Blueprint's methods to fail execution.""" +class NCInvalidSeqnum(NCTxValidationError, NCFail): + pass class NanoContractDoesNotExist(NCFail): @@ -71,7 +71,7 @@ class NCViewMethodError(NCFail): pass -class NCMethodNotFound(NCFail, NCTxValidationError): +class NCMethodNotFound(NCTxValidationError): """Raised when a method is not found in a nano contract.""" pass @@ -102,7 +102,7 @@ class NCInvalidContractId(NCFail): """Raised when a contract call is invalid.""" -class NCInvalidMethodCall(NCFail, NCTxValidationError): +class NCInvalidMethodCall(NCTxValidationError): """Raised when a contract calls another contract's invalid method.""" @@ -155,12 +155,12 @@ class NCForbiddenReentrancy(NCFail): pass -class UnknownFieldType(NCError): +class UnknownFieldType(NCFail): """Raised when there is no field available for a given type.""" pass -class NCContractCreationNotFound(NCError): +class NCContractCreationNotFound(NCFail): """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.""" @@ -181,38 +181,38 @@ class NCContractCreationVoided(NCContractCreationNotFound): pass -class OCBInvalidScript(NCError): +class OCBInvalidScript(NCFail): """Raised when an On-Chain Blueprint script does not pass our script restrictions check. """ pass -class OCBInvalidBlueprintVertexType(NCError): +class OCBInvalidBlueprintVertexType(NCFail): """Raised when a vertex that is not an OnChainBlueprint is used as a blueprint-id. """ pass -class OCBBlueprintNotConfirmed(NCError): +class OCBBlueprintNotConfirmed(NCFail): """Raised when trying to use an OnChainBlueprint that is not confirmed by a block in the current best chain. """ -class OCBPubKeyNotAllowed(NCError): +class OCBPubKeyNotAllowed(NCFail): """Raised when an OnChainBlueprint transaction uses a pubkey that is not explicitly allowed in the settings. """ -class OCBOutOfFuelDuringLoading(NCError): +class OCBOutOfFuelDuringLoading(NCFail): """Raised when loading an On-chain Blueprint and the execution exceeds the fuel limit. """ -class OCBOutOfMemoryDuringLoading(NCError): +class OCBOutOfMemoryDuringLoading(NCFail): """Raised when loading an On-chain Blueprint and the execution exceeds the memory limit. """ -class NCDisabledBuiltinError(NCError): +class NCDisabledBuiltinError(NCFail): """Raised when a disabled builtin is used during creation or execution of a nanocontract. """ diff --git a/hathor/nanocontracts/metered_exec.py b/hathor/nanocontracts/metered_exec.py index e2e82c19d..5a37d685d 100644 --- a/hathor/nanocontracts/metered_exec.py +++ b/hathor/nanocontracts/metered_exec.py @@ -80,7 +80,9 @@ def exec(self, source: str, /) -> dict[str, Any]: def call(self, func: Callable[_P, _T], /, *, args: _P.args) -> _T: """ This is equivalent to `func(*args, **kwargs)` but with execution metering and memory limiting. """ + from hathor import NCFail from hathor.nanocontracts.custom_builtins import EXEC_BUILTINS + env: dict[str, object] = { '__builtins__': EXEC_BUILTINS, '__func__': func, @@ -97,5 +99,13 @@ def call(self, func: Callable[_P, _T], /, *, args: _P.args) -> _T: optimize=0, _feature_version=PYTHON_CODE_COMPAT_VERSION[1], ) - exec(code, env) + + try: + exec(code, env) + except NCFail: + raise + except Exception as e: + # Convert any other exception to NCFail. + raise NCFail from e + return cast(_T, env['__result__']) diff --git a/hathor/nanocontracts/nc_exec_logs.py b/hathor/nanocontracts/nc_exec_logs.py index 5ebd67227..21a977060 100644 --- a/hathor/nanocontracts/nc_exec_logs.py +++ b/hathor/nanocontracts/nc_exec_logs.py @@ -234,7 +234,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 NCFail(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: diff --git a/hathor/nanocontracts/runner/runner.py b/hathor/nanocontracts/runner/runner.py index 7f91846db..82a720eaf 100644 --- a/hathor/nanocontracts/runner/runner.py +++ b/hathor/nanocontracts/runner/runner.py @@ -631,17 +631,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, args=(ctx.copy(), *args)) - 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, args=(ctx.copy(), *args)) if method_name == NC_INITIALIZE_METHOD: self._check_all_field_initialized(blueprint) @@ -909,7 +903,7 @@ def syscall_create_another_contract( ) -> tuple[ContractId, Any]: """Create a contract from another contract.""" if not salt: - raise Exception('invalid salt') + raise NCFail('invalid salt') assert self._call_info is not None last_call_record = self.get_current_call_record() diff --git a/hathor/nanocontracts/types.py b/hathor/nanocontracts/types.py index badd65ec2..0dfb4954d 100644 --- a/hathor/nanocontracts/types.py +++ b/hathor/nanocontracts/types.py @@ -31,6 +31,7 @@ ) from hathor.nanocontracts.exception import BlueprintSyntaxError, NCSerializationError from hathor.nanocontracts.faux_immutable import FauxImmutableMeta +from hathor.serialization import SerializationError from hathor.transaction.util import bytes_to_int, get_deposit_token_withdraw_amount, int_to_bytes from hathor.utils.typing import InnerTypeMixin @@ -506,7 +507,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, SerializationError, TypeError, ValueError): return None diff --git a/hathor/verification/nano_header_verifier.py b/hathor/verification/nano_header_verifier.py index 28ae88143..0db78d7bb 100644 --- a/hathor/verification/nano_header_verifier.py +++ b/hathor/verification/nano_header_verifier.py @@ -20,7 +20,6 @@ from hathor.conf.settings import HATHOR_TOKEN_UID, HathorSettings from hathor.nanocontracts.exception import ( NanoContractDoesNotExist, - NCError, NCFail, NCForbiddenAction, NCInvalidAction, @@ -171,7 +170,7 @@ def verify_method_call(self, tx: BaseTransaction, params: VerificationParams) -> try: blueprint_class = self._tx_storage.get_blueprint_class(blueprint_id) - except NCError as e: + except NCFail as e: raise NCTxValidationError from e method_name = nano_header.nc_method diff --git a/tests/nanocontracts/test_exceptions.py b/tests/nanocontracts/test_exceptions.py new file mode 100644 index 000000000..2b0535fde --- /dev/null +++ b/tests/nanocontracts/test_exceptions.py @@ -0,0 +1,30 @@ +# 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 unittest + + +class TestExceptions(unittest.TestCase): + def test_inherit_from_nc_fail(self) -> None: + from hathor.nanocontracts import exception as nano_exceptions + + skip = { + nano_exceptions.HathorError, + nano_exceptions.NCFail, + nano_exceptions.TxValidationError, + } + + for name, obj in nano_exceptions.__dict__.items(): + if isinstance(obj, type) and obj not in skip: + assert issubclass(obj, nano_exceptions.NCFail), f'all nano exceptions must inherit from NCFail: {name}' diff --git a/tests/nanocontracts/test_fallback_method.py b/tests/nanocontracts/test_fallback_method.py index 0022b3ca4..e3cc04a9c 100644 --- a/tests/nanocontracts/test_fallback_method.py +++ b/tests/nanocontracts/test_fallback_method.py @@ -18,7 +18,7 @@ import pytest from hathor.nanocontracts import HATHOR_TOKEN_UID, NC_EXECUTION_FAIL_ID, Blueprint, Context, NCFail, public -from hathor.nanocontracts.exception import NCError, NCInvalidMethodCall +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 @@ -140,7 +140,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(NCFail, 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: