From 9b7d725ec68216bc10e7b4af76d4776224ae020a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Thu, 26 Feb 2026 12:04:28 -0300 Subject: [PATCH 01/16] feat: move nc exceptions to hathorlib --- hathor/nanocontracts/exception.py | 224 ++++-------------- .../hathorlib/nanocontracts/exception.py | 195 +++++++++++++++ 2 files changed, 237 insertions(+), 182 deletions(-) create mode 100644 hathorlib/hathorlib/nanocontracts/exception.py diff --git a/hathor/nanocontracts/exception.py b/hathor/nanocontracts/exception.py index 3c95bab5ff..9cb0695ecf 100644 --- a/hathor/nanocontracts/exception.py +++ b/hathor/nanocontracts/exception.py @@ -12,22 +12,50 @@ # 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 - -""" -All exceptions in this module MUST inherit from NCFail so they're -correctly caught by the block consensus to fail NC transactions. -""" - - -class NCFail(HathorError): - """Raised by Blueprint's methods to fail execution.""" +# Re-export all exceptions from hathorlib for backward compatibility +from hathorlib.nanocontracts.exception import ( # noqa: F401 + BlueprintDoesNotExist, + BlueprintSyntaxError, + NanoContractDoesNotExist, + NCAlreadyInitializedContractError, + NCAttributeError, + NCContractCreationAtMempool, + NCContractCreationNotFound, + NCContractCreationVoided, + NCDisabledBuiltinError, + NCFail, + NCForbiddenAction, + NCForbiddenReentrancy, + NCInsufficientFunds, + NCInvalidAction, + NCInvalidContractId, + NCInvalidContext, + NCInvalidFee, + NCInvalidFeePaymentToken, + NCInvalidInitializeMethodCall, + NCInvalidPublicMethodCallFromView, + NCInvalidSyscall, + NCNumberOfCallsExceeded, + NCRecursionError, + NCSerializationArgTooLong, + NCSerializationError, + NCSerializationTypeError, + NCTokenAlreadyExists, + NCTypeError, + NCUninitializedContractError, + NCViewMethodError, + OCBBlueprintNotConfirmed, + OCBInvalidBlueprintVertexType, + OCBInvalidScript, + OCBOutOfFuelDuringLoading, + OCBOutOfMemoryDuringLoading, + OCBPubKeyNotAllowed, + UnknownFieldType, +) +from hathor.transaction.exceptions import TxValidationError -class BlueprintSyntaxError(SyntaxError, NCFail): - """Raised when a blueprint contains invalid syntax.""" - pass +# hathor-specific exceptions that depend on TxValidationError class NCTxValidationError(TxValidationError, NCFail): @@ -46,178 +74,10 @@ class NCInvalidSeqnum(NCTxValidationError, NCFail): pass -class NanoContractDoesNotExist(NCFail): - pass - - -class BlueprintDoesNotExist(NCFail): - pass - - -class NCSerializationError(NCFail): - pass - - -class NCSerializationArgTooLong(NCSerializationError): - pass - - -class NCSerializationTypeError(NCSerializationError): - pass - - -class NCViewMethodError(NCFail): - """Raised when a view method changes the state of the contract.""" - pass - - class NCMethodNotFound(NCTxValidationError): """Raised when a method is not found in a nano contract.""" pass -class NCInsufficientFunds(NCFail): - """Raised when there is not enough funds to withdrawal from a nano contract.""" - pass - - -class NCAttributeError(NCFail): - pass - - -class NCInvalidContext(NCFail): - """Raised when trying to run a method with an invalid context.""" - pass - - -class NCRecursionError(NCFail): - """Raised when recursion gets too deep.""" - - -class NCNumberOfCallsExceeded(NCFail): - """Raised when the total number of calls have been exceeded.""" - - -class NCInvalidContractId(NCFail): - """Raised when a contract call is invalid.""" - - class NCInvalidMethodCall(NCTxValidationError): """Raised when a contract calls another contract's invalid method.""" - - -class NCInvalidFeePaymentToken(NCFail): - """Raised when a payment token is invalid.""" - - -class NCInvalidInitializeMethodCall(NCFail): - """Raised when a contract calls another contract's initialize method.""" - - -class NCInvalidPublicMethodCallFromView(NCFail): - """Raised when a contract calls another contract's initialize method.""" - - -class NCAlreadyInitializedContractError(NCFail): - """Raised when one tries to initialize a contract that has already been initialized.""" - - -class NCUninitializedContractError(NCFail): - """Raised when a contract calls a method from an uninitialized contract.""" - - -class NCInvalidAction(NCFail): - """Raised when an action is invalid.""" - pass - - -class NCInvalidFee(NCFail): - """Raised when a fee is invalid.""" - pass - - -class NCInvalidSyscall(NCFail): - """Raised when a syscall is invalid.""" - pass - - -class NCTokenAlreadyExists(NCFail): - """Raised when one tries to create a duplicated token.""" - - -class NCForbiddenAction(NCFail): - """Raised when an action is forbidden on a method.""" - pass - - -class NCForbiddenReentrancy(NCFail): - """Raised when a reentrancy is forbidden on a method.""" - pass - - -class NCTypeError(NCFail): - """Raised when a wrong type is used.""" - pass - - -class UnknownFieldType(NCFail): - """Raised when there is no field available for a given type.""" - pass - - -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.""" - pass - - -class NCContractCreationAtMempool(NCContractCreationNotFound): - """Raised when a nano contract creation transaction is at the mempool, so it has not been - executed yet.""" - pass - - -class NCContractCreationVoided(NCContractCreationNotFound): - """Raised when a nano contract creation transaction is voided. - - The two most common reasons to have a voided transaction is because it was voided by - another transaction (e.g., double spending) or it has failed execution.""" - pass - - -class OCBInvalidScript(NCFail): - """Raised when an On-Chain Blueprint script does not pass our script restrictions check. - """ - pass - - -class OCBInvalidBlueprintVertexType(NCFail): - """Raised when a vertex that is not an OnChainBlueprint is used as a blueprint-id. - """ - pass - - -class OCBBlueprintNotConfirmed(NCFail): - """Raised when trying to use an OnChainBlueprint that is not confirmed by a block in the current best chain. - """ - - -class OCBPubKeyNotAllowed(NCFail): - """Raised when an OnChainBlueprint transaction uses a pubkey that is not explicitly allowed in the settings. - """ - - -class OCBOutOfFuelDuringLoading(NCFail): - """Raised when loading an On-chain Blueprint and the execution exceeds the fuel limit. - """ - - -class OCBOutOfMemoryDuringLoading(NCFail): - """Raised when loading an On-chain Blueprint and the execution exceeds the memory limit. - """ - - -class NCDisabledBuiltinError(NCFail): - """Raised when a disabled builtin is used during creation or execution of a nanocontract. - """ diff --git a/hathorlib/hathorlib/nanocontracts/exception.py b/hathorlib/hathorlib/nanocontracts/exception.py new file mode 100644 index 0000000000..bf05a39048 --- /dev/null +++ b/hathorlib/hathorlib/nanocontracts/exception.py @@ -0,0 +1,195 @@ +# 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. + +""" +All exceptions in this module MUST inherit from NCFail so they're +correctly caught by the block consensus to fail NC transactions. +""" + + +class NCFail(Exception): + """Raised by Blueprint's methods to fail execution.""" + + +class BlueprintSyntaxError(SyntaxError, NCFail): + """Raised when a blueprint contains invalid syntax.""" + pass + + +class NanoContractDoesNotExist(NCFail): + pass + + +class BlueprintDoesNotExist(NCFail): + pass + + +class NCSerializationError(NCFail): + pass + + +class NCSerializationArgTooLong(NCSerializationError): + pass + + +class NCSerializationTypeError(NCSerializationError): + pass + + +class NCViewMethodError(NCFail): + """Raised when a view method changes the state of the contract.""" + pass + + +class NCInsufficientFunds(NCFail): + """Raised when there is not enough funds to withdrawal from a nano contract.""" + pass + + +class NCAttributeError(NCFail): + pass + + +class NCInvalidContext(NCFail): + """Raised when trying to run a method with an invalid context.""" + pass + + +class NCRecursionError(NCFail): + """Raised when recursion gets too deep.""" + + +class NCNumberOfCallsExceeded(NCFail): + """Raised when the total number of calls have been exceeded.""" + + +class NCInvalidContractId(NCFail): + """Raised when a contract call is invalid.""" + + +class NCInvalidFeePaymentToken(NCFail): + """Raised when a payment token is invalid.""" + + +class NCInvalidInitializeMethodCall(NCFail): + """Raised when a contract calls another contract's initialize method.""" + + +class NCInvalidPublicMethodCallFromView(NCFail): + """Raised when a contract calls another contract's initialize method.""" + + +class NCAlreadyInitializedContractError(NCFail): + """Raised when one tries to initialize a contract that has already been initialized.""" + + +class NCUninitializedContractError(NCFail): + """Raised when a contract calls a method from an uninitialized contract.""" + + +class NCInvalidAction(NCFail): + """Raised when an action is invalid.""" + pass + + +class NCInvalidFee(NCFail): + """Raised when a fee is invalid.""" + pass + + +class NCInvalidSyscall(NCFail): + """Raised when a syscall is invalid.""" + pass + + +class NCTokenAlreadyExists(NCFail): + """Raised when one tries to create a duplicated token.""" + + +class NCForbiddenAction(NCFail): + """Raised when an action is forbidden on a method.""" + pass + + +class NCForbiddenReentrancy(NCFail): + """Raised when a reentrancy is forbidden on a method.""" + pass + + +class NCTypeError(NCFail): + """Raised when a wrong type is used.""" + pass + + +class UnknownFieldType(NCFail): + """Raised when there is no field available for a given type.""" + pass + + +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.""" + pass + + +class NCContractCreationAtMempool(NCContractCreationNotFound): + """Raised when a nano contract creation transaction is at the mempool, so it has not been + executed yet.""" + pass + + +class NCContractCreationVoided(NCContractCreationNotFound): + """Raised when a nano contract creation transaction is voided. + + The two most common reasons to have a voided transaction is because it was voided by + another transaction (e.g., double spending) or it has failed execution.""" + pass + + +class OCBInvalidScript(NCFail): + """Raised when an On-Chain Blueprint script does not pass our script restrictions check. + """ + pass + + +class OCBInvalidBlueprintVertexType(NCFail): + """Raised when a vertex that is not an OnChainBlueprint is used as a blueprint-id. + """ + pass + + +class OCBBlueprintNotConfirmed(NCFail): + """Raised when trying to use an OnChainBlueprint that is not confirmed by a block in the current best chain. + """ + + +class OCBPubKeyNotAllowed(NCFail): + """Raised when an OnChainBlueprint transaction uses a pubkey that is not explicitly allowed in the settings. + """ + + +class OCBOutOfFuelDuringLoading(NCFail): + """Raised when loading an On-chain Blueprint and the execution exceeds the fuel limit. + """ + + +class OCBOutOfMemoryDuringLoading(NCFail): + """Raised when loading an On-chain Blueprint and the execution exceeds the memory limit. + """ + + +class NCDisabledBuiltinError(NCFail): + """Raised when a disabled builtin is used during creation or execution of a nanocontract. + """ From 08e08ba997f9c1da95782ac2994bebfc576d2509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Thu, 26 Feb 2026 12:08:09 -0300 Subject: [PATCH 02/16] feat: Move core types, decorators, and address utilities to hathorlib --- .../blueprint_syntax_validation.py | 92 +-- hathor/nanocontracts/types.py | 559 ++---------------- .../blueprint_syntax_validation.py | 77 +++ hathorlib/hathorlib/nanocontracts/types.py | 512 +++++++++++++++- hathorlib/hathorlib/utils/__init__.py | 180 +----- hathorlib/hathorlib/utils/address.py | 184 ++++++ 6 files changed, 830 insertions(+), 774 deletions(-) create mode 100644 hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py create mode 100644 hathorlib/hathorlib/utils/address.py diff --git a/hathor/nanocontracts/blueprint_syntax_validation.py b/hathor/nanocontracts/blueprint_syntax_validation.py index eb1307c416..c575247572 100644 --- a/hathor/nanocontracts/blueprint_syntax_validation.py +++ b/hathor/nanocontracts/blueprint_syntax_validation.py @@ -12,88 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - -import inspect -from typing import Callable - -from hathor.nanocontracts.exception import BlueprintSyntaxError - - -def validate_has_self_arg(fn: Callable, annotation_name: str) -> None: - """Validate the `self` arg of a callable.""" - arg_spec = inspect.getfullargspec(fn) - if len(arg_spec.args) == 0: - raise BlueprintSyntaxError(f'@{annotation_name} method must have `self` argument: `{fn.__name__}()`') - - if arg_spec.args[0] != 'self': - raise BlueprintSyntaxError( - f'@{annotation_name} method first argument must be called `self`: `{fn.__name__}()`' - ) - - if 'self' in arg_spec.annotations.keys(): - raise BlueprintSyntaxError(f'@{annotation_name} method `self` argument must not be typed: `{fn.__name__}()`') - - -def validate_method_types(fn: Callable) -> None: - """Validate the arg and return types of a callable.""" - special_args = ['self'] - arg_spec = inspect.getfullargspec(fn) - - if 'return' not in arg_spec.annotations: - raise BlueprintSyntaxError(f'missing return type on method `{fn.__name__}`') - - # TODO: This currently fails for types such as unions, probably because this is the wrong - # parsing function to use. Fix this. - # from hathor.nanocontracts.fields import get_field_class_for_attr - # return_type = arg_spec.annotations['return'] - # if return_type is not None: - # try: - # get_field_class_for_attr(return_type) - # except UnknownFieldType: - # raise BlueprintSyntaxError( - # f'unsupported return type `{return_type}` on method `{fn.__name__}`' - # ) - - for arg_name in arg_spec.args: - if arg_name in special_args: - continue - - if arg_name not in arg_spec.annotations: - raise BlueprintSyntaxError(f'argument `{arg_name}` on method `{fn.__name__}` must be typed') - - # TODO: This currently fails for @view methods with NamedTuple as args for example, - # because API calls use a different parsing function. Fix this. - # arg_type = arg_spec.annotations[arg_name] - # try: - # get_field_class_for_attr(arg_type) - # except UnknownFieldType: - # raise BlueprintSyntaxError( - # f'unsupported type `{arg_type.__name__}` on argument `{arg_name}` of method `{fn.__name__}`' - # ) - - -def validate_has_ctx_arg(fn: Callable, annotation_name: str) -> None: - """Validate the context arg of a callable.""" - arg_spec = inspect.getfullargspec(fn) - - if len(arg_spec.args) < 2: - raise BlueprintSyntaxError( - f'@{annotation_name} method must have `Context` argument: `{fn.__name__}()`' - ) - - from hathor.nanocontracts import Context - second_arg = arg_spec.args[1] - if arg_spec.annotations[second_arg] is not Context: - raise BlueprintSyntaxError( - f'@{annotation_name} method second arg `{second_arg}` argument must be of type `Context`: ' - f'`{fn.__name__}()`' - ) - - -def validate_has_not_ctx_arg(fn: Callable, annotation_name: str) -> None: - """Validate that a callable doesn't have a `Context` arg.""" - from hathor.nanocontracts import Context - arg_spec = inspect.getfullargspec(fn) - if Context in arg_spec.annotations.values(): - raise BlueprintSyntaxError(f'@{annotation_name} method cannot have arg with type `Context`: `{fn.__name__}()`') +# Re-export from hathorlib for backward compatibility +from hathorlib.nanocontracts.blueprint_syntax_validation import ( # noqa: F401 + validate_has_ctx_arg, + validate_has_not_ctx_arg, + validate_has_self_arg, + validate_method_types, +) diff --git a/hathor/nanocontracts/types.py b/hathor/nanocontracts/types.py index 3d18359880..75bf6b237a 100644 --- a/hathor/nanocontracts/types.py +++ b/hathor/nanocontracts/types.py @@ -12,524 +12,43 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - -import inspect -from dataclasses import dataclass -from enum import Enum, unique -from typing import Any, Callable, Generic, Self, TypeAlias, TypeVar - -from typing_extensions import override - -from hathor.conf.settings import HATHOR_TOKEN_UID, HathorSettings -from hathor.crypto.util import decode_address, get_address_b58_from_bytes -from hathor.nanocontracts.blueprint_syntax_validation import ( - validate_has_ctx_arg, - validate_has_not_ctx_arg, - validate_has_self_arg, - validate_method_types, -) -from hathor.nanocontracts.exception import BlueprintSyntaxError, NCSerializationError -from hathor.nanocontracts.faux_immutable import FauxImmutableMeta -from hathor.serialization import SerializationError -from hathor.transaction.scripts.opcode import OpcodesVersion -from hathor.transaction.util import bytes_to_int, get_deposit_token_withdraw_amount, int_to_bytes -from hathor.utils.typing import InnerTypeMixin - -# XXX: mypy gives the following errors on all subclasses of `bytes` that use FauxImmutableMeta: -# -# Metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its -# bases -# -# However `bytes` metaclass appears to be `type` (just like `int`'s) and `FauxImmutableMeta` correctly inherits from -# `type`, so it seems like it's a mypy error. - - -# Types to be used by blueprints. -class Address(bytes, metaclass=FauxImmutableMeta): - __allow_faux_inheritance__ = True - __allow_faux_dunder__ = ('__str__', '__repr__') - __slots__ = () - - @classmethod - def from_str(cls, /, encoded_address: str) -> Self: - if not isinstance(encoded_address, str): - raise TypeError(f'expected `str` instance, got `{type(encoded_address)}` instance') - return cls(decode_address(encoded_address)) - - def __str__(self) -> str: - encoded_address = get_address_b58_from_bytes(self) - return encoded_address - - def __repr__(self) -> str: - encoded_address = str(self) # uses __str__ - # XXX: should we support `Address(encoded_address)` constructor? - return f"Address.from_str({encoded_address!r})" - - -class VertexId(bytes, metaclass=FauxImmutableMeta): - __slots__ = () - __allow_faux_inheritance__ = True - - -class BlueprintId(VertexId): - __slots__ = () - __allow_faux_inheritance__ = True - - -class ContractId(VertexId): - __slots__ = () - __allow_faux_inheritance__ = True - - -class TokenUid(bytes, metaclass=FauxImmutableMeta): - __slots__ = () - __allow_faux_inheritance__ = True - - -class TxOutputScript(bytes, metaclass=FauxImmutableMeta): - __slots__ = () - __allow_faux_inheritance__ = True - - -class Amount(int, metaclass=FauxImmutableMeta): - __slots__ = () - __allow_faux_inheritance__ = True - - -class Timestamp(int, metaclass=FauxImmutableMeta): - __slots__ = () - __allow_faux_inheritance__ = True - - -CallerId: TypeAlias = Address | ContractId - -T = TypeVar('T') -B = TypeVar('B', bound=type) - -NC_INITIALIZE_METHOD: str = 'initialize' -NC_FALLBACK_METHOD: str = 'fallback' - -NC_ALLOWED_ACTIONS_ATTR = '__nc_allowed_actions' -NC_ALLOW_REENTRANCY = '__nc_allow_reentrancy' -NC_METHOD_TYPE_ATTR: str = '__nc_method_type' - -# this is the name we use internally to store the blueprint that is exported by a module -BLUEPRINT_EXPORT_NAME: str = '__blueprint__' - - -class NCMethodType(Enum): - PUBLIC = 'public' - VIEW = 'view' - FALLBACK = 'fallback' - - -def blueprint_id_from_bytes(data: bytes) -> BlueprintId: - """Create a BlueprintId from a bytes object.""" - return BlueprintId(VertexId(data)) - - -class RawSignedData(InnerTypeMixin[T], Generic[T]): - """A wrapper class to sign data. - - T must be serializable. - """ - - def __init__(self, data: T, script_input: bytes) -> None: - from hathor.nanocontracts.nc_types import make_nc_type_for_return_type as make_nc_type - self.data = data - self.script_input = script_input - self.__nc_type = make_nc_type(self.__inner_type__) - - def __eq__(self, other): - if not isinstance(other, RawSignedData): - return False - if self.data != other.data: - return False - if self.script_input != other.script_input: - return False - return True - - def get_data_bytes(self) -> bytes: - """Return the serialized data.""" - return self.__nc_type.to_bytes(self.data) - - def get_sighash_all_data(self) -> bytes: - """Workaround to be able to pass `self` for ScriptExtras. See the method `checksig`.""" - return self.get_data_bytes() - - def checksig(self, script: bytes) -> bool: - """Check if `self.script_input` satisfies the provided script.""" - from hathor.transaction.exceptions import ScriptError - from hathor.transaction.scripts import ScriptExtras - from hathor.transaction.scripts.execute import raw_script_eval - extras = ScriptExtras(tx=self, version=OpcodesVersion.V2) # type: ignore[arg-type] - try: - raw_script_eval(input_data=self.script_input, output_script=script, extras=extras) - except ScriptError: - return False - else: - return True - - -class SignedData(InnerTypeMixin[T], Generic[T]): - def __init__(self, data: T, script_input: bytes) -> None: - self.data = data - self.script_input = script_input - - def __eq__(self, other): - if not isinstance(other, SignedData): - return False - if self.data != other.data: - return False - if self.script_input != other.script_input: - return False - return True - - def _get_raw_signed_data(self, contract_id: ContractId) -> RawSignedData: - # XXX: for some reason mypy doesn't recognize that self.__inner_type__ is defined even though it should - raw_type: type = tuple[ContractId, self.__inner_type__] # type: ignore[name-defined] - raw_data = (contract_id, self.data) - return RawSignedData[raw_type](raw_data, self.script_input) # type: ignore[valid-type] - - def get_data_bytes(self, contract_id: ContractId) -> bytes: - """Return the serialized data.""" - raw_signed_data = self._get_raw_signed_data(contract_id) - return raw_signed_data.get_data_bytes() - - def checksig(self, contract_id: ContractId, script: bytes) -> bool: - """Check if script_input satisfies the provided script.""" - raw_signed_data = self._get_raw_signed_data(contract_id) - return raw_signed_data.checksig(script) - - -def _set_method_type(fn: Callable, method_type: NCMethodType) -> None: - if hasattr(fn, NC_METHOD_TYPE_ATTR): - raise BlueprintSyntaxError(f'method must be annotated with at most one method type: `{fn.__name__}()`') - setattr(fn, NC_METHOD_TYPE_ATTR, method_type) - - -def _create_decorator_with_allowed_actions( - *, - decorator_body: Callable[[Callable], None], - maybe_fn: Callable | None, - allow_deposit: bool | None, - allow_withdrawal: bool | None, - allow_grant_authority: bool | None, - allow_acquire_authority: bool | None, - allow_actions: list[NCActionType] | None, - allow_reentrancy: bool, -) -> Callable: - """Internal utility to create a decorator that sets allowed actions.""" - flags = { - NCActionType.DEPOSIT: allow_deposit, - NCActionType.WITHDRAWAL: allow_withdrawal, - NCActionType.GRANT_AUTHORITY: allow_grant_authority, - NCActionType.ACQUIRE_AUTHORITY: allow_acquire_authority, - } - - def decorator(fn: Callable) -> Callable: - if allow_actions is not None and any(flag is not None for flag in flags.values()): - raise BlueprintSyntaxError(f'use only one of `allow_actions` or per-action flags: `{fn.__name__}()`') - - allowed_actions = set(allow_actions) if allow_actions else set() - allowed_actions.update(action for action, flag in flags.items() if flag) - setattr(fn, NC_ALLOWED_ACTIONS_ATTR, allowed_actions) - setattr(fn, NC_ALLOW_REENTRANCY, allow_reentrancy) - - decorator_body(fn) - return fn - - if maybe_fn is not None: - return decorator(maybe_fn) - return decorator - - -def public( - maybe_fn: Callable | None = None, - /, - *, - allow_deposit: bool | None = None, - allow_withdrawal: bool | None = None, - allow_grant_authority: bool | None = None, - allow_acquire_authority: bool | None = None, - allow_actions: list[NCActionType] | None = None, - allow_reentrancy: bool = False, -) -> Callable: - """Decorator to mark a blueprint method as public.""" - def decorator(fn: Callable) -> None: - annotation_name = 'public' - forbidden_methods = {NC_FALLBACK_METHOD} - _set_method_type(fn, NCMethodType.PUBLIC) - - if fn.__name__ in forbidden_methods: - raise BlueprintSyntaxError(f'`{fn.__name__}` method cannot be annotated with @{annotation_name}') - - validate_has_self_arg(fn, annotation_name) - validate_method_types(fn) - validate_has_ctx_arg(fn, annotation_name) - - return _create_decorator_with_allowed_actions( - decorator_body=decorator, - maybe_fn=maybe_fn, - allow_deposit=allow_deposit, - allow_withdrawal=allow_withdrawal, - allow_grant_authority=allow_grant_authority, - allow_acquire_authority=allow_acquire_authority, - allow_actions=allow_actions, - allow_reentrancy=allow_reentrancy, - ) - - -def view(fn: Callable) -> Callable: - """Decorator to mark a blueprint method as view (read-only).""" - annotation_name = 'view' - forbidden_methods = {NC_INITIALIZE_METHOD, NC_FALLBACK_METHOD} - _set_method_type(fn, NCMethodType.VIEW) - - if fn.__name__ in forbidden_methods: - raise BlueprintSyntaxError(f'`{fn.__name__}` method cannot be annotated with @{annotation_name}') - - validate_has_self_arg(fn, annotation_name) - validate_has_not_ctx_arg(fn, annotation_name) - validate_method_types(fn) - return fn - - -def export(cls: B) -> B: - """Decorator to export the main Blueprint of a Python module.""" - current_frame = inspect.currentframe() - assert current_frame is not None - module_frame = current_frame.f_back - assert module_frame is not None - module_globals = module_frame.f_globals - if BLUEPRINT_EXPORT_NAME in module_globals: - raise TypeError('A Blueprint has already been registered') - module_globals[BLUEPRINT_EXPORT_NAME] = cls - return cls - - -def fallback( - maybe_fn: Callable | None = None, - /, - *, - allow_deposit: bool | None = None, - allow_withdrawal: bool | None = None, - allow_grant_authority: bool | None = None, - allow_acquire_authority: bool | None = None, - allow_actions: list[NCActionType] | None = None, - allow_reentrancy: bool = False, -) -> Callable: - """Decorator to mark a blueprint method as fallback. The method must also be called `fallback`.""" - def decorator(fn: Callable) -> None: - annotation_name = 'fallback' - _set_method_type(fn, NCMethodType.FALLBACK) - - if fn.__name__ != NC_FALLBACK_METHOD: - raise BlueprintSyntaxError(f'@{annotation_name} method must be called `fallback`: `{fn.__name__}()`') - - validate_has_self_arg(fn, annotation_name) - validate_method_types(fn) - validate_has_ctx_arg(fn, annotation_name) - - arg_spec = inspect.getfullargspec(fn) - msg = f'@{annotation_name} method must have these args: `ctx: Context, method_name: str, nc_args: NCArgs`' - - if len(arg_spec.args) < 4: - raise BlueprintSyntaxError(msg) - - third_arg = arg_spec.args[2] - fourth_arg = arg_spec.args[3] - - if arg_spec.annotations[third_arg] is not str or arg_spec.annotations[fourth_arg] is not NCArgs: - raise BlueprintSyntaxError(msg) - - return _create_decorator_with_allowed_actions( - decorator_body=decorator, - maybe_fn=maybe_fn, - allow_deposit=allow_deposit, - allow_withdrawal=allow_withdrawal, - allow_grant_authority=allow_grant_authority, - allow_acquire_authority=allow_acquire_authority, - allow_actions=allow_actions, - allow_reentrancy=allow_reentrancy, - ) - - -@unique -class NCActionType(Enum): - """ - Types of interactions a transaction might have with a contract. - Check the respective dataclasses below for more info. - """ - DEPOSIT = 1 - WITHDRAWAL = 2 - GRANT_AUTHORITY = 3 - ACQUIRE_AUTHORITY = 4 - - def __str__(self) -> str: - return self.name - - def to_bytes(self) -> bytes: - return int_to_bytes(number=self.value, size=1) - - @staticmethod - def from_bytes(data: bytes) -> NCActionType: - return NCActionType(bytes_to_int(data)) - - -@dataclass(slots=True, frozen=True, kw_only=True) -class BaseAction: - """The base dataclass for all NC actions. Shouldn't be instantiated directly.""" - token_uid: TokenUid - - @property - def type(self) -> NCActionType: - """The respective NCActionType for each NCAction.""" - action_types: dict[type[BaseAction], NCActionType] = { - NCDepositAction: NCActionType.DEPOSIT, - NCWithdrawalAction: NCActionType.WITHDRAWAL, - NCGrantAuthorityAction: NCActionType.GRANT_AUTHORITY, - NCAcquireAuthorityAction: NCActionType.ACQUIRE_AUTHORITY, - } - - if action_type := action_types.get(type(self)): - return action_type - - raise NotImplementedError(f'unknown action type {type(self)}') - - @property - def name(self) -> str: - """The action name.""" - return str(self.type) - - def to_json(self) -> dict[str, Any]: - """ - Convert this action to a json dict. - - >>> NCDepositAction(token_uid=TokenUid(b'\x01'), amount=123).to_json() - {'type': 'deposit', 'token_uid': '01', 'amount': 123} - >>> NCWithdrawalAction(token_uid=TokenUid(b'\x01'), amount=123).to_json() - {'type': 'withdrawal', 'token_uid': '01', 'amount': 123} - >>> NCGrantAuthorityAction(token_uid=TokenUid(b'\x01'), mint=True, melt=False).to_json() - {'type': 'grant_authority', 'token_uid': '01', 'mint': True, 'melt': False} - >>> NCAcquireAuthorityAction(token_uid=TokenUid(b'\x01'), mint=False, melt=True).to_json() - {'type': 'acquire_authority', 'token_uid': '01', 'mint': False, 'melt': True} - """ - return dict( - type=self.name.lower(), - token_uid=self.token_uid.hex(), - ) - - -@dataclass(slots=True, frozen=True, kw_only=True) -class BaseTokenAction(BaseAction): - """The base dataclass for all token-related NC actions. Shouldn't be instantiated directly.""" - amount: int - - @override - def to_json(self) -> dict[str, Any]: - json_dict = super(BaseTokenAction, self).to_json() - return dict( - **json_dict, - amount=self.amount, - ) - - -@dataclass(slots=True, frozen=True, kw_only=True) -class BaseAuthorityAction(BaseAction): - """The base dataclass for all authority-related NC actions. Shouldn't be instantiated directly.""" - mint: bool - melt: bool - - def __post_init__(self) -> None: - """Validate the token uid.""" - from hathor.conf.settings import HATHOR_TOKEN_UID - from hathor.nanocontracts.exception import NCInvalidAction - if self.token_uid == HATHOR_TOKEN_UID: - raise NCInvalidAction(f'{self.name} action cannot be executed on HTR token') - - @override - def to_json(self) -> dict[str, Any]: - json_dict = super(BaseAuthorityAction, self).to_json() - return dict( - **json_dict, - mint=self.mint, - melt=self.melt, - ) - - -@dataclass(slots=True, frozen=True, kw_only=True) -class NCDepositAction(BaseTokenAction): - """Deposit tokens into the contract.""" - - -@dataclass(slots=True, frozen=True, kw_only=True) -class NCWithdrawalAction(BaseTokenAction): - """Withdraw tokens from the contract.""" - - -@dataclass(slots=True, frozen=True, kw_only=True) -class NCGrantAuthorityAction(BaseAuthorityAction): - """Grant an authority to the contract.""" - - -@dataclass(slots=True, frozen=True, kw_only=True) -class NCAcquireAuthorityAction(BaseAuthorityAction): - """ - Acquire an authority stored in a contract to create authority outputs or mint/melt tokens in the tx, - or to store and use in a caller contract. - """ - - -"""A sum type representing all possible nano contract actions.""" -NCAction: TypeAlias = ( - NCDepositAction - | NCWithdrawalAction - | NCGrantAuthorityAction - | NCAcquireAuthorityAction +# Re-export all types from hathorlib for backward compatibility +from hathorlib.nanocontracts.types import ( # noqa: F401 + BLUEPRINT_EXPORT_NAME, + HATHOR_TOKEN_UID, + NC_ALLOW_REENTRANCY, + NC_ALLOWED_ACTIONS_ATTR, + NC_FALLBACK_METHOD, + NC_INITIALIZE_METHOD, + NC_METHOD_TYPE_ATTR, + Address, + Amount, + BaseAction, + BaseAuthorityAction, + BaseTokenAction, + BlueprintId, + CallerId, + ContractId, + NCAction, + NCActionType, + NCAcquireAuthorityAction, + NCArgs, + NCDepositAction, + NCFee, + NCGrantAuthorityAction, + NCMethodType, + NCParsedArgs, + NCRawArgs, + NCWithdrawalAction, + RawSignedData, + SignedData, + Timestamp, + TokenUid, + TxOutputScript, + VertexId, + blueprint_id_from_bytes, + export, + fallback, + public, + view, ) - - -@dataclass(slots=True, frozen=True) -class NCRawArgs: - args_bytes: bytes - - def __str__(self) -> str: - return self.args_bytes.hex() - - def __repr__(self) -> str: - return f"NCRawArgs('{str(self)}')" - - def try_parse_as(self, arg_types: tuple[type, ...]) -> tuple[Any, ...] | None: - from hathor.nanocontracts.method import ArgsOnly - try: - args_parser = ArgsOnly.from_arg_types(arg_types) - return args_parser.deserialize_args_bytes(self.args_bytes) - except (NCSerializationError, SerializationError, TypeError, ValueError): - return None - - -@dataclass(slots=True, frozen=True) -class NCParsedArgs: - args: tuple[Any, ...] - kwargs: dict[str, Any] - - -NCArgs: TypeAlias = NCRawArgs | NCParsedArgs - - -@dataclass(slots=True, frozen=True, kw_only=True) -class NCFee: - """The dataclass for all NC actions fee""" - token_uid: TokenUid - amount: int - - def get_htr_value(self, settings: HathorSettings) -> int: - """ - Get the amount converted to HTR - """ - if self.token_uid == HATHOR_TOKEN_UID: - return self.amount - else: - return get_deposit_token_withdraw_amount(settings, self.amount) diff --git a/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py b/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py new file mode 100644 index 0000000000..25c2ef5a76 --- /dev/null +++ b/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py @@ -0,0 +1,77 @@ +# 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. + +from __future__ import annotations + +import inspect +from typing import Callable + +from hathorlib.nanocontracts.exception import BlueprintSyntaxError + + +def validate_has_self_arg(fn: Callable, annotation_name: str) -> None: + """Validate the `self` arg of a callable.""" + arg_spec = inspect.getfullargspec(fn) + if len(arg_spec.args) == 0: + raise BlueprintSyntaxError(f'@{annotation_name} method must have `self` argument: `{fn.__name__}()`') + + if arg_spec.args[0] != 'self': + raise BlueprintSyntaxError( + f'@{annotation_name} method first argument must be called `self`: `{fn.__name__}()`' + ) + + if 'self' in arg_spec.annotations.keys(): + raise BlueprintSyntaxError(f'@{annotation_name} method `self` argument must not be typed: `{fn.__name__}()`') + + +def validate_method_types(fn: Callable) -> None: + """Validate the arg and return types of a callable.""" + special_args = ['self'] + arg_spec = inspect.getfullargspec(fn) + + if 'return' not in arg_spec.annotations: + raise BlueprintSyntaxError(f'missing return type on method `{fn.__name__}`') + + for arg_name in arg_spec.args: + if arg_name in special_args: + continue + + if arg_name not in arg_spec.annotations: + raise BlueprintSyntaxError(f'argument `{arg_name}` on method `{fn.__name__}` must be typed') + + +def validate_has_ctx_arg(fn: Callable, annotation_name: str) -> None: + """Validate the context arg of a callable.""" + arg_spec = inspect.getfullargspec(fn) + + if len(arg_spec.args) < 2: + raise BlueprintSyntaxError( + f'@{annotation_name} method must have `Context` argument: `{fn.__name__}()`' + ) + + from hathorlib.nanocontracts.context import Context + second_arg = arg_spec.args[1] + if arg_spec.annotations[second_arg] is not Context: + raise BlueprintSyntaxError( + f'@{annotation_name} method second arg `{second_arg}` argument must be of type `Context`: ' + f'`{fn.__name__}()`' + ) + + +def validate_has_not_ctx_arg(fn: Callable, annotation_name: str) -> None: + """Validate that a callable doesn't have a `Context` arg.""" + from hathorlib.nanocontracts.context import Context + arg_spec = inspect.getfullargspec(fn) + if Context in arg_spec.annotations.values(): + raise BlueprintSyntaxError(f'@{annotation_name} method cannot have arg with type `Context`: `{fn.__name__}()`') diff --git a/hathorlib/hathorlib/nanocontracts/types.py b/hathorlib/hathorlib/nanocontracts/types.py index 15341ba352..edbc694d00 100644 --- a/hathorlib/hathorlib/nanocontracts/types.py +++ b/hathorlib/hathorlib/nanocontracts/types.py @@ -14,25 +14,527 @@ from __future__ import annotations +import inspect +from dataclasses import dataclass from enum import Enum, unique +from math import floor +from typing import Any, Callable, Generic, Self, TypeAlias, TypeVar -from hathorlib.utils import bytes_to_int, int_to_bytes +from typing_extensions import override + +from hathorlib.conf.settings import HathorSettings +from hathorlib.nanocontracts.blueprint_syntax_validation import ( + validate_has_ctx_arg, + validate_has_not_ctx_arg, + validate_has_self_arg, + validate_method_types, +) +from hathorlib.nanocontracts.exception import BlueprintSyntaxError, NCSerializationError +from hathorlib.nanocontracts.faux_immutable import FauxImmutableMeta +from hathorlib.serialization import SerializationError +from hathorlib.utils.address import decode_address, get_address_b58_from_bytes +from hathorlib.utils.typing import InnerTypeMixin + +# Well-known constant: HTR token UID is always 0x00 +HATHOR_TOKEN_UID: bytes = b'\x00' + +# XXX: mypy gives the following errors on all subclasses of `bytes` that use FauxImmutableMeta: +# +# Metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its +# bases +# +# However `bytes` metaclass appears to be `type` (just like `int`'s) and `FauxImmutableMeta` correctly inherits from +# `type`, so it seems like it's a mypy error. + + +# Types to be used by blueprints. +class Address(bytes, metaclass=FauxImmutableMeta): + __allow_faux_inheritance__ = True + __allow_faux_dunder__ = ('__str__', '__repr__') + __slots__ = () + + @classmethod + def from_str(cls, /, encoded_address: str) -> Self: + if not isinstance(encoded_address, str): + raise TypeError(f'expected `str` instance, got `{type(encoded_address)}` instance') + return cls(decode_address(encoded_address)) + + def __str__(self) -> str: + encoded_address = get_address_b58_from_bytes(self) + return encoded_address + + def __repr__(self) -> str: + encoded_address = str(self) # uses __str__ + # XXX: should we support `Address(encoded_address)` constructor? + return f"Address.from_str({encoded_address!r})" + + +class VertexId(bytes, metaclass=FauxImmutableMeta): + __slots__ = () + __allow_faux_inheritance__ = True + + +class BlueprintId(VertexId): + __slots__ = () + __allow_faux_inheritance__ = True + + +class ContractId(VertexId): + __slots__ = () + __allow_faux_inheritance__ = True + + +class TokenUid(bytes, metaclass=FauxImmutableMeta): + __slots__ = () + __allow_faux_inheritance__ = True + + +class TxOutputScript(bytes, metaclass=FauxImmutableMeta): + __slots__ = () + __allow_faux_inheritance__ = True + + +class Amount(int, metaclass=FauxImmutableMeta): + __slots__ = () + __allow_faux_inheritance__ = True + + +class Timestamp(int, metaclass=FauxImmutableMeta): + __slots__ = () + __allow_faux_inheritance__ = True + + +CallerId: TypeAlias = Address | ContractId + +T = TypeVar('T') +B = TypeVar('B', bound=type) + +NC_INITIALIZE_METHOD: str = 'initialize' +NC_FALLBACK_METHOD: str = 'fallback' + +NC_ALLOWED_ACTIONS_ATTR = '__nc_allowed_actions' +NC_ALLOW_REENTRANCY = '__nc_allow_reentrancy' +NC_METHOD_TYPE_ATTR: str = '__nc_method_type' + +# this is the name we use internally to store the blueprint that is exported by a module +BLUEPRINT_EXPORT_NAME: str = '__blueprint__' + + +class NCMethodType(Enum): + PUBLIC = 'public' + VIEW = 'view' + FALLBACK = 'fallback' + + +def blueprint_id_from_bytes(data: bytes) -> BlueprintId: + """Create a BlueprintId from a bytes object.""" + return BlueprintId(VertexId(data)) + + +class RawSignedData(InnerTypeMixin[T], Generic[T]): + """A wrapper class to sign data. + + T must be serializable. + """ + + def __init__(self, data: T, script_input: bytes) -> None: + from hathorlib.nanocontracts.nc_types import make_nc_type_for_return_type as make_nc_type + self.data = data + self.script_input = script_input + self.__nc_type = make_nc_type(self.__inner_type__) + + def __eq__(self, other): + if not isinstance(other, RawSignedData): + return False + if self.data != other.data: + return False + if self.script_input != other.script_input: + return False + return True + + def get_data_bytes(self) -> bytes: + """Return the serialized data.""" + return self.__nc_type.to_bytes(self.data) + + def get_sighash_all_data(self) -> bytes: + """Workaround to be able to pass `self` for ScriptExtras. See the method `checksig`.""" + return self.get_data_bytes() + + def checksig(self, script: bytes) -> bool: + """Check if `self.script_input` satisfies the provided script.""" + try: + from hathor.transaction.exceptions import ScriptError + from hathor.transaction.scripts import ScriptExtras + from hathor.transaction.scripts.execute import raw_script_eval + from hathor.transaction.scripts.opcode import OpcodesVersion + except ImportError: + raise NotImplementedError('checksig requires hathor-core to be installed') + extras = ScriptExtras(tx=self, version=OpcodesVersion.V2) # type: ignore[arg-type] + try: + raw_script_eval(input_data=self.script_input, output_script=script, extras=extras) + except ScriptError: + return False + else: + return True + + +class SignedData(InnerTypeMixin[T], Generic[T]): + def __init__(self, data: T, script_input: bytes) -> None: + self.data = data + self.script_input = script_input + + def __eq__(self, other): + if not isinstance(other, SignedData): + return False + if self.data != other.data: + return False + if self.script_input != other.script_input: + return False + return True + + def _get_raw_signed_data(self, contract_id: ContractId) -> RawSignedData: + # XXX: for some reason mypy doesn't recognize that self.__inner_type__ is defined even though it should + raw_type: type = tuple[ContractId, self.__inner_type__] # type: ignore[name-defined] + raw_data = (contract_id, self.data) + return RawSignedData[raw_type](raw_data, self.script_input) # type: ignore[valid-type] + + def get_data_bytes(self, contract_id: ContractId) -> bytes: + """Return the serialized data.""" + raw_signed_data = self._get_raw_signed_data(contract_id) + return raw_signed_data.get_data_bytes() + + def checksig(self, contract_id: ContractId, script: bytes) -> bool: + """Check if script_input satisfies the provided script.""" + raw_signed_data = self._get_raw_signed_data(contract_id) + return raw_signed_data.checksig(script) + + +def _set_method_type(fn: Callable, method_type: NCMethodType) -> None: + if hasattr(fn, NC_METHOD_TYPE_ATTR): + raise BlueprintSyntaxError(f'method must be annotated with at most one method type: `{fn.__name__}()`') + setattr(fn, NC_METHOD_TYPE_ATTR, method_type) + + +def _create_decorator_with_allowed_actions( + *, + decorator_body: Callable[[Callable], None], + maybe_fn: Callable | None, + allow_deposit: bool | None, + allow_withdrawal: bool | None, + allow_grant_authority: bool | None, + allow_acquire_authority: bool | None, + allow_actions: list[NCActionType] | None, + allow_reentrancy: bool, +) -> Callable: + """Internal utility to create a decorator that sets allowed actions.""" + flags = { + NCActionType.DEPOSIT: allow_deposit, + NCActionType.WITHDRAWAL: allow_withdrawal, + NCActionType.GRANT_AUTHORITY: allow_grant_authority, + NCActionType.ACQUIRE_AUTHORITY: allow_acquire_authority, + } + + def decorator(fn: Callable) -> Callable: + if allow_actions is not None and any(flag is not None for flag in flags.values()): + raise BlueprintSyntaxError(f'use only one of `allow_actions` or per-action flags: `{fn.__name__}()`') + + allowed_actions = set(allow_actions) if allow_actions else set() + allowed_actions.update(action for action, flag in flags.items() if flag) + setattr(fn, NC_ALLOWED_ACTIONS_ATTR, allowed_actions) + setattr(fn, NC_ALLOW_REENTRANCY, allow_reentrancy) + + decorator_body(fn) + return fn + + if maybe_fn is not None: + return decorator(maybe_fn) + return decorator + + +def public( + maybe_fn: Callable | None = None, + /, + *, + allow_deposit: bool | None = None, + allow_withdrawal: bool | None = None, + allow_grant_authority: bool | None = None, + allow_acquire_authority: bool | None = None, + allow_actions: list[NCActionType] | None = None, + allow_reentrancy: bool = False, +) -> Callable: + """Decorator to mark a blueprint method as public.""" + def decorator(fn: Callable) -> None: + annotation_name = 'public' + forbidden_methods = {NC_FALLBACK_METHOD} + _set_method_type(fn, NCMethodType.PUBLIC) + + if fn.__name__ in forbidden_methods: + raise BlueprintSyntaxError(f'`{fn.__name__}` method cannot be annotated with @{annotation_name}') + + validate_has_self_arg(fn, annotation_name) + validate_method_types(fn) + validate_has_ctx_arg(fn, annotation_name) + + return _create_decorator_with_allowed_actions( + decorator_body=decorator, + maybe_fn=maybe_fn, + allow_deposit=allow_deposit, + allow_withdrawal=allow_withdrawal, + allow_grant_authority=allow_grant_authority, + allow_acquire_authority=allow_acquire_authority, + allow_actions=allow_actions, + allow_reentrancy=allow_reentrancy, + ) + + +def view(fn: Callable) -> Callable: + """Decorator to mark a blueprint method as view (read-only).""" + annotation_name = 'view' + forbidden_methods = {NC_INITIALIZE_METHOD, NC_FALLBACK_METHOD} + _set_method_type(fn, NCMethodType.VIEW) + + if fn.__name__ in forbidden_methods: + raise BlueprintSyntaxError(f'`{fn.__name__}` method cannot be annotated with @{annotation_name}') + + validate_has_self_arg(fn, annotation_name) + validate_has_not_ctx_arg(fn, annotation_name) + validate_method_types(fn) + return fn + + +def export(cls: B) -> B: + """Decorator to export the main Blueprint of a Python module.""" + current_frame = inspect.currentframe() + assert current_frame is not None + module_frame = current_frame.f_back + assert module_frame is not None + module_globals = module_frame.f_globals + if BLUEPRINT_EXPORT_NAME in module_globals: + raise TypeError('A Blueprint has already been registered') + module_globals[BLUEPRINT_EXPORT_NAME] = cls + return cls + + +def fallback( + maybe_fn: Callable | None = None, + /, + *, + allow_deposit: bool | None = None, + allow_withdrawal: bool | None = None, + allow_grant_authority: bool | None = None, + allow_acquire_authority: bool | None = None, + allow_actions: list[NCActionType] | None = None, + allow_reentrancy: bool = False, +) -> Callable: + """Decorator to mark a blueprint method as fallback. The method must also be called `fallback`.""" + def decorator(fn: Callable) -> None: + annotation_name = 'fallback' + _set_method_type(fn, NCMethodType.FALLBACK) + + if fn.__name__ != NC_FALLBACK_METHOD: + raise BlueprintSyntaxError(f'@{annotation_name} method must be called `fallback`: `{fn.__name__}()`') + + validate_has_self_arg(fn, annotation_name) + validate_method_types(fn) + validate_has_ctx_arg(fn, annotation_name) + + arg_spec = inspect.getfullargspec(fn) + msg = f'@{annotation_name} method must have these args: `ctx: Context, method_name: str, nc_args: NCArgs`' + + if len(arg_spec.args) < 4: + raise BlueprintSyntaxError(msg) + + third_arg = arg_spec.args[2] + fourth_arg = arg_spec.args[3] + + if arg_spec.annotations[third_arg] is not str or arg_spec.annotations[fourth_arg] is not NCArgs: + raise BlueprintSyntaxError(msg) + + return _create_decorator_with_allowed_actions( + decorator_body=decorator, + maybe_fn=maybe_fn, + allow_deposit=allow_deposit, + allow_withdrawal=allow_withdrawal, + allow_grant_authority=allow_grant_authority, + allow_acquire_authority=allow_acquire_authority, + allow_actions=allow_actions, + allow_reentrancy=allow_reentrancy, + ) @unique class NCActionType(Enum): - """Types of interactions a transaction might have with a contract.""" + """ + Types of interactions a transaction might have with a contract. + Check the respective dataclasses below for more info. + """ DEPOSIT = 1 WITHDRAWAL = 2 GRANT_AUTHORITY = 3 ACQUIRE_AUTHORITY = 4 def __str__(self) -> str: - return self.name.lower() + return self.name def to_bytes(self) -> bytes: - return int_to_bytes(number=self.value, size=1) + return self.value.to_bytes(1, byteorder='big') @staticmethod def from_bytes(data: bytes) -> NCActionType: - return NCActionType(bytes_to_int(data)) + return NCActionType(int.from_bytes(data, byteorder='big')) + + +@dataclass(slots=True, frozen=True, kw_only=True) +class BaseAction: + """The base dataclass for all NC actions. Shouldn't be instantiated directly.""" + token_uid: TokenUid + + @property + def type(self) -> NCActionType: + """The respective NCActionType for each NCAction.""" + action_types: dict[type[BaseAction], NCActionType] = { + NCDepositAction: NCActionType.DEPOSIT, + NCWithdrawalAction: NCActionType.WITHDRAWAL, + NCGrantAuthorityAction: NCActionType.GRANT_AUTHORITY, + NCAcquireAuthorityAction: NCActionType.ACQUIRE_AUTHORITY, + } + + if action_type := action_types.get(type(self)): + return action_type + + raise NotImplementedError(f'unknown action type {type(self)}') + + @property + def name(self) -> str: + """The action name.""" + return str(self.type) + + def to_json(self) -> dict[str, Any]: + """ + Convert this action to a json dict. + + >>> NCDepositAction(token_uid=TokenUid(b'\x01'), amount=123).to_json() + {'type': 'deposit', 'token_uid': '01', 'amount': 123} + >>> NCWithdrawalAction(token_uid=TokenUid(b'\x01'), amount=123).to_json() + {'type': 'withdrawal', 'token_uid': '01', 'amount': 123} + >>> NCGrantAuthorityAction(token_uid=TokenUid(b'\x01'), mint=True, melt=False).to_json() + {'type': 'grant_authority', 'token_uid': '01', 'mint': True, 'melt': False} + >>> NCAcquireAuthorityAction(token_uid=TokenUid(b'\x01'), mint=False, melt=True).to_json() + {'type': 'acquire_authority', 'token_uid': '01', 'mint': False, 'melt': True} + """ + return dict( + type=self.name.lower(), + token_uid=self.token_uid.hex(), + ) + + +@dataclass(slots=True, frozen=True, kw_only=True) +class BaseTokenAction(BaseAction): + """The base dataclass for all token-related NC actions. Shouldn't be instantiated directly.""" + amount: int + + @override + def to_json(self) -> dict[str, Any]: + json_dict = super(BaseTokenAction, self).to_json() + return dict( + **json_dict, + amount=self.amount, + ) + + +@dataclass(slots=True, frozen=True, kw_only=True) +class BaseAuthorityAction(BaseAction): + """The base dataclass for all authority-related NC actions. Shouldn't be instantiated directly.""" + mint: bool + melt: bool + + def __post_init__(self) -> None: + """Validate the token uid.""" + from hathorlib.nanocontracts.exception import NCInvalidAction + if self.token_uid == HATHOR_TOKEN_UID: + raise NCInvalidAction(f'{self.name} action cannot be executed on HTR token') + + @override + def to_json(self) -> dict[str, Any]: + json_dict = super(BaseAuthorityAction, self).to_json() + return dict( + **json_dict, + mint=self.mint, + melt=self.melt, + ) + + +@dataclass(slots=True, frozen=True, kw_only=True) +class NCDepositAction(BaseTokenAction): + """Deposit tokens into the contract.""" + + +@dataclass(slots=True, frozen=True, kw_only=True) +class NCWithdrawalAction(BaseTokenAction): + """Withdraw tokens from the contract.""" + + +@dataclass(slots=True, frozen=True, kw_only=True) +class NCGrantAuthorityAction(BaseAuthorityAction): + """Grant an authority to the contract.""" + + +@dataclass(slots=True, frozen=True, kw_only=True) +class NCAcquireAuthorityAction(BaseAuthorityAction): + """ + Acquire an authority stored in a contract to create authority outputs or mint/melt tokens in the tx, + or to store and use in a caller contract. + """ + + +"""A sum type representing all possible nano contract actions.""" +NCAction: TypeAlias = ( + NCDepositAction + | NCWithdrawalAction + | NCGrantAuthorityAction + | NCAcquireAuthorityAction +) + + +@dataclass(slots=True, frozen=True) +class NCRawArgs: + args_bytes: bytes + + def __str__(self) -> str: + return self.args_bytes.hex() + + def __repr__(self) -> str: + return f"NCRawArgs('{str(self)}')" + + def try_parse_as(self, arg_types: tuple[type, ...]) -> tuple[Any, ...] | None: + from hathorlib.nanocontracts.method import ArgsOnly + try: + args_parser = ArgsOnly.from_arg_types(arg_types) + return args_parser.deserialize_args_bytes(self.args_bytes) + except (NCSerializationError, SerializationError, TypeError, ValueError): + return None + + +@dataclass(slots=True, frozen=True) +class NCParsedArgs: + args: tuple[Any, ...] + kwargs: dict[str, Any] + + +NCArgs: TypeAlias = NCRawArgs | NCParsedArgs + + +@dataclass(slots=True, frozen=True, kw_only=True) +class NCFee: + """The dataclass for all NC actions fee""" + token_uid: TokenUid + amount: int + + def get_htr_value(self, settings: HathorSettings) -> int: + """ + Get the amount converted to HTR + """ + if self.token_uid == HATHOR_TOKEN_UID: + return self.amount + else: + return floor(abs(settings.TOKEN_DEPOSIT_PERCENTAGE * self.amount)) diff --git a/hathorlib/hathorlib/utils/__init__.py b/hathorlib/hathorlib/utils/__init__.py index c41e75e851..8efb2a5875 100644 --- a/hathorlib/hathorlib/utils/__init__.py +++ b/hathorlib/hathorlib/utils/__init__.py @@ -4,22 +4,29 @@ This source code is licensed under the MIT license found in the LICENSE file in the root directory of this source tree. """ -import hashlib import re import struct -from typing import Any, Tuple, Union, cast +from typing import Any, Tuple, Union -import base58 -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat - -from hathorlib.conf import HathorSettings -from hathorlib.exceptions import InvalidAddress from hathorlib.serialization import Deserializer, SerializationError, Serializer from hathorlib.serialization.adapters import MaxBytesExceededError from hathorlib.serialization.encoding.leb128 import decode_leb128, encode_leb128 -settings = HathorSettings() +# Re-export address utilities from the dedicated module for backward compatibility +from hathorlib.utils.address import ( # noqa: F401 + decode_address, + get_address_b58_from_bytes, + get_address_b58_from_public_key, + get_address_b58_from_public_key_bytes, + get_address_b58_from_public_key_hash, + get_address_b58_from_redeem_script_hash, + get_address_from_public_key_hash, + get_address_from_redeem_script_hash, + get_checksum, + get_hash160, + get_public_key_bytes_compressed, + get_public_key_from_bytes_compressed, +) def int_to_bytes(number: int, size: int, signed: bool = False) -> bytes: @@ -48,119 +55,6 @@ def unpack_len(n: int, buf: bytes) -> Tuple[bytes, bytes]: return buf[:n], buf[n:] -def get_checksum(address_bytes: bytes) -> bytes: - """ Calculate double sha256 of address and gets first 4 bytes - - :param address_bytes: address before checksum - :param address_bytes: bytes - - :return: checksum of the address - :rtype: bytes - """ - return hashlib.sha256(hashlib.sha256(address_bytes).digest()).digest()[:4] - - -def decode_address(address58: str) -> bytes: - """ Decode address in base58 to bytes - - :param address58: Wallet address in base58 - :type address58: string - - :raises InvalidAddress: if address58 is not a valid base58 string or - not a valid address or has invalid checksum - - :return: Address in bytes - :rtype: bytes - """ - try: - decoded_address = base58.b58decode(address58) - except ValueError: - # Invalid base58 string - raise InvalidAddress('Invalid base58 address') - # Validate address size [25 bytes] - if len(decoded_address) != 25: - raise InvalidAddress('Address size must have 25 bytes') - # Validate the checksum - address_checksum = decoded_address[-4:] - valid_checksum = get_checksum(decoded_address[:-4]) - if address_checksum != valid_checksum: - raise InvalidAddress('Invalid checksum of address') - return decoded_address - - -def get_address_b58_from_public_key_hash(public_key_hash: bytes) -> str: - """Gets the b58 address from the hash of a public key. - - :param public_key_hash: hash of public key (sha256 and ripemd160) - :param public_key_hash: bytes - - :return: address in base 58 - :rtype: string - """ - address = get_address_from_public_key_hash(public_key_hash) - return base58.b58encode(address).decode('utf-8') - - -def get_address_from_public_key_hash(public_key_hash: bytes, - version_byte: bytes = settings.P2PKH_VERSION_BYTE) -> bytes: - """Gets the address in bytes from the public key hash - - :param public_key_hash: hash of public key (sha256 and ripemd160) - :param public_key_hash: bytes - - :param version_byte: first byte of address to define the version of this address - :param version_byte: bytes - - :return: address in bytes - :rtype: bytes - """ - address = b'' - # Version byte - address += version_byte - # Pubkey hash - address += public_key_hash - checksum = get_checksum(address) - address += checksum - return address - - -def get_address_b58_from_redeem_script_hash(redeem_script_hash: bytes, - version_byte: bytes = settings.MULTISIG_VERSION_BYTE) -> str: - """Gets the b58 address from the hash of the redeem script in multisig. - - :param redeem_script_hash: hash of the redeem script (sha256 and ripemd160) - :param redeem_script_hash: bytes - - :return: address in base 58 - :rtype: string - """ - address = get_address_from_redeem_script_hash(redeem_script_hash, version_byte) - return base58.b58encode(address).decode('utf-8') - - -def get_address_from_redeem_script_hash(redeem_script_hash: bytes, - version_byte: bytes = settings.MULTISIG_VERSION_BYTE) -> bytes: - """Gets the address in bytes from the redeem script hash - - :param redeem_script_hash: hash of redeem script (sha256 and ripemd160) - :param redeem_script_hash: bytes - - :param version_byte: first byte of address to define the version of this address - :param version_byte: bytes - - :return: address in bytes - :rtype: bytes - """ - address = b'' - # Version byte - address += version_byte - # redeem script hash - address += redeem_script_hash - checksum = get_checksum(address) - address += checksum - return address - - def clean_token_string(string: str) -> str: """ Receives the token name/symbol and returns it after some cleanups. It sets to uppercase, removes double spaces and spaces at the beginning and end. @@ -168,48 +62,6 @@ def clean_token_string(string: str) -> str: return re.sub(r'\s\s+', ' ', string).strip().upper() -def get_public_key_from_bytes_compressed(public_key_bytes: bytes) -> ec.EllipticCurvePublicKey: - """Return the cryptography public key from the compressed bytes format.""" - return ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256K1(), public_key_bytes) - - -def get_address_b58_from_public_key(public_key: ec.EllipticCurvePublicKey) -> str: - """Get the b58 address from a public key.""" - public_key_bytes = get_public_key_bytes_compressed(public_key) - return get_address_b58_from_public_key_bytes(public_key_bytes) - - -def get_address_b58_from_public_key_bytes(public_key_bytes: bytes) -> str: - """Get the b58 address from a public key bytes.""" - public_key_hash = get_hash160(public_key_bytes) - return get_address_b58_from_public_key_hash(public_key_hash) - - -def get_public_key_bytes_compressed(public_key: ec.EllipticCurvePublicKey) -> bytes: - """Return the bytes of a pubkey in the compressed format.""" - return public_key.public_bytes(Encoding.X962, PublicFormat.CompressedPoint) - - -try: - hashlib.new('ripemd160', b'') -except Exception: - # XXX: the source says "Test-only pure Python RIPEMD160 implementation", however for our case this is acceptable - # for more details see: https://github.com/bitcoin/bitcoin/pull/23716/files which has a copy of the same code - import pycoin.contrib.ripemd160 # type: ignore[import-untyped] - - def get_hash160(public_key_bytes: bytes) -> bytes: - """The input is hashed twice: first with SHA-256 and then with RIPEMD-160""" - key_hash = hashlib.sha256(public_key_bytes) - return cast(bytes, pycoin.contrib.ripemd160.ripemd160(key_hash.digest())) -else: - def get_hash160(public_key_bytes: bytes) -> bytes: - """The input is hashed twice: first with SHA-256 and then with RIPEMD-160""" - key_hash = hashlib.sha256(public_key_bytes) - h = hashlib.new('ripemd160') - h.update(key_hash.digest()) - return h.digest() - - def encode_signed(value: int, *, max_bytes: Union[int, None] = None) -> bytes: """ Receive a signed integer and return its LEB128-encoded bytes. diff --git a/hathorlib/hathorlib/utils/address.py b/hathorlib/hathorlib/utils/address.py new file mode 100644 index 0000000000..408ed72e37 --- /dev/null +++ b/hathorlib/hathorlib/utils/address.py @@ -0,0 +1,184 @@ +""" +Copyright (c) Hathor Labs and its affiliates. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +""" +import hashlib +from typing import Union, cast + +import base58 +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + +from hathorlib.conf import HathorSettings +from hathorlib.exceptions import InvalidAddress + +settings = HathorSettings() + + +def get_checksum(address_bytes: bytes) -> bytes: + """ Calculate double sha256 of address and gets first 4 bytes + + :param address_bytes: address before checksum + :param address_bytes: bytes + + :return: checksum of the address + :rtype: bytes + """ + return hashlib.sha256(hashlib.sha256(address_bytes).digest()).digest()[:4] + + +def decode_address(address58: str) -> bytes: + """ Decode address in base58 to bytes + + :param address58: Wallet address in base58 + :type address58: string + + :raises InvalidAddress: if address58 is not a valid base58 string or + not a valid address or has invalid checksum + + :return: Address in bytes + :rtype: bytes + """ + try: + decoded_address = base58.b58decode(address58) + except ValueError: + # Invalid base58 string + raise InvalidAddress('Invalid base58 address') + # Validate address size [25 bytes] + if len(decoded_address) != 25: + raise InvalidAddress('Address size must have 25 bytes') + # Validate the checksum + address_checksum = decoded_address[-4:] + valid_checksum = get_checksum(decoded_address[:-4]) + if address_checksum != valid_checksum: + raise InvalidAddress('Invalid checksum of address') + return decoded_address + + +def get_address_b58_from_bytes(address: bytes) -> str: + """Encode address bytes to base58 string. + + :param address: address in bytes + :type address: bytes + + :return: address in base 58 + :rtype: string + """ + return base58.b58encode(address).decode('utf-8') + + +def get_address_b58_from_public_key_hash(public_key_hash: bytes) -> str: + """Gets the b58 address from the hash of a public key. + + :param public_key_hash: hash of public key (sha256 and ripemd160) + :param public_key_hash: bytes + + :return: address in base 58 + :rtype: string + """ + address = get_address_from_public_key_hash(public_key_hash) + return base58.b58encode(address).decode('utf-8') + + +def get_address_from_public_key_hash(public_key_hash: bytes, + version_byte: bytes = settings.P2PKH_VERSION_BYTE) -> bytes: + """Gets the address in bytes from the public key hash + + :param public_key_hash: hash of public key (sha256 and ripemd160) + :param public_key_hash: bytes + + :param version_byte: first byte of address to define the version of this address + :param version_byte: bytes + + :return: address in bytes + :rtype: bytes + """ + address = b'' + # Version byte + address += version_byte + # Pubkey hash + address += public_key_hash + checksum = get_checksum(address) + address += checksum + return address + + +def get_address_b58_from_redeem_script_hash(redeem_script_hash: bytes, + version_byte: bytes = settings.MULTISIG_VERSION_BYTE) -> str: + """Gets the b58 address from the hash of the redeem script in multisig. + + :param redeem_script_hash: hash of the redeem script (sha256 and ripemd160) + :param redeem_script_hash: bytes + + :return: address in base 58 + :rtype: string + """ + address = get_address_from_redeem_script_hash(redeem_script_hash, version_byte) + return base58.b58encode(address).decode('utf-8') + + +def get_address_from_redeem_script_hash(redeem_script_hash: bytes, + version_byte: bytes = settings.MULTISIG_VERSION_BYTE) -> bytes: + """Gets the address in bytes from the redeem script hash + + :param redeem_script_hash: hash of redeem script (sha256 and ripemd160) + :param redeem_script_hash: bytes + + :param version_byte: first byte of address to define the version of this address + :param version_byte: bytes + + :return: address in bytes + :rtype: bytes + """ + address = b'' + # Version byte + address += version_byte + # redeem script hash + address += redeem_script_hash + checksum = get_checksum(address) + address += checksum + return address + + +def get_public_key_from_bytes_compressed(public_key_bytes: bytes) -> ec.EllipticCurvePublicKey: + """Return the cryptography public key from the compressed bytes format.""" + return ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256K1(), public_key_bytes) + + +def get_address_b58_from_public_key(public_key: ec.EllipticCurvePublicKey) -> str: + """Get the b58 address from a public key.""" + public_key_bytes = get_public_key_bytes_compressed(public_key) + return get_address_b58_from_public_key_bytes(public_key_bytes) + + +def get_address_b58_from_public_key_bytes(public_key_bytes: bytes) -> str: + """Get the b58 address from a public key bytes.""" + public_key_hash = get_hash160(public_key_bytes) + return get_address_b58_from_public_key_hash(public_key_hash) + + +def get_public_key_bytes_compressed(public_key: ec.EllipticCurvePublicKey) -> bytes: + """Return the bytes of a pubkey in the compressed format.""" + return public_key.public_bytes(Encoding.X962, PublicFormat.CompressedPoint) + + +try: + hashlib.new('ripemd160', b'') +except Exception: + # XXX: the source says "Test-only pure Python RIPEMD160 implementation", however for our case this is acceptable + # for more details see: https://github.com/bitcoin/bitcoin/pull/23716/files which has a copy of the same code + import pycoin.contrib.ripemd160 # type: ignore[import-untyped] + + def get_hash160(public_key_bytes: bytes) -> bytes: + """The input is hashed twice: first with SHA-256 and then with RIPEMD-160""" + key_hash = hashlib.sha256(public_key_bytes) + return cast(bytes, pycoin.contrib.ripemd160.ripemd160(key_hash.digest())) +else: + def get_hash160(public_key_bytes: bytes) -> bytes: + """The input is hashed twice: first with SHA-256 and then with RIPEMD-160""" + key_hash = hashlib.sha256(public_key_bytes) + h = hashlib.new('ripemd160') + h.update(key_hash.digest()) + return h.digest() From 615a961a0580b900df1f47c881759589dd72ad8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Thu, 26 Feb 2026 13:28:49 -0300 Subject: [PATCH 03/16] feat: checksig implementation --- hathor/nanocontracts/types.py | 23 +++++++++++++ hathor/transaction/util.py | 12 +++---- .../blueprint_syntax_validation.py | 23 +++++++++++++ hathorlib/hathorlib/nanocontracts/types.py | 33 ++++++++++--------- hathorlib/hathorlib/utils/__init__.py | 14 +++++++- 5 files changed, 81 insertions(+), 24 deletions(-) diff --git a/hathor/nanocontracts/types.py b/hathor/nanocontracts/types.py index 75bf6b237a..1a0d90f0eb 100644 --- a/hathor/nanocontracts/types.py +++ b/hathor/nanocontracts/types.py @@ -50,5 +50,28 @@ export, fallback, public, + set_checksig_backend, view, ) + + +def _checksig_impl(sighash_all_data: bytes, script_input: bytes, script: bytes) -> bool: + from hathor.transaction.exceptions import ScriptError + from hathor.transaction.scripts import ScriptExtras + from hathor.transaction.scripts.execute import raw_script_eval + from hathor.transaction.scripts.opcode import OpcodesVersion + + class _FakeTx: + def get_sighash_all_data(self) -> bytes: + return sighash_all_data + + extras = ScriptExtras(tx=_FakeTx(), version=OpcodesVersion.V2) # type: ignore[arg-type] + try: + raw_script_eval(input_data=script_input, output_script=script, extras=extras) + except ScriptError: + return False + else: + return True + + +set_checksig_backend(_checksig_impl) diff --git a/hathor/transaction/util.py b/hathor/transaction/util.py index bbeabd9673..0e03017d51 100644 --- a/hathor/transaction/util.py +++ b/hathor/transaction/util.py @@ -16,11 +16,14 @@ import re import struct -from math import ceil, floor from struct import error as StructError from typing import TYPE_CHECKING, Any, Callable, Optional from hathor.transaction.exceptions import InvalidFeeAmount, InvalidOutputValue, TransactionDataError +from hathorlib.utils import ( # noqa: F401 + get_deposit_token_deposit_amount, + get_deposit_token_withdraw_amount, +) if TYPE_CHECKING: from hathor import TokenUid @@ -56,13 +59,6 @@ def unpack_len(n: int, buf: bytes | memoryview) -> tuple[bytes, bytes | memoryvi return ret, buf[n:] -def get_deposit_token_deposit_amount(settings: HathorSettings, mint_amount: int) -> int: - return ceil(abs(settings.TOKEN_DEPOSIT_PERCENTAGE * mint_amount)) - - -def get_deposit_token_withdraw_amount(settings: HathorSettings, melt_amount: int) -> int: - return floor(abs(settings.TOKEN_DEPOSIT_PERCENTAGE * melt_amount)) - def clean_token_string(string: str) -> str: """ Receives the token name/symbol and returns it after some cleanups. diff --git a/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py b/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py index 25c2ef5a76..5abcd239e1 100644 --- a/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py +++ b/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py @@ -43,6 +43,18 @@ def validate_method_types(fn: Callable) -> None: if 'return' not in arg_spec.annotations: raise BlueprintSyntaxError(f'missing return type on method `{fn.__name__}`') + # TODO: This currently fails for types such as unions, probably because this is the wrong + # parsing function to use. Fix this. + # from hathorlib.nanocontracts.fields import get_field_class_for_attr + # return_type = arg_spec.annotations['return'] + # if return_type is not None: + # try: + # get_field_class_for_attr(return_type) + # except UnknownFieldType: + # raise BlueprintSyntaxError( + # f'unsupported return type `{return_type}` on method `{fn.__name__}`' + # ) + for arg_name in arg_spec.args: if arg_name in special_args: continue @@ -50,6 +62,17 @@ def validate_method_types(fn: Callable) -> None: if arg_name not in arg_spec.annotations: raise BlueprintSyntaxError(f'argument `{arg_name}` on method `{fn.__name__}` must be typed') + # TODO: This currently fails for @view methods with NamedTuple as args for example, + # because API calls use a different parsing function. Fix this. + # arg_type = arg_spec.annotations[arg_name] + # try: + # from hathorlib.nanocontracts.fields import get_field_class_for_attr + # get_field_class_for_attr(arg_type) + # except UnknownFieldType: + # raise BlueprintSyntaxError( + # f'unsupported type `{arg_type.__name__}` on argument `{arg_name}` of method `{fn.__name__}`' + # ) + def validate_has_ctx_arg(fn: Callable, annotation_name: str) -> None: """Validate the context arg of a callable.""" diff --git a/hathorlib/hathorlib/nanocontracts/types.py b/hathorlib/hathorlib/nanocontracts/types.py index edbc694d00..2744e89c24 100644 --- a/hathorlib/hathorlib/nanocontracts/types.py +++ b/hathorlib/hathorlib/nanocontracts/types.py @@ -17,7 +17,6 @@ import inspect from dataclasses import dataclass from enum import Enum, unique -from math import floor from typing import Any, Callable, Generic, Self, TypeAlias, TypeVar from typing_extensions import override @@ -32,6 +31,7 @@ from hathorlib.nanocontracts.exception import BlueprintSyntaxError, NCSerializationError from hathorlib.nanocontracts.faux_immutable import FauxImmutableMeta from hathorlib.serialization import SerializationError +from hathorlib.utils import get_deposit_token_withdraw_amount from hathorlib.utils.address import decode_address, get_address_b58_from_bytes from hathorlib.utils.typing import InnerTypeMixin @@ -131,6 +131,20 @@ def blueprint_id_from_bytes(data: bytes) -> BlueprintId: return BlueprintId(VertexId(data)) +# Injectable backend for RawSignedData.checksig. +# hathorlib raises NotImplementedError by default; hathor-core registers the real implementation. +_checksig_backend: Callable[[bytes, bytes, bytes], bool] | None = None + + +def set_checksig_backend(fn: Callable[[bytes, bytes, bytes], bool]) -> None: + """Register the checksig implementation. Must be called by hathor-core during initialization. + + The callable receives (sighash_all_data: bytes, script_input: bytes, script: bytes) -> bool. + """ + global _checksig_backend + _checksig_backend = fn + + class RawSignedData(InnerTypeMixin[T], Generic[T]): """A wrapper class to sign data. @@ -162,20 +176,9 @@ def get_sighash_all_data(self) -> bytes: def checksig(self, script: bytes) -> bool: """Check if `self.script_input` satisfies the provided script.""" - try: - from hathor.transaction.exceptions import ScriptError - from hathor.transaction.scripts import ScriptExtras - from hathor.transaction.scripts.execute import raw_script_eval - from hathor.transaction.scripts.opcode import OpcodesVersion - except ImportError: + if _checksig_backend is None: raise NotImplementedError('checksig requires hathor-core to be installed') - extras = ScriptExtras(tx=self, version=OpcodesVersion.V2) # type: ignore[arg-type] - try: - raw_script_eval(input_data=self.script_input, output_script=script, extras=extras) - except ScriptError: - return False - else: - return True + return _checksig_backend(self.get_sighash_all_data(), self.script_input, script) class SignedData(InnerTypeMixin[T], Generic[T]): @@ -537,4 +540,4 @@ def get_htr_value(self, settings: HathorSettings) -> int: if self.token_uid == HATHOR_TOKEN_UID: return self.amount else: - return floor(abs(settings.TOKEN_DEPOSIT_PERCENTAGE * self.amount)) + return get_deposit_token_withdraw_amount(settings, self.amount) diff --git a/hathorlib/hathorlib/utils/__init__.py b/hathorlib/hathorlib/utils/__init__.py index 8efb2a5875..75e5157447 100644 --- a/hathorlib/hathorlib/utils/__init__.py +++ b/hathorlib/hathorlib/utils/__init__.py @@ -6,7 +6,11 @@ """ import re import struct -from typing import Any, Tuple, Union +from math import ceil, floor +from typing import TYPE_CHECKING, Any, Tuple, Union + +if TYPE_CHECKING: + from hathorlib.conf.settings import HathorSettings from hathorlib.serialization import Deserializer, SerializationError, Serializer from hathorlib.serialization.adapters import MaxBytesExceededError @@ -160,3 +164,11 @@ def decode_unsigned(data: bytes, *, max_bytes: Union[int, None] = None) -> tuple remaining_data = bytes(deserializer.read_all()) deserializer.finalize() return (value, remaining_data) + + +def get_deposit_token_deposit_amount(settings: 'HathorSettings', mint_amount: int) -> int: + return ceil(abs(settings.TOKEN_DEPOSIT_PERCENTAGE * mint_amount)) + + +def get_deposit_token_withdraw_amount(settings: 'HathorSettings', melt_amount: int) -> int: + return floor(abs(settings.TOKEN_DEPOSIT_PERCENTAGE * melt_amount)) From 6570205c562b07d89b7135802e84072efb3ea878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Thu, 26 Feb 2026 14:36:30 -0300 Subject: [PATCH 04/16] chore: fix mypy issues --- hathor/nanocontracts/exception.py | 5 ++--- hathor/nanocontracts/types.py | 2 +- hathor/transaction/util.py | 6 +----- hathor_tests/nanocontracts/test_exceptions.py | 3 ++- hathorlib/hathorlib/nanocontracts/context.py | 16 ++++++++++++++++ hathorlib/hathorlib/nanocontracts/method.py | 16 ++++++++++++++++ hathorlib/hathorlib/nanocontracts/nc_types.py | 16 ++++++++++++++++ hathorlib/hathorlib/utils/__init__.py | 1 - hathorlib/hathorlib/utils/address.py | 2 +- hathorlib/pyproject.toml | 1 + 10 files changed, 56 insertions(+), 12 deletions(-) create mode 100644 hathorlib/hathorlib/nanocontracts/context.py create mode 100644 hathorlib/hathorlib/nanocontracts/method.py create mode 100644 hathorlib/hathorlib/nanocontracts/nc_types.py diff --git a/hathor/nanocontracts/exception.py b/hathor/nanocontracts/exception.py index 9cb0695ecf..45a9bbba70 100644 --- a/hathor/nanocontracts/exception.py +++ b/hathor/nanocontracts/exception.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from hathor.transaction.exceptions import TxValidationError # Re-export all exceptions from hathorlib for backward compatibility from hathorlib.nanocontracts.exception import ( # noqa: F401 BlueprintDoesNotExist, @@ -28,8 +29,8 @@ NCForbiddenReentrancy, NCInsufficientFunds, NCInvalidAction, - NCInvalidContractId, NCInvalidContext, + NCInvalidContractId, NCInvalidFee, NCInvalidFeePaymentToken, NCInvalidInitializeMethodCall, @@ -53,8 +54,6 @@ UnknownFieldType, ) -from hathor.transaction.exceptions import TxValidationError - # hathor-specific exceptions that depend on TxValidationError diff --git a/hathor/nanocontracts/types.py b/hathor/nanocontracts/types.py index 1a0d90f0eb..1ba911b56c 100644 --- a/hathor/nanocontracts/types.py +++ b/hathor/nanocontracts/types.py @@ -29,9 +29,9 @@ BlueprintId, CallerId, ContractId, + NCAcquireAuthorityAction, NCAction, NCActionType, - NCAcquireAuthorityAction, NCArgs, NCDepositAction, NCFee, diff --git a/hathor/transaction/util.py b/hathor/transaction/util.py index 0e03017d51..74d21f999f 100644 --- a/hathor/transaction/util.py +++ b/hathor/transaction/util.py @@ -20,10 +20,7 @@ from typing import TYPE_CHECKING, Any, Callable, Optional from hathor.transaction.exceptions import InvalidFeeAmount, InvalidOutputValue, TransactionDataError -from hathorlib.utils import ( # noqa: F401 - get_deposit_token_deposit_amount, - get_deposit_token_withdraw_amount, -) +from hathorlib.utils import get_deposit_token_deposit_amount, get_deposit_token_withdraw_amount # noqa: F401 if TYPE_CHECKING: from hathor import TokenUid @@ -59,7 +56,6 @@ def unpack_len(n: int, buf: bytes | memoryview) -> tuple[bytes, bytes | memoryvi return ret, buf[n:] - def clean_token_string(string: str) -> str: """ Receives the token name/symbol and returns it after some cleanups. It sets to uppercase, removes double spaces and spaces at the beginning and end. diff --git a/hathor_tests/nanocontracts/test_exceptions.py b/hathor_tests/nanocontracts/test_exceptions.py index 2b0535fde9..53a21edcad 100644 --- a/hathor_tests/nanocontracts/test_exceptions.py +++ b/hathor_tests/nanocontracts/test_exceptions.py @@ -17,10 +17,11 @@ class TestExceptions(unittest.TestCase): def test_inherit_from_nc_fail(self) -> None: + from hathor.exception import HathorError from hathor.nanocontracts import exception as nano_exceptions skip = { - nano_exceptions.HathorError, + HathorError, nano_exceptions.NCFail, nano_exceptions.TxValidationError, } diff --git a/hathorlib/hathorlib/nanocontracts/context.py b/hathorlib/hathorlib/nanocontracts/context.py new file mode 100644 index 0000000000..98ddd12351 --- /dev/null +++ b/hathorlib/hathorlib/nanocontracts/context.py @@ -0,0 +1,16 @@ +# Copyright 2026 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. + +# mypy: disable-error-code="import-not-found" +from hathor.nanocontracts.context import Context as Context # noqa: F401 diff --git a/hathorlib/hathorlib/nanocontracts/method.py b/hathorlib/hathorlib/nanocontracts/method.py new file mode 100644 index 0000000000..8a3141f10e --- /dev/null +++ b/hathorlib/hathorlib/nanocontracts/method.py @@ -0,0 +1,16 @@ +# Copyright 2026 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. + +# mypy: disable-error-code="import-not-found" +from hathor.nanocontracts.method import ArgsOnly # noqa: F401 diff --git a/hathorlib/hathorlib/nanocontracts/nc_types.py b/hathorlib/hathorlib/nanocontracts/nc_types.py new file mode 100644 index 0000000000..b529f35f45 --- /dev/null +++ b/hathorlib/hathorlib/nanocontracts/nc_types.py @@ -0,0 +1,16 @@ +# Copyright 2026 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. + +# mypy: disable-error-code="import-not-found" +from hathor.nanocontracts.nc_types import make_nc_type_for_return_type # noqa: F401 diff --git a/hathorlib/hathorlib/utils/__init__.py b/hathorlib/hathorlib/utils/__init__.py index 75e5157447..6e0ee70e03 100644 --- a/hathorlib/hathorlib/utils/__init__.py +++ b/hathorlib/hathorlib/utils/__init__.py @@ -15,7 +15,6 @@ from hathorlib.serialization import Deserializer, SerializationError, Serializer from hathorlib.serialization.adapters import MaxBytesExceededError from hathorlib.serialization.encoding.leb128 import decode_leb128, encode_leb128 - # Re-export address utilities from the dedicated module for backward compatibility from hathorlib.utils.address import ( # noqa: F401 decode_address, diff --git a/hathorlib/hathorlib/utils/address.py b/hathorlib/hathorlib/utils/address.py index 408ed72e37..efd42bab15 100644 --- a/hathorlib/hathorlib/utils/address.py +++ b/hathorlib/hathorlib/utils/address.py @@ -5,7 +5,7 @@ LICENSE file in the root directory of this source tree. """ import hashlib -from typing import Union, cast +from typing import cast import base58 from cryptography.hazmat.primitives.asymmetric import ec diff --git a/hathorlib/pyproject.toml b/hathorlib/pyproject.toml index 4f2b639e1d..dc3440c120 100644 --- a/hathorlib/pyproject.toml +++ b/hathorlib/pyproject.toml @@ -85,6 +85,7 @@ module = [ ] check_untyped_defs = false disallow_untyped_defs = false +warn_return_any = false [tool.pytest.ini_options] minversion = "6.0" From 62df017ad1531a4428f964bd5147ca3dd0d162bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Sat, 28 Feb 2026 08:46:16 -0300 Subject: [PATCH 05/16] feat: only use lazy imports --- .../nanocontracts/blueprint_syntax_validation.py | 4 ++-- hathorlib/hathorlib/nanocontracts/context.py | 16 ---------------- hathorlib/hathorlib/nanocontracts/method.py | 16 ---------------- hathorlib/hathorlib/nanocontracts/nc_types.py | 16 ---------------- hathorlib/hathorlib/nanocontracts/types.py | 6 ++++-- hathorlib/pyproject.toml | 1 + 6 files changed, 7 insertions(+), 52 deletions(-) delete mode 100644 hathorlib/hathorlib/nanocontracts/context.py delete mode 100644 hathorlib/hathorlib/nanocontracts/method.py delete mode 100644 hathorlib/hathorlib/nanocontracts/nc_types.py diff --git a/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py b/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py index 5abcd239e1..a907875f46 100644 --- a/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py +++ b/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py @@ -83,7 +83,7 @@ def validate_has_ctx_arg(fn: Callable, annotation_name: str) -> None: f'@{annotation_name} method must have `Context` argument: `{fn.__name__}()`' ) - from hathorlib.nanocontracts.context import Context + from hathor.nanocontracts.context import Context second_arg = arg_spec.args[1] if arg_spec.annotations[second_arg] is not Context: raise BlueprintSyntaxError( @@ -94,7 +94,7 @@ def validate_has_ctx_arg(fn: Callable, annotation_name: str) -> None: def validate_has_not_ctx_arg(fn: Callable, annotation_name: str) -> None: """Validate that a callable doesn't have a `Context` arg.""" - from hathorlib.nanocontracts.context import Context + from hathor.nanocontracts.context import Context arg_spec = inspect.getfullargspec(fn) if Context in arg_spec.annotations.values(): raise BlueprintSyntaxError(f'@{annotation_name} method cannot have arg with type `Context`: `{fn.__name__}()`') diff --git a/hathorlib/hathorlib/nanocontracts/context.py b/hathorlib/hathorlib/nanocontracts/context.py deleted file mode 100644 index 98ddd12351..0000000000 --- a/hathorlib/hathorlib/nanocontracts/context.py +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright 2026 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. - -# mypy: disable-error-code="import-not-found" -from hathor.nanocontracts.context import Context as Context # noqa: F401 diff --git a/hathorlib/hathorlib/nanocontracts/method.py b/hathorlib/hathorlib/nanocontracts/method.py deleted file mode 100644 index 8a3141f10e..0000000000 --- a/hathorlib/hathorlib/nanocontracts/method.py +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright 2026 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. - -# mypy: disable-error-code="import-not-found" -from hathor.nanocontracts.method import ArgsOnly # noqa: F401 diff --git a/hathorlib/hathorlib/nanocontracts/nc_types.py b/hathorlib/hathorlib/nanocontracts/nc_types.py deleted file mode 100644 index b529f35f45..0000000000 --- a/hathorlib/hathorlib/nanocontracts/nc_types.py +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright 2026 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. - -# mypy: disable-error-code="import-not-found" -from hathor.nanocontracts.nc_types import make_nc_type_for_return_type # noqa: F401 diff --git a/hathorlib/hathorlib/nanocontracts/types.py b/hathorlib/hathorlib/nanocontracts/types.py index 2744e89c24..eb3d9bd4b3 100644 --- a/hathorlib/hathorlib/nanocontracts/types.py +++ b/hathorlib/hathorlib/nanocontracts/types.py @@ -152,7 +152,8 @@ class RawSignedData(InnerTypeMixin[T], Generic[T]): """ def __init__(self, data: T, script_input: bytes) -> None: - from hathorlib.nanocontracts.nc_types import make_nc_type_for_return_type as make_nc_type + # mypy: disable-error-code="import-not-found" + from hathor.nanocontracts.nc_types import make_nc_type_for_return_type as make_nc_type self.data = data self.script_input = script_input self.__nc_type = make_nc_type(self.__inner_type__) @@ -510,7 +511,8 @@ def __repr__(self) -> str: return f"NCRawArgs('{str(self)}')" def try_parse_as(self, arg_types: tuple[type, ...]) -> tuple[Any, ...] | None: - from hathorlib.nanocontracts.method import ArgsOnly + # mypy: disable-error-code="import-not-found" + from hathor.nanocontracts.method import ArgsOnly try: args_parser = ArgsOnly.from_arg_types(arg_types) return args_parser.deserialize_args_bytes(self.args_bytes) diff --git a/hathorlib/pyproject.toml b/hathorlib/pyproject.toml index dc3440c120..1af7f9d991 100644 --- a/hathorlib/pyproject.toml +++ b/hathorlib/pyproject.toml @@ -86,6 +86,7 @@ module = [ check_untyped_defs = false disallow_untyped_defs = false warn_return_any = false +ignore_missing_imports = true [tool.pytest.ini_options] minversion = "6.0" From 1b41fa33fce260de13dbd0e8aca3945fe6ae7e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Sat, 28 Feb 2026 10:31:50 -0300 Subject: [PATCH 06/16] feat: ignore missing imports --- .../hathorlib/nanocontracts/blueprint_syntax_validation.py | 4 ++-- hathorlib/hathorlib/nanocontracts/types.py | 7 +++++-- hathorlib/pyproject.toml | 3 ++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py b/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py index a907875f46..cf77ad2362 100644 --- a/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py +++ b/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py @@ -83,7 +83,7 @@ def validate_has_ctx_arg(fn: Callable, annotation_name: str) -> None: f'@{annotation_name} method must have `Context` argument: `{fn.__name__}()`' ) - from hathor.nanocontracts.context import Context + from hathor.nanocontracts.context import Context # type: ignore second_arg = arg_spec.args[1] if arg_spec.annotations[second_arg] is not Context: raise BlueprintSyntaxError( @@ -94,7 +94,7 @@ def validate_has_ctx_arg(fn: Callable, annotation_name: str) -> None: def validate_has_not_ctx_arg(fn: Callable, annotation_name: str) -> None: """Validate that a callable doesn't have a `Context` arg.""" - from hathor.nanocontracts.context import Context + from hathor.nanocontracts.context import Context # type: ignore[import-not-found] arg_spec = inspect.getfullargspec(fn) if Context in arg_spec.annotations.values(): raise BlueprintSyntaxError(f'@{annotation_name} method cannot have arg with type `Context`: `{fn.__name__}()`') diff --git a/hathorlib/hathorlib/nanocontracts/types.py b/hathorlib/hathorlib/nanocontracts/types.py index eb3d9bd4b3..9d69c53cbe 100644 --- a/hathorlib/hathorlib/nanocontracts/types.py +++ b/hathorlib/hathorlib/nanocontracts/types.py @@ -153,7 +153,10 @@ class RawSignedData(InnerTypeMixin[T], Generic[T]): def __init__(self, data: T, script_input: bytes) -> None: # mypy: disable-error-code="import-not-found" - from hathor.nanocontracts.nc_types import make_nc_type_for_return_type as make_nc_type + from hathor.nanocontracts.nc_types import ( # type: ignore[import-not-found] + make_nc_type_for_return_type as make_nc_type, + ) + self.data = data self.script_input = script_input self.__nc_type = make_nc_type(self.__inner_type__) @@ -512,7 +515,7 @@ def __repr__(self) -> str: def try_parse_as(self, arg_types: tuple[type, ...]) -> tuple[Any, ...] | None: # mypy: disable-error-code="import-not-found" - from hathor.nanocontracts.method import ArgsOnly + from hathor.nanocontracts.method import ArgsOnly # type: ignore[import-not-found] try: args_parser = ArgsOnly.from_arg_types(arg_types) return args_parser.deserialize_args_bytes(self.args_bytes) diff --git a/hathorlib/pyproject.toml b/hathorlib/pyproject.toml index 1af7f9d991..65fbab5b5b 100644 --- a/hathorlib/pyproject.toml +++ b/hathorlib/pyproject.toml @@ -80,13 +80,14 @@ plugins = [ [[tool.mypy.overrides]] module = [ + "hathorlib.nanocontracts.method", "hathorlib.nanocontracts.*", "hathorlib.utils.*", ] check_untyped_defs = false disallow_untyped_defs = false warn_return_any = false -ignore_missing_imports = true +warn_unused_ignores = false [tool.pytest.ini_options] minversion = "6.0" From a2a1c31e829ad657e1b01cfd05c895001f92c16c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Sat, 28 Feb 2026 11:41:52 -0300 Subject: [PATCH 07/16] feat: move HathorError to hathorlib --- hathor/exception.py | 5 +---- hathor_tests/nanocontracts/test_nc_exec_logs.py | 4 ++-- hathorlib/hathorlib/exceptions.py | 3 ++- hathorlib/hathorlib/nanocontracts/exception.py | 4 +++- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/hathor/exception.py b/hathor/exception.py index 1d3d425473..eafc98c1fc 100644 --- a/hathor/exception.py +++ b/hathor/exception.py @@ -12,10 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. - -class HathorError(Exception): - """Base class for exceptions in Hathor.""" - pass +from hathorlib.exceptions import HathorError # noqa: F401 class BuilderError(Exception): diff --git a/hathor_tests/nanocontracts/test_nc_exec_logs.py b/hathor_tests/nanocontracts/test_nc_exec_logs.py index 7b6dfb386f..87d06854b3 100644 --- a/hathor_tests/nanocontracts/test_nc_exec_logs.py +++ b/hathor_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('hathorlib.nanocontracts.exception.NCFail: 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('hathorlib.nanocontracts.exception.NCFail\n') def test_reexecution_on_reorgs(self) -> None: self._prepare() diff --git a/hathorlib/hathorlib/exceptions.py b/hathorlib/hathorlib/exceptions.py index 7c525de105..933888f203 100644 --- a/hathorlib/hathorlib/exceptions.py +++ b/hathorlib/hathorlib/exceptions.py @@ -16,7 +16,8 @@ class HathorError(Exception): - """General error class""" + """Base class for exceptions in Hathor.""" + pass class InvalidAddress(HathorError): diff --git a/hathorlib/hathorlib/nanocontracts/exception.py b/hathorlib/hathorlib/nanocontracts/exception.py index bf05a39048..d99216777e 100644 --- a/hathorlib/hathorlib/nanocontracts/exception.py +++ b/hathorlib/hathorlib/nanocontracts/exception.py @@ -17,8 +17,10 @@ correctly caught by the block consensus to fail NC transactions. """ +from hathorlib.exceptions import HathorError -class NCFail(Exception): + +class NCFail(HathorError): """Raised by Blueprint's methods to fail execution.""" From 729d0dbf2e7dba602fb997094b57e0762292249a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Tue, 3 Mar 2026 14:44:15 -0300 Subject: [PATCH 08/16] feat: review changes --- hathor/crypto/util.py | 53 ++----------------- hathor/transaction/scripts/opcode.py | 2 + .../blueprint_syntax_validation.py | 4 +- hathorlib/hathorlib/nanocontracts/types.py | 10 ++-- hathorlib/hathorlib/utils/address.py | 9 ++-- hathorlib/pyproject.toml | 3 -- 6 files changed, 21 insertions(+), 60 deletions(-) diff --git a/hathor/crypto/util.py b/hathor/crypto/util.py index dbb36783bf..dc35f86c4f 100644 --- a/hathor/crypto/util.py +++ b/hathor/crypto/util.py @@ -29,6 +29,11 @@ from hathor.conf.get_settings import get_global_settings from hathor.util import not_none +from hathorlib.utils.address import ( # noqa: F401 + get_address_b58_from_bytes, + get_address_b58_from_public_key_hash, + get_address_from_public_key_hash, +) _BACKEND = default_backend() @@ -104,43 +109,6 @@ def get_address_b58_from_public_key(public_key: ec.EllipticCurvePublicKey) -> st return get_address_b58_from_public_key_bytes(public_key_bytes) -def get_address_b58_from_public_key_hash(public_key_hash: bytes) -> str: - """Gets the b58 address from the hash of a public key. - - :param public_key_hash: hash of public key (sha256 and ripemd160) - :param public_key_hash: bytes - - :return: address in base 58 - :rtype: string - """ - address = get_address_from_public_key_hash(public_key_hash) - return base58.b58encode(address).decode('utf-8') - - -def get_address_from_public_key_hash(public_key_hash: bytes, version_byte: Optional[bytes] = None) -> bytes: - """Gets the address in bytes from the public key hash - - :param public_key_hash: hash of public key (sha256 and ripemd160) - :param public_key_hash: bytes - - :param version_byte: first byte of address to define the version of this address - :param version_byte: bytes - - :return: address in bytes - :rtype: bytes - """ - settings = get_global_settings() - address = b'' - actual_version_byte: bytes = version_byte if version_byte is not None else settings.P2PKH_VERSION_BYTE - # Version byte - address += actual_version_byte - # Pubkey hash - address += public_key_hash - checksum = get_checksum(address) - address += checksum - return address - - def get_checksum(address_bytes: bytes) -> bytes: """ Calculate double sha256 of address and gets first 4 bytes @@ -166,17 +134,6 @@ def get_address_b58_from_public_key_bytes(public_key_bytes: bytes) -> str: return get_address_b58_from_public_key_hash(public_key_hash) -def get_address_b58_from_bytes(address): - """Gets the b58 address from the address in bytes - - :param address: bytes - - :return: address in base 58 - :rtype: string - """ - return base58.b58encode(address).decode('utf-8') - - def get_public_key_bytes_compressed(public_key: ec.EllipticCurvePublicKey) -> bytes: """ Returns the bytes from a cryptography ec.EllipticCurvePublicKey in a compressed format diff --git a/hathor/transaction/scripts/opcode.py b/hathor/transaction/scripts/opcode.py index 0f39424fb1..323743082a 100644 --- a/hathor/transaction/scripts/opcode.py +++ b/hathor/transaction/scripts/opcode.py @@ -518,6 +518,8 @@ def op_find_p2pkh(context: ScriptContext) -> None: contract_value = spent_tx.outputs[txin.index].value address = context.stack.pop() + if not isinstance(address, bytes): + raise VerifyFailed address_b58 = get_address_b58_from_bytes(address) for output in tx.outputs: p2pkh_out = P2PKH.parse_script(output.script) diff --git a/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py b/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py index cf77ad2362..637bc7288d 100644 --- a/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py +++ b/hathorlib/hathorlib/nanocontracts/blueprint_syntax_validation.py @@ -83,7 +83,7 @@ def validate_has_ctx_arg(fn: Callable, annotation_name: str) -> None: f'@{annotation_name} method must have `Context` argument: `{fn.__name__}()`' ) - from hathor.nanocontracts.context import Context # type: ignore + from hathor.nanocontracts.context import Context # type: ignore[import-not-found] second_arg = arg_spec.args[1] if arg_spec.annotations[second_arg] is not Context: raise BlueprintSyntaxError( @@ -94,7 +94,7 @@ def validate_has_ctx_arg(fn: Callable, annotation_name: str) -> None: def validate_has_not_ctx_arg(fn: Callable, annotation_name: str) -> None: """Validate that a callable doesn't have a `Context` arg.""" - from hathor.nanocontracts.context import Context # type: ignore[import-not-found] + from hathor.nanocontracts.context import Context arg_spec = inspect.getfullargspec(fn) if Context in arg_spec.annotations.values(): raise BlueprintSyntaxError(f'@{annotation_name} method cannot have arg with type `Context`: `{fn.__name__}()`') diff --git a/hathorlib/hathorlib/nanocontracts/types.py b/hathorlib/hathorlib/nanocontracts/types.py index 9d69c53cbe..7a1aeb1fc2 100644 --- a/hathorlib/hathorlib/nanocontracts/types.py +++ b/hathorlib/hathorlib/nanocontracts/types.py @@ -17,7 +17,7 @@ import inspect from dataclasses import dataclass from enum import Enum, unique -from typing import Any, Callable, Generic, Self, TypeAlias, TypeVar +from typing import Any, Callable, Generic, Protocol, Self, TypeAlias, TypeVar from typing_extensions import override @@ -131,12 +131,16 @@ def blueprint_id_from_bytes(data: bytes) -> BlueprintId: return BlueprintId(VertexId(data)) +class ChecksigBackend(Protocol): + def __call__(self, sighash_all_data: bytes, script_input: bytes, script: bytes) -> bool: ... + + # Injectable backend for RawSignedData.checksig. # hathorlib raises NotImplementedError by default; hathor-core registers the real implementation. -_checksig_backend: Callable[[bytes, bytes, bytes], bool] | None = None +_checksig_backend: ChecksigBackend | None = None -def set_checksig_backend(fn: Callable[[bytes, bytes, bytes], bool]) -> None: +def set_checksig_backend(fn: ChecksigBackend) -> None: """Register the checksig implementation. Must be called by hathor-core during initialization. The callable receives (sighash_all_data: bytes, script_input: bytes, script: bytes) -> bool. diff --git a/hathorlib/hathorlib/utils/address.py b/hathorlib/hathorlib/utils/address.py index efd42bab15..956bc85980 100644 --- a/hathorlib/hathorlib/utils/address.py +++ b/hathorlib/hathorlib/utils/address.py @@ -5,7 +5,7 @@ LICENSE file in the root directory of this source tree. """ import hashlib -from typing import cast +from typing import cast, Optional import base58 from cryptography.hazmat.primitives.asymmetric import ec @@ -82,8 +82,7 @@ def get_address_b58_from_public_key_hash(public_key_hash: bytes) -> str: return base58.b58encode(address).decode('utf-8') -def get_address_from_public_key_hash(public_key_hash: bytes, - version_byte: bytes = settings.P2PKH_VERSION_BYTE) -> bytes: +def get_address_from_public_key_hash(public_key_hash: bytes, version_byte: Optional[bytes] = None) -> bytes: """Gets the address in bytes from the public key hash :param public_key_hash: hash of public key (sha256 and ripemd160) @@ -95,9 +94,11 @@ def get_address_from_public_key_hash(public_key_hash: bytes, :return: address in bytes :rtype: bytes """ + settings = HathorSettings() address = b'' + actual_version_byte: bytes = version_byte if version_byte is not None else settings.P2PKH_VERSION_BYTE # Version byte - address += version_byte + address += actual_version_byte # Pubkey hash address += public_key_hash checksum = get_checksum(address) diff --git a/hathorlib/pyproject.toml b/hathorlib/pyproject.toml index 65fbab5b5b..4f2b639e1d 100644 --- a/hathorlib/pyproject.toml +++ b/hathorlib/pyproject.toml @@ -80,14 +80,11 @@ plugins = [ [[tool.mypy.overrides]] module = [ - "hathorlib.nanocontracts.method", "hathorlib.nanocontracts.*", "hathorlib.utils.*", ] check_untyped_defs = false disallow_untyped_defs = false -warn_return_any = false -warn_unused_ignores = false [tool.pytest.ini_options] minversion = "6.0" From 5fc7ceee7e60ba3b6f024cf06561379513e294f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Tue, 3 Mar 2026 15:11:24 -0300 Subject: [PATCH 09/16] chore: fix double settings init --- hathorlib/hathorlib/utils/address.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hathorlib/hathorlib/utils/address.py b/hathorlib/hathorlib/utils/address.py index 956bc85980..03551943ee 100644 --- a/hathorlib/hathorlib/utils/address.py +++ b/hathorlib/hathorlib/utils/address.py @@ -82,7 +82,9 @@ def get_address_b58_from_public_key_hash(public_key_hash: bytes) -> str: return base58.b58encode(address).decode('utf-8') -def get_address_from_public_key_hash(public_key_hash: bytes, version_byte: Optional[bytes] = None) -> bytes: +def get_address_from_public_key_hash( + public_key_hash: bytes, version_byte: bytes = settings.P2PKH_VERSION_BYTE +) -> bytes: """Gets the address in bytes from the public key hash :param public_key_hash: hash of public key (sha256 and ripemd160) @@ -94,11 +96,9 @@ def get_address_from_public_key_hash(public_key_hash: bytes, version_byte: Optio :return: address in bytes :rtype: bytes """ - settings = HathorSettings() address = b'' - actual_version_byte: bytes = version_byte if version_byte is not None else settings.P2PKH_VERSION_BYTE # Version byte - address += actual_version_byte + address += version_byte # Pubkey hash address += public_key_hash checksum = get_checksum(address) From ec9fbb6abb2c34082ba47a4ebd9875c409956798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Tue, 3 Mar 2026 15:15:42 -0300 Subject: [PATCH 10/16] chore: remove unused import --- hathorlib/hathorlib/utils/address.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hathorlib/hathorlib/utils/address.py b/hathorlib/hathorlib/utils/address.py index 03551943ee..a9e20221d8 100644 --- a/hathorlib/hathorlib/utils/address.py +++ b/hathorlib/hathorlib/utils/address.py @@ -5,7 +5,7 @@ LICENSE file in the root directory of this source tree. """ import hashlib -from typing import cast, Optional +from typing import cast import base58 from cryptography.hazmat.primitives.asymmetric import ec From f8cc661538f948d52996979857ac564a7291ef45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Tue, 3 Mar 2026 15:22:14 -0300 Subject: [PATCH 11/16] feat: disable warn_return_any --- hathorlib/pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hathorlib/pyproject.toml b/hathorlib/pyproject.toml index ccd5348ee9..7f8a6799aa 100644 --- a/hathorlib/pyproject.toml +++ b/hathorlib/pyproject.toml @@ -85,6 +85,8 @@ module = [ ] check_untyped_defs = false disallow_untyped_defs = false +warn_return_any = false + [tool.pytest.ini_options] minversion = "6.0" From 954db0bb9931e2227ae0cd415277a41102cd49bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Tue, 3 Mar 2026 20:09:17 -0300 Subject: [PATCH 12/16] chore: linter changes --- hathor/crypto/util.py | 159 ++------------------- hathorlib/hathorlib/nanocontracts/types.py | 4 +- 2 files changed, 11 insertions(+), 152 deletions(-) diff --git a/hathor/crypto/util.py b/hathor/crypto/util.py index dc35f86c4f..1df182dd93 100644 --- a/hathor/crypto/util.py +++ b/hathor/crypto/util.py @@ -12,10 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -import hashlib from typing import Optional -import base58 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.serialization import ( @@ -23,16 +21,23 @@ KeySerializationEncryption, NoEncryption, PrivateFormat, - PublicFormat, load_der_private_key, ) -from hathor.conf.get_settings import get_global_settings from hathor.util import not_none from hathorlib.utils.address import ( # noqa: F401 + decode_address, get_address_b58_from_bytes, + get_address_b58_from_public_key, + get_address_b58_from_public_key_bytes, get_address_b58_from_public_key_hash, + get_address_b58_from_redeem_script_hash, get_address_from_public_key_hash, + get_address_from_redeem_script_hash, + get_checksum, + get_hash160, + get_public_key_bytes_compressed, + get_public_key_from_bytes_compressed, ) _BACKEND = default_backend() @@ -52,26 +57,6 @@ def get_private_key_from_bytes(private_key_bytes: bytes, return not_none(load_der_private_key(private_key_bytes, password, _BACKEND)) -try: - hashlib.new('ripemd160', b'') -except Exception: - # XXX: the source says "Test-only pure Python RIPEMD160 implementation", however for our case this is acceptable - # for more details see: https://github.com/bitcoin/bitcoin/pull/23716/files which has a copy of the same code - import pycoin.contrib.ripemd160 - - def get_hash160(public_key_bytes: bytes) -> bytes: - """The input is hashed twice: first with SHA-256 and then with RIPEMD-160""" - key_hash = hashlib.sha256(public_key_bytes) - return pycoin.contrib.ripemd160.ripemd160(key_hash.digest()) -else: - def get_hash160(public_key_bytes: bytes) -> bytes: - """The input is hashed twice: first with SHA-256 and then with RIPEMD-160""" - key_hash = hashlib.sha256(public_key_bytes) - h = hashlib.new('ripemd160') - h.update(key_hash.digest()) - return h.digest() - - def get_address_from_public_key(public_key): """ Get bytes from public key object and call method that expect bytes @@ -98,132 +83,6 @@ def get_address_from_public_key_bytes(public_key_bytes): return get_address_from_public_key_hash(public_key_hash) -def get_address_b58_from_public_key(public_key: ec.EllipticCurvePublicKey) -> str: - """Gets the b58 address from a public key. - - :param: ec.EllipticCurvePublicKey - :return: the b58-encoded address - :rtype: string - """ - public_key_bytes = get_public_key_bytes_compressed(public_key) - return get_address_b58_from_public_key_bytes(public_key_bytes) - - -def get_checksum(address_bytes: bytes) -> bytes: - """ Calculate double sha256 of address and gets first 4 bytes - - :param address_bytes: address before checksum - :param address_bytes: bytes - - :return: checksum of the address - :rtype: bytes - """ - return hashlib.sha256(hashlib.sha256(address_bytes).digest()).digest()[:4] - - -def get_address_b58_from_public_key_bytes(public_key_bytes: bytes) -> str: - """Gets the b58 address from a public key bytes. - - :param public_key_bytes: public key in bytes - :param public_key_bytes: bytes - - :return: address in base 58 - :rtype: string - """ - public_key_hash = get_hash160(public_key_bytes) - return get_address_b58_from_public_key_hash(public_key_hash) - - -def get_public_key_bytes_compressed(public_key: ec.EllipticCurvePublicKey) -> bytes: - """ Returns the bytes from a cryptography ec.EllipticCurvePublicKey in a compressed format - - :param public_key: Public key object - :type public_key: ec.EllipticCurvePublicKey - - :rtype: bytes - """ - return public_key.public_bytes(Encoding.X962, PublicFormat.CompressedPoint) - - -def get_public_key_from_bytes_compressed(public_key_bytes: bytes) -> ec.EllipticCurvePublicKey: - """ Returns the cryptography public key from the compressed bytes format - - :param public_key_bytes: Compressed format of public key in bytes - :type public_key_bytes: bytes - - :rtype: ec.EllipticCurvePublicKey - """ - return ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256K1(), public_key_bytes) - - -def get_address_b58_from_redeem_script_hash(redeem_script_hash: bytes, version_byte: Optional[bytes] = None) -> str: - """Gets the b58 address from the hash of the redeem script in multisig. - - :param redeem_script_hash: hash of the redeem script (sha256 and ripemd160) - :param redeem_script_hash: bytes - - :return: address in base 58 - :rtype: string - """ - settings = get_global_settings() - actual_version_byte: bytes = version_byte if version_byte is not None else settings.MULTISIG_VERSION_BYTE - address = get_address_from_redeem_script_hash(redeem_script_hash, actual_version_byte) - return base58.b58encode(address).decode('utf-8') - - -def get_address_from_redeem_script_hash(redeem_script_hash: bytes, version_byte: Optional[bytes] = None) -> bytes: - """Gets the address in bytes from the redeem script hash - - :param redeem_script_hash: hash of redeem script (sha256 and ripemd160) - :param redeem_script_hash: bytes - - :param version_byte: first byte of address to define the version of this address - :param version_byte: bytes - - :return: address in bytes - :rtype: bytes - """ - settings = get_global_settings() - actual_version_byte: bytes = version_byte if version_byte is not None else settings.MULTISIG_VERSION_BYTE - address = b'' - # Version byte - address += actual_version_byte - # redeem script hash - address += redeem_script_hash - checksum = get_checksum(address) - address += checksum - return address - - -def decode_address(address58: str) -> bytes: - """ Decode address in base58 to bytes - - :param address58: Wallet address in base58 - :type address58: string - - :raises InvalidAddress: if address58 is not a valid base58 string or - not a valid address or has invalid checksum - - :return: Address in bytes - :rtype: bytes - """ - from hathor.wallet.exceptions import InvalidAddress - try: - decoded_address = base58.b58decode(address58) - except ValueError: - # Invalid base58 string - raise InvalidAddress('Invalid base58 address') - # Validate address size [25 bytes] - if len(decoded_address) != 25: - raise InvalidAddress('Address size must have 25 bytes') - # Validate the checksum - address_checksum = decoded_address[-4:] - valid_checksum = get_checksum(decoded_address[:-4]) - if address_checksum != valid_checksum: - raise InvalidAddress('Invalid checksum of address') - return decoded_address - - def is_pubkey_compressed(pubkey: bytes) -> bool: """ Receives a public key bytes and return True if in CompressedPoint format This function will not test if this is a valid public key. diff --git a/hathorlib/hathorlib/nanocontracts/types.py b/hathorlib/hathorlib/nanocontracts/types.py index 7a1aeb1fc2..29796dbfda 100644 --- a/hathorlib/hathorlib/nanocontracts/types.py +++ b/hathorlib/hathorlib/nanocontracts/types.py @@ -157,7 +157,7 @@ class RawSignedData(InnerTypeMixin[T], Generic[T]): def __init__(self, data: T, script_input: bytes) -> None: # mypy: disable-error-code="import-not-found" - from hathor.nanocontracts.nc_types import ( # type: ignore[import-not-found] + from hathor.nanocontracts.nc_types import ( # type: ignore[import-not-found, import-untyped] make_nc_type_for_return_type as make_nc_type, ) @@ -519,7 +519,7 @@ def __repr__(self) -> str: def try_parse_as(self, arg_types: tuple[type, ...]) -> tuple[Any, ...] | None: # mypy: disable-error-code="import-not-found" - from hathor.nanocontracts.method import ArgsOnly # type: ignore[import-not-found] + from hathor.nanocontracts.method import ArgsOnly # type: ignore[import-not-found, import-untyped] try: args_parser = ArgsOnly.from_arg_types(arg_types) return args_parser.deserialize_args_bytes(self.args_bytes) From e0c6d6b8970d3a34e0c43f4821f016d9bd797569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Tue, 3 Mar 2026 21:44:27 -0300 Subject: [PATCH 13/16] chore: remove duplicate invalid address exception --- hathor/wallet/exceptions.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/hathor/wallet/exceptions.py b/hathor/wallet/exceptions.py index dc7be4520d..f0c5e858f6 100644 --- a/hathor/wallet/exceptions.py +++ b/hathor/wallet/exceptions.py @@ -13,6 +13,7 @@ # limitations under the License. from hathor.exception import HathorError +from hathorlib.exceptions import InvalidAddress # noqa: F401 class HathorWalletError(HathorError): @@ -59,8 +60,3 @@ class InvalidWords(HathorWalletError): class InputDuplicated(HathorWalletError): """User is trying to use same input more than one time """ - - -class InvalidAddress(HathorWalletError): - """Address used in the wallet is invalid - """ From 75160fc3cd592268c1a313ef4efade23fea41979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Wed, 4 Mar 2026 10:42:40 -0300 Subject: [PATCH 14/16] chore: linter changes --- hathorlib/hathorlib/nanocontracts/types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hathorlib/hathorlib/nanocontracts/types.py b/hathorlib/hathorlib/nanocontracts/types.py index 29796dbfda..7a1aeb1fc2 100644 --- a/hathorlib/hathorlib/nanocontracts/types.py +++ b/hathorlib/hathorlib/nanocontracts/types.py @@ -157,7 +157,7 @@ class RawSignedData(InnerTypeMixin[T], Generic[T]): def __init__(self, data: T, script_input: bytes) -> None: # mypy: disable-error-code="import-not-found" - from hathor.nanocontracts.nc_types import ( # type: ignore[import-not-found, import-untyped] + from hathor.nanocontracts.nc_types import ( # type: ignore[import-not-found] make_nc_type_for_return_type as make_nc_type, ) @@ -519,7 +519,7 @@ def __repr__(self) -> str: def try_parse_as(self, arg_types: tuple[type, ...]) -> tuple[Any, ...] | None: # mypy: disable-error-code="import-not-found" - from hathor.nanocontracts.method import ArgsOnly # type: ignore[import-not-found, import-untyped] + from hathor.nanocontracts.method import ArgsOnly # type: ignore[import-not-found] try: args_parser = ArgsOnly.from_arg_types(arg_types) return args_parser.deserialize_args_bytes(self.args_bytes) From dcac4cacb0f8c55b21ab6a07ccefef5868bf9505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Thu, 5 Mar 2026 00:12:18 -0300 Subject: [PATCH 15/16] feat: do not supress any return errors --- .github/workflows/lib.yml | 2 +- hathorlib/pyproject.toml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/lib.yml b/.github/workflows/lib.yml index 06ed721bfd..a81cf03038 100644 --- a/.github/workflows/lib.yml +++ b/.github/workflows/lib.yml @@ -35,7 +35,7 @@ jobs: with: python-version: ${{ matrix.python }} - name: Install Poetry - run: pip install 'poetry<2' + run: pip install 'poetry' - name: Install Poetry dependencies run: poetry install -n --no-root -E client - name: Run linters diff --git a/hathorlib/pyproject.toml b/hathorlib/pyproject.toml index 7f8a6799aa..ddadbd0eee 100644 --- a/hathorlib/pyproject.toml +++ b/hathorlib/pyproject.toml @@ -85,7 +85,6 @@ module = [ ] check_untyped_defs = false disallow_untyped_defs = false -warn_return_any = false [tool.pytest.ini_options] From e0a4f7f0530987cec289ef43fd9fadd814366024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Carneiro?= Date: Thu, 5 Mar 2026 12:08:53 -0300 Subject: [PATCH 16/16] chore: mypy ignore --- hathorlib/hathorlib/nanocontracts/types.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hathorlib/hathorlib/nanocontracts/types.py b/hathorlib/hathorlib/nanocontracts/types.py index 7a1aeb1fc2..b40e7fa762 100644 --- a/hathorlib/hathorlib/nanocontracts/types.py +++ b/hathorlib/hathorlib/nanocontracts/types.py @@ -17,7 +17,7 @@ import inspect from dataclasses import dataclass from enum import Enum, unique -from typing import Any, Callable, Generic, Protocol, Self, TypeAlias, TypeVar +from typing import Any, Callable, Generic, Protocol, Self, TypeAlias, TypeVar, cast from typing_extensions import override @@ -176,7 +176,7 @@ def __eq__(self, other): def get_data_bytes(self) -> bytes: """Return the serialized data.""" - return self.__nc_type.to_bytes(self.data) + return cast(bytes, self.__nc_type.to_bytes(self.data)) def get_sighash_all_data(self) -> bytes: """Workaround to be able to pass `self` for ScriptExtras. See the method `checksig`.""" @@ -522,7 +522,7 @@ def try_parse_as(self, arg_types: tuple[type, ...]) -> tuple[Any, ...] | None: from hathor.nanocontracts.method import ArgsOnly # type: ignore[import-not-found] try: args_parser = ArgsOnly.from_arg_types(arg_types) - return args_parser.deserialize_args_bytes(self.args_bytes) + return cast(tuple[Any, ...], args_parser.deserialize_args_bytes(self.args_bytes)) except (NCSerializationError, SerializationError, TypeError, ValueError): return None