diff --git a/cryptarchia/cryptarchia.py b/cryptarchia/cryptarchia.py index c410251..b510962 100644 --- a/cryptarchia/cryptarchia.py +++ b/cryptarchia/cryptarchia.py @@ -1,11 +1,12 @@ -from typing import TypeAlias, List, Optional +from typing import TypeAlias, List, Dict from hashlib import sha256, blake2b from math import floor from copy import deepcopy -from itertools import chain +import itertools import functools from dataclasses import dataclass, field, replace import logging +from collections import defaultdict import numpy as np @@ -248,36 +249,13 @@ class BlockHeader: 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] - - def length(self) -> int: - return len(self.blocks) - - def block_position(self, block: Id) -> Optional[int]: - for i, b in enumerate(self.blocks): - if b.id() == block: - return i - return None - - @dataclass class LedgerState: """ A snapshot of the ledger state up to some block """ - block: Id = None + block: BlockHeader # This nonce is used to derive the seed for the slot leader lottery. # It's updated at every block by hashing the previous nonce with the @@ -324,7 +302,7 @@ class LedgerState: return nullifier not in self.nullifiers def apply(self, block: BlockHeader): - assert block.parent == self.block + assert block.parent == self.block.id() h = blake2b(digest_size=32) h.update("epoch-nonce".encode(encoding="utf-8")) @@ -333,8 +311,8 @@ class LedgerState: h.update(block.slot.encode()) self.nonce = h.digest() - self.block = block.id() - for proof in chain(block.orphaned_proofs, [block]): + self.block = block + for proof in itertools.chain(block.orphaned_proofs, [block]): self.apply_leader_proof(proof.leader_proof) def apply_leader_proof(self, proof: MockLeaderProof): @@ -385,19 +363,25 @@ class Follower: def __init__(self, genesis_state: LedgerState, config: Config): self.config = config self.forks = [] - self.local_chain = Chain([], genesis=genesis_state.block) + self.local_chain = genesis_state.block.id() self.genesis_state = genesis_state - self.ledger_state = {genesis_state.block: genesis_state.copy()} + self.ledger_state = {genesis_state.block.id(): genesis_state.copy()} self.epoch_state = {} - def validate_header(self, block: BlockHeader, chain: Chain) -> bool: + def validate_header(self, block: BlockHeader) -> bool: # TODO: verify blocks are not in the 'future' - if block.parent != chain.tip_id(): - logger.warning("block parent is not chain tip") + if block.parent not in self.ledger_state: + logger.warning("We have not seen block parent") return False current_state = self.ledger_state[block.parent].copy() + # We use the proposed block epoch state to validate orphans as well. + # For very old orphans, these states may be different. + epoch_state = self.compute_epoch_state( + block.slot.epoch(self.config), block.parent + ) + # first, we verify adopted leadership transactions for orphan in block.orphaned_proofs: # orphan proofs are checked in two ways @@ -410,10 +394,6 @@ class Follower: logger.warning("missing orphan proof") return False - # we use the proposed block epoch state here instead of the orphan's - # epoch state. For very old orphans, these states may be different. - epoch_state = self.compute_epoch_state(block.slot.epoch(self.config), chain) - # (2.) is satisfied by verifying the proof against current state ensuring: # - it is a valid proof # - and the nullifier has not already been spent @@ -431,7 +411,6 @@ class Follower: # effects to the ledger state current_state.apply_leader_proof(orphan.leader_proof) - 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, @@ -462,129 +441,100 @@ class Follower: and current_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) -> Optional[Chain]: - if self.tip_id() == block.parent: - return self.local_chain - - for chain in self.forks: - if chain.tip_id() == block.parent: - return chain - - return None - - 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=[], genesis=self.genesis_state.block) - - chains = self.forks + [self.local_chain] - for chain in chains: - block_position = chain.block_position(block.parent) - if block_position is not None: - return Chain( - blocks=chain.blocks[: block_position + 1], - genesis=self.genesis_state.block, - ) - - return None - def on_block(self, block: BlockHeader): - # check if the new block extends an existing chain - new_chain = self.try_extend_chains(block) - if new_chain is None: - # 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: - logger.warning("missing parent block") - # otherwise, we're missing the parent block - # in that case, just ignore the block - return - - if not self.validate_header(block, new_chain): - logger.warning("invalid header") + if block.id() in self.ledger_state: + logger.warning("dropping already processed block") return - new_chain.blocks.append(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: - self.forks.remove(new_chain) - self.forks.append(self.local_chain) - self.local_chain = new_chain + if not self.validate_header(block): + logger.warning("invalid header") + return new_state = self.ledger_state[block.parent].copy() new_state.apply(block) self.ledger_state[block.id()] = new_state - def unimported_orphans(self, tip: Id) -> list[BlockHeader]: + if block.parent == self.local_chain: + # simply extending the local chain + self.local_chain = block.id() + else: + # otherwise, this block creates a fork + self.forks.append(block.id()) + + # remove any existing fork that is superceded by this block + if block.parent in self.forks: + self.forks.remove(block.parent) + + # We may need to switch forks, lets run the fork choice rule to check. + new_tip = self.fork_choice() + self.forks.append(self.local_chain) + self.forks.remove(new_tip) + self.local_chain = new_tip + + def unimported_orphans(self) -> list[BlockHeader]: """ Returns all unimported orphans w.r.t. the given tip's state. Orphans are returned in the order that they should be imported. """ - tip_state = self.ledger_state[tip].copy() + tip_state = self.tip_state().copy() + tip = tip_state.block.id() orphans = [] - for fork in [self.local_chain, *self.forks]: - if fork.block_position(tip) is not None: - # the tip is a member of this fork, it doesn't make sense - # to take orphans from this fork as they are all already "imported" - continue - for block in fork.blocks: - for b in [*block.orphaned_proofs, block]: - if b.leader_proof.nullifier not in tip_state.nullifiers: - tip_state.nullifiers.add(b.leader_proof.nullifier) - orphans += [b] + for fork in self.forks: + _, fork_depth = common_prefix_depth(tip, fork, self.ledger_state) + for block_state in chain_suffix(fork, fork_depth, self.ledger_state): + b = block_state.block + if b.leader_proof.nullifier not in tip_state.nullifiers: + tip_state.nullifiers.add(b.leader_proof.nullifier) + orphans += [b] return orphans # Evaluate the fork choice rule and return the chain we should be following - def fork_choice(self) -> Chain: + def fork_choice(self) -> Id: return maxvalid_bg( - self.local_chain, self.forks, k=self.config.k, s=self.config.s + self.local_chain, + self.forks, + k=self.config.k, + s=self.config.s, + states=self.ledger_state, ) def tip(self) -> BlockHeader: - return self.local_chain.tip() + return self.tip_state().block def tip_id(self) -> Id: - return self.local_chain.tip_id() + return self.local_chain def tip_state(self) -> LedgerState: return self.ledger_state[self.tip_id()] - def state_at_slot_beginning(self, chain: Chain, slot: Slot) -> LedgerState: - for block in reversed(chain.blocks): - if block.slot < slot: - return self.ledger_state[block.id()] - + def state_at_slot_beginning(self, tip: Id, slot: Slot) -> LedgerState: + for state in iter_chain(tip, self.ledger_state): + if state.block.slot < slot: + return state return self.genesis_state def epoch_start_slot(self, epoch) -> Slot: return Slot(epoch.epoch * self.config.epoch_length) - def stake_distribution_snapshot(self, epoch, chain): + def stake_distribution_snapshot(self, epoch, tip: Id): # stake distribution snapshot happens at the beginning of the previous epoch, # i.e. for epoch e, the snapshot is taken at the last block of epoch e-2 slot = Slot(epoch.prev().epoch * self.config.epoch_length) - return self.state_at_slot_beginning(chain, slot) + return self.state_at_slot_beginning(tip, slot) - def nonce_snapshot(self, epoch, chain): + def nonce_snapshot(self, epoch, tip): # nonce snapshot happens partway through the previous epoch after the # stake distribution has stabilized slot = Slot( self.config.epoch_relative_nonce_slot + self.epoch_start_slot(epoch.prev()).absolute_slot ) - return self.state_at_slot_beginning(chain, slot) + return self.state_at_slot_beginning(tip, slot) - def compute_epoch_state(self, epoch: Epoch, chain: Chain) -> EpochState: + def compute_epoch_state(self, epoch: Epoch, tip: Id) -> EpochState: if epoch.epoch == 0: return EpochState( stake_distribution_snapshot=self.genesis_state, @@ -592,18 +542,18 @@ class Follower: inferred_total_active_stake=self.config.initial_total_active_stake, ) - stake_distribution_snapshot = self.stake_distribution_snapshot(epoch, chain) - nonce_snapshot = self.nonce_snapshot(epoch, chain) + stake_distribution_snapshot = self.stake_distribution_snapshot(epoch, tip) + nonce_snapshot = self.nonce_snapshot(epoch, tip) # we memoize epoch states to avoid recursion killing our performance - memo_block_id = nonce_snapshot.block + memo_block_id = nonce_snapshot.block.id() if state := self.epoch_state.get((epoch, memo_block_id)): return state # To update our inference of total stake, we need the prior estimate which # was calculated last epoch. Thus we recurse here to retreive the previous # estimate of total stake. - prev_epoch = self.compute_epoch_state(epoch.prev(), chain) + prev_epoch = self.compute_epoch_state(epoch.prev(), tip) inferred_total_active_stake = self._infer_total_active_stake( prev_epoch, nonce_snapshot, stake_distribution_snapshot ) @@ -696,45 +646,99 @@ class Leader: ) -def common_prefix_len(a: Chain, b: Chain) -> int: - for i, (x, y) in enumerate(zip(a.blocks, b.blocks)): - if x.id() != y.id(): - return i - return min(len(a.blocks), len(b.blocks)) +def iter_chain(tip: Id, states: Dict[Id, LedgerState]): + while tip in states: + yield states[tip] + tip = states[tip].block.parent -def chain_density(chain: Chain, slot: Slot) -> int: - return len( - [ - block - for block in chain.blocks - if block.slot.absolute_slot < slot.absolute_slot - ] - ) +def chain_suffix(tip: Id, n: int, states: Dict[Id, LedgerState]) -> list[LedgerState]: + return list(reversed(list(itertools.islice(iter_chain(tip, states), n)))) -# Implementation of the fork choice rule as defined in the Ouroboros Genesis paper -# k defines the forking depth of chain we accept without more analysis +def common_prefix_depth(a: Id, b: Id, states: Dict[Id, LedgerState]) -> (int, int): + a_blocks = iter_chain(a, states) + b_blocks = iter_chain(b, states) + + seen = {} + depth = 0 + while True: + try: + a_block = next(a_blocks).block.id() + if a_block in seen: + # we had seen this block from the fork chain + return depth, seen[a_block] + + seen[a_block] = depth + except StopIteration: + pass + + try: + b_block = next(b_blocks).block.id() + if b_block in seen: + # we had seen the fork in the local chain + return seen[b_block], depth + seen[b_block] = depth + except StopIteration: + pass + + depth += 1 + + assert False + + +def chain_density( + head: Id, slot: Slot, reorg_depth: int, states: Dict[Id, LedgerState] +) -> int: + assert type(head) == Id + chain = iter_chain(head, states) + segment = itertools.islice(chain, reorg_depth) + return sum(1 for b in segment if b.block.slot < slot) + + +def block_children(states: Dict[Id, LedgerState]) -> Dict[Id, set[Id]]: + children = defaultdict(set) + for c, state in states.items(): + children[state.block.parent].add(c) + + return children + + +# Implementation of the Cryptarchia fork choice rule (following Ouroborous Genesis). +# The fork choice has two phases: +# 1. if the chain is not forking too deeply, we apply the longest chain fork choice rule +# 2. otherwise we look at the chain density immidiately following the fork +# +# k defines the forking depth of a chain at which point we switch phases. # s defines the length of time (unit of slots) after the fork happened we will inspect for chain density -def maxvalid_bg(local_chain: Chain, forks: List[Chain], k: int, s: int) -> Chain: +def maxvalid_bg( + local_chain: Id, + forks: List[Id], + k: int, + s: int, + states: Dict[Id, LedgerState], +) -> Id: + assert type(local_chain) == Id + assert all(type(f) == Id for f in forks) + cmax = local_chain - for chain in forks: - lowest_common_ancestor = common_prefix_len(cmax, chain) - m = cmax.length() - lowest_common_ancestor - if m <= k: - # Classic longest chain rule with parameter k - if cmax.length() < chain.length(): - cmax = chain + for fork in forks: + cmax_depth, fork_depth = common_prefix_depth(cmax, fork, states) + if cmax_depth <= k: + # Longest chain fork choice rule + if cmax_depth < fork_depth: + cmax = fork else: # The chain is forking too much, we need to pay a bit more attention # In particular, select the chain that is the densest after the fork - forking_slot = Slot( - cmax.blocks[lowest_common_ancestor].slot.absolute_slot + s - ) - cmax_density = chain_density(cmax, forking_slot) - candidate_density = chain_density(chain, forking_slot) - if cmax_density < candidate_density: - cmax = chain + cmax_divergent_block = chain_suffix(cmax, cmax_depth, states)[0].block + + forking_slot = Slot(cmax_divergent_block.slot.absolute_slot + s) + cmax_density = chain_density(cmax, forking_slot, cmax_depth, states) + fork_density = chain_density(fork, forking_slot, fork_depth, states) + + if cmax_density < fork_density: + cmax = fork return cmax diff --git a/cryptarchia/test_common.py b/cryptarchia/test_common.py index 4b3dbc0..cd35090 100644 --- a/cryptarchia/test_common.py +++ b/cryptarchia/test_common.py @@ -1,7 +1,5 @@ from .cryptarchia import ( Config, - TimeConfig, - Id, Slot, Coin, BlockHeader, @@ -20,19 +18,18 @@ class TestNode: def epoch_state(self, slot: Slot): return self.follower.compute_epoch_state( - slot.epoch(self.config), self.follower.local_chain + slot.epoch(self.config), self.follower.tip_id() ) def on_slot(self, slot: Slot) -> BlockHeader | None: parent = self.follower.tip_id() epoch_state = self.epoch_state(slot) if leader_proof := self.leader.try_prove_slot_leader(epoch_state, slot, parent): - orphans = self.follower.unimported_orphans(parent) self.leader.coin = self.leader.coin.evolve() return BlockHeader( parent=parent, slot=slot, - orphaned_proofs=orphans, + orphaned_proofs=self.follower.unimported_orphans(), leader_proof=leader_proof, content_size=0, content_id=bytes(32), @@ -53,7 +50,15 @@ def mk_config(initial_stake_distribution: list[Coin]) -> Config: def mk_genesis_state(initial_stake_distribution: list[Coin]) -> LedgerState: return LedgerState( - block=bytes(32), + block=BlockHeader( + slot=Slot(0), + parent=bytes(32), + content_size=0, + content_id=bytes(32), + leader_proof=MockLeaderProof.new( + Coin(sk=0, value=0), Slot(0), parent=bytes(32) + ), + ), nonce=bytes(32), commitments_spend={c.commitment() for c in initial_stake_distribution}, commitments_lead={c.commitment() for c in initial_stake_distribution}, @@ -62,26 +67,30 @@ def mk_genesis_state(initial_stake_distribution: list[Coin]) -> LedgerState: def mk_block( - parent: Id, slot: int, coin: Coin, content=bytes(32), orphaned_proofs=[] + parent: BlockHeader, slot: int, coin: Coin, content=bytes(32), orphaned_proofs=[] ) -> BlockHeader: - assert len(parent) == 32 + assert type(parent) == BlockHeader, type(parent) + assert type(slot) == int, type(slot) from hashlib import sha256 return BlockHeader( slot=Slot(slot), - parent=parent, + parent=parent.id(), content_size=len(content), content_id=sha256(content).digest(), - leader_proof=MockLeaderProof.new(coin, Slot(slot), parent=parent), + leader_proof=MockLeaderProof.new(coin, Slot(slot), parent=parent.id()), orphaned_proofs=orphaned_proofs, ) -def mk_chain(parent, coin: Coin, slots: list[int]) -> tuple[list[BlockHeader], Coin]: +def mk_chain( + parent: BlockHeader, coin: Coin, slots: list[int] +) -> tuple[list[BlockHeader], Coin]: + assert type(parent) == BlockHeader chain = [] for s in slots: block = mk_block(parent=parent, slot=s, coin=coin) chain.append(block) - parent = block.id() + parent = block coin = coin.evolve() return chain, coin diff --git a/cryptarchia/test_fork_choice.py b/cryptarchia/test_fork_choice.py index ff048e1..1a283c5 100644 --- a/cryptarchia/test_fork_choice.py +++ b/cryptarchia/test_fork_choice.py @@ -1,40 +1,82 @@ from unittest import TestCase -from itertools import repeat -import numpy as np -import hashlib from copy import deepcopy from cryptarchia.cryptarchia import ( maxvalid_bg, - Chain, - BlockHeader, Slot, - Id, - MockLeaderProof, Coin, Follower, + common_prefix_depth, + LedgerState, ) from .test_common import mk_chain, mk_config, mk_genesis_state, mk_block class TestForkChoice(TestCase): + def test_common_prefix_depth(self): + # 6 - 7 + # / + # 0 - 1 - 2 - 3 + # \ + # 4 - 5 + + coin = Coin(sk=1, value=100) + + b0 = mk_genesis_state([]).block + b1 = mk_block(b0, 1, coin) + b2 = mk_block(b1, 2, coin) + b3 = mk_block(b2, 3, coin) + b4 = mk_block(b0, 1, coin, content=b"b4") + b5 = mk_block(b4, 2, coin) + b6 = mk_block(b2, 3, coin, content=b"b6") + b7 = mk_block(b6, 4, coin) + + states = { + b.id(): LedgerState(block=b) for b in [b0, b1, b2, b3, b4, b5, b6, b7] + } + + assert (d := common_prefix_depth(b0.id(), b0.id(), states)) == (0, 0), d + assert (d := common_prefix_depth(b1.id(), b0.id(), states)) == (1, 0), d + assert (d := common_prefix_depth(b0.id(), b1.id(), states)) == (0, 1), d + assert (d := common_prefix_depth(b1.id(), b1.id(), states)) == (0, 0), d + assert (d := common_prefix_depth(b2.id(), b0.id(), states)) == (2, 0), d + assert (d := common_prefix_depth(b0.id(), b2.id(), states)) == (0, 2), d + assert (d := common_prefix_depth(b3.id(), b0.id(), states)) == (3, 0), d + assert (d := common_prefix_depth(b0.id(), b3.id(), states)) == (0, 3), d + assert (d := common_prefix_depth(b1.id(), b4.id(), states)) == (1, 1), d + assert (d := common_prefix_depth(b4.id(), b1.id(), states)) == (1, 1), d + assert (d := common_prefix_depth(b1.id(), b5.id(), states)) == (1, 2), d + assert (d := common_prefix_depth(b5.id(), b1.id(), states)) == (2, 1), d + assert (d := common_prefix_depth(b2.id(), b5.id(), states)) == (2, 2), d + assert (d := common_prefix_depth(b5.id(), b2.id(), states)) == (2, 2), d + assert (d := common_prefix_depth(b3.id(), b5.id(), states)) == (3, 2), d + assert (d := common_prefix_depth(b5.id(), b3.id(), states)) == (2, 3), d + assert (d := common_prefix_depth(b3.id(), b6.id(), states)) == (1, 1), d + assert (d := common_prefix_depth(b6.id(), b3.id(), states)) == (1, 1), d + assert (d := common_prefix_depth(b3.id(), b7.id(), states)) == (1, 2), d + assert (d := common_prefix_depth(b7.id(), b3.id(), states)) == (2, 1), d + assert (d := common_prefix_depth(b5.id(), b7.id(), states)) == (2, 4), d + assert (d := common_prefix_depth(b7.id(), b5.id(), states)) == (4, 2), d + def test_fork_choice_long_sparse_chain(self): # The longest chain is not dense after the fork + genesis = mk_genesis_state([]).block + short_coin, long_coin = Coin(sk=0, value=100), Coin(sk=1, value=100) - common, long_coin = mk_chain(parent=bytes(32), coin=long_coin, slots=range(50)) + common, long_coin = mk_chain(parent=genesis, coin=long_coin, slots=range(50)) long_chain_sparse_ext, long_coin = mk_chain( - parent=common[-1].id(), coin=long_coin, slots=range(50, 100, 2) + parent=common[-1], coin=long_coin, slots=range(50, 100, 2) ) short_chain_dense_ext, _ = mk_chain( - parent=common[-1].id(), coin=short_coin, slots=range(50, 100) + parent=common[-1], coin=short_coin, slots=range(50, 100) ) # add more blocks to the long chain to ensure the long chain is indeed longer long_chain_further_ext, _ = mk_chain( - parent=long_chain_sparse_ext[-1].id(), coin=long_coin, slots=range(100, 126) + parent=long_chain_sparse_ext[-1], coin=long_coin, slots=range(100, 126) ) long_chain = deepcopy(common) + long_chain_sparse_ext + long_chain_further_ext @@ -45,26 +87,34 @@ class TestForkChoice(TestCase): k = 1 s = 50 - 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 + states = {b.id(): LedgerState(block=b) for b in short_chain + long_chain} + + assert ( + maxvalid_bg(short_chain[-1].id(), [long_chain[-1].id()], k, s, states) + == short_chain[-1].id() + ) # However, if we set k to the fork length, it will be accepted - k = long_chain.length() - assert maxvalid_bg(short_chain, [long_chain], k, s) == long_chain + k = len(long_chain) + assert ( + maxvalid_bg(short_chain[-1].id(), [long_chain[-1].id()], k, s, states) + == long_chain[-1].id() + ) def test_fork_choice_long_dense_chain(self): # The longest chain is also the densest after the fork short_coin, long_coin = Coin(sk=0, value=100), Coin(sk=1, value=100) common, long_coin = mk_chain( - parent=bytes(32), coin=long_coin, slots=range(1, 50) + parent=mk_genesis_state([]).block, + coin=long_coin, + slots=range(1, 50), ) long_chain_dense_ext, _ = mk_chain( - parent=common[-1].id(), coin=long_coin, slots=range(50, 100) + parent=common[-1], coin=long_coin, slots=range(50, 100) ) short_chain_sparse_ext, _ = mk_chain( - parent=common[-1].id(), coin=short_coin, slots=range(50, 100, 2) + parent=common[-1], coin=short_coin, slots=range(50, 100, 2) ) long_chain = deepcopy(common) + long_chain_dense_ext @@ -72,9 +122,12 @@ class TestForkChoice(TestCase): k = 1 s = 50 - 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 + states = {b.id(): LedgerState(block=b) for b in short_chain + long_chain} + + assert ( + maxvalid_bg(short_chain[-1].id(), [long_chain[-1].id()], k, s, states) + == long_chain[-1].id() + ) def test_fork_choice_integration(self): c_a, c_b = Coin(sk=0, value=10), Coin(sk=1, value=10) @@ -88,7 +141,7 @@ class TestForkChoice(TestCase): follower.on_block(b1) assert follower.tip_id() == b1.id() - assert follower.forks == [] + assert follower.forks == [], follower.forks # -- then we fork -- # @@ -99,14 +152,14 @@ class TestForkChoice(TestCase): # b3 # - b2, c_a = mk_block(b1.id(), 2, c_a), c_a.evolve() - b3, c_b = mk_block(b1.id(), 2, c_b), c_b.evolve() + b2, c_a = mk_block(b1, 2, c_a), c_a.evolve() + b3, c_b = mk_block(b1, 2, c_b), c_b.evolve() follower.on_block(b2) follower.on_block(b3) assert follower.tip_id() == b2.id() - assert len(follower.forks) == 1 and follower.forks[0].tip_id() == b3.id() + assert len(follower.forks) == 1 and follower.forks[0] == b3.id() # -- extend the fork causing a re-org -- # @@ -117,8 +170,8 @@ class TestForkChoice(TestCase): # b3 - b4 == tip # - b4, c_b = mk_block(b3.id(), 3, c_b), c_a.evolve() + b4, c_b = mk_block(b3, 3, c_b), c_a.evolve() follower.on_block(b4) assert follower.tip_id() == b4.id() - assert len(follower.forks) == 1 and follower.forks[0].tip_id() == b2.id() + assert len(follower.forks) == 1 and follower.forks[0] == b2.id(), follower.forks diff --git a/cryptarchia/test_leader.py b/cryptarchia/test_leader.py index 3f6918c..08d662a 100644 --- a/cryptarchia/test_leader.py +++ b/cryptarchia/test_leader.py @@ -2,24 +2,15 @@ from unittest import TestCase import numpy as np -from .cryptarchia import ( - Leader, - Config, - EpochState, - LedgerState, - Coin, - phi, - TimeConfig, - Slot, -) +from .cryptarchia import Leader, EpochState, LedgerState, Coin, phi, Slot from .test_common import mk_config class TestLeader(TestCase): def test_slot_leader_statistics(self): epoch = EpochState( - stake_distribution_snapshot=LedgerState(), - nonce_snapshot=LedgerState(nonce=b"1010101010"), + stake_distribution_snapshot=LedgerState(block=None), + nonce_snapshot=LedgerState(block=None, nonce=b"1010101010"), inferred_total_active_stake=1000, ) diff --git a/cryptarchia/test_ledger_state_update.py b/cryptarchia/test_ledger_state_update.py index f724891..d67abd4 100644 --- a/cryptarchia/test_ledger_state_update.py +++ b/cryptarchia/test_ledger_state_update.py @@ -2,22 +2,33 @@ from unittest import TestCase import numpy as np -from .cryptarchia import ( - Follower, - TimeConfig, - BlockHeader, - Config, - Coin, - LedgerState, - MockLeaderProof, - Slot, - Id, -) +from .cryptarchia import Follower, Coin, iter_chain from .test_common import mk_config, mk_block, mk_genesis_state class TestLedgerStateUpdate(TestCase): + def test_on_block_idempotent(self): + leader_coin = Coin(sk=0, value=100) + genesis = mk_genesis_state([leader_coin]) + + follower = Follower(genesis, mk_config([leader_coin])) + + block = mk_block(slot=0, parent=genesis.block, coin=leader_coin) + follower.on_block(block) + + # Follower should have accepted the block + assert len(list(iter_chain(follower.tip_id(), follower.ledger_state))) == 2 + assert follower.tip() == block + + follower.on_block(block) + + # Should have been a No-op + assert len(list(iter_chain(follower.tip_id(), follower.ledger_state))) == 2 + assert follower.tip() == block + assert len(follower.ledger_state) == 2 + assert len(follower.forks) == 0 + def test_ledger_state_prevents_coin_reuse(self): leader_coin = Coin(sk=0, value=100) genesis = mk_genesis_state([leader_coin]) @@ -28,17 +39,17 @@ class TestLedgerStateUpdate(TestCase): follower.on_block(block) # Follower should have accepted the block - assert follower.local_chain.length() == 1 + assert len(list(iter_chain(follower.tip_id(), follower.ledger_state))) == 2 assert follower.tip() == block # Follower should have updated their ledger state to mark the leader coin as spent assert follower.tip_state().verify_unspent(leader_coin.nullifier()) == False - reuse_coin_block = mk_block(slot=1, parent=block.id(), coin=leader_coin) - follower.on_block(block) + reuse_coin_block = mk_block(slot=1, parent=block, coin=leader_coin) + follower.on_block(reuse_coin_block) # Follower should *not* have accepted the block - assert follower.local_chain.length() == 1 + assert len(list(iter_chain(follower.tip_id(), follower.ledger_state))) == 2 assert follower.tip() == block def test_ledger_state_is_properly_updated_on_reorg(self): @@ -67,7 +78,7 @@ class TestLedgerStateUpdate(TestCase): # 4) then coin[2] wins slot 1 and chooses to extend from block_2 - block_3 = mk_block(parent=block_2.id(), slot=1, coin=coin[2]) + block_3 = mk_block(parent=block_2, slot=1, coin=coin[2]) follower.on_block(block_3) # the follower should have switched over to the block_2 fork assert follower.tip() == block_3 @@ -89,37 +100,37 @@ class TestLedgerStateUpdate(TestCase): follower.on_block(block_2) assert follower.tip() == block_1 assert len(follower.forks) == 1, f"{len(follower.forks)}" - assert follower.forks[0].tip() == block_2 + assert follower.forks[0] == block_2.id() # coin_2 wins slot 1 and chooses to extend from block_1 # coin_3 also wins slot 1 and but chooses to extend from block_2 # Both blocks are accepted. Both the local chain and the fork grow. No fork is newly created. - block_3 = mk_block(parent=block_1.id(), slot=1, coin=coins[2]) - block_4 = mk_block(parent=block_2.id(), slot=1, coin=coins[3]) + block_3 = mk_block(parent=block_1, slot=1, coin=coins[2]) + block_4 = mk_block(parent=block_2, slot=1, coin=coins[3]) follower.on_block(block_3) follower.on_block(block_4) assert follower.tip() == block_3 assert len(follower.forks) == 1, f"{len(follower.forks)}" - assert follower.forks[0].tip() == block_4 + assert follower.forks[0] == block_4.id() # coin_4 wins slot 1 and but chooses to extend from block_2 as well # The block is accepted. A new fork is created "from the block_2". - block_5 = mk_block(parent=block_2.id(), slot=1, coin=coins[4]) + block_5 = mk_block(parent=block_2, slot=1, coin=coins[4]) follower.on_block(block_5) assert follower.tip() == block_3 assert len(follower.forks) == 2, f"{len(follower.forks)}" - assert follower.forks[0].tip() == block_4 - assert follower.forks[1].tip() == block_5 + assert follower.forks[0] == block_4.id() + assert follower.forks[1] == block_5.id() # A block based on an unknown parent is not accepted. # Nothing changes from the local chain and forks. - unknown_block = mk_block(parent=block_5.id(), slot=2, coin=coins[5]) - block_6 = mk_block(parent=unknown_block.id(), slot=2, coin=coins[6]) + unknown_block = mk_block(parent=block_5, slot=2, coin=coins[5]) + block_6 = mk_block(parent=unknown_block, slot=2, coin=coins[6]) follower.on_block(block_6) assert follower.tip() == block_3 assert len(follower.forks) == 2, f"{len(follower.forks)}" - assert follower.forks[0].tip() == block_4 - assert follower.forks[1].tip() == block_5 + assert follower.forks[0] == block_4.id() + assert follower.forks[1] == block_5.id() def test_epoch_transition(self): leader_coins = [Coin(sk=i, value=100) for i in range(4)] @@ -138,14 +149,14 @@ class TestLedgerStateUpdate(TestCase): assert follower.tip() == block_1 assert follower.tip().slot.epoch(config).epoch == 0 - block_2 = mk_block(slot=19, parent=block_1.id(), coin=leader_coins[1]) + block_2 = mk_block(slot=19, parent=block_1, coin=leader_coins[1]) follower.on_block(block_2) assert follower.tip() == block_2 assert follower.tip().slot.epoch(config).epoch == 0 # ---- EPOCH 1 ---- - block_3 = mk_block(slot=20, parent=block_2.id(), coin=leader_coins[2]) + block_3 = mk_block(slot=20, parent=block_2, coin=leader_coins[2]) follower.on_block(block_3) assert follower.tip() == block_3 assert follower.tip().slot.epoch(config).epoch == 1 @@ -157,7 +168,7 @@ class TestLedgerStateUpdate(TestCase): # To ensure this is the case, we add a new coin just to the state associated with that slot, # so that the new block can be accepted only if that is the snapshot used # first, verify that if we don't change the state, the block is not accepted - block_4 = mk_block(slot=40, parent=block_3.id(), coin=Coin(sk=4, value=100)) + block_4 = mk_block(slot=40, parent=block_3, coin=Coin(sk=4, value=100)) follower.on_block(block_4) assert follower.tip() == block_3 # then we add the coin to "spendable commitments" associated with slot 9 @@ -181,12 +192,12 @@ class TestLedgerStateUpdate(TestCase): assert follower.tip() == block_1 # coin can't be reused to win following slots: - block_2_reuse = mk_block(slot=1, parent=block_1.id(), coin=coin) + block_2_reuse = mk_block(slot=1, parent=block_1, coin=coin) follower.on_block(block_2_reuse) assert follower.tip() == block_1 # but the evolved coin is eligible - block_2_evolve = mk_block(slot=1, parent=block_1.id(), coin=coin.evolve()) + block_2_evolve = mk_block(slot=1, parent=block_1, coin=coin.evolve()) follower.on_block(block_2_evolve) assert follower.tip() == block_2_evolve @@ -212,12 +223,12 @@ class TestLedgerStateUpdate(TestCase): ) # the new coin is not yet eligible for elections - block_0_1_attempt = mk_block(slot=1, parent=block_0_0.id(), coin=coin_new) + block_0_1_attempt = mk_block(slot=1, parent=block_0_0, coin=coin_new) follower.on_block(block_0_1_attempt) assert follower.tip() == block_0_0 # whereas the evolved coin from genesis can be spent immediately - block_0_1 = mk_block(slot=1, parent=block_0_0.id(), coin=coin.evolve()) + block_0_1 = mk_block(slot=1, parent=block_0_0, coin=coin.evolve()) follower.on_block(block_0_1) assert follower.tip() == block_0_1 @@ -226,7 +237,7 @@ class TestLedgerStateUpdate(TestCase): # The newly minted coin is still not eligible in the following epoch since the # stake distribution snapshot is taken at the beginning of the previous epoch - block_1_0 = mk_block(slot=20, parent=block_0_1.id(), coin=coin_new) + block_1_0 = mk_block(slot=20, parent=block_0_1, coin=coin_new) follower.on_block(block_1_0) assert follower.tip() == block_0_1 @@ -234,16 +245,12 @@ class TestLedgerStateUpdate(TestCase): # The coin is finally eligible 2 epochs after it was first minted - block_2_0 = mk_block( - slot=40, - parent=block_0_1.id(), - coin=coin_new, - ) + block_2_0 = mk_block(slot=40, parent=block_0_1, coin=coin_new) follower.on_block(block_2_0) assert follower.tip() == block_2_0 # And now the minted coin can freely use the evolved coin for subsequent blocks - block_2_1 = mk_block(slot=40, parent=block_2_0.id(), coin=coin_new.evolve()) + block_2_1 = mk_block(slot=40, parent=block_2_0, coin=coin_new.evolve()) follower.on_block(block_2_1) assert follower.tip() == block_2_1 @@ -259,7 +266,7 @@ class TestLedgerStateUpdate(TestCase): 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) + block_0_1 = mk_block(slot=1, parent=block_0_0, 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 @@ -272,7 +279,7 @@ class TestLedgerStateUpdate(TestCase): orphan = mk_block(parent=genesis.block, slot=0, coin=coin_orphan) block_0_1 = mk_block( slot=1, - parent=block_0_0.id(), + parent=block_0_0, coin=coin_orphan.evolve(), orphaned_proofs=[orphan], ) diff --git a/cryptarchia/test_orphaned_proofs.py b/cryptarchia/test_orphaned_proofs.py index 41a1ccb..0114464 100644 --- a/cryptarchia/test_orphaned_proofs.py +++ b/cryptarchia/test_orphaned_proofs.py @@ -1,21 +1,8 @@ from unittest import TestCase -from itertools import repeat -import numpy as np -import hashlib -from copy import deepcopy -from cryptarchia.cryptarchia import ( - maxvalid_bg, - Chain, - BlockHeader, - Slot, - Id, - MockLeaderProof, - Coin, - Follower, -) +from cryptarchia.cryptarchia import Coin, Follower -from .test_common import mk_chain, mk_config, mk_genesis_state, mk_block +from .test_common import mk_config, mk_genesis_state, mk_block class TestOrphanedProofs(TestCase): @@ -36,15 +23,15 @@ class TestOrphanedProofs(TestCase): # b1, c_a = mk_block(genesis.block, 1, c_a), c_a.evolve() - b2, c_a = mk_block(b1.id(), 2, c_a), c_a.evolve() - b3, c_b = mk_block(b1.id(), 2, c_b), c_b.evolve() + b2, c_a = mk_block(b1, 2, c_a), c_a.evolve() + b3, c_b = mk_block(b1, 2, c_b), c_b.evolve() for b in [b1, b2, b3]: follower.on_block(b) assert follower.tip() == b2 - assert [f.tip() for f in follower.forks] == [b3] - assert follower.unimported_orphans(follower.tip_id()) == [b3] + assert [f for f in follower.forks] == [b3.id()] + assert follower.unimported_orphans() == [b3] # -- extend with import -- # @@ -54,12 +41,12 @@ class TestOrphanedProofs(TestCase): # \ / # b3 # - b4, c_a = mk_block(b2.id(), 3, c_a, orphaned_proofs=[b3]), c_a.evolve() + b4, c_a = mk_block(b2, 3, c_a, orphaned_proofs=[b3]), c_a.evolve() follower.on_block(b4) assert follower.tip() == b4 - assert [f.tip() for f in follower.forks] == [b3] - assert follower.unimported_orphans(follower.tip_id()) == [] + assert [f for f in follower.forks] == [b3.id()] + assert follower.unimported_orphans() == [] def test_orphan_proof_import_from_long_running_fork(self): c_a, c_b = Coin(sk=0, value=10), Coin(sk=1, value=10) @@ -79,18 +66,18 @@ class TestOrphanedProofs(TestCase): b1, c_a = mk_block(genesis.block, 1, c_a), c_a.evolve() - b2, c_a = mk_block(b1.id(), 2, c_a), c_a.evolve() - b3, c_a = mk_block(b2.id(), 3, c_a), c_a.evolve() + b2, c_a = mk_block(b1, 2, c_a), c_a.evolve() + b3, c_a = mk_block(b2, 3, c_a), c_a.evolve() - b4, c_b = mk_block(b1.id(), 2, c_b), c_b.evolve() - b5, c_b = mk_block(b4.id(), 3, c_b), c_b.evolve() + b4, c_b = mk_block(b1, 2, c_b), c_b.evolve() + b5, c_b = mk_block(b4, 3, c_b), c_b.evolve() for b in [b1, b2, b3, b4, b5]: follower.on_block(b) assert follower.tip() == b3 - assert [f.tip() for f in follower.forks] == [b5] - assert follower.unimported_orphans(follower.tip_id()) == [b4, b5] + assert [f for f in follower.forks] == [b5.id()] + assert follower.unimported_orphans() == [b4, b5] # -- extend b3, importing the fork -- # @@ -100,11 +87,11 @@ class TestOrphanedProofs(TestCase): # \ / / # b4 - b5 - b6, c_a = mk_block(b3.id(), 4, c_a, orphaned_proofs=[b4, b5]), c_a.evolve() + b6, c_a = mk_block(b3, 4, c_a, orphaned_proofs=[b4, b5]), c_a.evolve() follower.on_block(b6) assert follower.tip() == b6 - assert [f.tip() for f in follower.forks] == [b5] + assert [f for f in follower.forks] == [b5.id()] def test_orphan_proof_import_from_fork_without_direct_shared_parent(self): coins = [Coin(sk=i, value=10) for i in range(2)] @@ -123,20 +110,20 @@ class TestOrphanedProofs(TestCase): b1, c_a = mk_block(genesis.block, 1, c_a), c_a.evolve() - b2, c_a = mk_block(b1.id(), 2, c_a), c_a.evolve() - b3, c_a = mk_block(b2.id(), 3, c_a), c_a.evolve() - b4, c_a = mk_block(b3.id(), 4, c_a), c_a.evolve() + b2, c_a = mk_block(b1, 2, c_a), c_a.evolve() + b3, c_a = mk_block(b2, 3, c_a), c_a.evolve() + b4, c_a = mk_block(b3, 4, c_a), c_a.evolve() - b5, c_b = mk_block(b1.id(), 2, c_b), c_b.evolve() - b6, c_b = mk_block(b5.id(), 3, c_b), c_b.evolve() - b7, c_b = mk_block(b6.id(), 4, c_b), c_b.evolve() + b5, c_b = mk_block(b1, 2, c_b), c_b.evolve() + b6, c_b = mk_block(b5, 3, c_b), c_b.evolve() + b7, c_b = mk_block(b6, 4, c_b), c_b.evolve() for b in [b1, b2, b3, b4, b5, b6, b7]: follower.on_block(b) assert follower.tip() == b4 - assert [f.tip() for f in follower.forks] == [b7] - assert follower.unimported_orphans(follower.tip_id()) == [b5, b6, b7] + assert [f for f in follower.forks] == [b7.id()] + assert follower.unimported_orphans() == [b5, b6, b7] # -- extend b4, importing the forks -- # @@ -149,12 +136,12 @@ class TestOrphanedProofs(TestCase): # Earlier implementations of orphan proof validation failed to # validate b7 as an orphan here. - b8, c_a = mk_block(b4.id(), 5, c_a, orphaned_proofs=[b5, b6, b7]), c_a.evolve() + b8, c_a = mk_block(b4, 5, c_a, orphaned_proofs=[b5, b6, b7]), c_a.evolve() follower.on_block(b8) assert follower.tip() == b8 - assert [f.tip() for f in follower.forks] == [b7] - assert follower.unimported_orphans(follower.tip_id()) == [] + assert [f for f in follower.forks] == [b7.id()] + assert follower.unimported_orphans() == [] def test_unimported_orphans(self): # Given the following fork graph: @@ -187,22 +174,22 @@ class TestOrphanedProofs(TestCase): b1, c_a = mk_block(genesis.block, 1, c_a), c_a.evolve() - b2, c_a = mk_block(b1.id(), 2, c_a), c_a.evolve() - b3, c_a = mk_block(b2.id(), 3, c_a), c_a.evolve() + b2, c_a = mk_block(b1, 2, c_a), c_a.evolve() + b3, c_a = mk_block(b2, 3, c_a), c_a.evolve() - b4, c_b = mk_block(b1.id(), 2, c_b), c_b.evolve() - b5, c_b = mk_block(b4.id(), 3, c_b), c_b.evolve() + b4, c_b = mk_block(b1, 2, c_b), c_b.evolve() + b5, c_b = mk_block(b4, 3, c_b), c_b.evolve() - b6, c_c = mk_block(b4.id(), 3, c_c), c_c.evolve() + b6, c_c = mk_block(b4, 3, c_c), c_c.evolve() for b in [b1, b2, b3, b4, b5, b6]: follower.on_block(b) assert follower.tip() == b3 - assert [f.tip() for f in follower.forks] == [b5, b6] - assert follower.unimported_orphans(follower.tip_id()) == [b4, b5, b6] + assert [f for f in follower.forks] == [b5.id(), b6.id()] + assert follower.unimported_orphans() == [b4, b5, b6] - b7, c_a = mk_block(b3.id(), 4, c_a, orphaned_proofs=[b4, b5, b6]), c_a.evolve() + b7, c_a = mk_block(b3, 4, c_a, orphaned_proofs=[b4, b5, b6]), c_a.evolve() follower.on_block(b7) assert follower.tip() == b7 @@ -235,30 +222,30 @@ class TestOrphanedProofs(TestCase): follower = Follower(genesis, config) b1, c_a = mk_block(genesis.block, 1, c_a), c_a.evolve() - b2, c_a = mk_block(b1.id(), 2, c_a), c_a.evolve() - b3, c_a = mk_block(b2.id(), 3, c_a), c_a.evolve() + b2, c_a = mk_block(b1, 2, c_a), c_a.evolve() + b3, c_a = mk_block(b2, 3, c_a), c_a.evolve() - b4, c_b = mk_block(b3.id(), 4, c_b), c_b.evolve() - b5, c_a = mk_block(b3.id(), 4, c_a), c_a.evolve() + b4, c_b = mk_block(b3, 4, c_b), c_b.evolve() + b5, c_a = mk_block(b3, 4, c_a), c_a.evolve() - b6, c_b = mk_block(b4.id(), 5, c_b, orphaned_proofs=[b5]), c_b.evolve() - b7, c_a = mk_block(b4.id(), 5, c_a, orphaned_proofs=[b5]), c_a.evolve() + b6, c_b = mk_block(b4, 5, c_b, orphaned_proofs=[b5]), c_b.evolve() + b7, c_a = mk_block(b4, 5, c_a, orphaned_proofs=[b5]), c_a.evolve() - b8, c_b = mk_block(b6.id(), 6, c_b, orphaned_proofs=[b7]), c_b.evolve() + b8, c_b = mk_block(b6, 6, c_b, orphaned_proofs=[b7]), c_b.evolve() for b in [b1, b2, b3, b4, b5]: follower.on_block(b) assert follower.tip() == b4 - assert follower.unimported_orphans(follower.tip_id()) == [b5] + assert follower.unimported_orphans() == [b5] for b in [b6, b7]: follower.on_block(b) assert follower.tip() == b6 - assert follower.unimported_orphans(follower.tip_id()) == [b7] + assert follower.unimported_orphans() == [b7] follower.on_block(b8) assert follower.tip() == b8 - assert follower.unimported_orphans(follower.tip_id()) == [] + assert follower.unimported_orphans() == [] diff --git a/cryptarchia/test_stake_relativization.py b/cryptarchia/test_stake_relativization.py index 6cc3f36..cfa9473 100644 --- a/cryptarchia/test_stake_relativization.py +++ b/cryptarchia/test_stake_relativization.py @@ -1,5 +1,4 @@ from unittest import TestCase -from dataclasses import dataclass import itertools import numpy as np @@ -29,13 +28,13 @@ class TestStakeRelativization(TestCase): # on fork, tip state is not updated orphan = mk_block(genesis.block, slot=1, coin=c_b) follower.on_block(orphan) - assert follower.tip_state().block == b1.id() + assert follower.tip_state().block == b1 assert follower.tip_state().leader_count == 1 # after orphan is adopted, leader count should jumpy by 2 (each orphan counts as a leader) - b2 = mk_block(b1.id(), slot=2, coin=c_a.evolve(), orphaned_proofs=[orphan]) + b2 = mk_block(b1, slot=2, coin=c_a.evolve(), orphaned_proofs=[orphan]) follower.on_block(b2) - assert follower.tip_state().block == b2.id() + assert follower.tip_state().block == b2 assert follower.tip_state().leader_count == 3 def test_inference_on_empty_genesis_epoch(self):