diff --git a/hathor/_openapi/openapi_base.json b/hathor/_openapi/openapi_base.json index c24ab9786..9eec06b7f 100644 --- a/hathor/_openapi/openapi_base.json +++ b/hathor/_openapi/openapi_base.json @@ -7,7 +7,7 @@ ], "info": { "title": "Hathor API", - "version": "0.68.0" + "version": "0.68.1" }, "consumes": [ "application/json" diff --git a/hathor/nanocontracts/exception.py b/hathor/nanocontracts/exception.py index cfa467d9e..3c95bab5f 100644 --- a/hathor/nanocontracts/exception.py +++ b/hathor/nanocontracts/exception.py @@ -155,6 +155,11 @@ class NCForbiddenReentrancy(NCFail): pass +class NCTypeError(NCFail): + """Raised when a wrong type is used.""" + pass + + class UnknownFieldType(NCFail): """Raised when there is no field available for a given type.""" pass diff --git a/hathor/nanocontracts/resources/state.py b/hathor/nanocontracts/resources/state.py index be2f401b5..08f242e94 100644 --- a/hathor/nanocontracts/resources/state.py +++ b/hathor/nanocontracts/resources/state.py @@ -203,6 +203,9 @@ def render_GET(self, request: 'Request') -> bytes: except KeyError: fields[field] = NCValueErrorResponse(errmsg='field not found') continue + except TypeError: + fields[field] = NCValueErrorResponse(errmsg='field cannot be rendered') + continue json_value = field_nc_type.value_to_json(value) fields[field] = NCValueSuccessResponse(value=json_value) diff --git a/hathor/nanocontracts/runner/runner.py b/hathor/nanocontracts/runner/runner.py index a04903320..c94699a07 100644 --- a/hathor/nanocontracts/runner/runner.py +++ b/hathor/nanocontracts/runner/runner.py @@ -40,6 +40,7 @@ NCInvalidPublicMethodCallFromView, NCInvalidSyscall, NCMethodNotFound, + NCTypeError, NCUninitializedContractError, NCViewMethodError, ) @@ -1241,6 +1242,9 @@ def syscall_create_child_fee_token( @_forbid_syscall_from_view('emit_event') def syscall_emit_event(self, data: bytes) -> None: """Emit a custom event from a Nano Contract.""" + if not isinstance(data, bytes): + raise NCTypeError(f'got {type(data)} instead of bytes') + data = bytes(data) # force actual bytes because isinstance could be True for "compatible" types assert self._call_info is not None self._call_info.nc_logger.__emit_event__(data) diff --git a/hathor/version.py b/hathor/version.py index 1243b5377..6f9d4ea5a 100644 --- a/hathor/version.py +++ b/hathor/version.py @@ -19,7 +19,7 @@ from structlog import get_logger -BASE_VERSION = '0.68.0' +BASE_VERSION = '0.68.1' DEFAULT_VERSION_SUFFIX = "local" BUILD_VERSION_FILE_PATH = "./BUILD_VERSION" diff --git a/hathor_tests/nanocontracts/test_emit_event_payload.py b/hathor_tests/nanocontracts/test_emit_event_payload.py new file mode 100644 index 000000000..bd7d1af2a --- /dev/null +++ b/hathor_tests/nanocontracts/test_emit_event_payload.py @@ -0,0 +1,66 @@ +# 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.manager import HathorManager +from hathor.nanocontracts import NC_EXECUTION_FAIL_ID, Blueprint, Context, public +from hathor.transaction import Block, Transaction +from hathor.transaction.nc_execution_state import NCExecutionState +from hathor_tests.dag_builder.builder import TestDAGBuilder +from hathor_tests.nanocontracts.blueprints.unittest import BlueprintTestCase + + +class EmitEventWithDictBlueprint(Blueprint): + @public + def initialize(self, ctx: Context) -> None: + # Intentionally emit a non-bytes payload; runtime type checks must reject this. + self.syscall.emit_event({'should': 'fail'}) # type: ignore[arg-type] + + +class EmitEventPayloadTestCase(BlueprintTestCase): + def build_manager(self) -> HathorManager: + # Lower reward spend requirement to avoid reward-lock interference in this focused test. + settings = self._settings._replace(REWARD_SPEND_MIN_BLOCKS=1) + return self.create_peer( + 'unittests', + nc_indexes=True, + wallet_index=True, + settings=settings, + ) + + def setUp(self) -> None: + super().setUp() + self.blueprint_id = self._register_blueprint_class(EmitEventWithDictBlueprint) + self.dag_builder = TestDAGBuilder.from_manager(self.manager) + + def test_emit_event_requires_bytes_payload(self) -> None: + artifacts = self.dag_builder.build_from_str(f''' + blockchain genesis b[1..5] + b3 < dummy # ensure enough confirmations to unlock rewards + + tx1.nc_id = "{self.blueprint_id.hex()}" + tx1.nc_method = initialize() + + tx1 < b4 < b5 + tx1 <-- b4 + ''') + + b4 = artifacts.get_typed_vertex('b4', Block) + tx1 = artifacts.get_typed_vertex('tx1', Transaction) + + # Executing the contract must fail because emit_event payload is not bytes. + artifacts.propagate_with(self.manager, up_to='b4') + meta = tx1.get_metadata() + assert meta.first_block == b4.hash + assert meta.nc_execution == NCExecutionState.FAILURE + assert meta.voided_by == {NC_EXECUTION_FAIL_ID, tx1.hash} diff --git a/hathor_tests/resources/nanocontracts/test_state.py b/hathor_tests/resources/nanocontracts/test_state.py index 987208617..3db6b7c70 100644 --- a/hathor_tests/resources/nanocontracts/test_state.py +++ b/hathor_tests/resources/nanocontracts/test_state.py @@ -54,6 +54,8 @@ class MyBlueprint(Blueprint): address_details: dict[Address, dict[str, Amount]] bytes_field: bytes dict_with_bytes: dict[bytes, str] + list_field: list[int] + set_field: set[int] last_caller_id: CallerId last_bet_address: Address last_vertex_id: VertexId @@ -67,6 +69,8 @@ def initialize(self, ctx: Context, token_uid: TokenUid, date_last_bet: Timestamp self.address_details = {} self.bytes_field = b'' self.dict_with_bytes = {} + self.list_field = [] + self.set_field = set() self.last_caller_id = ctx.caller_id self.last_bet_address = Address(b'\00' * 25) self.last_vertex_id = VertexId(ctx.vertex.hash) @@ -214,6 +218,52 @@ def _fill_nc( sign_openssl(nano_header, private_key) self.manager.cpu_mining_service.resolve(nc) + @inlineCallbacks + def test_container_field_returns_field_error(self): + parents = [tx.hash for tx in self.genesis_txs] + timestamp = 1 + max(tx.timestamp for tx in self.genesis) + + nc = Transaction( + weight=1, + inputs=[], + outputs=[], + parents=parents, + storage=self.tx_storage, + timestamp=timestamp + ) + self._fill_nc( + nc, + self.bet_id, + 'initialize', + [settings.HATHOR_TOKEN_UID, timestamp], + self.genesis_private_key, + ) + self.assertTrue(self.manager.on_new_tx(nc)) + add_new_block(self.manager) + + response = yield self.web.get( + 'state', [ + (b'id', nc.hash.hex().encode('ascii')), + (b'fields[]', b'token_uid'), + (b'fields[]', b'address_details'), + (b'fields[]', b'list_field'), + (b'fields[]', b'set_field'), + ] + ) + + data = response.json_value() + self.assertTrue(data['success']) + fields = data['fields'] + self.assertEqual(fields['token_uid'], {'value': settings.HATHOR_TOKEN_UID.hex()}) + self.assertEqual(fields['address_details'], {'errmsg': 'field cannot be rendered'}) + # XXX: these are ideally the errors that make sense to return: + # self.assertEqual(fields['list_field'], {'errmsg': 'field cannot be rendered'}) + # self.assertEqual(fields['set_field'], {'errmsg': 'field cannot be rendered'}) + # XXX: however the current implementation quirks field these: + self.assertEqual(fields['list_field'], {'errmsg': 'field not found'}) + self.assertEqual(fields['set_field'], {'errmsg': 'field not found'}) + # XXX: it will change in the future along with a complete state-api implementation for container fields + @inlineCallbacks def test_success(self): parents = [tx.hash for tx in self.genesis_txs] diff --git a/pyproject.toml b/pyproject.toml index 2055668cd..de9dd54aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ [tool.poetry] name = "hathor" -version = "0.68.0" +version = "0.68.1" description = "Hathor Network full-node" authors = ["Hathor Team "] license = "Apache-2.0"