diff --git a/hathor/conf/settings.py b/hathor/conf/settings.py index 6996b1851..e82d91835 100644 --- a/hathor/conf/settings.py +++ b/hathor/conf/settings.py @@ -31,7 +31,7 @@ GENESIS_TOKEN_UNITS = 1 * (10**9) # 1B GENESIS_TOKENS = GENESIS_TOKEN_UNITS * (10**DECIMAL_PLACES) # 100B -HATHOR_TOKEN_UID = b'\x00' +HATHOR_TOKEN_UID: bytes = b'\x00' @unique @@ -469,7 +469,7 @@ def GENESIS_TX2_TIMESTAMP(self) -> int: ENABLE_NANO_CONTRACTS: NanoContractsSetting = NanoContractsSetting.DISABLED # List of enabled blueprints. - BLUEPRINTS: dict[bytes, 'str'] = {} + BLUEPRINTS: dict[bytes, str] = {} # The consensus algorithm protocol settings. CONSENSUS_ALGORITHM: ConsensusSettings = PowSettings() diff --git a/hathor/dag_builder/artifacts.py b/hathor/dag_builder/artifacts.py index b0a4ae0fe..52a1e7640 100644 --- a/hathor/dag_builder/artifacts.py +++ b/hathor/dag_builder/artifacts.py @@ -14,7 +14,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterator, NamedTuple, TypeVar +from typing import TYPE_CHECKING, Iterator, NamedTuple, Sequence, TypeVar from hathor.dag_builder.types import DAGNode from hathor.manager import HathorManager @@ -49,9 +49,9 @@ def get_typed_vertex(self, name: str, type_: type[T]) -> T: assert isinstance(vertex, type_) return vertex - def get_typed_vertices(self, names: list[str], type_: type[T]) -> list[T]: + def get_typed_vertices(self, names: Sequence[str], type_: type[T]) -> Sequence[T]: """Get a list of vertices by name, asserting they are of the provided type.""" - return [self.get_typed_vertex(name, type_) for name in names] + return tuple(self.get_typed_vertex(name, type_) for name in names) def propagate_with(self, manager: HathorManager, *, up_to: str | None = None) -> None: """ diff --git a/hathor/nanocontracts/__init__.py b/hathor/nanocontracts/__init__.py index f7b85e25a..da94b0e25 100644 --- a/hathor/nanocontracts/__init__.py +++ b/hathor/nanocontracts/__init__.py @@ -12,16 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. +from hathor.conf import settings from hathor.nanocontracts.blueprint import Blueprint from hathor.nanocontracts.context import Context +from hathor.nanocontracts.contract_accessor import get_contract from hathor.nanocontracts.exception import NCFail from hathor.nanocontracts.on_chain_blueprint import OnChainBlueprint from hathor.nanocontracts.runner import Runner from hathor.nanocontracts.storage import NCMemoryStorageFactory, NCRocksDBStorageFactory, NCStorageFactory -from hathor.nanocontracts.types import fallback, public, view +from hathor.nanocontracts.types import TokenUid, VertexId, fallback, public, view # Identifier used in metadata's voided_by when a Nano Contract method fails. NC_EXECUTION_FAIL_ID: bytes = b'nc-fail' +HATHOR_TOKEN_UID: TokenUid = TokenUid(VertexId(settings.HATHOR_TOKEN_UID)) + __all__ = [ 'Blueprint', @@ -36,4 +40,6 @@ 'fallback', 'view', 'NC_EXECUTION_FAIL_ID', + 'HATHOR_TOKEN_UID', + 'get_contract', ] diff --git a/hathor/nanocontracts/allowed_imports.py b/hathor/nanocontracts/allowed_imports.py index f124acfae..453477ae4 100644 --- a/hathor/nanocontracts/allowed_imports.py +++ b/hathor/nanocontracts/allowed_imports.py @@ -33,7 +33,11 @@ ), 'collections': dict(OrderedDict=collections.OrderedDict), # hathor - 'hathor.nanocontracts': dict(Blueprint=nc.Blueprint), + 'hathor.nanocontracts': dict( + Blueprint=nc.Blueprint, + HATHOR_TOKEN_UID=nc.HATHOR_TOKEN_UID, + get_contract=nc.contract_accessor.get_contract, + ), 'hathor.nanocontracts.blueprint': dict(Blueprint=nc.Blueprint), 'hathor.nanocontracts.context': dict(Context=nc.Context), 'hathor.nanocontracts.exception': dict(NCFail=nc.NCFail), diff --git a/hathor/nanocontracts/blueprint_env.py b/hathor/nanocontracts/blueprint_env.py index 7a9f8f187..015861740 100644 --- a/hathor/nanocontracts/blueprint_env.py +++ b/hathor/nanocontracts/blueprint_env.py @@ -171,7 +171,14 @@ def call_public_method( **kwargs: Any, ) -> Any: """Call a public method of another contract.""" - return self.__runner.syscall_call_another_contract_public_method(nc_id, method_name, actions, args, kwargs) + return self.__runner.syscall_call_another_contract_public_method( + nc_id, + method_name, + actions, + args, + kwargs, + forbid_fallback=False, + ) @final def proxy_call_public_method( diff --git a/hathor/nanocontracts/contract_accessor.py b/hathor/nanocontracts/contract_accessor.py new file mode 100644 index 000000000..f32fbf37b --- /dev/null +++ b/hathor/nanocontracts/contract_accessor.py @@ -0,0 +1,304 @@ +# 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 + +from typing import TYPE_CHECKING, Any, Collection, Protocol, Sequence, final + +from hathor.nanocontracts.faux_immutable import FauxImmutable, __set_faux_immutable__ +from hathor.nanocontracts.lazy_import import LazyImport +from hathor.nanocontracts.types import BlueprintId, ContractId, NCAction + +if TYPE_CHECKING: + from hathor.nanocontracts import Runner + + +class GetContractCallable(Protocol): + def __call__(self, contract_id: ContractId, *, blueprint_id: BlueprintId | Collection[BlueprintId] | None) -> Any: + ... + + +def create_get_contract(runner: Runner) -> GetContractCallable: + def get_contract(contract_id: ContractId, *, blueprint_id: BlueprintId | Collection[BlueprintId] | None) -> Any: + """ + Get a contract accessor for the given contract ID. + + Args: + contract_id: the ID of the contract. + blueprint_id: the expected blueprint ID of the contract, or a collection of accepted blueprints, + or None if any blueprint is accepted. + + """ + return ContractAccessor(runner=runner, contract_id=contract_id, blueprint_id=blueprint_id) + return get_contract + + +get_contract = LazyImport('get_contract', create_get_contract) + + +@final +class ContractAccessor(FauxImmutable): + """ + This class represents a "proxy contract instance", or a contract accessor, during a blueprint method execution. + Calling custom blueprint methods on this class will forward the call to the actual wrapped blueprint via syscalls. + """ + __slots__ = ('__runner', '__contract_id', '__blueprint_ids') + + def __init__( + self, + *, + runner: Runner, + contract_id: ContractId, + blueprint_id: BlueprintId | Collection[BlueprintId] | None, + ) -> None: + self.__runner: Runner + self.__contract_id: ContractId + self.__blueprint_ids: frozenset[BlueprintId] | None + + blueprint_ids: frozenset[BlueprintId] | None + match blueprint_id: + case None: + blueprint_ids = None + case bytes(): + blueprint_ids = frozenset({blueprint_id}) + case _: + blueprint_ids = frozenset(blueprint_id) + + __set_faux_immutable__(self, '__runner', runner) + __set_faux_immutable__(self, '__contract_id', contract_id) + __set_faux_immutable__(self, '__blueprint_ids', blueprint_ids) + + def prepare_view_call(self) -> Any: + return PreparedViewCall( + runner=self.__runner, + contract_id=self.__contract_id, + blueprint_ids=self.__blueprint_ids, + ) + + def prepare_public_call(self, *actions: NCAction, forbid_fallback: bool = False) -> Any: + return PreparedPublicCall( + runner=self.__runner, + contract_id=self.__contract_id, + blueprint_ids=self.__blueprint_ids, + actions=actions, + forbid_fallback=forbid_fallback, + ) + + +@final +class PreparedViewCall(FauxImmutable): + __slots__ = ('__runner', '__contract_id', '__blueprint_ids') + __skip_faux_immutability_validation__ = True # Needed to implement __getattr__ + + def __init__( + self, + *, + runner: Runner, + contract_id: ContractId, + blueprint_ids: frozenset[BlueprintId] | None, + ) -> None: + self.__runner: Runner + self.__contract_id: ContractId + self.__blueprint_ids: frozenset[BlueprintId] | None + + __set_faux_immutable__(self, '__runner', runner) + __set_faux_immutable__(self, '__contract_id', contract_id) + __set_faux_immutable__(self, '__blueprint_ids', blueprint_ids) + + def __getattr__(self, method_name: str) -> ViewMethodAccessor: + return ViewMethodAccessor( + runner=self.__runner, + contract_id=self.__contract_id, + blueprint_ids=self.__blueprint_ids, + method_name=method_name, + ) + + +@final +class PreparedPublicCall(FauxImmutable): + __slots__ = ('__runner', '__contract_id', '__blueprint_ids', '__actions', '__forbid_fallback', '__is_dirty') + __skip_faux_immutability_validation__ = True # Needed to implement __getattr__ + + def __init__( + self, + *, + runner: Runner, + contract_id: ContractId, + blueprint_ids: frozenset[BlueprintId] | None, + actions: Sequence[NCAction], + forbid_fallback: bool, + ) -> None: + self.__runner: Runner + self.__contract_id: ContractId + self.__blueprint_ids: frozenset[BlueprintId] | None + self.__actions: Sequence[NCAction] + self.__forbid_fallback: bool + self.__is_dirty: bool + + __set_faux_immutable__(self, '__runner', runner) + __set_faux_immutable__(self, '__contract_id', contract_id) + __set_faux_immutable__(self, '__blueprint_ids', blueprint_ids) + __set_faux_immutable__(self, '__actions', actions) + __set_faux_immutable__(self, '__forbid_fallback', forbid_fallback) + __set_faux_immutable__(self, '__is_dirty', False) + + def __getattr__(self, method_name: str) -> PublicMethodAccessor: + from hathor.nanocontracts import NCFail + if self.__is_dirty: + raise NCFail( + f'prepared public method for contract `{self.__contract_id.hex()}` was already used, ' + f'you must use `prepare_public_call` on the contract to call it again' + ) + + __set_faux_immutable__(self, '__is_dirty', True) + + return PublicMethodAccessor( + runner=self.__runner, + contract_id=self.__contract_id, + blueprint_ids=self.__blueprint_ids, + method_name=method_name, + actions=self.__actions, + forbid_fallback=self.__forbid_fallback, + ) + + +@final +class ViewMethodAccessor(FauxImmutable): + """ + This class represents a "proxy view method", or a view method accessor, during a blueprint method execution. + It's a callable that will forward the call to the actual wrapped blueprint via syscall. + It may be used multiple times to call the same method with different arguments. + """ + __slots__ = ('__runner', '__contract_id', '__blueprint_ids', '__method_name') + + def __init__( + self, + *, + runner: Runner, + contract_id: ContractId, + blueprint_ids: frozenset[BlueprintId] | None, + method_name: str, + ) -> None: + self.__runner: Runner + self.__contract_id: ContractId + self.__blueprint_ids: frozenset[BlueprintId] | None + self.__method_name: str + + __set_faux_immutable__(self, '__runner', runner) + __set_faux_immutable__(self, '__contract_id', contract_id) + __set_faux_immutable__(self, '__blueprint_ids', blueprint_ids) + __set_faux_immutable__(self, '__method_name', method_name) + + def __call__(self, *args: Any, **kwargs: Any) -> object: + validate_blueprint_id( + runner=self.__runner, + contract_id=self.__contract_id, + blueprint_ids=self.__blueprint_ids, + ) + + return self.__runner.syscall_call_another_contract_view_method( + contract_id=self.__contract_id, + method_name=self.__method_name, + args=args, + kwargs=kwargs, + ) + + +@final +class PublicMethodAccessor(FauxImmutable): + """ + This class represents a "proxy public method", or a public method accessor, during a blueprint method execution. + It's a callable that will forward the call to the actual wrapped blueprint via syscall. + It can only be used once because it consumes the provided actions after a single use. + """ + __slots__ = ( + '__runner', + '__contract_id', + '__blueprint_ids', + '__method_name', + '__actions', + '__forbid_fallback', + '__is_dirty', + ) + + def __init__( + self, + *, + runner: Runner, + contract_id: ContractId, + blueprint_ids: frozenset[BlueprintId] | None, + method_name: str, + actions: Sequence[NCAction], + forbid_fallback: bool, + ) -> None: + self.__runner: Runner + self.__contract_id: ContractId + self.__blueprint_ids: frozenset[BlueprintId] | None + self.__method_name: str + self.__actions: Sequence[NCAction] + self.__forbid_fallback: bool + self.__is_dirty: bool + + __set_faux_immutable__(self, '__runner', runner) + __set_faux_immutable__(self, '__contract_id', contract_id) + __set_faux_immutable__(self, '__blueprint_ids', blueprint_ids) + __set_faux_immutable__(self, '__method_name', method_name) + __set_faux_immutable__(self, '__actions', actions) + __set_faux_immutable__(self, '__forbid_fallback', forbid_fallback) + __set_faux_immutable__(self, '__is_dirty', False) + + def __call__(self, *args: Any, **kwargs: Any) -> object: + from hathor.nanocontracts import NCFail + if self.__is_dirty: + raise NCFail( + f'accessor for public method `{self.__method_name}` was already used, ' + f'you must use `prepare_public_call` on the contract to call it again' + ) + + __set_faux_immutable__(self, '__is_dirty', True) + + validate_blueprint_id( + runner=self.__runner, + contract_id=self.__contract_id, + blueprint_ids=self.__blueprint_ids, + ) + + return self.__runner.syscall_call_another_contract_public_method( + contract_id=self.__contract_id, + method_name=self.__method_name, + actions=self.__actions, + args=args, + kwargs=kwargs, + forbid_fallback=self.__forbid_fallback, + ) + + +def validate_blueprint_id( + *, + runner: Runner, + contract_id: ContractId, + blueprint_ids: frozenset[BlueprintId] | None, +) -> None: + """Check whether the blueprint id of a contract matches the expected id(s), raise an exception otherwise.""" + if blueprint_ids is None: + return + + blueprint_id = runner.get_blueprint_id(contract_id) + if blueprint_id not in blueprint_ids: + from hathor.nanocontracts import NCFail + expected = tuple(sorted(bp.hex() for bp in blueprint_ids)) + raise NCFail( + f'expected blueprint to be one of `{expected}`, ' + f'got `{blueprint_id.hex()}` for contract `{contract_id.hex()}`' + ) diff --git a/hathor/nanocontracts/custom_builtins.py b/hathor/nanocontracts/custom_builtins.py index 53fca9c12..0a2b7e338 100644 --- a/hathor/nanocontracts/custom_builtins.py +++ b/hathor/nanocontracts/custom_builtins.py @@ -33,6 +33,7 @@ from typing_extensions import Self, TypeVarTuple +from hathor.nanocontracts import Runner from hathor.nanocontracts.allowed_imports import ALLOWED_IMPORTS from hathor.nanocontracts.exception import NCDisabledBuiltinError from hathor.nanocontracts.faux_immutable import FauxImmutable @@ -221,8 +222,14 @@ def __call__( ... -def _generate_restricted_import_function(allowed_imports: dict[str, dict[str, object]]) -> ImportFunction: - """Returns a function equivalent to builtins.__import__ but that will only import `allowed_imports`""" +def _generate_restricted_import_function( + runner: Runner | None, + allowed_imports: dict[str, dict[str, object]], +) -> ImportFunction: + """ + Returns a function equivalent to builtins.__import__ but that will only import `allowed_imports`. + If a Runner is provided, it will be injected in lazy imports, otherwise an invalid import will be generated. + """ @_wraps(builtins.__import__) def __import__( name: str, @@ -250,7 +257,13 @@ class FakeModule: if import_what not in allowed_fromlist: raise ImportError(f'Import from "{name}.{import_what}" is not allowed.') - setattr(fake_module, import_what, allowed_fromlist[import_what]) + import_value = allowed_fromlist[import_what] + from hathor.nanocontracts.lazy_import import LazyImport + if isinstance(import_value, LazyImport): + # Lazy imports are created here, at the time they're imported, using the provided Runner. + import_value = import_value.create_with_runner(runner) + + setattr(fake_module, import_what, import_value) # This cast is safe because the only requirement is that the object contains the imported attributes. return cast(types.ModuleType, fake_module) @@ -483,350 +496,354 @@ def filter(function: None | Callable[[T], object], iterable: Iterable[T]) -> Ite }) -# list of allowed builtins during execution of an on-chain blueprint code -EXEC_BUILTINS: dict[str, Any] = { - # XXX: check https://github.com/python/mypy/blob/master/mypy/typeshed/stdlib/builtins.pyi for the full typing - # XXX: check https://github.com/python/cpython/blob/main/Python/bltinmodule.c for the implementation - - # XXX: required to declare classes - # O(1) - # (func: Callable[[], CellType | Any], name: str, /, *bases: Any, metaclass: Any = ..., **kwds: Any) -> Any - '__build_class__': builtins.__build_class__, - - # XXX: required to do imports - # XXX: will trigger the execution of the imported module - # (name: str, globals: Mapping[str, object] | None = None, locals: Mapping[str, object] | None = None, - # fromlist: Sequence[str] = (), level: int = 0) -> types.ModuleType - '__import__': _generate_restricted_import_function(ALLOWED_IMPORTS), - - # XXX: also required to declare classes - # XXX: this would be '__main__' for a module that is loaded as the main entrypoint, and the module name otherwise, - # since the blueprint code is adhoc, we could as well expose something else, like '__blueprint__' - # constant - '__name__': BLUEPRINT_CLASS_NAME, - - # make it always True, which is how we'll normally run anyway - '__debug__': True, - - # O(1) - # (x: SupportsAbs[T], /) -> T - 'abs': builtins.abs, - - # XXX: consumes an iterable when calling - # O(N) for N=len(iterable) - # (iterable: Iterable[object], /) -> bool - 'all': custom_all, - - # XXX: consumes an iterable when calling - # O(N) for N=len(iterable) - # (iterable: Iterable[object], /) -> bool - 'any': custom_any, - - # O(1) - # (number: int | SupportsIndex, /) -> str - 'bin': builtins.bin, - - # O(1) - # type bool(int) - 'bool': builtins.bool, - - # XXX: consumes an iterable when calling - # O(N) for N=len(iterable) - # type bytearray(MutableSequence[int]) - 'bytearray': builtins.bytearray, - - # XXX: consumes an iterable when calling - # O(N) for N=len(iterable) - # type bytes(Sequence[int]) - 'bytes': builtins.bytes, - - # O(1) - # (obj: object, /) -> bool - 'callable': builtins.callable, - - # O(1) - # (i: int, /) -> str - 'chr': builtins.chr, - - # O(1) - # decorator - 'classmethod': builtins.classmethod, - - # XXX: consumes an iterator when calling - # O(N) for N=len(iterable) - # type dict(MutableMapping[K, V]) - # () -> dict - # (**kwargs: V) -> dict[str, V] - # (map: SupportsKeysAndGetItem[K, V], /) -> dict[K, V] - # (map: SupportsKeysAndGetItem[str, V], /, **kwargs: V) -> dict[K, V] - # (iterable: Iterable[tuple[K, V]], /) -> dict[K, V] - # (iterable: Iterable[tuple[str, V]], /, **kwargs: V) -> dict[str, V] - # (iterable: Iterable[list[str]], /) -> dict[str, str] - # (iterable: Iterable[list[bytes]], /) -> dict[bytes, bytes] - 'dict': builtins.dict, - - # O(1) - # (x: SupportsDivMod[T, R], y: T, /) -> R - # (x: T, y: SupportsRDivMod[T, R], /) -> R - 'divmod': builtins.divmod, - - # O(1) - # (iterable: Iterable[T], start: int = 0) -> enumerate(Iterator[T]) - 'enumerate': enumerate, - - # O(1) - # (function: None, iterable: Iterable[T | None], /) -> filter(Iterator[T]) - # (function: Callable[[S], TypeGuard[T]], iterable: Iterable[S], /) -> filter(Iterator[T]) - # (function: Callable[[S], TypeIs[T]], iterable: Iterable[S], /) -> filter(Iterator[T]) - # (function: Callable[[T], Any], iterable: Iterable[T], /) -> filter(Iterator[T]) - 'filter': builtins.filter, - - # O(1) - # type float - 'float': builtins.float, - - # O(N) for N=len(value) - # (value: object, format_spec: str = "", /) -> str - 'format': builtins.format, - - # XXX: consumes an iterator when calling - # O(N) for N=len(iterable) - # type frozenset(AbstractSet[T]) - # () -> frozenset - # (iterable: Iterable[T], /) -> frozenset[T] - 'frozenset': builtins.frozenset, - - # O(1) - # __hash__ shortcut - # (obj: object, /) -> int - 'hash': builtins.hash, - - # O(1) - # (number: int | SupportsIndex, /) -> str - 'hex': builtins.hex, - - # We allow `isinstance()` checks - 'isinstance': builtins.isinstance, - - # O(1) various -> int - # (x: ConvertibleToInt = ..., /) -> int - # (x: str | bytes | bytearray, /, base: SupportsIndex) -> int - 'int': builtins.int, - - # O(1) - # __iter__ shortcut - # (object: SupportsIter[I], /) -> I - # (object: GetItemIterable[T], /) -> Iterator[T] - # (object: Callable[[], T | None], sentinel: None, /) -> Iterator[T] - # (object: Callable[[], T], sentinel: object, /) -> Iterator[T] - 'iter': builtins.iter, - - # O(1) - # (obj: Sized, /) -> int - 'len': builtins.len, - - # XXX: consumes an iterator when calling - # O(N) for N=len(iterable) - # () -> list - # (iterable: Iterable[T], /) -> list[T] - 'list': builtins.list, - - # O(1) - # type map - # (func: Callable[[T], S], iter: Iterable[T], /) -> map[S] - # (func: Callable[[T1, T2], S], iter1: Iterable[T1], iter2: Iterable[T2], /) -> map[S] - # ... - # (func: Callable[[T1, ..., TN], S], iter1: Iterable[T1], ..., iterN: Iterable[TN],/) -> map[S] - 'map': builtins.map, - - # XXX: consumes an iterator when calling - # O(N) for N=len(iterables) - # (arg1: T, arg2: T, /, *_args: T, key: None = None) -> T - # (arg1: T, arg2: T, /, *_args: T, key: Callable[[T], T]) -> T - # (iterable: Iterable[T], /, *, key: None = None) -> T - # (iterable: Iterable[T], /, *, key: Callable[[T], T]) -> T - # (iterable: Iterable[T], /, *, key: None = None, default: T) -> T - # (iterable: Iterable[T], /, *, key: Callable[[T], T], default: T) -> T - 'max': builtins.max, - - # XXX: consumes an iterator when calling - # O(N) for N=len(iterables) - # (arg1: T, arg2: T, /, *_args: T, key: None = None) -> T - # (arg1: T, arg2: T, /, *_args: T, key: Callable[[T], T]) -> T - # (iterable: Iterable[T], /, *, key: None = None) -> T - # (iterable: Iterable[T], /, *, key: Callable[[T], T]) -> T - # (iterable: Iterable[T], /, *, key: None = None, default: T) -> T - # (iterable: Iterable[T], /, *, key: Callable[[T], T], default: T) -> T - 'min': builtins.min, - - # O(1) - # __next__ shortcut - # (i: SupportsNext[T], /) -> T - # (i: SupportsNext[T], default: V, /) -> T | V - 'next': builtins.next, - - # O(1) - # (number: int | SupportsIndex, /) -> str - 'oct': builtins.oct, - - # O(1) - # (c: str | bytes | bytearray, /) -> int - 'ord': builtins.ord, - - # XXX: can be used to easily make large numbers - # O(1) - # (base: int, exp: int, mod: int) -> int - 'pow': builtins.pow, - - # XXX: generator that escapes the VM - # O(1) - # type range(Sequence[int]) - # (stop: SupportsIndex, /) -> range - # (start: SupportsIndex, stop: SupportsIndex, step: SupportsIndex = ..., /) -> range - 'range': custom_range, - - # XXX: can consume an iterator when calling - # O(N) for N=len(sequence) - # type reversed(Iterator[T]) - # (sequence: Reversible[T], /) -> reversed[T] - # (sequence: SupportsLenAndGetItem[T], /) -> reversed[T] - 'reversed': builtins.reversed, - - # O(1) - # (number: SupportsRound1[T], ndigits: None = None) -> T - # (number: SupportsRound2[T], ndigits: SupportsIndex) -> T - 'round': builtins.round, - - # XXX: consumes an iterator when calling - # O(N) for N=len(iterable) - # type set(MutableSet[T]) - # () -> set - # (iterable: Iterable[T], /) -> set[T] - 'set': builtins.set, - - # O(1) - # type slice(Generic[A, B, C]) - # (stop: int | None, /) -> slice[int | MaybeNone, int | MaybeNone, int | MaybeNone] - 'slice': builtins.slice, - - # XXX: consumes an iterator when calling - # O(N*log(N)) for N=len(iterable) - # (iterable: Iterable[T], /, *, key: None = None, reverse: bool = False) -> list[T] - # (iterable: Iterable[T], /, *, key: Callable[[T], T], reverse: bool = False) -> list[T] - 'sorted': builtins.sorted, - - # O(1) - # type staticmethod(Generic[P, R]) - # (f: Callable[P, R], /) -> staticmethod[P, R] - 'staticmethod': builtins.staticmethod, - - # O(1) - # __str__ shortcut - # (object: object = ...) -> str - # (object: ReadableBuffer, encoding: str = ..., errors: str = ...) -> str - 'str': builtins.str, - - # XXX: consumes an iterator when calling - # O(N) for N=len(iterable) - # (iterable: Iterable[bool], /, start: int = 0) -> int - # (iterable: Iterable[T], /) -> T - # (iterable: Iterable[T], /, start: T) -> T - 'sum': builtins.sum, - - # XXX: consumes an iterator when calling - # O(N) for N=len(iterable) - # type tuple(Sequence[T]) - # (iterable: Iterable[T] = ..., /) -> tuple[T] - 'tuple': builtins.tuple, - - # O(1) - # type zip(Iterator[T]) - # (iter: Iterable[T], /, *, strict: bool = ...) -> zip[tuple[T]] - # (iter1: Iterable[T1], iter2: Iterable[T2], /, *, strict: bool = ...) -> zip[tuple[T1, T2]] - # ... - # (iter1: Iterable[T1], ..., iterN: Iterable[TN], /, *, strict: bool = ...) -> zip[tuple[T1, ..., TN]] - 'zip': builtins.zip, - - # these exceptions aren't available in Python 3.10, so don't expose them - # 'BaseExceptionGroup': builtins.BaseExceptionGroup, - # 'ExceptionGroup': builtins.ExceptionGroup, - - # expose all other exception types: - 'ArithmeticError': builtins.ArithmeticError, - 'AssertionError': builtins.AssertionError, - 'AttributeError': builtins.AttributeError, - 'BaseException': builtins.BaseException, - 'BlockingIOError': builtins.BlockingIOError, - 'BrokenPipeError': builtins.BrokenPipeError, - 'BufferError': builtins.BufferError, - 'ChildProcessError': builtins.ChildProcessError, - 'ConnectionAbortedError': builtins.ConnectionAbortedError, - 'ConnectionError': builtins.ConnectionError, - 'ConnectionRefusedError': builtins.ConnectionRefusedError, - 'ConnectionResetError': builtins.ConnectionResetError, - 'EOFError': builtins.EOFError, - 'EnvironmentError': builtins.EnvironmentError, - 'Exception': builtins.Exception, - 'FileExistsError': builtins.FileExistsError, - 'FileNotFoundError': builtins.FileNotFoundError, - 'FloatingPointError': builtins.FloatingPointError, - 'GeneratorExit': builtins.GeneratorExit, - 'IOError': builtins.IOError, - 'ImportError': builtins.ImportError, - 'IndentationError': builtins.IndentationError, - 'IndexError': builtins.IndexError, - 'InterruptedError': builtins.InterruptedError, - 'IsADirectoryError': builtins.IsADirectoryError, - 'KeyError': builtins.KeyError, - 'KeyboardInterrupt': builtins.KeyboardInterrupt, - 'LookupError': builtins.LookupError, - 'MemoryError': builtins.MemoryError, - 'ModuleNotFoundError': builtins.ModuleNotFoundError, - 'NameError': builtins.NameError, - 'NotADirectoryError': builtins.NotADirectoryError, - 'NotImplementedError': builtins.NotImplementedError, - 'OSError': builtins.OSError, - 'OverflowError': builtins.OverflowError, - 'PermissionError': builtins.PermissionError, - 'ProcessLookupError': builtins.ProcessLookupError, - 'RecursionError': builtins.RecursionError, - 'ReferenceError': builtins.ReferenceError, - 'RuntimeError': builtins.RuntimeError, - 'StopAsyncIteration': builtins.StopAsyncIteration, - 'StopIteration': builtins.StopIteration, - 'SyntaxError': builtins.SyntaxError, - 'SystemError': builtins.SystemError, - 'SystemExit': builtins.SystemExit, - 'TabError': builtins.TabError, - 'TimeoutError': builtins.TimeoutError, - 'TypeError': builtins.TypeError, - 'UnboundLocalError': builtins.UnboundLocalError, - 'UnicodeDecodeError': builtins.UnicodeDecodeError, - 'UnicodeEncodeError': builtins.UnicodeEncodeError, - 'UnicodeError': builtins.UnicodeError, - 'UnicodeTranslateError': builtins.UnicodeTranslateError, - 'ValueError': builtins.ValueError, - 'ZeroDivisionError': builtins.ZeroDivisionError, - - # expose all warning types: - 'BytesWarning': builtins.BytesWarning, - 'DeprecationWarning': builtins.DeprecationWarning, - 'EncodingWarning': builtins.EncodingWarning, - 'FutureWarning': builtins.FutureWarning, - 'ImportWarning': builtins.ImportWarning, - 'PendingDeprecationWarning': builtins.PendingDeprecationWarning, - 'ResourceWarning': builtins.ResourceWarning, - 'RuntimeWarning': builtins.RuntimeWarning, - 'SyntaxWarning': builtins.SyntaxWarning, - 'UnicodeWarning': builtins.UnicodeWarning, - 'UserWarning': builtins.UserWarning, - 'Warning': builtins.Warning, - - # All other builtins are NOT exposed: - # ===================================== - - **{ - name: _generate_disabled_builtin_func(name) - for name in DISABLED_BUILTINS - }, -} +def get_exec_builtins(*, runner: Runner | None) -> dict[str, Any]: + """ + Dynamically generate a list of allowed builtins during execution of a contract, + with an optional Runner that is used to instantiate lazy imports. + """ + return { + # XXX: check https://github.com/python/mypy/blob/master/mypy/typeshed/stdlib/builtins.pyi for the full typing + # XXX: check https://github.com/python/cpython/blob/main/Python/bltinmodule.c for the implementation + + # XXX: required to declare classes + # O(1) + # (func: Callable[[], CellType | Any], name: str, /, *bases: Any, metaclass: Any = ..., **kwds: Any) -> Any + '__build_class__': builtins.__build_class__, + + # XXX: required to do imports + # XXX: will trigger the execution of the imported module + # (name: str, globals: Mapping[str, object] | None = None, locals: Mapping[str, object] | None = None, + # fromlist: Sequence[str] = (), level: int = 0) -> types.ModuleType + '__import__': _generate_restricted_import_function(runner, ALLOWED_IMPORTS), + + # XXX: also required to declare classes + # XXX: this would be '__main__' for a module that is loaded as the main entrypoint, + # and the module name otherwise, since the blueprint code is adhoc, + # we could as well expose something else, like '__blueprint__' constant + '__name__': BLUEPRINT_CLASS_NAME, + + # make it always True, which is how we'll normally run anyway + '__debug__': True, + + # O(1) + # (x: SupportsAbs[T], /) -> T + 'abs': builtins.abs, + + # XXX: consumes an iterable when calling + # O(N) for N=len(iterable) + # (iterable: Iterable[object], /) -> bool + 'all': custom_all, + + # XXX: consumes an iterable when calling + # O(N) for N=len(iterable) + # (iterable: Iterable[object], /) -> bool + 'any': custom_any, + + # O(1) + # (number: int | SupportsIndex, /) -> str + 'bin': builtins.bin, + + # O(1) + # type bool(int) + 'bool': builtins.bool, + + # XXX: consumes an iterable when calling + # O(N) for N=len(iterable) + # type bytearray(MutableSequence[int]) + 'bytearray': builtins.bytearray, + + # XXX: consumes an iterable when calling + # O(N) for N=len(iterable) + # type bytes(Sequence[int]) + 'bytes': builtins.bytes, + + # O(1) + # (obj: object, /) -> bool + 'callable': builtins.callable, + + # O(1) + # (i: int, /) -> str + 'chr': builtins.chr, + + # O(1) + # decorator + 'classmethod': builtins.classmethod, + + # XXX: consumes an iterator when calling + # O(N) for N=len(iterable) + # type dict(MutableMapping[K, V]) + # () -> dict + # (**kwargs: V) -> dict[str, V] + # (map: SupportsKeysAndGetItem[K, V], /) -> dict[K, V] + # (map: SupportsKeysAndGetItem[str, V], /, **kwargs: V) -> dict[K, V] + # (iterable: Iterable[tuple[K, V]], /) -> dict[K, V] + # (iterable: Iterable[tuple[str, V]], /, **kwargs: V) -> dict[str, V] + # (iterable: Iterable[list[str]], /) -> dict[str, str] + # (iterable: Iterable[list[bytes]], /) -> dict[bytes, bytes] + 'dict': builtins.dict, + + # O(1) + # (x: SupportsDivMod[T, R], y: T, /) -> R + # (x: T, y: SupportsRDivMod[T, R], /) -> R + 'divmod': builtins.divmod, + + # O(1) + # (iterable: Iterable[T], start: int = 0) -> enumerate(Iterator[T]) + 'enumerate': enumerate, + + # O(1) + # (function: None, iterable: Iterable[T | None], /) -> filter(Iterator[T]) + # (function: Callable[[S], TypeGuard[T]], iterable: Iterable[S], /) -> filter(Iterator[T]) + # (function: Callable[[S], TypeIs[T]], iterable: Iterable[S], /) -> filter(Iterator[T]) + # (function: Callable[[T], Any], iterable: Iterable[T], /) -> filter(Iterator[T]) + 'filter': builtins.filter, + + # O(1) + # type float + 'float': builtins.float, + + # O(N) for N=len(value) + # (value: object, format_spec: str = "", /) -> str + 'format': builtins.format, + + # XXX: consumes an iterator when calling + # O(N) for N=len(iterable) + # type frozenset(AbstractSet[T]) + # () -> frozenset + # (iterable: Iterable[T], /) -> frozenset[T] + 'frozenset': builtins.frozenset, + + # O(1) + # __hash__ shortcut + # (obj: object, /) -> int + 'hash': builtins.hash, + + # O(1) + # (number: int | SupportsIndex, /) -> str + 'hex': builtins.hex, + + # We allow `isinstance()` checks + 'isinstance': builtins.isinstance, + + # O(1) various -> int + # (x: ConvertibleToInt = ..., /) -> int + # (x: str | bytes | bytearray, /, base: SupportsIndex) -> int + 'int': builtins.int, + + # O(1) + # __iter__ shortcut + # (object: SupportsIter[I], /) -> I + # (object: GetItemIterable[T], /) -> Iterator[T] + # (object: Callable[[], T | None], sentinel: None, /) -> Iterator[T] + # (object: Callable[[], T], sentinel: object, /) -> Iterator[T] + 'iter': builtins.iter, + + # O(1) + # (obj: Sized, /) -> int + 'len': builtins.len, + + # XXX: consumes an iterator when calling + # O(N) for N=len(iterable) + # () -> list + # (iterable: Iterable[T], /) -> list[T] + 'list': builtins.list, + + # O(1) + # type map + # (func: Callable[[T], S], iter: Iterable[T], /) -> map[S] + # (func: Callable[[T1, T2], S], iter1: Iterable[T1], iter2: Iterable[T2], /) -> map[S] + # ... + # (func: Callable[[T1, ..., TN], S], iter1: Iterable[T1], ..., iterN: Iterable[TN],/) -> map[S] + 'map': builtins.map, + + # XXX: consumes an iterator when calling + # O(N) for N=len(iterables) + # (arg1: T, arg2: T, /, *_args: T, key: None = None) -> T + # (arg1: T, arg2: T, /, *_args: T, key: Callable[[T], T]) -> T + # (iterable: Iterable[T], /, *, key: None = None) -> T + # (iterable: Iterable[T], /, *, key: Callable[[T], T]) -> T + # (iterable: Iterable[T], /, *, key: None = None, default: T) -> T + # (iterable: Iterable[T], /, *, key: Callable[[T], T], default: T) -> T + 'max': builtins.max, + + # XXX: consumes an iterator when calling + # O(N) for N=len(iterables) + # (arg1: T, arg2: T, /, *_args: T, key: None = None) -> T + # (arg1: T, arg2: T, /, *_args: T, key: Callable[[T], T]) -> T + # (iterable: Iterable[T], /, *, key: None = None) -> T + # (iterable: Iterable[T], /, *, key: Callable[[T], T]) -> T + # (iterable: Iterable[T], /, *, key: None = None, default: T) -> T + # (iterable: Iterable[T], /, *, key: Callable[[T], T], default: T) -> T + 'min': builtins.min, + + # O(1) + # __next__ shortcut + # (i: SupportsNext[T], /) -> T + # (i: SupportsNext[T], default: V, /) -> T | V + 'next': builtins.next, + + # O(1) + # (number: int | SupportsIndex, /) -> str + 'oct': builtins.oct, + + # O(1) + # (c: str | bytes | bytearray, /) -> int + 'ord': builtins.ord, + + # XXX: can be used to easily make large numbers + # O(1) + # (base: int, exp: int, mod: int) -> int + 'pow': builtins.pow, + + # XXX: generator that escapes the VM + # O(1) + # type range(Sequence[int]) + # (stop: SupportsIndex, /) -> range + # (start: SupportsIndex, stop: SupportsIndex, step: SupportsIndex = ..., /) -> range + 'range': custom_range, + + # XXX: can consume an iterator when calling + # O(N) for N=len(sequence) + # type reversed(Iterator[T]) + # (sequence: Reversible[T], /) -> reversed[T] + # (sequence: SupportsLenAndGetItem[T], /) -> reversed[T] + 'reversed': builtins.reversed, + + # O(1) + # (number: SupportsRound1[T], ndigits: None = None) -> T + # (number: SupportsRound2[T], ndigits: SupportsIndex) -> T + 'round': builtins.round, + + # XXX: consumes an iterator when calling + # O(N) for N=len(iterable) + # type set(MutableSet[T]) + # () -> set + # (iterable: Iterable[T], /) -> set[T] + 'set': builtins.set, + + # O(1) + # type slice(Generic[A, B, C]) + # (stop: int | None, /) -> slice[int | MaybeNone, int | MaybeNone, int | MaybeNone] + 'slice': builtins.slice, + + # XXX: consumes an iterator when calling + # O(N*log(N)) for N=len(iterable) + # (iterable: Iterable[T], /, *, key: None = None, reverse: bool = False) -> list[T] + # (iterable: Iterable[T], /, *, key: Callable[[T], T], reverse: bool = False) -> list[T] + 'sorted': builtins.sorted, + + # O(1) + # type staticmethod(Generic[P, R]) + # (f: Callable[P, R], /) -> staticmethod[P, R] + 'staticmethod': builtins.staticmethod, + + # O(1) + # __str__ shortcut + # (object: object = ...) -> str + # (object: ReadableBuffer, encoding: str = ..., errors: str = ...) -> str + 'str': builtins.str, + + # XXX: consumes an iterator when calling + # O(N) for N=len(iterable) + # (iterable: Iterable[bool], /, start: int = 0) -> int + # (iterable: Iterable[T], /) -> T + # (iterable: Iterable[T], /, start: T) -> T + 'sum': builtins.sum, + + # XXX: consumes an iterator when calling + # O(N) for N=len(iterable) + # type tuple(Sequence[T]) + # (iterable: Iterable[T] = ..., /) -> tuple[T] + 'tuple': builtins.tuple, + + # O(1) + # type zip(Iterator[T]) + # (iter: Iterable[T], /, *, strict: bool = ...) -> zip[tuple[T]] + # (iter1: Iterable[T1], iter2: Iterable[T2], /, *, strict: bool = ...) -> zip[tuple[T1, T2]] + # ... + # (iter1: Iterable[T1], ..., iterN: Iterable[TN], /, *, strict: bool = ...) -> zip[tuple[T1, ..., TN]] + 'zip': builtins.zip, + + # these exceptions aren't available in Python 3.10, so don't expose them + # 'BaseExceptionGroup': builtins.BaseExceptionGroup, + # 'ExceptionGroup': builtins.ExceptionGroup, + + # expose all other exception types: + 'ArithmeticError': builtins.ArithmeticError, + 'AssertionError': builtins.AssertionError, + 'AttributeError': builtins.AttributeError, + 'BaseException': builtins.BaseException, + 'BlockingIOError': builtins.BlockingIOError, + 'BrokenPipeError': builtins.BrokenPipeError, + 'BufferError': builtins.BufferError, + 'ChildProcessError': builtins.ChildProcessError, + 'ConnectionAbortedError': builtins.ConnectionAbortedError, + 'ConnectionError': builtins.ConnectionError, + 'ConnectionRefusedError': builtins.ConnectionRefusedError, + 'ConnectionResetError': builtins.ConnectionResetError, + 'EOFError': builtins.EOFError, + 'EnvironmentError': builtins.EnvironmentError, + 'Exception': builtins.Exception, + 'FileExistsError': builtins.FileExistsError, + 'FileNotFoundError': builtins.FileNotFoundError, + 'FloatingPointError': builtins.FloatingPointError, + 'GeneratorExit': builtins.GeneratorExit, + 'IOError': builtins.IOError, + 'ImportError': builtins.ImportError, + 'IndentationError': builtins.IndentationError, + 'IndexError': builtins.IndexError, + 'InterruptedError': builtins.InterruptedError, + 'IsADirectoryError': builtins.IsADirectoryError, + 'KeyError': builtins.KeyError, + 'KeyboardInterrupt': builtins.KeyboardInterrupt, + 'LookupError': builtins.LookupError, + 'MemoryError': builtins.MemoryError, + 'ModuleNotFoundError': builtins.ModuleNotFoundError, + 'NameError': builtins.NameError, + 'NotADirectoryError': builtins.NotADirectoryError, + 'NotImplementedError': builtins.NotImplementedError, + 'OSError': builtins.OSError, + 'OverflowError': builtins.OverflowError, + 'PermissionError': builtins.PermissionError, + 'ProcessLookupError': builtins.ProcessLookupError, + 'RecursionError': builtins.RecursionError, + 'ReferenceError': builtins.ReferenceError, + 'RuntimeError': builtins.RuntimeError, + 'StopAsyncIteration': builtins.StopAsyncIteration, + 'StopIteration': builtins.StopIteration, + 'SyntaxError': builtins.SyntaxError, + 'SystemError': builtins.SystemError, + 'SystemExit': builtins.SystemExit, + 'TabError': builtins.TabError, + 'TimeoutError': builtins.TimeoutError, + 'TypeError': builtins.TypeError, + 'UnboundLocalError': builtins.UnboundLocalError, + 'UnicodeDecodeError': builtins.UnicodeDecodeError, + 'UnicodeEncodeError': builtins.UnicodeEncodeError, + 'UnicodeError': builtins.UnicodeError, + 'UnicodeTranslateError': builtins.UnicodeTranslateError, + 'ValueError': builtins.ValueError, + 'ZeroDivisionError': builtins.ZeroDivisionError, + + # expose all warning types: + 'BytesWarning': builtins.BytesWarning, + 'DeprecationWarning': builtins.DeprecationWarning, + 'EncodingWarning': builtins.EncodingWarning, + 'FutureWarning': builtins.FutureWarning, + 'ImportWarning': builtins.ImportWarning, + 'PendingDeprecationWarning': builtins.PendingDeprecationWarning, + 'ResourceWarning': builtins.ResourceWarning, + 'RuntimeWarning': builtins.RuntimeWarning, + 'SyntaxWarning': builtins.SyntaxWarning, + 'UnicodeWarning': builtins.UnicodeWarning, + 'UserWarning': builtins.UserWarning, + 'Warning': builtins.Warning, + + # All other builtins are NOT exposed: + # ===================================== + + **{ + name: _generate_disabled_builtin_func(name) + for name in DISABLED_BUILTINS + }, + } diff --git a/hathor/nanocontracts/lazy_import.py b/hathor/nanocontracts/lazy_import.py new file mode 100644 index 000000000..6f480b2f2 --- /dev/null +++ b/hathor/nanocontracts/lazy_import.py @@ -0,0 +1,79 @@ +# 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 + +from typing import TYPE_CHECKING, Callable, Generic, ParamSpec, TypeVar, final + +if TYPE_CHECKING: + from hathor.nanocontracts import Runner + + +P = ParamSpec('P') +T = TypeVar('T', covariant=True) + + +@final +class LazyImport(Generic[P, T]): + """ + A class for imports that are lazy, that is, that cannot be created statically because they depend on a Runner, + which is only available in runtime during nano executions. + """ + __slots__ = ('__name', '__create_func', '__runner') + + def __init__(self, name: str, create_func: Callable[[Runner], Callable[P, T]]) -> None: + self.__name = name + self.__create_func = create_func + + # This runner must NOT be used by normal code, only in tests. Use the `create_with_runner` method instead. + # View the `__call__` method for more info. + self.__runner: Runner | None = None + + def create_with_runner(self, runner: Runner | None) -> Callable[P, T]: + """ + Create the respective import func with the provided Runner. + The returned callable will replace the imported function in runtime. + """ + if runner is not None: + # Happy path, this might happen during contract execution, when a + # Runner is available and used to instantiate the lazy import func. + return self.__create_func(runner) + + # When there's no Runner, the import is replaced with a function that raises an exception + # to represent an unsupported import. + # This might happen for example during verification, when a Blueprint class is created + # (and therefore its imports are retrieved), but there's no Runner to actually instantiate + # the lazy import. This means lazy imports can never be called at the module-level of + # blueprints, only in method calls. + def unsupported(*args: P.args, **kwargs: P.kwargs) -> T: + raise ImportError( + f'`{self.__name}` cannot be called without a runtime, probably outside a method call' + ) + + return unsupported + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T: + """ + This method is only defined for compatibility with tests and must NOT be used in normal code. + It'll raise an AssertionError because `self.__runner` must never be set outside of tests. + + Since tests often use ad-hoc blueprint classes that are set directly in the NCCatalog, lazy imports for + these blueprints will be loaded in "compile-time", meaning they won't call `create_with_runner` because + they won't go through the normal OCB loading path. Then, tests must set the `__runner` attribute with + the respective test runner before executing nano contracts, which will call this method directly instead + of the callable returned by `create_with_runner`. + """ + assert self.__runner is not None + func = self.create_with_runner(self.__runner) + return func(*args, **kwargs) diff --git a/hathor/nanocontracts/metered_exec.py b/hathor/nanocontracts/metered_exec.py index e2e82c19d..7915ca2ec 100644 --- a/hathor/nanocontracts/metered_exec.py +++ b/hathor/nanocontracts/metered_exec.py @@ -14,12 +14,15 @@ from __future__ import annotations -from typing import Any, Callable, ParamSpec, TypeVar, cast +from typing import TYPE_CHECKING, Any, Callable, ParamSpec, TypeVar, cast from structlog import get_logger from hathor.nanocontracts.on_chain_blueprint import PYTHON_CODE_COMPAT_VERSION +if TYPE_CHECKING: + from hathor.nanocontracts import Runner + logger = get_logger() _T = TypeVar('_T') @@ -55,13 +58,13 @@ def get_fuel(self) -> int: def get_memory_limit(self) -> int: return self._memory_limit - def exec(self, source: str, /) -> dict[str, Any]: + def exec(self, source: str, /, *, runner: Runner | None) -> dict[str, Any]: """ This is equivalent to `exec(source)` but with execution metering and memory limiting. """ - from hathor.nanocontracts.custom_builtins import EXEC_BUILTINS - env: dict[str, object] = { - '__builtins__': EXEC_BUILTINS, - } + from hathor.nanocontracts.custom_builtins import get_exec_builtins + builtins = get_exec_builtins(runner=runner) + env: dict[str, object] = dict(__builtins__=builtins) + # XXX: calling compile now makes the exec step consume less fuel code = compile( source=source, @@ -78,15 +81,16 @@ def exec(self, source: str, /) -> dict[str, Any]: return env def call(self, func: Callable[_P, _T], /, *, args: _P.args) -> _T: - """ This is equivalent to `func(*args, **kwargs)` but with execution metering and memory limiting. + """ This is equivalent to `func(*args)` but with execution metering and memory limiting. """ - from hathor.nanocontracts.custom_builtins import EXEC_BUILTINS - env: dict[str, object] = { - '__builtins__': EXEC_BUILTINS, - '__func__': func, - '__args__': args, - '__result__': None, - } + # We don't need to pass the builtins here again because they're captured by + # the function on its creation when the `self.exec` method above is called. + env: dict[str, object] = dict( + __builtins__={}, + __func__=func, + __args__=args, + __result__=None, + ) # XXX: calling compile now makes the exec step consume less fuel code = compile( source='__result__ = __func__(*__args__)', diff --git a/hathor/nanocontracts/on_chain_blueprint.py b/hathor/nanocontracts/on_chain_blueprint.py index cc3348074..365277822 100644 --- a/hathor/nanocontracts/on_chain_blueprint.py +++ b/hathor/nanocontracts/on_chain_blueprint.py @@ -36,6 +36,7 @@ if TYPE_CHECKING: from hathor.conf.settings import HathorSettings + from hathor.nanocontracts import Runner from hathor.nanocontracts.storage import NCContractStorage # noqa: F401 from hathor.transaction.storage import TransactionStorage # noqa: F401 @@ -193,14 +194,14 @@ def blueprint_id(self) -> BlueprintId: """The blueprint's contract-id is it's own tx-id, this helper method just converts to the right type.""" return blueprint_id_from_bytes(self.hash) - def _load_blueprint_code_exec(self) -> tuple[object, dict[str, object]]: + def _load_blueprint_code_exec(self, *, runner: Runner | None) -> tuple[object, dict[str, object]]: """XXX: DO NOT CALL THIS METHOD UNLESS YOU REALLY KNOW WHAT IT DOES.""" from hathor.nanocontracts.metered_exec import MeteredExecutor, OutOfFuelError, OutOfMemoryError fuel = self._settings.NC_INITIAL_FUEL_TO_LOAD_BLUEPRINT_MODULE memory_limit = self._settings.NC_MEMORY_LIMIT_TO_LOAD_BLUEPRINT_MODULE metered_executor = MeteredExecutor(fuel=fuel, memory_limit=memory_limit) try: - env = metered_executor.exec(self.code.text) + env = metered_executor.exec(self.code.text, runner=runner) except OutOfFuelError as e: self.log.error('loading blueprint module failed, fuel limit exceeded') raise OCBOutOfFuelDuringLoading from e @@ -210,10 +211,11 @@ def _load_blueprint_code_exec(self) -> tuple[object, dict[str, object]]: blueprint_class = env[BLUEPRINT_CLASS_NAME] return blueprint_class, env - def _load_blueprint_code(self) -> tuple[type[Blueprint], dict[str, object]]: + def _load_blueprint_code(self, *, runner: Runner | None) -> tuple[type[Blueprint], dict[str, object]]: """This method loads the on-chain code (if not loaded) and returns the blueprint class and env.""" - if self._blueprint_loaded_env is None: - blueprint_class, env = self._load_blueprint_code_exec() + # We need to reload the env when a runner is used, because it means we're executing on a new runtime. + if self._blueprint_loaded_env is None or runner is not None: + blueprint_class, env = self._load_blueprint_code_exec(runner=runner) assert isinstance(blueprint_class, type) assert issubclass(blueprint_class, Blueprint) self._blueprint_loaded_env = blueprint_class, env @@ -221,12 +223,12 @@ def _load_blueprint_code(self) -> tuple[type[Blueprint], dict[str, object]]: def get_blueprint_object_bypass(self) -> object: """Loads the code and returns the object defined in __blueprint__""" - blueprint_class, _ = self._load_blueprint_code_exec() + blueprint_class, _ = self._load_blueprint_code_exec(runner=None) return blueprint_class - def get_blueprint_class(self) -> type[Blueprint]: + def get_blueprint_class(self, *, runner: Runner | None = None) -> type[Blueprint]: """Returns the blueprint class, loads and executes the code as needed.""" - blueprint_class, _ = self._load_blueprint_code() + blueprint_class, _ = self._load_blueprint_code(runner=runner) return blueprint_class def serialize_code(self) -> bytes: diff --git a/hathor/nanocontracts/runner/runner.py b/hathor/nanocontracts/runner/runner.py index 55ab8dc8b..21100931a 100644 --- a/hathor/nanocontracts/runner/runner.py +++ b/hathor/nanocontracts/runner/runner.py @@ -310,6 +310,7 @@ def syscall_call_another_contract_public_method( actions: Sequence[NCAction], args: tuple[Any, ...], kwargs: dict[str, Any], + forbid_fallback: bool, ) -> Any: """Call another contract's public method. This method must be called by a blueprint during an execution.""" if method_name == NC_INITIALIZE_METHOD: @@ -329,6 +330,7 @@ def syscall_call_another_contract_public_method( method_name=method_name, actions=actions, nc_args=nc_args, + forbid_fallback=forbid_fallback, ) @_forbid_syscall_from_view('proxy_call_public_method') @@ -385,6 +387,7 @@ def _unsafe_call_another_contract_public_method( actions: Sequence[NCAction], nc_args: NCArgs, skip_reentrancy_validation: bool = False, + forbid_fallback: bool = False, ) -> Any: """Invoke another contract's public method without running the usual guard‑safety checks. @@ -425,6 +428,7 @@ def _unsafe_call_another_contract_public_method( ctx=ctx, nc_args=nc_args, skip_reentrancy_validation=skip_reentrancy_validation, + forbid_fallback=forbid_fallback, ) def _reset_all_change_trackers(self) -> None: @@ -534,6 +538,7 @@ def _execute_public_method_call( ctx: Context, nc_args: NCArgs, skip_reentrancy_validation: bool = False, + forbid_fallback: bool = False, ) -> Any: """An internal method that actually execute the public method call. It is also used when a contract calls another contract. @@ -551,6 +556,8 @@ def _execute_public_method_call( args: tuple[Any, ...] if method is None: assert method_name != NC_INITIALIZE_METHOD + if forbid_fallback: + raise NCMethodNotFound(f'method `{method_name}` not found and fallback is forbidden') fallback_method = getattr(blueprint, NC_FALLBACK_METHOD, None) if fallback_method is None: raise NCMethodNotFound(f'method `{method_name}` not found and no fallback is provided') @@ -705,7 +712,7 @@ def _unsafe_call_view_method( if method is None: raise NCMethodNotFound(method_name) if not is_nc_view_method(method): - raise NCInvalidMethodCall('not a view method') + raise NCInvalidMethodCall(f'`{method_name}` is not a view method') parser = Method.from_callable(method) args = self._validate_nc_args_for_method(parser, NCParsedArgs(args, kwargs)) @@ -1008,7 +1015,7 @@ def _create_blueprint_instance(self, blueprint_id: BlueprintId, changes_tracker: """Create a new blueprint instance.""" assert self._call_info is not None env = BlueprintEnvironment(self, self._call_info.nc_logger, changes_tracker) - blueprint_class = self.tx_storage.get_blueprint_class(blueprint_id) + blueprint_class = self.tx_storage.get_blueprint_class(blueprint_id, runner=self) return blueprint_class(env) @_forbid_syscall_from_view('create_token') diff --git a/hathor/transaction/storage/transaction_storage.py b/hathor/transaction/storage/transaction_storage.py index e7a9ebe6b..009c66464 100644 --- a/hathor/transaction/storage/transaction_storage.py +++ b/hathor/transaction/storage/transaction_storage.py @@ -53,7 +53,7 @@ if TYPE_CHECKING: from hathor.conf.settings import HathorSettings - from hathor.nanocontracts import OnChainBlueprint + from hathor.nanocontracts import OnChainBlueprint, Runner from hathor.nanocontracts.blueprint import Blueprint from hathor.nanocontracts.catalog import NCBlueprintCatalog from hathor.nanocontracts.storage import NCBlockStorage, NCContractStorage, NCStorageFactory @@ -1184,7 +1184,7 @@ def get_blueprint_source(self, blueprint_id: BlueprintId) -> str: assert module is not None return inspect.getsource(module) - def get_blueprint_class(self, blueprint_id: BlueprintId) -> type[Blueprint]: + def get_blueprint_class(self, blueprint_id: BlueprintId, *, runner: Runner | None = None) -> type[Blueprint]: """Returns the blueprint class associated with the given blueprint_id. The blueprint class could be in the catalog (first search), or it could be the tx_id of an on-chain blueprint. @@ -1192,7 +1192,7 @@ def get_blueprint_class(self, blueprint_id: BlueprintId) -> type[Blueprint]: from hathor.nanocontracts import OnChainBlueprint blueprint = self._get_blueprint(blueprint_id) if isinstance(blueprint, OnChainBlueprint): - return blueprint.get_blueprint_class() + return blueprint.get_blueprint_class(runner=runner) else: return blueprint diff --git a/tests/nanocontracts/blueprints/unittest.py b/tests/nanocontracts/blueprints/unittest.py index d0993cf64..0d80cf9bc 100644 --- a/tests/nanocontracts/blueprints/unittest.py +++ b/tests/nanocontracts/blueprints/unittest.py @@ -1,13 +1,14 @@ from io import TextIOWrapper -from os import PathLike from typing import Sequence from hathor.conf.settings import HATHOR_TOKEN_UID from hathor.crypto.util import decode_address from hathor.manager import HathorManager from hathor.nanocontracts import Context +from hathor.nanocontracts.allowed_imports import ALLOWED_IMPORTS from hathor.nanocontracts.blueprint import Blueprint from hathor.nanocontracts.blueprint_env import BlueprintEnvironment +from hathor.nanocontracts.lazy_import import LazyImport from hathor.nanocontracts.nc_exec_logs import NCLogConfig from hathor.nanocontracts.on_chain_blueprint import Code, OnChainBlueprint from hathor.nanocontracts.storage import NCBlockStorage, NCMemoryStorageFactory @@ -38,10 +39,20 @@ def setUp(self): self._token_index = 1 + self._prepare_lazy_imports() + def build_manager(self) -> HathorManager: """Create a HathorManager instance.""" return self.create_peer('unittests', nc_indexes=True, nc_log_config=NCLogConfig.FAILED, wallet_index=True) + def _prepare_lazy_imports(self) -> None: + """Setup lazy imports, instantiating them with the test runner.""" + for imports in ALLOWED_IMPORTS.values(): + for import_value in imports.values(): + if isinstance(import_value, LazyImport): + # We set the internal attribute here because this attribute is supposed to be used only by tests. + import_value._LazyImport__runner = self.runner # type: ignore[attr-defined] + def get_readonly_contract(self, contract_id: ContractId) -> Blueprint: """ Returns a read-only instance of a given contract to help testing it. @@ -87,7 +98,7 @@ def _register_blueprint_class( self.nc_catalog.blueprints[blueprint_id] = blueprint_class return blueprint_id - def register_blueprint_file(self, path: PathLike[str], blueprint_id: BlueprintId | None = None) -> BlueprintId: + def register_blueprint_file(self, path: str, blueprint_id: BlueprintId | None = None) -> BlueprintId: """Register a blueprint file with an optional id, allowing contracts to be created from it.""" with open(path, 'r') as f: return self._register_blueprint_contents(f, blueprint_id) @@ -118,7 +129,7 @@ def _register_blueprint_contents( verifier = OnChainBlueprintVerifier(settings=self._settings) verifier.verify_code(ocb) - blueprint_class = ocb.get_blueprint_class() + blueprint_class = ocb.get_blueprint_class(runner=self.runner) if inject_in_class is not None: for key, value in inject_in_class.items(): setattr(blueprint_class, key, value) diff --git a/tests/nanocontracts/test_blueprints/contract_accessor_blueprint.py b/tests/nanocontracts/test_blueprints/contract_accessor_blueprint.py new file mode 100644 index 000000000..a5f275ec7 --- /dev/null +++ b/tests/nanocontracts/test_blueprints/contract_accessor_blueprint.py @@ -0,0 +1,205 @@ +# 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 hathor.nanocontracts import HATHOR_TOKEN_UID, Blueprint, get_contract +from hathor.nanocontracts.context import Context +from hathor.nanocontracts.types import ( + BlueprintId, + ContractId, + NCArgs, + NCDepositAction, + VertexId, + fallback, + public, + view, +) + + +class MyBlueprint(Blueprint): + message: str + + @public(allow_deposit=True) + def initialize(self, ctx: Context) -> None: + self.message = 'initialize called' + + def internal_method(self) -> None: + pass + + @view + def simple_view_method(self, name: str) -> str: + return f'hello "{name}" from simple view method' + + @public(allow_deposit=True) + def simple_public_method(self, ctx: Context, name: str) -> str: + actions = ctx.actions.get(HATHOR_TOKEN_UID, ()) + + # Setting the attribute makes it easier to test on OCBs with the DagBuilder, + # as the returned value is not accessible. + self.message = f'hello "{name}" from simple public method with actions: {actions}' + + return self.message + + @view + def test_simple_view_method(self, other_id: ContractId, name: str) -> str: + contract = get_contract(other_id, blueprint_id=None) + return contract \ + .prepare_view_call() \ + .simple_view_method(name) + + @public + def test_simple_public_method(self, ctx: Context, other_id: ContractId, name: str) -> str: + contract = get_contract(other_id, blueprint_id=None) + return contract \ + .prepare_public_call(NCDepositAction(amount=123, token_uid=HATHOR_TOKEN_UID)) \ + .simple_public_method(name) + + @public + def test_simple_public_method_no_actions(self, ctx: Context, other_id: ContractId, name: str) -> str: + contract = get_contract(other_id, blueprint_id=None) + return contract \ + .prepare_public_call() \ + .simple_public_method(name) + + @view + def test_multiple_view_calls_on_prepared_call(self, other_id: ContractId, name: str) -> tuple[str, str]: + prepared_call = get_contract(other_id, blueprint_id=None) \ + .prepare_view_call() + ret1 = prepared_call.simple_view_method(name + '1') + ret2 = prepared_call.simple_view_method(name + '2') + return ret1, ret2 + + @view + def test_multiple_view_calls_on_method(self, other_id: ContractId, name: str) -> tuple[str, str]: + prepared_call = get_contract(other_id, blueprint_id=None) \ + .prepare_view_call() + method = prepared_call.simple_view_method + ret1 = method(name + '1') + ret2 = method(name + '2') + return ret1, ret2 + + @public + def test_multiple_public_calls_on_prepared_call( + self, + ctx: Context, + other_id: ContractId, + name: str, + ) -> tuple[str, str]: + prepared_call = get_contract(other_id, blueprint_id=None) \ + .prepare_public_call(NCDepositAction(amount=123, token_uid=HATHOR_TOKEN_UID)) + ret1 = prepared_call.simple_public_method(name + '1') + ret2 = prepared_call.simple_public_method(name + '2') + return ret1, ret2 + + @public + def test_multiple_public_calls_on_method( + self, + ctx: Context, + other_id: ContractId, + name: str, + ) -> tuple[str, str]: + prepared_call = get_contract(other_id, blueprint_id=None) \ + .prepare_public_call(NCDepositAction(amount=123, token_uid=HATHOR_TOKEN_UID)) + method = prepared_call.simple_public_method + ret1 = method(name + '1') + ret2 = method(name + '2') + return ret1, ret2 + + @public + def test_fallback_allowed(self, ctx: Context, other_id: ContractId) -> str: + contract = get_contract(other_id, blueprint_id=None) + return contract \ + .prepare_public_call() \ + .unknown() + + @public + def test_fallback_forbidden(self, ctx: Context, other_id: ContractId) -> str: + contract = get_contract(other_id, blueprint_id=None) + return contract \ + .prepare_public_call(forbid_fallback=True) \ + .unknown() + + @view + def test_view_allow_single_blueprint_valid(self, other_id: ContractId, name: str) -> str: + my_blueprint_id = self.syscall.get_blueprint_id() + contract = get_contract(other_id, blueprint_id=my_blueprint_id) + return contract \ + .prepare_view_call() \ + .simple_view_method(name) + + @view + def test_view_allow_single_blueprint_invalid(self, other_id: ContractId, name: str) -> str: + blueprint_id = BlueprintId(VertexId(b'\x11' * 32)) + contract = get_contract(other_id, blueprint_id=blueprint_id) + return contract \ + .prepare_view_call() \ + .simple_view_method(name) + + @view + def test_view_allow_multiple_blueprints_valid(self, other_id: ContractId, name: str) -> str: + blueprint_id = BlueprintId(VertexId(b'\x11' * 32)) + my_blueprint_id = self.syscall.get_blueprint_id() + contract = get_contract(other_id, blueprint_id=(blueprint_id, my_blueprint_id)) + return contract \ + .prepare_view_call() \ + .simple_view_method(name) + + @view + def test_view_allow_multiple_blueprints_invalid(self, other_id: ContractId, name: str) -> str: + blueprint_id1 = BlueprintId(VertexId(b'\x11' * 32)) + blueprint_id2 = BlueprintId(VertexId(b'\x22' * 32)) + contract = get_contract(other_id, blueprint_id=(blueprint_id1, blueprint_id2)) + return contract \ + .prepare_view_call() \ + .simple_view_method(name) + + @public + def test_public_allow_single_blueprint_valid(self, ctx: Context, other_id: ContractId, name: str) -> str: + my_blueprint_id = self.syscall.get_blueprint_id() + contract = get_contract(other_id, blueprint_id=my_blueprint_id) + return contract \ + .prepare_public_call() \ + .simple_public_method(name) + + @public + def test_public_allow_single_blueprint_invalid(self, ctx: Context, other_id: ContractId, name: str) -> str: + blueprint_id = BlueprintId(VertexId(b'\x11' * 32)) + contract = get_contract(other_id, blueprint_id=blueprint_id) + return contract \ + .prepare_public_call() \ + .simple_public_method(name) + + @public + def test_public_allow_multiple_blueprints_valid(self, ctx: Context, other_id: ContractId, name: str) -> str: + blueprint_id = BlueprintId(VertexId(b'\x11' * 32)) + my_blueprint_id = self.syscall.get_blueprint_id() + contract = get_contract(other_id, blueprint_id=(blueprint_id, my_blueprint_id)) + return contract \ + .prepare_public_call() \ + .simple_public_method(name) + + @public + def test_public_allow_multiple_blueprints_invalid(self, ctx: Context, other_id: ContractId, name: str) -> str: + blueprint_id1 = BlueprintId(VertexId(b'\x11' * 32)) + blueprint_id2 = BlueprintId(VertexId(b'\x22' * 32)) + contract = get_contract(other_id, blueprint_id=(blueprint_id1, blueprint_id2)) + return contract \ + .prepare_public_call() \ + .simple_public_method(name) + + @fallback + def fallback(self, ctx: Context, method_name: str, nc_args: NCArgs) -> str: + return f'fallback called for method `{method_name}`' + + +__blueprint__ = MyBlueprint diff --git a/tests/nanocontracts/test_contract_accessor.py b/tests/nanocontracts/test_contract_accessor.py new file mode 100644 index 000000000..cb500c0c5 --- /dev/null +++ b/tests/nanocontracts/test_contract_accessor.py @@ -0,0 +1,424 @@ +# Copyright 2025 Hathor Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import inspect +import re + +import pytest + +from hathor.nanocontracts import HATHOR_TOKEN_UID, NCFail +from hathor.nanocontracts.types import NCDepositAction +from hathor.transaction import Transaction +from hathor.transaction.nc_execution_state import NCExecutionState +from tests import unittest +from tests.dag_builder.builder import TestDAGBuilder +from tests.nanocontracts.blueprints.unittest import BlueprintTestCase +from tests.nanocontracts.test_blueprint import STR_NC_TYPE +from tests.nanocontracts.test_blueprints import contract_accessor_blueprint + + +class TestContractAccessor(BlueprintTestCase): + def setUp(self) -> None: + super().setUp() + + self.blueprint_id = self.register_blueprint_file(inspect.getfile(contract_accessor_blueprint)) + self.contract_id1 = self.gen_random_contract_id() + self.contract_id2 = self.gen_random_contract_id() + + action = NCDepositAction(amount=1000, token_uid=HATHOR_TOKEN_UID) + self.runner.create_contract(self.contract_id1, self.blueprint_id, self.create_context(actions=[action])) + self.runner.create_contract(self.contract_id2, self.blueprint_id, self.create_context()) + + def test_simple_view_method(self) -> None: + ret = self.runner.call_view_method( + self.contract_id1, 'test_simple_view_method', other_id=self.contract_id2, name='alice' + ) + assert ret == 'hello "alice" from simple view method' + + def test_simple_public_method(self) -> None: + ret = self.runner.call_public_method( + self.contract_id1, + 'test_simple_public_method', + self.create_context(), + other_id=self.contract_id2, + name='bob', + ) + assert ret == ( + "hello \"bob\" from simple public method with actions: (NCDepositAction(token_uid=b'\\x00', amount=123),)" + ) + + def test_simple_public_method_no_actions(self) -> None: + ret = self.runner.call_public_method( + self.contract_id1, + 'test_simple_public_method_no_actions', + self.create_context(), + other_id=self.contract_id2, + name='bob', + ) + assert ret == ( + "hello \"bob\" from simple public method with actions: ()" + ) + + def test_multiple_view_calls_on_prepared_call(self) -> None: + ret = self.runner.call_view_method( + self.contract_id1, 'test_multiple_view_calls_on_prepared_call', other_id=self.contract_id2, name='alice' + ) + assert ret == ( + 'hello "alice1" from simple view method', + 'hello "alice2" from simple view method', + ) + + def test_multiple_view_calls_on_method(self) -> None: + ret = self.runner.call_view_method( + self.contract_id1, 'test_multiple_view_calls_on_method', other_id=self.contract_id2, name='alice' + ) + assert ret == ( + 'hello "alice1" from simple view method', + 'hello "alice2" from simple view method', + ) + + def test_multiple_public_calls_on_prepared_call(self) -> None: + msg = ( + f'prepared public method for contract `{self.contract_id2.hex()}` was already used, ' + f'you must use `prepare_public_call` on the contract to call it again' + ) + with pytest.raises(NCFail, match=re.escape(msg)): + self.runner.call_public_method( + self.contract_id1, + 'test_multiple_public_calls_on_prepared_call', + self.create_context(), + other_id=self.contract_id2, + name='bob', + ) + + def test_multiple_public_calls_on_method(self) -> None: + msg = ( + 'accessor for public method `simple_public_method` was already used, ' + 'you must use `prepare_public_call` on the contract to call it again' + ) + with pytest.raises(NCFail, match=re.escape(msg)): + self.runner.call_public_method( + self.contract_id1, + 'test_multiple_public_calls_on_method', + self.create_context(), + other_id=self.contract_id2, + name='bob', + ) + + def test_fallback_allowed(self) -> None: + ret = self.runner.call_public_method( + self.contract_id1, + 'test_fallback_allowed', + self.create_context(), + other_id=self.contract_id2, + ) + assert ret == 'fallback called for method `unknown`' + + def test_fallback_forbidden(self) -> None: + msg = 'method `unknown` not found and fallback is forbidden' + with pytest.raises(NCFail, match=re.escape(msg)): + self.runner.call_public_method( + self.contract_id1, + 'test_fallback_forbidden', + self.create_context(), + other_id=self.contract_id2, + ) + + def test_view_allow_single_blueprint_valid(self) -> None: + ret = self.runner.call_view_method( + self.contract_id1, 'test_view_allow_single_blueprint_valid', other_id=self.contract_id2, name='alice' + ) + assert ret == 'hello "alice" from simple view method' + + def test_view_allow_single_blueprint_invalid(self) -> None: + blueprint_id = b'\x11' * 32 + msg = ( + f"expected blueprint to be one of `('{blueprint_id.hex()}',)`, " + f'got `{self.blueprint_id.hex()}` for contract `{self.contract_id2.hex()}`' + ) + with pytest.raises(NCFail, match=re.escape(msg)): + self.runner.call_view_method( + self.contract_id1, 'test_view_allow_single_blueprint_invalid', other_id=self.contract_id2, name='alice' + ) + + def test_view_allow_multiple_blueprints_valid(self) -> None: + ret = self.runner.call_view_method( + self.contract_id1, 'test_view_allow_multiple_blueprints_valid', other_id=self.contract_id2, name='alice' + ) + assert ret == 'hello "alice" from simple view method' + + def test_view_allow_multiple_blueprints_invalid(self) -> None: + blueprint_id1 = b'\x11' * 32 + blueprint_id2 = b'\x22' * 32 + msg = ( + f"expected blueprint to be one of `('{blueprint_id1.hex()}', '{blueprint_id2.hex()}')`, " + f"got `{self.blueprint_id.hex()}` for contract `{self.contract_id2.hex()}`" + ) + with pytest.raises(NCFail, match=re.escape(msg)): + self.runner.call_view_method( + self.contract_id1, + 'test_view_allow_multiple_blueprints_invalid', + other_id=self.contract_id2, + name='alice', + ) + + def test_public_allow_single_blueprint_valid(self) -> None: + ret = self.runner.call_public_method( + self.contract_id1, + 'test_public_allow_single_blueprint_valid', + self.create_context(), + other_id=self.contract_id2, + name='alice', + ) + assert ret == 'hello "alice" from simple public method with actions: ()' + + def test_public_allow_single_blueprint_invalid(self) -> None: + blueprint_id = b'\x11' * 32 + msg = ( + f"expected blueprint to be one of `('{blueprint_id.hex()}',)`, " + f'got `{self.blueprint_id.hex()}` for contract `{self.contract_id2.hex()}`' + ) + with pytest.raises(NCFail, match=re.escape(msg)): + self.runner.call_public_method( + self.contract_id1, + 'test_public_allow_single_blueprint_invalid', + self.create_context(), + other_id=self.contract_id2, + name='alice', + ) + + def test_public_allow_multiple_blueprints_valid(self) -> None: + ret = self.runner.call_public_method( + self.contract_id1, + 'test_public_allow_multiple_blueprints_valid', + self.create_context(), + other_id=self.contract_id2, + name='alice', + ) + assert ret == 'hello "alice" from simple public method with actions: ()' + + def test_public_allow_multiple_blueprints_invalid(self) -> None: + blueprint_id1 = b'\x11' * 32 + blueprint_id2 = b'\x22' * 32 + msg = ( + f"expected blueprint to be one of `('{blueprint_id1.hex()}', '{blueprint_id2.hex()}')`, " + f"got `{self.blueprint_id.hex()}` for contract `{self.contract_id2.hex()}`" + ) + with pytest.raises(NCFail, match=re.escape(msg)): + self.runner.call_public_method( + self.contract_id1, + 'test_public_allow_multiple_blueprints_invalid', + self.create_context(), + other_id=self.contract_id2, + name='alice', + ) + + def test_accessor_on_ocb(self) -> None: + """ + We have to make a test for OCB with the DagBuilder because the `get_contract` lazy import + is instantiated through different paths for test blueprints, builtin blueprints, and OCBs. + """ + dag_builder = TestDAGBuilder.from_manager(self.manager) + private_key = unittest.OCB_TEST_PRIVKEY.hex() + password = unittest.OCB_TEST_PASSWORD.hex() + + artifacts = dag_builder.build_from_str(f''' + blockchain genesis b[1..13] + b10 < dummy + + ocb.ocb_private_key = "{private_key}" + ocb.ocb_password = "{password}" + ocb.ocb_code = contract_accessor_blueprint.py, MyBlueprint + + nc1.nc_id = ocb + nc1.nc_method = initialize() + nc1.nc_deposit = 1000 HTR + + nc2.nc_id = ocb + nc2.nc_method = initialize() + + nc3.nc_id = nc1 + nc3.nc_method = test_simple_public_method(`nc2`, "alice") + + ocb <-- b11 + nc1 <-- nc2 <-- b12 + nc2 <-- nc3 <-- b13 + ''') + + artifacts.propagate_with(self.manager) + nc1, nc2, nc3 = artifacts.get_typed_vertices(('nc1', 'nc2', 'nc3'), Transaction) + + assert nc1.get_metadata().nc_execution is NCExecutionState.SUCCESS + assert nc2.get_metadata().nc_execution is NCExecutionState.SUCCESS + assert nc3.get_metadata().nc_execution is NCExecutionState.SUCCESS + + storage1 = self.manager.get_best_block_nc_storage(nc1.hash) + storage2 = self.manager.get_best_block_nc_storage(nc2.hash) + + assert storage1.get_obj(b'message', STR_NC_TYPE) == 'initialize called' + assert storage2.get_obj(b'message', STR_NC_TYPE) == ( + "hello \"alice\" from simple public method with actions: " + "(NCDepositAction(token_uid=b'\\x00', amount=123),)" + ) + + @pytest.mark.xfail(strict=True, reason=''' + Support for builtin blueprints with lazy functions is currently not implemented, since it's not a priority + while we don't have any builtin blueprints. In order to support it, we must change the NCCatalog to hold + blueprint files instead of classes, because the class has to be loaded in runtime, just like OCBs, so we can + inject the runner for the lazy imports. + This means we have to update the `TransactionStorage.get_blueprint_class` method to pass the Runner to the + builtin blueprint class (just like it already does for OCBs). Currently, it jus returns the preloaded class. + ''') + def test_accessor_on_builtin(self) -> None: + """ + We have to make a test for a builtin blueprint with the DagBuilder because the `get_contract` lazy import + is instantiated through different paths for test blueprints, builtin blueprints, and OCBs. + """ + builtin_blueprint_id = self._register_blueprint_class(contract_accessor_blueprint.MyBlueprint) + dag_builder = TestDAGBuilder.from_manager(self.manager) + artifacts = dag_builder.build_from_str(f''' + blockchain genesis b[1..12] + b10 < dummy + + nc1.nc_id = "{builtin_blueprint_id.hex()}" + nc1.nc_method = initialize() + nc1.nc_deposit = 1000 HTR + + nc2.nc_id = "{builtin_blueprint_id.hex()}" + nc2.nc_method = initialize() + + nc3.nc_id = nc1 + nc3.nc_method = test_simple_public_method(`nc2`, "alice") + + nc1 <-- nc2 <-- b11 + nc2 <-- nc3 <-- b12 + ''') + + artifacts.propagate_with(self.manager) + nc1, nc2, nc3 = artifacts.get_typed_vertices(('nc1', 'nc2', 'nc3'), Transaction) + + assert nc1.get_metadata().nc_execution is NCExecutionState.SUCCESS + assert nc2.get_metadata().nc_execution is NCExecutionState.SUCCESS + assert nc3.get_metadata().nc_execution is NCExecutionState.SUCCESS + + storage1 = self.manager.get_best_block_nc_storage(nc1.hash) + storage2 = self.manager.get_best_block_nc_storage(nc2.hash) + + assert storage1.get_obj(b'message', STR_NC_TYPE) == 'initialize called' + assert storage2.get_obj(b'message', STR_NC_TYPE) == ( + "hello \"alice\" from simple public method with actions: " + "(NCDepositAction(token_uid=b'\\x00', amount=123),)" + ) + + def test_import_during_runtime(self) -> None: + """ + Make sure that importing `get_contract` during a method call is also supported, + that is, not at module-level. + """ + dag_builder = TestDAGBuilder.from_manager(self.manager) + private_key = unittest.OCB_TEST_PRIVKEY.hex() + password = unittest.OCB_TEST_PASSWORD.hex() + + artifacts = dag_builder.build_from_str(f''' + blockchain genesis b[1..13] + b10 < dummy + + ocb.ocb_private_key = "{private_key}" + ocb.ocb_password = "{password}" + + nc1.nc_id = ocb + nc1.nc_method = initialize(null) + + nc2.nc_id = ocb + nc2.nc_method = initialize(`nc1`) + + ocb <-- b11 + nc1 <-- b12 + nc1 <-- nc2 <-- b13 + + ocb.ocb_code = ``` + from hathor.nanocontracts import Blueprint + from hathor.nanocontracts.context import Context + from hathor.nanocontracts.types import public, ContractId + + class MyBlueprint(Blueprint): + message: str + + @public + def initialize(self, ctx: Context, other_id: ContractId | None) -> None: + self.message = 'initialize called' + if other_id is not None: + from hathor.nanocontracts import get_contract + other = get_contract(other_id, blueprint_id=None) + other.prepare_public_call().public_method() + + @public + def public_method(self, ctx: Context) -> None: + self.message = 'public_method called' + + __blueprint__ = MyBlueprint + ``` + ''') + + artifacts.propagate_with(self.manager) + nc1, nc2 = artifacts.get_typed_vertices(('nc1', 'nc2'), Transaction) + + assert nc1.get_metadata().nc_execution is NCExecutionState.SUCCESS + assert nc2.get_metadata().nc_execution is NCExecutionState.SUCCESS + + storage1 = self.manager.get_best_block_nc_storage(nc1.hash) + storage2 = self.manager.get_best_block_nc_storage(nc2.hash) + + assert storage1.get_obj(b'message', STR_NC_TYPE) == 'public_method called' + assert storage2.get_obj(b'message', STR_NC_TYPE) == 'initialize called' + + def test_get_contract_at_module_level(self) -> None: + dag_builder = TestDAGBuilder.from_manager(self.manager) + private_key = unittest.OCB_TEST_PRIVKEY.hex() + password = unittest.OCB_TEST_PASSWORD.hex() + + artifacts = dag_builder.build_from_str(f''' + blockchain genesis b[1..12] + b10 < dummy + + ocb.ocb_private_key = "{private_key}" + ocb.ocb_password = "{password}" + tx1 <-- ocb <-- b11 + + ocb.ocb_code = ``` + from hathor.nanocontracts import Blueprint + from hathor.nanocontracts.context import Context + from hathor.nanocontracts.types import public, ContractId + from hathor.nanocontracts import get_contract + + x = get_contract() + + class MyBlueprint(Blueprint): + @public + def initialize(self, ctx: Context) -> None: + pass + + __blueprint__ = MyBlueprint + ``` + ''') + + artifacts.propagate_with(self.manager, up_to='tx1') + + with pytest.raises(Exception) as e: + artifacts.propagate_with(self.manager) + + assert isinstance(e.value.__cause__, ImportError) + assert e.value.__cause__.args[0] == ( + '`get_contract` cannot be called without a runtime, probably outside a method call' + ) diff --git a/tests/nanocontracts/test_custom_import.py b/tests/nanocontracts/test_custom_import.py index 876c15f33..80952291b 100644 --- a/tests/nanocontracts/test_custom_import.py +++ b/tests/nanocontracts/test_custom_import.py @@ -17,10 +17,13 @@ from textwrap import dedent from unittest.mock import ANY, Mock, call, patch -from hathor.nanocontracts.custom_builtins import EXEC_BUILTINS +import pytest + +from hathor.nanocontracts.custom_builtins import get_exec_builtins from tests.nanocontracts.blueprints.unittest import BlueprintTestCase +@pytest.mark.skip # TODO: Fix this test class TestCustomImport(BlueprintTestCase): def test_custom_import_is_used(self) -> None: """Guarantee our custom import function is being called, instead of the builtin one.""" @@ -42,8 +45,9 @@ def initialize(self, ctx: Context) -> None: ''' # Wrap our custom builtin so we can spy its calls - wrapped_import_function = Mock(wraps=EXEC_BUILTINS['__import__']) - EXEC_BUILTINS['__import__'] = wrapped_import_function + builtins = get_exec_builtins(runner=None) + wrapped_import_function = Mock(wraps=builtins['__import__']) + builtins['__import__'] = wrapped_import_function # Before being used, the function is uncalled wrapped_import_function.assert_not_called() diff --git a/tests/nanocontracts/test_exposed_properties.py b/tests/nanocontracts/test_exposed_properties.py index 20abfd420..e415f3ac1 100644 --- a/tests/nanocontracts/test_exposed_properties.py +++ b/tests/nanocontracts/test_exposed_properties.py @@ -5,7 +5,7 @@ from hathor.nanocontracts import Blueprint, Context, public from hathor.nanocontracts.allowed_imports import ALLOWED_IMPORTS -from hathor.nanocontracts.custom_builtins import EXEC_BUILTINS +from hathor.nanocontracts.custom_builtins import get_exec_builtins from tests.nanocontracts.blueprints.unittest import BlueprintTestCase MAX_DEPTH = 20 @@ -44,6 +44,7 @@ 'hathor.nanocontracts.exception.NCFail.args', 'hathor.nanocontracts.exception.NCFail.some_new_attribute', 'hathor.nanocontracts.exception.NCFail.with_traceback', + 'hathor.nanocontracts.get_contract.some_new_attribute', 'hathor.nanocontracts.types.Address.capitalize', 'hathor.nanocontracts.types.Address.center', 'hathor.nanocontracts.types.Address.count', @@ -382,14 +383,15 @@ def check(self, ctx: Context) -> list[str]: mutable_props.extend(search_writeable_properties(MyBlueprint, 'MyBlueprint')) mutable_props.extend(search_writeable_properties(self, 'self')) mutable_props.extend(search_writeable_properties(ctx, 'ctx')) - custom_import = EXEC_BUILTINS['__import__'] + builtins = get_exec_builtins(runner=None) # TODO: This does not cover the lazy imports + custom_import = builtins['__import__'] for module_name, import_names in ALLOWED_IMPORTS.items(): module = custom_import(module_name, fromlist=list(import_names)) for import_name in import_names: obj = getattr(module, import_name) obj_name = f'{module_name}.{import_name}' mutable_props.extend(search_writeable_properties(obj, obj_name)) - for builtin_name, builtin_obj in EXEC_BUILTINS.items(): + for builtin_name, builtin_obj in builtins.items(): if should_skip_attr(builtin_name): continue mutable_props.extend(search_writeable_properties(builtin_obj, builtin_name)) diff --git a/tests/nanocontracts/test_types_across_contracts.py b/tests/nanocontracts/test_types_across_contracts.py index eb1fb4e94..74df03400 100644 --- a/tests/nanocontracts/test_types_across_contracts.py +++ b/tests/nanocontracts/test_types_across_contracts.py @@ -14,7 +14,7 @@ import pytest -from hathor.nanocontracts import Blueprint, Context, NCFail, public +from hathor.nanocontracts import Blueprint, Context, NCFail, get_contract, public from hathor.nanocontracts.types import ContractId, NCArgs, fallback, view from tests.nanocontracts.blueprints.unittest import BlueprintTestCase @@ -42,27 +42,39 @@ def view_method_wrong_return_type(self) -> int: @public def call_public_wrong_arg_type(self, ctx: Context, other_id: ContractId) -> None: - self.syscall.call_public_method(other_id, 'public_method', [], 'abc') + get_contract(other_id, blueprint_id=None) \ + .prepare_public_call() \ + .public_method('abc') @public def call_public_wrong_kwarg_type(self, ctx: Context, other_id: ContractId) -> None: - self.syscall.call_public_method(other_id, 'public_method', [], a='abc') + get_contract(other_id, blueprint_id=None) \ + .prepare_public_call() \ + .public_method(a='abc') @public def call_public_wrong_return_type(self, ctx: Context, other_id: ContractId) -> None: - self.syscall.call_public_method(other_id, 'public_method_wrong_return_type', []) + get_contract(other_id, blueprint_id=None) \ + .prepare_public_call() \ + .public_method_wrong_return_type() @view def call_view_wrong_arg_type(self, other_id: ContractId) -> None: - self.syscall.call_view_method(other_id, 'view_method', 'abc') + get_contract(other_id, blueprint_id=None) \ + .prepare_view_call() \ + .view_method('abc') @view def call_view_wrong_kwarg_type(self, other_id: ContractId) -> None: - self.syscall.call_view_method(other_id, 'view_method', a='abc') + get_contract(other_id, blueprint_id=None) \ + .prepare_view_call() \ + .view_method(a='abc') @view def call_view_wrong_return_type(self, other_id: ContractId) -> None: - self.syscall.call_view_method(other_id, 'view_method_wrong_return_type') + get_contract(other_id, blueprint_id=None) \ + .prepare_view_call() \ + .view_method_wrong_return_type() @fallback def fallback(self, ctx: Context, method_name: str, nc_args: NCArgs) -> int: @@ -71,7 +83,9 @@ def fallback(self, ctx: Context, method_name: str, nc_args: NCArgs) -> int: @public def call_mutate_list(self, ctx: Context, other_id: ContractId) -> None: items = [1, 2, 3] - self.syscall.call_public_method(other_id, 'mutate_list', [], items) + get_contract(other_id, blueprint_id=None) \ + .prepare_public_call() \ + .mutate_list(items) assert items == [1, 2, 3] @public