diff --git a/hathor/builder/builder.py b/hathor/builder/builder.py index ec79c4af1..aa66d889c 100644 --- a/hathor/builder/builder.py +++ b/hathor/builder/builder.py @@ -530,8 +530,9 @@ def _get_or_create_bit_signaling_service(self) -> BitSignalingService: def _get_or_create_verification_service(self) -> VerificationService: if self._verification_service is None: + settings = self._get_or_create_settings() verifiers = self._get_or_create_vertex_verifiers() - self._verification_service = VerificationService(verifiers=verifiers) + self._verification_service = VerificationService(settings=settings, verifiers=verifiers) return self._verification_service diff --git a/hathor/builder/cli_builder.py b/hathor/builder/cli_builder.py index 2d7cf1372..d3806abd5 100644 --- a/hathor/builder/cli_builder.py +++ b/hathor/builder/cli_builder.py @@ -284,7 +284,7 @@ def create_manager(self, reactor: Reactor) -> HathorManager: daa=daa, feature_service=self.feature_service ) - verification_service = VerificationService(verifiers=vertex_verifiers) + verification_service = VerificationService(settings=settings, verifiers=vertex_verifiers) cpu_mining_service = CpuMiningService() diff --git a/hathor/builder/resources_builder.py b/hathor/builder/resources_builder.py index 0e89448ab..88c38ff98 100644 --- a/hathor/builder/resources_builder.py +++ b/hathor/builder/resources_builder.py @@ -266,9 +266,10 @@ def create_resources(self) -> server.Site: ws_factory.start() root.putChild(b'ws', WebSocketResource(ws_factory)) - # Mining websocket resource - mining_ws_factory = MiningWebsocketFactory(self.manager) - root.putChild(b'mining_ws', WebSocketResource(mining_ws_factory)) + if settings.CONSENSUS_ALGORITHM.is_pow(): + # Mining websocket resource + mining_ws_factory = MiningWebsocketFactory(self.manager) + root.putChild(b'mining_ws', WebSocketResource(mining_ws_factory)) ws_factory.subscribe(self.manager.pubsub) diff --git a/hathor/cli/mining.py b/hathor/cli/mining.py index a769ed3a0..bb8655a82 100644 --- a/hathor/cli/mining.py +++ b/hathor/cli/mining.py @@ -142,7 +142,7 @@ def execute(args: Namespace) -> None: settings = get_global_settings() daa = DifficultyAdjustmentAlgorithm(settings=settings) verifiers = VertexVerifiers.create_defaults(settings=settings, daa=daa, feature_service=Mock()) - verification_service = VerificationService(verifiers=verifiers) + verification_service = VerificationService(settings=settings, verifiers=verifiers) verification_service.verify_without_storage(block) except HathorError: print('[{}] ERROR: Block has not been pushed because it is not valid.'.format(datetime.datetime.now())) diff --git a/hathor/conf/get_settings.py b/hathor/conf/get_settings.py index 6bdbd88b6..fefd74c5c 100644 --- a/hathor/conf/get_settings.py +++ b/hathor/conf/get_settings.py @@ -12,14 +12,16 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import importlib import os -from typing import NamedTuple, Optional +from typing import TYPE_CHECKING, NamedTuple, Optional from structlog import get_logger -from hathor import conf -from hathor.conf.settings import HathorSettings as Settings +if TYPE_CHECKING: + from hathor.conf.settings import HathorSettings as Settings logger = get_logger() @@ -51,6 +53,7 @@ def HathorSettings() -> Settings: if settings_module_filepath is not None: return _load_settings_singleton(settings_module_filepath, is_yaml=False) + from hathor import conf settings_yaml_filepath = os.environ.get('HATHOR_CONFIG_YAML', conf.MAINNET_SETTINGS_FILEPATH) return _load_settings_singleton(settings_yaml_filepath, is_yaml=True) @@ -94,9 +97,11 @@ def _load_module_settings(module_path: str) -> Settings: ) settings_module = importlib.import_module(module_path) settings = getattr(settings_module, 'SETTINGS') + from hathor.conf.settings import HathorSettings as Settings assert isinstance(settings, Settings) return settings def _load_yaml_settings(filepath: str) -> Settings: + from hathor.conf.settings import HathorSettings as Settings return Settings.from_yaml(filepath=filepath) diff --git a/hathor/conf/settings.py b/hathor/conf/settings.py index 682279f6c..80cd86940 100644 --- a/hathor/conf/settings.py +++ b/hathor/conf/settings.py @@ -15,11 +15,12 @@ import os from math import log from pathlib import Path -from typing import NamedTuple, Optional, Union +from typing import Any, NamedTuple, Optional, Union import pydantic from hathor.checkpoint import Checkpoint +from hathor.consensus.consensus_settings import ConsensusSettings, PowSettings from hathor.feature_activation.settings import Settings as FeatureActivationSettings from hathor.utils import yaml from hathor.utils.named_tuple import validated_named_tuple_from_dict @@ -436,6 +437,9 @@ def GENESIS_TX2_TIMESTAMP(self) -> int: # List of enabled blueprints. BLUEPRINTS: dict[bytes, 'str'] = {} + # The consensus algorithm protocol settings. + CONSENSUS_ALGORITHM: ConsensusSettings = PowSettings() + @classmethod def from_yaml(cls, *, filepath: str) -> 'HathorSettings': """Takes a filepath to a yaml file and returns a validated HathorSettings instance.""" @@ -473,7 +477,7 @@ def _parse_blueprints(blueprints_raw: dict[str, str]) -> dict[bytes, str]: return blueprints -def _parse_hex_str(hex_str: Union[str, bytes]) -> bytes: +def parse_hex_str(hex_str: Union[str, bytes]) -> bytes: """Parse a raw hex string into bytes.""" if isinstance(hex_str, str): return bytes.fromhex(hex_str.lstrip('x')) @@ -484,6 +488,24 @@ def _parse_hex_str(hex_str: Union[str, bytes]) -> bytes: return hex_str +def _validate_consensus_algorithm(consensus_algorithm: ConsensusSettings, values: dict[str, Any]) -> ConsensusSettings: + """Validate that if Proof-of-Authority is enabled, block rewards must not be set.""" + if consensus_algorithm.is_pow(): + return consensus_algorithm + + assert consensus_algorithm.is_poa() + blocks_per_halving = values.get('BLOCKS_PER_HALVING') + initial_token_units_per_block = values.get('INITIAL_TOKEN_UNITS_PER_BLOCK') + minimum_token_units_per_block = values.get('MINIMUM_TOKEN_UNITS_PER_BLOCK') + assert initial_token_units_per_block is not None, 'INITIAL_TOKEN_UNITS_PER_BLOCK must be set' + assert minimum_token_units_per_block is not None, 'MINIMUM_TOKEN_UNITS_PER_BLOCK must be set' + + if blocks_per_halving is not None or initial_token_units_per_block != 0 or minimum_token_units_per_block != 0: + raise ValueError('PoA networks do not support block rewards') + + return consensus_algorithm + + _VALIDATORS = dict( _parse_hex_str=pydantic.validator( 'P2PKH_VERSION_BYTE', @@ -494,13 +516,13 @@ def _parse_hex_str(hex_str: Union[str, bytes]) -> bytes: 'GENESIS_TX2_HASH', pre=True, allow_reuse=True - )(_parse_hex_str), + )(parse_hex_str), _parse_soft_voided_tx_id=pydantic.validator( 'SOFT_VOIDED_TX_IDS', pre=True, allow_reuse=True, each_item=True - )(_parse_hex_str), + )(parse_hex_str), _parse_checkpoints=pydantic.validator( 'CHECKPOINTS', pre=True @@ -508,5 +530,8 @@ def _parse_hex_str(hex_str: Union[str, bytes]) -> bytes: _parse_blueprints=pydantic.validator( 'BLUEPRINTS', pre=True - )(_parse_blueprints) + )(_parse_blueprints), + _validate_consensus_algorithm=pydantic.validator( + 'CONSENSUS_ALGORITHM' + )(_validate_consensus_algorithm), ) diff --git a/hathor/consensus/consensus_settings.py b/hathor/consensus/consensus_settings.py new file mode 100644 index 000000000..2b84b7d4b --- /dev/null +++ b/hathor/consensus/consensus_settings.py @@ -0,0 +1,86 @@ +# Copyright 2024 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 abc import ABC, abstractmethod +from enum import Enum, unique +from typing import Annotated, Literal, TypeAlias + +from pydantic import Field, validator +from typing_extensions import override + +from hathor.transaction import TxVersion +from hathor.utils.pydantic import BaseModel + + +@unique +class ConsensusType(str, Enum): + PROOF_OF_WORK = 'PROOF_OF_WORK' + PROOF_OF_AUTHORITY = 'PROOF_OF_AUTHORITY' + + +class _BaseConsensusSettings(ABC, BaseModel): + type: ConsensusType + + def is_pow(self) -> bool: + """Return whether this is a Proof-of-Work consensus.""" + return self.type is ConsensusType.PROOF_OF_WORK + + def is_poa(self) -> bool: + """Return whether this is a Proof-of-Authority consensus.""" + return self.type is ConsensusType.PROOF_OF_AUTHORITY + + @abstractmethod + def _get_valid_vertex_versions(self) -> set[TxVersion]: + """Return a set of `TxVersion`s that are valid in for this consensus type.""" + raise NotImplementedError + + def is_vertex_version_valid(self, version: TxVersion) -> bool: + """Return whether a `TxVersion` is valid for this consensus type.""" + return version in self._get_valid_vertex_versions() + + +class PowSettings(_BaseConsensusSettings): + type: Literal[ConsensusType.PROOF_OF_WORK] = ConsensusType.PROOF_OF_WORK + + @override + def _get_valid_vertex_versions(self) -> set[TxVersion]: + return { + TxVersion.REGULAR_BLOCK, + TxVersion.REGULAR_TRANSACTION, + TxVersion.TOKEN_CREATION_TRANSACTION, + TxVersion.MERGE_MINED_BLOCK + } + + +class PoaSettings(_BaseConsensusSettings): + type: Literal[ConsensusType.PROOF_OF_AUTHORITY] = ConsensusType.PROOF_OF_AUTHORITY + + # A list of Proof-of-Authority signer public keys that have permission to produce blocks. + signers: tuple[bytes, ...] = () + + @validator('signers', each_item=True) + def parse_hex_str(cls, hex_str: str | bytes) -> bytes: + from hathor.conf.settings import parse_hex_str + return parse_hex_str(hex_str) + + @override + def _get_valid_vertex_versions(self) -> set[TxVersion]: + return { + TxVersion.POA_BLOCK, + TxVersion.REGULAR_TRANSACTION, + TxVersion.TOKEN_CREATION_TRANSACTION, + } + + +ConsensusSettings: TypeAlias = Annotated[PowSettings | PoaSettings, Field(discriminator='type')] diff --git a/hathor/consensus/poa/__init__.py b/hathor/consensus/poa/__init__.py new file mode 100644 index 000000000..54b67c954 --- /dev/null +++ b/hathor/consensus/poa/__init__.py @@ -0,0 +1,8 @@ +from .poa import BLOCK_WEIGHT_IN_TURN, BLOCK_WEIGHT_OUT_OF_TURN, SIGNER_ID_LEN, get_hashed_poa_data + +__all__ = [ + 'BLOCK_WEIGHT_IN_TURN', + 'BLOCK_WEIGHT_OUT_OF_TURN', + 'SIGNER_ID_LEN', + 'get_hashed_poa_data', +] diff --git a/hathor/consensus/poa/poa.py b/hathor/consensus/poa/poa.py new file mode 100644 index 000000000..7af28fba8 --- /dev/null +++ b/hathor/consensus/poa/poa.py @@ -0,0 +1,36 @@ +# Copyright 2024 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 + +import hashlib +from typing import TYPE_CHECKING + +from hathor.transaction import Block + +if TYPE_CHECKING: + from hathor.transaction.poa import PoaBlock + +BLOCK_WEIGHT_IN_TURN = 2.0 +BLOCK_WEIGHT_OUT_OF_TURN = 1.0 +SIGNER_ID_LEN = 2 + + +def get_hashed_poa_data(block: PoaBlock) -> bytes: + """Get the data to be signed for the Proof-of-Authority.""" + poa_data = block.get_funds_struct() + poa_data += Block.get_graph_struct(block) # We call Block's to exclude poa fields + poa_data += block.get_struct_nonce() + hashed_poa_data = hashlib.sha256(poa_data).digest() + return hashed_poa_data diff --git a/hathor/consensus/poa/poa_signer.py b/hathor/consensus/poa/poa_signer.py new file mode 100644 index 000000000..2fd9934b7 --- /dev/null +++ b/hathor/consensus/poa/poa_signer.py @@ -0,0 +1,108 @@ +# Copyright 2024 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 + +import hashlib +from typing import Any, NewType + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from pydantic import validator + +from hathor.consensus import poa +from hathor.crypto.util import ( + get_address_b58_from_public_key, + get_private_key_from_bytes, + get_public_key_bytes_compressed, +) +from hathor.transaction.poa import PoaBlock +from hathor.utils.pydantic import BaseModel + + +class PoaSignerFile(BaseModel, arbitrary_types_allowed=True): + """Class that represents a Proof-of-Authority signer configuration file.""" + private_key: ec.EllipticCurvePrivateKeyWithSerialization + public_key: ec.EllipticCurvePublicKey + address: str + + @validator('private_key', pre=True) + def _parse_private_key(cls, private_key_hex: str) -> ec.EllipticCurvePrivateKeyWithSerialization: + """Parse a private key hex into a private key instance.""" + private_key_bytes = bytes.fromhex(private_key_hex) + return get_private_key_from_bytes(private_key_bytes) + + @validator('public_key', pre=True) + def _validate_public_key_first_bytes( + cls, + public_key_hex: str, + values: dict[str, Any] + ) -> ec.EllipticCurvePublicKey: + """Parse a public key hex into a public key instance, and validate that it corresponds to the private key.""" + private_key = values.get('private_key') + assert isinstance(private_key, ec.EllipticCurvePrivateKey), 'private_key must be set' + + public_key_bytes = bytes.fromhex(public_key_hex) + actual_public_key = private_key.public_key() + + if public_key_bytes != get_public_key_bytes_compressed(actual_public_key): + raise ValueError('invalid public key') + + return actual_public_key + + @validator('address') + def _validate_address(cls, address: str, values: dict[str, Any]) -> str: + """Validate that the provided address corresponds to the provided private key.""" + private_key = values.get('private_key') + assert isinstance(private_key, ec.EllipticCurvePrivateKey), 'private_key must be set' + + if address != get_address_b58_from_public_key(private_key.public_key()): + raise ValueError('invalid address') + + return address + + def get_signer(self) -> PoaSigner: + """Get a PoaSigner for this file.""" + return PoaSigner(self.private_key) + + +""" +The `PoaSignerId` is the first 2 bytes of the hashed public key of a signer(see `PoaSigner.get_poa_signer_id()`). +It is a non-unique ID that represents a signer and exists simply to skip unnecessary signature verifications during the +verification process of PoA blocks. +""" +PoaSignerId = NewType('PoaSignerId', bytes) + + +class PoaSigner: + """Class that represents a Proof-of-Authority signer.""" + __slots__ = ('_private_key', '_signer_id') + + def __init__(self, private_key: ec.EllipticCurvePrivateKey) -> None: + self._private_key = private_key + public_key_bytes = get_public_key_bytes_compressed(private_key.public_key()) + self._signer_id = self.get_poa_signer_id(public_key_bytes) + + def sign_block(self, block: PoaBlock) -> None: + """Sign the Proof-of-Authority for a block.""" + hashed_poa_data = poa.get_hashed_poa_data(block) + signature = self._private_key.sign(hashed_poa_data, ec.ECDSA(hashes.SHA256())) + block.signer_id = self._signer_id + block.signature = signature + + @staticmethod + def get_poa_signer_id(compressed_public_key_bytes: bytes) -> PoaSignerId: + """Get the PoaSignerId from the compressed public key bytes.""" + hashed_public_key = hashlib.sha256(compressed_public_key_bytes).digest() + return PoaSignerId(hashed_public_key[:poa.SIGNER_ID_LEN]) diff --git a/hathor/transaction/base_transaction.py b/hathor/transaction/base_transaction.py index 56898d6f9..c82407052 100644 --- a/hathor/transaction/base_transaction.py +++ b/hathor/transaction/base_transaction.py @@ -86,6 +86,7 @@ class TxVersion(IntEnum): REGULAR_TRANSACTION = 1 TOKEN_CREATION_TRANSACTION = 2 MERGE_MINED_BLOCK = 3 + POA_BLOCK = 5 @classmethod def _missing_(cls, value: Any) -> None: @@ -97,6 +98,7 @@ def _missing_(cls, value: Any) -> None: def get_cls(self) -> type['BaseTransaction']: from hathor.transaction.block import Block from hathor.transaction.merge_mined_block import MergeMinedBlock + from hathor.transaction.poa import PoaBlock from hathor.transaction.token_creation_tx import TokenCreationTransaction from hathor.transaction.transaction import Transaction @@ -105,6 +107,7 @@ def get_cls(self) -> type['BaseTransaction']: TxVersion.REGULAR_TRANSACTION: Transaction, TxVersion.TOKEN_CREATION_TRANSACTION: TokenCreationTransaction, TxVersion.MERGE_MINED_BLOCK: MergeMinedBlock, + TxVersion.POA_BLOCK: PoaBlock } cls = cls_map.get(self) @@ -1137,9 +1140,12 @@ def tx_or_block_from_bytes(data: bytes, """ Creates the correct tx subclass from a sequence of bytes """ # version field takes up the second byte only + settings = get_global_settings() # TODO: Remove this from here and receive by argument. version = data[1] try: tx_version = TxVersion(version) + if not settings.CONSENSUS_ALGORITHM.is_vertex_version_valid(tx_version): + raise StructError(f"invalid vertex version: {tx_version}") cls = tx_version.get_cls() return cls.create_from_struct(data, storage=storage) except ValueError: diff --git a/hathor/transaction/block.py b/hathor/transaction/block.py index bc6c159c1..82dd0fb6b 100644 --- a/hathor/transaction/block.py +++ b/hathor/transaction/block.py @@ -18,6 +18,8 @@ from struct import pack from typing import TYPE_CHECKING, Any, Iterator, Optional +from typing_extensions import Self + from hathor.checkpoint import Checkpoint from hathor.feature_activation.feature import Feature from hathor.feature_activation.model.feature_state import FeatureState @@ -73,7 +75,7 @@ def is_transaction(self) -> bool: @classmethod def create_from_struct(cls, struct_bytes: bytes, storage: Optional['TransactionStorage'] = None, - *, verbose: VerboseCallback = None) -> 'Block': + *, verbose: VerboseCallback = None) -> Self: blc = cls() buf = blc.get_fields_from_struct(struct_bytes, verbose=verbose) diff --git a/hathor/transaction/exceptions.py b/hathor/transaction/exceptions.py index 25e61596c..2d1bfbda8 100644 --- a/hathor/transaction/exceptions.py +++ b/hathor/transaction/exceptions.py @@ -110,6 +110,10 @@ class WeightError(TxValidationError): """Transaction not using correct weight""" +class PoaValidationError(TxValidationError): + """Block using invalid PoA signature""" + + class InvalidBlockReward(TxValidationError): """Wrong amount of issued tokens""" @@ -134,6 +138,10 @@ class RewardLocked(TxValidationError): """Block reward cannot be spent yet, needs more confirmations""" +class InvalidVersionError(TxValidationError): + """Vertex version is invalid.""" + + class BlockWithInputs(BlockError): """Block has inputs""" diff --git a/hathor/transaction/poa/__init__.py b/hathor/transaction/poa/__init__.py new file mode 100644 index 000000000..0719c5324 --- /dev/null +++ b/hathor/transaction/poa/__init__.py @@ -0,0 +1,19 @@ +# Copyright 2024 Hathor Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from hathor.transaction.poa.poa_block import PoaBlock + +__all__ = [ + 'PoaBlock', +] diff --git a/hathor/transaction/poa/poa_block.py b/hathor/transaction/poa/poa_block.py new file mode 100644 index 000000000..e9f2c3aaa --- /dev/null +++ b/hathor/transaction/poa/poa_block.py @@ -0,0 +1,78 @@ +# Copyright 2024 Hathor Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from hathor.consensus import poa +from hathor.transaction import Block, TxVersion +from hathor.transaction.storage import TransactionStorage +from hathor.transaction.util import VerboseCallback, int_to_bytes, unpack, unpack_len + +# Size limit in bytes for signature field +_MAX_POA_SIGNATURE_LEN: int = 100 + + +class PoaBlock(Block): + """A Proof-of-Authority block.""" + + def __init__( + self, + timestamp: int | None = None, + signal_bits: int = 0, + weight: float = 0, + parents: list[bytes] | None = None, + hash: bytes | None = None, + data: bytes = b'', + storage: TransactionStorage | None = None, + signer_id: bytes = b'', + signature: bytes = b'', + ) -> None: + super().__init__( + nonce=0, + timestamp=timestamp, + signal_bits=signal_bits, + version=TxVersion.POA_BLOCK, + weight=weight, + outputs=[], + parents=parents or [], + hash=hash, + data=data, + storage=storage + ) + self.signer_id = signer_id + self.signature = signature + + def get_graph_fields_from_struct(self, buf: bytes, *, verbose: VerboseCallback = None) -> bytes: + buf = super().get_graph_fields_from_struct(buf, verbose=verbose) + + self.signer_id, buf = unpack_len(poa.SIGNER_ID_LEN, buf) + if verbose: + verbose('signer_id', self.signer_id.hex()) + + (signature_len,), buf = unpack('!B', buf) + if verbose: + verbose('signature_len', signature_len) + + if signature_len > _MAX_POA_SIGNATURE_LEN: + raise ValueError(f'invalid signature length: {signature_len}') + + self.signature, buf = unpack_len(signature_len, buf) + if verbose: + verbose('signature', self.signature.hex()) + + return buf + + def get_graph_struct(self) -> bytes: + assert len(self.signer_id) == poa.SIGNER_ID_LEN + struct_bytes_without_poa = super().get_graph_struct() + signature_len = int_to_bytes(len(self.signature), 1) + return struct_bytes_without_poa + self.signer_id + signature_len + self.signature diff --git a/hathor/verification/block_verifier.py b/hathor/verification/block_verifier.py index 2110bbd91..2935e24b4 100644 --- a/hathor/verification/block_verifier.py +++ b/hathor/verification/block_verifier.py @@ -51,6 +51,7 @@ def verify_height(self, block: Block) -> None: def verify_weight(self, block: Block) -> None: """Validate minimum block difficulty.""" + assert self._settings.CONSENSUS_ALGORITHM.is_pow() assert block.storage is not None min_block_weight = self._daa.calculate_block_difficulty(block, block.storage.get_parent_block) if block.weight < min_block_weight - self._settings.WEIGHT_TOL: diff --git a/hathor/verification/poa_block_verifier.py b/hathor/verification/poa_block_verifier.py new file mode 100644 index 000000000..30756269b --- /dev/null +++ b/hathor/verification/poa_block_verifier.py @@ -0,0 +1,74 @@ +# Copyright 2024 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 cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec + +from hathor.conf.settings import HathorSettings +from hathor.consensus import poa +from hathor.consensus.consensus_settings import PoaSettings +from hathor.consensus.poa.poa_signer import PoaSigner +from hathor.crypto.util import get_public_key_from_bytes_compressed +from hathor.transaction.exceptions import PoaValidationError +from hathor.transaction.poa import PoaBlock + + +class PoaBlockVerifier: + __slots__ = ('_settings',) + + def __init__(self, *, settings: HathorSettings): + self._settings = settings + + def verify_poa(self, block: PoaBlock) -> None: + """Validate the Proof-of-Authority.""" + assert isinstance(self._settings.CONSENSUS_ALGORITHM, PoaSettings) + + # validate block rewards + if block.outputs: + raise PoaValidationError('blocks must not have rewards in a PoA network') + + # validate that the signature is valid + sorted_signers = sorted(self._settings.CONSENSUS_ALGORITHM.signers) + signer_index: int | None = None + + for i, public_key_bytes in enumerate(sorted_signers): + if self._verify_poa_signature(block, public_key_bytes): + signer_index = i + break + + if signer_index is None: + raise PoaValidationError('invalid PoA signature') + + # validate block weight is in turn + is_in_turn = block.get_height() % len(sorted_signers) == signer_index + expected_weight = poa.BLOCK_WEIGHT_IN_TURN if is_in_turn else poa.BLOCK_WEIGHT_OUT_OF_TURN + + if block.weight != expected_weight: + raise PoaValidationError(f'block weight is {block.weight}, expected {expected_weight}') + + @staticmethod + def _verify_poa_signature(block: PoaBlock, public_key_bytes: bytes) -> bool: + """Return whether the provided public key was used to sign the block Proof-of-Authority.""" + signer_id = PoaSigner.get_poa_signer_id(public_key_bytes) + if block.signer_id != signer_id: + return False + + public_key = get_public_key_from_bytes_compressed(public_key_bytes) + hashed_poa_data = poa.get_hashed_poa_data(block) + try: + public_key.verify(block.signature, hashed_poa_data, ec.ECDSA(hashes.SHA256())) + except InvalidSignature: + return False + return True diff --git a/hathor/verification/transaction_verifier.py b/hathor/verification/transaction_verifier.py index f55e0239c..af3ac35c2 100644 --- a/hathor/verification/transaction_verifier.py +++ b/hathor/verification/transaction_verifier.py @@ -63,6 +63,7 @@ def verify_parents_basic(self, tx: Transaction) -> None: def verify_weight(self, tx: Transaction) -> None: """Validate minimum tx difficulty.""" + assert self._settings.CONSENSUS_ALGORITHM.is_pow() min_tx_weight = self._daa.minimum_tx_weight(tx) max_tx_weight = min_tx_weight + self._settings.MAX_TX_WEIGHT_DIFF if tx.weight < min_tx_weight - self._settings.WEIGHT_TOL: diff --git a/hathor/verification/verification_service.py b/hathor/verification/verification_service.py index efa18c6f6..3f75c0b9b 100644 --- a/hathor/verification/verification_service.py +++ b/hathor/verification/verification_service.py @@ -14,8 +14,10 @@ from typing_extensions import assert_never +from hathor.conf.settings import HathorSettings from hathor.profiler import get_cpu_profiler from hathor.transaction import BaseTransaction, Block, MergeMinedBlock, Transaction, TxVersion +from hathor.transaction.poa import PoaBlock from hathor.transaction.token_creation_tx import TokenCreationTransaction from hathor.transaction.transaction import TokenInfo from hathor.transaction.validation_state import ValidationState @@ -26,9 +28,10 @@ class VerificationService: - __slots__ = ('verifiers', ) + __slots__ = ('_settings', 'verifiers') - def __init__(self, *, verifiers: VertexVerifiers) -> None: + def __init__(self, *, settings: HathorSettings, verifiers: VertexVerifiers) -> None: + self._settings = settings self.verifiers = verifiers def validate_basic(self, vertex: BaseTransaction, *, skip_block_weight_verification: bool = False) -> bool: @@ -82,6 +85,8 @@ def verify_basic(self, vertex: BaseTransaction, *, skip_block_weight_verificatio """Basic verifications (the ones without access to dependencies: parents+inputs). Raises on error. Used by `self.validate_basic`. Should not modify the validation state.""" + self.verifiers.vertex.verify_version(vertex) + # We assert with type() instead of isinstance() because each subclass has a specific branch. match vertex.version: case TxVersion.REGULAR_BLOCK: @@ -90,6 +95,9 @@ def verify_basic(self, vertex: BaseTransaction, *, skip_block_weight_verificatio case TxVersion.MERGE_MINED_BLOCK: assert type(vertex) is MergeMinedBlock self._verify_basic_merge_mined_block(vertex, skip_weight_verification=skip_block_weight_verification) + case TxVersion.POA_BLOCK: + assert type(vertex) is PoaBlock + self._verify_basic_poa_block(vertex) case TxVersion.REGULAR_TRANSACTION: assert type(vertex) is Transaction self._verify_basic_tx(vertex) @@ -108,13 +116,18 @@ def _verify_basic_block(self, block: Block, *, skip_weight_verification: bool) - def _verify_basic_merge_mined_block(self, block: MergeMinedBlock, *, skip_weight_verification: bool) -> None: self._verify_basic_block(block, skip_weight_verification=skip_weight_verification) + def _verify_basic_poa_block(self, block: PoaBlock) -> None: + self.verifiers.poa_block.verify_poa(block) + self.verifiers.block.verify_reward(block) + def _verify_basic_tx(self, tx: Transaction) -> None: """Partially run validations, the ones that need parents/inputs are skipped.""" if tx.is_genesis: # TODO do genesis validation? return self.verifiers.tx.verify_parents_basic(tx) - self.verifiers.tx.verify_weight(tx) + if self._settings.CONSENSUS_ALGORITHM.is_pow(): + self.verifiers.tx.verify_weight(tx) self.verify_without_storage(tx) def _verify_basic_token_creation_tx(self, tx: TokenCreationTransaction) -> None: @@ -132,6 +145,9 @@ def verify(self, vertex: BaseTransaction, *, reject_locked_reward: bool = True) case TxVersion.MERGE_MINED_BLOCK: assert type(vertex) is MergeMinedBlock self._verify_merge_mined_block(vertex) + case TxVersion.POA_BLOCK: + assert type(vertex) is PoaBlock + self._verify_poa_block(vertex) case TxVersion.REGULAR_TRANSACTION: assert type(vertex) is Transaction self._verify_tx(vertex, reject_locked_reward=reject_locked_reward) @@ -168,6 +184,9 @@ def _verify_block(self, block: Block) -> None: def _verify_merge_mined_block(self, block: MergeMinedBlock) -> None: self._verify_block(block) + def _verify_poa_block(self, block: PoaBlock) -> None: + self._verify_block(block) + @cpu.profiler(key=lambda _, tx: 'tx-verify!{}'.format(tx.hash.hex())) def _verify_tx( self, @@ -217,6 +236,9 @@ def verify_without_storage(self, vertex: BaseTransaction) -> None: case TxVersion.MERGE_MINED_BLOCK: assert type(vertex) is MergeMinedBlock self._verify_without_storage_merge_mined_block(vertex) + case TxVersion.POA_BLOCK: + assert type(vertex) is PoaBlock + self._verify_without_storage_poa_block(vertex) case TxVersion.REGULAR_TRANSACTION: assert type(vertex) is Transaction self._verify_without_storage_tx(vertex) @@ -226,24 +248,31 @@ def verify_without_storage(self, vertex: BaseTransaction) -> None: case _: assert_never(vertex.version) - def _verify_without_storage_block(self, block: Block) -> None: - """ Run all verifications that do not need a storage. - """ - self.verifiers.vertex.verify_pow(block) + def _verify_without_storage_base_block(self, block: Block) -> None: self.verifiers.block.verify_no_inputs(block) self.verifiers.vertex.verify_outputs(block) self.verifiers.block.verify_output_token_indexes(block) self.verifiers.block.verify_data(block) self.verifiers.vertex.verify_sigops_output(block) + def _verify_without_storage_block(self, block: Block) -> None: + """ Run all verifications that do not need a storage. + """ + self.verifiers.vertex.verify_pow(block) + self._verify_without_storage_base_block(block) + def _verify_without_storage_merge_mined_block(self, block: MergeMinedBlock) -> None: self.verifiers.merge_mined_block.verify_aux_pow(block) self._verify_without_storage_block(block) + def _verify_without_storage_poa_block(self, block: PoaBlock) -> None: + self._verify_without_storage_base_block(block) + def _verify_without_storage_tx(self, tx: Transaction) -> None: """ Run all verifications that do not need a storage. """ - self.verifiers.vertex.verify_pow(tx) + if self._settings.CONSENSUS_ALGORITHM.is_pow(): + self.verifiers.vertex.verify_pow(tx) self.verifiers.tx.verify_number_of_inputs(tx) self.verifiers.vertex.verify_outputs(tx) self.verifiers.tx.verify_output_token_indexes(tx) diff --git a/hathor/verification/vertex_verifier.py b/hathor/verification/vertex_verifier.py index d3ef72046..0e4282410 100644 --- a/hathor/verification/vertex_verifier.py +++ b/hathor/verification/vertex_verifier.py @@ -22,6 +22,7 @@ InvalidOutputScriptSize, InvalidOutputValue, InvalidToken, + InvalidVersionError, ParentDoesNotExist, PowError, TimestampError, @@ -44,6 +45,11 @@ class VertexVerifier: def __init__(self, *, settings: HathorSettings) -> None: self._settings = settings + def verify_version(self, vertex: BaseTransaction) -> None: + """Verify that the vertex version is valid.""" + if not self._settings.CONSENSUS_ALGORITHM.is_vertex_version_valid(vertex.version): + raise InvalidVersionError(f"invalid vertex version: {vertex.version}") + def verify_parents(self, vertex: BaseTransaction) -> None: """All parents must exist and their timestamps must be smaller than ours. @@ -126,6 +132,7 @@ def verify_pow(self, vertex: BaseTransaction, *, override_weight: Optional[float :raises PowError: when the hash is equal or greater than the target """ + assert self._settings.CONSENSUS_ALGORITHM.is_pow() numeric_hash = int(vertex.hash_hex, vertex.HEX_BASE) minimum_target = vertex.get_target(override_weight) if numeric_hash >= minimum_target: diff --git a/hathor/verification/vertex_verifiers.py b/hathor/verification/vertex_verifiers.py index 31e3fe190..1a9b56b21 100644 --- a/hathor/verification/vertex_verifiers.py +++ b/hathor/verification/vertex_verifiers.py @@ -19,6 +19,7 @@ from hathor.feature_activation.feature_service import FeatureService from hathor.verification.block_verifier import BlockVerifier from hathor.verification.merge_mined_block_verifier import MergeMinedBlockVerifier +from hathor.verification.poa_block_verifier import PoaBlockVerifier from hathor.verification.token_creation_transaction_verifier import TokenCreationTransactionVerifier from hathor.verification.transaction_verifier import TransactionVerifier from hathor.verification.vertex_verifier import VertexVerifier @@ -29,6 +30,7 @@ class VertexVerifiers(NamedTuple): vertex: VertexVerifier block: BlockVerifier merge_mined_block: MergeMinedBlockVerifier + poa_block: PoaBlockVerifier tx: TransactionVerifier token_creation_tx: TokenCreationTransactionVerifier @@ -67,6 +69,7 @@ def create( """ block_verifier = BlockVerifier(settings=settings, daa=daa, feature_service=feature_service) merge_mined_block_verifier = MergeMinedBlockVerifier(settings=settings, feature_service=feature_service) + poa_block_verifier = PoaBlockVerifier(settings=settings) tx_verifier = TransactionVerifier(settings=settings, daa=daa) token_creation_tx_verifier = TokenCreationTransactionVerifier(settings=settings) @@ -74,6 +77,7 @@ def create( vertex=vertex_verifier, block=block_verifier, merge_mined_block=merge_mined_block_verifier, + poa_block=poa_block_verifier, tx=tx_verifier, token_creation_tx=token_creation_tx_verifier, ) diff --git a/tests/others/test_hathor_settings.py b/tests/others/test_hathor_settings.py index 3994e2a42..7c9b59310 100644 --- a/tests/others/test_hathor_settings.py +++ b/tests/others/test_hathor_settings.py @@ -13,6 +13,8 @@ # limitations under the License. from pathlib import Path +from typing import Any +from unittest.mock import Mock, patch import pytest from pydantic import ValidationError @@ -29,6 +31,7 @@ from hathor.conf.settings import HathorSettings from hathor.conf.testnet import SETTINGS as TESTNET_SETTINGS from hathor.conf.unittests import SETTINGS as UNITTESTS_SETTINGS +from hathor.consensus.consensus_settings import PoaSettings, PowSettings @pytest.mark.parametrize('filepath', ['fixtures/valid_hathor_settings_fixture.yml']) @@ -107,6 +110,67 @@ def test_missing_hathor_settings_from_yaml(filepath): assert "missing 1 required positional argument: 'NETWORK_NAME'" 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') + + 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 passes when PoA is disabled with default settings + mock_settings(dict(CONSENSUS_ALGORITHM=PowSettings())) + HathorSettings.from_yaml(filepath='some_path') + + # Test fails when PoA is enabled with default settings + mock_settings(dict(CONSENSUS_ALGORITHM=PoaSettings(signers=(b'some_signer',)))) + with pytest.raises(ValidationError) as e: + HathorSettings.from_yaml(filepath='some_path') + assert 'PoA networks do not support block rewards' in str(e.value) + + # Test passes when PoA is enabled without block rewards + mock_settings(dict( + BLOCKS_PER_HALVING=None, + INITIAL_TOKEN_UNITS_PER_BLOCK=0, + MINIMUM_TOKEN_UNITS_PER_BLOCK=0, + CONSENSUS_ALGORITHM=PoaSettings(signers=(b'some_signer',)), + )) + HathorSettings.from_yaml(filepath='some_path') + + # Test fails when PoA is enabled with BLOCKS_PER_HALVING + mock_settings(dict( + BLOCKS_PER_HALVING=123, + INITIAL_TOKEN_UNITS_PER_BLOCK=0, + MINIMUM_TOKEN_UNITS_PER_BLOCK=0, + CONSENSUS_ALGORITHM=PoaSettings(signers=(b'some_signer',)), + )) + with pytest.raises(ValidationError) as e: + HathorSettings.from_yaml(filepath='some_path') + assert 'PoA networks do not support block rewards' in str(e.value) + + # Test fails when PoA is enabled with INITIAL_TOKEN_UNITS_PER_BLOCK + mock_settings(dict( + BLOCKS_PER_HALVING=None, + INITIAL_TOKEN_UNITS_PER_BLOCK=123, + MINIMUM_TOKEN_UNITS_PER_BLOCK=0, + CONSENSUS_ALGORITHM=PoaSettings(signers=(b'some_signer',)), + )) + with pytest.raises(ValidationError) as e: + HathorSettings.from_yaml(filepath='some_path') + assert 'PoA networks do not support block rewards' in str(e.value) + + # Test fails when PoA is enabled with MINIMUM_TOKEN_UNITS_PER_BLOCK + mock_settings(dict( + BLOCKS_PER_HALVING=None, + INITIAL_TOKEN_UNITS_PER_BLOCK=0, + MINIMUM_TOKEN_UNITS_PER_BLOCK=123, + CONSENSUS_ALGORITHM=PoaSettings(signers=(b'some_signer',)), + )) + with pytest.raises(ValidationError) as e: + HathorSettings.from_yaml(filepath='some_path') + assert 'PoA networks do not support block rewards' in str(e.value) + + # TODO: Tests below are temporary while settings via python coexist with settings via yaml, just to make sure # the conversion was made correctly. After python settings are removed, this file can be removed too. diff --git a/tests/tx/test_block.py b/tests/tx/test_block.py index c5f698965..a088c0c36 100644 --- a/tests/tx/test_block.py +++ b/tests/tx/test_block.py @@ -15,15 +15,22 @@ from unittest.mock import Mock import pytest +from cryptography.hazmat.primitives.asymmetric import ec from hathor.conf.get_settings import get_global_settings from hathor.conf.settings import HathorSettings +from hathor.consensus import poa +from hathor.consensus.consensus_settings import PoaSettings +from hathor.consensus.poa.poa_signer import PoaSigner, PoaSignerFile +from hathor.crypto.util import get_address_b58_from_public_key, get_private_key_bytes, get_public_key_bytes_compressed from hathor.feature_activation.feature import Feature from hathor.feature_activation.feature_service import BlockIsMissingSignal, BlockIsSignaling, FeatureService -from hathor.transaction import Block, TransactionMetadata -from hathor.transaction.exceptions import BlockMustSignalError +from hathor.transaction import Block, TransactionMetadata, TxOutput +from hathor.transaction.exceptions import BlockMustSignalError, PoaValidationError +from hathor.transaction.poa import PoaBlock from hathor.transaction.storage import TransactionMemoryStorage, TransactionStorage from hathor.verification.block_verifier import BlockVerifier +from hathor.verification.poa_block_verifier import PoaBlockVerifier def test_calculate_feature_activation_bit_counts_genesis(): @@ -161,3 +168,175 @@ def test_verify_must_not_signal() -> None: block = Block() verifier.verify_mandatory_signaling(block) + + +def test_get_hashed_poa_data() -> None: + block = PoaBlock( + timestamp=123, + signal_bits=0b1010, + weight=2, + parents=[b'\xFF' * 32, b'\xFF' * 32], + data=b'some data', + signer_id=b'\xAB\xCD', + signature=b'some signature' + ) + + def clone_block() -> PoaBlock: + return PoaBlock.create_from_struct(block.get_struct()) + + # Test that each field changes the PoA data + test_block = clone_block() + test_block.nonce += 1 + assert poa.get_hashed_poa_data(test_block) != poa.get_hashed_poa_data(block) + + test_block = clone_block() + test_block.timestamp += 1 + assert poa.get_hashed_poa_data(test_block) != poa.get_hashed_poa_data(block) + + test_block = clone_block() + test_block.signal_bits += 1 + assert poa.get_hashed_poa_data(test_block) != poa.get_hashed_poa_data(block) + + test_block = clone_block() + test_block.weight += 1 + assert poa.get_hashed_poa_data(test_block) != poa.get_hashed_poa_data(block) + + test_block = clone_block() + test_block.parents.pop() + assert poa.get_hashed_poa_data(test_block) != poa.get_hashed_poa_data(block) + + test_block = clone_block() + test_block.data = b'some other data' + assert poa.get_hashed_poa_data(test_block) != poa.get_hashed_poa_data(block) + + # Test that changing PoA fields do not change PoA data + test_block = clone_block() + test_block.signer_id = b'\x00\xFF' + assert poa.get_hashed_poa_data(test_block) == poa.get_hashed_poa_data(block) + + test_block = clone_block() + test_block.signature = b'some other signature' + assert poa.get_hashed_poa_data(test_block) == poa.get_hashed_poa_data(block) + + +def test_verify_poa() -> None: + def get_signer() -> tuple[PoaSigner, bytes]: + private_key = ec.generate_private_key(ec.SECP256K1()) + private_key_bytes = get_private_key_bytes(private_key) # type: ignore[arg-type] + public_key = private_key.public_key() + public_key_bytes = get_public_key_bytes_compressed(public_key) + address = get_address_b58_from_public_key(public_key) + file = PoaSignerFile.parse_obj(dict( + private_key=private_key_bytes.hex(), + public_key=public_key_bytes.hex(), + address=address + )) + return file.get_signer(), public_key_bytes + + poa_signer, public_key_bytes = get_signer() + settings = Mock(spec_set=HathorSettings) + settings.CONSENSUS_ALGORITHM = PoaSettings() + block_verifier = PoaBlockVerifier(settings=settings) + block = PoaBlock( + timestamp=123, + signal_bits=0b1010, + weight=poa.BLOCK_WEIGHT_IN_TURN, + parents=[b'parent1', b'parent2'], + ) + block._metadata = Mock() + block._metadata.height = 2 + + # Test no rewards + block.outputs = [TxOutput(123, b'')] + with pytest.raises(PoaValidationError) as e: + block_verifier.verify_poa(block) + assert str(e.value) == 'blocks must not have rewards in a PoA network' + block.outputs = [] + + # Test no signers + settings.CONSENSUS_ALGORITHM = PoaSettings(signers=()) + with pytest.raises(PoaValidationError) as e: + block_verifier.verify_poa(block) + assert str(e.value) == 'invalid PoA signature' + + # Test no data + settings.CONSENSUS_ALGORITHM = PoaSettings(signers=(public_key_bytes,)) + with pytest.raises(PoaValidationError) as e: + block_verifier.verify_poa(block) + assert str(e.value) == 'invalid PoA signature' + + # Test invalid data + block.data = b'some_data' + with pytest.raises(PoaValidationError) as e: + block_verifier.verify_poa(block) + assert str(e.value) == 'invalid PoA signature' + + # Test incorrect private key + PoaSigner(ec.generate_private_key(ec.SECP256K1())).sign_block(block) + with pytest.raises(PoaValidationError) as e: + block_verifier.verify_poa(block) + assert str(e.value) == 'invalid PoA signature' + + # Test valid signature + poa_signer.sign_block(block) + block_verifier.verify_poa(block) + + # Test some random weight fails + block.weight = 123 + poa_signer.sign_block(block) + with pytest.raises(PoaValidationError) as e: + block_verifier.verify_poa(block) + assert str(e.value) == 'block weight is 123, expected 2.0' + + # For this part we use two signers, so the ordering matters + signer_and_keys: list[tuple[PoaSigner, bytes]] = [get_signer(), get_signer()] + sorted_keys = sorted(signer_and_keys, key=lambda key_pair: key_pair[1]) # sort by public key + settings.CONSENSUS_ALGORITHM = PoaSettings(signers=tuple([key_pair[1] for key_pair in signer_and_keys])) + first_poa_signer, second_poa_signer = [key_pair[0] for key_pair in sorted_keys] + + # Test valid signature with two signers, in turn + block.weight = poa.BLOCK_WEIGHT_IN_TURN + first_poa_signer.sign_block(block) + block_verifier.verify_poa(block) + + # And the other signature fails for the weight + second_poa_signer.sign_block(block) + with pytest.raises(PoaValidationError) as e: + block_verifier.verify_poa(block) + assert str(e.value) == 'block weight is 2.0, expected 1.0' + + # Test valid signature with two signers, out of turn + block.weight = poa.BLOCK_WEIGHT_OUT_OF_TURN + second_poa_signer.sign_block(block) + block_verifier.verify_poa(block) + + # And the other signature fails for the weight + first_poa_signer.sign_block(block) + with pytest.raises(PoaValidationError) as e: + block_verifier.verify_poa(block) + assert str(e.value) == 'block weight is 1.0, expected 2.0' + + # When we increment the height, the turn inverts + block._metadata.height += 1 + + # Test valid signature with two signers, in turn + block.weight = poa.BLOCK_WEIGHT_IN_TURN + second_poa_signer.sign_block(block) + block_verifier.verify_poa(block) + + # And the other signature fails for the weight + first_poa_signer.sign_block(block) + with pytest.raises(PoaValidationError) as e: + block_verifier.verify_poa(block) + assert str(e.value) == 'block weight is 2.0, expected 1.0' + + # Test valid signature with two signers, out of turn + block.weight = poa.BLOCK_WEIGHT_OUT_OF_TURN + first_poa_signer.sign_block(block) + block_verifier.verify_poa(block) + + # And the other signature fails for the weight + second_poa_signer.sign_block(block) + with pytest.raises(PoaValidationError) as e: + block_verifier.verify_poa(block) + assert str(e.value) == 'block weight is 1.0, expected 2.0' diff --git a/tests/tx/test_genesis.py b/tests/tx/test_genesis.py index fe08117bf..737d39b85 100644 --- a/tests/tx/test_genesis.py +++ b/tests/tx/test_genesis.py @@ -29,11 +29,11 @@ def get_genesis_output(): class GenesisTest(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: super().setUp() self._daa = DifficultyAdjustmentAlgorithm(settings=self._settings) verifiers = VertexVerifiers.create_defaults(settings=self._settings, daa=self._daa, feature_service=Mock()) - self._verification_service = VerificationService(verifiers=verifiers) + self._verification_service = VerificationService(settings=self._settings, verifiers=verifiers) self.storage = TransactionMemoryStorage() def test_pow(self): diff --git a/tests/tx/test_tx_deserialization.py b/tests/tx/test_tx_deserialization.py index ba19abc28..f467c26f0 100644 --- a/tests/tx/test_tx_deserialization.py +++ b/tests/tx/test_tx_deserialization.py @@ -14,7 +14,7 @@ def setUp(self) -> None: super().setUp() daa = DifficultyAdjustmentAlgorithm(settings=self._settings) verifiers = VertexVerifiers.create_defaults(settings=self._settings, daa=daa, feature_service=Mock()) - self._verification_service = VerificationService(verifiers=verifiers) + self._verification_service = VerificationService(settings=self._settings, verifiers=verifiers) def test_deserialize(self): cls = self.get_tx_class()