diff --git a/hathor/builder/resources_builder.py b/hathor/builder/resources_builder.py index 6d17954e6..28b031edc 100644 --- a/hathor/builder/resources_builder.py +++ b/hathor/builder/resources_builder.py @@ -24,6 +24,9 @@ from hathor.event.resources.event import EventResource from hathor.exception import BuilderError from hathor.feature_activation.feature_service import FeatureService +from hathor.nanocontracts.resources.builtin import BlueprintBuiltinResource +from hathor.nanocontracts.resources.nc_creation import NCCreationResource +from hathor.nanocontracts.resources.on_chain import BlueprintOnChainResource from hathor.prometheus import PrometheusMetricsExporter if TYPE_CHECKING: @@ -250,6 +253,25 @@ def create_resources(self) -> server.Site: (b'utxo_search', UtxoSearchResource(self.manager), root), ]) + if settings.ENABLE_NANO_CONTRACTS: + from hathor.nanocontracts.resources import ( + BlueprintInfoResource, + BlueprintSourceCodeResource, + NanoContractHistoryResource, + NanoContractStateResource, + ) + nc_resource = Resource() + root.putChild(b'nano_contract', nc_resource) + blueprint_resource = Resource() + nc_resource.putChild(b'blueprint', blueprint_resource) + blueprint_resource.putChild(b'info', BlueprintInfoResource(self.manager)) + blueprint_resource.putChild(b'builtin', BlueprintBuiltinResource(self.manager)) + blueprint_resource.putChild(b'on_chain', BlueprintOnChainResource(self.manager)) + blueprint_resource.putChild(b'source', BlueprintSourceCodeResource(self.manager)) + nc_resource.putChild(b'history', NanoContractHistoryResource(self.manager)) + nc_resource.putChild(b'state', NanoContractStateResource(self.manager)) + nc_resource.putChild(b'creation', NCCreationResource(self.manager)) + if self._args.enable_debug_api: debug_resource = Resource() root.putChild(b'_debug', debug_resource) diff --git a/hathor/cli/events_simulator/scenario.py b/hathor/cli/events_simulator/scenario.py index f497b307a..dd4f8c3ca 100644 --- a/hathor/cli/events_simulator/scenario.py +++ b/hathor/cli/events_simulator/scenario.py @@ -13,9 +13,10 @@ # limitations under the License. from enum import Enum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: + from hathor.dag_builder.artifacts import DAGArtifacts from hathor.manager import HathorManager from hathor.simulator import Simulator @@ -29,8 +30,10 @@ class Scenario(Enum): INVALID_MEMPOOL_TRANSACTION = 'INVALID_MEMPOOL_TRANSACTION' EMPTY_SCRIPT = 'EMPTY_SCRIPT' CUSTOM_SCRIPT = 'CUSTOM_SCRIPT' + NC_EVENTS = 'NC_EVENTS' + NC_EVENTS_REORG = 'NC_EVENTS_REORG' - def simulate(self, simulator: 'Simulator', manager: 'HathorManager') -> None: + def simulate(self, simulator: 'Simulator', manager: 'HathorManager') -> Optional['DAGArtifacts']: simulate_fns = { Scenario.ONLY_LOAD: simulate_only_load, Scenario.SINGLE_CHAIN_ONE_BLOCK: simulate_single_chain_one_block, @@ -40,24 +43,31 @@ def simulate(self, simulator: 'Simulator', manager: 'HathorManager') -> None: Scenario.INVALID_MEMPOOL_TRANSACTION: simulate_invalid_mempool_transaction, Scenario.EMPTY_SCRIPT: simulate_empty_script, Scenario.CUSTOM_SCRIPT: simulate_custom_script, + Scenario.NC_EVENTS: simulate_nc_events, + Scenario.NC_EVENTS_REORG: simulate_nc_events_reorg, } simulate_fn = simulate_fns[self] - simulate_fn(simulator, manager) + return simulate_fn(simulator, manager) -def simulate_only_load(simulator: 'Simulator', _manager: 'HathorManager') -> None: +def simulate_only_load(simulator: 'Simulator', _manager: 'HathorManager') -> Optional['DAGArtifacts']: simulator.run(60) + return None -def simulate_single_chain_one_block(simulator: 'Simulator', manager: 'HathorManager') -> None: +def simulate_single_chain_one_block(simulator: 'Simulator', manager: 'HathorManager') -> Optional['DAGArtifacts']: from hathor.simulator.utils import add_new_blocks add_new_blocks(manager, 1) simulator.run(60) + return None -def simulate_single_chain_blocks_and_transactions(simulator: 'Simulator', manager: 'HathorManager') -> None: +def simulate_single_chain_blocks_and_transactions( + simulator: 'Simulator', + manager: 'HathorManager', +) -> Optional['DAGArtifacts']: from hathor.conf.get_settings import get_global_settings from hathor.simulator.utils import add_new_blocks, gen_new_tx @@ -83,8 +93,10 @@ def simulate_single_chain_blocks_and_transactions(simulator: 'Simulator', manage add_new_blocks(manager, 1) simulator.run(60) + return None -def simulate_reorg(simulator: 'Simulator', manager: 'HathorManager') -> None: + +def simulate_reorg(simulator: 'Simulator', manager: 'HathorManager') -> Optional['DAGArtifacts']: from hathor.simulator import FakeConnection from hathor.simulator.utils import add_new_blocks @@ -101,8 +113,10 @@ def simulate_reorg(simulator: 'Simulator', manager: 'HathorManager') -> None: simulator.add_connection(connection) simulator.run(60) + return None + -def simulate_unvoided_transaction(simulator: 'Simulator', manager: 'HathorManager') -> None: +def simulate_unvoided_transaction(simulator: 'Simulator', manager: 'HathorManager') -> Optional['DAGArtifacts']: from hathor.conf.get_settings import get_global_settings from hathor.simulator.utils import add_new_block, add_new_blocks, gen_new_tx @@ -147,13 +161,14 @@ def simulate_unvoided_transaction(simulator: 'Simulator', manager: 'HathorManage assert tx.get_metadata().voided_by assert not tx2.get_metadata().voided_by + return None -def simulate_invalid_mempool_transaction(simulator: 'Simulator', manager: 'HathorManager') -> None: - from hathor.conf.get_settings import get_global_settings + +def simulate_invalid_mempool_transaction(simulator: 'Simulator', manager: 'HathorManager') -> Optional['DAGArtifacts']: from hathor.simulator.utils import add_new_blocks, gen_new_tx from hathor.transaction import Block - settings = get_global_settings() + settings = manager._settings assert manager.wallet is not None address = manager.wallet.get_unused_address(mark_as_used=False) @@ -186,8 +201,10 @@ def simulate_invalid_mempool_transaction(simulator: 'Simulator', manager: 'Hatho balance_per_address = manager.wallet.get_balance_per_address(settings.HATHOR_TOKEN_UID) assert balance_per_address[address] == 6400 + return None + -def simulate_empty_script(simulator: 'Simulator', manager: 'HathorManager') -> None: +def simulate_empty_script(simulator: 'Simulator', manager: 'HathorManager') -> Optional['DAGArtifacts']: from hathor.conf.get_settings import get_global_settings from hathor.simulator.utils import add_new_blocks, gen_new_tx from hathor.transaction import TxInput, TxOutput @@ -218,8 +235,10 @@ def simulate_empty_script(simulator: 'Simulator', manager: 'HathorManager') -> N add_new_blocks(manager, 1) simulator.run(60) + return None -def simulate_custom_script(simulator: 'Simulator', manager: 'HathorManager') -> None: + +def simulate_custom_script(simulator: 'Simulator', manager: 'HathorManager') -> Optional['DAGArtifacts']: from hathor.conf.get_settings import get_global_settings from hathor.simulator.utils import add_new_blocks, gen_new_tx from hathor.transaction import TxInput, TxOutput @@ -255,3 +274,111 @@ def simulate_custom_script(simulator: 'Simulator', manager: 'HathorManager') -> add_new_blocks(manager, 1) simulator.run(60) + + return None + + +def simulate_nc_events(simulator: 'Simulator', manager: 'HathorManager') -> Optional['DAGArtifacts']: + from hathor.nanocontracts import Blueprint, NCFail, public + from hathor.nanocontracts.catalog import NCBlueprintCatalog + from hathor.nanocontracts.context import Context + from hathor.nanocontracts.types import ContractId + from tests.dag_builder.builder import TestDAGBuilder # skip-import-tests-custom-check + + class TestEventsBlueprint1(Blueprint): + @public + def initialize(self, ctx: Context) -> None: + self.syscall.emit_event(b'test event on initialize 1') + + @public + def fail(self, ctx: Context) -> None: + # This will not be emitted because the tx will fail. + self.syscall.emit_event(b'test event on fail') + raise NCFail + + @public + def call_another(self, ctx: Context, contract_id: ContractId) -> None: + self.syscall.emit_event(b'test event on call_another') + self.syscall.call_public_method(contract_id, 'some_method', []) + + class TestEventsBlueprint2(Blueprint): + @public + def initialize(self, ctx: Context) -> None: + self.syscall.emit_event(b'test event on initialize 2') + + @public + def some_method(self, ctx: Context) -> None: + self.syscall.emit_event(b'test event on some_method') + + blueprint1_id = b'\x11' * 32 + blueprint2_id = b'\x22' * 32 + manager.tx_storage.nc_catalog = NCBlueprintCatalog({ + blueprint1_id: TestEventsBlueprint1, + blueprint2_id: TestEventsBlueprint2, + }) + dag_builder = TestDAGBuilder.from_manager(manager) + artifacts = dag_builder.build_from_str(f''' + blockchain genesis b[1..3] + b1 < dummy + + # test simple event + nc1.nc_id = "{blueprint1_id.hex()}" + nc1.nc_method = initialize() + + nc2.nc_id = "{blueprint2_id.hex()}" + nc2.nc_method = initialize() + + # test events across contracts + nc3.nc_id = nc1 + nc3.nc_method = call_another(`nc2`) + + # test NC failure + nc4.nc_id = nc1 + nc4.nc_method = fail() + + nc1 <-- nc2 <-- nc3 <-- nc4 + nc2 <-- b2 + nc4 <-- b3 + nc4 < b2 + ''') + artifacts.propagate_with(manager, up_to='b2') + simulator.run(1) + artifacts.propagate_with(manager) + simulator.run(1) + + return artifacts + + +def simulate_nc_events_reorg(simulator: 'Simulator', manager: 'HathorManager') -> Optional['DAGArtifacts']: + from hathor.nanocontracts import Blueprint, public + from hathor.nanocontracts.catalog import NCBlueprintCatalog + from hathor.nanocontracts.context import Context + from tests.dag_builder.builder import TestDAGBuilder # skip-import-tests-custom-check + + class TestEventsBlueprint1(Blueprint): + @public + def initialize(self, ctx: Context) -> None: + self.syscall.emit_event(b'test event on initialize 1') + + blueprint1_id = b'\x11' * 32 + manager.tx_storage.nc_catalog = NCBlueprintCatalog({blueprint1_id: TestEventsBlueprint1}) + dag_builder = TestDAGBuilder.from_manager(manager) + + # 2 reorgs happen, so nc1.initialize() gets executed 3 times, once in block a2 and twice in block b2 + artifacts = dag_builder.build_from_str(f''' + blockchain genesis b[1..4] + blockchain b1 a[2..3] + b1 < dummy + b2 < a2 < a3 < b3 < b4 + + nc1.nc_id = "{blueprint1_id.hex()}" + nc1.nc_method = initialize() + + nc1 <-- b2 + nc1 <-- a2 + ''') + + artifacts.propagate_with(manager) + simulator.run(1) + + return artifacts diff --git a/hathor/cli/openapi_files/register.py b/hathor/cli/openapi_files/register.py index 77dc29b87..0d355f684 100644 --- a/hathor/cli/openapi_files/register.py +++ b/hathor/cli/openapi_files/register.py @@ -37,6 +37,7 @@ def get_registered_resources() -> list[type[Resource]]: import hathor.event.resources.event # noqa: 401 import hathor.feature_activation.resources.feature # noqa: 401 import hathor.healthcheck.resources.healthcheck # noqa: 401 + import hathor.nanocontracts.resources # noqa: 401 import hathor.p2p.resources # noqa: 401 import hathor.profiler.resources # noqa: 401 import hathor.stratum.resources # noqa: 401 diff --git a/hathor/dag_builder/vertex_exporter.py b/hathor/dag_builder/vertex_exporter.py index 3a9811378..5d96204ac 100644 --- a/hathor/dag_builder/vertex_exporter.py +++ b/hathor/dag_builder/vertex_exporter.py @@ -16,26 +16,27 @@ import re from collections import defaultdict from types import ModuleType -from typing import Iterator +from typing import Iterator, cast from typing_extensions import assert_never from hathor.conf.settings import HathorSettings from hathor.crypto.util import decode_address, get_address_from_public_key_bytes from hathor.daa import DifficultyAdjustmentAlgorithm -from hathor.dag_builder.builder import DAGBuilder, DAGNode +from hathor.dag_builder.builder import NC_DEPOSIT_KEY, NC_WITHDRAWAL_KEY, DAGBuilder, DAGNode from hathor.dag_builder.types import DAGNodeType, VertexResolverType, WalletFactoryType from hathor.dag_builder.utils import get_literal, is_literal from hathor.nanocontracts import Blueprint, OnChainBlueprint from hathor.nanocontracts.catalog import NCBlueprintCatalog from hathor.nanocontracts.on_chain_blueprint import Code -from hathor.nanocontracts.types import BlueprintId, ContractId, VertexId -from hathor.nanocontracts.utils import derive_child_contract_id, load_builtin_blueprint_for_ocb +from hathor.nanocontracts.types import BlueprintId, ContractId, NCActionType, VertexId, blueprint_id_from_bytes +from hathor.nanocontracts.utils import derive_child_contract_id, load_builtin_blueprint_for_ocb, sign_pycoin from hathor.transaction import BaseTransaction, Block, Transaction from hathor.transaction.base_transaction import TxInput, TxOutput +from hathor.transaction.headers.nano_header import ADDRESS_LEN_BYTES from hathor.transaction.scripts.p2pkh import P2PKH from hathor.transaction.token_creation_tx import TokenCreationTransaction -from hathor.wallet import BaseWallet, KeyPair +from hathor.wallet import BaseWallet, HDWallet, KeyPair _TEMPLATE_PATTERN = re.compile(r'`(\w+)`') @@ -227,6 +228,7 @@ def create_vertex_token(self, node: DAGNode) -> TokenCreationTransaction: vertex.token_name = node.name vertex.token_symbol = node.name vertex.timestamp = self.get_min_timestamp(node) + self.add_nano_header_if_needed(node, vertex) self.sign_all_inputs(vertex, node=node) if 'weight' in node.attrs: vertex.weight = float(node.attrs['weight']) @@ -250,6 +252,7 @@ def create_vertex_block(self, node: DAGNode) -> Block: parents = block_parents + txs_parents blk = Block(parents=parents, outputs=outputs) + self.add_nano_header_if_needed(node, blk) blk.timestamp = self.get_min_timestamp(node) + self._settings.AVG_TIME_BETWEEN_BLOCKS blk.get_height = lambda: height # type: ignore[method-assign] blk.update_hash() # the next call fails is blk.hash is None @@ -299,6 +302,100 @@ def _get_next_nc_seqnum(self, nc_pubkey: bytes) -> int: self._next_nc_seqnum[address] = cur + 1 return cur + def add_nano_header_if_needed(self, node: DAGNode, vertex: BaseTransaction) -> None: + if 'nc_id' not in node.attrs: + return + + nc_id, blueprint_id = self._parse_nc_id(node.get_attr_ast('nc_id')) + nc_method_raw = node.get_attr_str('nc_method') + + if blueprint_id is None: + if nc_method_raw.startswith('initialize('): + blueprint_id = blueprint_id_from_bytes(nc_id) + else: + contract_creation_vertex = self._vertice_per_id[nc_id] + assert contract_creation_vertex.is_nano_contract() + assert isinstance(contract_creation_vertex, Transaction) + contract_creation_vertex_nano_header = contract_creation_vertex.get_nano_header() + blueprint_id = blueprint_id_from_bytes(contract_creation_vertex_nano_header.nc_id) + + blueprint_class = self._get_blueprint_class(blueprint_id) + + # allows method calls such as + # nc2.nc_method = call_another_nc(`nc1`) + def _replace_escaped_vertex_id(match: re.Match) -> str: + vertex_name = match.group(1) + if vertex_ := self._vertices.get(vertex_name): + return f'"{vertex_.hash_hex}"' + raise SyntaxError(f'unknown vertex: {vertex_name}') + + if raw_args_bytes := node.get_attr_str('nc_args_bytes', default=''): + nc_method = nc_method_raw + nc_args_bytes = bytes.fromhex(get_literal(raw_args_bytes)) + else: + from hathor.nanocontracts.api_arguments_parser import parse_nc_method_call + from hathor.nanocontracts.method import Method + nc_method_raw = _TEMPLATE_PATTERN.sub(_replace_escaped_vertex_id, nc_method_raw) + nc_method, nc_args = parse_nc_method_call(blueprint_class, nc_method_raw) + method = Method.from_callable(getattr(blueprint_class, nc_method)) + nc_args_bytes = method.serialize_args_bytes(nc_args) + + wallet_name = node.attrs.get('nc_address', f'node_{node.name}') + wallet = self.get_wallet(wallet_name) + assert isinstance(wallet, HDWallet) + privkey = wallet.get_key_at_index(0) + + from hathor.transaction.headers.nano_header import NanoHeaderAction + nc_actions = [] + + def append_actions(action: NCActionType, key: str) -> None: + actions = node.get_attr_list(key, default=[]) + for token_name, value in actions: + assert isinstance(token_name, str) + assert isinstance(value, int) + token_index = 0 + if token_name != 'HTR': + assert isinstance(vertex, Transaction) + token_creation_tx = self._vertices[token_name] + if token_creation_tx.hash not in vertex.tokens: + # when depositing, the token uid must be added to the tokens list + # because it's possible that there are no outputs with this token. + assert action == NCActionType.DEPOSIT + vertex.tokens.append(token_creation_tx.hash) + token_index = 1 + vertex.tokens.index(token_creation_tx.hash) + + nc_actions.append(NanoHeaderAction( + type=action, + token_index=token_index, + amount=value, + )) + + append_actions(NCActionType.DEPOSIT, NC_DEPOSIT_KEY) + append_actions(NCActionType.WITHDRAWAL, NC_WITHDRAWAL_KEY) + + from hathor.transaction.headers import NanoHeader + nano_header = NanoHeader( + # Even though we know the NanoHeader only supports Transactions, we force the typing here so we can test + # that other types of vertices such as blocks would fail verification by using an unsupported header. + tx=cast(Transaction, vertex), + nc_seqnum=0, + nc_id=nc_id, + nc_method=nc_method, + nc_args_bytes=nc_args_bytes, + nc_actions=nc_actions, + nc_address=b'\x00' * ADDRESS_LEN_BYTES, + nc_script=b'', + ) + vertex.headers.append(nano_header) + + if isinstance(vertex, Transaction): + sign_pycoin(nano_header, privkey) + + if 'nc_seqnum' in node.attrs: + nano_header.nc_seqnum = int(node.attrs['nc_seqnum']) + else: + nano_header.nc_seqnum = self._get_next_nc_seqnum(nano_header.nc_address) + def create_vertex_on_chain_blueprint(self, node: DAGNode) -> OnChainBlueprint: """Create an OnChainBlueprint given a node.""" block_parents, txs_parents = self._create_vertex_parents(node) @@ -307,6 +404,7 @@ def create_vertex_on_chain_blueprint(self, node: DAGNode) -> OnChainBlueprint: assert len(block_parents) == 0 ocb = OnChainBlueprint(parents=txs_parents, inputs=inputs, outputs=outputs, tokens=tokens) + self.add_nano_header_if_needed(node, ocb) code_attr = node.get_attr_str('ocb_code') if is_literal(code_attr): @@ -354,6 +452,7 @@ def create_vertex_transaction(self, node: DAGNode, *, cls: type[Transaction] = T assert len(block_parents) == 0 tx = cls(parents=txs_parents, inputs=inputs, outputs=outputs, tokens=tokens) tx.timestamp = self.get_min_timestamp(node) + self.add_nano_header_if_needed(node, tx) self.sign_all_inputs(tx, node=node) if 'weight' in node.attrs: tx.weight = float(node.attrs['weight']) diff --git a/hathor/event/event_manager.py b/hathor/event/event_manager.py index 6ce402b82..8eb828b28 100644 --- a/hathor/event/event_manager.py +++ b/hathor/event/event_manager.py @@ -47,6 +47,7 @@ HathorEvents.REORG_FINISHED, HathorEvents.CONSENSUS_TX_UPDATE, HathorEvents.CONSENSUS_TX_REMOVED, + HathorEvents.NC_EVENT, ] diff --git a/hathor/event/model/event_data.py b/hathor/event/model/event_data.py index 77509a13b..ba24e2c24 100644 --- a/hathor/event/model/event_data.py +++ b/hathor/event/model/event_data.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + from typing import Any, Optional, TypeAlias, Union, cast from pydantic import Extra, validator @@ -61,6 +63,7 @@ class TxMetadata(BaseModel, extra=Extra.ignore): first_block: Optional[str] height: int validation: str + nc_execution: str | None @validator('spent_outputs', pre=True, each_item=True) def _parse_spent_outputs(cls, spent_output: Union[SpentOutput, list[Union[int, list[str]]]]) -> SpentOutput: @@ -160,5 +163,38 @@ def from_event_arguments(cls, args: EventArguments) -> 'ReorgData': ) +class NCEventData(BaseEventData): + """Class that represents data for a custom nano contract event.""" + + # The ID of the transaction that executed a nano contract. + vertex_id: str + + # The ID of the nano contract that was executed. + nc_id: str + + # The nano contract execution state. + nc_execution: str + + # The block that confirmed this transaction, executing the nano contract. + first_block: str + + # Custom data provided by the blueprint. + data_hex: str + + @classmethod + def from_event_arguments(cls, args: EventArguments) -> NCEventData: + meta = args.tx.get_metadata() + assert meta.nc_execution is not None + assert meta.first_block is not None + + return cls( + vertex_id=args.tx.hash_hex, + nc_id=args.nc_event.nc_id.hex(), + nc_execution=meta.nc_execution, + first_block=meta.first_block.hex(), + data_hex=args.nc_event.data.hex(), + ) + + # Union type to encompass BaseEventData polymorphism -EventData: TypeAlias = EmptyData | TxData | TxDataWithoutMeta | ReorgData +EventData: TypeAlias = EmptyData | TxData | TxDataWithoutMeta | ReorgData | NCEventData diff --git a/hathor/event/model/event_type.py b/hathor/event/model/event_type.py index 38e968427..bba786664 100644 --- a/hathor/event/model/event_type.py +++ b/hathor/event/model/event_type.py @@ -14,7 +14,7 @@ from enum import Enum -from hathor.event.model.event_data import BaseEventData, EmptyData, ReorgData, TxData, TxDataWithoutMeta +from hathor.event.model.event_data import BaseEventData, EmptyData, NCEventData, ReorgData, TxData, TxDataWithoutMeta from hathor.pubsub import HathorEvents @@ -27,6 +27,7 @@ class EventType(Enum): VERTEX_METADATA_CHANGED = 'VERTEX_METADATA_CHANGED' VERTEX_REMOVED = 'VERTEX_REMOVED' FULL_NODE_CRASHED = 'FULL_NODE_CRASHED' + NC_EVENT = 'NC_EVENT' @classmethod def from_hathor_event(cls, hathor_event: HathorEvents) -> 'EventType': @@ -46,7 +47,8 @@ def data_type(self) -> type[BaseEventData]: HathorEvents.REORG_STARTED: EventType.REORG_STARTED, HathorEvents.REORG_FINISHED: EventType.REORG_FINISHED, HathorEvents.CONSENSUS_TX_UPDATE: EventType.VERTEX_METADATA_CHANGED, - HathorEvents.CONSENSUS_TX_REMOVED: EventType.VERTEX_REMOVED + HathorEvents.CONSENSUS_TX_REMOVED: EventType.VERTEX_REMOVED, + HathorEvents.NC_EVENT: EventType.NC_EVENT } _EVENT_TYPE_TO_EVENT_DATA: dict[EventType, type[BaseEventData]] = { @@ -58,4 +60,5 @@ def data_type(self) -> type[BaseEventData]: EventType.VERTEX_METADATA_CHANGED: TxData, EventType.VERTEX_REMOVED: TxDataWithoutMeta, EventType.FULL_NODE_CRASHED: EmptyData, + EventType.NC_EVENT: NCEventData, } diff --git a/hathor/manager.py b/hathor/manager.py index 2cf3351fa..964565e48 100644 --- a/hathor/manager.py +++ b/hathor/manager.py @@ -44,7 +44,10 @@ from hathor.feature_activation.bit_signaling_service import BitSignalingService from hathor.mining import BlockTemplate, BlockTemplates from hathor.mining.cpu_mining_service import CpuMiningService +from hathor.nanocontracts.exception import NanoContractDoesNotExist +from hathor.nanocontracts.runner import Runner from hathor.nanocontracts.runner.runner import RunnerFactory +from hathor.nanocontracts.storage import NCBlockStorage, NCContractStorage from hathor.p2p.manager import ConnectionsManager from hathor.p2p.peer import PrivatePeer from hathor.p2p.peer_id import PeerId @@ -384,6 +387,34 @@ def stop_profiler(self, save_to: Optional[str] = None) -> None: if save_to: self.profiler.dump_stats(save_to) + def get_nc_runner(self, block: Block) -> Runner: + """Return a contract runner for a given block.""" + raise NotImplementedError('temporarily removed during nano merge') + + def get_best_block_nc_runner(self) -> Runner: + """Return a contract runner for the best block.""" + best_block = self.tx_storage.get_best_block() + return self.get_nc_runner(best_block) + + def get_nc_block_storage(self, block: Block) -> NCBlockStorage: + """Return the nano block storage for a given block.""" + raise NotImplementedError('temporarily removed during nano merge') + + def get_nc_storage(self, block: Block, nc_id: VertexId) -> NCContractStorage: + """Return a contract storage with the contract state at a given block.""" + from hathor.nanocontracts.types import ContractId, VertexId as NCVertexId + block_storage = self.get_nc_block_storage(block) + try: + contract_storage = block_storage.get_contract_storage(ContractId(NCVertexId(nc_id))) + except KeyError: + raise NanoContractDoesNotExist(nc_id.hex()) + return contract_storage + + def get_best_block_nc_storage(self, nc_id: VertexId) -> NCContractStorage: + """Return a contract storage with the contract state at the best block.""" + best_block = self.tx_storage.get_best_block() + return self.get_nc_storage(best_block, nc_id) + def _initialize_components(self) -> None: """You are not supposed to run this method manually. You should run `doStart()` to initialize the manager. diff --git a/hathor/nanocontracts/resources/__init__.py b/hathor/nanocontracts/resources/__init__.py new file mode 100644 index 000000000..5bb0b1119 --- /dev/null +++ b/hathor/nanocontracts/resources/__init__.py @@ -0,0 +1,31 @@ +# Copyright 2021 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.resources.blueprint import BlueprintInfoResource +from hathor.nanocontracts.resources.blueprint_source_code import BlueprintSourceCodeResource +from hathor.nanocontracts.resources.builtin import BlueprintBuiltinResource +from hathor.nanocontracts.resources.history import NanoContractHistoryResource +from hathor.nanocontracts.resources.nc_creation import NCCreationResource +from hathor.nanocontracts.resources.on_chain import BlueprintOnChainResource +from hathor.nanocontracts.resources.state import NanoContractStateResource + +__all__ = [ + 'BlueprintBuiltinResource', + 'BlueprintInfoResource', + 'BlueprintOnChainResource', + 'BlueprintSourceCodeResource', + 'NanoContractStateResource', + 'NanoContractHistoryResource', + 'NCCreationResource', +] diff --git a/hathor/nanocontracts/resources/blueprint.py b/hathor/nanocontracts/resources/blueprint.py new file mode 100644 index 000000000..242f3beab --- /dev/null +++ b/hathor/nanocontracts/resources/blueprint.py @@ -0,0 +1,251 @@ +# Copyright 2022 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 builtins +import inspect +import types +import typing +from typing import TYPE_CHECKING, Any, Optional + +from hathor.api_util import Resource, set_cors +from hathor.cli.openapi_files.register import register_resource +from hathor.nanocontracts import types as nc_types +from hathor.nanocontracts.blueprint import NC_FIELDS_ATTR +from hathor.nanocontracts.context import Context +from hathor.nanocontracts.exception import BlueprintDoesNotExist +from hathor.nanocontracts.types import blueprint_id_from_bytes +from hathor.nanocontracts.utils import is_nc_public_method, is_nc_view_method +from hathor.utils.api import ErrorResponse, QueryParams, Response +from hathor.utils.typing import get_args, get_origin + +if TYPE_CHECKING: + from twisted.web.http import Request + + from hathor.manager import HathorManager + + +@register_resource +class BlueprintInfoResource(Resource): + """Implements a GET API to return information about a blueprint.""" + isLeaf = True + + def __init__(self, manager: 'HathorManager'): + self.manager = manager + + def _get_composed_type_name(self, type_name: str, args: tuple[Any, ...]) -> str: + subtypes = ', '.join([self.get_type_name(x) for x in args]) + return f'{type_name}[{subtypes}]' + + def _get_optional_type_name(self, arg: Any) -> str: + subtype = self.get_type_name(arg) + return f'{subtype}?' + + def get_type_name(self, type_: type) -> str: + """Return a string representation for `type_`.""" + origin = get_origin(type_) or type_ + args = get_args(type_) or tuple() + + if (type_ is type(None)) or (type_ is None): # noqa: E721 + return 'null' + + match origin: + case builtins.dict | builtins.tuple | builtins.list | builtins.set: + return self._get_composed_type_name(origin.__name__, args) + case typing.Union | types.UnionType: + match args: + case (_subtype, types.NoneType) | (types.NoneType, _subtype): + return self._get_optional_type_name(_subtype) + return self._get_composed_type_name('union', args) + case nc_types.SignedData: + return self._get_composed_type_name('SignedData', args) + + return type_.__name__ + + def render_GET(self, request: 'Request') -> bytes: + request.setHeader(b'content-type', b'application/json; charset=utf-8') + set_cors(request, 'GET') + + params = BlueprintInfoParams.from_request(request) + if isinstance(params, ErrorResponse): + request.setResponseCode(400) + return params.json_dumpb() + + try: + blueprint_id = blueprint_id_from_bytes(bytes.fromhex(params.blueprint_id)) + except ValueError: + request.setResponseCode(400) + error_response = ErrorResponse(success=False, error=f'Invalid id: {params.blueprint_id}') + return error_response.json_dumpb() + + try: + blueprint_class = self.manager.tx_storage.get_blueprint_class(blueprint_id) + except BlueprintDoesNotExist: + request.setResponseCode(404) + error_response = ErrorResponse(success=False, error=f'Blueprint not found: {params.blueprint_id}') + return error_response.json_dumpb() + + attributes: dict[str, str] = {} + fields = getattr(blueprint_class, NC_FIELDS_ATTR) + for name, _type in fields.items(): + assert name not in attributes + attributes[name] = self.get_type_name(_type) + + public_methods = {} + view_methods = {} + skip_methods = {'__init__'} + for name, method in inspect.getmembers(blueprint_class, predicate=inspect.isfunction): + if name in skip_methods: + continue + + if not (is_nc_public_method(method) or is_nc_view_method(method)): + continue + + method_args = [] + argspec = inspect.getfullargspec(method) + for arg_name in argspec.args[1:]: + arg_type = argspec.annotations[arg_name] + if arg_type is Context: + continue + method_args.append(MethodArgInfo( + name=arg_name, + type=self.get_type_name(arg_type), + )) + + return_type = argspec.annotations.get('return', None) + + method_info = MethodInfo( + args=method_args, + return_type=self.get_type_name(return_type), + docstring=inspect.getdoc(method), + ) + + if is_nc_public_method(method): + assert name not in public_methods + public_methods[name] = method_info + + if is_nc_view_method(method): + assert name not in view_methods + view_methods[name] = method_info + + response = BlueprintInfoResponse( + id=params.blueprint_id, + name=blueprint_class.__name__, + attributes=attributes, + public_methods=public_methods, + private_methods=view_methods, # DEPRECATED + view_methods=view_methods, + docstring=inspect.getdoc(blueprint_class), + ) + return response.json_dumpb() + + +class BlueprintInfoParams(QueryParams): + blueprint_id: str + + +class MethodArgInfo(Response): + name: str + type: str + + +class MethodInfo(Response): + args: list[MethodArgInfo] + return_type: Optional[str] + docstring: str | None + + +class BlueprintInfoResponse(Response): + id: str + name: str + attributes: dict[str, str] + public_methods: dict[str, MethodInfo] + private_methods: dict[str, MethodInfo] # DEPRECATED + view_methods: dict[str, MethodInfo] + docstring: str | None + + +BlueprintInfoResource.openapi = { + '/nano_contract/blueprint/info': { + 'x-visibility': 'public', + 'x-rate-limit': { + 'global': [ + { + 'rate': '100r/s', + 'burst': 100, + 'delay': 100 + } + ], + 'per-ip': [ + { + 'rate': '3r/s', + 'burst': 10, + 'delay': 3 + } + ] + }, + 'get': { + 'operationId': 'blueprint-info', + 'summary': 'Return information about a blueprint', + 'responses': { + '200': { + 'description': 'Success', + 'content': { + 'application/json': { + 'examples': { + 'success': { + 'summary': 'Success', + 'value': { + 'id': '3cb032600bdf7db784800e4ea911b10676fa2f67591f82bb62628c234e771595', + 'name': 'Bet', + 'attributes': { + 'total_bets': 'int', + }, + 'public_methods': { + 'initialize': { + 'args': [{ + 'name': 'oracle_script', + 'type': 'bytes' + }], + 'return_type': 'null' + }, + 'bet': { + 'args': [{ + 'name': 'address', + 'type': 'bytes', + }, { + 'name': 'score', + 'type': 'str' + }], + 'return_type': 'null' + }, + }, + 'view_methods': { + 'get_winner_amount': { + 'args': [{ + 'name': 'address', + 'type': 'bytes' + }], + 'return_type': 'int' + }, + } + } + } + } + } + } + } + } + } + } +} diff --git a/hathor/nanocontracts/resources/blueprint_source_code.py b/hathor/nanocontracts/resources/blueprint_source_code.py new file mode 100644 index 000000000..193790a0c --- /dev/null +++ b/hathor/nanocontracts/resources/blueprint_source_code.py @@ -0,0 +1,124 @@ +# Copyright 2022 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 typing import TYPE_CHECKING + +from hathor.api_util import Resource, set_cors +from hathor.cli.openapi_files.register import register_resource +from hathor.nanocontracts.exception import BlueprintDoesNotExist, OCBBlueprintNotConfirmed +from hathor.nanocontracts.types import blueprint_id_from_bytes +from hathor.utils.api import ErrorResponse, QueryParams, Response + +if TYPE_CHECKING: + from twisted.web.http import Request + + from hathor.manager import HathorManager + + +@register_resource +class BlueprintSourceCodeResource(Resource): + """Implements a GET API to return the source code of a blueprint.""" + isLeaf = True + + def __init__(self, manager: 'HathorManager'): + self.manager = manager + + def render_GET(self, request: 'Request') -> bytes: + request.setHeader(b'content-type', b'application/json; charset=utf-8') + set_cors(request, 'GET') + + params = BlueprintSourceCodeParams.from_request(request) + if isinstance(params, ErrorResponse): + request.setResponseCode(400) + return params.json_dumpb() + + try: + blueprint_id = blueprint_id_from_bytes(bytes.fromhex(params.blueprint_id)) + except ValueError: + request.setResponseCode(400) + error_response = ErrorResponse(success=False, error=f'Invalid id: {params.blueprint_id}') + return error_response.json_dumpb() + + assert self.manager.tx_storage.nc_catalog is not None + + try: + blueprint_source = self.manager.tx_storage.get_blueprint_source(blueprint_id) + except OCBBlueprintNotConfirmed: + request.setResponseCode(404) + error_response = ErrorResponse(success=False, error=f'Blueprint not confirmed: {params.blueprint_id}') + return error_response.json_dumpb() + except BlueprintDoesNotExist: + request.setResponseCode(404) + error_response = ErrorResponse(success=False, error=f'Blueprint not found: {params.blueprint_id}') + return error_response.json_dumpb() + + response = BlueprintSourceCodeResponse( + id=params.blueprint_id, + source_code=blueprint_source, + ) + return response.json_dumpb() + + +class BlueprintSourceCodeParams(QueryParams): + blueprint_id: str + + +class BlueprintSourceCodeResponse(Response): + id: str + source_code: str + + +BlueprintSourceCodeResource.openapi = { + '/nano_contract/blueprint/source': { + 'x-visibility': 'public', + 'x-rate-limit': { + 'global': [ + { + 'rate': '5r/s', + 'burst': 8, + 'delay': 3 + } + ], + 'per-ip': [ + { + 'rate': '2r/s', + 'burst': 4, + 'delay': 3 + } + ] + }, + 'get': { + 'operationId': 'blueprint-source-code', + 'summary': 'Return source code of a blueprint', + 'responses': { + '200': { + 'description': 'Success', + 'content': { + 'application/json': { + 'examples': { + 'success': { + 'summary': 'Success', + 'value': { + 'id': '3cb032600bdf7db784800e4ea911b10676fa2f67591f82bb62628c234e771595', + 'source_code': 'def f(arg1: str):\nreturn arg1 + 2', + } + } + } + } + } + } + } + } + } +} diff --git a/hathor/nanocontracts/resources/builtin.py b/hathor/nanocontracts/resources/builtin.py new file mode 100644 index 000000000..a75abe90b --- /dev/null +++ b/hathor/nanocontracts/resources/builtin.py @@ -0,0 +1,214 @@ +# 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 typing import Iterator + +from pydantic import Field +from sortedcontainers import SortedKeyList +from twisted.web.http import Request + +from hathor.api_util import Resource, set_cors +from hathor.cli.openapi_files.register import register_resource +from hathor.manager import HathorManager +from hathor.nanocontracts import Blueprint +from hathor.util import collect_n +from hathor.utils.api import ErrorResponse, QueryParams, Response + + +@register_resource +class BlueprintBuiltinResource(Resource): + """Implements a GET API to return a list of builtin blueprints.""" + isLeaf = True + + def __init__(self, manager: HathorManager) -> None: + super().__init__() + self.manager = manager + + def render_GET(self, request: Request) -> bytes: + request.setHeader(b'content-type', b'application/json; charset=utf-8') + set_cors(request, 'GET') + + params = BuiltinBlueprintsParams.from_request(request) + if isinstance(params, ErrorResponse): + request.setResponseCode(400) + return params.json_dumpb() + + if params.after and params.before: + request.setResponseCode(400) + error_response = ErrorResponse( + success=False, error='Parameters after and before can\'t be used together.') + return error_response.json_dumpb() + + assert self.manager.tx_storage.nc_catalog is not None + builtin_bps = list(self.manager.tx_storage.nc_catalog.blueprints.items()) + + filtered_bps = builtin_bps + if params.search: + search = params.search.strip().lower() + # first we try to find by blueprint ID + filtered_bps = [ + (bp_id, bp_class) for bp_id, bp_class in builtin_bps + if bp_id.hex().lower() == search + ] + + if filtered_bps: + # If we find the Blueprint, it's a single match, and any pagination returns empty. + assert len(filtered_bps) == 1 + if params.after or params.before: + filtered_bps = [] + else: + # If we didn't find it, we'll try by name + filtered_bps = [ + (bp_id, bp_class) for bp_id, bp_class in builtin_bps + if search in bp_class.__name__.lower() + ] + + sorted_bps = SortedKeyList(filtered_bps, key=lambda bp_id_and_class: bp_id_and_class[0]) + reverse = bool(params.before) + start_key = bytes.fromhex(params.before or params.after or '') or None + bp_iter: Iterator[tuple[bytes, type[Blueprint]]] = sorted_bps.irange_key( + min_key=None if reverse else start_key, + max_key=start_key if reverse else None, + reverse=reverse, + inclusive=(False, False), + ) + page, has_more = collect_n(bp_iter, params.count) + + blueprints = [ + BuiltinBlueprintItem(id=bp_id.hex(), name=bp_class.__name__) + for bp_id, bp_class in page + ] + + response = BuiltinBlueprintsResponse( + before=params.before, + after=params.after, + count=params.count, + has_more=has_more, + blueprints=blueprints, + ) + return response.json_dumpb() + + +class BuiltinBlueprintsParams(QueryParams, use_enum_values=True): + before: str | None + after: str | None + count: int = Field(default=10, gt=0, le=100) + search: str | None = None + + +class BuiltinBlueprintItem(Response): + id: str + name: str + + +class BuiltinBlueprintsResponse(Response): + success: bool = Field(default=True, const=True) + blueprints: list[BuiltinBlueprintItem] + before: str | None + after: str | None + count: int + has_more: bool + + +BlueprintBuiltinResource.openapi = { + '/nano_contract/blueprint/builtin': { + 'x-visibility': 'public', + 'x-rate-limit': { + 'global': [ + { + 'rate': '100r/s', + 'burst': 100, + 'delay': 100 + } + ], + 'per-ip': [ + { + 'rate': '3r/s', + 'burst': 10, + 'delay': 3 + } + ] + }, + 'get': { + 'operationId': 'builtin-blueprints', + 'summary': 'Return a list of builtin blueprints', + 'parameters': [ + { + 'name': 'before', + 'in': 'query', + 'description': 'Hash of transaction to offset the result before.', + 'required': False, + 'schema': { + 'type': 'string', + } + }, + { + 'name': 'after', + 'in': 'query', + 'description': 'Hash of transaction to offset the result after.', + 'required': False, + 'schema': { + 'type': 'string', + } + }, + { + 'name': 'count', + 'in': 'query', + 'description': 'Maximum number of items to be returned. Default is 10.', + 'required': False, + 'schema': { + 'type': 'int', + } + }, + { + 'name': 'search', + 'in': 'query', + 'description': 'Filter the list using the provided string, that could be a Blueprint ID or name.', + 'required': False, + 'schema': { + 'type': 'string', + } + }, + ], + 'responses': { + '200': { + 'description': 'Success', + 'content': { + 'application/json': { + 'examples': { + 'success': { + 'summary': 'Success', + 'value': { + 'success': True, + 'before': None, + 'after': None, + 'count': 10, + 'has_more': False, + 'blueprints': [ + { + 'id': '3cb032600bdf7db784800e4ea911b106' + '76fa2f67591f82bb62628c234e771595', + 'name': 'Bet' + } + ], + } + } + } + } + } + } + } + } + } +} diff --git a/hathor/nanocontracts/resources/history.py b/hathor/nanocontracts/resources/history.py new file mode 100644 index 000000000..9e10bfd35 --- /dev/null +++ b/hathor/nanocontracts/resources/history.py @@ -0,0 +1,265 @@ +# Copyright 2021 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 typing import TYPE_CHECKING, Any, Optional + +from pydantic import Field + +from hathor.api_util import Resource, set_cors +from hathor.cli.openapi_files.register import register_resource +from hathor.nanocontracts.exception import NanoContractDoesNotExist +from hathor.transaction.storage.exceptions import TransactionDoesNotExist +from hathor.utils.api import ErrorResponse, QueryParams, Response + +if TYPE_CHECKING: + from twisted.web.http import Request + + from hathor.manager import HathorManager + + +@register_resource +class NanoContractHistoryResource(Resource): + """ Implements a web server GET API to get a nano contract history. + You must run with option `--status `. + """ + isLeaf = True + + def __init__(self, manager: 'HathorManager'): + self.manager = manager + + def render_GET(self, request: 'Request') -> bytes: + request.setHeader(b'content-type', b'application/json; charset=utf-8') + set_cors(request, 'GET') + + tx_storage = self.manager.tx_storage + assert tx_storage.indexes is not None + if tx_storage.indexes.nc_history is None: + request.setResponseCode(503) + error_response = ErrorResponse(success=False, error='Nano contract history index not initialized') + return error_response.json_dumpb() + + params = NCHistoryParams.from_request(request) + if isinstance(params, ErrorResponse): + request.setResponseCode(400) + return params.json_dumpb() + + if params.after and params.before: + request.setResponseCode(400) + error_response = ErrorResponse(success=False, error='Parameters after and before can\'t be used together.') + return error_response.json_dumpb() + + try: + nc_id_bytes = bytes.fromhex(params.id) + except ValueError: + request.setResponseCode(400) + error_response = ErrorResponse(success=False, error=f'Invalid id: {params.id}') + return error_response.json_dumpb() + + # Check if the contract exists. + try: + self.manager.get_best_block_nc_storage(nc_id_bytes) + except NanoContractDoesNotExist: + request.setResponseCode(404) + error_response = ErrorResponse(success=False, error='Nano contract does not exist.') + return error_response.json_dumpb() + + if params.after: + try: + ref_tx = tx_storage.get_transaction(bytes.fromhex(params.after)) + except TransactionDoesNotExist: + request.setResponseCode(400) + error_response = ErrorResponse(success=False, error=f'Hash {params.after} is not a transaction hash.') + return error_response.json_dumpb() + + iter_history = iter(tx_storage.indexes.nc_history.get_older(nc_id_bytes, ref_tx)) + # This method returns the iterator including the tx used as `after` + next(iter_history) + elif params.before: + try: + ref_tx = tx_storage.get_transaction(bytes.fromhex(params.before)) + except TransactionDoesNotExist: + request.setResponseCode(400) + error_response = ErrorResponse(success=False, error=f'Hash {params.before} is not a transaction hash.') + return error_response.json_dumpb() + + iter_history = iter(tx_storage.indexes.nc_history.get_newer(nc_id_bytes, ref_tx)) + # This method returns the iterator including the tx used as `before` + next(iter_history) + else: + iter_history = iter(tx_storage.indexes.nc_history.get_newest(nc_id_bytes)) + + count = params.count + has_more = False + history_list = [] + for idx, tx_id in enumerate(iter_history): + history_list.append(tx_storage.get_transaction(tx_id).to_json_extended()) + if idx >= count - 1: + # Check if iterator still has more elements + try: + next(iter_history) + has_more = True + except StopIteration: + has_more = False + break + + response = NCHistoryResponse( + success=True, + count=count, + after=params.after, + before=params.before, + history=history_list, + has_more=has_more, + ) + return response.json_dumpb() + + +class NCHistoryParams(QueryParams): + id: str + after: Optional[str] + before: Optional[str] + count: int = Field(default=100, lt=500) + + +class NCHistoryResponse(Response): + success: bool + count: int + after: Optional[str] + before: Optional[str] + history: list[dict[str, Any]] + has_more: bool + + +openapi_history_response = { + 'hash': '5c02adea056d7b43e83171a0e2d226d564c791d583b32e9a404ef53a2e1b363a', + 'nonce': 0, + 'timestamp': 1572636346, + 'version': 4, + 'weight': 1, + 'signal_bits': 0, + 'parents': ['1234', '5678'], + 'inputs': [], + 'outputs': [], + 'metadata': { + 'hash': '5c02adea056d7b43e83171a0e2d226d564c791d583b32e9a404ef53a2e1b363a', + 'spent_outputs': [], + 'received_by': [], + 'children': [], + 'conflict_with': [], + 'voided_by': [], + 'twins': [], + 'accumulated_weight': 1, + 'score': 0, + 'height': 0, + 'min_height': 0, + 'feature_activation_bit_counts': None, + 'first_block': None, + 'validation': 'full' + }, + 'tokens': [], + 'nc_id': '5c02adea056d7b43e83171a0e2d226d564c791d583b32e9a404ef53a2e1b363a', + 'nc_method': 'initialize', + 'nc_args': '0004313233340001000004654d8749', + 'nc_pubkey': '033f5d238afaa9e2218d05dd7fa50eb6f9e55431e6359e04b861cd991ae24dc655' +} + + +NanoContractHistoryResource.openapi = { + '/nano_contract/history': { + 'x-visibility': 'public', + 'x-rate-limit': { + 'global': [ + { + 'rate': '3r/s', + 'burst': 10, + 'delay': 3 + } + ], + 'per-ip': [ + { + 'rate': '1r/s', + 'burst': 4, + 'delay': 2 + } + ] + }, + 'get': { + 'tags': ['nano_contracts'], + 'operationId': 'nano_contracts_history', + 'summary': 'Get history of a nano contract', + 'description': 'Returns the history of a nano contract.', + 'parameters': [ + { + 'name': 'id', + 'in': 'query', + 'description': 'ID of the nano contract to get the history from.', + 'required': True, + 'schema': { + 'type': 'string' + } + }, { + 'name': 'count', + 'in': 'query', + 'description': 'Maximum number of items to be returned. Default is 100.', + 'required': False, + 'schema': { + 'type': 'int', + } + }, { + 'name': 'after', + 'in': 'query', + 'description': 'Hash of transaction to offset the result after.', + 'required': False, + 'schema': { + 'type': 'string', + } + }, { + 'name': 'before', + 'in': 'query', + 'description': 'Hash of transaction to offset the result before.', + 'required': False, + 'schema': { + 'type': 'string', + } + } + ], + 'responses': { + '200': { + 'description': 'Success', + 'content': { + 'application/json': { + 'examples': { + 'success': { + 'summary': 'History of a nano contract', + 'value': { + 'success': True, + 'count': 100, + 'has_more': False, + 'history': [openapi_history_response], + } + }, + 'error': { + 'summary': 'Nano contract history index not initialized.', + 'value': { + 'success': False, + 'message': 'Nano contract history index not initialized.' + } + }, + } + } + } + } + } + } + } +} diff --git a/hathor/nanocontracts/resources/nc_creation.py b/hathor/nanocontracts/resources/nc_creation.py new file mode 100644 index 000000000..d994904ce --- /dev/null +++ b/hathor/nanocontracts/resources/nc_creation.py @@ -0,0 +1,328 @@ +# Copyright 2021 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 pydantic import Field +from twisted.web.http import Request + +from hathor.api_util import Resource, set_cors +from hathor.cli.openapi_files.register import register_resource +from hathor.manager import HathorManager +from hathor.nanocontracts.resources.on_chain import SortOrder +from hathor.nanocontracts.types import BlueprintId, VertexId +from hathor.transaction.storage.exceptions import TransactionDoesNotExist +from hathor.util import bytes_from_hex, collect_n, not_none +from hathor.utils.api import ErrorResponse, QueryParams, Response + + +@register_resource +class NCCreationResource(Resource): + """Implements a GET API to return a list of NC creation txs.""" + isLeaf = True + + def __init__(self, manager: HathorManager) -> None: + super().__init__() + self.manager = manager + self.tx_storage = self.manager.tx_storage + assert self.tx_storage.indexes is not None + self.nc_creation_index = self.tx_storage.indexes.nc_creation + self.nc_history_index = self.tx_storage.indexes.nc_history + self.bp_history_index = self.tx_storage.indexes.blueprint_history + + def render_GET(self, request: Request) -> bytes: + request.setHeader(b'content-type', b'application/json; charset=utf-8') + set_cors(request, 'GET') + + if not self.nc_creation_index or not self.nc_history_index or not self.bp_history_index: + request.setResponseCode(503) + error_response = ErrorResponse(success=False, error='NC indices not initialized') + return error_response.json_dumpb() + + params = NCCreationParams.from_request(request) + if isinstance(params, ErrorResponse): + request.setResponseCode(400) + return params.json_dumpb() + + if params.after and params.before: + request.setResponseCode(400) + error_response = ErrorResponse(success=False, error='Parameters after and before can\'t be used together.') + return error_response.json_dumpb() + + vertex_id: VertexId | None = None + if params.search: + search = params.search.strip() + maybe_bytes = bytes_from_hex(search) + if maybe_bytes is None: + # in this case we do have `search` but it's not a valid hex, so we return empty. + response = NCCreationResponse( + nc_creation_txs=[], + before=params.before, + after=params.after, + count=params.count, + has_more=False, + ) + return response.json_dumpb() + + vertex_id = VertexId(maybe_bytes) + + # when using `search`, the value can be either a NC ID or a BP ID. + if nc_item := self._get_nc_creation_item(vertex_id): + # if we find the respective NC, it's a single match, and therefore any pagination + # returns an empty result. + nc_list = [nc_item] if not params.after and not params.before else [] + response = NCCreationResponse( + nc_creation_txs=nc_list, + before=params.before, + after=params.after, + count=params.count, + has_more=False, + ) + return response.json_dumpb() + # now vertex_id may be a BP, so it will be used below + + is_desc = params.order.is_desc() + + if not params.before and not params.after: + if vertex_id: + iter_nc_ids = ( + self.bp_history_index.get_newest(vertex_id) + if is_desc else self.bp_history_index.get_oldest(vertex_id) + ) + else: + iter_nc_ids = self.nc_creation_index.get_newest() if is_desc else self.nc_creation_index.get_oldest() + else: + ref_tx_id_hex = params.before or params.after + assert ref_tx_id_hex is not None + ref_tx_id = bytes_from_hex(ref_tx_id_hex) + if ref_tx_id is None: + request.setResponseCode(400) + error_response = ErrorResponse(success=False, error=f'Invalid "before" or "after": {ref_tx_id_hex}') + return error_response.json_dumpb() + + try: + ref_tx = self.tx_storage.get_transaction(ref_tx_id) + except TransactionDoesNotExist: + request.setResponseCode(404) + error_response = ErrorResponse(success=False, error=f'Transaction {ref_tx_id_hex} not found.') + return error_response.json_dumpb() + + if vertex_id: + if is_desc: + iter_getter = self.bp_history_index.get_newer if params.before else self.bp_history_index.get_older + else: + iter_getter = self.bp_history_index.get_older if params.before else self.bp_history_index.get_newer + iter_nc_ids = iter_getter(vertex_id, ref_tx) + next(iter_nc_ids) # these iterators include the ref_tx, so we skip it. + else: + if is_desc: + iter_getter2 = ( + self.nc_creation_index.get_newer if params.before else self.nc_creation_index.get_older + ) + else: + iter_getter2 = ( + self.nc_creation_index.get_older if params.before else self.nc_creation_index.get_newer + ) + iter_nc_ids = iter_getter2(tx_start=ref_tx) + + iter_ncs = map(self._get_nc_creation_item_strict, iter_nc_ids) + nc_txs, has_more = collect_n(iter_ncs, params.count) + response = NCCreationResponse( + nc_creation_txs=nc_txs, + before=params.before, + after=params.after, + count=params.count, + has_more=has_more, + ) + return response.json_dumpb() + + def _get_nc_creation_item(self, nc_id: bytes) -> NCCreationItem | None: + try: + tx = self.tx_storage.get_transaction(nc_id) + except TransactionDoesNotExist: + return None + + if not tx.is_nano_contract(): + return None + + from hathor.transaction import Transaction + if not isinstance(tx, Transaction): + return None + + nano_header = tx.get_nano_header() + if not nano_header.is_creating_a_new_contract(): + return None + + blueprint_id = BlueprintId(VertexId(nano_header.nc_id)) + blueprint_class = self.tx_storage.get_blueprint_class(blueprint_id) + + assert self.nc_history_index is not None + return NCCreationItem( + nano_contract_id=nc_id.hex(), + blueprint_id=blueprint_id.hex(), + blueprint_name=blueprint_class.__name__, + last_tx_timestamp=not_none(self.nc_history_index.get_last_tx_timestamp(nc_id)), + total_txs=self.nc_history_index.get_transaction_count(nc_id), + created_at=tx.timestamp, + ) + + def _get_nc_creation_item_strict(self, nc_id: bytes) -> NCCreationItem: + tx = self._get_nc_creation_item(nc_id) + assert tx is not None + return tx + + +class NCCreationParams(QueryParams): + before: str | None + after: str | None + count: int = Field(default=10, le=100) + search: str | None + order: SortOrder = SortOrder.DESC + + +class NCCreationItem(Response): + nano_contract_id: str + blueprint_id: str + blueprint_name: str + last_tx_timestamp: int + total_txs: int + created_at: int + + +class NCCreationResponse(Response): + success: bool = Field(default=True, const=True) + nc_creation_txs: list[NCCreationItem] + before: str | None + after: str | None + count: int + has_more: bool + + +NCCreationResource.openapi = { + '/nano_contract/creation': { + 'x-visibility': 'public', + 'x-rate-limit': { + 'global': [ + { + 'rate': '3r/s', + 'burst': 10, + 'delay': 3 + } + ], + 'per-ip': [ + { + 'rate': '1r/s', + 'burst': 4, + 'delay': 2 + } + ] + }, + 'get': { + 'tags': ['nano_contracts'], + 'operationId': 'nc-creations-txs', + 'summary': 'Get a list of Nano Contract creation transactions', + 'parameters': [ + { + 'name': 'before', + 'in': 'query', + 'description': 'Hash of transaction to offset the result before.', + 'required': False, + 'schema': { + 'type': 'string', + } + }, + { + 'name': 'after', + 'in': 'query', + 'description': 'Hash of transaction to offset the result after.', + 'required': False, + 'schema': { + 'type': 'string', + } + }, + { + 'name': 'count', + 'in': 'query', + 'description': 'Maximum number of items to be returned. Default is 10.', + 'required': False, + 'schema': { + 'type': 'int', + } + }, + { + 'name': 'search', + 'in': 'query', + 'description': 'Filter the list using the provided string,' + 'that could be a Nano Contract ID or a Blueprint ID.', + 'required': False, + 'schema': { + 'type': 'string', + } + }, + { + 'name': 'order', + 'in': 'query', + 'description': 'Sort order, either "asc" or "desc".', + 'required': False, + 'schema': { + 'type': 'string', + } + } + ], + 'responses': { + '200': { + 'description': 'Success', + 'content': { + 'application/json': { + 'examples': { + 'success': { + 'summary': 'Success', + 'value': { + 'success': True, + 'after': None, + 'before': None, + 'count': 10, + 'has_more': False, + 'nc_creation_txs': [ + { + 'blueprint_id': '3cb032600bdf7db784800e4ea911b106' + '76fa2f67591f82bb62628c234e771595', + 'blueprint_name': 'BlueprintA', + 'created_at': 1737565681, + 'last_tx_timestamp': 1737565681, + 'nano_contract_id': '081c0e7586486d657353bc844b26dace' + 'aa93e54e2f0b65e9debf956e51a3805f', + 'total_txs': 1 + }, + { + 'blueprint_id': '15b9eb0547e0961259df84c400615a69' + 'fc204fe8d026b93337c33f0b9377a5bd', + 'blueprint_name': 'BlueprintB', + 'created_at': 1737565679, + 'last_tx_timestamp': 1737565679, + 'nano_contract_id': '773cd47af52e55fca04ce3aecab585c9' + '40b4661daf600956b3d60cff8fa186ed', + 'total_txs': 1 + } + ] + } + }, + } + } + } + } + } + } + } +} diff --git a/hathor/nanocontracts/resources/nc_exec_logs.py b/hathor/nanocontracts/resources/nc_exec_logs.py new file mode 100644 index 000000000..c9092644f --- /dev/null +++ b/hathor/nanocontracts/resources/nc_exec_logs.py @@ -0,0 +1,131 @@ +# 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 typing import Any + +from pydantic import Field +from twisted.web.http import Request + +from hathor.api_util import Resource +from hathor.cli.openapi_files.register import register_resource +from hathor.utils.api import QueryParams + + +@register_resource +class NCExecLogsResource(Resource): + """Implements a web server GET API to get nano contract execution logs.""" + isLeaf = True + + def render_GET(self, request: Request) -> bytes: + raise NotImplementedError('temporarily removed during nano merge') + + +class NCExecLogsParams(QueryParams): + id: str + log_level: str | None = None + all_execs: bool = False + + +class NCExecLogsResponse(QueryParams): + success: bool = Field(const=True, default=True) + nc_id: str + nc_execution: str | None + logs: dict[str, Any] + + +NCExecLogsResource.openapi = { + '/nano_contract/logs': { + 'x-visibility': 'private', + 'get': { + 'operationId': 'nano_contracts_logs', + 'summary': 'Get execution logs of a nano contract', + 'description': 'Returns the execution logs of a nano contract per Block ID that executed it.', + 'parameters': [ + { + 'name': 'id', + 'in': 'query', + 'description': 'ID of the nano contract to get the logs from.', + 'required': True, + 'schema': { + 'type': 'string' + } + }, + { + 'name': 'log_level', + 'in': 'query', + 'description': 'Minimum log level to filter logs. One of DEBUG, INFO, WARN, ERROR. ' + 'Default is DEBUG, that is, no filter.', + 'required': False, + 'schema': { + 'type': 'string' + } + }, + { + 'name': 'all_execs', + 'in': 'query', + 'description': 'Whether to get all NC executions or just from the current block that executed the ' + 'NC, that is, the NC\'s first_block. Default is false.', + 'required': False, + 'schema': { + 'type': 'bool' + } + }, + ], + 'responses': { + '200': { + 'description': 'Success', + 'content': { + 'application/json': { + 'examples': { + 'success': { + 'summary': 'NC execution logs', + 'value': { + 'success': True, + 'logs': { + '25b90432c597f715e4ad4bd62436ae5f48dc988d47f051d8b3eb21ca008d6783': [ + { + 'error_traceback': None, + 'timestamp': 1739289130, + 'logs': [ + { + 'type': 'BEGIN', + 'level': 'DEBUG', + 'nc_id': '00001cc24fc57fce28da879c24d46d84' + '1c932c04bdadac28f0cd530c6c702dc9', + 'call_type': 'public', + 'method_name': 'initialize', + 'args': [], + 'kwargs': {}, + 'timestamp': 1739289133, + }, + { + 'type': 'LOG', + 'level': 'INFO', + 'message': 'initialize() called on MyBlueprint1', + 'key_values': {} + } + ], + } + ], + }, + } + } + } + } + } + } + } + } + } +} diff --git a/hathor/nanocontracts/resources/on_chain.py b/hathor/nanocontracts/resources/on_chain.py new file mode 100644 index 000000000..9f9a1c321 --- /dev/null +++ b/hathor/nanocontracts/resources/on_chain.py @@ -0,0 +1,281 @@ +# 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 enum import Enum + +from pydantic import Field +from twisted.web.http import Request + +from hathor.api_util import Resource, set_cors +from hathor.cli.openapi_files.register import register_resource +from hathor.manager import HathorManager +from hathor.nanocontracts.exception import ( + BlueprintDoesNotExist, + OCBBlueprintNotConfirmed, + OCBInvalidBlueprintVertexType, +) +from hathor.nanocontracts.types import blueprint_id_from_bytes +from hathor.util import bytes_from_hex +from hathor.utils.api import ErrorResponse, QueryParams, Response + + +@register_resource +class BlueprintOnChainResource(Resource): + """Implements a GET API to return a list of on-chain blueprints.""" + isLeaf = True + + def __init__(self, manager: HathorManager) -> None: + super().__init__() + self.manager = manager + + def render_GET(self, request: Request) -> bytes: + request.setHeader(b'content-type', b'application/json; charset=utf-8') + set_cors(request, 'GET') + + tx_storage = self.manager.tx_storage + assert tx_storage.indexes is not None + if tx_storage.indexes.blueprints is None: + request.setResponseCode(503) + error_response = ErrorResponse(success=False, error='Blueprint index not initialized') + return error_response.json_dumpb() + + bp_index = tx_storage.indexes.blueprints + + params = OnChainBlueprintsParams.from_request(request) + if isinstance(params, ErrorResponse): + request.setResponseCode(400) + return params.json_dumpb() + + if params.after and params.before: + request.setResponseCode(400) + error_response = ErrorResponse(success=False, error='Parameters after and before can\'t be used together.') + return error_response.json_dumpb() + + if params.search: + search = params.search.strip() + blueprint_list = [] + if bp_id := bytes_from_hex(search): + try: + bp_tx = tx_storage.get_on_chain_blueprint(blueprint_id_from_bytes(bp_id)) + except (BlueprintDoesNotExist, OCBInvalidBlueprintVertexType, OCBBlueprintNotConfirmed): + pass + else: + bp_class = bp_tx.get_blueprint_class() + bp_item = OnChainBlueprintItem( + id=search, + name=bp_class.__name__, + created_at=bp_tx.timestamp, + ) + blueprint_list = [bp_item] if not params.after and not params.before else [] + + response = OnChainBlueprintsResponse( + blueprints=blueprint_list, + before=params.before, + after=params.after, + count=params.count, + has_more=False, + ) + return response.json_dumpb() + + if not params.before and not params.after: + iter_bps = bp_index.get_newest() if params.order.is_desc() else bp_index.get_oldest() + else: + ref_tx_id = bytes.fromhex(params.before or params.after or '') + assert ref_tx_id + try: + ref_tx = tx_storage.get_on_chain_blueprint(blueprint_id_from_bytes(ref_tx_id)) + except (BlueprintDoesNotExist, OCBInvalidBlueprintVertexType, OCBBlueprintNotConfirmed) as e: + request.setResponseCode(404) + error_response = ErrorResponse( + success=False, error=f'Blueprint not found: {repr(e)}' + ) + return error_response.json_dumpb() + + if params.order.is_desc(): + iter_bps_getter = bp_index.get_newer if params.before else bp_index.get_older + else: + iter_bps_getter = bp_index.get_older if params.before else bp_index.get_newer + iter_bps = iter_bps_getter(tx_start=ref_tx) + + has_more = False + blueprints = [] + for idx, bp_id in enumerate(iter_bps): + try: + bp_tx = tx_storage.get_on_chain_blueprint(blueprint_id_from_bytes(bp_id)) + except (BlueprintDoesNotExist, OCBInvalidBlueprintVertexType): + raise AssertionError('bps iterator must always yield valid blueprint txs') + except OCBBlueprintNotConfirmed: + # unconfirmed OCBs are simply not added to the response + continue + bp_class = bp_tx.get_blueprint_class() + bp_item = OnChainBlueprintItem( + id=bp_id.hex(), + name=bp_class.__name__, + created_at=bp_tx.timestamp, + ) + blueprints.append(bp_item) + if idx >= params.count - 1: + try: + next(iter_bps) + has_more = True + except StopIteration: + has_more = False + break + + response = OnChainBlueprintsResponse( + blueprints=blueprints, + before=params.before, + after=params.after, + count=params.count, + has_more=has_more, + ) + return response.json_dumpb() + + +class SortOrder(str, Enum): + ASC = 'asc' + DESC = 'desc' + + def is_desc(self) -> bool: + return self == SortOrder.DESC + + +class OnChainBlueprintsParams(QueryParams): + before: str | None + after: str | None + count: int = Field(default=10, le=100) + search: str | None = None + order: SortOrder = SortOrder.DESC + + +class OnChainBlueprintItem(Response): + id: str + name: str + created_at: int + + +class OnChainBlueprintsResponse(Response): + success: bool = Field(default=True, const=True) + blueprints: list[OnChainBlueprintItem] + before: str | None + after: str | None + count: int + has_more: bool + + +BlueprintOnChainResource.openapi = { + '/nano_contract/blueprint/on_chain': { + 'x-visibility': 'public', + 'x-rate-limit': { + 'global': [ + { + 'rate': '100r/s', + 'burst': 100, + 'delay': 100 + } + ], + 'per-ip': [ + { + 'rate': '3r/s', + 'burst': 10, + 'delay': 3 + } + ] + }, + 'get': { + 'operationId': 'on-chain-blueprints', + 'summary': 'Return a list of on-chain blueprints', + 'parameters': [ + { + 'name': 'before', + 'in': 'query', + 'description': 'Hash of transaction to offset the result before.', + 'required': False, + 'schema': { + 'type': 'string', + } + }, + { + 'name': 'after', + 'in': 'query', + 'description': 'Hash of transaction to offset the result after.', + 'required': False, + 'schema': { + 'type': 'string', + } + }, + { + 'name': 'count', + 'in': 'query', + 'description': 'Maximum number of items to be returned. Default is 10.', + 'required': False, + 'schema': { + 'type': 'int', + } + }, + { + 'name': 'search', + 'in': 'query', + 'description': 'Filter the list using the provided string, that can be a Blueprint ID.', + 'required': False, + 'schema': { + 'type': 'string', + } + }, + { + 'name': 'order', + 'in': 'query', + 'description': 'Sort order, either "asc" or "desc".', + 'required': False, + 'schema': { + 'type': 'string', + } + } + ], + 'responses': { + '200': { + 'description': 'Success', + 'content': { + 'application/json': { + 'examples': { + 'success': { + 'summary': 'Success', + 'value': { + 'success': True, + 'blueprints': [ + { + 'id': '0000035c5977ff42c40e6845f91d72af4feb06ce87ce9f50119b5d00e0906458', + 'name': 'BlueprintA', + 'created_at': 1736353724 + }, + { + 'id': '0000010881987e7fcce37cac7c1342f6f81b0a8e2f9c8ba6377a6272d433366e', + 'name': 'BlueprintB', + 'created_at': 1736351322 + } + ], + 'before': None, + 'after': None, + 'count': 2, + 'has_more': True + } + } + } + } + } + } + } + } + } +} diff --git a/hathor/nanocontracts/resources/state.py b/hathor/nanocontracts/resources/state.py new file mode 100644 index 000000000..58bbfb521 --- /dev/null +++ b/hathor/nanocontracts/resources/state.py @@ -0,0 +1,277 @@ +# Copyright 2021 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, Optional + +from pydantic import Field + +from hathor.api_util import Resource +from hathor.cli.openapi_files.register import register_resource +from hathor.crypto.util import decode_address +from hathor.utils.api import QueryParams, Response +from hathor.wallet.exceptions import InvalidAddress + +if TYPE_CHECKING: + from twisted.web.http import Request + + from hathor.manager import HathorManager + + +@register_resource +class NanoContractStateResource(Resource): + """ Implements a web server GET API to get a nano contract state. + You must run with option `--status `. + """ + isLeaf = True + + def __init__(self, manager: 'HathorManager') -> None: + super().__init__() + self.manager = manager + + def render_GET(self, request: 'Request') -> bytes: + raise NotImplementedError('temporarily removed during nano merge') + + def get_key_for_field(self, field: str) -> Optional[str]: + """Return the storage key for a given field.""" + # Queries might have multiple parts separated by '.' + parts = field.split('.') + try: + key_parts = [self.parse_field_name(name) for name in parts] + except ValueError: + return None + return ':'.join(key_parts) + + def parse_field_name(self, field: str) -> str: + """Parse field names.""" + if field.startswith("a'") and field.endswith("'"): + # Addresses are decoded to bytes + address = field[2:-1] + try: + return str(decode_address(address)) + except InvalidAddress as e: + raise ValueError from e + elif field.startswith("b'") and field.endswith("'"): + # This field is bytes and we receive this in hexa + hexa = field[2:-1] + # This will raise ValueError in case it's an invalid hexa + # and this will be handled in the get_key_for_field method + return str(bytes.fromhex(hexa)) + return field + + +class NCStateParams(QueryParams): + id: str + fields: list[str] = Field(alias='fields[]', default_factory=list) + balances: list[str] = Field(alias='balances[]', default_factory=list) + calls: list[str] = Field(alias='calls[]', default_factory=list) + block_hash: Optional[str] + block_height: Optional[int] + + +class NCValueSuccessResponse(Response): + value: Any + + +class NCBalanceSuccessResponse(Response): + value: str + can_mint: bool + can_melt: bool + + +class NCValueErrorResponse(Response): + errmsg: str + + +class NCStateResponse(Response): + success: bool + nc_id: str + blueprint_id: str + blueprint_name: str + fields: dict[str, NCValueSuccessResponse | NCValueErrorResponse] + balances: dict[str, NCBalanceSuccessResponse | NCValueErrorResponse] + calls: dict[str, NCValueSuccessResponse | NCValueErrorResponse] + + +_openapi_success_value = { + 'success': True, + 'nc_id': '00007f246f6d645ef3174f2eddf53f4b6bd41e8be0c0b7fbea9827cf53e12d9e', + 'blueprint_id': '3cb032600bdf7db784800e4ea911b10676fa2f67591f82bb62628c234e771595', + 'blueprint_name': 'Bet', + 'fields': { + 'token_uid': {'value': '00'}, + 'total': {'value': 300}, + 'final_result': {'value': '1x0'}, + 'oracle_script': {'value': '76a91441c431ff7ad5d6ce5565991e3dcd5d9106cfd1e288ac'}, + 'withdrawals.a\'Wi8zvxdXHjaUVAoCJf52t3WovTZYcU9aX6\'': {'value': 300}, + 'address_details.a\'Wi8zvxdXHjaUVAoCJf52t3WovTZYcU9aX6\'': {'value': {'1x0': 100}}, + } +} + + +NanoContractStateResource.openapi = { + '/nano_contract/state': { + 'x-visibility': 'public', + 'x-rate-limit': { + 'global': [ + { + 'rate': '10r/s', + 'burst': 20, + 'delay': 10 + } + ], + 'per-ip': [ + { + 'rate': '2r/s', + 'burst': 6, + 'delay': 3 + } + ] + }, + 'get': { + 'tags': ['nano_contracts'], + 'operationId': 'nano_contracts_state', + 'summary': 'Get state of a nano contract', + 'description': 'Returns the state requested of a nano contract.', + 'parameters': [ + { + 'name': 'id', + 'in': 'query', + 'description': 'ID of the nano contract to get the state from', + 'required': True, + 'schema': { + 'type': 'string' + } + }, + { + 'name': 'balances[]', + 'in': 'query', + 'description': 'List of token ids in hex to get the contract balance. ' + 'If you want to get the balance for all tokens in the contract, just use __all__.', + 'required': False, + 'schema': { + 'type': 'array', + 'items': { + 'type': 'string' + } + }, + 'examples': { + 'balances': { + 'summary': 'Example of balances', + 'value': ['00', '000008f2ee2059a189322ae7cb1d7e7773dcb4fdc8c4de8767f63022b3731845'] + }, + } + }, + { + 'name': 'calls[]', + 'in': 'query', + 'description': 'List of private method calls to be executed. ' + 'The format must be "method_name(arg1, arg2, arg3, ...)". ' + 'Bytes arguments must be sent in hex, address arguments in bytes ' + 'must be sent as hex itself, or in base58 with the address tag, e.g. ' + 'a\'Wi8zvxdXHjaUVAoCJf52t3WovTZYcU9aX6\', and tuple arguments must be ' + 'sent as an array, e.g., (a, b, c) must be sent as [a, b, c]. ' + 'For SignedData field we expect a list with two elements, where the ' + 'first one is the data to be signed and the second is the signature in hex.', + 'required': False, + 'schema': { + 'type': 'array', + 'items': { + 'type': 'string' + } + }, + 'examples': { + 'calls': { + 'summary': 'Example of calls', + 'value': ['view_method_1(arg1, arg2)', 'view_method_2()'] + }, + } + }, + { + 'name': 'fields[]', + 'in': 'query', + 'description': 'Fields to get the data from the nano contract state', + 'required': False, + 'schema': { + 'type': 'array', + 'items': { + 'type': 'string' + } + }, + 'examples': { + 'simple fields': { + 'summary': 'Only direct fields', + 'value': ['token_uid', 'total', 'final_result', 'oracle_script'] + }, + 'With dict fields': { + 'summary': ('Simple and dict fields (dict fields where the keys are addresses). ' + 'For an address you must encapsulate the b58 with a\'\''), + 'value': [ + 'token_uid', + 'total', + 'final_result', + 'oracle_script', + 'withdrawals.a\'Wi8zvxdXHjaUVAoCJf52t3WovTZYcU9aX6\'', + 'address_details.a\'Wi8zvxdXHjaUVAoCJf52t3WovTZYcU9aX6\'' + ] + }, + } + }, + { + 'name': 'block_height', + 'in': 'query', + 'description': 'Height of the block to get the nano contract state from.' + 'Can\'t be used together with block_hash parameter.', + 'required': False, + 'schema': { + 'type': 'string' + } + }, + { + 'name': 'block_hash', + 'in': 'query', + 'description': 'Hash of the block to get the nano contract state from.' + 'Can\'t be used together with block_height parameter.', + 'required': False, + 'schema': { + 'type': 'string' + } + }, + ], + 'responses': { + '200': { + 'description': 'Success', + 'content': { + 'application/json': { + 'examples': { + 'success': { + 'summary': 'Success to get state from nano', + 'value': _openapi_success_value, + }, + 'error': { + 'summary': 'Invalid nano contract ID', + 'value': { + 'success': False, + 'message': 'Invalid nano contract ID.' + } + }, + } + } + } + } + } + } + } +} diff --git a/hathor/pubsub.py b/hathor/pubsub.py index 8a4e25d4a..395123a12 100644 --- a/hathor/pubsub.py +++ b/hathor/pubsub.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + from collections import defaultdict, deque from enum import Enum from typing import TYPE_CHECKING, Any, Callable, Optional @@ -24,6 +26,7 @@ from hathor.utils.zope import verified_cast if TYPE_CHECKING: + from hathor.nanocontracts.nc_exec_logs import NCEvent from hathor.transaction import BaseTransaction, Block logger = get_logger() @@ -138,6 +141,8 @@ class HathorEvents(Enum): REORG_FINISHED = 'reorg:finished' + NC_EVENT = 'nc:event' + class EventArguments: """Simple object for storing event arguments. @@ -149,6 +154,7 @@ class EventArguments: old_best_block: 'Block' new_best_block: 'Block' common_block: 'Block' + nc_event: NCEvent def __init__(self, **kwargs: Any) -> None: for key, value in kwargs.items(): diff --git a/tests/event/test_base_event.py b/tests/event/test_base_event.py index fe842764e..54da1c4e0 100644 --- a/tests/event/test_base_event.py +++ b/tests/event/test_base_event.py @@ -64,7 +64,8 @@ def test_create_base_event(event_id: int, group_id: int | None) -> None: accumulated_weight_raw="1024", score_raw="1048576", height=100, - validation='validation' + validation='validation', + nc_execution=None, ) ), group_id=group_id diff --git a/tests/event/websocket/test_protocol.py b/tests/event/websocket/test_protocol.py index a13778876..290057f11 100644 --- a/tests/event/websocket/test_protocol.py +++ b/tests/event/websocket/test_protocol.py @@ -105,7 +105,7 @@ def test_send_event_response() -> None: b'"spent_outputs":[],"conflict_with":[],"voided_by":[],"received_by":[],"children":[],' b'"twins":[],"accumulated_weight":10.0,"score":20.0,"accumulated_weight_raw":"1024",' b'"score_raw":"1048576","first_block":null,"height":100,' - b'"validation":"validation"}},"group_id":null},"latest_event_id":10,' + b'"validation":"validation","nc_execution":null}},"group_id":null},"latest_event_id":10,' b'"stream_id":"stream_id"}') protocol.sendMessage.assert_called_once_with(expected_payload) diff --git a/tests/resources/nanocontracts/__init__.py b/tests/resources/nanocontracts/__init__.py new file mode 100644 index 000000000..e69de29bb