From b207594b3a9243058e9182bc7caac5de2e817b4c Mon Sep 17 00:00:00 2001 From: raul-oliveira Date: Mon, 15 Sep 2025 08:32:04 -0300 Subject: [PATCH] feat: nc actions fees --- hathor/__init__.py | 1 + hathor/conf/settings.py | 21 ++ hathor/nanocontracts/allowed_imports.py | 1 + hathor/nanocontracts/blueprint_env.py | 26 +- hathor/nanocontracts/contract_accessor.py | 24 +- hathor/nanocontracts/exception.py | 5 + hathor/nanocontracts/runner/runner.py | 117 +++++- hathor/nanocontracts/types.py | 19 +- hathor/transaction/base_transaction.py | 4 + hathor/transaction/exceptions.py | 2 +- hathor/transaction/headers/fee_header.py | 20 +- hathor/transaction/util.py | 13 +- hathor/verification/fee_header_verifier.py | 7 +- tests/nanocontracts/blueprints/unittest.py | 17 + tests/nanocontracts/test_actions_fee.py | 348 ++++++++++++++++++ .../test_authorities_call_another.py | 12 +- .../test_blueprints/test_blueprint1.py | 2 +- .../nanocontracts/test_call_other_contract.py | 16 +- tests/nanocontracts/test_caller_id.py | 2 +- .../test_contract_create_contract.py | 8 +- tests/nanocontracts/test_contract_upgrade.py | 6 +- tests/nanocontracts/test_execution_order.py | 30 +- .../nanocontracts/test_exposed_properties.py | 4 + tests/nanocontracts/test_nc_exec_logs.py | 5 +- tests/nanocontracts/test_syscalls.py | 90 +++++ tests/nanocontracts/test_syscalls_in_view.py | 12 +- tests/others/test_hathor_settings.py | 27 ++ .../verification/test_fee_header_verifier.py | 90 ++--- 28 files changed, 810 insertions(+), 119 deletions(-) create mode 100644 tests/nanocontracts/test_actions_fee.py diff --git a/hathor/__init__.py b/hathor/__init__.py index 6a298ef04..3d378e3ad 100644 --- a/hathor/__init__.py +++ b/hathor/__init__.py @@ -28,6 +28,7 @@ NCActionType, NCArgs, NCDepositAction, + NCFee, NCGrantAuthorityAction, NCParsedArgs, NCRawArgs, diff --git a/hathor/conf/settings.py b/hathor/conf/settings.py index 8d8222c05..a2cc56af9 100644 --- a/hathor/conf/settings.py +++ b/hathor/conf/settings.py @@ -89,6 +89,13 @@ class HathorSettings(NamedTuple): # Fee rate settings FEE_PER_OUTPUT: int = 1 + @property + def FEE_DIVISOR(self) -> int: + """Divisor used for evaluating fee amounts""" + result = 1 / self.TOKEN_DEPOSIT_PERCENTAGE + assert result.is_integer() + return int(result) + # To disable reward halving, just set this to `None` and make sure that INITIAL_TOKEN_UNITS_PER_BLOCK is equal to # MINIMUM_TOKEN_UNITS_PER_BLOCK. BLOCKS_PER_HALVING: Optional[int] = 2 * 60 * 24 * 365 # 1051200, every 365 days @@ -582,6 +589,17 @@ def _validate_tokens(genesis_tokens: int, values: dict[str, Any]) -> int: return genesis_tokens +def _validate_token_deposit_percentage(token_deposit_percentage: float) -> float: + """Validate that TOKEN_DEPOSIT_PERCENTAGE results in an integer FEE_DIVISOR.""" + result = 1 / token_deposit_percentage + if not result.is_integer(): + raise ValueError( + f'TOKEN_DEPOSIT_PERCENTAGE must result in an integer FEE_DIVISOR. ' + f'Got TOKEN_DEPOSIT_PERCENTAGE={token_deposit_percentage}, FEE_DIVISOR={result}' + ) + return token_deposit_percentage + + _VALIDATORS = dict( _parse_hex_str=pydantic.validator( 'P2PKH_VERSION_BYTE', @@ -619,4 +637,7 @@ def _validate_tokens(genesis_tokens: int, values: dict[str, Any]) -> int: _validate_tokens=pydantic.validator( 'GENESIS_TOKENS' )(_validate_tokens), + _validate_token_deposit_percentage=pydantic.validator( + 'TOKEN_DEPOSIT_PERCENTAGE' + )(_validate_token_deposit_percentage), ) diff --git a/hathor/nanocontracts/allowed_imports.py b/hathor/nanocontracts/allowed_imports.py index ad1110aa9..835c528da 100644 --- a/hathor/nanocontracts/allowed_imports.py +++ b/hathor/nanocontracts/allowed_imports.py @@ -39,6 +39,7 @@ Context=hathor.Context, NCFail=hathor.NCFail, NCAction=hathor.NCAction, + NCFee=hathor.NCFee, NCActionType=hathor.NCActionType, SignedData=hathor.SignedData, public=hathor.public, diff --git a/hathor/nanocontracts/blueprint_env.py b/hathor/nanocontracts/blueprint_env.py index 35ea0e3cf..9618a2e3b 100644 --- a/hathor/nanocontracts/blueprint_env.py +++ b/hathor/nanocontracts/blueprint_env.py @@ -18,7 +18,7 @@ from hathor.conf.settings import HATHOR_TOKEN_UID from hathor.nanocontracts.storage import NCContractStorage -from hathor.nanocontracts.types import Amount, BlueprintId, ContractId, NCAction, TokenUid +from hathor.nanocontracts.types import Amount, BlueprintId, ContractId, NCAction, NCFee, TokenUid if TYPE_CHECKING: from hathor.nanocontracts.contract_accessor import ContractAccessor @@ -172,6 +172,7 @@ def call_public_method( nc_id: ContractId, method_name: str, actions: Sequence[NCAction], + fees: Sequence[NCFee] | None = None, *args: Any, **kwargs: Any, ) -> Any: @@ -183,6 +184,7 @@ def call_public_method( args, kwargs, forbid_fallback=False, + fees=fees or [] ) @final @@ -191,11 +193,19 @@ def proxy_call_public_method( blueprint_id: BlueprintId, method_name: str, actions: Sequence[NCAction], + fees: Sequence[NCFee], *args: Any, **kwargs: Any, ) -> Any: """Execute a proxy call to a public method of another blueprint.""" - return self.__runner.syscall_proxy_call_public_method(blueprint_id, method_name, actions, args, kwargs) + return self.__runner.syscall_proxy_call_public_method( + blueprint_id=blueprint_id, + method_name=method_name, + actions=actions, + fees=fees, + args=args, + kwargs=kwargs + ) @final def proxy_call_public_method_nc_args( @@ -203,10 +213,17 @@ def proxy_call_public_method_nc_args( blueprint_id: BlueprintId, method_name: str, actions: Sequence[NCAction], + fees: Sequence[NCFee], nc_args: NCArgs, ) -> Any: """Execute a proxy call to a public method of another blueprint.""" - return self.__runner.syscall_proxy_call_public_method_nc_args(blueprint_id, method_name, actions, nc_args) + return self.__runner.syscall_proxy_call_public_method_nc_args( + blueprint_id=blueprint_id, + method_name=method_name, + actions=actions, + fees=fees, + nc_args=nc_args + ) @final def call_view_method(self, nc_id: ContractId, method_name: str, *args: Any, **kwargs: Any) -> Any: @@ -246,11 +263,12 @@ def create_contract( blueprint_id: BlueprintId, salt: bytes, actions: Sequence[NCAction], + fees: Sequence[NCFee] | None, *args: Any, **kwargs: Any, ) -> tuple[ContractId, Any]: """Create a new contract.""" - return self.__runner.syscall_create_another_contract(blueprint_id, salt, actions, args, kwargs) + return self.__runner.syscall_create_another_contract(blueprint_id, salt, actions, args, kwargs, fees or []) @final def emit_event(self, data: bytes) -> None: diff --git a/hathor/nanocontracts/contract_accessor.py b/hathor/nanocontracts/contract_accessor.py index cce94e03e..22248b672 100644 --- a/hathor/nanocontracts/contract_accessor.py +++ b/hathor/nanocontracts/contract_accessor.py @@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Any, Collection, Sequence, final from hathor.nanocontracts.faux_immutable import FauxImmutable, __set_faux_immutable__ -from hathor.nanocontracts.types import BlueprintId, ContractId, NCAction +from hathor.nanocontracts.types import BlueprintId, ContractId, NCAction, NCFee if TYPE_CHECKING: from hathor.nanocontracts import Runner @@ -62,12 +62,13 @@ def view(self) -> Any: blueprint_ids=self.__blueprint_ids, ) - def public(self, *actions: NCAction, forbid_fallback: bool = False) -> Any: + def public(self, *actions: NCAction, fees: Sequence[NCFee] | None = None, forbid_fallback: bool = False) -> Any: return PreparedPublicCall( runner=self.__runner, contract_id=self.__contract_id, blueprint_ids=self.__blueprint_ids, actions=actions, + fees=fees, forbid_fallback=forbid_fallback, ) @@ -103,7 +104,15 @@ def __getattr__(self, method_name: str) -> ViewMethodAccessor: @final class PreparedPublicCall(FauxImmutable): - __slots__ = ('__runner', '__contract_id', '__blueprint_ids', '__actions', '__forbid_fallback', '__is_dirty') + __slots__ = ( + '__runner', + '__contract_id', + '__blueprint_ids', + '__actions', + '__forbid_fallback', + '__is_dirty', + '__fees' + ) __skip_faux_immutability_validation__ = True # Needed to implement __getattr__ def __init__( @@ -113,6 +122,7 @@ def __init__( contract_id: ContractId, blueprint_ids: frozenset[BlueprintId] | None, actions: Sequence[NCAction], + fees: Sequence[NCFee] | None, forbid_fallback: bool, ) -> None: self.__runner: Runner @@ -121,6 +131,7 @@ def __init__( self.__actions: Sequence[NCAction] self.__forbid_fallback: bool self.__is_dirty: bool + self.__fees: Sequence[NCFee] __set_faux_immutable__(self, '__runner', runner) __set_faux_immutable__(self, '__contract_id', contract_id) @@ -128,6 +139,7 @@ def __init__( __set_faux_immutable__(self, '__actions', actions) __set_faux_immutable__(self, '__forbid_fallback', forbid_fallback) __set_faux_immutable__(self, '__is_dirty', False) + __set_faux_immutable__(self, '__fees', fees) def __getattr__(self, method_name: str) -> PublicMethodAccessor: from hathor.nanocontracts import NCFail @@ -146,6 +158,7 @@ def __getattr__(self, method_name: str) -> PublicMethodAccessor: method_name=method_name, actions=self.__actions, forbid_fallback=self.__forbid_fallback, + fees=self.__fees, ) @@ -206,6 +219,7 @@ class PublicMethodAccessor(FauxImmutable): '__actions', '__forbid_fallback', '__is_dirty', + '__fees' ) def __init__( @@ -217,6 +231,7 @@ def __init__( method_name: str, actions: Sequence[NCAction], forbid_fallback: bool, + fees: Sequence[NCFee] | None, ) -> None: self.__runner: Runner self.__contract_id: ContractId @@ -225,6 +240,7 @@ def __init__( self.__actions: Sequence[NCAction] self.__forbid_fallback: bool self.__is_dirty: bool + self.__fees: Sequence[NCFee] __set_faux_immutable__(self, '__runner', runner) __set_faux_immutable__(self, '__contract_id', contract_id) @@ -233,6 +249,7 @@ def __init__( __set_faux_immutable__(self, '__actions', actions) __set_faux_immutable__(self, '__forbid_fallback', forbid_fallback) __set_faux_immutable__(self, '__is_dirty', False) + __set_faux_immutable__(self, '__fees', fees) def __call__(self, *args: Any, **kwargs: Any) -> object: from hathor.nanocontracts import NCFail @@ -254,6 +271,7 @@ def __call__(self, *args: Any, **kwargs: Any) -> object: contract_id=self.__contract_id, method_name=self.__method_name, actions=self.__actions, + fees=self.__fees or [], args=args, kwargs=kwargs, forbid_fallback=self.__forbid_fallback, diff --git a/hathor/nanocontracts/exception.py b/hathor/nanocontracts/exception.py index 3e9ff96df..b11730647 100644 --- a/hathor/nanocontracts/exception.py +++ b/hathor/nanocontracts/exception.py @@ -131,6 +131,11 @@ class NCInvalidAction(NCFail): pass +class NCInvalidFee(NCFail): + """Raised when a fee is invalid.""" + pass + + class NCInvalidSyscall(NCFail): """Raised when a syscall is invalid.""" pass diff --git a/hathor/nanocontracts/runner/runner.py b/hathor/nanocontracts/runner/runner.py index b2776705c..b0db78e88 100644 --- a/hathor/nanocontracts/runner/runner.py +++ b/hathor/nanocontracts/runner/runner.py @@ -16,6 +16,7 @@ from collections import defaultdict from inspect import getattr_static +from types import MappingProxyType from typing import Any, Callable, Concatenate, ParamSpec, Sequence, TypeVar from typing_extensions import assert_never @@ -32,6 +33,8 @@ NCForbiddenReentrancy, NCInvalidContext, NCInvalidContractId, + NCInvalidFee, + NCInvalidFeePaymentToken, NCInvalidInitializeMethodCall, NCInvalidMethodCall, NCInvalidPublicMethodCallFromView, @@ -70,6 +73,7 @@ NCActionType, NCArgs, NCDepositAction, + NCFee, NCGrantAuthorityAction, NCParsedArgs, NCRawArgs, @@ -86,11 +90,11 @@ ) from hathor.reactor import ReactorProtocol from hathor.transaction import Transaction -from hathor.transaction.exceptions import TransactionDataError +from hathor.transaction.exceptions import InvalidFeeAmount from hathor.transaction.storage import TransactionStorage from hathor.transaction.storage.exceptions import TransactionDoesNotExist from hathor.transaction.token_info import TokenDescription, TokenVersion -from hathor.transaction.util import clean_token_string, validate_token_name_and_symbol +from hathor.transaction.util import clean_token_string, validate_fee_amount, validate_token_name_and_symbol P = ParamSpec('P') T = TypeVar('T') @@ -155,6 +159,9 @@ def __init__( # Information about updated tokens in the current call via syscalls. self._updated_tokens_totals: defaultdict[TokenUid, int] = defaultdict(int) + # Information about fees paid during execution inter-contract calls. + self._paid_actions_fees: defaultdict[TokenUid, int] = defaultdict(int) + def execute_from_tx(self, tx: Transaction) -> None: """Execute the contract's method call.""" # Check seqnum. @@ -298,6 +305,7 @@ def _unsafe_call_public_method( # Reset the tokens counters so this Runner can be reused (in blueprint tests, for example). self._updated_tokens_totals = defaultdict(int) + self._paid_actions_fees = defaultdict(int) return ret def _check_all_field_initialized(self, blueprint: Blueprint) -> None: @@ -321,6 +329,7 @@ def syscall_call_another_contract_public_method( args: tuple[Any, ...], kwargs: dict[str, Any], forbid_fallback: bool, + fees: Sequence[NCFee] ) -> Any: """Call another contract's public method. This method must be called by a blueprint during an execution.""" if method_name == NC_INITIALIZE_METHOD: @@ -341,14 +350,17 @@ def syscall_call_another_contract_public_method( actions=actions, nc_args=nc_args, forbid_fallback=forbid_fallback, + fees=fees ) @_forbid_syscall_from_view('proxy_call_public_method') def syscall_proxy_call_public_method( self, + *, blueprint_id: BlueprintId, method_name: str, actions: Sequence[NCAction], + fees: Sequence[NCFee], args: tuple[Any, ...], kwargs: dict[str, Any], ) -> Any: @@ -361,15 +373,23 @@ def syscall_proxy_call_public_method( - The storage context remains that of the calling contract """ nc_args = NCParsedArgs(args, kwargs) - return self.syscall_proxy_call_public_method_nc_args(blueprint_id, method_name, actions, nc_args) + return self.syscall_proxy_call_public_method_nc_args( + blueprint_id=blueprint_id, + method_name=method_name, + actions=actions, + fees=fees, + nc_args=nc_args + ) @_forbid_syscall_from_view('proxy_call_public_method_nc_args') def syscall_proxy_call_public_method_nc_args( self, + *, blueprint_id: BlueprintId, method_name: str, actions: Sequence[NCAction], - nc_args: NCArgs, + fees: Sequence[NCFee], + nc_args: NCArgs ) -> Any: if method_name == NC_INITIALIZE_METHOD: raise NCInvalidInitializeMethodCall('cannot call initialize from another contract') @@ -386,6 +406,7 @@ def syscall_proxy_call_public_method_nc_args( actions=actions, nc_args=nc_args, skip_reentrancy_validation=True, + fees=fees ) def _unsafe_call_another_contract_public_method( @@ -398,6 +419,7 @@ def _unsafe_call_another_contract_public_method( nc_args: NCArgs, skip_reentrancy_validation: bool = False, forbid_fallback: bool = False, + fees: Sequence[NCFee] ) -> Any: """Invoke another contract's public method without running the usual guard‑safety checks. @@ -412,7 +434,14 @@ def _unsafe_call_another_contract_public_method( # Validate actions. for action in actions: if isinstance(action, BaseTokenAction) and action.amount < 0: - raise NCInvalidContext('amount must be positive') + raise NCInvalidContext('action amount must be positive') + + # Validate fees + for fee in fees: + try: + validate_fee_amount(self._settings, fee.token_uid, fee.amount) + except InvalidFeeAmount as e: + raise NCInvalidFee(str(e)) from e first_ctx = self._call_info.stack[0].ctx assert first_ctx is not None @@ -424,14 +453,21 @@ def _unsafe_call_another_contract_public_method( rules = BalanceRules.get_rules(self._settings, action) rules.nc_caller_execution_rule(previous_changes_tracker) + # Update the balances with the fee payment amount. Since some tokens could be created during contract + # execution, the verification of the tokens and amounts will be done after it + for fee in fees: + previous_changes_tracker.add_balance(fee.token_uid, -fee.amount) + self._register_paid_fee(fee.token_uid, fee.amount) + + ctx_actions = Context.__group_actions__(actions) # Call the other contract method. ctx = Context( caller_id=last_call_record.contract_id, vertex_data=first_ctx.vertex, block_data=first_ctx.block, - actions=Context.__group_actions__(actions), + actions=ctx_actions, ) - return self._execute_public_method_call( + result = self._execute_public_method_call( contract_id=contract_id, blueprint_id=blueprint_id, method_name=method_name, @@ -441,6 +477,10 @@ def _unsafe_call_another_contract_public_method( forbid_fallback=forbid_fallback, ) + self._validate_actions_fees(ctx_actions=ctx_actions, fees=fees) + + return result + def _reset_all_change_trackers(self) -> None: """Reset all changes and prepare for next call.""" assert self._call_info is not None @@ -485,7 +525,7 @@ def _validate_balances(self, ctx: Context) -> None: pass case SyscallUpdateTokenRecord(): calculated_tokens_totals[record.token_uid] += record.amount - case _: + case _: # pragma: no cover assert_never(record) assert calculated_tokens_totals == self._updated_tokens_totals, ( @@ -510,9 +550,13 @@ def _validate_balances(self, ctx: Context) -> None: # so no need to account for them. pass - case _: + case _: # pragma: no cover assert_never(action) + # Account for fees paid during execution + for fee_token_uid, amount in self._paid_actions_fees.items(): + total_diffs[fee_token_uid] += amount + assert all(diff == 0 for diff in total_diffs.values()), ( f'change tracker diffs do not match actions: {total_diffs}' ) @@ -868,6 +912,7 @@ def syscall_create_another_contract( actions: Sequence[NCAction], args: tuple[Any, ...], kwargs: dict[str, Any], + fees: Sequence[NCFee], ) -> tuple[ContractId, Any]: """Create a contract from another contract.""" if not salt: @@ -889,6 +934,7 @@ def syscall_create_another_contract( method_name=NC_INITIALIZE_METHOD, actions=actions, nc_args=nc_args, + fees=fees ) assert last_call_record.index_updates is not None @@ -1049,6 +1095,7 @@ def syscall_create_child_deposit_token( if amount <= 0: raise NCInvalidSyscall(f"token amount must be always positive. amount={amount}") + from hathor.transaction.exceptions import TransactionDataError try: validate_token_name_and_symbol(self._settings, token_name, token_symbol) except TransactionDataError as e: @@ -1104,6 +1151,7 @@ def syscall_create_child_fee_token( if amount <= 0: raise NCInvalidSyscall(f"token amount must be always positive. amount={amount}") + from hathor.transaction.exceptions import TransactionDataError try: validate_token_name_and_symbol(self._settings, token_name, token_symbol) except TransactionDataError as e: @@ -1237,6 +1285,57 @@ def _update_tokens_amount( self._updated_tokens_totals[record.token_uid] += record.amount call_record.index_updates.append(record) + def _register_paid_fee(self, token_uid: TokenUid, amount: int) -> None: + """ Register a fee payment in the current call.""" + self._paid_actions_fees[token_uid] += amount + + def _validate_actions_fees( + self, + ctx_actions: MappingProxyType[TokenUid, tuple[NCAction, ...]], + fees: Sequence[NCFee], + ) -> None: + """ + Validate if the sum of fees is the same of the provided actions fees. + It should be called only after a nano contract method execution to ensure all tokens are already created. + """ + # sum of the fee provided by the caller + fee_sum = 0 + + # sum of the expected fee calculated by this method + expected_fee = 0 + + allowed_token_versions = {TokenVersion.DEPOSIT, TokenVersion.NATIVE} + # check if the payment tokens are all deposit + for token_uid in self._paid_actions_fees.keys(): + token = self._get_token(token_uid) + + if token.token_version not in allowed_token_versions: + raise NCInvalidFeePaymentToken("fee-based tokens aren't allowed for paying fees") + + for fee in fees: + # because we registered all fee tokens in the paid fees dict, it should contain at + # least the length of fees + assert fee.token_uid in self._paid_actions_fees + fee_sum += fee.get_htr_value(self._settings) + + chargeable_actions = {NCActionType.DEPOSIT, NCActionType.WITHDRAWAL} + for token_uid, actions in ctx_actions.items(): + # it is in the paid fees, so we assume this token is deposit-based or htr + if token_uid in self._paid_actions_fees: + continue + + # we still need to check here, other tokens that weren't used to pay fees can be used + # so we need to fetch them + token_info = self._get_token(token_uid) + if token_info.token_version == TokenVersion.FEE: + # filter actions to only include deposit and withdrawal actions + expected_fee += sum(1 for action in actions if action.type in chargeable_actions) + + if fee_sum != expected_fee: + raise NCInvalidFee( + f'Fee payment balance is different than expected. (amount={fee_sum}, expected={expected_fee})' + ) + class RunnerFactory: __slots__ = ('reactor', 'settings', 'tx_storage', 'nc_storage_factory') diff --git a/hathor/nanocontracts/types.py b/hathor/nanocontracts/types.py index 814418c86..badd65ec2 100644 --- a/hathor/nanocontracts/types.py +++ b/hathor/nanocontracts/types.py @@ -21,6 +21,7 @@ from typing_extensions import override +from hathor.conf.settings import HATHOR_TOKEN_UID, HathorSettings from hathor.crypto.util import decode_address, get_address_b58_from_bytes from hathor.nanocontracts.blueprint_syntax_validation import ( validate_has_ctx_arg, @@ -30,7 +31,7 @@ ) from hathor.nanocontracts.exception import BlueprintSyntaxError, NCSerializationError from hathor.nanocontracts.faux_immutable import FauxImmutableMeta -from hathor.transaction.util import bytes_to_int, int_to_bytes +from hathor.transaction.util import bytes_to_int, get_deposit_token_withdraw_amount, int_to_bytes from hathor.utils.typing import InnerTypeMixin # XXX: mypy gives the following errors on all subclasses of `bytes` that use FauxImmutableMeta: @@ -516,3 +517,19 @@ class NCParsedArgs: NCArgs: TypeAlias = NCRawArgs | NCParsedArgs + + +@dataclass(slots=True, frozen=True, kw_only=True) +class NCFee: + """The dataclass for all NC actions fee""" + token_uid: TokenUid + amount: int + + def get_htr_value(self, settings: HathorSettings) -> int: + """ + Get the amount converted to HTR + """ + if self.token_uid == HATHOR_TOKEN_UID: + return self.amount + else: + return get_deposit_token_withdraw_amount(settings, self.amount) diff --git a/hathor/transaction/base_transaction.py b/hathor/transaction/base_transaction.py index c2f231620..8d5786460 100644 --- a/hathor/transaction/base_transaction.py +++ b/hathor/transaction/base_transaction.py @@ -263,6 +263,10 @@ def is_nano_contract(self) -> bool: """Return True if this transaction is a nano contract or not.""" return False + def has_fees(self) -> bool: + """Return whether this transaction has a fee header.""" + return False + def get_fields_from_struct(self, struct_bytes: bytes, *, verbose: VerboseCallback = None) -> bytes: """ Gets all common fields for a Transaction and a Block from a buffer. diff --git a/hathor/transaction/exceptions.py b/hathor/transaction/exceptions.py index 7442e2c8d..140215817 100644 --- a/hathor/transaction/exceptions.py +++ b/hathor/transaction/exceptions.py @@ -268,5 +268,5 @@ class FeeHeaderTokenNotFound(InvalidFeeHeader): """Token not found in the transaction tokens list""" -class FeeHeaderInvalidAmount(InvalidFeeHeader): +class InvalidFeeAmount(InvalidFeeHeader): """Invalid fee amount""" diff --git a/hathor/transaction/headers/fee_header.py b/hathor/transaction/headers/fee_header.py index 4acf7aec5..d2bca54b5 100644 --- a/hathor/transaction/headers/fee_header.py +++ b/hathor/transaction/headers/fee_header.py @@ -19,7 +19,6 @@ from hathor.serialization import Deserializer, Serializer from hathor.serialization.encoding.output_value import decode_output_value -from hathor.transaction.exceptions import FeeHeaderInvalidAmount from hathor.transaction.headers.base import VertexBaseHeader from hathor.transaction.headers.types import VertexHeaderId from hathor.transaction.util import ( @@ -47,17 +46,6 @@ class FeeEntry: token_uid: TokenUid amount: int - def __post_init__(self) -> None: - """Validate the amount.""" - from hathor.conf.settings import HATHOR_TOKEN_UID - - if self.amount <= 0: - raise FeeHeaderInvalidAmount(f'fees should be a positive integer, got {self.amount}') - - if self.token_uid != HATHOR_TOKEN_UID and self.amount % 100 != 0: - raise FeeHeaderInvalidAmount(f'fees using deposit custom tokens should be a multiple of 100,' - f' got {self.amount}') - @dataclass(slots=True, kw_only=True) class FeeHeader(VertexBaseHeader): @@ -65,12 +53,12 @@ class FeeHeader(VertexBaseHeader): tx: 'Transaction' # list of tokens and amounts that will be used to pay fees in the transaction fees: list[FeeHeaderEntry] - _settings: HathorSettings + settings: HathorSettings def __init__(self, settings: HathorSettings, tx: 'Transaction', fees: list[FeeHeaderEntry]): self.tx = tx self.fees = fees - self._settings = settings + self.settings = settings @classmethod def deserialize( @@ -135,8 +123,8 @@ def total_fee_amount(self) -> int: """Sum fees amounts in this header and return as HTR""" total_fee = 0 for fee in self.get_fees(): - if fee.token_uid == self._settings.HATHOR_TOKEN_UID: + if fee.token_uid == self.settings.HATHOR_TOKEN_UID: total_fee += fee.amount else: - total_fee += get_deposit_token_withdraw_amount(self._settings, fee.amount) + total_fee += get_deposit_token_withdraw_amount(self.settings, fee.amount) return total_fee diff --git a/hathor/transaction/util.py b/hathor/transaction/util.py index ac6e64c0b..bbeabd967 100644 --- a/hathor/transaction/util.py +++ b/hathor/transaction/util.py @@ -20,9 +20,10 @@ from struct import error as StructError from typing import TYPE_CHECKING, Any, Callable, Optional -from hathor.transaction.exceptions import InvalidOutputValue, TransactionDataError +from hathor.transaction.exceptions import InvalidFeeAmount, InvalidOutputValue, TransactionDataError if TYPE_CHECKING: + from hathor import TokenUid from hathor.conf.settings import HathorSettings VerboseCallback = Optional[Callable[[str, Any], None]] @@ -119,3 +120,13 @@ def validate_token_name_and_symbol(settings: HathorSettings, raise TransactionDataError('Invalid token name ({})'.format(token_name)) if clean_token_string(token_symbol) == clean_token_string(settings.HATHOR_TOKEN_SYMBOL): raise TransactionDataError('Invalid token symbol ({})'.format(token_symbol)) + + +def validate_fee_amount(settings: HathorSettings, token_uid: TokenUid | bytes, amount: int) -> None: + """Validate the fee amount.""" + if amount <= 0: + raise InvalidFeeAmount(f'fees should be a positive integer, got {amount}') + + if token_uid != settings.HATHOR_TOKEN_UID and amount % settings.FEE_DIVISOR != 0: + raise InvalidFeeAmount(f'fees using deposit custom tokens should be a multiple of {settings.FEE_DIVISOR}, ' + f'got {amount}') diff --git a/hathor/verification/fee_header_verifier.py b/hathor/verification/fee_header_verifier.py index f1ec2c367..784c1f056 100644 --- a/hathor/verification/fee_header_verifier.py +++ b/hathor/verification/fee_header_verifier.py @@ -16,6 +16,7 @@ from typing import Sequence +from hathor.transaction import Transaction from hathor.transaction.exceptions import FeeHeaderTokenNotFound, InvalidFeeHeader from hathor.transaction.headers import FeeHeader @@ -25,7 +26,7 @@ class FeeHeaderVerifier: @staticmethod - def verify_fee_list(fee_header: 'FeeHeader', tx_tokens_len: int) -> None: + def verify_fee_list(fee_header: 'FeeHeader', tx: Transaction) -> None: """Perform FeeHeader verifications that do not depend on the tx storage.""" fees = fee_header.fees FeeHeaderVerifier._verify_fee_list_size('fees', len(fees)) @@ -34,8 +35,10 @@ def verify_fee_list(fee_header: 'FeeHeader', tx_tokens_len: int) -> None: token_indices = [fee.token_index for fee in fees] FeeHeaderVerifier._verify_duplicate_indexes('fees', token_indices) + from hathor.transaction.util import validate_fee_amount for fee in fees: - FeeHeaderVerifier._verify_token_index('fees', fee.token_index, tx_tokens_len) + FeeHeaderVerifier._verify_token_index('fees', fee.token_index, len(tx.tokens)) + validate_fee_amount(fee_header.settings, tx.get_token_uid(fee.token_index), fee.amount) @staticmethod def _verify_token_index(prop: str, token_index: int, tx_token_len: int) -> None: diff --git a/tests/nanocontracts/blueprints/unittest.py b/tests/nanocontracts/blueprints/unittest.py index 5fd43361a..ab17f3b79 100644 --- a/tests/nanocontracts/blueprints/unittest.py +++ b/tests/nanocontracts/blueprints/unittest.py @@ -15,6 +15,7 @@ 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 +from hathor.transaction.token_info import TokenVersion from hathor.util import not_none from hathor.verification.on_chain_blueprint_verifier import OnChainBlueprintVerifier from hathor.wallet import KeyPair @@ -181,3 +182,19 @@ def create_context( block_data=BlockData(hash=VertexId(b''), timestamp=timestamp or 0, height=0), actions=Context.__group_actions__(actions or ()), ) + + def create_token( + self, + token_uid: TokenUid, + token_name: str, + token_symbol: + str, + token_version: TokenVersion + ) -> None: + """Create a token in the runner block storage""" + self.runner.block_storage.create_token( + token_id=token_uid, + token_name=token_name, + token_symbol=token_symbol, + token_version=token_version + ) diff --git a/tests/nanocontracts/test_actions_fee.py b/tests/nanocontracts/test_actions_fee.py new file mode 100644 index 000000000..33fdb77db --- /dev/null +++ b/tests/nanocontracts/test_actions_fee.py @@ -0,0 +1,348 @@ +import pytest + +from hathor.conf.settings import HATHOR_TOKEN_UID +from hathor.nanocontracts.blueprint import Blueprint +from hathor.nanocontracts.context import Context +from hathor.nanocontracts.exception import NCInvalidFee, NCInvalidFeePaymentToken +from hathor.nanocontracts.storage.contract_storage import Balance, BalanceKey +from hathor.nanocontracts.types import ContractId, NCDepositAction, NCFee, NCWithdrawalAction, TokenUid, public +from hathor.nanocontracts.utils import derive_child_token_id +from tests.nanocontracts.blueprints.unittest import BlueprintTestCase + + +class MyBlueprint(Blueprint): + @public(allow_deposit=True, allow_grant_authority=True) + def initialize(self, ctx: Context) -> None: + pass + + @public(allow_deposit=True, allow_grant_authority=True, allow_withdrawal=True,) + def create_fee_token(self, ctx: Context, name: str, symbol: str, amount: int) -> TokenUid: + return self.syscall.create_fee_token( + name, + symbol, + amount, + mint_authority=True, + melt_authority=True, + ) + + @public(allow_deposit=True, allow_withdrawal=True, allow_grant_authority=True) + def create_deposit_token(self, ctx: Context, name: str, symbol: str, amount: int) -> TokenUid: + return self.syscall.create_deposit_token(name, symbol, amount) + + @public(allow_deposit=True, allow_withdrawal=True) + def noop(self, ctx: Context) -> None: + pass + + @public(allow_deposit=True, allow_withdrawal=True) + def get_tokens_from_nc( + self, + ctx: Context, + nc_id: ContractId, + token_uid: TokenUid, + token_amount: int, + fee_payment_token: TokenUid, + fee_amount: int + ) -> None: + actions = [NCWithdrawalAction(token_uid=token_uid, amount=token_amount)] + fees = [NCFee(token_uid=TokenUid(fee_payment_token), amount=fee_amount)] + self.syscall.call_public_method(nc_id, 'noop', actions, fees) + + @public(allow_deposit=True, allow_withdrawal=True) + def move_tokens_to_nc( + self, + ctx: Context, + nc_id: ContractId, + token_uid: TokenUid, + token_amount: int, + fee_payment_token: TokenUid, + fee_amount: int + ) -> None: + actions = [NCDepositAction(token_uid=token_uid, amount=token_amount)] + fees = [NCFee(token_uid=TokenUid(fee_payment_token), amount=fee_amount)] + self.syscall.call_public_method(nc_id, 'noop', actions, fees) + + +class MyOtherBlueprint(Blueprint): + + @public(allow_deposit=True, allow_withdrawal=True, allow_grant_authority=True) + def initialize(self, ctx: Context) -> None: + self.syscall.create_fee_token( + 'FBT', + 'FBT', + 1_000_000, + mint_authority=True, + melt_authority=True, + ) + + +class NCActionsFeeTestCase(BlueprintTestCase): + def setUp(self) -> None: + super().setUp() + + self.my_blueprint_id = self.gen_random_blueprint_id() + self.my_other_blueprint_id = self.gen_random_blueprint_id() + self.nc_catalog.blueprints[self.my_blueprint_id] = MyBlueprint + self.nc_catalog.blueprints[self.my_other_blueprint_id] = MyOtherBlueprint + + self.nc1_id = self.gen_random_contract_id() + self.nc2_id = self.gen_random_contract_id() + + ctx = self.create_context() + self.runner.create_contract(self.nc1_id, self.my_blueprint_id, ctx) + self.runner.create_contract(self.nc2_id, self.my_blueprint_id, ctx) + + self.nc1_storage = self.runner.get_storage(self.nc1_id) + self.nc2_storage = self.runner.get_storage(self.nc2_id) + + def test_actions_fee(self) -> None: + # Starting state + assert self.nc1_storage.get_all_balances() == {} + assert self.nc2_storage.get_all_balances() == {} + + dbt_token_symbol = 'DBT' + fbt_token_symbol = 'FBT' + expected_dbt_token_uid = derive_child_token_id(ContractId(self.nc1_id), dbt_token_symbol) + expected_fbt_token_uid = derive_child_token_id(ContractId(self.nc1_id), fbt_token_symbol) + + ctx_create_token = self.create_context([NCDepositAction(token_uid=TokenUid(HATHOR_TOKEN_UID), amount=100)]) + dbt_token_uid = self.runner.call_public_method( + self.nc1_id, + 'create_deposit_token', + ctx_create_token, + name='DBT', + symbol=dbt_token_symbol, + amount=1000 + ) + fbt_token_uid = self.runner.call_public_method( + self.nc1_id, + 'create_fee_token', + self.create_context(), + name='FBT', + symbol=fbt_token_symbol, + amount=10000 + ) + + assert dbt_token_uid == expected_dbt_token_uid + assert fbt_token_uid == expected_fbt_token_uid + nc1_htr_balance_key = BalanceKey(nc_id=self.nc1_id, token_uid=HATHOR_TOKEN_UID) + nc1_dbt_balance_key = BalanceKey(nc_id=self.nc1_id, token_uid=dbt_token_uid) + nc1_fbt_balance_key = BalanceKey(nc_id=self.nc1_id, token_uid=fbt_token_uid) + + # deposit token creation charging 1% HTR (10) + # fee token creation charging 1 HTR + # 100 - 10 - 1 = 89 + assert self.nc1_storage.get_all_balances() == { + nc1_htr_balance_key: Balance(value=89, can_mint=False, can_melt=False), + nc1_dbt_balance_key: Balance(value=1000, can_mint=True, can_melt=True), + nc1_fbt_balance_key: Balance(value=10000, can_mint=True, can_melt=True), + } + + # move tokens from nc1 to nc2 + ctx_move_tokens_to_nc = self.create_context() + self.runner.call_public_method( + self.nc1_id, + 'move_tokens_to_nc', + ctx_move_tokens_to_nc, + self.nc2_id, + token_uid=fbt_token_uid, + token_amount=1000, + fee_payment_token=TokenUid(HATHOR_TOKEN_UID), + fee_amount=1 + ) + + assert self.nc1_storage.get_all_balances() == { + nc1_htr_balance_key: Balance(value=88, can_mint=False, can_melt=False), + nc1_dbt_balance_key: Balance(value=1000, can_mint=True, can_melt=True), + nc1_fbt_balance_key: Balance(value=9000, can_mint=True, can_melt=True), + } + + nc2_fbt_balance_key = BalanceKey(nc_id=self.nc2_id, token_uid=fbt_token_uid) + assert self.nc2_storage.get_all_balances() == { + nc2_fbt_balance_key: Balance(value=1000, can_mint=False, can_melt=False), + } + + # move tokens from nc1 to nc2 paying with deposit tokens + ctx_move_tokens_to_nc = self.create_context() + self.runner.call_public_method( + self.nc1_id, + 'move_tokens_to_nc', + ctx_move_tokens_to_nc, + self.nc2_id, + token_uid=fbt_token_uid, + token_amount=1000, + fee_payment_token=dbt_token_uid, + fee_amount=100 + ) + + assert self.nc1_storage.get_all_balances() == { + nc1_htr_balance_key: Balance(value=88, can_mint=False, can_melt=False), + nc1_dbt_balance_key: Balance(value=900, can_mint=True, can_melt=True), + nc1_fbt_balance_key: Balance(value=8000, can_mint=True, can_melt=True), + } + + nc2_fbt_balance_key = BalanceKey(nc_id=self.nc2_id, token_uid=fbt_token_uid) + assert self.nc2_storage.get_all_balances() == { + nc2_fbt_balance_key: Balance(value=2000, can_mint=False, can_melt=False), + } + + # get tokens from nc2 to nc1 paying with htr + self.runner.call_public_method( + self.nc1_id, + 'get_tokens_from_nc', + self.create_context(), + self.nc2_id, + token_uid=fbt_token_uid, + token_amount=1000, + fee_payment_token=TokenUid(HATHOR_TOKEN_UID), + fee_amount=1 + ) + + assert self.nc1_storage.get_all_balances() == { + nc1_htr_balance_key: Balance(value=87, can_mint=False, can_melt=False), + nc1_dbt_balance_key: Balance(value=900, can_mint=True, can_melt=True), + nc1_fbt_balance_key: Balance(value=9000, can_mint=True, can_melt=True), + } + + nc2_fbt_balance_key = BalanceKey(nc_id=self.nc2_id, token_uid=fbt_token_uid) + assert self.nc2_storage.get_all_balances() == { + nc2_fbt_balance_key: Balance(value=1000, can_mint=False, can_melt=False), + } + + # get tokens from nc2 to nc1 paying with deposit token + self.runner.call_public_method( + self.nc1_id, + 'get_tokens_from_nc', + self.create_context(), + self.nc2_id, + token_uid=fbt_token_uid, + token_amount=1000, + fee_payment_token=dbt_token_uid, + fee_amount=100 + ) + + assert self.nc1_storage.get_all_balances() == { + nc1_htr_balance_key: Balance(value=87, can_mint=False, can_melt=False), + nc1_dbt_balance_key: Balance(value=800, can_mint=True, can_melt=True), + nc1_fbt_balance_key: Balance(value=10_000, can_mint=True, can_melt=True), + } + + nc2_fbt_balance_key = BalanceKey(nc_id=self.nc2_id, token_uid=fbt_token_uid) + assert self.nc2_storage.get_all_balances() == { + nc2_fbt_balance_key: Balance(value=0, can_mint=False, can_melt=False), + } + + # paying attempt with negative value is forbidden + msg = 'fees should be a positive integer, got -100' + with pytest.raises(NCInvalidFee, match=msg): + self.runner.call_public_method( + self.nc1_id, + 'move_tokens_to_nc', + self.create_context(), + self.nc2_id, + token_uid=fbt_token_uid, + token_amount=1000, + fee_payment_token=dbt_token_uid, + fee_amount=-100 + ) + # paying attempt with deposit token zero amount is forbidden + msg = 'fees should be a positive integer, got 0' + with pytest.raises(NCInvalidFee, match=msg): + self.runner.call_public_method( + self.nc1_id, + 'move_tokens_to_nc', + self.create_context(), + self.nc2_id, + token_uid=fbt_token_uid, + token_amount=1000, + fee_payment_token=dbt_token_uid, + fee_amount=0 + ) + + # paying attempt with zero amount HTR is forbidden + msg = 'fees should be a positive integer, got 0' + with pytest.raises(NCInvalidFee, match=msg): + self.runner.call_public_method( + self.nc1_id, + 'move_tokens_to_nc', + self.create_context(), + self.nc2_id, + token_uid=fbt_token_uid, + token_amount=1000, + fee_payment_token=TokenUid(HATHOR_TOKEN_UID), + fee_amount=0 + ) + + msg = 'fees using deposit custom tokens should be a multiple of 100, got 101' + with pytest.raises(NCInvalidFee, match=msg): + self.runner.call_public_method( + self.nc1_id, + 'move_tokens_to_nc', + self.create_context(), + self.nc2_id, + token_uid=fbt_token_uid, + token_amount=1000, + fee_payment_token=dbt_token_uid, + fee_amount=101 + ) + + # paying attempt with a valid value but incorrect amount + msg = r'Fee payment balance is different than expected\. \(amount=2, expected=1\)' + with pytest.raises(NCInvalidFee, match=msg): + self.runner.call_public_method( + self.nc1_id, + 'move_tokens_to_nc', + self.create_context(), + self.nc2_id, + token_uid=fbt_token_uid, + token_amount=1000, + fee_payment_token=dbt_token_uid, + fee_amount=200 + ) + + # paying fees with a fee token is forbidden + msg = "fee-based tokens aren't allowed for paying fees" + with pytest.raises(NCInvalidFeePaymentToken, match=msg): + self.runner.call_public_method( + self.nc1_id, + 'move_tokens_to_nc', + self.create_context(), + self.nc2_id, + token_uid=fbt_token_uid, + token_amount=1000, + fee_payment_token=fbt_token_uid, + fee_amount=100 + ) + + assert self.nc1_storage.get_all_balances() == { + nc1_htr_balance_key: Balance(value=87, can_mint=False, can_melt=False), + nc1_dbt_balance_key: Balance(value=800, can_mint=True, can_melt=True), + nc1_fbt_balance_key: Balance(value=10_000, can_mint=True, can_melt=True), + } + + nc2_fbt_balance_key = BalanceKey(nc_id=self.nc2_id, token_uid=fbt_token_uid) + assert self.nc2_storage.get_all_balances() == { + nc2_fbt_balance_key: Balance(value=0, can_mint=False, can_melt=False), + } + + def test_create_and_actions(self) -> None: + # Check the contract creation with a token creation syscall and withdrawal of the created token + self.nc3_id = self.gen_random_contract_id() + fbt_token_uid = derive_child_token_id(self.nc3_id, 'FBT') + + htr_token_uid = TokenUid(HATHOR_TOKEN_UID) + ctx = self.create_context( + actions=[ + NCDepositAction(token_uid=htr_token_uid, amount=1), + NCWithdrawalAction(token_uid=fbt_token_uid, amount=100_000) + ], + ) + self.runner.create_contract(self.nc3_id, self.my_other_blueprint_id, ctx) + + fbt_balance_key = BalanceKey(self.nc3_id, token_uid=fbt_token_uid) + htr_balance_key = BalanceKey(self.nc3_id, token_uid=htr_token_uid) + + storage = self.runner.get_storage(self.nc3_id) + assert storage.get_all_balances() == { + htr_balance_key: Balance(value=0, can_mint=False, can_melt=False), + fbt_balance_key: Balance(value=900_000, can_mint=True, can_melt=True), + } diff --git a/tests/nanocontracts/test_authorities_call_another.py b/tests/nanocontracts/test_authorities_call_another.py index c32b95114..228c9f067 100644 --- a/tests/nanocontracts/test_authorities_call_another.py +++ b/tests/nanocontracts/test_authorities_call_another.py @@ -18,6 +18,7 @@ from hathor.nanocontracts.exception import NCInvalidAction from hathor.nanocontracts.storage.contract_storage import Balance from hathor.nanocontracts.types import ContractId, NCAcquireAuthorityAction, NCAction, NCGrantAuthorityAction, TokenUid +from hathor.transaction.token_info import TokenVersion from tests.nanocontracts.blueprints.unittest import BlueprintTestCase @@ -41,7 +42,7 @@ def grant_all_to_other(self, ctx: Context, contract_id: ContractId, token_uid: T @public def revoke_all_from_other(self, ctx: Context, contract_id: ContractId, token_uid: TokenUid) -> None: - self.syscall.call_public_method(contract_id, 'revoke_from_self', [], token_uid, True, True) + self.syscall.call_public_method(contract_id, 'revoke_from_self', [], [], token_uid, True, True) class CallerBlueprint(Blueprint): @@ -66,7 +67,7 @@ def revoke_from_self(self, ctx: Context, token_uid: TokenUid, mint: bool, melt: @public def revoke_from_other(self, ctx: Context, token_uid: TokenUid, mint: bool, melt: bool) -> None: - self.syscall.call_public_method(self.other_id, 'revoke_from_self', [], token_uid, mint, melt) + self.syscall.call_public_method(self.other_id, 'revoke_from_self', [], [], token_uid, mint, melt) @public def acquire_another(self, ctx: Context, token_uid: TokenUid, mint: bool, melt: bool) -> None: @@ -141,6 +142,13 @@ def _initialize(self, caller_actions: list[NCAction] | None = None) -> None: self.caller_storage = self.runner.get_storage(self.caller_id) self.callee_storage = self.runner.get_storage(self.callee_id) + self.caller_storage.create_token( + token_id=self.token_a, + token_name='TKA', + token_symbol='TKA', + token_version=TokenVersion.DEPOSIT + ) + def _grant_to_other(self, *, mint: bool, melt: bool) -> None: context = self.create_context( actions=[], diff --git a/tests/nanocontracts/test_blueprints/test_blueprint1.py b/tests/nanocontracts/test_blueprints/test_blueprint1.py index 131acf4f6..e734a3c64 100644 --- a/tests/nanocontracts/test_blueprints/test_blueprint1.py +++ b/tests/nanocontracts/test_blueprints/test_blueprint1.py @@ -32,4 +32,4 @@ def view(self) -> None: @public def create_child_contract(self, ctx: Context) -> None: blueprint_id = self.syscall.get_blueprint_id() - self.syscall.create_contract(blueprint_id, b'', [], 0) + self.syscall.create_contract(blueprint_id, salt=b'', actions=[], fees=[], args=[0]) diff --git a/tests/nanocontracts/test_call_other_contract.py b/tests/nanocontracts/test_call_other_contract.py index a0f7d58cd..d259cdf81 100644 --- a/tests/nanocontracts/test_call_other_contract.py +++ b/tests/nanocontracts/test_call_other_contract.py @@ -27,6 +27,7 @@ TokenUid, VertexId, ) +from hathor.transaction.token_info import TokenVersion from tests.nanocontracts.blueprints.unittest import BlueprintTestCase from tests.nanocontracts.utils import TestRunner @@ -273,8 +274,16 @@ def test_getting_funds_from_another_contract(self) -> None: NCDepositAction(token_uid=token2_uid, amount=12), NCDepositAction(token_uid=token3_uid, amount=13), ] - ctx = self.create_context(actions, self.tx, MOCK_ADDRESS) - self.runner.create_contract(self.nc1_id, self.blueprint_id, ctx, 0) + self.runner.create_contract( + self.nc1_id, + self.blueprint_id, + self.create_context(actions, self.tx, MOCK_ADDRESS), + 0 + ) + + self.create_token(token2_uid, 'tk2', 'tk2', TokenVersion.DEPOSIT) + self.create_token(token3_uid, 'tk3', 'tk3', TokenVersion.DEPOSIT) + self.assertEqual( Balance(value=11, can_mint=False, can_melt=False), self.runner.get_current_balance(self.nc1_id, token1_uid) ) @@ -389,6 +398,9 @@ def test_transfer_between_contracts(self) -> None: token2_uid = TokenUid(b'b' * 32) token3_uid = TokenUid(b'c' * 32) + self.create_token(token2_uid, 'tk2', 'tk2', TokenVersion.DEPOSIT) + self.create_token(token3_uid, 'tk3', 'tk3', TokenVersion.DEPOSIT) + actions = [ NCDepositAction(token_uid=token1_uid, amount=100), NCDepositAction(token_uid=token2_uid, amount=50), diff --git a/tests/nanocontracts/test_caller_id.py b/tests/nanocontracts/test_caller_id.py index e0a9048f3..e4e577e69 100644 --- a/tests/nanocontracts/test_caller_id.py +++ b/tests/nanocontracts/test_caller_id.py @@ -37,7 +37,7 @@ def initialize(self, ctx: Context) -> None: @public def create_another(self, ctx: Context, blueprint_id: BlueprintId) -> ContractId: - contract_id, _ = self.syscall.create_contract(blueprint_id, b'1', []) + contract_id, _ = self.syscall.create_contract(blueprint_id, salt=b'1', actions=[], fees=[]) return contract_id @public diff --git a/tests/nanocontracts/test_contract_create_contract.py b/tests/nanocontracts/test_contract_create_contract.py index fcaebd16b..8c9c357b7 100644 --- a/tests/nanocontracts/test_contract_create_contract.py +++ b/tests/nanocontracts/test_contract_create_contract.py @@ -41,7 +41,7 @@ def initialize(self, ctx: Context, blueprint_id: BlueprintId, initial: int, toke assert isinstance(action, NCDepositAction) new_actions = [NCDepositAction(token_uid=token_uid, amount=action.amount - initial)] self.contract, _ = self.syscall.create_contract( - blueprint_id, salt, new_actions, blueprint_id, initial - 1, self.token_uid + blueprint_id, salt, new_actions, [], blueprint_id, initial - 1, self.token_uid ) else: self.contract = None @@ -52,9 +52,9 @@ def create_children(self, ctx: Context, blueprint_id: BlueprintId, salt: bytes) new_actions = [] if self.token_uid and self.syscall.can_mint(self.token_uid): new_actions.append(NCGrantAuthorityAction(token_uid=self.token_uid, mint=True, melt=True)) - self.syscall.create_contract(blueprint_id, salt + b'1', new_actions, blueprint_id, 0, self.token_uid) - self.syscall.create_contract(blueprint_id, salt + b'2', new_actions, blueprint_id, 0, self.token_uid) - self.syscall.create_contract(blueprint_id, salt + b'3', new_actions, blueprint_id, 0, self.token_uid) + self.syscall.create_contract(blueprint_id, salt + b'1', new_actions, [], blueprint_id, 0, self.token_uid) + self.syscall.create_contract(blueprint_id, salt + b'2', new_actions, [], blueprint_id, 0, self.token_uid) + self.syscall.create_contract(blueprint_id, salt + b'3', new_actions, [], blueprint_id, 0, self.token_uid) @public def nop(self, ctx: Context) -> None: diff --git a/tests/nanocontracts/test_contract_upgrade.py b/tests/nanocontracts/test_contract_upgrade.py index d2dfbd8bd..e89262bcd 100644 --- a/tests/nanocontracts/test_contract_upgrade.py +++ b/tests/nanocontracts/test_contract_upgrade.py @@ -27,7 +27,7 @@ def upgrade_no_cb(self, ctx: Context, blueprint_id: BlueprintId) -> None: def upgrade(self, ctx: Context, blueprint_id: BlueprintId, method_name: str) -> None: contract_id = self.syscall.get_contract_id() self.syscall.change_blueprint(blueprint_id) - self.syscall.call_public_method(self.contract, 'on_upgrade', [], contract_id, method_name) + self.syscall.call_public_method(self.contract, 'on_upgrade', [], [], contract_id, method_name) @public def on_upgrade(self, ctx: Context) -> None: @@ -37,12 +37,12 @@ def on_upgrade(self, ctx: Context) -> None: def inc(self, ctx: Context) -> None: actions: list[NCAction] = [] blueprint_id = self.syscall.get_blueprint_id(self.contract) - self.syscall.proxy_call_public_method(blueprint_id, 'inc', actions) + self.syscall.proxy_call_public_method(blueprint_id, 'inc', actions, []) @fallback def fallback(self, ctx: Context, method_name: str, nc_args: NCArgs) -> None: blueprint_id = self.syscall.get_blueprint_id(self.contract) - self.syscall.proxy_call_public_method_nc_args(blueprint_id, method_name, ctx.actions_list, nc_args) + self.syscall.proxy_call_public_method_nc_args(blueprint_id, method_name, ctx.actions_list, [], nc_args) class CodeBlueprint1(Blueprint): diff --git a/tests/nanocontracts/test_execution_order.py b/tests/nanocontracts/test_execution_order.py index 3b5f64177..d3c0c72af 100644 --- a/tests/nanocontracts/test_execution_order.py +++ b/tests/nanocontracts/test_execution_order.py @@ -22,6 +22,7 @@ NCWithdrawalAction, TokenUid, ) +from hathor.transaction.token_info import TokenVersion from tests.nanocontracts.blueprints.unittest import BlueprintTestCase @@ -85,7 +86,11 @@ def deposit_into_another(self, ctx: Context, contract_id: ContractId) -> None: self.assert_token_balance(before=0, current=10) action = NCDepositAction(token_uid=self.token_uid, amount=7) self.syscall.call_public_method( - contract_id, 'accept_deposit_from_another', [action], self.syscall.get_contract_id() + contract_id, + 'accept_deposit_from_another', + [action], + [], + self.syscall.get_contract_id() ) self.assert_token_balance(before=0, current=6) @@ -105,7 +110,11 @@ def withdraw_from_another(self, ctx: Context, contract_id: ContractId) -> None: self.assert_token_balance(before=6, current=5) action = NCWithdrawalAction(token_uid=self.token_uid, amount=2) self.syscall.call_public_method( - contract_id, 'accept_withdrawal_from_another', [action], self.syscall.get_contract_id() + contract_id, + 'accept_withdrawal_from_another', + [action], + [], + self.syscall.get_contract_id() ) self.assert_token_balance(before=6, current=6) @@ -136,6 +145,13 @@ def setUp(self) -> None: self.runner.create_contract(self.contract_id1, self.blueprint_id, self._get_context(action), self.token_a) self.runner.create_contract(self.contract_id2, self.blueprint_id, self._get_context(action), self.token_a) + self.create_token( + token_uid=self.token_a, + token_name='TKA', + token_symbol='TKA', + token_version=TokenVersion.DEPOSIT, + ) + def _get_context(self, *actions: NCAction) -> Context: return self.create_context( actions=list(actions), @@ -152,16 +168,6 @@ def test_deposit_and_withdrawal(self) -> None: self.runner.call_public_method(self.contract_id1, 'withdrawal', self._get_context(action)) def test_mint_and_melt(self) -> None: - # First create the token so it exists in the system - from hathor.transaction.token_info import TokenVersion - changes_tracker = self.runner.get_storage(self.contract_id1) - changes_tracker.create_token( - token_id=self.token_a, - token_name="Test Token", - token_symbol="TST", - token_version=TokenVersion.DEPOSIT - ) - action: NCAction = NCGrantAuthorityAction(token_uid=self.token_a, mint=True, melt=False) self.runner.call_public_method(self.contract_id1, 'mint', self._get_context(action)) diff --git a/tests/nanocontracts/test_exposed_properties.py b/tests/nanocontracts/test_exposed_properties.py index f544174e1..f9dbb6974 100644 --- a/tests/nanocontracts/test_exposed_properties.py +++ b/tests/nanocontracts/test_exposed_properties.py @@ -89,6 +89,10 @@ 'hathor.NCFail.args', 'hathor.NCFail.some_new_attribute', 'hathor.NCFail.with_traceback', + 'hathor.NCFee.amount', + 'hathor.NCFee.get_htr_value', + 'hathor.NCFee.some_new_attribute', + 'hathor.NCFee.token_uid', 'hathor.NCGrantAuthorityAction.melt', 'hathor.NCGrantAuthorityAction.mint', 'hathor.NCGrantAuthorityAction.name', diff --git a/tests/nanocontracts/test_nc_exec_logs.py b/tests/nanocontracts/test_nc_exec_logs.py index e6b54a879..ce9b32708 100644 --- a/tests/nanocontracts/test_nc_exec_logs.py +++ b/tests/nanocontracts/test_nc_exec_logs.py @@ -13,8 +13,10 @@ # limitations under the License. from textwrap import dedent +from typing import Sequence from unittest.mock import ANY +from hathor import NCFee from hathor.nanocontracts import Blueprint, Context, NCFail, public from hathor.nanocontracts.nc_exec_logs import ( NCCallBeginEntry, @@ -62,7 +64,8 @@ def value_error(self, ctx: Context) -> None: def call_another_public(self, ctx: Context, contract_id: ContractId) -> None: self.log.debug('call_another_public() called on MyBlueprint1', contract_id=contract_id) actions = [NCDepositAction(token_uid=TokenUid(b'\x00'), amount=5)] - result1 = self.syscall.call_public_method(contract_id, 'sum', actions, 1, 2) + fees: Sequence[NCFee] = [] + result1 = self.syscall.call_public_method(contract_id, 'sum', actions, fees, 1, 2) result2 = self.syscall.call_view_method(contract_id, 'hello_world') self.log.debug('results on MyBlueprint1', result1=result1, result2=result2) diff --git a/tests/nanocontracts/test_syscalls.py b/tests/nanocontracts/test_syscalls.py index f836e7d22..19b3d60e0 100644 --- a/tests/nanocontracts/test_syscalls.py +++ b/tests/nanocontracts/test_syscalls.py @@ -6,13 +6,16 @@ from hathor.nanocontracts.blueprint import Blueprint from hathor.nanocontracts.context import Context from hathor.nanocontracts.exception import NCInsufficientFunds, NCInvalidSyscall +from hathor.nanocontracts.method import ArgsOnly from hathor.nanocontracts.nc_types import NCType, make_nc_type_for_arg_type as make_nc_type from hathor.nanocontracts.storage.contract_storage import Balance, BalanceKey from hathor.nanocontracts.types import ( BlueprintId, ContractId, NCDepositAction, + NCFee, NCGrantAuthorityAction, + NCRawArgs, TokenUid, public, ) @@ -94,6 +97,49 @@ def melt(self, ctx: Context, token: TokenUid, amount: int, fee_payment_token: To self.syscall.melt_tokens(token, amount=amount, fee_payment_token=fee_payment_token) +class ProxyCallerBlueprint(Blueprint): + counter: int + + @public(allow_deposit=True) + def initialize(self, ctx: Context) -> None: + self.counter = 0 + + @public(allow_deposit=True) + def increment(self, ctx: Context, value: int) -> int: + self.counter += value + return self.counter + + +class TargetBlueprint(Blueprint): + counter: int + + @public(allow_deposit=True) + def initialize(self, ctx: Context) -> None: + self.counter = 0 + + @public(allow_deposit=True) + def increment(self, ctx: Context, value: int) -> int: + self.counter += value + return self.counter + + @public(allow_deposit=True) + def proxy_increment(self, ctx: Context, blueprint_id: BlueprintId, value: int) -> int: + """Call the increment method of another blueprint using proxy_call_public_method_nc_args.""" + args_parser = ArgsOnly.from_arg_types((int,)) + args_bytes = args_parser.serialize_args_bytes((value,)) + nc_args = NCRawArgs(args_bytes) + # Pay 1 HTR as fee for the proxy call + fees: list[NCFee] = [NCFee(token_uid=TokenUid(HATHOR_TOKEN_UID), amount=1)] + result = self.syscall.proxy_call_public_method_nc_args( + blueprint_id=blueprint_id, + method_name='increment', + actions=ctx.actions_list, + fees=fees, + nc_args=nc_args + ) + return result + + class NCNanoContractTestCase(BlueprintTestCase): def setUp(self) -> None: super().setUp() @@ -101,10 +147,14 @@ def setUp(self) -> None: self.my_blueprint_id = self.gen_random_blueprint_id() self.other_blueprint_id = self.gen_random_blueprint_id() self.fee_blueprint_id = self.gen_random_blueprint_id() + self.proxy_caller_blueprint_id = self.gen_random_blueprint_id() + self.target_blueprint_id = self.gen_random_blueprint_id() self.nc_catalog.blueprints[self.my_blueprint_id] = MyBlueprint self.nc_catalog.blueprints[self.other_blueprint_id] = OtherBlueprint self.nc_catalog.blueprints[self.fee_blueprint_id] = FeeTokenBlueprint + self.nc_catalog.blueprints[self.proxy_caller_blueprint_id] = ProxyCallerBlueprint + self.nc_catalog.blueprints[self.target_blueprint_id] = TargetBlueprint def test_basics(self) -> None: nc1_id = self.gen_random_contract_id() @@ -635,3 +685,43 @@ def test_fee_token_as_payment_rejected(self) -> None: htr_balance_key: Balance(value=9, can_mint=False, can_melt=False), ft1_balance_key: Balance(value=1000000, can_mint=True, can_melt=True), } + + def test_proxy_call_public_method_nc_args(self) -> None: + """Test proxy_call_public_method_nc_args with fee-based token action charges 1 HTR fee.""" + target_nc_id = self.gen_random_contract_id() + + # Initialize contract with HTR to pay fees + ctx_initialize = self.create_context( + [NCDepositAction(token_uid=TokenUid(HATHOR_TOKEN_UID), amount=10)], + self.get_genesis_tx() + ) + self.runner.create_contract(target_nc_id, self.target_blueprint_id, ctx_initialize) + + # Create fee-based token directly + fee_token_uid = self.gen_random_token_uid() + self.create_token(fee_token_uid, 'FeeToken', 'FBT', TokenVersion.FEE) + + target_storage = self.runner.get_storage(target_nc_id) + htr_balance_key = BalanceKey(nc_id=target_nc_id, token_uid=HATHOR_TOKEN_UID) + fbt_balance_key = BalanceKey(nc_id=target_nc_id, token_uid=fee_token_uid) + + # Initial state: 10 HTR, no fee tokens + assert target_storage.get_all_balances() == { + htr_balance_key: Balance(value=10, can_mint=False, can_melt=False), + } + + # Proxy call with fee-based token deposit + ctx_with_deposit = self.create_context( + [NCDepositAction(token_uid=fee_token_uid, amount=20)], + self.get_genesis_tx() + ) + result = self.runner.call_public_method( + target_nc_id, 'proxy_increment', ctx_with_deposit, self.proxy_caller_blueprint_id, 5 + ) + assert result == 5 + + # Verify: 20 FBT deposited, 1 HTR charged as fee + assert target_storage.get_all_balances() == { + htr_balance_key: Balance(value=9, can_mint=False, can_melt=False), + fbt_balance_key: Balance(value=20, can_mint=False, can_melt=False), + } diff --git a/tests/nanocontracts/test_syscalls_in_view.py b/tests/nanocontracts/test_syscalls_in_view.py index ff7f0bedc..b372bf7d2 100644 --- a/tests/nanocontracts/test_syscalls_in_view.py +++ b/tests/nanocontracts/test_syscalls_in_view.py @@ -91,7 +91,7 @@ def melt_tokens(self) -> None: @view def create_contract(self) -> None: - self.syscall.create_contract(BlueprintId(VertexId(b'')), b'', []) + self.syscall.create_contract(BlueprintId(VertexId(b'')), salt=b'', actions=[], fees=[]) @view def emit_event(self) -> None: @@ -107,12 +107,18 @@ def create_fee_token(self) -> None: @view def proxy_call_public_method(self) -> None: - self.syscall.proxy_call_public_method(BlueprintId(VertexId(b'')), '', []) + self.syscall.proxy_call_public_method(BlueprintId(VertexId(b'')), method_name='', actions=[], fees=[]) @view def proxy_call_public_method_nc_args(self) -> None: nc_args = NCRawArgs(b'') - self.syscall.proxy_call_public_method_nc_args(BlueprintId(VertexId(b'')), '', [], nc_args) + self.syscall.proxy_call_public_method_nc_args( + BlueprintId(VertexId(b'')), + method_name='', + actions=[], + fees=[], + nc_args=nc_args + ) @view def change_blueprint(self) -> None: diff --git a/tests/others/test_hathor_settings.py b/tests/others/test_hathor_settings.py index e41578579..88bbddef7 100644 --- a/tests/others/test_hathor_settings.py +++ b/tests/others/test_hathor_settings.py @@ -153,6 +153,33 @@ def mock_settings(settings_: dict[str, Any]) -> None: ) in str(e.value) +def test_token_deposit_percentage() -> None: + yaml_mock = Mock() + required_settings = dict(P2PKH_VERSION_BYTE='x01', MULTISIG_VERSION_BYTE='x02', NETWORK_NAME='test') + + def mock_settings(settings_: dict[str, Any]) -> None: + yaml_mock.dict_from_extended_yaml = Mock(return_value=required_settings | settings_) + + with patch('hathor.conf.settings.yaml', yaml_mock): + # Test default value passes (0.01 results in FEE_DIVISOR=100) + mock_settings(dict(TOKEN_DEPOSIT_PERCENTAGE=0.01)) + HathorSettings.from_yaml(filepath='some_path') + + # Test fails when TOKEN_DEPOSIT_PERCENTAGE results in non-integer FEE_DIVISOR (0.03 -> 33.333...) + mock_settings(dict(TOKEN_DEPOSIT_PERCENTAGE=0.03)) + with pytest.raises(ValidationError) as e: + HathorSettings.from_yaml(filepath='some_path') + assert 'TOKEN_DEPOSIT_PERCENTAGE must result in an integer FEE_DIVISOR' in str(e.value) + assert 'TOKEN_DEPOSIT_PERCENTAGE=0.03' in str(e.value) + + # Test fails when TOKEN_DEPOSIT_PERCENTAGE results in non-integer FEE_DIVISOR (0.07 -> 14.285...) + mock_settings(dict(TOKEN_DEPOSIT_PERCENTAGE=0.07)) + with pytest.raises(ValidationError) as e: + HathorSettings.from_yaml(filepath='some_path') + assert 'TOKEN_DEPOSIT_PERCENTAGE must result in an integer FEE_DIVISOR' in str(e.value) + assert 'TOKEN_DEPOSIT_PERCENTAGE=0.07' in str(e.value) + + def test_consensus_algorithm() -> None: yaml_mock = Mock() required_settings = dict(P2PKH_VERSION_BYTE='x01', MULTISIG_VERSION_BYTE='x02', NETWORK_NAME='test') diff --git a/tests/verification/test_fee_header_verifier.py b/tests/verification/test_fee_header_verifier.py index a55e094f8..c4c53243e 100644 --- a/tests/verification/test_fee_header_verifier.py +++ b/tests/verification/test_fee_header_verifier.py @@ -1,8 +1,8 @@ import pytest from hathor.transaction import Transaction -from hathor.transaction.exceptions import FeeHeaderInvalidAmount, FeeHeaderTokenNotFound, InvalidFeeHeader -from hathor.transaction.headers.fee_header import FeeEntry, FeeHeader, FeeHeaderEntry +from hathor.transaction.exceptions import FeeHeaderTokenNotFound, InvalidFeeAmount, InvalidFeeHeader +from hathor.transaction.headers.fee_header import FeeHeader, FeeHeaderEntry from hathor.types import TokenUid from hathor.verification.fee_header_verifier import MAX_FEES_LEN, FeeHeaderVerifier from tests import unittest @@ -49,17 +49,41 @@ def test_verify_without_storage_valid_cases(self) -> None: fees = [FeeHeaderEntry(token_index=0, amount=100)] # HTR fee header = self._create_fee_header(tx, fees) - FeeHeaderVerifier.verify_fee_list(header, len(tx.tokens) + 1) # +1 for HTR + FeeHeaderVerifier.verify_fee_list(header, tx) # +1 for HTR # Multiple fee entries with different tokens tx = self._create_transaction_with_tokens(2) # Custom tokens at indices 1,2 fees = [ - FeeHeaderEntry(token_index=0, amount=100), # HTR + FeeHeaderEntry(token_index=0, amount=3), # HTR FeeHeaderEntry(token_index=1, amount=200), # Custom token 1 ] header = self._create_fee_header(tx, fees) - FeeHeaderVerifier.verify_fee_list(header, len(tx.tokens) + 1) + FeeHeaderVerifier.verify_fee_list(header, tx) + + def test_verify_without_storage_invalid_fee_amounts(self) -> None: + """Test valid scenarios for verify_without_storage.""" + # Single fee entry + tx = self._create_transaction_with_tokens(1) # Custom token at index 1 + + # Invalid zero amount + with pytest.raises(InvalidFeeAmount, match="fees should be a positive integer, got 0"): + fees = [FeeHeaderEntry(token_index=0, amount=0)] # HTR fee + header = self._create_fee_header(tx, fees) + FeeHeaderVerifier.verify_fee_list(header, tx) + + # Invalid negative amount + with pytest.raises(InvalidFeeAmount, match="fees should be a positive integer, got -50"): + fees = [FeeHeaderEntry(token_index=0, amount=-50)] # HTR fee + header = self._create_fee_header(tx, fees) + FeeHeaderVerifier.verify_fee_list(header, tx) + + # Invalid non-multiple of 100 + with pytest.raises(InvalidFeeAmount, + match="fees using deposit custom tokens should be a multiple of 100, got 150"): + fees = [FeeHeaderEntry(token_index=1, amount=150)] + header = self._create_fee_header(tx, fees) + FeeHeaderVerifier.verify_fee_list(header, tx) def test_verify_fee_list_size_empty(self) -> None: """Test that empty fees list raises InvalidFeeHeader.""" @@ -67,7 +91,7 @@ def test_verify_fee_list_size_empty(self) -> None: header = self._create_fee_header(tx, []) with pytest.raises(InvalidFeeHeader, match="fees cannot be empty"): - FeeHeaderVerifier.verify_fee_list(header, len(tx.tokens) + 1) + FeeHeaderVerifier.verify_fee_list(header, tx) def test_verify_fee_list_size_exceeds_max(self) -> None: """Test that fees list exceeding MAX_FEES_LEN raises InvalidFeeHeader.""" @@ -78,7 +102,7 @@ def test_verify_fee_list_size_exceeds_max(self) -> None: expected_msg = f"more fees than the max allowed: {MAX_FEES_LEN + 1} > {MAX_FEES_LEN}" with pytest.raises(InvalidFeeHeader, match=expected_msg): - FeeHeaderVerifier.verify_fee_list(header, len(tx.tokens) + 1) + FeeHeaderVerifier.verify_fee_list(header, tx) def test_verify_fee_list_size_at_max(self) -> None: """Test that fees list exactly at MAX_FEES_LEN is valid.""" @@ -88,7 +112,7 @@ def test_verify_fee_list_size_at_max(self) -> None: header = self._create_fee_header(tx, fees) # Should not raise any exception - FeeHeaderVerifier.verify_fee_list(header, len(tx.tokens) + 1) + FeeHeaderVerifier.verify_fee_list(header, tx) def test_duplicate_token_indexes_in_fees(self) -> None: """Test that duplicate token indices in fees raise InvalidFeeHeader.""" @@ -100,7 +124,7 @@ def test_duplicate_token_indexes_in_fees(self) -> None: header = self._create_fee_header(tx, fees) with pytest.raises(InvalidFeeHeader, match="duplicate token indexes in fees list"): - FeeHeaderVerifier.verify_fee_list(header, len(tx.tokens) + 1) + FeeHeaderVerifier.verify_fee_list(header, tx) def test_invalid_token_indexes_out_of_bounds(self) -> None: """Test that token indices out of bounds raise FeeHeaderTokenNotFound.""" @@ -110,54 +134,14 @@ def test_invalid_token_indexes_out_of_bounds(self) -> None: with pytest.raises(FeeHeaderTokenNotFound, match="fees contains token index 5 which is not in tokens list"): - FeeHeaderVerifier.verify_fee_list(header, len(tx.tokens) + 1) + FeeHeaderVerifier.verify_fee_list(header, tx) def test_invalid_token_index_greater_than_tx_tokens_len(self) -> None: """Test that token index greater than tx_tokens_len raises FeeHeaderTokenNotFound.""" tx = self._create_transaction_with_tokens(1) # tx_tokens_len = 2 (HTR + 1 custom) - tx_tokens_len = len(tx.tokens) + 1 # 2 - fees = [FeeHeaderEntry(token_index=3, amount=100)] # Index 3 > tx_tokens_len (2) + fees = [FeeHeaderEntry(token_index=2, amount=100)] # Index 2 > tx_tokens_len (1) header = self._create_fee_header(tx, fees) with pytest.raises(FeeHeaderTokenNotFound, - match="fees contains token index 3 which is not in tokens list"): - FeeHeaderVerifier.verify_fee_list(header, tx_tokens_len) - - def test_fee_entry_validation_positive_amount(self) -> None: - """Test FeeEntry validation for positive amounts.""" - # Valid positive amount - fee_entry = FeeEntry(token_uid=self._settings.HATHOR_TOKEN_UID, amount=100) - assert fee_entry.amount == 100 - - # Invalid zero amount - with pytest.raises(FeeHeaderInvalidAmount, match="fees should be a positive integer, got 0"): - FeeEntry(token_uid=self._settings.HATHOR_TOKEN_UID, amount=0) - - # Invalid negative amount - with pytest.raises(FeeHeaderInvalidAmount, match="fees should be a positive integer, got -50"): - FeeEntry(token_uid=self._settings.HATHOR_TOKEN_UID, amount=-50) - - def test_fee_entry_validation_custom_token_multiple_of_100(self) -> None: - """Test FeeEntry validation for custom tokens requiring multiples of 100.""" - custom_token_uid = TokenUid(b'custom_token_uid_32_bytes_long!') - - # Valid multiple of 100 - fee_entry = FeeEntry(token_uid=custom_token_uid, amount=100) - assert fee_entry.amount == 100 - - fee_entry = FeeEntry(token_uid=custom_token_uid, amount=500) - assert fee_entry.amount == 500 - - # Invalid non-multiple of 100 - with pytest.raises(FeeHeaderInvalidAmount, - match="fees using deposit custom tokens should be a multiple of 100, got 150"): - FeeEntry(token_uid=custom_token_uid, amount=150) - - def test_fee_entry_validation_htr_token_any_amount(self) -> None: - """Test that HTR token fees can be any positive amount (not restricted to multiples of 100).""" - # HTR can use any positive amount - fee_entry = FeeEntry(token_uid=self._settings.HATHOR_TOKEN_UID, amount=123) - assert fee_entry.amount == 123 - - fee_entry = FeeEntry(token_uid=self._settings.HATHOR_TOKEN_UID, amount=1) - assert fee_entry.amount == 1 + match="fees contains token index 2 which is not in tokens list"): + FeeHeaderVerifier.verify_fee_list(header, tx)