diff --git a/cryptarchia/cryptarchia.py b/cryptarchia/cryptarchia.py index 1e6e2cc..b7df7c2 100644 --- a/cryptarchia/cryptarchia.py +++ b/cryptarchia/cryptarchia.py @@ -2,6 +2,7 @@ from typing import TypeAlias, List, Optional from hashlib import sha256, blake2b from math import floor from copy import deepcopy +from itertools import chain import functools # Please note this is still a work in progress @@ -133,20 +134,24 @@ class MockLeaderProof: commitment: Id nullifier: Id evolved_commitment: Id + slot: Slot + parent: Id @staticmethod - def from_coin(coin: Coin): + def new(coin: Coin, slot: Slot, parent: Id): evolved_coin = coin.evolve() return MockLeaderProof( commitment=coin.commitment(), nullifier=coin.nullifier(), evolved_commitment=evolved_coin.commitment(), + slot=slot, + parent=parent, ) - def verify(self, slot): + def verify(self, slot: Slot, parent: Id): # TODO: verification not implemented - return True + return slot == self.slot and parent == self.parent @dataclass @@ -156,15 +161,9 @@ class BlockHeader: content_size: int content_id: Id leader_proof: MockLeaderProof + orphaned_proofs: List["BlockHeader"] = field(default_factory=list) - # **Attention**: - # The ID of a block header is defined as the 32byte blake2b hash of its fields - # as serialized in the format specified by the 'HEADER' rule in 'messages.abnf'. - # - # The following code is to be considered as a reference implementation, mostly to be used for testing. - def id(self) -> Id: - h = blake2b(digest_size=32) - + def update_header_hash(self, h): # version byte h.update(b"\x01") @@ -190,12 +189,31 @@ class BlockHeader: assert len(self.leader_proof.evolved_commitment) == 32 h.update(self.leader_proof.evolved_commitment) + # orphaned proofs + h.update(int.to_bytes(len(self.orphaned_proofs), length=4, byteorder="big")) + for proof in self.orphaned_proofs: + proof.update_header_hash(h) + + # **Attention**: + # The ID of a block header is defined as the 32byte blake2b hash of its fields + # as serialized in the format specified by the 'HEADER' rule in 'messages.abnf'. + # + # The following code is to be considered as a reference implementation, mostly to be used for testing. + def id(self) -> Id: + h = blake2b(digest_size=32) + self.update_header_hash(h) return h.digest() @dataclass class Chain: blocks: List[BlockHeader] + genesis: Id + + def tip_id(self) -> Id: + if len(self.blocks) == 0: + return self.genesis + return self.tip().id() def tip(self) -> BlockHeader: return self.blocks[-1] @@ -265,9 +283,11 @@ class LedgerState: self.nonce = h.digest() self.block = block.id() - self.nullifiers.add(block.leader_proof.nullifier) - self.commitments_spend.add(block.leader_proof.evolved_commitment) - self.commitments_lead.add(block.leader_proof.evolved_commitment) + for proof in chain(block.orphaned_proofs, [block]): + proof = proof.leader_proof + self.nullifiers.add(proof.nullifier) + self.commitments_spend.add(proof.evolved_commitment) + self.commitments_lead.add(proof.evolved_commitment) @dataclass @@ -303,33 +323,59 @@ class Follower: def __init__(self, genesis_state: LedgerState, config: Config): self.config = config self.forks = [] - self.local_chain = Chain([]) + self.local_chain = Chain([], genesis=genesis_state.block) self.genesis_state = genesis_state self.ledger_state = {genesis_state.block: genesis_state.copy()} def validate_header(self, block: BlockHeader, chain: Chain) -> bool: # TODO: verify blocks are not in the 'future' - parent_state = self.ledger_state[block.parent] + current_state = self.ledger_state[chain.tip_id()].copy() + orphaned_commitments = set() + # first, we verify adopted leadership transactions + for proof in block.orphaned_proofs: + proof = proof.leader_proof + # each proof is validated against the last state of the ledger of the chain this block + # is being added to before that proof slot + parent_state = self.state_at_slot_beginning(chain, proof.slot).copy() + # we add effects of previous orphaned proofs to the ledger state + parent_state.commitments_lead |= orphaned_commitments + epoch_state = self.compute_epoch_state(proof.slot.epoch(self.config), chain) + if self.verify_slot_leader( + proof.slot, proof, epoch_state, parent_state, current_state + ): + # if an adopted leadership proof is valid we need to apply its effects to the ledger state + orphaned_commitments.add(proof.evolved_commitment) + current_state.nullifiers.add(proof.nullifier) + else: + # otherwise, the whole block is invalid + return False + + parent_state = self.ledger_state[block.parent].copy() + parent_state.commitments_lead |= orphaned_commitments epoch_state = self.compute_epoch_state(block.slot.epoch(self.config), chain) # TODO: this is not the full block validation spec, only slot leader is verified return self.verify_slot_leader( - block.slot, block.leader_proof, epoch_state, parent_state + block.slot, block.leader_proof, epoch_state, parent_state, current_state ) def verify_slot_leader( self, slot: Slot, proof: MockLeaderProof, + # coins are old enough if their commitment is in the stake distribution snapshot epoch_state: EpochState, - ledger_state: LedgerState, + # commitments derived from leadership coin evolution are checked in the parent state + parent_state: LedgerState, + # nullifiers are checked in the current state + current_state: LedgerState, ) -> bool: return ( - proof.verify(slot) # verify slot leader proof + proof.verify(slot, parent_state.block) # verify slot leader proof and ( - ledger_state.verify_eligible_to_lead(proof.commitment) + parent_state.verify_eligible_to_lead(proof.commitment) or epoch_state.verify_eligible_to_lead_due_to_age(proof.commitment) ) - and ledger_state.verify_unspent(proof.nullifier) + and current_state.verify_unspent(proof.nullifier) ) # Try appending this block to an existing chain and return whether @@ -347,13 +393,16 @@ class Follower: 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=[]) + return Chain(blocks=[], genesis=self.genesis_state.block) chains = self.forks + [self.local_chain] for chain in chains: if chain.contains_block(block): block_position = chain.block_position(block) - return Chain(blocks=chain.blocks[:block_position]) + return Chain( + blocks=chain.blocks[:block_position], + genesis=self.genesis_state.block, + ) return None @@ -468,13 +517,17 @@ class Leader: coin: Coin def try_prove_slot_leader( - self, epoch: EpochState, slot: Slot + self, epoch: EpochState, slot: Slot, parent: Id ) -> MockLeaderProof | None: if self._is_slot_leader(epoch, slot): - return MockLeaderProof.from_coin(self.coin) + return MockLeaderProof.new(self.coin, slot, parent) - def propose_block(self, slot: Slot, parent: BlockHeader) -> BlockHeader: - return BlockHeader(parent=parent.id(), slot=slot) + def propose_block( + self, slot: Slot, parent: BlockHeader, orphaned_proofs=[] + ) -> BlockHeader: + return BlockHeader( + parent=parent.id(), slot=slot, orphaned_proofs=orphaned_proofs + ) def _is_slot_leader(self, epoch: EpochState, slot: Slot): relative_stake = self.coin.value / epoch.total_stake() diff --git a/cryptarchia/messages.abnf b/cryptarchia/messages.abnf index 94aa013..e47475e 100644 --- a/cryptarchia/messages.abnf +++ b/cryptarchia/messages.abnf @@ -3,7 +3,7 @@ BLOCK = HEADER CONTENT ; ------------ HEADER --------------------- VERSION = %x01 -HEADER = VERSION HEADER-FIELDS MOCK-LEADER-PROOF +HEADER = VERSION HEADER-FIELDS MOCK-LEADER-PROOF ORPHAN-PROOFS HEADER-FIELDS = CONTENT-SIZE CONTENT-ID BLOCK-DATE PARENT-ID CONTENT-SIZE = U32 BLOCK-DATE = BLOCK-SLOT @@ -11,6 +11,10 @@ BLOCK-SLOT = U64 PARENT-ID = HEADER-ID MOCK-LEADER-PROOF = COMMITMENT NULLIFIER EVOLVE-COMMITMENT EVOLVE-COMMITMENT = COMMITMENT +ORPHAN-PROOFS = ORPHAN-PROOF-CNT *ORPHAN-PROOF +ORPHAN-PROOF-CNT = U32 +; note this is not recursive, only the header leadership proof will be processed (orphan proofs are ignored) +ORPHAN-PROOF = HEADER ; ------------ CONTENT -------------------- CONTENT = *OCTET diff --git a/cryptarchia/test_fork_choice.py b/cryptarchia/test_fork_choice.py index 93e5571..90258b1 100644 --- a/cryptarchia/test_fork_choice.py +++ b/cryptarchia/test_fork_choice.py @@ -23,7 +23,9 @@ def make_block(parent_id: Id, slot: Slot, content: bytes) -> BlockHeader: content_size=1, slot=slot, content_id=content_id, - leader_proof=MockLeaderProof.from_coin(Coin(sk=0, value=10)), + leader_proof=MockLeaderProof.new( + Coin(sk=0, value=10), slot=slot, parent=parent_id + ), ) @@ -49,14 +51,28 @@ class TestLeader(TestCase): # by setting a low k we trigger the density choice rule k = 1 s = 50 - assert maxvalid_bg(Chain(short_chain), [Chain(long_chain)], k, s) == Chain( - short_chain + short_chain = Chain(short_chain, genesis=bytes(32)) + long_chain = Chain(long_chain, genesis=bytes(32)) + assert ( + maxvalid_bg( + short_chain, + [long_chain], + k, + s, + ) + == short_chain ) # However, if we set k to the fork length, it will be accepted - k = len(long_chain) - assert maxvalid_bg(Chain(short_chain), [Chain(long_chain)], k, s) == Chain( - long_chain + k = long_chain.length() + assert ( + maxvalid_bg( + short_chain, + [long_chain], + k, + s, + ) + == long_chain ) def test_fork_choice_long_dense_chain(self): @@ -73,6 +89,14 @@ class TestLeader(TestCase): short_chain.append(make_block(bytes(32), Slot(slot), short_content)) k = 1 s = 50 - assert maxvalid_bg(Chain(short_chain), [Chain(long_chain)], k, s) == Chain( - long_chain + short_chain = Chain(short_chain, genesis=bytes(32)) + long_chain = Chain(long_chain, genesis=bytes(32)) + assert ( + maxvalid_bg( + short_chain, + [long_chain], + k, + s, + ) + == long_chain ) diff --git a/cryptarchia/test_leader.py b/cryptarchia/test_leader.py index 3b88c23..5b29974 100644 --- a/cryptarchia/test_leader.py +++ b/cryptarchia/test_leader.py @@ -45,7 +45,7 @@ class TestLeader(TestCase): # After N slots, the measured leader rate should be within the interval `p +- margin_of_error` with high probabiltiy leader_rate = ( sum( - l.try_prove_slot_leader(epoch, Slot(slot)) is not None + l.try_prove_slot_leader(epoch, Slot(slot), bytes(32)) is not None for slot in range(N) ) / N diff --git a/cryptarchia/test_ledger_state_update.py b/cryptarchia/test_ledger_state_update.py index c56e1b0..b624a1e 100644 --- a/cryptarchia/test_ledger_state_update.py +++ b/cryptarchia/test_ledger_state_update.py @@ -26,7 +26,9 @@ def mk_genesis_state(initial_stake_distribution: list[Coin]) -> LedgerState: ) -def mk_block(parent: Id, slot: int, coin: Coin, content=bytes(32)) -> BlockHeader: +def mk_block( + parent: Id, slot: int, coin: Coin, content=bytes(32), orphaned_proofs=[] +) -> BlockHeader: from hashlib import sha256 return BlockHeader( @@ -34,7 +36,8 @@ def mk_block(parent: Id, slot: int, coin: Coin, content=bytes(32)) -> BlockHeade parent=parent, content_size=len(content), content_id=sha256(content).digest(), - leader_proof=MockLeaderProof.from_coin(coin), + leader_proof=MockLeaderProof.new(coin, Slot(slot), parent=parent), + orphaned_proofs=orphaned_proofs, ) @@ -251,3 +254,51 @@ class TestLedgerStateUpdate(TestCase): block_2_1 = mk_block(slot=20, parent=block_2_0.id(), coin=coin_new.evolve()) follower.on_block(block_2_1) assert follower.tip() == block_2_1 + + def test_orphaned_proofs(self): + coin = Coin(sk=0, value=100) + genesis = mk_genesis_state([coin]) + + # An epoch will be 10 slots long, with stake distribution snapshot taken at the start of the epoch + # and nonce snapshot before slot 7 + config = Config( + k=1, + active_slot_coeff=1, + epoch_stake_distribution_stabilization=4, + epoch_period_nonce_buffer=3, + epoch_period_nonce_stabilization=3, + time=TimeConfig(slot_duration=1, chain_start_time=0), + ) + + follower = Follower(genesis, config) + + # ---- EPOCH 0 ---- + + block_0_0 = mk_block(slot=0, parent=genesis.block, coin=coin) + follower.on_block(block_0_0) + assert follower.tip() == block_0_0 + + coin_new = coin.evolve() + coin_new_new = coin_new.evolve() + block_0_1 = mk_block(slot=1, parent=block_0_0.id(), coin=coin_new_new) + follower.on_block(block_0_1) + # the coin evolved twice should not be accepted as it is not in the lead commitments + assert follower.tip() == block_0_0 + # an orphaned proof with an evolved coin for the same slot as the original coin should not be accepted as the evolved coin is not in the lead commitments at slot 0 + block_0_1 = mk_block( + slot=1, + parent=block_0_0.id(), + coin=coin_new_new, + orphaned_proofs=[mk_block(parent=genesis.block, slot=0, coin=coin_new)], + ) + follower.on_block(block_0_1) + assert follower.tip() == block_0_0 + # the coin evolved twice should be accepted as the evolved coin is in the lead commitments at slot 1 and processed before that + block_0_2 = mk_block( + slot=2, + parent=block_0_0.id(), + coin=coin_new_new, + orphaned_proofs=[mk_block(parent=block_0_0.id(), slot=1, coin=coin_new)], + ) + follower.on_block(block_0_2) + assert follower.tip() == block_0_2