Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 28 additions & 9 deletions hathor/consensus/consensus_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@

from abc import ABC, abstractmethod
from enum import Enum, unique
from typing import Annotated, Literal, TypeAlias
from typing import Annotated, Any, Literal, TypeAlias

from pydantic import Field, validator
from pydantic import Field, NonNegativeInt, validator
from typing_extensions import override

from hathor.transaction import TxVersion
Expand Down Expand Up @@ -63,19 +63,38 @@ def _get_valid_vertex_versions(self, include_genesis: bool) -> set[TxVersion]:
}


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, ...]
class PoaSignerSettings(BaseModel):
public_key: bytes
start_height: NonNegativeInt = 0
end_height: NonNegativeInt | None = None

@validator('signers', each_item=True, pre=True)
@validator('public_key', pre=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)

@validator('end_height')
def _validate_end_height(cls, end_height: int | None, values: dict[str, Any]) -> int | None:
start_height = values.get('start_height')
assert start_height is not None, 'start_height must be set'

if end_height is None:
return None

if end_height <= start_height:
raise ValueError(f'end_height ({end_height}) must be greater than start_height ({start_height})')

return end_height


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[PoaSignerSettings, ...]

@validator('signers')
def _validate_signers(cls, signers: tuple[bytes, ...]) -> tuple[bytes, ...]:
def _validate_signers(cls, signers: tuple[PoaSignerSettings, ...]) -> tuple[PoaSignerSettings, ...]:
if len(signers) == 0:
raise ValueError('At least one signer must be provided in PoA networks')
return signers
Expand Down
4 changes: 3 additions & 1 deletion hathor/consensus/poa/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
InvalidSignature,
ValidSignature,
calculate_weight,
get_active_signers,
get_hashed_poa_data,
in_turn_signer_index,
verify_poa_signature,
Expand All @@ -24,5 +25,6 @@
'PoaSignerFile',
'verify_poa_signature',
'InvalidSignature',
'ValidSignature'
'ValidSignature',
'get_active_signers',
]
18 changes: 16 additions & 2 deletions hathor/consensus/poa/poa.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,22 @@ def get_hashed_poa_data(block: PoaBlock) -> bytes:
return hashed_poa_data


def get_active_signers(settings: PoaSettings, height: int) -> list[bytes]:
"""Return a list of signers that are currently active considering the given block height."""
active_signers = []
for signer_settings in settings.signers:
end_height = float('inf') if signer_settings.end_height is None else signer_settings.end_height

if signer_settings.start_height <= height <= end_height:
active_signers.append(signer_settings.public_key)

return active_signers


def in_turn_signer_index(settings: PoaSettings, height: int) -> int:
"""Return the signer index that is in turn for the given height."""
return height % len(settings.signers)
active_signers = get_active_signers(settings, height)
return height % len(active_signers)


def calculate_weight(settings: PoaSettings, block: PoaBlock, signer_index: int) -> float:
Expand All @@ -68,7 +81,8 @@ class ValidSignature:
def verify_poa_signature(settings: PoaSettings, block: PoaBlock) -> InvalidSignature | ValidSignature:
"""Return whether the provided public key was used to sign the block Proof-of-Authority."""
from hathor.consensus.poa import PoaSigner
sorted_signers = sorted(settings.signers)
active_signers = get_active_signers(settings, block.get_height())
sorted_signers = sorted(active_signers)
hashed_poa_data = get_hashed_poa_data(block)

for signer_index, public_key_bytes in enumerate(sorted_signers):
Expand Down
40 changes: 26 additions & 14 deletions hathor/consensus/poa/poa_block_producer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
from typing import TYPE_CHECKING

from structlog import get_logger
from twisted.internet.interfaces import IDelayedCall
from twisted.internet.task import LoopingCall

from hathor.conf.settings import HathorSettings
from hathor.consensus import poa
from hathor.consensus.consensus_settings import PoaSettings
from hathor.crypto.util import get_public_key_bytes_compressed
from hathor.reactor import ReactorProtocol
from hathor.util import not_none

