diff --git a/hathor/conf/mainnet.py b/hathor/conf/mainnet.py index 38b9797d0..301a2659e 100644 --- a/hathor/conf/mainnet.py +++ b/hathor/conf/mainnet.py @@ -214,6 +214,10 @@ '000045ecbab77c9a8d819ff6d26893b9da2774eee5539f17d8fc2394f82b758e', ])), ENABLE_NANO_CONTRACTS=NanoContractsSetting.FEATURE_ACTIVATION, + NC_ON_CHAIN_BLUEPRINT_ALLOWED_ADDRESSES=[ + 'HDkKGHwDHTuUGbhET73XdTJZkS8uU7PHf9', + 'HUbxYhtqW8pdRCC2WngPxN7MB4SUMDPrrh', + ], FEATURE_ACTIVATION=FeatureActivationSettings( features={ Feature.INCREASE_MAX_MERKLE_PATH_LENGTH: Criteria( @@ -246,8 +250,8 @@ # 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_947_200, - timeout_height=6_350_400, # N + 10 * 20160 (10 weeks after the start) - minimum_activation_height=6_027_840, + timeout_height=6_350_400, # 20 weeks + minimum_activation_height=6_048_000, # 5 weeks lock_in_on_timeout=False, version='0.67.0', signal_support_by_default=True, diff --git a/hathor/conf/mainnet.yml b/hathor/conf/mainnet.yml index 1d77e4d20..2428d95e5 100644 --- a/hathor/conf/mainnet.yml +++ b/hathor/conf/mainnet.yml @@ -194,7 +194,6 @@ SOFT_VOIDED_TX_IDS: - 00004305882eb3eef6b45f025ff58eb7baa5ca35f7d6f42c8b085482b00474e6 - 000045ecbab77c9a8d819ff6d26893b9da2774eee5539f17d8fc2394f82b758e -ENABLE_NANO_CONTRACTS: feature_activation FEATURE_ACTIVATION: features: INCREASE_MAX_MERKLE_PATH_LENGTH: @@ -226,7 +225,12 @@ FEATURE_ACTIVATION: # Right now the best block is 5_947_110 at Tuesday, 2025-10-14 20:01:09 GMT start_height: 5_947_200 timeout_height: 6_350_400 # 20 weeks - minimum_activation_height: 6_027_840 # 4 weeks + minimum_activation_height: 6_048_000 # 5 weeks lock_in_on_timeout: false version: 0.67.0 signal_support_by_default: true + +ENABLE_NANO_CONTRACTS: feature_activation +NC_ON_CHAIN_BLUEPRINT_ALLOWED_ADDRESSES: + - HDkKGHwDHTuUGbhET73XdTJZkS8uU7PHf9 + - HUbxYhtqW8pdRCC2WngPxN7MB4SUMDPrrh diff --git a/hathor/consensus/consensus.py b/hathor/consensus/consensus.py index 4e1fc4471..15fe538d7 100644 --- a/hathor/consensus/consensus.py +++ b/hathor/consensus/consensus.py @@ -167,8 +167,17 @@ def unsafe_update(self, base: BaseTransaction) -> None: assert isinstance(old_best_block, Block) new_best_block = base.storage.get_transaction(new_best_tip) reorg_size = old_best_block.get_height() - context.reorg_info.common_block.get_height() + # TODO: After we remove block ties, should the assert below be true? + # assert old_best_block.get_metadata().voided_by assert old_best_block != new_best_block assert reorg_size > 0 + self.log.info( + 'reorg detected', + reorg_size=reorg_size, + previous_best_block=old_best_block.hash_hex, + new_best_block=new_best_block.hash_hex, + common_block=context.reorg_info.common_block.hash_hex, + ) context.pubsub.publish( HathorEvents.REORG_STARTED, old_best_height=best_height, diff --git a/hathor/dag_builder/artifacts.py b/hathor/dag_builder/artifacts.py index 32875fc82..c183f4358 100644 --- a/hathor/dag_builder/artifacts.py +++ b/hathor/dag_builder/artifacts.py @@ -18,7 +18,6 @@ from hathor.dag_builder.types import DAGNode from hathor.manager import HathorManager -from hathor.verification.verification_params import VerificationParams if TYPE_CHECKING: from hathor.transaction import BaseTransaction @@ -60,7 +59,6 @@ def propagate_with( *, up_to: str | None = None, up_to_before: str | None = None, - new_relayed_vertex: bool = False ) -> None: """ Propagate vertices using the provided manager up to the provided node name, included. Last propagation is @@ -82,17 +80,7 @@ def propagate_with( if found_begin: try: - if new_relayed_vertex: - assert manager.vertex_handler.on_new_relayed_vertex(vertex) - else: - best_block = manager.tx_storage.get_best_block() - best_block_meta = best_block.get_metadata() - params = VerificationParams( - enable_checkdatasig_count=True, - enable_nano=True, - nc_block_root_id=best_block_meta.nc_block_root_id, - ) - assert manager.vertex_handler._old_on_new_vertex(vertex, params) + assert manager.vertex_handler.on_new_relayed_vertex(vertex) except Exception as e: raise Exception(f'failed on_new_tx({node.name})') from e self._last_propagated = node.name diff --git a/hathor/nanocontracts/__init__.py b/hathor/nanocontracts/__init__.py index 42beabc97..4a6a9af03 100644 --- a/hathor/nanocontracts/__init__.py +++ b/hathor/nanocontracts/__init__.py @@ -18,7 +18,7 @@ from hathor.nanocontracts.exception import NCFail from hathor.nanocontracts.on_chain_blueprint import OnChainBlueprint from hathor.nanocontracts.runner import Runner -from hathor.nanocontracts.storage import NCMemoryStorageFactory, NCRocksDBStorageFactory, NCStorageFactory +from hathor.nanocontracts.storage import NCRocksDBStorageFactory, NCStorageFactory from hathor.nanocontracts.types import TokenUid, VertexId, export, fallback, public, view # Identifier used in metadata's voided_by when a Nano Contract method fails. @@ -32,7 +32,6 @@ 'Runner', 'OnChainBlueprint', 'NCFail', - 'NCMemoryStorageFactory', 'NCRocksDBStorageFactory', 'NCStorageFactory', 'public', diff --git a/hathor/nanocontracts/blueprint_env.py b/hathor/nanocontracts/blueprint_env.py index 3c41b0c41..bc220fd97 100644 --- a/hathor/nanocontracts/blueprint_env.py +++ b/hathor/nanocontracts/blueprint_env.py @@ -58,14 +58,28 @@ def rng(self) -> NanoRNG: return self.__runner.syscall_get_rng() def get_contract_id(self) -> ContractId: - """Return the contract id of this nano contract.""" + """Return the ContractId of the current nano contract.""" return self.__runner.get_current_contract_id() def get_blueprint_id(self) -> BlueprintId: - """Return the blueprint id of this nano contract.""" + """ + Return the BlueprintId of the current nano contract. + + This means that during a proxy call, this method will return the BlueprintId of the caller's blueprint, + NOT the BlueprintId of the Blueprint that owns the running code. + """ contract_id = self.get_contract_id() return self.__runner.get_blueprint_id(contract_id) + def get_current_code_blueprint_id(self) -> BlueprintId: + """ + Return the BlueprintId of the Blueprint that owns the currently running code. + + This means that during a proxy call, this method will return the BlueprintId of the Blueprint that owns the + running code, NOT the BlueprintId of the current nano contract. + """ + return self.__runner.get_current_code_blueprint_id() + def get_balance_before_current_call(self, token_uid: TokenUid | None = None) -> Amount: """ Return the balance for a given token before the current call, that is, @@ -233,7 +247,6 @@ def get_proxy(self, blueprint_id: BlueprintId) -> ProxyAccessor: Get a proxy accessor for the given blueprint ID. Use this for interacting with another blueprint via a proxy. """ from hathor.nanocontracts.proxy_accessor import ProxyAccessor - self.__runner.forbid_call_on_view('get_proxy') return ProxyAccessor(runner=self.__runner, blueprint_id=blueprint_id) def setup_new_contract( diff --git a/hathor/nanocontracts/proxy_accessor.py b/hathor/nanocontracts/proxy_accessor.py index 40027ef16..2317fc05d 100644 --- a/hathor/nanocontracts/proxy_accessor.py +++ b/hathor/nanocontracts/proxy_accessor.py @@ -45,6 +45,13 @@ def get_blueprint_id(self) -> BlueprintId: """Return the blueprint id of this proxy.""" return self.__blueprint_id + def view(self) -> Any: + """Prepare a call to a proxy view method.""" + return PreparedProxyViewCall( + runner=self.__runner, + blueprint_id=self.__blueprint_id, + ) + def public(self, *actions: NCAction, fees: Sequence[NCFee] | None = None, forbid_fallback: bool = False) -> Any: """Prepare a proxy call to a public method.""" return PreparedProxyPublicCall( @@ -55,6 +62,14 @@ def public(self, *actions: NCAction, fees: Sequence[NCFee] | None = None, forbid forbid_fallback=forbid_fallback, ) + def get_view_method(self, method_name: str) -> ProxyViewMethodAccessor: + """Get a proxy view method.""" + return ProxyViewMethodAccessor( + runner=self.__runner, + blueprint_id=self.__blueprint_id, + method_name=method_name, + ) + def get_public_method( self, method_name: str, @@ -73,6 +88,26 @@ def get_public_method( ) +@final +class PreparedProxyViewCall(FauxImmutable): + __slots__ = ('__runner', '__blueprint_id') + __skip_faux_immutability_validation__ = True # Needed to implement __getattr__ + + def __init__(self, *, runner: Runner, blueprint_id: BlueprintId) -> None: + self.__runner: Runner + self.__blueprint_id: BlueprintId + + __set_faux_immutable__(self, '__runner', runner) + __set_faux_immutable__(self, '__blueprint_id', blueprint_id) + + def __getattr__(self, method_name: str) -> ProxyViewMethodAccessor: + return ProxyViewMethodAccessor( + runner=self.__runner, + blueprint_id=self.__blueprint_id, + method_name=method_name, + ) + + @final class PreparedProxyPublicCall(FauxImmutable): __slots__ = ( @@ -128,6 +163,38 @@ def __getattr__(self, method_name: str) -> ProxyPublicMethodAccessor: ) +@final +class ProxyViewMethodAccessor(FauxImmutable): + """ + This class represents a "proxy view method", or a proxy view method accessor, during a blueprint method execution. + It's a callable that will forward the call to the actual wrapped blueprint via syscall. + It may be used multiple times to call the same method with different arguments. + """ + __slots__ = ('__runner', '__blueprint_id', '__method_name') + + def __init__(self, *, runner: Runner, blueprint_id: BlueprintId, method_name: str) -> None: + self.__runner: Runner + self.__blueprint_id: BlueprintId + self.__method_name: str + + __set_faux_immutable__(self, '__runner', runner) + __set_faux_immutable__(self, '__blueprint_id', blueprint_id) + __set_faux_immutable__(self, '__method_name', method_name) + + def call(self, *args: Any, **kwargs: Any) -> object: + """Call the method with the provided arguments. This is just an alias for calling the object directly.""" + return self(*args, **kwargs) + + def __call__(self, *args: Any, **kwargs: Any) -> object: + """Call the method with the provided arguments.""" + return self.__runner.syscall_proxy_call_view_method( + blueprint_id=self.__blueprint_id, + method_name=self.__method_name, + args=args, + kwargs=kwargs, + ) + + @final class ProxyPublicMethodAccessor(FauxImmutable): """ diff --git a/hathor/nanocontracts/runner/index_records.py b/hathor/nanocontracts/runner/index_records.py index 14d5c7929..2e0123358 100644 --- a/hathor/nanocontracts/runner/index_records.py +++ b/hathor/nanocontracts/runner/index_records.py @@ -83,10 +83,12 @@ def to_json(self) -> dict[str, Any]: @classmethod def from_json(cls, json_dict: dict[str, Any]) -> Self: + token_version = TokenVersion(json_dict['token_version']) + assert token_version in (TokenVersion.DEPOSIT, TokenVersion.FEE) return cls( token_uid=TokenUid(VertexId(bytes.fromhex(json_dict['token_uid']))), amount=json_dict['amount'], - token_version=json_dict['token_version'], + token_version=token_version, # type: ignore[arg-type] token_name=json_dict['token_name'], token_symbol=json_dict['token_symbol'], ) diff --git a/hathor/nanocontracts/runner/runner.py b/hathor/nanocontracts/runner/runner.py index dcdcea100..a04903320 100644 --- a/hathor/nanocontracts/runner/runner.py +++ b/hathor/nanocontracts/runner/runner.py @@ -55,9 +55,9 @@ UpdateAuthoritiesRecord, UpdateTokenBalanceRecord, ) +from hathor.nanocontracts.runner.token_fees import calculate_melt_fee, calculate_mint_fee from hathor.nanocontracts.storage import NCBlockStorage, NCChangesTracker, NCContractStorage, NCStorageFactory from hathor.nanocontracts.storage.contract_storage import Balance -from hathor.nanocontracts.syscall_token_balance_rules import TokenSyscallBalanceRules from hathor.nanocontracts.types import ( NC_ALLOW_REENTRANCY, NC_ALLOWED_ACTIONS_ATTR, @@ -229,6 +229,11 @@ def get_blueprint_id(self, contract_id: ContractId) -> BlueprintId: nc_storage = self.get_current_changes_tracker_or_storage(contract_id) return nc_storage.get_blueprint_id() + def get_current_code_blueprint_id(self) -> BlueprintId: + """Return the blueprint id of the blueprint that owns the executing code.""" + current_call_record = self.get_current_call_record() + return current_call_record.blueprint_id + def _build_call_info(self, contract_id: ContractId) -> CallInfo: from hathor.nanocontracts.nc_exec_logs import NCLogger return CallInfo( @@ -351,6 +356,37 @@ def syscall_call_another_contract_public_method( fees=fees ) + def syscall_proxy_call_view_method( + self, + *, + blueprint_id: BlueprintId, + method_name: str, + args: tuple[Any, ...], + kwargs: dict[str, Any], + ) -> Any: + """Execute a proxy call to another blueprint's view method (similar to a DELEGATECALL). + This method must be called by a blueprint during an execution. + + When using a proxy call: + - The code from the target blueprint runs as if it were part of the calling contract + - For all purposes, it is a call to the calling contract + - The storage context remains that of the calling contract + """ + contract_id = self.get_current_contract_id() + if blueprint_id == self.get_blueprint_id(contract_id): + raise NCInvalidSyscall('cannot call the same blueprint of the running contract') + + if blueprint_id == self.get_current_code_blueprint_id(): + raise NCInvalidSyscall('cannot call the same blueprint of the running blueprint') + + return self._unsafe_call_view_method( + contract_id=self.get_current_contract_id(), + blueprint_id=blueprint_id, + method_name=method_name, + args=args, + kwargs=kwargs, + ) + @_forbid_syscall_from_view('proxy_call_public_method') def syscall_proxy_call_public_method( self, @@ -365,7 +401,7 @@ def syscall_proxy_call_public_method( """Execute a proxy call to another blueprint's public method (similar to a DELEGATECALL). This method must be called by a blueprint during an execution. - When using delegatecall: + When using a proxy call: - The code from the target blueprint runs as if it were part of the calling contract - For all purposes, it is a call to the calling contract - The storage context remains that of the calling contract @@ -374,9 +410,11 @@ def syscall_proxy_call_public_method( raise NCInvalidInitializeMethodCall('cannot call initialize from another contract') contract_id = self.get_current_contract_id() - if blueprint_id == self.get_blueprint_id(contract_id): - raise NCInvalidSyscall('cannot call the same blueprint') + raise NCInvalidSyscall('cannot call the same blueprint of the running contract') + + if blueprint_id == self.get_current_code_blueprint_id(): + raise NCInvalidSyscall('cannot call the same blueprint of the running blueprint') return self._unsafe_call_another_contract_public_method( contract_id=contract_id, @@ -437,9 +475,9 @@ def _unsafe_call_another_contract_public_method( # execution, the verification of the tokens and amounts will be done after it for fee in fees: assert fee.amount > 0 - self._update_tokens_amount([ - UpdateTokenBalanceRecord(token_uid=fee.token_uid, amount=-fee.amount) - ]) + self._update_tokens_amount( + fee=UpdateTokenBalanceRecord(token_uid=fee.token_uid, amount=-fee.amount), + ) self._register_paid_fee(fee.token_uid, fee.amount) ctx_actions = Context.__group_actions__(actions) @@ -676,7 +714,13 @@ def call_view_method(self, contract_id: ContractId, method_name: str, *args: Any assert self._call_info is None self._call_info = self._build_call_info(contract_id) try: - return self._unsafe_call_view_method(contract_id, method_name, args, kwargs) + return self._unsafe_call_view_method( + contract_id=contract_id, + blueprint_id=self.get_blueprint_id(contract_id), + method_name=method_name, + args=args, + kwargs=kwargs, + ) finally: self._reset_all_change_trackers() @@ -717,11 +761,19 @@ def syscall_call_another_contract_view_method( assert self._call_info is not None if self.get_current_contract_id() == contract_id: raise NCInvalidContractId('a contract cannot call itself') - return self._unsafe_call_view_method(contract_id, method_name, args, kwargs) + return self._unsafe_call_view_method( + contract_id=contract_id, + blueprint_id=self.get_blueprint_id(contract_id), + method_name=method_name, + args=args, + kwargs=kwargs, + ) def _unsafe_call_view_method( self, + *, contract_id: ContractId, + blueprint_id: BlueprintId, method_name: str, args: tuple[Any, ...], kwargs: dict[str, Any], @@ -735,7 +787,6 @@ def _unsafe_call_view_method( self._metered_executor = MeteredExecutor(fuel=self._initial_fuel, memory_limit=self._memory_limit) changes_tracker = self._create_changes_tracker(contract_id) - blueprint_id = self.get_blueprint_id(contract_id) blueprint = self._create_blueprint_instance(blueprint_id, changes_tracker) method = getattr(blueprint, method_name, None) @@ -982,14 +1033,19 @@ def syscall_mint_tokens( if not balance.can_mint: raise NCInvalidSyscall(f'contract {call_record.contract_id.hex()} cannot mint {token_uid.hex()} tokens') - fee_payment_token_info = self._get_token(fee_payment_token) token_info = self._get_token(token_uid) + fee_amount = calculate_mint_fee( + settings=self._settings, + token_version=token_info.token_version, + amount=amount, + fee_payment_token=self._get_token(fee_payment_token), + ) - syscall_rules = TokenSyscallBalanceRules.get_rules(token_uid, token_info.token_version, self._settings) - syscall_balance = syscall_rules.mint(amount, fee_payment_token=fee_payment_token_info) - records = syscall_rules.get_syscall_update_token_records(syscall_balance) - - self._update_tokens_amount(records) + assert amount > 0 and fee_amount < 0 + self._update_tokens_amount( + operation=UpdateTokenBalanceRecord(token_uid=token_uid, amount=amount), + fee=UpdateTokenBalanceRecord(token_uid=fee_payment_token, amount=fee_amount), + ) @_forbid_syscall_from_view('melt_tokens') def syscall_melt_tokens( @@ -1017,13 +1073,28 @@ def syscall_melt_tokens( raise NCInvalidSyscall(f'contract {call_record.contract_id.hex()} cannot melt {token_uid.hex()} tokens') token_info = self._get_token(token_uid) - fee_payment_token_info = self._get_token(fee_payment_token) - - syscall_rules = TokenSyscallBalanceRules.get_rules(token_uid, token_info.token_version, self._settings) - syscall_balance = syscall_rules.melt(amount, fee_payment_token=fee_payment_token_info) - records = syscall_rules.get_syscall_update_token_records(syscall_balance) + fee_amount = calculate_melt_fee( + settings=self._settings, + token_version=token_info.token_version, + amount=amount, + fee_payment_token=self._get_token(fee_payment_token), + ) - self._update_tokens_amount(records) + assert amount > 0 + match token_info.token_version: + case TokenVersion.NATIVE: + raise AssertionError + case TokenVersion.DEPOSIT: + assert fee_amount > 0 + case TokenVersion.FEE: + assert fee_amount < 0 + case _: # pragma: no cover + assert_never(token_info.token_version) + + self._update_tokens_amount( + operation=UpdateTokenBalanceRecord(token_uid=token_uid, amount=-amount), + fee=UpdateTokenBalanceRecord(token_uid=fee_payment_token, amount=fee_amount), + ) def _validate_context(self, ctx: Context) -> None: """Check whether the context is valid.""" @@ -1103,17 +1174,14 @@ def syscall_create_child_deposit_token( grant_melt=melt_authority, ) - syscall_rules = TokenSyscallBalanceRules.get_rules(token_id, token_version, self._settings) - syscall_balance = syscall_rules.create_token( + self._create_token( + token_version=token_version, token_uid=token_id, - token_symbol=token_symbol, - token_name=token_name, amount=amount, - fee_payment_token=self._get_token(TokenUid(HATHOR_TOKEN_UID)) + fee_payment_token=self._get_token(TokenUid(HATHOR_TOKEN_UID)), + token_name=token_name, + token_symbol=token_symbol, ) - records = syscall_rules.get_syscall_update_token_records(syscall_balance) - - self._update_tokens_amount(records) return token_id @@ -1143,7 +1211,6 @@ def syscall_create_child_fee_token( parent_id = call_record.contract_id cleaned_token_symbol = clean_token_string(token_symbol) - fee_payment_token_info = self._get_token(fee_payment_token) token_id = derive_child_token_id(parent_id, cleaned_token_symbol, salt=salt) token_version = TokenVersion.FEE @@ -1159,17 +1226,15 @@ def syscall_create_child_fee_token( grant_mint=mint_authority, grant_melt=melt_authority, ) - syscall_rules = TokenSyscallBalanceRules.get_rules(token_id, token_version, self._settings) - syscall_balance = syscall_rules.create_token( + + self._create_token( + token_version=token_version, token_uid=token_id, + amount=amount, + fee_payment_token=self._get_token(fee_payment_token), token_symbol=token_symbol, token_name=token_name, - amount=amount, - fee_payment_token=fee_payment_token_info ) - records = syscall_rules.get_syscall_update_token_records(syscall_balance) - - self._update_tokens_amount(records) return token_id @@ -1238,7 +1303,45 @@ def _get_token(self, token_uid: TokenUid) -> TokenDescription: token_id=token_creation_tx.hash ) - def _update_tokens_amount(self, records: list[UpdateTokenBalanceRecord | CreateTokenRecord]) -> None: + def _create_token( + self, + *, + token_version: TokenVersion, + token_uid: TokenUid, + amount: int, + fee_payment_token: TokenDescription, + token_symbol: str, + token_name: str, + ) -> None: + """Create a new token.""" + assert token_version in (TokenVersion.DEPOSIT, TokenVersion.FEE) + fee_amount = calculate_mint_fee( + settings=self._settings, + token_version=token_version, + amount=amount, + fee_payment_token=fee_payment_token, + ) + assert amount > 0 and fee_amount < 0 + self._update_tokens_amount( + operation=CreateTokenRecord( + token_uid=token_uid, + amount=amount, + token_version=token_version, # type: ignore[arg-type] + token_symbol=token_symbol, + token_name=token_name, + ), + fee=UpdateTokenBalanceRecord( + token_uid=TokenUid(fee_payment_token.token_id), + amount=fee_amount, + ), + ) + + def _update_tokens_amount( + self, + *, + operation: UpdateTokenBalanceRecord | CreateTokenRecord | None = None, + fee: UpdateTokenBalanceRecord | None = None, + ) -> None: """ Update token balances and create index records for a token operation. @@ -1246,20 +1349,16 @@ def _update_tokens_amount(self, records: list[UpdateTokenBalanceRecord | CreateT 1. Updates the contract's token balances in the changes tracker 2. Updates the global token totals 3. Appends the syscall records to call_record.index_updates - - Args: - records: List of syscall update records (typically main token + fee payment) - - Raises: - AssertionError: If call_record.index_updates is None """ call_record = self.get_current_call_record() changes_tracker = self.get_current_changes_tracker() - + assert operation or fee assert changes_tracker.nc_id == call_record.contract_id assert call_record.index_updates is not None - for record in records: + for record in (operation, fee): + if record is None: + continue changes_tracker.add_balance(record.token_uid, record.amount) self._updated_tokens_totals[record.token_uid] += record.amount call_record.index_updates.append(record) diff --git a/hathor/nanocontracts/runner/token_fees.py b/hathor/nanocontracts/runner/token_fees.py new file mode 100644 index 000000000..896fd76e9 --- /dev/null +++ b/hathor/nanocontracts/runner/token_fees.py @@ -0,0 +1,88 @@ +# 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_extensions import assert_never + +from hathor.conf.settings import HathorSettings +from hathor.nanocontracts.exception import NCInvalidFeePaymentToken +from hathor.transaction.token_info import TokenDescription, TokenVersion +from hathor.transaction.util import get_deposit_token_deposit_amount, get_deposit_token_withdraw_amount + + +def calculate_mint_fee( + *, + settings: HathorSettings, + token_version: TokenVersion, + amount: int, + fee_payment_token: TokenDescription, +) -> int: + """Calculate the fee for a mint operation.""" + match token_version: + case TokenVersion.NATIVE: + raise AssertionError + case TokenVersion.DEPOSIT: + _validate_deposit_based_payment_token(fee_payment_token) + return -get_deposit_token_deposit_amount(settings, amount) + case TokenVersion.FEE: + _validate_fee_based_payment_token(fee_payment_token) + return -_calculate_unit_fee_token_fee(settings, fee_payment_token) + case _: # pragma: no cover + assert_never(token_version) + + +def calculate_melt_fee( + *, + settings: HathorSettings, + token_version: TokenVersion, + amount: int, + fee_payment_token: TokenDescription, +) -> int: + """Calculate the fee for a melt operation.""" + match token_version: + case TokenVersion.NATIVE: + raise AssertionError + case TokenVersion.DEPOSIT: + _validate_deposit_based_payment_token(fee_payment_token) + return +get_deposit_token_withdraw_amount(settings, amount) + case TokenVersion.FEE: + _validate_fee_based_payment_token(fee_payment_token) + return -_calculate_unit_fee_token_fee(settings, fee_payment_token) + case _: # pragma: no cover + assert_never(token_version) + + +def _validate_deposit_based_payment_token(fee_payment_token: TokenDescription) -> None: + """Validate the token used to pay the fee of a deposit-based token operation.""" + from hathor import HATHOR_TOKEN_UID + if fee_payment_token.token_id != HATHOR_TOKEN_UID: + raise NCInvalidFeePaymentToken('Only HTR is allowed to be used with deposit based token syscalls') + + +def _validate_fee_based_payment_token(fee_payment_token: TokenDescription) -> None: + """Validate the token used to pay the fee of a fee-based token operation.""" + match fee_payment_token.token_version: + case TokenVersion.FEE: + raise NCInvalidFeePaymentToken("fee-based tokens aren't allowed for paying fees") + case TokenVersion.DEPOSIT | TokenVersion.NATIVE: + pass + case _: # pragma: no cover + assert_never(fee_payment_token.token_version) + + +def _calculate_unit_fee_token_fee(settings: HathorSettings, fee_payment_token: TokenDescription) -> int: + """Calculate the fee for handling a fee-based token""" + from hathor import HATHOR_TOKEN_UID + if fee_payment_token.token_id == HATHOR_TOKEN_UID: + return settings.FEE_PER_OUTPUT + return int(settings.FEE_PER_OUTPUT / settings.TOKEN_DEPOSIT_PERCENTAGE) diff --git a/hathor/nanocontracts/storage/__init__.py b/hathor/nanocontracts/storage/__init__.py index 37f274af2..e54903494 100644 --- a/hathor/nanocontracts/storage/__init__.py +++ b/hathor/nanocontracts/storage/__init__.py @@ -15,14 +15,13 @@ from hathor.nanocontracts.storage.block_storage import NCBlockStorage from hathor.nanocontracts.storage.changes_tracker import NCChangesTracker from hathor.nanocontracts.storage.contract_storage import NCContractStorage -from hathor.nanocontracts.storage.factory import NCMemoryStorageFactory, NCRocksDBStorageFactory, NCStorageFactory +from hathor.nanocontracts.storage.factory import NCRocksDBStorageFactory, NCStorageFactory from hathor.nanocontracts.storage.types import DeletedKey __all__ = [ 'NCBlockStorage', 'NCContractStorage', 'NCChangesTracker', - 'NCMemoryStorageFactory', 'NCRocksDBStorageFactory', 'NCStorageFactory', 'DeletedKey', diff --git a/hathor/nanocontracts/storage/backends.py b/hathor/nanocontracts/storage/backends.py index d331c2831..e102068c4 100644 --- a/hathor/nanocontracts/storage/backends.py +++ b/hathor/nanocontracts/storage/backends.py @@ -43,23 +43,6 @@ def __contains__(self, key: bytes) -> bool: raise NotImplementedError -class MemoryNodeTrieStore(NodeTrieStore): - def __init__(self) -> None: - self._db: dict[bytes, Node] = {} - - def __getitem__(self, key: bytes) -> Node: - return self._db[key] - - def __setitem__(self, key: bytes, item: Node) -> None: - self._db[key] = item - - def __len__(self) -> int: - return len(self._db) - - def __contains__(self, key: bytes) -> bool: - return key in self._db - - class RocksDBNodeTrieStore(NodeTrieStore): _CF_NAME = b'nc-state' _KEY_LENGTH = b'length' diff --git a/hathor/nanocontracts/storage/factory.py b/hathor/nanocontracts/storage/factory.py index e4430b8f6..3086bcfaf 100644 --- a/hathor/nanocontracts/storage/factory.py +++ b/hathor/nanocontracts/storage/factory.py @@ -17,7 +17,7 @@ from abc import ABC from typing import TYPE_CHECKING, Optional -from hathor.nanocontracts.storage.backends import MemoryNodeTrieStore, NodeTrieStore, RocksDBNodeTrieStore +from hathor.nanocontracts.storage.backends import NodeTrieStore, RocksDBNodeTrieStore from hathor.nanocontracts.storage.block_storage import NCBlockStorage if TYPE_CHECKING: @@ -62,18 +62,6 @@ def get_empty_block_storage(self) -> NCBlockStorage: return NCBlockStorage(trie) -class NCMemoryStorageFactory(NCStorageFactory): - """Factory to create a memory storage for a contract. - - As it is a memory storage, the factory keeps all contract stored data on - its attribute `self.data`. - """ - - def __init__(self) -> None: - # This attribute stores data from all contracts. - self._store = MemoryNodeTrieStore() - - class NCRocksDBStorageFactory(NCStorageFactory): """Factory to create a RocksDB storage for a contract. """ diff --git a/hathor/nanocontracts/syscall_token_balance_rules.py b/hathor/nanocontracts/syscall_token_balance_rules.py deleted file mode 100644 index 582182512..000000000 --- a/hathor/nanocontracts/syscall_token_balance_rules.py +++ /dev/null @@ -1,357 +0,0 @@ -# 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 __future__ import annotations - -from abc import ABC, abstractmethod -from dataclasses import dataclass -from enum import StrEnum, auto, unique - -from typing_extensions import assert_never - -from hathor.conf.settings import HATHOR_TOKEN_UID, HathorSettings -from hathor.nanocontracts.exception import NCInvalidFeePaymentToken -from hathor.nanocontracts.runner.index_records import CreateTokenRecord, UpdateTokenBalanceRecord -from hathor.nanocontracts.types import TokenUid -from hathor.transaction.token_info import TokenDescription, TokenVersion -from hathor.transaction.util import get_deposit_token_deposit_amount, get_deposit_token_withdraw_amount - - -@unique -class TokenOperationType(StrEnum): - """Types of token operations for syscalls.""" - CREATE = auto() - MINT = auto() - MELT = auto() - - -@dataclass(slots=True, kw_only=True) -class TokenSyscallBalanceEntry: - token_uid: TokenUid - amount: int - - -@dataclass(slots=True, kw_only=True) -class TokenSyscallBalance: - type: TokenOperationType - token: TokenSyscallBalanceEntry - fee_payment: TokenSyscallBalanceEntry - # create token syscall - token_version: TokenVersion | None = None - token_symbol: str | None = None - token_name: str | None = None - - def to_syscall_records(self) -> list[UpdateTokenBalanceRecord | CreateTokenRecord]: - """ - Convert TokenSyscallBalance to a list of UpdateTokenBalanceRecord or CreateTokenRecord. - - Each operation generates two records: - 1. Main token operation (mint/melt/create) - 2. Fee payment token operation - - Returns: - A list with two index record instances. - """ - records: list[UpdateTokenBalanceRecord | CreateTokenRecord] = [] - - # First record: main token operation - if self.token_version is not None: - assert self.token_symbol is not None - assert self.token_name is not None - assert self.token_version != TokenVersion.NATIVE - records.append(CreateTokenRecord( - token_uid=self.token.token_uid, - amount=self.token.amount, - token_version=self.token_version, - token_symbol=self.token_symbol, - token_name=self.token_name, - )) - else: - assert self.token_symbol is None - assert self.token_name is None - records.append(UpdateTokenBalanceRecord(token_uid=self.token.token_uid, amount=self.token.amount)) - - # Second record: fee payment token - records.append(UpdateTokenBalanceRecord( - token_uid=self.fee_payment.token_uid, - amount=self.fee_payment.amount, - )) - - return records - - -class TokenSyscallBalanceRules(ABC): - """ - An abstract base class that unifies token balance rules for syscalls. - - Requires definitions for create tokens, mint, and melt syscalls. - """ - - __slots__ = ('_settings', 'token_version', 'token_uid') - - def __init__( - self, - settings: HathorSettings, - token_uid: TokenUid, - token_version: TokenVersion - ) -> None: - self._settings = settings - self.token_version = token_version - self.token_uid = token_uid - - assert token_uid != TokenUid(HATHOR_TOKEN_UID) - assert token_version is not TokenVersion.NATIVE - - @abstractmethod - def create_token( - self, - *, - token_uid: TokenUid, - token_symbol: str, - token_name: str, - amount: int, - fee_payment_token: TokenDescription - ) -> TokenSyscallBalance: - """ - Calculate and return the token amounts needed for token creation syscalls. - - Returns: - `TokenSyscallBalance` with the token data and the amounts - """ - raise NotImplementedError - - @abstractmethod - def mint(self, amount: int, *, fee_payment_token: TokenDescription) -> TokenSyscallBalance: - """ - Calculate and return the token amounts needed for minting operations. - - Args: - amount: The amount to be minted. - fee_payment_token: The token that will be used to pay fees - - Returns: - TokenSyscallBalance: A data class with the current syscall record type, token UIDs, and - their respective amounts that will be used by the Runner class for balance updates during token minting. - """ - raise NotImplementedError - - @abstractmethod - def melt(self, amount: int, *, fee_payment_token: TokenDescription) -> TokenSyscallBalance: - """ - Calculate and return the token amounts needed for melting operations. - - Args: - amount: The amount to be melted. - fee_payment_token: The token that will be used to pay fees - - Returns: - TokenSyscallBalance: A data class with the current syscall record type, token UIDs, and - their respective amounts that will be used by the Runner class for balance updates during token melting. - """ - raise NotImplementedError - - @abstractmethod - def get_syscall_update_token_records( - self, - syscall_balance: TokenSyscallBalance - ) -> list[UpdateTokenBalanceRecord | CreateTokenRecord]: - """ - Create syscall update records for the given token operation. - - This method transforms a TokenSyscallBalance into a list of index records - that will be appended to the call record's index_updates for tracking token operations. - - Args: - syscall_balance: The token balance operation containing operation type, - token amounts, and payment details. - - Returns: - A list of syscall update records (main token + fee payment). - """ - raise NotImplementedError - - @staticmethod - def get_rules( - token_uid: TokenUid, - token_version: TokenVersion, - settings: HathorSettings - ) -> TokenSyscallBalanceRules: - """Get the balance rules instance for the provided token version.""" - match token_version: - case TokenVersion.DEPOSIT: - return _DepositTokenRules( - settings, - token_uid, - token_version, - ) - case TokenVersion.FEE: - return _FeeTokenRules( - settings, - token_uid, - token_version, - ) - case TokenVersion.NATIVE: - raise AssertionError(f"NATIVE token version is not supported for token {token_uid.hex()}") - case _: - assert_never(token_version) - - -class _DepositTokenRules(TokenSyscallBalanceRules): - - def create_token( - self, - *, - token_uid: TokenUid, - token_symbol: str, - token_name: str, - amount: int, - fee_payment_token: TokenDescription - ) -> TokenSyscallBalance: - assert amount > 0 - self._validate_payment_token(fee_payment_token) - htr_amount = -get_deposit_token_deposit_amount(self._settings, amount) - - return TokenSyscallBalance( - type=TokenOperationType.CREATE, - token_version=TokenVersion.DEPOSIT, - token_name=token_name, - token_symbol=token_symbol, - token=TokenSyscallBalanceEntry(token_uid=self.token_uid, amount=amount), - fee_payment=TokenSyscallBalanceEntry(token_uid=TokenUid(fee_payment_token.token_id), amount=htr_amount) - ) - - def mint(self, amount: int, *, fee_payment_token: TokenDescription) -> TokenSyscallBalance: - assert amount > 0 - self._validate_payment_token(fee_payment_token) - htr_amount = -get_deposit_token_deposit_amount(self._settings, amount) - - return TokenSyscallBalance( - type=TokenOperationType.MINT, - token=TokenSyscallBalanceEntry(token_uid=self.token_uid, amount=amount), - fee_payment=TokenSyscallBalanceEntry(token_uid=TokenUid(fee_payment_token.token_id), amount=htr_amount) - ) - - def melt(self, amount: int, *, fee_payment_token: TokenDescription) -> TokenSyscallBalance: - assert amount > 0 - self._validate_payment_token(fee_payment_token) - htr_amount = +get_deposit_token_withdraw_amount(self._settings, amount) - - return TokenSyscallBalance( - type=TokenOperationType.MELT, - token=TokenSyscallBalanceEntry(token_uid=self.token_uid, amount=-amount), - fee_payment=TokenSyscallBalanceEntry(token_uid=TokenUid(fee_payment_token.token_id), amount=htr_amount) - ) - - def get_syscall_update_token_records( - self, - operation: TokenSyscallBalance, - ) -> list[UpdateTokenBalanceRecord | CreateTokenRecord]: - match operation.type: - case TokenOperationType.MINT | TokenOperationType.CREATE: - assert operation.token.amount > 0 and operation.fee_payment.amount < 0 - case TokenOperationType.MELT: - assert operation.token.amount < 0 and operation.fee_payment.amount > 0 - case _: - assert_never(operation.type) - - return operation.to_syscall_records() - - def _validate_payment_token(self, token: TokenDescription) -> bool: - if token.token_id == TokenUid(HATHOR_TOKEN_UID): - return True - raise NCInvalidFeePaymentToken("Only HTR is allowed to be used with deposit based token syscalls") - - -class _FeeTokenRules(TokenSyscallBalanceRules): - - def _get_fee_amount(self, fee_payment_token: TokenUid) -> int: - # For fee tokens, we only need to pay the transaction fee, not deposit HTR - if fee_payment_token == TokenUid(HATHOR_TOKEN_UID): - fee_amount = -self._settings.FEE_PER_OUTPUT - else: - fee_amount = -int(self._settings.FEE_PER_OUTPUT / self._settings.TOKEN_DEPOSIT_PERCENTAGE) - - assert fee_amount < 0 - return fee_amount - - def create_token( - self, - *, - token_uid: TokenUid, - token_symbol: str, - token_name: str, - amount: int, - fee_payment_token: TokenDescription - ) -> TokenSyscallBalance: - assert amount > 0 - self._validate_payment_token(fee_payment_token) - # For fee tokens, we only need to pay the transaction fee, not deposit HTR - fee_amount = self._get_fee_amount(TokenUid(fee_payment_token.token_id)) - - return TokenSyscallBalance( - type=TokenOperationType.CREATE, - token_version=TokenVersion.FEE, - token_name=token_name, - token_symbol=token_symbol, - token=TokenSyscallBalanceEntry(token_uid=self.token_uid, amount=amount), - fee_payment=TokenSyscallBalanceEntry(token_uid=TokenUid(fee_payment_token.token_id), amount=fee_amount) - ) - - def mint(self, amount: int, *, fee_payment_token: TokenDescription) -> TokenSyscallBalance: - assert amount > 0 - self._validate_payment_token(fee_payment_token) - fee_amount = self._get_fee_amount(TokenUid(fee_payment_token.token_id)) - return TokenSyscallBalance( - type=TokenOperationType.MINT, - token=TokenSyscallBalanceEntry(token_uid=self.token_uid, amount=amount), - fee_payment=TokenSyscallBalanceEntry(token_uid=TokenUid(fee_payment_token.token_id), amount=fee_amount) - ) - - def melt(self, amount: int, *, fee_payment_token: TokenDescription) -> TokenSyscallBalance: - assert amount > 0 - self._validate_payment_token(fee_payment_token) - fee_amount = self._get_fee_amount(TokenUid(fee_payment_token.token_id)) - - return TokenSyscallBalance( - type=TokenOperationType.MELT, - token=TokenSyscallBalanceEntry(token_uid=self.token_uid, amount=-amount), - fee_payment=TokenSyscallBalanceEntry(token_uid=TokenUid(fee_payment_token.token_id), amount=fee_amount) - ) - - def get_syscall_update_token_records( - self, - operation: TokenSyscallBalance, - ) -> list[UpdateTokenBalanceRecord | CreateTokenRecord]: - assert operation.fee_payment.amount < 0 - - match operation.type: - case TokenOperationType.MINT | TokenOperationType.CREATE: - assert operation.token.amount > 0 - case TokenOperationType.MELT: - assert operation.token.amount < 0 - case _: - assert_never(operation.type) - - return operation.to_syscall_records() - - def _validate_payment_token(self, token_info: TokenDescription) -> None: - match token_info.token_version: - case TokenVersion.FEE: - raise NCInvalidFeePaymentToken("fee-based tokens aren't allowed for paying fees") - case TokenVersion.DEPOSIT: - pass - case TokenVersion.NATIVE: - pass - case _: - assert_never(token_info.token_version) diff --git a/hathor/wallet/resources/thin_wallet/token_history.py b/hathor/wallet/resources/thin_wallet/token_history.py index 603a6c7f6..f0526aafe 100644 --- a/hathor/wallet/resources/thin_wallet/token_history.py +++ b/hathor/wallet/resources/thin_wallet/token_history.py @@ -17,6 +17,7 @@ from hathor.api_util import Resource, get_args, get_missing_params_msg, parse_args, parse_int, set_cors from hathor.cli.openapi_files.register import register_resource from hathor.conf.get_settings import get_global_settings +from hathor.transaction.base_transaction import TX_HASH_SIZE from hathor.util import json_dumpb ARGS = ['id', 'count'] @@ -67,6 +68,9 @@ def render_GET(self, request: Request) -> bytes: except (ValueError, AttributeError): return json_dumpb({'success': False, 'message': 'Invalid token id'}) + if len(token_uid) != TX_HASH_SIZE: + return json_dumpb({'success': False, 'message': 'Invalid token id'}) + try: count = parse_int(parsed['args']['count'], cap=self._settings.MAX_TX_COUNT) except ValueError as e: diff --git a/tests/nanocontracts/blueprints/unittest.py b/tests/nanocontracts/blueprints/unittest.py index 7563cdd0b..3f64eae7b 100644 --- a/tests/nanocontracts/blueprints/unittest.py +++ b/tests/nanocontracts/blueprints/unittest.py @@ -8,9 +8,6 @@ from hathor.nanocontracts.blueprint_env import BlueprintEnvironment from hathor.nanocontracts.nc_exec_logs import NCLogConfig from hathor.nanocontracts.on_chain_blueprint import Code, OnChainBlueprint -from hathor.nanocontracts.storage import NCBlockStorage, NCMemoryStorageFactory -from hathor.nanocontracts.storage.backends import MemoryNodeTrieStore -from hathor.nanocontracts.storage.patricia_trie import PatriciaTrie from hathor.nanocontracts.types import Address, BlueprintId, ContractId, NCAction, TokenUid, VertexId from hathor.nanocontracts.vertex_data import BlockData, VertexData from hathor.transaction import Transaction, Vertex @@ -126,13 +123,7 @@ def _register_blueprint_contents( def build_runner(self) -> TestRunner: """Create a Runner instance.""" - nc_storage_factory = NCMemoryStorageFactory() - store = MemoryNodeTrieStore() - block_trie = PatriciaTrie(store) - block_storage = NCBlockStorage(block_trie) - return TestRunner( - self.manager.tx_storage, nc_storage_factory, block_storage, settings=self._settings, reactor=self.reactor - ) + return TestRunner(tx_storage=self.manager.tx_storage, settings=self._settings, reactor=self.reactor) def gen_random_token_uid(self) -> TokenUid: """Generate a random token UID (32 bytes).""" diff --git a/tests/nanocontracts/test_blueprint.py b/tests/nanocontracts/test_blueprint.py index 61c566b56..2d02f968b 100644 --- a/tests/nanocontracts/test_blueprint.py +++ b/tests/nanocontracts/test_blueprint.py @@ -2,23 +2,9 @@ from hathor.nanocontracts.context import Context from hathor.nanocontracts.exception import BlueprintSyntaxError, NCFail, NCInsufficientFunds, NCViewMethodError from hathor.nanocontracts.nc_types import make_nc_type_for_arg_type as make_nc_type -from hathor.nanocontracts.storage import NCBlockStorage, NCMemoryStorageFactory -from hathor.nanocontracts.storage.backends import MemoryNodeTrieStore from hathor.nanocontracts.storage.contract_storage import Balance, BalanceKey -from hathor.nanocontracts.storage.patricia_trie import PatriciaTrie -from hathor.nanocontracts.types import ( - Address, - BlueprintId, - ContractId, - NCDepositAction, - NCWithdrawalAction, - TokenUid, - VertexId, - public, - view, -) +from hathor.nanocontracts.types import Address, NCDepositAction, NCWithdrawalAction, TokenUid, public, view from tests.nanocontracts.blueprints.unittest import BlueprintTestCase -from tests.nanocontracts.utils import TestRunner STR_NC_TYPE = make_nc_type(str) BYTES_NC_TYPE = make_nc_type(bytes) @@ -101,35 +87,14 @@ def my_private_method_nop(self) -> int: class NCBlueprintTestCase(BlueprintTestCase): def setUp(self) -> None: super().setUp() - self.simple_fields_id = ContractId(VertexId(b'1' * 32)) - self.container_fields_id = ContractId(VertexId(b'2' * 32)) - self.my_blueprint_id = ContractId(VertexId(b'3' * 32)) - - nc_storage_factory = NCMemoryStorageFactory() - store = MemoryNodeTrieStore() - block_trie = PatriciaTrie(store) - block_storage = NCBlockStorage(block_trie) - self.manager = self.create_peer('unittests') - self.runner = TestRunner( - self.manager.tx_storage, nc_storage_factory, block_storage, settings=self._settings, reactor=self.reactor - ) - - self.blueprint_ids: dict[str, BlueprintId] = { - 'simple_fields': BlueprintId(VertexId(b'a' * 32)), - 'container_fields': BlueprintId(VertexId(b'b' * 32)), - 'my_blueprint': BlueprintId(VertexId(b'c' * 32)), - } - - nc_catalog = self.manager.tx_storage.nc_catalog - nc_catalog.blueprints[self.blueprint_ids['simple_fields']] = SimpleFields - nc_catalog.blueprints[self.blueprint_ids['container_fields']] = ContainerFields - nc_catalog.blueprints[self.blueprint_ids['my_blueprint']] = MyBlueprint + self.simple_fields_id = self._register_blueprint_class(SimpleFields) + self.container_fields_id = self._register_blueprint_class(ContainerFields) + self.my_blueprint_id = self._register_blueprint_class(MyBlueprint) genesis = self.manager.tx_storage.get_all_genesis() self.tx = [t for t in genesis if t.is_transaction][0] def test_simple_fields(self) -> None: - blueprint_id = self.blueprint_ids['simple_fields'] nc_id = self.simple_fields_id ctx = self.create_context() @@ -137,7 +102,7 @@ def test_simple_fields(self) -> None: b = b'bytes' c = 123 d = True - self.runner.create_contract(nc_id, blueprint_id, ctx, a, b, c, d) + self.runner.create_contract(nc_id, self.simple_fields_id, ctx, a, b, c, d) storage = self.runner.get_storage(nc_id) self.assertEqual(storage.get_obj(b'a', STR_NC_TYPE), a) @@ -146,7 +111,6 @@ def test_simple_fields(self) -> None: self.assertEqual(storage.get_obj(b'd', BOOL_NC_TYPE), d) def test_container_fields(self) -> None: - blueprint_id = self.blueprint_ids['container_fields'] nc_id = self.container_fields_id ctx = self.create_context() @@ -155,7 +119,7 @@ def test_container_fields(self) -> None: ('b', '2', b'2', 2), ('c', '3', b'3', 3), ] - self.runner.create_contract(nc_id, blueprint_id, ctx, items) + self.runner.create_contract(nc_id, self.container_fields_id, ctx, items) storage = self.runner.get_storage(nc_id) self.assertEqual(storage.get_obj(b'a:\x01a', STR_NC_TYPE), '1') @@ -163,10 +127,9 @@ def test_container_fields(self) -> None: self.assertEqual(storage.get_obj(b'a:\x01c', STR_NC_TYPE), '3') def _create_my_blueprint_contract(self) -> None: - blueprint_id = self.blueprint_ids['my_blueprint'] nc_id = self.my_blueprint_id ctx = self.create_context() - self.runner.create_contract(nc_id, blueprint_id, ctx) + self.runner.create_contract(nc_id, self.my_blueprint_id, ctx) def test_public_method_fails(self) -> None: self._create_my_blueprint_contract() diff --git a/tests/nanocontracts/test_blueprints/contract_accessor_blueprint.py b/tests/nanocontracts/test_blueprints/contract_accessor_blueprint.py index e132fb69b..02f10a3c2 100644 --- a/tests/nanocontracts/test_blueprints/contract_accessor_blueprint.py +++ b/tests/nanocontracts/test_blueprints/contract_accessor_blueprint.py @@ -222,3 +222,47 @@ def test_other_syscalls(self, ctx: Context, other_id: ContractId, token_uid: Tok assert contract.get_current_balance() == 0 assert not contract.can_mint(token_uid) assert not contract.can_melt(token_uid) + + @public + def test_visibility_combinations_public_public_public(self, ctx: Context, other_id: ContractId) -> None: + contract = self.syscall.get_contract(other_id, blueprint_id=None) + action = NCDepositAction(amount=123, token_uid=HATHOR_TOKEN_UID) + contract.public(action).simple_public_method('') + + @public + def test_visibility_combinations_public_public_view(self, ctx: Context, other_id: ContractId) -> None: + contract = self.syscall.get_contract(other_id, blueprint_id=None) + action = NCDepositAction(amount=123, token_uid=HATHOR_TOKEN_UID) + contract.public(action).simple_view_method('') + + @public + def test_visibility_combinations_public_view_public(self, ctx: Context, other_id: ContractId) -> None: + contract = self.syscall.get_contract(other_id, blueprint_id=None) + contract.view().simple_public_method('') + + @public + def test_visibility_combinations_public_view_view(self, ctx: Context, other_id: ContractId) -> None: + contract = self.syscall.get_contract(other_id, blueprint_id=None) + contract.view().simple_view_method('') + + @view + def test_visibility_combinations_view_public_public(self, other_id: ContractId) -> None: + contract = self.syscall.get_contract(other_id, blueprint_id=None) + action = NCDepositAction(amount=123, token_uid=HATHOR_TOKEN_UID) + contract.public(action).simple_public_method('') + + @view + def test_visibility_combinations_view_public_view(self, other_id: ContractId) -> None: + contract = self.syscall.get_contract(other_id, blueprint_id=None) + action = NCDepositAction(amount=123, token_uid=HATHOR_TOKEN_UID) + contract.public(action).simple_view_method('') + + @view + def test_visibility_combinations_view_view_public(self, other_id: ContractId) -> None: + contract = self.syscall.get_contract(other_id, blueprint_id=None) + contract.view().simple_public_method('') + + @view + def test_visibility_combinations_view_view_view(self, other_id: ContractId) -> None: + contract = self.syscall.get_contract(other_id, blueprint_id=None) + contract.view().simple_view_method('') diff --git a/tests/nanocontracts/test_call_other_contract.py b/tests/nanocontracts/test_call_other_contract.py index 2217de244..9f1144a90 100644 --- a/tests/nanocontracts/test_call_other_contract.py +++ b/tests/nanocontracts/test_call_other_contract.py @@ -13,13 +13,9 @@ NCViewMethodError, ) from hathor.nanocontracts.nc_types import NCType, make_nc_type_for_arg_type as make_nc_type -from hathor.nanocontracts.storage import NCBlockStorage, NCMemoryStorageFactory -from hathor.nanocontracts.storage.backends import MemoryNodeTrieStore from hathor.nanocontracts.storage.contract_storage import Balance -from hathor.nanocontracts.storage.patricia_trie import PatriciaTrie from hathor.nanocontracts.types import ( Address, - BlueprintId, ContractId, NCAction, NCDepositAction, @@ -29,7 +25,6 @@ ) from hathor.transaction.token_info import TokenVersion from tests.nanocontracts.blueprints.unittest import BlueprintTestCase -from tests.nanocontracts.utils import TestRunner COUNTER_NC_TYPE = make_nc_type(int) CONTRACT_NC_TYPE: NCType[ContractId | None] = make_nc_type(ContractId | None) # type: ignore[arg-type] @@ -138,22 +133,9 @@ class NCBlueprintTestCase(BlueprintTestCase): def setUp(self) -> None: super().setUp() - self.manager = self.create_peer('unittests') self.genesis = self.manager.tx_storage.get_all_genesis() self.tx = [t for t in self.genesis if t.is_transaction][0] - - nc_storage_factory = NCMemoryStorageFactory() - store = MemoryNodeTrieStore() - block_trie = PatriciaTrie(store) - block_storage = NCBlockStorage(block_trie=block_trie) - self.runner = TestRunner( - self.manager.tx_storage, nc_storage_factory, block_storage, settings=self._settings, reactor=self.reactor - ) - - self.blueprint_id = BlueprintId(VertexId(b'a' * 32)) - - nc_catalog = self.manager.tx_storage.nc_catalog - nc_catalog.blueprints[self.blueprint_id] = MyBlueprint + self.blueprint_id = self._register_blueprint_class(MyBlueprint) self.nc1_id = ContractId(VertexId(b'1' * 32)) self.nc2_id = ContractId(VertexId(b'2' * 32)) diff --git a/tests/nanocontracts/test_contract_accessor.py b/tests/nanocontracts/test_contract_accessor.py index 81a679b31..3a8b9602a 100644 --- a/tests/nanocontracts/test_contract_accessor.py +++ b/tests/nanocontracts/test_contract_accessor.py @@ -18,6 +18,7 @@ import pytest from hathor.nanocontracts import HATHOR_TOKEN_UID, NCFail +from hathor.nanocontracts.exception import NCInvalidMethodCall, NCViewMethodError from hathor.nanocontracts.types import NCDepositAction from tests.nanocontracts.blueprints.unittest import BlueprintTestCase from tests.nanocontracts.test_blueprints import contract_accessor_blueprint @@ -221,7 +222,6 @@ def test_public_allow_multiple_blueprints_invalid(self) -> None: def test_other_syscalls(self) -> None: token_uid = self.gen_random_token_uid() - self.runner.call_public_method( self.contract_id1, 'test_other_syscalls', @@ -229,3 +229,78 @@ def test_other_syscalls(self) -> None: other_id=self.contract_id2, token_uid=token_uid, ) + + def test_visibility_combinations(self) -> None: + """ + This test checks that method visibility is respected when using contract accessors. + Consider this exhaustive table of combinations of the caller method, the accessor it uses, + the method it calls, and the expected outcode: + + caller | accessor | callee | expected + ------------------------------------- + public | public | public | SUCCESS + public | public | view | FAIL + public | view | public | FAIL + public | view | view | SUCCESS + view | public | public | FAIL + view | public | view | FAIL + view | view | public | FAIL + view | view | view | SUCCESS + """ + + self.runner.call_public_method( + self.contract_id1, + 'test_visibility_combinations_public_public_public', + self.create_context(), + other_id=self.contract_id2, + ) + + with pytest.raises(NCInvalidMethodCall, match='method `simple_view_method` is not a public method'): + self.runner.call_public_method( + self.contract_id1, + 'test_visibility_combinations_public_public_view', + self.create_context(), + other_id=self.contract_id2, + ) + + with pytest.raises(NCInvalidMethodCall, match='`simple_public_method` is not a view method'): + self.runner.call_public_method( + self.contract_id1, + 'test_visibility_combinations_public_view_public', + self.create_context(), + other_id=self.contract_id2, + ) + + self.runner.call_public_method( + self.contract_id1, + 'test_visibility_combinations_public_view_view', + self.create_context(), + other_id=self.contract_id2, + ) + + with pytest.raises(NCViewMethodError, match='@view method cannot call `syscall.call_public_method`'): + self.runner.call_view_method( + self.contract_id1, + 'test_visibility_combinations_view_public_public', + other_id=self.contract_id2, + ) + + with pytest.raises(NCViewMethodError, match='@view method cannot call `syscall.call_public_method`'): + self.runner.call_view_method( + self.contract_id1, + 'test_visibility_combinations_view_public_view', + other_id=self.contract_id2, + ) + + with pytest.raises(NCInvalidMethodCall, match='`simple_public_method` is not a view method'): + self.runner.call_view_method( + self.contract_id1, + 'test_visibility_combinations_view_view_public', + other_id=self.contract_id2, + ) + + self.runner.call_view_method( + self.contract_id1, + 'test_visibility_combinations_view_view_view', + other_id=self.contract_id2, + ) diff --git a/tests/nanocontracts/test_initialize_method_accessor.py b/tests/nanocontracts/test_initialize_method_accessor.py index acaa6fa56..82f118650 100644 --- a/tests/nanocontracts/test_initialize_method_accessor.py +++ b/tests/nanocontracts/test_initialize_method_accessor.py @@ -16,7 +16,8 @@ import pytest -from hathor import HATHOR_TOKEN_UID, Blueprint, BlueprintId, Context, NCDepositAction, NCFail, public +from hathor import HATHOR_TOKEN_UID, Blueprint, BlueprintId, Context, NCDepositAction, NCFail, public, view +from hathor.nanocontracts.exception import NCViewMethodError from tests.nanocontracts.blueprints.unittest import BlueprintTestCase @@ -44,6 +45,11 @@ def test_multiple_initialize_calls_on_method(self, ctx: Context, blueprint_id: B method('') method('') + @view + def test_initialize_from_view(self, blueprint_id: BlueprintId) -> None: + action = NCDepositAction(token_uid=HATHOR_TOKEN_UID, amount=123) + self.syscall.setup_new_contract(blueprint_id, action, salt=b'1').initialize('') + class MyBlueprint2(Blueprint): @public(allow_deposit=True) @@ -91,3 +97,11 @@ def test_multiple_initialize_calls_on_method(self) -> None: self.create_context(), self.blueprint_id2, ) + + def test_initialize_from_view(self) -> None: + with pytest.raises(NCViewMethodError, match='@view method cannot call `syscall.setup_new_contract`'): + self.runner.call_view_method( + self.contract_id, + 'test_initialize_from_view', + self.blueprint_id2, + ) diff --git a/tests/nanocontracts/test_patricia_trie.py b/tests/nanocontracts/test_patricia_trie.py index b9db86554..b5f61079d 100644 --- a/tests/nanocontracts/test_patricia_trie.py +++ b/tests/nanocontracts/test_patricia_trie.py @@ -3,7 +3,7 @@ from math import log from typing import Optional -from hathor.nanocontracts.storage.backends import MemoryNodeTrieStore, RocksDBNodeTrieStore +from hathor.nanocontracts.storage.backends import RocksDBNodeTrieStore from hathor.nanocontracts.storage.patricia_trie import Node, PatriciaTrie from hathor.storage.rocksdb_storage import RocksDBStorage from tests import unittest @@ -25,10 +25,15 @@ def export_trie_outline(trie: PatriciaTrie, *, node: Optional[Node] = None) -> t class PatriciaTrieTestCase(unittest.TestCase): - __test__ = False + def setUp(self) -> None: + super().setUp() + directory = tempfile.mkdtemp() + self.tmpdirs.append(directory) + self.rocksdb_storage = RocksDBStorage(path=directory) def create_trie(self) -> PatriciaTrie: - raise NotImplementedError + store = RocksDBNodeTrieStore(self.rocksdb_storage) + return PatriciaTrie(store) def test_empty_key(self) -> None: trie = self.create_trie() @@ -208,25 +213,3 @@ def test_multiple_keys_same_value(self) -> None: for k, v in data.items(): self.assertEqual(trie.get(k), v) - - -class MemoryPatriciaTrieTest(PatriciaTrieTestCase): - __test__ = True - - def create_trie(self) -> PatriciaTrie: - store = MemoryNodeTrieStore() - return PatriciaTrie(store) - - -class RocksDBPatriciaTrieTest(PatriciaTrieTestCase): - __test__ = True - - def setUp(self) -> None: - super().setUp() - directory = tempfile.mkdtemp() - self.tmpdirs.append(directory) - self.rocksdb_storage = RocksDBStorage(path=directory) - - def create_trie(self) -> PatriciaTrie: - store = RocksDBNodeTrieStore(self.rocksdb_storage) - return PatriciaTrie(store) diff --git a/tests/nanocontracts/test_proxy_accessor.py b/tests/nanocontracts/test_proxy_accessor.py index 3fcffb5dd..3c347def0 100644 --- a/tests/nanocontracts/test_proxy_accessor.py +++ b/tests/nanocontracts/test_proxy_accessor.py @@ -27,7 +27,9 @@ NCParsedArgs, fallback, public, + view, ) +from hathor.nanocontracts.exception import NCInvalidMethodCall, NCInvalidSyscall, NCViewMethodError from tests.nanocontracts.blueprints.unittest import BlueprintTestCase @@ -43,6 +45,18 @@ def test_get_blueprint_id(self, ctx: Context) -> BlueprintId: proxy = self.syscall.get_proxy(self.other_blueprint_id) return proxy.get_blueprint_id() + @view + def test_view_method(self, name: str) -> str: + proxy = self.syscall.get_proxy(self.other_blueprint_id) + + ret1 = proxy.view().hello_view(name) + ret2 = proxy.view().hello_view.call(name) + ret3 = proxy.get_view_method('hello_view').call(name) + ret4 = proxy.get_view_method('hello_view')(name) + + assert len({ret1, ret2, ret3, ret4}) == 1 + return ret1 + @public def test_public_method(self, ctx: Context, name: str) -> str: proxy = self.syscall.get_proxy(self.other_blueprint_id) @@ -60,6 +74,25 @@ def test_public_method(self, ctx: Context, name: str) -> str: assert len({ret1, ret2, ret3, ret4, ret5, ret6}) == 1 return ret1 + @view + def test_multiple_view_calls_on_prepared_call(self) -> tuple[str, str]: + proxy = self.syscall.get_proxy(self.other_blueprint_id) + prepared_call = proxy.view() + + ret1 = prepared_call.hello_view('alice') + ret2 = prepared_call.hello_view('bob') + return ret1, ret2 + + @view + def test_multiple_view_calls_on_method(self) -> tuple[str, str]: + proxy = self.syscall.get_proxy(self.other_blueprint_id) + prepared_call = proxy.view() + method = prepared_call.hello_view + + ret1 = method('alice') + ret2 = method('bob') + return ret1, ret2 + @public def test_multiple_public_calls_on_prepared_call(self, ctx: Context) -> tuple[str, str]: proxy = self.syscall.get_proxy(self.other_blueprint_id) @@ -91,12 +124,98 @@ def test_fallback_forbidden(self, ctx: Context) -> str: proxy = self.syscall.get_proxy(self.other_blueprint_id) return proxy.public(forbid_fallback=True).unknown() + @public + def test_get_blueprint_id_through_proxy(self, ctx: Context) -> BlueprintId: + proxy = self.syscall.get_proxy(self.other_blueprint_id) + return proxy.public().get_blueprint_id() + + @public + def test_get_current_code_blueprint_id(self, ctx: Context) -> BlueprintId: + current_code_blueprint_id = self.syscall.get_current_code_blueprint_id() + assert self.syscall.get_blueprint_id() == current_code_blueprint_id, ( + "should be the same BlueprintId when we're not in a proxy call" + ) + proxy = self.syscall.get_proxy(self.other_blueprint_id) + return proxy.public().get_current_code_blueprint_id() + + @public(allow_deposit=True) + def nop_public(self, ctx: Context) -> None: + pass + + @public + def call_itself_through_double_proxy_other(self, ctx: Context) -> None: + proxy = self.syscall.get_proxy(self.other_blueprint_id) + proxy.public().call_itself_through_proxy(self.other_blueprint_id) + + @public + def call_itself_through_double_proxy_same(self, ctx: Context) -> None: + proxy = self.syscall.get_proxy(self.other_blueprint_id) + proxy.public().call_itself_through_proxy(self.syscall.get_blueprint_id()) + + @view + def call_itself_through_double_proxy_other_view(self) -> None: + proxy = self.syscall.get_proxy(self.other_blueprint_id) + proxy.view().call_itself_through_proxy_view(self.other_blueprint_id) + + @view + def call_itself_through_double_proxy_same_view(self) -> None: + proxy = self.syscall.get_proxy(self.other_blueprint_id) + proxy.view().call_itself_through_proxy_view(self.syscall.get_blueprint_id()) + + @public + def test_visibility_combinations_public_public_public(self, ctx: Context, blueprint_id: BlueprintId) -> None: + proxy = self.syscall.get_proxy(blueprint_id) + action = NCDepositAction(amount=123, token_uid=HATHOR_TOKEN_UID) + proxy.public(action).nop_public() + + @public + def test_visibility_combinations_public_public_view(self, ctx: Context, blueprint_id: BlueprintId) -> None: + proxy = self.syscall.get_proxy(blueprint_id) + action = NCDepositAction(amount=123, token_uid=HATHOR_TOKEN_UID) + proxy.public(action).nop_view() + + @public + def test_visibility_combinations_public_view_public(self, ctx: Context, blueprint_id: BlueprintId) -> None: + proxy = self.syscall.get_proxy(blueprint_id) + proxy.view().nop_public() + + @public + def test_visibility_combinations_public_view_view(self, ctx: Context, blueprint_id: BlueprintId) -> None: + proxy = self.syscall.get_proxy(blueprint_id) + proxy.view().nop_view() + + @view + def test_visibility_combinations_view_public_public(self, blueprint_id: BlueprintId) -> None: + proxy = self.syscall.get_proxy(blueprint_id) + action = NCDepositAction(amount=123, token_uid=HATHOR_TOKEN_UID) + proxy.public(action).nop_public() + + @view + def test_visibility_combinations_view_public_view(self, blueprint_id: BlueprintId) -> None: + proxy = self.syscall.get_proxy(blueprint_id) + action = NCDepositAction(amount=123, token_uid=HATHOR_TOKEN_UID) + proxy.public(action).nop_view() + + @view + def test_visibility_combinations_view_view_public(self, blueprint_id: BlueprintId) -> None: + proxy = self.syscall.get_proxy(blueprint_id) + proxy.view().nop_public() + + @view + def test_visibility_combinations_view_view_view(self, blueprint_id: BlueprintId) -> None: + proxy = self.syscall.get_proxy(blueprint_id) + proxy.view().nop_view() + class MyBlueprint2(Blueprint): @public def initialize(self, ctx: Context) -> None: pass + @view + def hello_view(self, name: str) -> str: + return f'hello {name}' + @public(allow_deposit=True) def hello(self, ctx: Context, name: str) -> str: return f'hello {name}' @@ -105,6 +224,32 @@ def hello(self, ctx: Context, name: str) -> str: def fallback(self, ctx: Context, method_name: str, nc_args: NCArgs) -> str: return f'fallback called for method `{method_name}`' + @public + def get_blueprint_id(self, ctx: Context) -> BlueprintId: + return self.syscall.get_blueprint_id() + + @public + def get_current_code_blueprint_id(self, ctx: Context) -> BlueprintId: + return self.syscall.get_current_code_blueprint_id() + + @public(allow_deposit=True) + def nop_public(self, ctx: Context) -> None: + pass + + @view + def nop_view(self) -> None: + pass + + @public + def call_itself_through_proxy(self, ctx: Context, blueprint_id: BlueprintId) -> None: + proxy = self.syscall.get_proxy(blueprint_id) + proxy.public().nop_public() + + @view + def call_itself_through_proxy_view(self, blueprint_id: BlueprintId) -> None: + proxy = self.syscall.get_proxy(blueprint_id) + proxy.view().nop_view() + class TestProxyAccessor(BlueprintTestCase): def setUp(self) -> None: @@ -112,28 +257,42 @@ def setUp(self) -> None: self.blueprint_id1 = self._register_blueprint_class(MyBlueprint1) self.blueprint_id2 = self._register_blueprint_class(MyBlueprint2) - self.contract_id = self.gen_random_contract_id() + self.contract_id1 = self.gen_random_contract_id() + self.contract_id2 = self.gen_random_contract_id() ctx = self.create_context([NCDepositAction(amount=123, token_uid=HATHOR_TOKEN_UID)]) - self.runner.create_contract(self.contract_id, self.blueprint_id1, ctx, self.blueprint_id2) + self.runner.create_contract(self.contract_id1, self.blueprint_id1, ctx, self.blueprint_id2) + self.runner.create_contract(self.contract_id2, self.blueprint_id2, self.create_context()) def test_get_blueprint_id(self) -> None: ret = self.runner.call_public_method( - self.contract_id, + self.contract_id1, 'test_get_blueprint_id', self.create_context(), ) assert ret == self.blueprint_id2 + def test_view_method(self) -> None: + ret = self.runner.call_view_method(self.contract_id1, 'test_view_method', 'alice') + assert ret == 'hello alice' + def test_public_method(self) -> None: ret = self.runner.call_public_method( - self.contract_id, + self.contract_id1, 'test_public_method', self.create_context(), 'alice', ) assert ret == 'hello alice' + def test_multiple_view_calls_on_prepared_call(self) -> None: + ret = self.runner.call_view_method(self.contract_id1, 'test_multiple_view_calls_on_prepared_call') + assert ret == ('hello alice', 'hello bob') + + def test_multiple_view_calls_on_method(self) -> None: + ret = self.runner.call_view_method(self.contract_id1, 'test_multiple_view_calls_on_method') + assert ret == ('hello alice', 'hello bob') + def test_multiple_public_calls_on_prepared_call(self) -> None: msg = ( f'prepared proxy public method for blueprint `{self.blueprint_id2.hex()}` was already used, ' @@ -141,7 +300,7 @@ def test_multiple_public_calls_on_prepared_call(self) -> None: ) with pytest.raises(NCFail, match=re.escape(msg)): self.runner.call_public_method( - self.contract_id, + self.contract_id1, 'test_multiple_public_calls_on_prepared_call', self.create_context(), ) @@ -153,14 +312,14 @@ def test_multiple_public_calls_on_method(self) -> None: ) with pytest.raises(NCFail, match=re.escape(msg)): self.runner.call_public_method( - self.contract_id, + self.contract_id1, 'test_multiple_public_calls_on_method', self.create_context(), ) def test_fallback_allowed(self) -> None: ret = self.runner.call_public_method( - self.contract_id, + self.contract_id1, 'test_fallback_allowed', self.create_context(), ) @@ -170,7 +329,145 @@ def test_fallback_forbidden(self) -> None: msg = 'method `unknown` not found and fallback is forbidden' with pytest.raises(NCFail, match=re.escape(msg)): self.runner.call_public_method( - self.contract_id, + self.contract_id1, 'test_fallback_forbidden', self.create_context(), ) + + def test_get_blueprint_id_through_proxy(self) -> None: + ret = self.runner.call_public_method( + self.contract_id1, + 'test_get_blueprint_id_through_proxy', + self.create_context(), + ) + assert ret == self.blueprint_id1 + + def test_get_current_code_blueprint_id(self) -> None: + ret = self.runner.call_public_method( + self.contract_id1, + 'test_get_current_code_blueprint_id', + self.create_context(), + ) + assert ret == self.blueprint_id2 + + def test_call_itself_through_proxy(self) -> None: + with pytest.raises(NCInvalidSyscall, match='cannot call the same blueprint of the running contract'): + self.runner.call_public_method( + self.contract_id2, + 'call_itself_through_proxy', + self.create_context(), + self.blueprint_id2, + ) + + def test_call_itself_through_double_proxy_other(self) -> None: + with pytest.raises(NCInvalidSyscall, match='cannot call the same blueprint of the running blueprint'): + self.runner.call_public_method( + self.contract_id1, + 'call_itself_through_double_proxy_other', + self.create_context(), + ) + + def test_call_itself_through_double_proxy_same(self) -> None: + with pytest.raises(NCInvalidSyscall, match='cannot call the same blueprint of the running contract'): + self.runner.call_public_method( + self.contract_id1, + 'call_itself_through_double_proxy_same', + self.create_context(), + ) + + def test_call_itself_through_proxy_view(self) -> None: + with pytest.raises(NCInvalidSyscall, match='cannot call the same blueprint of the running contract'): + self.runner.call_view_method( + self.contract_id2, + 'call_itself_through_proxy_view', + self.blueprint_id2, + ) + + def test_call_itself_through_double_proxy_other_view(self) -> None: + with pytest.raises(NCInvalidSyscall, match='cannot call the same blueprint of the running blueprint'): + self.runner.call_view_method( + self.contract_id1, + 'call_itself_through_double_proxy_other_view', + ) + + def test_call_itself_through_double_proxy_same_view(self) -> None: + with pytest.raises(NCInvalidSyscall, match='cannot call the same blueprint of the running contract'): + self.runner.call_view_method( + self.contract_id1, + 'call_itself_through_double_proxy_same_view', + ) + + def test_visibility_combinations(self) -> None: + """ + This test checks that method visibility is respected when using proxy accessors. + Consider this exhaustive table of combinations of the caller method, the accessor it uses, + the method it calls, and the expected outcode: + + caller | accessor | callee | expected + ------------------------------------- + public | public | public | SUCCESS + public | public | view | FAIL + public | view | public | FAIL + public | view | view | SUCCESS + view | public | public | FAIL + view | public | view | FAIL + view | view | public | FAIL + view | view | view | SUCCESS + """ + + self.runner.call_public_method( + self.contract_id1, + 'test_visibility_combinations_public_public_public', + self.create_context(), + blueprint_id=self.blueprint_id2, + ) + + with pytest.raises(NCInvalidMethodCall, match='method `nop_view` is not a public method'): + self.runner.call_public_method( + self.contract_id1, + 'test_visibility_combinations_public_public_view', + self.create_context(), + blueprint_id=self.blueprint_id2, + ) + + with pytest.raises(NCInvalidMethodCall, match='`nop_public` is not a view method'): + self.runner.call_public_method( + self.contract_id1, + 'test_visibility_combinations_public_view_public', + self.create_context(), + blueprint_id=self.blueprint_id2, + ) + + self.runner.call_public_method( + self.contract_id1, + 'test_visibility_combinations_public_view_view', + self.create_context(), + blueprint_id=self.blueprint_id2, + ) + + with pytest.raises(NCViewMethodError, match='@view method cannot call `syscall.proxy_call_public_method`'): + self.runner.call_view_method( + self.contract_id1, + 'test_visibility_combinations_view_public_public', + blueprint_id=self.blueprint_id2, + ) + + with pytest.raises(NCViewMethodError, match='@view method cannot call `syscall.proxy_call_public_method`'): + self.runner.call_view_method( + self.contract_id1, + 'test_visibility_combinations_view_public_view', + blueprint_id=self.blueprint_id2, + ) + + with pytest.raises(NCInvalidMethodCall, match='`nop_public` is not a view method'): + self.runner.call_view_method( + self.contract_id1, + 'test_visibility_combinations_view_view_public', + blueprint_id=self.blueprint_id2, + ) + + self.runner.call_view_method( + self.contract_id1, + 'test_visibility_combinations_view_view_view', + blueprint_id=self.blueprint_id2, + ) diff --git a/tests/nanocontracts/test_storage.py b/tests/nanocontracts/test_storage.py index 6b3eddd97..c2b0e546f 100644 --- a/tests/nanocontracts/test_storage.py +++ b/tests/nanocontracts/test_storage.py @@ -1,5 +1,6 @@ from typing import TypeVar +from hathor.nanocontracts import NCRocksDBStorageFactory from hathor.nanocontracts.nc_types import NCType, NullNCType, make_nc_type_for_arg_type as make_nc_type from hathor.nanocontracts.storage import NCChangesTracker from hathor.nanocontracts.types import Amount, ContractId, Timestamp, VertexId @@ -13,10 +14,11 @@ BOOL_NC_TYPE = make_nc_type(bool) -class NCMemoryStorageTestCase(unittest.TestCase): +class NCRocksDBStorageTestCase(unittest.TestCase): def setUp(self) -> None: - from hathor.nanocontracts.storage import NCMemoryStorageFactory - factory = NCMemoryStorageFactory() + super().setUp() + rocksdb_storage = self.create_rocksdb_storage() + factory = NCRocksDBStorageFactory(rocksdb_storage) block_storage = factory.get_empty_block_storage() self.storage = block_storage.get_empty_contract_storage(ContractId(VertexId(b''))) super().setUp() diff --git a/tests/nanocontracts/test_syscalls.py b/tests/nanocontracts/test_syscalls.py index 12414305c..0a3e90f97 100644 --- a/tests/nanocontracts/test_syscalls.py +++ b/tests/nanocontracts/test_syscalls.py @@ -371,7 +371,7 @@ def test_fee_token_creation(self) -> None: fbt2_balance_key: Balance(value=1000000, can_mint=True, can_melt=True), } - # Try to create fee tokens without enough dbt balances + # Try to create fee tokens without enough dbt balance msg = f'negative balance for contract {nc_id.hex()}' with pytest.raises(NCInsufficientFunds, match=msg): self.runner.call_public_method( @@ -409,7 +409,7 @@ def test_fee_token_creation(self) -> None: TokenUid(HATHOR_TOKEN_UID) ) - # created fee token paying with deposit token + # Balance should remain unchanged after failed melt attempt assert storage.get_all_balances() == { htr_balance_key: Balance(value=0, can_mint=False, can_melt=False), fbt_balance_key: Balance(value=1000000, can_mint=True, can_melt=True), @@ -456,7 +456,7 @@ def test_fee_token_melt(self) -> None: # Successfully melt some tokens - don't deposit, melt from existing balance using deposit token self.runner.call_public_method(nc_id, 'melt', self.create_context(), token_uid, 500000, dbt_token_uid) - # Balance should decrease by melted amount, HTR consumed for fee + # Balance should decrease by melted amount, DBT consumed for fee assert storage.get_all_balances() == { htr_balance_key: Balance(value=0, can_mint=False, can_melt=False), fbt_balance_key: Balance(value=500000, can_mint=True, can_melt=True), diff --git a/tests/nanocontracts/test_syscalls_in_view.py b/tests/nanocontracts/test_syscalls_in_view.py index e5d2ec180..71a90ad5e 100644 --- a/tests/nanocontracts/test_syscalls_in_view.py +++ b/tests/nanocontracts/test_syscalls_in_view.py @@ -17,16 +17,14 @@ from hathor.nanocontracts import Blueprint, Context, public, view from hathor.nanocontracts.blueprint_env import BlueprintEnvironment from hathor.nanocontracts.exception import NCViewMethodError -from hathor.nanocontracts.types import BlueprintId, ContractId, NCRawArgs, TokenUid, VertexId +from hathor.nanocontracts.types import BlueprintId, ContractId, TokenUid, VertexId from tests.nanocontracts.blueprints.unittest import BlueprintTestCase -class MyBlueprint(Blueprint): - other_id: ContractId | None - +class DirectSyscalls(Blueprint): @public - def initialize(self, ctx: Context, other_id: ContractId | None) -> None: - self.other_id = other_id + def initialize(self, ctx: Context) -> None: + pass @view def nop(self) -> None: @@ -44,6 +42,10 @@ def get_contract_id(self) -> None: def get_blueprint_id(self) -> None: self.syscall.get_blueprint_id() + @view + def get_current_code_blueprint_id(self) -> None: + self.syscall.get_current_code_blueprint_id() + @view def get_balance_before_current_call(self) -> None: self.syscall.get_balance_before_current_call() @@ -68,15 +70,6 @@ def can_melt(self) -> None: def can_melt_before_current_call(self) -> None: self.syscall.can_melt_before_current_call(TokenUid(b'')) - @view - def call_public_method(self) -> None: - self.syscall.get_contract(ContractId(VertexId(b'')), blueprint_id=None).public().nop() - - @view - def call_view_method(self) -> None: - assert self.other_id is not None - self.syscall.get_contract(self.other_id, blueprint_id=None).view().nop() - @view def revoke_authorities(self) -> None: self.syscall.revoke_authorities(TokenUid(b''), revoke_mint=True, revoke_melt=True) @@ -89,10 +82,6 @@ def mint_tokens(self) -> None: def melt_tokens(self) -> None: self.syscall.melt_tokens(TokenUid(b''), amount=0) - @view - def create_contract(self) -> None: - self.syscall.setup_new_contract(BlueprintId(VertexId(b'')), salt=b'').initialize() - @view def emit_event(self) -> None: self.syscall.emit_event(b'') @@ -105,15 +94,6 @@ def create_deposit_token(self) -> None: def create_fee_token(self) -> None: self.syscall.create_fee_token(token_name='', token_symbol='', amount=0) - @view - def proxy_call_public_method(self) -> None: - self.syscall.get_proxy(BlueprintId(VertexId(b''))).public().nop() - - @view - def proxy_call_public_method_nc_args(self) -> None: - nc_args = NCRawArgs(b'') - self.syscall.get_proxy(BlueprintId(VertexId(b''))).get_public_method('').call_with_nc_args(nc_args) - @view def change_blueprint(self) -> None: self.syscall.change_blueprint(BlueprintId(VertexId(b''))) @@ -131,54 +111,118 @@ def setup_new_contract(self) -> None: self.syscall.setup_new_contract(BlueprintId(VertexId(b'')), salt=b'') +class IndirectSyscalls(Blueprint): + other_blueprint_id: BlueprintId | None + other_contract_id: ContractId | None + + @public + def initialize( + self, + ctx: Context, + other_blueprint_id: BlueprintId | None, + other_contract_id: ContractId | None, + ) -> None: + self.other_blueprint_id = other_blueprint_id + self.other_contract_id = other_contract_id + + @view + def nop(self) -> None: + pass + + @view + def call_public_method(self) -> None: + self.syscall.get_contract(ContractId(VertexId(b'')), blueprint_id=None).public().nop() + + @view + def call_view_method(self) -> None: + assert self.other_contract_id is not None + self.syscall.get_contract(self.other_contract_id, blueprint_id=None).view().nop() + + @view + def setup_new_contract(self) -> None: + self.syscall.setup_new_contract(BlueprintId(VertexId(b'')), salt=b'').initialize() + + @view + def proxy_call_view_method(self) -> None: + assert self.other_blueprint_id is not None + self.syscall.get_proxy(self.other_blueprint_id).view().nop() + + @view + def proxy_call_public_method(self) -> None: + self.syscall.get_proxy(BlueprintId(VertexId(b''))).public().nop() + + class TestSyscallsInView(BlueprintTestCase): def setUp(self) -> None: super().setUp() - self.blueprint_id = self._register_blueprint_class(MyBlueprint) - - self.ctx = self.create_context( - actions=[], - vertex=self.get_genesis_tx(), - caller_id=self.gen_random_address(), - timestamp=self.now, - ) + self.blueprint_id1 = self._register_blueprint_class(DirectSyscalls) + self.blueprint_id2 = self._register_blueprint_class(IndirectSyscalls) def test_rng(self) -> None: contract_id = self.gen_random_contract_id() - self.runner.create_contract(contract_id, self.blueprint_id, self.ctx, None) + self.runner.create_contract(contract_id, self.blueprint_id1, self.create_context()) with pytest.raises(NCViewMethodError, match='@view method cannot call `syscall.rng`'): self.runner.call_view_method(contract_id, 'test_rng') - def test_syscalls(self) -> None: - other_id = self.gen_random_contract_id() - self.runner.create_contract(other_id, self.blueprint_id, self.ctx, None) + def test_direct_syscalls(self) -> None: + contract_id = self.gen_random_contract_id() + self.runner.create_contract(contract_id, self.blueprint_id1, self.create_context()) properties = {'rng'} # each property must be tested specifically allowed_view_syscalls = { 'get_contract_id', 'get_blueprint_id', - 'get_balance', 'get_balance_before_current_call', 'get_current_balance', 'can_mint', 'can_mint_before_current_call', 'can_melt', 'can_melt_before_current_call', - 'call_view_method', 'get_contract', + 'get_proxy', + 'get_current_code_blueprint_id', } + tested_methods = set() for method_name, method in BlueprintEnvironment.__dict__.items(): if '__' in method_name or method_name in properties: continue - contract_id = self.gen_random_contract_id() - self.runner.create_contract(contract_id, self.blueprint_id, self.ctx, other_id) - + tested_methods.add(method_name) if method_name in allowed_view_syscalls: self.runner.call_view_method(contract_id, method_name) else: with pytest.raises(NCViewMethodError, match=f'@view method cannot call `syscall.{method_name}`'): self.runner.call_view_method(contract_id, method_name) + + skip_tested_methods = {'initialize', 'nop', 'test_rng'} + for method_name, method in DirectSyscalls.__dict__.items(): + if '__' in method_name or method_name in skip_tested_methods: + continue + assert method_name in tested_methods, f'method `{method_name}` of DirectSyscalls was not tested' + + for method_name in allowed_view_syscalls: + assert method_name in tested_methods, f'method `{method_name}` of `allowed_view_syscalls` was not tested' + + def test_indirect_syscalls(self) -> None: + contract_id1 = self.gen_random_contract_id() + contract_id2 = self.gen_random_contract_id() + + self.runner.create_contract(contract_id1, self.blueprint_id2, self.create_context(), None, None) + self.runner.create_contract( + contract_id2, self.blueprint_id2, self.create_context(), self.blueprint_id1, contract_id1 + ) + + self.runner.call_view_method(contract_id2, 'call_view_method') + self.runner.call_view_method(contract_id2, 'proxy_call_view_method') + + with pytest.raises(NCViewMethodError, match='@view method cannot call `syscall.call_public_method`'): + self.runner.call_view_method(contract_id2, 'call_public_method') + + with pytest.raises(NCViewMethodError, match='@view method cannot call `syscall.setup_new_contract`'): + self.runner.call_view_method(contract_id2, 'setup_new_contract') + + with pytest.raises(NCViewMethodError, match='@view method cannot call `syscall.proxy_call_public_method`'): + self.runner.call_view_method(contract_id2, 'proxy_call_public_method') diff --git a/tests/nanocontracts/utils.py b/tests/nanocontracts/utils.py index 2f8780150..fcafb4373 100644 --- a/tests/nanocontracts/utils.py +++ b/tests/nanocontracts/utils.py @@ -2,16 +2,18 @@ from hathor.conf.settings import HathorSettings from hathor.manager import HathorManager -from hathor.nanocontracts import Blueprint +from hathor.nanocontracts import Blueprint, NCRocksDBStorageFactory from hathor.nanocontracts.method import Method from hathor.nanocontracts.nc_exec_logs import NCExecEntry, NCLogConfig from hathor.nanocontracts.runner import Runner -from hathor.nanocontracts.storage import NCBlockStorage, NCStorageFactory +from hathor.nanocontracts.storage import NCBlockStorage +from hathor.nanocontracts.storage.backends import RocksDBNodeTrieStore +from hathor.nanocontracts.storage.patricia_trie import PatriciaTrie from hathor.nanocontracts.utils import sign_pycoin from hathor.reactor import ReactorProtocol from hathor.transaction import Transaction from hathor.transaction.headers.nano_header import NanoHeader, NanoHeaderAction -from hathor.transaction.storage import TransactionStorage +from hathor.transaction.storage import TransactionRocksDBStorage, TransactionStorage from hathor.types import VertexId from hathor.util import not_none from hathor.wallet import HDWallet @@ -22,16 +24,19 @@ class TestRunner(Runner): def __init__( self, - tx_storage: TransactionStorage, - storage_factory: NCStorageFactory, - block_storage: NCBlockStorage, *, + tx_storage: TransactionStorage, settings: HathorSettings, reactor: ReactorProtocol, seed: bytes | None = None, ) -> None: if seed is None: seed = b'x' * 32 + assert isinstance(tx_storage, TransactionRocksDBStorage) + storage_factory = NCRocksDBStorageFactory(tx_storage._rocksdb_storage) + store = RocksDBNodeTrieStore(tx_storage._rocksdb_storage) + block_trie = PatriciaTrie(store) + block_storage = NCBlockStorage(block_trie) super().__init__( tx_storage=tx_storage, storage_factory=storage_factory, diff --git a/tests/resources/wallet/test_thin_wallet.py b/tests/resources/wallet/test_thin_wallet.py index 434909435..ee8fef08c 100644 --- a/tests/resources/wallet/test_thin_wallet.py +++ b/tests/resources/wallet/test_thin_wallet.py @@ -670,6 +670,13 @@ def test_token_history_invalid_params(self): data = response.json_value() self.assertFalse(data['success']) + response = yield resource.get('thin_wallet/token_history', { + b'id': b'0000', + b'count': b'3' + }) + data = response.json_value() + self.assertFalse(data['success']) + # missing timestamp response = yield resource.get('thin_wallet/token_history', { b'id': b'000003a3b261e142d3dfd84970d3a50a93b5bc3a66a3b6ba973956148a3eb824',