From ada1ee2d5ae3231c3f36a1e0d561fa98b9fd7abe Mon Sep 17 00:00:00 2001 From: Giacomo Pasini Date: Wed, 28 May 2025 12:43:06 +0200 Subject: [PATCH] Add Boostrapping/Online modes Add Boostrapping and Online modes to cryptarchia, including relevant tests. The Boostrap mode uses the Genesis fc rule, while Online uses Praos. Swtitching between the two rules is left to the implementation and is specified in the public Notion as linked in the comment --- cryptarchia/cryptarchia.py | 68 +++++++++++++++++++++++++++++---- cryptarchia/test_fork_choice.py | 56 +++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 8 deletions(-) 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