if TYPE_CHECKING:
from hathor.consensus.poa import PoaSigner
Expand Down Expand Up @@ -52,11 +54,11 @@ class PoaBlockProducer:
'_reactor',
'_manager',
'_poa_signer',
'_signer_index',
'_started_producing',
'_start_producing_lc',
'_schedule_block_lc',
'_last_seen_best_block',
'_delayed_call',
)

def __init__(self, *, settings: HathorSettings, reactor: ReactorProtocol, poa_signer: PoaSigner) -> None:
Expand All @@ -67,7 +69,6 @@ def __init__(self, *, settings: HathorSettings, reactor: ReactorProtocol, poa_si
self._reactor = reactor
self._manager: HathorManager | None = None
self._poa_signer = poa_signer
self._signer_index = self._calculate_signer_index(self._poa_settings, self._poa_signer)
self._last_seen_best_block: Block | None = None

self._started_producing = False
Expand All @@ -76,6 +77,7 @@ def __init__(self, *, settings: HathorSettings, reactor: ReactorProtocol, poa_si

self._schedule_block_lc = LoopingCall(self._schedule_block)
self._schedule_block_lc.clock = self._reactor
self._delayed_call: IDelayedCall | None = None

@property
def manager(self) -> HathorManager:
Expand All @@ -97,16 +99,20 @@ def stop(self) -> None:
if self._schedule_block_lc.running:
self._schedule_block_lc.stop()

@staticmethod
def _calculate_signer_index(settings: PoaSettings, poa_signer: PoaSigner) -> int:
"""Return the signer index for the given private key."""
public_key = poa_signer.get_public_key()
if self._delayed_call and self._delayed_call.active():
self._delayed_call.cancel()

def _get_signer_index(self, previous_block: Block) -> int | None:
"""Return our signer index considering the active signers."""
height = previous_block.get_height() + 1
public_key = self._poa_signer.get_public_key()
public_key_bytes = get_public_key_bytes_compressed(public_key)
sorted_signers = sorted(settings.signers)
active_signers = poa.get_active_signers(self._poa_settings, height)
sorted_signers = sorted(active_signers)
try:
return sorted_signers.index(public_key_bytes)
except ValueError:
raise ValueError(f'Public key "{public_key_bytes.hex()}" not in list of PoA signers')
return None

def _start_producing(self) -> None:
"""Start producing new blocks."""
Expand All @@ -126,11 +132,15 @@ def _schedule_block(self) -> None:
return

self._last_seen_best_block = previous_block
signer_index = self._get_signer_index(previous_block)
if signer_index is None:
return

now = self._reactor.seconds()
expected_timestamp = self._expected_block_timestamp(previous_block)
expected_timestamp = self._expected_block_timestamp(previous_block, signer_index)
propagation_delay = 0 if expected_timestamp < now else expected_timestamp - now

self._reactor.callLater(propagation_delay, self._produce_block, previous_block)
self._delayed_call = self._reactor.callLater(propagation_delay, self._produce_block, previous_block)
self._log.debug(
'scheduling block production',
previous_block=previous_block.hash_hex,
Expand All @@ -144,7 +154,8 @@ def _produce_block(self, previous_block: PoaBlock) -> None:
block_templates = self.manager.get_block_templates(parent_block_hash=previous_block.hash)
block = block_templates.generate_mining_block(self.manager.rng, cls=PoaBlock)
assert isinstance(block, PoaBlock)
block.weight = poa.calculate_weight(self._poa_settings, block, self._signer_index)
signer_index = self._get_signer_index(previous_block)
block.weight = poa.calculate_weight(self._poa_settings, block, not_none(signer_index))
self._poa_signer.sign_block(block)
block.update_hash()

Expand All @@ -161,11 +172,12 @@ def _produce_block(self, previous_block: PoaBlock) -> None:
voided=bool(block.get_metadata().voided_by),
)

def _expected_block_timestamp(self, previous_block: Block) -> int:
def _expected_block_timestamp(self, previous_block: Block, signer_index: int) -> int:
"""Calculate the expected timestamp for a new block."""
height = previous_block.get_height() + 1
expected_index = poa.in_turn_signer_index(settings=self._poa_settings, height=height)
index_distance = (self._signer_index - expected_index) % len(self._poa_settings.signers)
assert 0 <= index_distance < len(self._poa_settings.signers)
signers = poa.get_active_signers(self._poa_settings, height)
index_distance = (signer_index - expected_index) % len(signers)
assert 0 <= index_distance < len(signers)
delay = _SIGNER_TURN_INTERVAL * index_distance
return previous_block.timestamp + self._settings.AVG_TIME_BETWEEN_BLOCKS + delay
15 changes: 10 additions & 5 deletions tests/others/test_hathor_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,12 @@ def mock_settings(settings_: dict[str, Any]) -> None:
HathorSettings.from_yaml(filepath='some_path')

# Test fails when PoA is enabled with default settings
mock_settings(dict(CONSENSUS_ALGORITHM=dict(type='PROOF_OF_AUTHORITY', signers=(b'some_signer',))))
mock_settings(dict(
CONSENSUS_ALGORITHM=dict(
type='PROOF_OF_AUTHORITY',
signers=(dict(public_key=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)
Expand All @@ -183,7 +188,7 @@ def mock_settings(settings_: dict[str, Any]) -> None:
BLOCKS_PER_HALVING=None,
INITIAL_TOKEN_UNITS_PER_BLOCK=0,
MINIMUM_TOKEN_UNITS_PER_BLOCK=0,
CONSENSUS_ALGORITHM=dict(type='PROOF_OF_AUTHORITY', signers=(b'some_signer',)),
CONSENSUS_ALGORITHM=dict(type='PROOF_OF_AUTHORITY', signers=(dict(public_key=b'some_signer'),)),
))
HathorSettings.from_yaml(filepath='some_path')

Expand All @@ -203,7 +208,7 @@ def mock_settings(settings_: dict[str, Any]) -> None:
BLOCKS_PER_HALVING=123,
INITIAL_TOKEN_UNITS_PER_BLOCK=0,
MINIMUM_TOKEN_UNITS_PER_BLOCK=0,
CONSENSUS_ALGORITHM=dict(type='PROOF_OF_AUTHORITY', signers=(b'some_signer',)),
CONSENSUS_ALGORITHM=dict(type='PROOF_OF_AUTHORITY', signers=(dict(public_key=b'some_signer'),)),
))
with pytest.raises(ValidationError) as e:
HathorSettings.from_yaml(filepath='some_path')
Expand All @@ -214,7 +219,7 @@ def mock_settings(settings_: dict[str, Any]) -> None:
BLOCKS_PER_HALVING=None,
INITIAL_TOKEN_UNITS_PER_BLOCK=123,
MINIMUM_TOKEN_UNITS_PER_BLOCK=0,
CONSENSUS_ALGORITHM=dict(type='PROOF_OF_AUTHORITY', signers=(b'some_signer',)),
CONSENSUS_ALGORITHM=dict(type='PROOF_OF_AUTHORITY', signers=(dict(public_key=b'some_signer'),)),
))
with pytest.raises(ValidationError) as e:
HathorSettings.from_yaml(filepath='some_path')
Expand All @@ -225,7 +230,7 @@ def mock_settings(settings_: dict[str, Any]) -> None:
BLOCKS_PER_HALVING=None,
INITIAL_TOKEN_UNITS_PER_BLOCK=0,
MINIMUM_TOKEN_UNITS_PER_BLOCK=123,
CONSENSUS_ALGORITHM=dict(type='PROOF_OF_AUTHORITY', signers=(b'some_signer',)),
CONSENSUS_ALGORITHM=dict(type='PROOF_OF_AUTHORITY', signers=(dict(public_key=b'some_signer'),)),
))
with pytest.raises(ValidationError) as e:
HathorSettings.from_yaml(filepath='some_path')
Expand Down
85 changes: 80 additions & 5 deletions tests/poa/test_poa.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@

