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
2 changes: 1 addition & 1 deletion hathor/cli/openapi_files/openapi_base.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
],
"info": {
"title": "Hathor API",
"version": "0.64.0"
"version": "0.65.0"
},
"consumes": [
"application/json"
Expand Down
2 changes: 1 addition & 1 deletion hathor/conf/mainnet.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@
# Expected to be reached around Tuesday, 2025-08-12 17:39:56 GMT
# Right now the best block is 5_748_286 at Wednesday, 2025-08-06 16:02:56 GMT
start_height=5_765_760,
timeout_height=5_886_720, # N + 6 * 20160 (6 weeks after the start)
timeout_height=5_967_360, # N + 10 * 20160 (10 weeks after the start)
minimum_activation_height=0,
lock_in_on_timeout=False,
version='0.64.0',
Expand Down
2 changes: 1 addition & 1 deletion hathor/conf/mainnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ FEATURE_ACTIVATION:
# Expected to be reached around Tuesday, 2025-08-12 17:39:56 GMT
# Right now the best block is 5_748_286 at Wednesday, 2025-08-06 16:02:56 GMT
start_height: 5_765_760
timeout_height: 5_886_720 # N + 6 * 20160 (6 weeks after the start)
timeout_height: 5_967_360 # N + 10 * 20160 (10 weeks after the start)
minimum_activation_height: 0
lock_in_on_timeout: false
version: 0.64.0
Expand Down
1 change: 1 addition & 0 deletions hathor/nanocontracts/allowed_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
BlueprintId=nc.types.BlueprintId,
ContractId=nc.types.ContractId,
VertexId=nc.types.VertexId,
CallerId=nc.types.CallerId,
NCDepositAction=nc.types.NCDepositAction,
NCWithdrawalAction=nc.types.NCWithdrawalAction,
NCGrantAuthorityAction=nc.types.NCGrantAuthorityAction,
Expand Down
13 changes: 12 additions & 1 deletion hathor/nanocontracts/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,19 @@ def copy(self) -> Context:

def to_json(self) -> dict[str, Any]:
"""Return a JSON representation of the context."""
caller_id: str
match self.caller_id:
case Address():
caller_id = get_address_b58_from_bytes(self.caller_id)
case ContractId():
caller_id = self.caller_id.hex()
case _:
assert_never(self.caller_id)

return {
'actions': [action.to_json() for action in self.__all_actions__],
'caller_id': get_address_b58_from_bytes(self.caller_id),
'caller_id': caller_id,
'timestamp': self.timestamp,
# XXX: Deprecated attribute
'address': caller_id,
}
5 changes: 5 additions & 0 deletions hathor/nanocontracts/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ class NCForbiddenAction(NCFail):
pass


class NCForbiddenReentrancy(NCFail):
"""Raised when a reentrancy is forbidden on a method."""
pass


class UnknownFieldType(NCError):
"""Raised when there is no field available for a given type."""
pass
Expand Down
5 changes: 2 additions & 3 deletions hathor/nanocontracts/resources/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,8 @@ def render_GET(self, request: 'Request') -> bytes:
fields[field] = NCValueErrorResponse(errmsg='field not found')
continue

if type(value) is bytes:
value = value.hex()
fields[field] = NCValueSuccessResponse(value=value)
json_value = field_nc_type.value_to_json(value)
fields[field] = NCValueSuccessResponse(value=json_value)

# Call view methods.
runner.disable_call_trace() # call trace is not required for calling view methods.
Expand Down
31 changes: 26 additions & 5 deletions hathor/nanocontracts/runner/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
NCAlreadyInitializedContractError,
NCFail,
NCForbiddenAction,
NCForbiddenReentrancy,
NCInvalidContext,
NCInvalidContractId,
NCInvalidInitializeMethodCall,
Expand Down Expand Up @@ -55,6 +56,7 @@
from hathor.nanocontracts.storage import NCBlockStorage, NCChangesTracker, NCContractStorage, NCStorageFactory
from hathor.nanocontracts.storage.contract_storage import Balance
from hathor.nanocontracts.types import (
NC_ALLOW_REENTRANCY,
NC_ALLOWED_ACTIONS_ATTR,
NC_FALLBACK_METHOD,
NC_INITIALIZE_METHOD,
Expand Down Expand Up @@ -371,15 +373,18 @@ def syscall_proxy_call_public_method_nc_args(
method_name=method_name,
actions=actions,
nc_args=nc_args,
skip_reentrancy_validation=True,
)

def _unsafe_call_another_contract_public_method(
self,
*,
contract_id: ContractId,
blueprint_id: BlueprintId,
method_name: str,
actions: Sequence[NCAction],
nc_args: NCArgs,
skip_reentrancy_validation: bool = False,
) -> Any:
"""Invoke another contract's public method without running the usual guard‑safety checks.

Expand Down Expand Up @@ -419,6 +424,7 @@ def _unsafe_call_another_contract_public_method(
method_name=method_name,
ctx=ctx,
nc_args=nc_args,
skip_reentrancy_validation=skip_reentrancy_validation,
)

def _reset_all_change_trackers(self) -> None:
Expand Down Expand Up @@ -527,6 +533,7 @@ def _execute_public_method_call(
method_name: str,
ctx: Context,
nc_args: NCArgs,
skip_reentrancy_validation: bool = False,
) -> Any:
"""An internal method that actually execute the public method call.
It is also used when a contract calls another contract.
Expand Down Expand Up @@ -558,6 +565,9 @@ def _execute_public_method_call(
parser = Method.from_callable(method)
args = self._validate_nc_args_for_method(parser, nc_args)

if not skip_reentrancy_validation:
self._validate_reentrancy(contract_id, called_method_name, method)

call_record = CallRecord(
type=CallType.PUBLIC,
depth=self._call_info.depth,
Expand Down Expand Up @@ -855,11 +865,11 @@ def syscall_create_another_contract(
self._internal_create_contract(child_id, blueprint_id)
nc_args = NCParsedArgs(args, kwargs)
ret = self._unsafe_call_another_contract_public_method(
child_id,
blueprint_id,
NC_INITIALIZE_METHOD,
actions,
nc_args,
contract_id=child_id,
blueprint_id=blueprint_id,
method_name=NC_INITIALIZE_METHOD,
actions=actions,
nc_args=nc_args,
)

assert last_call_record.index_updates is not None
Expand Down Expand Up @@ -973,6 +983,17 @@ def _validate_context(self, ctx: Context) -> None:
if isinstance(action, BaseTokenAction) and action.amount < 0:
raise NCInvalidContext('amount must be positive')

def _validate_reentrancy(self, contract_id: ContractId, method_name: str, method: Any) -> None:
"""Check whether a reentrancy is happening and whether it is allowed."""
assert self._call_info is not None
allow_reentrancy = getattr(method, NC_ALLOW_REENTRANCY, False)
if allow_reentrancy:
return

for call_record in self._call_info.stack:
if call_record.contract_id == contract_id:
raise NCForbiddenReentrancy(f'reentrancy is forbidden on method `{method_name}`')

def _validate_actions(self, method: Any, method_name: str, ctx: Context) -> None:
"""Check whether actions are allowed."""
allowed_actions: set[NCActionType] = getattr(method, NC_ALLOWED_ACTIONS_ATTR, set())
Expand Down
5 changes: 0 additions & 5 deletions hathor/nanocontracts/storage/changes_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,6 @@ def commit(self) -> None:

self.has_been_commited = True

def reset(self) -> None:
"""Discard all local changes without persisting."""
self.data = {}
self._balance_diff = {}

@override
def _get_mutable_balance(self, token_uid: bytes) -> MutableBalance:
internal_key = BalanceKey(self.nc_id, token_uid)
Expand Down
7 changes: 7 additions & 0 deletions hathor/nanocontracts/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class ContractId(VertexId):
NC_FALLBACK_METHOD: str = 'fallback'

NC_ALLOWED_ACTIONS_ATTR = '__nc_allowed_actions'
NC_ALLOW_REENTRANCY = '__nc_allow_reentrancy'
NC_METHOD_TYPE_ATTR: str = '__nc_method_type'


Expand Down Expand Up @@ -163,6 +164,7 @@ def _create_decorator_with_allowed_actions(
allow_grant_authority: bool | None,
allow_acquire_authority: bool | None,
allow_actions: list[NCActionType] | None,
allow_reentrancy: bool,
) -> Callable:
"""Internal utility to create a decorator that sets allowed actions."""
flags = {
Expand All @@ -179,6 +181,7 @@ def decorator(fn: Callable) -> Callable:
allowed_actions = set(allow_actions) if allow_actions else set()
allowed_actions.update(action for action, flag in flags.items() if flag)
setattr(fn, NC_ALLOWED_ACTIONS_ATTR, allowed_actions)
setattr(fn, NC_ALLOW_REENTRANCY, allow_reentrancy)

decorator_body(fn)
return fn
Expand All @@ -197,6 +200,7 @@ def public(
allow_grant_authority: bool | None = None,
allow_acquire_authority: bool | None = None,
allow_actions: list[NCActionType] | None = None,
allow_reentrancy: bool = False,
) -> Callable:
"""Decorator to mark a blueprint method as public."""
def decorator(fn: Callable) -> None:
Expand All @@ -219,6 +223,7 @@ def decorator(fn: Callable) -> None:
allow_grant_authority=allow_grant_authority,
allow_acquire_authority=allow_acquire_authority,
allow_actions=allow_actions,
allow_reentrancy=allow_reentrancy,
)


Expand Down Expand Up @@ -246,6 +251,7 @@ def fallback(
allow_grant_authority: bool | None = None,
allow_acquire_authority: bool | None = None,
allow_actions: list[NCActionType] | None = None,
allow_reentrancy: bool = False,
) -> Callable:
"""Decorator to mark a blueprint method as fallback. The method must also be called `fallback`."""
def decorator(fn: Callable) -> None:
Expand Down Expand Up @@ -279,6 +285,7 @@ def decorator(fn: Callable) -> None:
allow_grant_authority=allow_grant_authority,
allow_acquire_authority=allow_acquire_authority,
allow_actions=allow_actions,
allow_reentrancy=allow_reentrancy,
)


Expand Down
4 changes: 2 additions & 2 deletions hathor/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@

from structlog import get_logger

BASE_VERSION = '0.64.0'
BASE_VERSION = '0.65.0'

DEFAULT_VERSION_SUFFIX = "local"
BUILD_VERSION_FILE_PATH = "./BUILD_VERSION"

# Valid formats: 1.2.3, 1.2.3-rc.1 and nightly-ab49c20f
BUILD_VERSION_REGEX = r"^(\d+\.\d+\.\d+(-rc\.\d+)?|nightly-[a-f0-9]{7,8})$"
BUILD_VERSION_REGEX = r"^(\d+\.\d+\.\d+(-(rc|alpha|beta)\.\d+)?|nightly-[a-f0-9]{7,8})$"


logger = get_logger()
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

[tool.poetry]
name = "hathor"
version = "0.64.0"
version = "0.65.0"
description = "Hathor Network full-node"
authors = ["Hathor Team <contact@hathor.network>"]
license = "Apache-2.0"
Expand Down
38 changes: 32 additions & 6 deletions tests/feature_activation/test_criteria.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,18 +143,44 @@ def test_minimum_activation_height(minimum_activation_height: int, error: str) -
assert errors[0]['msg'] == error


_invalid_version_msg = r'string does not match regex "^(\d+\.\d+\.\d+(-(rc|alpha|beta)\.\d+)?|nightly-[a-f0-9]{7,8})$"'


@pytest.mark.parametrize(
['version', 'error'],
['version'],
[
('0', 'string does not match regex "^(\\d+\\.\\d+\\.\\d+(-rc\\.\\d+)?|nightly-[a-f0-9]{7,8})$"'),
('alpha', 'string does not match regex "^(\\d+\\.\\d+\\.\\d+(-rc\\.\\d+)?|nightly-[a-f0-9]{7,8})$"'),
('0.0', 'string does not match regex "^(\\d+\\.\\d+\\.\\d+(-rc\\.\\d+)?|nightly-[a-f0-9]{7,8})$"')
('0',),
('alpha',),
('0.0',),
('0.0.0-',),
('0.1.0-alpha',),
('0.1.0-alpha.x',),
('0.1.0-gamma.1',),
('0.1.0-RC.1',),
]
)
def test_version(version: str, error: str) -> None:
def test_invalid_version(version: str) -> None:
criteria = VALID_CRITERIA | dict(version=version)
with pytest.raises(ValidationError) as e:
Criteria(**criteria).to_validated(evaluation_interval=1000, max_signal_bits=2) # type: ignore[arg-type]

errors = e.value.errors()
assert errors[0]['msg'] == error
assert errors[0]['msg'] == _invalid_version_msg


@pytest.mark.parametrize(
['version'],
[
('1.0.0',),
('1.2.3',),
('1.22222.30000',),
('1.2.3-alpha.1',),
('1.2.3-alpha.2',),
('1.2.3-rc.2',),
('1.2.3-beta.2',),
('1.2.3-alpha.299',),
]
)
def test_valid_version(version: str) -> None:
criteria = VALID_CRITERIA | dict(version=version)
Criteria(**criteria).to_validated(evaluation_interval=1000, max_signal_bits=2) # type: ignore[arg-type]
4 changes: 2 additions & 2 deletions tests/nanocontracts/test_authorities_call_another.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class CallerBlueprint(Blueprint):
def initialize(self, ctx: Context, other_id: ContractId) -> None:
self.other_id = other_id

@public(allow_grant_authority=True)
@public(allow_grant_authority=True, allow_reentrancy=True)
def nop(self, ctx: Context) -> None:
pass

Expand All @@ -60,7 +60,7 @@ def grant_to_other(self, ctx: Context, token_uid: TokenUid, mint: bool, melt: bo
action = NCGrantAuthorityAction(token_uid=token_uid, mint=mint, melt=melt)
self.syscall.call_public_method(self.other_id, 'nop', [action])

@public(allow_grant_authority=True)
@public(allow_grant_authority=True, allow_reentrancy=True)
def revoke_from_self(self, ctx: Context, token_uid: TokenUid, mint: bool, melt: bool) -> None:
self.syscall.revoke_authorities(token_uid, revoke_mint=mint, revoke_melt=melt)

Expand Down
2 changes: 1 addition & 1 deletion tests/nanocontracts/test_call_other_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def get_tokens_from_another_contract(self, ctx: Context) -> None:
if actions:
self.syscall.call_public_method(self.contract, 'get_tokens_from_another_contract', actions)

@public
@public(allow_reentrancy=True)
def dec(self, ctx: Context, fail_on_zero: bool) -> None:
if self.counter == 0:
if fail_on_zero:
Expand Down
2 changes: 1 addition & 1 deletion tests/nanocontracts/test_contract_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def initialize(self, ctx: Context) -> None:
def inc(self, ctx: Context) -> None:
self.counter += 3

@public
@public(allow_reentrancy=True)
def on_upgrade_inc(self, ctx: Context) -> None:
self.counter += 100

Expand Down
4 changes: 2 additions & 2 deletions tests/nanocontracts/test_execution_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def accept_deposit_from_another(self, ctx: Context, contract_id: ContractId) ->
self.syscall.call_public_method(contract_id, 'accept_deposit_from_another_callback', [action])
self.assert_token_balance(before=0, current=4)

@public(allow_deposit=True)
@public(allow_deposit=True, allow_reentrancy=True)
def accept_deposit_from_another_callback(self, ctx: Context) -> None:
self.assert_token_balance(before=3, current=6)

Expand All @@ -116,7 +116,7 @@ def accept_withdrawal_from_another(self, ctx: Context, contract_id: ContractId)
self.syscall.call_public_method(contract_id, 'accept_withdrawal_from_another_callback', [action])
self.assert_token_balance(before=4, current=3)

@public(allow_withdrawal=True)
@public(allow_withdrawal=True, allow_reentrancy=True)
def accept_withdrawal_from_another_callback(self, ctx: Context) -> None:
self.assert_token_balance(before=7, current=6)

Expand Down
Loading