diff --git a/cryptarchia/cryptarchia.py b/cryptarchia/cryptarchia.py index 05d7d07..7311240 100644 --- a/cryptarchia/cryptarchia.py +++ b/cryptarchia/cryptarchia.py @@ -7,6 +7,7 @@ from dataclasses import dataclass, field, replace from hashlib import blake2b, sha256 from math import floor from typing import Dict, Generator, List, TypeAlias +from enum import Enum import numpy as np @@ -308,6 +309,9 @@ class EpochState: def nonce(self) -> bytes: return self.nonce_snapshot.nonce +class State(Enum): + ONLINE = 1 + BOOTSTRAPPING = 2 class Follower: def __init__(self, genesis_state: LedgerState, config: Config): @@ -317,6 +321,17 @@ class Follower: self.genesis_state = genesis_state self.ledger_state = {genesis_state.block.id(): genesis_state.copy()} self.epoch_state = {} + self.state = State.BOOTSTRAPPING + + def to_online(self): + """ + Call this method when the follower has finished bootstrapping. While this is somewhat left to implementations + https://www.notion.so/Cryptarchia-v1-Bootstrapping-Synchronization-1fd261aa09df81ac94b5fb6a4eff32a6 contains a great deal + of information and is the reference for the Rust implementation. + """ + if self.state != State.BOOTSTRAPPING: + raise RuntimeError("Follower is not in BOOTSTRAPPING state") + self.state = State.ONLINE def validate_header(self, block: BlockHeader): # TODO: verify blocks are not in the 'future' @@ -368,13 +383,23 @@ class Follower: # Evaluate the fork choice rule and return the chain we should be following def fork_choice(self) -> Hash: - return maxvalid_bg( - self.local_chain, - self.forks, - k=self.config.k, - s=self.config.s, - states=self.ledger_state, - ) + if self.state == State.BOOTSTRAPPING: + return maxvalid_bg( + self.local_chain, + self.forks, + k=self.config.k, + s=self.config.s, + states=self.ledger_state, + ) + elif self.state == State.ONLINE: + return maxvalid_mc( + self.local_chain, + self.forks, + k=self.config.k, + states=self.ledger_state, + ) + else: + raise RuntimeError(f"Unknown follower state: {self.state}") def tip(self) -> BlockHeader: return self.tip_state().block @@ -592,7 +617,7 @@ def block_children(states: Dict[Hash, LedgerState]) -> Dict[Hash, set[Hash]]: return children -# Implementation of the Cryptarchia fork choice rule (following Ouroborous Genesis). +# Implementation of the Ouroboros Genesis fork choice rule. # 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 @@ -633,6 +658,33 @@ def maxvalid_bg( return cmax +# Implementation of the Ouroboros Praos fork choice rule. +# 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 discard the fork +# +# k defines the forking depth of a chain at which point we switch phases. +def maxvalid_mc( + local_chain: Hash, + forks: List[Hash], + k: int, + states: Dict[Hash, LedgerState], +) -> Hash: + assert type(local_chain) == Hash, type(local_chain) + assert all(type(f) == Hash for f in forks) + + cmax = local_chain + for fork in forks: + cmax_depth, cmax_suffix, fork_depth, fork_suffix = common_prefix_depth( + cmax, fork, states + ) + if cmax_depth <= k: + # Longest chain fork choice rule + if cmax_depth < fork_depth: + cmax = fork + + return cmax + class ParentNotFound(Exception): def __str__(self): return "Parent not found" diff --git a/cryptarchia/test_fork_choice.py b/cryptarchia/test_fork_choice.py index 45f197b..0a59dd3 100644 --- a/cryptarchia/test_fork_choice.py +++ b/cryptarchia/test_fork_choice.py @@ -3,6 +3,7 @@ from unittest import TestCase from copy import deepcopy from cryptarchia.cryptarchia import ( maxvalid_bg, + maxvalid_mc, Slot, Note, Follower, @@ -200,6 +201,11 @@ class TestForkChoice(TestCase): == short_chain[-1].id() ) + assert ( + maxvalid_mc(short_chain[-1].id(), [long_chain[-1].id()], k,states) + == short_chain[-1].id() + ) + # However, if we set k to the fork length, it will be accepted k = len(long_chain) assert ( @@ -207,6 +213,11 @@ class TestForkChoice(TestCase): == long_chain[-1].id() ) + assert ( + maxvalid_mc(short_chain[-1].id(), [long_chain[-1].id()], k, states) + == long_chain[-1].id() + ) + def test_fork_choice_long_dense_chain(self): # The longest chain is also the densest after the fork short_note, long_note = Note(sk=0, value=100), Note(sk=1, value=100) @@ -235,6 +246,13 @@ class TestForkChoice(TestCase): == long_chain[-1].id() ) + # praos fc rule should not accept a chain that diverged more than k blocks, + # even if it is longer + assert ( + maxvalid_mc(short_chain[-1].id(), [long_chain[-1].id()], k, states) + == short_chain[-1].id() + ) + def test_fork_choice_integration(self): n_a, n_b = Note(sk=0, value=10), Note(sk=1, value=10) notes = [n_a, n_b] @@ -281,3 +299,41 @@ class TestForkChoice(TestCase): assert follower.tip_id() == b4.id() assert len(follower.forks) == 1 and follower.forks[0] == b2.id(), follower.forks + + # -- switch to online mode -- + follower.to_online() + + # -- extend a fork deeper than k -- + # + # + # b2 - b5 - b6 + # / + # b1 + # \ + # b3 - b4 == tip + # + b5 = mk_block(b2, 3, n_a) + b6 = mk_block(b5, 4, n_a) + follower.on_block(b5) + follower.on_block(b6) + + assert follower.tip_id() == b4.id() + assert len(follower.forks) == 1 and follower.forks[0] == b6.id() + + # -- extend the main chain shallower than k -- + # + # + # b2 - b5 - b6 + # / + # b1 + # \ + # b3 - b4 + # \ + # - - b7 - b8 == tip + b7 = mk_block(b3, 4, n_b) + b8 = mk_block(b7, 5, n_b) + + follower.on_block(b7) + follower.on_block(b8) + assert follower.tip_id() == b8.id() + assert len(follower.forks) == 2 and {b6.id(), b4.id()}.issubset(follower.forks) \ No newline at end of file