Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion hathor/nanocontracts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -38,6 +38,7 @@
'public',
'fallback',
'view',
'export',
'NC_EXECUTION_FAIL_ID',
'HATHOR_TOKEN_UID',
]
1 change: 1 addition & 0 deletions hathor/nanocontracts/allowed_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions hathor/nanocontracts/custom_builtins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 3 additions & 6 deletions hathor/nanocontracts/on_chain_blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)

Expand Down Expand Up @@ -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]]:
Expand All @@ -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

Expand Down
17 changes: 17 additions & 0 deletions hathor/nanocontracts/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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
Comment on lines +298 to +308
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using global or globals() doesn't work?

Copy link
Copy Markdown
Member Author

@jansegre jansegre Sep 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't work because it would refer to the hathor.nanocontracts.types's globals, not the module that uses @export's globals.



def fallback(
maybe_fn: Callable | None = None,
/,
Expand Down
50 changes: 39 additions & 11 deletions hathor/verification/on_chain_blueprint_verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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',)

Expand Down Expand Up @@ -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:
Expand All @@ -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')
5 changes: 2 additions & 3 deletions tests/nanocontracts/test_blueprints/bet.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
Timestamp,
TokenUid,
TxOutputScript,
export,
public,
view,
)
Expand Down Expand Up @@ -63,6 +64,7 @@ class InvalidOracleSignature(NCFail):
pass


@export
class Bet(Blueprint):
"""Bet blueprint with final result provided by an oracle.

Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions tests/nanocontracts/test_exposed_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down