import pytest
from cryptography.hazmat.primitives.asymmetric import ec
from pydantic import ValidationError

from hathor.conf.settings import HathorSettings
from hathor.consensus import poa
from hathor.consensus.consensus_settings import PoaSettings
from hathor.consensus.consensus_settings import PoaSettings, PoaSignerSettings
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.transaction import Block, TxOutput
Expand Down Expand Up @@ -130,7 +131,7 @@ def get_signer() -> tuple[PoaSigner, bytes]:
assert str(e.value) == 'invalid PoA signature'

# Test no data
settings.CONSENSUS_ALGORITHM = PoaSettings(signers=(public_key_bytes,))
settings.CONSENSUS_ALGORITHM = PoaSettings(signers=(PoaSignerSettings(public_key=public_key_bytes),))
with pytest.raises(PoaValidationError) as e:
block_verifier.verify_poa(block)
assert str(e.value) == 'invalid PoA signature'
Expand Down Expand Up @@ -162,7 +163,9 @@ def get_signer() -> tuple[PoaSigner, bytes]:
# 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]))
settings.CONSENSUS_ALGORITHM = PoaSettings(signers=tuple(
[PoaSignerSettings(public_key=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
Expand Down Expand Up @@ -230,7 +233,7 @@ def get_signer() -> tuple[PoaSigner, bytes]:
]
)
def test_in_turn_signer_index(n_signers: int, height: int, signer_index: int, expected: bool) -> None:
settings = PoaSettings.construct(signers=tuple(b'' for _ in range(n_signers)))
settings = PoaSettings.construct(signers=tuple(PoaSignerSettings(public_key=b'') for _ in range(n_signers)))

result = poa.in_turn_signer_index(settings=settings, height=height) == signer_index
assert result == expected
Expand All @@ -253,9 +256,81 @@ def test_in_turn_signer_index(n_signers: int, height: int, signer_index: int, ex
]
)
def test_calculate_weight(n_signers: int, height: int, signer_index: int, expected: float) -> None:
settings = PoaSettings.construct(signers=tuple(b'' for _ in range(n_signers)))
settings = PoaSettings.construct(signers=tuple(PoaSignerSettings(public_key=b'') for _ in range(n_signers)))
block = Mock()
block.get_height = Mock(return_value=height)

result = poa.calculate_weight(settings, block, signer_index)
assert result == expected


@pytest.mark.parametrize(
['signers', 'heights_and_expected'],
[
(
(PoaSignerSettings(public_key=b'a'),),
[
(0, [b'a']),
(10, [b'a']),
(100, [b'a']),
],
),
(
(PoaSignerSettings(public_key=b'a', start_height=0, end_height=10),),
[
(0, [b'a']),
(10, [b'a']),
(100, []),
],
),
(
(PoaSignerSettings(public_key=b'a', start_height=10, end_height=None),),
[
(0, []),
(10, [b'a']),
(100, [b'a']),
],
),
(
(
PoaSignerSettings(public_key=b'a', start_height=0, end_height=10),
PoaSignerSettings(public_key=b'b', start_height=5, end_height=20),
PoaSignerSettings(public_key=b'c', start_height=10, end_height=30),
),
[
(0, [b'a']),
(5, [b'a', b'b']),
(10, [b'a', b'b', b'c']),
(15, [b'b', b'c']),
(20, [b'b', b'c']),
(30, [b'c']),
(100, []),
]
),
]
)
def test_get_active_signers(
signers: tuple[PoaSignerSettings, ...],
heights_and_expected: list[tuple[int, list[bytes]]],
) -> None:
settings = PoaSettings(signers=signers)

for height, expected in heights_and_expected:
result = poa.get_active_signers(settings, height)
assert result == expected, f'height={height}'


def test_poa_signer_settings() -> None:
# Test passes
_ = PoaSignerSettings(public_key=b'some_key')
_ = PoaSignerSettings(public_key=b'some_key', start_height=0, end_height=10)
_ = PoaSignerSettings(public_key=b'some_key', start_height=0, end_height=None)

# Test fails
with pytest.raises(ValidationError) as e:
_ = PoaSignerSettings(public_key=b'some_key', start_height=10, end_height=10)
assert 'end_height (10) must be greater than start_height (10)' in str(e.value)

with pytest.raises(ValidationError) as e:
_ = PoaSignerSettings(public_key=b'some_key', start_height=10, end_height=5)
assert 'end_height (5) must be greater than start_height (10)' in str(e.value)
Loading