diff --git a/README.md b/README.md index 28f4791..783e2f5 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,9 @@ To test a specific module ```bash python -m unittest -v cryptarchia.test_leader ``` + +Or all test modules in a directory + +```bash +python -m unittest -v cryptarchia/test_* +``` diff --git a/cryptarchia/cryptarchia.py b/cryptarchia/cryptarchia.py index b6d081f..64defba 100644 --- a/cryptarchia/cryptarchia.py +++ b/cryptarchia/cryptarchia.py @@ -2,7 +2,7 @@ from typing import TypeAlias, List, Optional from hashlib import sha256, blake2b # Please note this is still a work in progress -from dataclasses import dataclass +from dataclasses import dataclass, field Id: TypeAlias = bytes @@ -23,6 +23,17 @@ class TimeConfig: chain_start_time: int +@dataclass +class Config: + k: int + active_slot_coeff: float # 'f', the rate of occupied slots + time: TimeConfig + + @property + def s(self): + return int(3 * self.k / self.active_slot_coeff) + + # An absolute unique indentifier of a slot, counting incrementally from 0 @dataclass class Slot: @@ -37,9 +48,44 @@ class Slot: @dataclass -class Config: - k: int - time: TimeConfig +class Coin: + pk: int + value: int + + def commitment(self) -> Id: + # TODO: mocked until CL is understood + pk_bytes = int.to_bytes(self.pk, length=32, byteorder="little") + value_bytes = int.to_bytes(self.value, length=32, byteorder="little") + + h = sha256() + h.update(pk_bytes) + h.update(value_bytes) + return h.digest() + + def nullifier(self) -> Id: + # TODO: mocked until CL is understood + pk_bytes = int.to_bytes(self.pk, length=32, byteorder="little") + value_bytes = int.to_bytes(self.value, length=32, byteorder="little") + + h = sha256() + h.update(pk_bytes) + h.update(value_bytes) + h.update(b"\x00") # extra 0 byte to differentiate from commitment + return h.digest() + + +@dataclass +class MockLeaderProof: + commitment: Id + nullifier: Id + + @staticmethod + def from_coin(coin: Coin): + return MockLeaderProof(commitment=coin.commitment(), nullifier=coin.nullifier()) + + def verify(self, slot): + # TODO: verification not implemented + return True @dataclass @@ -48,6 +94,7 @@ class BlockHeader: parent: Id content_size: int content_id: Id + leader_proof: MockLeaderProof # **Attention**: # The ID of a block header is defined as the 32byte blake2b hash of its fields @@ -55,21 +102,31 @@ class BlockHeader: # # The following code is to be considered as a reference implementation, mostly to be used for testing. def id(self) -> Id: - # version byte h = blake2b(digest_size=32) + + # version byte h.update(b"\x01") - # header type - h.update(b"\x00") + # content size h.update(int.to_bytes(self.content_size, length=4, byteorder="big")) + # content id assert len(self.content_id) == 32 h.update(self.content_id) + # slot h.update(int.to_bytes(self.slot.absolute_slot, length=8, byteorder="big")) + # parent assert len(self.parent) == 32 h.update(self.parent) + + # leader proof + assert len(self.leader_proof.commitment) == 32 + h.update(self.leader_proof.commitment) + assert len(self.leader_proof.nullifier) == 32 + h.update(self.leader_proof.nullifier) + return h.digest() @@ -93,34 +150,84 @@ class Chain: return i +@dataclass +class LedgerState: + """ + A snapshot of the ledger state up to some block + """ + + block: Id = None + nonce: Id = None + total_stake: int = None + commitments: set[Id] = field(default_factory=set) # set of commitments + nullifiers: set[Id] = field(default_factory=set) # set of nullified + + def copy(self): + return LedgerState( + block=self.block, + nonce=self.nonce, + total_stake=self.total_stake, + commitments=self.commitments.copy(), + nullifiers=self.nullifiers.copy(), + ) + + def verify_committed(self, commitment: Id) -> bool: + return commitment in self.commitments + + def verify_unspent(self, nullifier: Id) -> bool: + return nullifier not in self.nullifiers + + def apply(self, block: BlockHeader): + assert block.parent == self.block + self.block = block.id() + self.nullifiers.add(block.leader_proof.nullifier) + + class Follower: - def __init__(self, genesis: BlockHeader, config: Config): + def __init__(self, genesis_state: LedgerState, config: Config): self.config = config self.forks = [] - self.local_chain = Chain([genesis]) + self.local_chain = Chain([]) + self.epoch = EpochState( + stake_distribution_snapshot=genesis_state, + nonce_snapshot=genesis_state, + ) + self.genesis_state = genesis_state + self.ledger_state = genesis_state.copy() - # We don't do any validation in the current version - def validate_header(block: BlockHeader) -> bool: - return True + def validate_header(self, block: BlockHeader) -> bool: + # TODO: this is not the full block validation spec, only slot leader is verified + return self.verify_slot_leader(block.slot, block.leader_proof) + + def verify_slot_leader(self, slot: Slot, proof: MockLeaderProof) -> bool: + return ( + proof.verify(slot) # verify slot leader proof + and self.epoch.verify_commitment_is_old_enough_to_lead(proof.commitment) + and self.ledger_state.verify_unspent(proof.nullifier) + ) # Try appending this block to an existing chain and return whether # the operation was successful def try_extend_chains(self, block: BlockHeader) -> bool: - if self.local_chain.tip().id() == block.parent(): + if self.tip_id() == block.parent: self.local_chain.blocks.append(block) return True for chain in self.forks: - if chain.tip().id() == block.parent(): + if chain.tip().id() == block.parent: chain.blocks.append(block) return True return False def try_create_fork(self, block: BlockHeader) -> Optional[Chain]: + if self.genesis_state.block == block.parent: + # this block is forking off the genesis state + return Chain(blocks=[block]) + chains = self.forks + [self.local_chain] for chain in chains: - if self.chain.contains_block(block): + if chain.contains_block(block): block_position = chain.block_position(block) return Chain(blocks=chain.blocks[:block_position] + [block]) @@ -131,40 +238,47 @@ class Follower: return # check if the new block extends an existing chain - if self.try_extend_chains(block): - return + succeeded_in_extending_a_chain = self.try_extend_chains(block) + if not succeeded_in_extending_a_chain: + # we failed to extend one of the existing chains, + # therefore we might need to create a new fork + new_chain = self.try_create_fork(block) + if new_chain is not None: + self.forks.append(new_chain) + else: + # otherwise, we're missing the parent block + # in that case, just ignore the block + return - # if we get here, we might need to create a fork - new_chain = self.try_create_fork(block) - if new_chain is not None: - self.forks.append(new_chain) - # otherwise, we're missing the parent block - # in that case, just ignore the block + # We may need to switch forks, lets run the fork choice rule to check. + new_chain = self.fork_choice() + + if new_chain == self.local_chain: + # we have not re-org'd therefore we can simply update our ledger state + # if this block extend our local chain + if self.local_chain.tip() == block: + self.ledger_state.apply(block) + else: + # we have re-org'd, therefore we must roll back out ledger state and + # re-apply blocks from the new chain + ledger_state = self.genesis_state.copy() + for block in new_chain.blocks: + ledger_state.apply(block) + + self.ledger_state = ledger_state + self.local_chain = new_chain # Evaluate the fork choice rule and return the block header of the block that should be the head of the chain - def fork_choice(local_chain: Chain, forks: List[Chain]) -> Chain: - # TODO: define k and s - return maxvalid_bg(local_chain, forks, 0, 0) + def fork_choice(self) -> Chain: + return maxvalid_bg( + self.local_chain, self.forks, k=self.config.k, s=self.config.s + ) - def tip(self) -> BlockHeader: - return self.fork_choice() - - -@dataclass -class Coin: - pk: int - value: int - - -@dataclass -class LedgerState: - """ - A snapshot of the ledger state up to some height - """ - - block: Id = None - nonce: bytes = None - total_stake: int = None + def tip_id(self) -> Id: + if self.local_chain.length() > 0: + return self.local_chain.tip().id() + else: + return self.ledger_state.block @dataclass @@ -178,6 +292,9 @@ class EpochState: # The nonce snapshot is taken 7k/f slots into the previous epoch nonce_snapshot: LedgerState + def verify_commitment_is_old_enough_to_lead(self, commitment: Id) -> bool: + return self.stake_distribution_snapshot.verify_committed(commitment) + def total_stake(self) -> int: """Returns the total stake that will be used to reletivize leadership proofs during this epoch""" return self.stake_distribution_snapshot.total_stake @@ -186,11 +303,6 @@ class EpochState: return self.nonce_snapshot.nonce -@dataclass -class LeaderConfig: - active_slot_coeff: float = 0.05 # 'f', the rate of occupied slots - - def phi(f: float, alpha: float) -> float: """ params: @@ -222,19 +334,26 @@ class MOCK_LEADER_VRF: @dataclass class Leader: - config: LeaderConfig + config: Config coin: Coin - def is_slot_leader(self, epoch: EpochState, slot: Slot) -> bool: - f = self.config.active_slot_coeff + def try_prove_slot_leader( + self, epoch: EpochState, slot: Slot + ) -> MockLeaderProof | None: + if self._is_slot_leader(epoch, slot): + return MockLeaderProof.from_coin(self.coin) + + def propose_block(self, slot: Slot, parent: BlockHeader) -> BlockHeader: + return BlockHeader(parent=parent.id(), slot=slot) + + def _is_slot_leader(self, epoch: EpochState, slot: Slot): relative_stake = self.coin.value / epoch.total_stake() r = MOCK_LEADER_VRF.vrf(self.coin.pk, epoch.nonce(), slot) - return r < MOCK_LEADER_VRF.ORDER * phi(f, relative_stake) - - def propose_block(self, slot: Slot, parent: BlockHeader) -> BlockHeader: - return BlockHeader(parent=parent.id(), slot=slot) + return r < MOCK_LEADER_VRF.ORDER * phi( + self.config.active_slot_coeff, relative_stake + ) def common_prefix_len(a: Chain, b: Chain) -> int: diff --git a/cryptarchia/messages.abnf b/cryptarchia/messages.abnf index 1f828fe..fefd6a6 100644 --- a/cryptarchia/messages.abnf +++ b/cryptarchia/messages.abnf @@ -2,14 +2,14 @@ ; ------------ BLOCK ---------------------- BLOCK = HEADER CONTENT ; ------------ HEADER --------------------- -VERSION = %x01 -HEADER = VERSION HEADER-UNSIGNED -HEADER-UNSIGNED = %x00 HEADER-COMMON -HEADER-COMMON = CONTENT-SIZE CONTENT-ID BLOCK-DATE PARENT-ID -CONTENT-SIZE = U32 -BLOCK-DATE = BLOCK-SLOT -BLOCK-SLOT = U64 -PARENT-ID = HEADER-ID +VERSION = %x01 +HEADER = VERSION HEADER-FIELDS MOCK-LEADER-PROOF +HEADER-FIELDS = CONTENT-SIZE CONTENT-ID BLOCK-DATE PARENT-ID +CONTENT-SIZE = U32 +BLOCK-DATE = BLOCK-SLOT +BLOCK-SLOT = U64 +PARENT-ID = HEADER-ID +MOCK-LEADER-PROOF = COMMITMENT NULLIFIER ; ------------ CONTENT -------------------- CONTENT = *OCTET @@ -19,3 +19,5 @@ U32 = 4OCTET ; unsigned integer 32 bit (BE) U64 = 8OCTET ; unsigned integer 32 bit (BE) HEADER-ID = 32OCTET CONTENT-ID = 32OCTET +COMMITMENT = 32OCTET +NULLIFIER = 32OCTET diff --git a/cryptarchia/test_fork_choice.py b/cryptarchia/test_fork_choice.py index 3f4f8b4..87dc379 100644 --- a/cryptarchia/test_fork_choice.py +++ b/cryptarchia/test_fork_choice.py @@ -4,14 +4,26 @@ import numpy as np import hashlib from copy import deepcopy -from cryptarchia.cryptarchia import maxvalid_bg, Chain, BlockHeader, Slot, Id +from cryptarchia.cryptarchia import ( + maxvalid_bg, + Chain, + BlockHeader, + Slot, + Id, + MockLeaderProof, + Coin, +) def make_block(parent_id: Id, slot: Slot, content: bytes) -> BlockHeader: assert len(parent_id) == 32 content_id = hashlib.sha256(content).digest() return BlockHeader( - parent=parent_id, content_size=1, slot=slot, content_id=content_id + parent=parent_id, + content_size=1, + slot=slot, + content_id=content_id, + leader_proof=MockLeaderProof.from_coin(Coin(pk=0, value=10)), ) diff --git a/cryptarchia/test_leader.py b/cryptarchia/test_leader.py index 6ea01f1..59cc44c 100644 --- a/cryptarchia/test_leader.py +++ b/cryptarchia/test_leader.py @@ -2,12 +2,12 @@ from unittest import TestCase import numpy as np -from .cryptarchia import Leader, LeaderConfig, EpochState, LedgerState, Coin, phi +from .cryptarchia import Leader, Config, EpochState, LedgerState, Coin, phi, TimeConfig class TestLeader(TestCase): def test_slot_leader_statistics(self): - epoch_state = EpochState( + epoch = EpochState( stake_distribution_snapshot=LedgerState( total_stake=1000, ), @@ -15,8 +15,12 @@ class TestLeader(TestCase): ) f = 0.05 - leader_config = LeaderConfig(active_slot_coeff=f) - l = Leader(config=leader_config, coin=Coin(pk=0, value=10)) + config = Config( + k=10, + active_slot_coeff=f, + time=TimeConfig(slots_per_epoch=1000, slot_duration=1, chain_start_time=0), + ) + l = Leader(config=config, coin=Coin(pk=0, value=10)) # We'll use the Margin of Error equation to decide how many samples we need. # https://en.wikipedia.org/wiki/Margin_of_error @@ -27,7 +31,10 @@ class TestLeader(TestCase): N = int((Z * std / margin_of_error) ** 2) # After N slots, the measured leader rate should be within the interval `p +- margin_of_error` with high probabiltiy - leader_rate = sum(l.is_slot_leader(epoch_state, slot) for slot in range(N)) / N + leader_rate = ( + sum(l.try_prove_slot_leader(epoch, slot) is not None for slot in range(N)) + / N + ) assert ( abs(leader_rate - p) < margin_of_error ), f"{leader_rate} != {p}, err={abs(leader_rate - p)} > {margin_of_error}" diff --git a/cryptarchia/test_ledger_state_update.py b/cryptarchia/test_ledger_state_update.py new file mode 100644 index 0000000..2c848bd --- /dev/null +++ b/cryptarchia/test_ledger_state_update.py @@ -0,0 +1,108 @@ +from unittest import TestCase + +import numpy as np + +from .cryptarchia import ( + Follower, + TimeConfig, + BlockHeader, + Config, + Coin, + LedgerState, + MockLeaderProof, + Slot, + Id, +) + + +def mk_genesis_state(initial_stake_distribution: list[Coin]) -> LedgerState: + return LedgerState( + block=bytes(32), + nonce=bytes(32), + total_stake=sum(c.value for c in initial_stake_distribution), + commitments={c.commitment() for c in initial_stake_distribution}, + nullifiers=set(), + ) + + +def mk_block(parent: Id, slot: int, coin: Coin, content=bytes(32)) -> BlockHeader: + from hashlib import sha256 + + return BlockHeader( + slot=Slot(slot), + parent=parent, + content_size=len(content), + content_id=sha256(content).digest(), + leader_proof=MockLeaderProof.from_coin(coin), + ) + + +def config() -> Config: + return Config( + k=10, + active_slot_coeff=0.05, + time=TimeConfig(slots_per_epoch=1000, slot_duration=1, chain_start_time=0), + ) + + +class TestLedgerStateUpdate(TestCase): + def test_ledger_state_prevents_coin_reuse(self): + leader_coin = Coin(pk=0, value=100) + genesis = mk_genesis_state([leader_coin]) + + follower = Follower(genesis, config()) + + block = mk_block(slot=0, parent=genesis.block, coin=leader_coin) + follower.on_block(block) + + # Follower should have accepted the block + assert follower.local_chain.length() == 1 + assert follower.local_chain.tip() == block + + # Follower should have updated their ledger state to mark the leader coin as spent + assert follower.ledger_state.verify_unspent(leader_coin.nullifier()) == False + + reuse_coin_block = mk_block(slot=1, parent=block.id, coin=leader_coin) + follower.on_block(block) + + # Follower should *not* have accepted the block + assert follower.local_chain.length() == 1 + assert follower.local_chain.tip() == block + + def test_ledger_state_is_properly_updated_on_reorg(self): + coin_1 = Coin(pk=0, value=100) + coin_2 = Coin(pk=1, value=100) + coin_3 = Coin(pk=1, value=100) + + genesis = mk_genesis_state([coin_1, coin_2, coin_3]) + + follower = Follower(genesis, config()) + + # 1) coin_1 & coin_2 both concurrently win slot 0 + + block_1 = mk_block(parent=genesis.block, slot=0, coin=coin_1) + block_2 = mk_block(parent=genesis.block, slot=0, coin=coin_2) + + # 2) follower sees block 1 first + + follower.on_block(block_1) + assert follower.tip_id() == block_1.id() + assert not follower.ledger_state.verify_unspent(coin_1.nullifier()) + + # 3) then sees block 2, but sticks with block_1 as the tip + + follower.on_block(block_2) + assert follower.tip_id() == block_1.id() + assert len(follower.forks) == 1, f"{len(follower.forks)}" + + # 4) then coin_3 wins slot 1 and chooses to extend from block_2 + + block_3 = mk_block(parent=block_2.id(), slot=0, coin=coin_3) + + follower.on_block(block_3) + + # the follower should have switched over to the block_2 fork + assert follower.tip_id() == block_3.id() + + # and the original coin_1 should now be removed from the spent pool + assert follower.ledger_state.verify_unspent(coin_1.nullifier())