From eb235f223e33da4cea7dc10f45dec5108af54ab2 Mon Sep 17 00:00:00 2001 From: Jan Segre Date: Sat, 6 Sep 2025 16:24:18 +0200 Subject: [PATCH] feat(nano): use decorator to export a Blueprint --- hathor/nanocontracts/__init__.py | 3 +- hathor/nanocontracts/allowed_imports.py | 1 + hathor/nanocontracts/custom_builtins.py | 4 +- hathor/nanocontracts/on_chain_blueprint.py | 9 ++-- hathor/nanocontracts/types.py | 17 +++++++ .../on_chain_blueprint_verifier.py | 50 +++++++++++++++---- tests/nanocontracts/test_blueprints/bet.py | 5 +- .../nanocontracts/test_exposed_properties.py | 1 + 8 files changed, 67 insertions(+), 23 deletions(-) diff --git a/hathor/nanocontracts/__init__.py b/hathor/nanocontracts/__init__.py index b91d4ddb5..42beabc97 100644 --- a/hathor/nanocontracts/__init__.py +++ b/hathor/nanocontracts/__init__.py @@ -19,7 +19,7 @@ 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 TokenUid, VertexId, fallback, public, view +from hathor.nanocontracts.types import TokenUid, VertexId, export, fallback, public, view # Identifier used in metadata's voided_by when a Nano Contract method fails. NC_EXECUTION_FAIL_ID: bytes = b'nc-fail' @@ -38,6 +38,7 @@ 'public', 'fallback', 'view', + 'export', 'NC_EXECUTION_FAIL_ID', 'HATHOR_TOKEN_UID', ] diff --git a/hathor/nanocontracts/allowed_imports.py b/hathor/nanocontracts/allowed_imports.py index c72e22879..d1c09eeff 100644 --- a/hathor/nanocontracts/allowed_imports.py +++ b/hathor/nanocontracts/allowed_imports.py @@ -46,6 +46,7 @@ SignedData=nc.types.SignedData, public=nc.public, view=nc.view, + export=nc.export, fallback=nc.fallback, Address=nc.types.Address, Amount=nc.types.Amount, diff --git a/hathor/nanocontracts/custom_builtins.py b/hathor/nanocontracts/custom_builtins.py index 53fca9c12..abb40ac02 100644 --- a/hathor/nanocontracts/custom_builtins.py +++ b/hathor/nanocontracts/custom_builtins.py @@ -36,7 +36,7 @@ from hathor.nanocontracts.allowed_imports import ALLOWED_IMPORTS from hathor.nanocontracts.exception import NCDisabledBuiltinError from hathor.nanocontracts.faux_immutable import FauxImmutable -from hathor.nanocontracts.on_chain_blueprint import BLUEPRINT_CLASS_NAME +from hathor.nanocontracts.types import BLUEPRINT_EXPORT_NAME T = TypeVar('T') Ts = TypeVarTuple('Ts') @@ -503,7 +503,7 @@ def filter(function: None | Callable[[T], object], iterable: Iterable[T]) -> Ite # 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, + '__name__': BLUEPRINT_EXPORT_NAME, # make it always True, which is how we'll normally run anyway '__debug__': True, diff --git a/hathor/nanocontracts/on_chain_blueprint.py b/hathor/nanocontracts/on_chain_blueprint.py index cc3348074..1cf3e0bc2 100644 --- a/hathor/nanocontracts/on_chain_blueprint.py +++ b/hathor/nanocontracts/on_chain_blueprint.py @@ -30,7 +30,7 @@ from hathor.nanocontracts.blueprint import Blueprint from hathor.nanocontracts.exception import OCBOutOfFuelDuringLoading, OCBOutOfMemoryDuringLoading from hathor.nanocontracts.method import Method -from hathor.nanocontracts.types import BlueprintId, blueprint_id_from_bytes +from hathor.nanocontracts.types import BLUEPRINT_EXPORT_NAME, BlueprintId, blueprint_id_from_bytes from hathor.transaction import Transaction, TxInput, TxOutput, TxVersion from hathor.transaction.util import VerboseCallback, int_to_bytes, unpack, unpack_len @@ -44,9 +44,6 @@ # used to allow new versions of the serialization format in the future ON_CHAIN_BLUEPRINT_VERSION: int = 1 -# this is the name we expect the source code to expose for the Blueprint class -BLUEPRINT_CLASS_NAME: str = '__blueprint__' - # source compatibility with Python 3.11 PYTHON_CODE_COMPAT_VERSION = (3, 11) @@ -207,7 +204,7 @@ def _load_blueprint_code_exec(self) -> tuple[object, dict[str, object]]: except OutOfMemoryError as e: self.log.error('loading blueprint module failed, memory limit exceeded') raise OCBOutOfMemoryDuringLoading from e - blueprint_class = env[BLUEPRINT_CLASS_NAME] + blueprint_class = env[BLUEPRINT_EXPORT_NAME] return blueprint_class, env def _load_blueprint_code(self) -> tuple[type[Blueprint], dict[str, object]]: @@ -220,7 +217,7 @@ def _load_blueprint_code(self) -> tuple[type[Blueprint], dict[str, object]]: return self._blueprint_loaded_env def get_blueprint_object_bypass(self) -> object: - """Loads the code and returns the object defined in __blueprint__""" + """Loads the code and returns the object exported with @export""" blueprint_class, _ = self._load_blueprint_code_exec() return blueprint_class diff --git a/hathor/nanocontracts/types.py b/hathor/nanocontracts/types.py index 8a903fa46..814418c86 100644 --- a/hathor/nanocontracts/types.py +++ b/hathor/nanocontracts/types.py @@ -102,6 +102,7 @@ class Timestamp(int, metaclass=FauxImmutableMeta): CallerId: TypeAlias = Address | ContractId T = TypeVar('T') +B = TypeVar('B', bound=type) NC_INITIALIZE_METHOD: str = 'initialize' NC_FALLBACK_METHOD: str = 'fallback' @@ -110,6 +111,9 @@ class Timestamp(int, metaclass=FauxImmutableMeta): 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' @@ -291,6 +295,19 @@ def view(fn: Callable) -> Callable: 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, /, diff --git a/hathor/verification/on_chain_blueprint_verifier.py b/hathor/verification/on_chain_blueprint_verifier.py index 81159288f..0e800a916 100644 --- a/hathor/verification/on_chain_blueprint_verifier.py +++ b/hathor/verification/on_chain_blueprint_verifier.py @@ -25,7 +25,8 @@ from hathor.nanocontracts.allowed_imports import ALLOWED_IMPORTS from hathor.nanocontracts.custom_builtins import AST_NAME_BLACKLIST from hathor.nanocontracts.exception import NCInvalidPubKey, NCInvalidSignature, OCBInvalidScript, OCBPubKeyNotAllowed -from hathor.nanocontracts.on_chain_blueprint import BLUEPRINT_CLASS_NAME, PYTHON_CODE_COMPAT_VERSION +from hathor.nanocontracts.on_chain_blueprint import PYTHON_CODE_COMPAT_VERSION +from hathor.nanocontracts.types import BLUEPRINT_EXPORT_NAME class _RestrictionsVisitor(ast.NodeVisitor): @@ -56,8 +57,8 @@ def visit_Name(self, node: ast.Name) -> None: assert isinstance(node.id, str) if node.id in AST_NAME_BLACKLIST: raise SyntaxError(f'Usage or reference to {node.id} is not allowed.') - assert BLUEPRINT_CLASS_NAME == '__blueprint__', 'sanity check for the rule below' - if '__' in node.id and node.id != BLUEPRINT_CLASS_NAME: + assert BLUEPRINT_EXPORT_NAME == '__blueprint__', 'sanity check for the rule below' + if '__' in node.id and node.id != BLUEPRINT_EXPORT_NAME: raise SyntaxError('Using dunder names is not allowed.') self.generic_visit(node) @@ -113,6 +114,29 @@ def visit_Name(self, node: ast.Name) -> None: self.generic_visit(node) +class _SearchDecorator(ast.NodeVisitor): + def __init__(self, name: str) -> None: + self.search_name = name + self.found = False + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + for decorator in node.decorator_list: + decorator_name: str + if isinstance(decorator, ast.Name): + decorator_name = decorator.id + elif isinstance(decorator, ast.Call): + if isinstance(decorator.func, ast.Name): + decorator_name = decorator.func.id + else: + continue # don't search invalid cases + else: + continue # don't search invalid cases + if decorator_name == self.search_name: + self.found = True + return + self.generic_visit(node) + + class OnChainBlueprintVerifier: __slots__ = ('_settings',) @@ -222,8 +246,8 @@ def _verify_python_script(self, tx: OnChainBlueprint) -> None: def _verify_raw_text(self, tx: OnChainBlueprint) -> None: """Verify that the script does not use any forbidden text.""" - assert BLUEPRINT_CLASS_NAME == '__blueprint__', 'sanity check for the rule below' - if '__' in tx.code.text.replace(BLUEPRINT_CLASS_NAME, ''): + assert BLUEPRINT_EXPORT_NAME == '__blueprint__', 'sanity check for the rule below' + if '__' in tx.code.text.replace(BLUEPRINT_EXPORT_NAME, ''): raise SyntaxError('script contains dunder text') def _verify_script_restrictions(self, tx: OnChainBlueprint) -> None: @@ -232,16 +256,20 @@ def _verify_script_restrictions(self, tx: OnChainBlueprint) -> None: def _verify_has_blueprint_attr(self, tx: OnChainBlueprint) -> None: """Verify that the script defines a __blueprint__ attribute.""" - search_name = _SearchName(BLUEPRINT_CLASS_NAME) - search_name.visit(self._get_python_code_ast(tx)) - if not search_name.found: - raise OCBInvalidScript(f'Could not find {BLUEPRINT_CLASS_NAME} object') + blueprint_ast = self._get_python_code_ast(tx) + search_name = _SearchName(BLUEPRINT_EXPORT_NAME) + search_name.visit(blueprint_ast) + search_decorator = _SearchDecorator('export') + search_decorator.visit(blueprint_ast) + either_found = search_name.found or search_decorator.found + if not either_found: + raise OCBInvalidScript('Could not find a main Blueprint definition') def _verify_blueprint_type(self, tx: OnChainBlueprint) -> None: """Verify that the __blueprint__ is a Blueprint, this will load and execute the blueprint code.""" from hathor.nanocontracts.blueprint import Blueprint blueprint_class = tx.get_blueprint_object_bypass() if not isinstance(blueprint_class, type): - raise OCBInvalidScript(f'{BLUEPRINT_CLASS_NAME} is not a class') + raise OCBInvalidScript(f'{BLUEPRINT_EXPORT_NAME} is not a class') if not issubclass(blueprint_class, Blueprint): - raise OCBInvalidScript(f'{BLUEPRINT_CLASS_NAME} is not a Blueprint subclass') + raise OCBInvalidScript(f'{BLUEPRINT_EXPORT_NAME} is not a Blueprint subclass') diff --git a/tests/nanocontracts/test_blueprints/bet.py b/tests/nanocontracts/test_blueprints/bet.py index 696245549..01178f116 100644 --- a/tests/nanocontracts/test_blueprints/bet.py +++ b/tests/nanocontracts/test_blueprints/bet.py @@ -27,6 +27,7 @@ Timestamp, TokenUid, TxOutputScript, + export, public, view, ) @@ -63,6 +64,7 @@ class InvalidOracleSignature(NCFail): pass +@export class Bet(Blueprint): """Bet blueprint with final result provided by an oracle. @@ -221,6 +223,3 @@ def get_winner_amount(self, address: Address) -> Amount: address_total = self.bets_address.get((self.final_result, address), 0) percentage = address_total / result_total return Amount(floor(percentage * self.total)) - - -__blueprint__ = Bet diff --git a/tests/nanocontracts/test_exposed_properties.py b/tests/nanocontracts/test_exposed_properties.py index fca936821..c0e39dd27 100644 --- a/tests/nanocontracts/test_exposed_properties.py +++ b/tests/nanocontracts/test_exposed_properties.py @@ -116,6 +116,7 @@ 'hathor.nanocontracts.types.SignedData.checksig', 'hathor.nanocontracts.types.SignedData.get_data_bytes', 'hathor.nanocontracts.types.SignedData.some_new_attribute', + 'hathor.nanocontracts.types.export.some_new_attribute', 'hathor.nanocontracts.types.fallback.some_new_attribute', 'hathor.nanocontracts.types.public.some_new_attribute', 'hathor.nanocontracts.types.view.some_new_attribute',