diff --git a/hathor/transaction/exceptions.py b/hathor/transaction/exceptions.py index e2e8b6136..7442e2c8d 100644 --- a/hathor/transaction/exceptions.py +++ b/hathor/transaction/exceptions.py @@ -258,3 +258,15 @@ class VerifyFailed(ScriptError): class TimeLocked(ScriptError): """Transaction is invalid because it is time locked""" + + +class InvalidFeeHeader(TxValidationError): + """Invalid fee header""" + + +class FeeHeaderTokenNotFound(InvalidFeeHeader): + """Token not found in the transaction tokens list""" + + +class FeeHeaderInvalidAmount(InvalidFeeHeader): + """Invalid fee amount""" diff --git a/hathor/transaction/headers/__init__.py b/hathor/transaction/headers/__init__.py index 029f61078..64efadf57 100644 --- a/hathor/transaction/headers/__init__.py +++ b/hathor/transaction/headers/__init__.py @@ -13,6 +13,7 @@ # limitations under the License. from hathor.transaction.headers.base import VertexBaseHeader +from hathor.transaction.headers.fee_header import FeeHeader from hathor.transaction.headers.nano_header import NanoHeader from hathor.transaction.headers.types import VertexHeaderId @@ -20,4 +21,5 @@ 'VertexBaseHeader', 'VertexHeaderId', 'NanoHeader', + 'FeeHeader', ] diff --git a/hathor/transaction/headers/fee_header.py b/hathor/transaction/headers/fee_header.py new file mode 100644 index 000000000..4acf7aec5 --- /dev/null +++ b/hathor/transaction/headers/fee_header.py @@ -0,0 +1,142 @@ +# Copyright 2023 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 dataclasses import dataclass +from typing import TYPE_CHECKING + +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 ( + VerboseCallback, + get_deposit_token_withdraw_amount, + int_to_bytes, + output_value_to_bytes, +) +from hathor.types import TokenUid + +if TYPE_CHECKING: + from hathor.conf.settings import HathorSettings + from hathor.transaction.base_transaction import BaseTransaction + from hathor.transaction.transaction import Transaction + + +@dataclass(slots=True, kw_only=True, frozen=True) +class FeeHeaderEntry: + token_index: int + amount: int + + +@dataclass(slots=True, kw_only=True, frozen=True) +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): + # transaction that contains the fee header + tx: 'Transaction' + # list of tokens and amounts that will be used to pay fees in the transaction + fees: list[FeeHeaderEntry] + _settings: HathorSettings + + def __init__(self, settings: HathorSettings, tx: 'Transaction', fees: list[FeeHeaderEntry]): + self.tx = tx + self.fees = fees + self._settings = settings + + @classmethod + def deserialize( + cls, + tx: BaseTransaction, + buf: bytes, + *, + verbose: VerboseCallback = None + ) -> tuple[FeeHeader, bytes]: + deserializer = Deserializer.build_bytes_deserializer(buf) + + header_id = deserializer.read_bytes(1) + if verbose: + verbose('header_id', header_id) + assert header_id == VertexHeaderId.FEE_HEADER.value + + fees: list[FeeHeaderEntry] = [] + fees_len = deserializer.read_byte() + if verbose: + verbose('fees_len', fees_len) + for _ in range(fees_len): + token_index = deserializer.read_byte() + amount = decode_output_value(deserializer) + fees.append(FeeHeaderEntry( + token_index=token_index, + amount=amount, + )) + + from hathor.transaction import Transaction + assert isinstance(tx, Transaction) + remaining_bytes = bytes(deserializer.read_all()) + return cls( + settings=tx._settings, + tx=tx, + fees=fees, + ), remaining_bytes + + def serialize(self) -> bytes: + serializer = Serializer.build_bytes_serializer() + serializer.write_bytes(VertexHeaderId.FEE_HEADER.value) + serializer.write_bytes(int_to_bytes(len(self.fees), 1)) + + for fee in self.fees: + serializer.write_bytes(int_to_bytes(fee.token_index, 1)) + serializer.write_bytes(output_value_to_bytes(fee.amount)) + + return bytes(serializer.finalize()) + + def get_sighash_bytes(self) -> bytes: + return self.serialize() + + def get_fees(self) -> list[FeeEntry]: + return [ + FeeEntry( + token_uid=self.tx.get_token_uid(fee.token_index), + amount=fee.amount + ) + for fee in self.fees + ] + + 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: + total_fee += fee.amount + else: + total_fee += get_deposit_token_withdraw_amount(self._settings, fee.amount) + return total_fee diff --git a/hathor/transaction/headers/types.py b/hathor/transaction/headers/types.py index c12613ff1..7b45b8a8e 100644 --- a/hathor/transaction/headers/types.py +++ b/hathor/transaction/headers/types.py @@ -18,3 +18,4 @@ @unique class VertexHeaderId(Enum): NANO_HEADER = b'\x10' + FEE_HEADER = b'\x11' diff --git a/hathor/transaction/transaction.py b/hathor/transaction/transaction.py index eecf45749..813bdae6e 100644 --- a/hathor/transaction/transaction.py +++ b/hathor/transaction/transaction.py @@ -16,7 +16,7 @@ import hashlib from struct import pack -from typing import TYPE_CHECKING, Any, NamedTuple, Optional +from typing import TYPE_CHECKING, Any, NamedTuple, Optional, TypeVar from typing_extensions import Self, override @@ -26,12 +26,15 @@ from hathor.transaction import TxInput, TxOutput, TxVersion from hathor.transaction.base_transaction import TX_HASH_SIZE, GenericVertex from hathor.transaction.exceptions import InvalidToken -from hathor.transaction.headers import NanoHeader +from hathor.transaction.headers import NanoHeader, VertexBaseHeader +from hathor.transaction.headers.fee_header import FeeHeader from hathor.transaction.static_metadata import TransactionStaticMetadata from hathor.transaction.token_info import TokenInfo, TokenInfoDict, TokenVersion from hathor.transaction.util import VerboseCallback, unpack, unpack_len from hathor.types import TokenUid, VertexId +T = TypeVar('T', bound=VertexBaseHeader) + if TYPE_CHECKING: from hathor.conf.settings import HathorSettings from hathor.transaction.storage import TransactionStorage # noqa: F401 @@ -112,12 +115,29 @@ def is_nano_contract(self) -> bool: else: return True + def has_fees(self) -> bool: + """Returns true if this transaction has a fee header""" + try: + self.get_fee_header() + except ValueError: + return False + else: + return True + def get_nano_header(self) -> NanoHeader: """Return the NanoHeader or raise ValueError.""" + return self._get_header(NanoHeader) + + def get_fee_header(self) -> FeeHeader: + """Return the FeeHeader or raise ValueError.""" + return self._get_header(FeeHeader) + + def _get_header(self, header_type: type[T]) -> T: + """Return the header of the given type or raise ValueError.""" for header in self.headers: - if isinstance(header, NanoHeader): + if isinstance(header, header_type): return header - raise ValueError('nano header not found') + raise ValueError(f'{header_type.__name__.lower()} not found') @classmethod def create_from_struct(cls, struct_bytes: bytes, storage: Optional['TransactionStorage'] = None, diff --git a/hathor/verification/fee_header_verifier.py b/hathor/verification/fee_header_verifier.py new file mode 100644 index 000000000..f1ec2c367 --- /dev/null +++ b/hathor/verification/fee_header_verifier.py @@ -0,0 +1,58 @@ +# Copyright 2023 Hathor Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Sequence + +from hathor.transaction.exceptions import FeeHeaderTokenNotFound, InvalidFeeHeader +from hathor.transaction.headers import FeeHeader + +MAX_FEES_LEN: int = 16 + + +class FeeHeaderVerifier: + + @staticmethod + def verify_fee_list(fee_header: 'FeeHeader', tx_tokens_len: int) -> None: + """Perform FeeHeader verifications that do not depend on the tx storage.""" + fees = fee_header.fees + FeeHeaderVerifier._verify_fee_list_size('fees', len(fees)) + + # Check for duplicate token indices in fees + token_indices = [fee.token_index for fee in fees] + FeeHeaderVerifier._verify_duplicate_indexes('fees', token_indices) + + for fee in fees: + FeeHeaderVerifier._verify_token_index('fees', fee.token_index, tx_tokens_len) + + @staticmethod + def _verify_token_index(prop: str, token_index: int, tx_token_len: int) -> None: + if token_index > tx_token_len: + raise FeeHeaderTokenNotFound( + f'{prop} contains token index {token_index} which is not in tokens list' + ) + + @staticmethod + def _verify_duplicate_indexes(list_name: str, indexes: Sequence[int]) -> None: + if len(indexes) != len(set(indexes)): + raise InvalidFeeHeader(f'duplicate token indexes in {list_name} list') + + @staticmethod + def _verify_fee_list_size(prop: str, list_len: int) -> None: + if list_len == 0: + raise InvalidFeeHeader(f'{prop} cannot be empty') + + if list_len > MAX_FEES_LEN: + raise InvalidFeeHeader(f'more {prop} than the max allowed: {list_len} > {MAX_FEES_LEN}') diff --git a/tests/tx/test_fee_header.py b/tests/tx/test_fee_header.py new file mode 100644 index 000000000..f1f1ed075 --- /dev/null +++ b/tests/tx/test_fee_header.py @@ -0,0 +1,148 @@ +from typing import Any + +from hathor.transaction import Transaction +from hathor.transaction.headers.fee_header import FeeEntry, FeeHeader, FeeHeaderEntry +from hathor.types import TokenUid +from tests import unittest + + +class FeeHeaderTest(unittest.TestCase): + + def test_fee_header_round_trip(self) -> None: + """Test FeeHeader serialization and deserialization round-trip.""" + tx = Transaction() + tx.tokens = [TokenUid(b'test' * 8)] # Add a custom token at index 1 + + # Basic round trip serialization test + header_round_trip = FeeHeader( + settings=self._settings, + tx=tx, + fees=[ + FeeHeaderEntry(token_index=0, amount=100), # HTR paying + FeeHeaderEntry(token_index=1, amount=200), # Custom token paying + ], + ) + serialized = header_round_trip.serialize() + deserialized, remaining = FeeHeader.deserialize(tx, serialized) + assert len(remaining) == 0 + assert deserialized.fees == header_round_trip.fees + + # Verbose callback functionality test + verbose_calls: list[tuple[str, Any]] = [] + + def verbose_callback(name: str, value: Any) -> None: + verbose_calls.append((name, value)) + + header_verbose = FeeHeader( + settings=self._settings, + tx=tx, + fees=[FeeHeaderEntry(token_index=0, amount=300)], # HTR paying + ) + serialized_verbose = header_verbose.serialize() + deserialized_verbose, remaining = FeeHeader.deserialize(tx, serialized_verbose, verbose=verbose_callback) + + # Check that verbose callback was called for all expected values + assert len(verbose_calls) == 2 # header_id, fees_len + call_names = [call[0] for call in verbose_calls] + assert 'header_id' in call_names + assert 'fees_len' in call_names + + assert len(remaining) == 0 + assert deserialized_verbose.fees == header_verbose.fees + + # get_sighash_bytes functionality test + header_sighash = FeeHeader( + settings=self._settings, + tx=tx, + fees=[FeeHeaderEntry(token_index=0, amount=500)], # HTR paying + ) + sighash_bytes = header_sighash.get_sighash_bytes() + serialized_bytes = header_sighash.serialize() + + # get_sighash_bytes should return the same as serialize() + assert sighash_bytes == serialized_bytes + + def test_fee_header_get_fees(self) -> None: + """Test FeeHeader.get_fees() method that converts to FeeEntry objects.""" + tx = Transaction() + token1_uid = TokenUid(b'token1' + b'\x00' * 26) + tx.tokens = [token1_uid] + + # Test with HTR and custom token paying fees + header = FeeHeader( + settings=self._settings, + tx=tx, + fees=[ + FeeHeaderEntry(token_index=0, amount=100), # HTR + FeeHeaderEntry(token_index=1, amount=200), # token1 (must be multiple of 100) + ], + ) + fees = header.get_fees() + + assert len(fees) == 2 + assert fees[0] == FeeEntry(token_uid=tx.get_token_uid(0), amount=100) # HTR + assert fees[1] == FeeEntry(token_uid=token1_uid, amount=200) # token1 + + # Test with single fee + header_single = FeeHeader( + settings=self._settings, + tx=tx, + fees=[FeeHeaderEntry(token_index=0, amount=300)], # HTR only + ) + fees_single = header_single.get_fees() + assert len(fees_single) == 1 + assert fees_single[0] == FeeEntry(token_uid=tx.get_token_uid(0), amount=300) + + def test_fee_header_edge_cases(self) -> None: + """Test FeeHeader edge cases and comprehensive scenarios.""" + tx = Transaction() + + # Test with many tokens + many_tokens = [TokenUid(f'token{i}'.encode() + b'\x00' * (32 - len(f'token{i}'))) for i in range(5)] + tx.tokens = many_tokens + + # Test complex scenario with multiple fees + header_complex = FeeHeader( + settings=self._settings, + tx=tx, + fees=[ + FeeHeaderEntry(token_index=0, amount=100), # HTR + FeeHeaderEntry(token_index=2, amount=50), # token2 + FeeHeaderEntry(token_index=4, amount=25), # token4 + ], + ) + serialized_complex = header_complex.serialize() + deserialized_complex, remaining = FeeHeader.deserialize(tx, serialized_complex) + + assert len(remaining) == 0 + assert len(deserialized_complex.fees) == 3 + assert deserialized_complex.fees[0].token_index == 0 + assert deserialized_complex.fees[0].amount == 100 + assert deserialized_complex.fees[1].token_index == 2 + assert deserialized_complex.fees[1].amount == 50 + assert deserialized_complex.fees[2].token_index == 4 + assert deserialized_complex.fees[2].amount == 25 + + # Test max values + header_max = FeeHeader( + settings=self._settings, + tx=tx, + fees=[FeeHeaderEntry(token_index=0, amount=2 ** 63 - 1)], # Max amount + ) + serialized_max = header_max.serialize() + deserialized_max, remaining = FeeHeader.deserialize(tx, serialized_max) + assert len(remaining) == 0 + assert deserialized_max.fees[0].amount == 2 ** 63 - 1 + + # Test single fee + header_single = FeeHeader( + settings=self._settings, + tx=tx, + fees=[FeeHeaderEntry(token_index=1, amount=42)], # Single custom token fee + ) + serialized_single = header_single.serialize() + deserialized_single, remaining = FeeHeader.deserialize(tx, serialized_single) + assert len(remaining) == 0 + assert len(deserialized_single.fees) == 1 + assert deserialized_single.fees[0].token_index == 1 + assert deserialized_single.fees[0].amount == 42 diff --git a/tests/tx/test_headers.py b/tests/tx/test_nano_header.py similarity index 100% rename from tests/tx/test_headers.py rename to tests/tx/test_nano_header.py diff --git a/tests/verification/test_fee_header_verifier.py b/tests/verification/test_fee_header_verifier.py new file mode 100644 index 000000000..a55e094f8 --- /dev/null +++ b/tests/verification/test_fee_header_verifier.py @@ -0,0 +1,163 @@ +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.types import TokenUid +from hathor.verification.fee_header_verifier import MAX_FEES_LEN, FeeHeaderVerifier +from tests import unittest + + +class TestFeeHeaderVerifier(unittest.TestCase): + def setUp(self) -> None: + super().setUp() + + def _create_transaction_with_tokens(self, num_tokens: int) -> Transaction: + """Helper method to create a transaction with specified number of custom tokens. + + Args: + num_tokens: Number of custom tokens to add (excludes HTR which is always at index 0) + + Returns: + Transaction with the specified tokens + """ + tx = Transaction() + if num_tokens > 0: + tx.tokens = [TokenUid(f'token{i:02d}'.encode().ljust(32, b'\x00')) for i in range(num_tokens)] + return tx + + def _create_fee_header(self, tx: Transaction, fees: list[FeeHeaderEntry]) -> FeeHeader: + """Helper method to create FeeHeader with given fees. + + Args: + tx: Transaction to associate with the header + fees: List of fee entries + + Returns: + FeeHeader configured with the given fees + """ + return FeeHeader( + settings=self._settings, + tx=tx, + fees=fees + ) + + def test_verify_without_storage_valid_cases(self) -> None: + """Test valid scenarios for verify_without_storage.""" + # Single fee entry + tx = self._create_transaction_with_tokens(1) # Custom token at index 1 + 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 + + # 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=1, amount=200), # Custom token 1 + ] + header = self._create_fee_header(tx, fees) + + FeeHeaderVerifier.verify_fee_list(header, len(tx.tokens) + 1) + + def test_verify_fee_list_size_empty(self) -> None: + """Test that empty fees list raises InvalidFeeHeader.""" + tx = self._create_transaction_with_tokens(1) + header = self._create_fee_header(tx, []) + + with pytest.raises(InvalidFeeHeader, match="fees cannot be empty"): + FeeHeaderVerifier.verify_fee_list(header, len(tx.tokens) + 1) + + def test_verify_fee_list_size_exceeds_max(self) -> None: + """Test that fees list exceeding MAX_FEES_LEN raises InvalidFeeHeader.""" + tx = self._create_transaction_with_tokens(MAX_FEES_LEN + 1) + # Create MAX_FEES_LEN + 1 fees to exceed the limit + fees = [FeeHeaderEntry(token_index=i, amount=100) for i in range(MAX_FEES_LEN + 1)] + header = self._create_fee_header(tx, fees) + + 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) + + def test_verify_fee_list_size_at_max(self) -> None: + """Test that fees list exactly at MAX_FEES_LEN is valid.""" + tx = self._create_transaction_with_tokens(MAX_FEES_LEN) + # Create exactly MAX_FEES_LEN fees + fees = [FeeHeaderEntry(token_index=i, amount=100) for i in range(MAX_FEES_LEN)] + header = self._create_fee_header(tx, fees) + + # Should not raise any exception + FeeHeaderVerifier.verify_fee_list(header, len(tx.tokens) + 1) + + def test_duplicate_token_indexes_in_fees(self) -> None: + """Test that duplicate token indices in fees raise InvalidFeeHeader.""" + tx = self._create_transaction_with_tokens(1) + fees = [ + FeeHeaderEntry(token_index=0, amount=100), + FeeHeaderEntry(token_index=0, amount=200), # Duplicate HTR fee + ] + 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) + + def test_invalid_token_indexes_out_of_bounds(self) -> None: + """Test that token indices out of bounds raise FeeHeaderTokenNotFound.""" + tx = self._create_transaction_with_tokens(1) # Only custom token at index 1 + fees = [FeeHeaderEntry(token_index=5, amount=100)] # Index 5 doesn't exist + header = self._create_fee_header(tx, fees) + + 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) + + 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) + 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