From c2e05a48c53be94951adc42933c221bb5115dd58 Mon Sep 17 00:00:00 2001 From: Daniel Sanchez Date: Fri, 5 May 2023 09:58:58 +0200 Subject: [PATCH] Runnable Python implementation (#7) * use coarse grained events * Add timeout handler * Added runnable carnot implementation bare bones * Removed rusty_results dependency * Make easy tests * Python impl tests (#9) * Make easy tests * Made sure an old aggregatedQC is not used. * Test when a block has an old qc * adding highest voted view so that a node doesn't vote twice. * adding highest voted view so that a node doesn't vote twice. * Tests for voting * Tests for voting * Tests for voting * Tests for voting * Update test_happy_path.py --------- Co-authored-by: mjalalzai <33738574+MForensic@users.noreply.github.com> * Fix commit grandparent * Add tests assertions * Added base vote test * Tests for updating latest_committed_view and high_qc. * Vote tests * Vote tests * Get max timeout by highQC * Received Votes tests * Receive timeout msgs * Receive timeout msgs * Remove local files * Stylish, adjustments and fixes * Start unhappy path and update tests (#10) * Tests for updating latest_committed_view and high_qc. * Vote tests * Vote tests * Get max timeout by highQC * Received Votes tests * Receive timeout msgs * Receive timeout msgs * Remove local files * Stylish, adjustments and fixes --------- Co-authored-by: mjalalzai <33738574+MForensic@users.noreply.github.com> * Update build timeout qc test * Added block content * Fix tests with block content and comments * Fix all timeouts are from the same view in timeout call * Add check for double view seen different block * Store just highest qc and aggregated views in AggregatedQc, * Fix timeout preconditions * Happy + Unhappy path implementation and tests (#15) * Implement timeouts unhappy path * views are sequential or consecutive. * Make sure view changes are incrementally done in ascending order. * Make sure view changes are incrementally done in ascending order. * Make sure view changes are incrementally done in ascending order. * Cleanup * refactor * Overlay based happy tests (#11) * Cleanup * Leaf committee member vote. * Leaf committee member vote, test. * Leaf committee member vote, test. * Description * Description * Description-refactoring * Cleanup * Fix leaf votes test * Clean overlay * Test single committee advances * Remove unhappy path test file * Update carnot description * Refactor local_timeout for NewView * Fix unhappy path conditions and added broadcasting * Unhappy path tests description * Commit all grandparents of a block from latest_committed view * Cleanup docs * Add unhappy path test vector * Remove block content --------- Co-authored-by: mjalalzai <33738574+MForensic@users.noreply.github.com> * Extract MockCarnot in happy path tests * Add is_safe_to_timeout checks on unhappy path methods * Fix happy math mockoverlay missing methods * Fix unhappy path double increments Fill missing qc building implementations * Implement first case of unhappy path with simple overlay Missing final assertions * Unhappy path base test assertions (#14) * Unhappy path tests replacing timeout msgs with NewView. Also revising the conditions to enter into the unhappy path. * Unhappy path tests assertions. * Fix assertions on test * Cleanup --------- Co-authored-by: mjalalzai <33738574+MForensic@users.noreply.github.com> * Remove redundant code and fix commit parent (#16) * Fix unhappy path tests (#17) * fix test * get highest qc from new view votes * Add missing assertion * Cleanup approve new view function * Remove calls to safe_timeout invariant * Fix approve new view * Make unhappy test continuously timeout * Increment current view on reciving a new qc Refactor reset last timeout qc * Fix happy path current view after receiving block qc * Complete mix succeed fails unhappy test * Refactor timeout_high_qc test to use fail method * Add block assertions on mixed unhappy test * Simplify approve block * add spec tests to ci * Extract implicit information from safe blocks (#19) * extract implicit information from safe blocks * fix test * Refactor last_view_timeout_qc and update calls on unhappy path * Update view upon reception of timeout qc (#20) * update view upon reception of timeout qc * only increase highest_voted_view * fix comments * [WIP] Use events instead of send/broadcast methods (#21) * Remove send and broadcast, use events * Adjust tests to use events * Adjust unhappy path tests to use events * Fix missing wrongly optional return types * Extrac common assert on propose_block * add informative comments and remove panic (#22) * add disclaimer (#23) * fix approve_new_view preconditions --------- Co-authored-by: mjalalzai <33738574+MForensic@users.noreply.github.com> Co-authored-by: Giacomo Pasini Co-authored-by: Giacomo Pasini --------- Co-authored-by: Giacomo Pasini Co-authored-by: mjalalzai <33738574+MForensic@users.noreply.github.com> Co-authored-by: Giacomo Pasini --- .github/workflows/ci.yml | 17 ++ .gitignore | 79 ----- carnot/__init__.py | 1 + carnot/carnot.py | 594 ++++++++++++++++++++++++++++++++++++ carnot/spec.md | 230 +++++++------- carnot/test_happy_path.py | 441 ++++++++++++++++++++++++++ carnot/test_unhappy_path.py | 265 ++++++++++++++++ 7 files changed, 1431 insertions(+), 196 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .gitignore create mode 100644 carnot/__init__.py create mode 100644 carnot/carnot.py create mode 100644 carnot/test_happy_path.py create mode 100644 carnot/test_unhappy_path.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..744d6d3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: Spec tests + +on: [pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.x + uses: actions/setup-python@v4 + with: + # Semantic version range syntax or exact version of a Python version + python-version: '3.x' + - name: Run tests + run: cd carnot && python -m unittest diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 93794e6..0000000 --- a/.gitignore +++ /dev/null @@ -1,79 +0,0 @@ -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# SonarLint plugin -.idea/sonarlint/ - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.serpython -python -venv \ No newline at end of file diff --git a/carnot/__init__.py b/carnot/__init__.py new file mode 100644 index 0000000..397eb42 --- /dev/null +++ b/carnot/__init__.py @@ -0,0 +1 @@ +from .carnot import * diff --git a/carnot/carnot.py b/carnot/carnot.py new file mode 100644 index 0000000..1c1a355 --- /dev/null +++ b/carnot/carnot.py @@ -0,0 +1,594 @@ +# The Carnot protocol is designed to be elastic, responsive, and provide fast finality +# Elastic scalability allows the protocol to operate effectively with both small and large networks +# All nodes in the Carnot network participate in the consensus of a block +# Optimistic responsiveness enables the protocol to operate quickly during periods of synchrony and honest leadership +# There is no block generation time in Carnot, allowing for fast finality +# Carnot avoids the chain reorg problem, making it compatible with PoS schemes +# This enhances the robustness of the protocol, making it a valuable addition to the ecosystem of consensus protocols + + +# The protocol in Carnot operates in two modes: the happy path and the unhappy path. +# +# In Carnot, nodes are arranged in a binary tree overlay committee structure. Moreover, Carnot is a +# pipelined consensus protocol where a block contains the proof of attestation of its parent. In happy path the +# leader proposes a block that contains a quorum certificate (QC) with votes from more than two-thirds of the root +# committee and its child committee/ committees. The voting process begins at the leaf committee where nodes verify +# the proposal and send their votes to the parent committee. Once a node in the parent committee receives more than +# two-thirds of the votes from its child committee members, it sends its votes to its parent. This process continues +# recursively until the root committee members collect votes from its child committee/ committees. The root committee +# member builds a QC from the votes and sends it to the next leader. The leader builds a QC and proposes the next block +# upon receiving more than two-thirds of votes. + + +# In the unhappy path, if a node does not receive a message within a timeout interval, it will timeout. Only nodes at +# the root committee and its child committee/ committees send their timeout messages to the root committee. The root +# committee builds a timeout QC from more than two-thirds of messages, recalculates the new overlay, and broadcasts it +# to the network. Similar to the happy path, the timeout message moves from leaves to the root. Each parent waits for +# more than two-thirds of timeout messages from its child committees and sends its timeout to the parent committee once +# the threshold is reached. A node in the root committee builds a QC from timeout messages received from its +# child committee/committees and forwards it to the next leader. Upon receiving more than two-thirds of timeout +# messages, the next leader builds an aggregated QC and proposes the next block containing the aggregated QC. +# It should be noted that while receiving timeout messages, each node also updates its high_qc (the most recent QC) +# and passes it to its parent through the timeout message. In this way, the aggregated QC will include the high_qc seen +# by the majority of honest nodes. Hence, after the view change, the protocol safety is preserved. + + +# Please note this is still a work in progress + +from dataclasses import dataclass +from typing import TypeAlias, List, Set, Self, Optional, Dict, FrozenSet +from abc import abstractmethod + + +Id: TypeAlias = bytes +View: TypeAlias = int +Committee: TypeAlias = Set[Id] + + +def int_to_id(i: int) -> Id: + return bytes(str(i), encoding="utf8") + + +@dataclass(unsafe_hash=True) +class StandardQc: + block: Id + view: View + + def view(self) -> View: + return self.view + + +@dataclass +class AggregateQc: + qcs: List[View] + highest_qc: StandardQc + view: View + + def view(self) -> View: + return self.view + + def high_qc(self) -> StandardQc: + assert self.highest_qc.view == max(self.qcs) + return self.highest_qc + + +Qc: TypeAlias = StandardQc | AggregateQc + + +@dataclass +class Block: + view: View + qc: Qc + _id: Id # this is an abstration over the block id, which should be the hash of the contents + + def extends(self, ancestor: Self) -> bool: + """ + :param ancestor: + :return: true if block is descendant of the ancestor in the chain + """ + return self.view > ancestor.view + + def parent(self) -> Id: + match self.qc: + case StandardQc(block): + return block + case AggregateQc() as aqc: + return aqc.high_qc().block + + def id(self) -> Id: + return self._id + + +@dataclass(unsafe_hash=True) +class Vote: + block: Id + view: View + voter: Id + qc: Optional[Qc] + + +@dataclass +class TimeoutQc: + view: View + high_qc: Qc + qc_views: List[View] + sender_ids: Set[Id] + sender: Id + + +@dataclass +class Timeout: + """ + Local timeout field is only used by the root committee and its children when they timeout. The timeout_qc is built + from local_timeouts. Leaf nodes when receive timeout_qc build their timeout msg and includes the timeout_qc in it. + The timeout_qc is indicator that the root committee and its child committees (if exist) have failed to collect votes. + """ + view: View + high_qc: Qc + sender: Id + timeout_qc: TimeoutQc + + +# Timeout has been detected, nodes agree on it and gather high qc +@dataclass +class NewView: + view: View + high_qc: Qc + sender: Id + timeout_qc: TimeoutQc + + +Quorum: TypeAlias = Set[Vote] | Set[NewView] + + +Payload: TypeAlias = Block | Vote | Timeout | NewView | TimeoutQc + +@dataclass +class BroadCast: + payload: Payload + + +@dataclass +class Send: + to: [Id] + payload: Payload + + +Event: TypeAlias = BroadCast | Send + +class Overlay: + """ + Overlay structure for a View + """ + + @abstractmethod + def is_leader(self, _id: Id): + """ + :param _id: Node id to be checked + :return: true if node is the leader of the current view + """ + pass + + @abstractmethod + def leader(self, view: View) -> Id: + """ + :param view: + :return: the leader Id of the specified view + """ + pass + + @abstractmethod + def is_member_of_leaf_committee(self, _id: Id) -> bool: + """ + :param _id: Node id to be checked + :return: true if the participant with Id _id is in the leaf committee of the committee overlay + """ + pass + + @abstractmethod + def is_member_of_root_committee(self, _id: Id) -> bool: + """ + :param _id: + :return: true if the participant with Id _id is member of the root committee withing the tree overlay + """ + pass + + @abstractmethod + def is_member_of_child_committee(self, parent: Id, child: Id) -> bool: + """ + :param parent: + :param child: + :return: true if participant with Id child is member of the child committee of the participant with Id parent + """ + pass + + @abstractmethod + def parent_committee(self, _id: Id) -> Optional[Committee]: + """ + :param _id: + :return: Some(parent committee) of the participant with Id _id withing the committee tree overlay + or Empty if the member with Id _id is a participant of the root committee + """ + pass + + @abstractmethod + def leaf_committees(self) -> Set[Committee]: + pass + + @abstractmethod + def root_committee(self) -> Committee: + """ + :return: returns root committee + """ + pass + + @abstractmethod + def is_child_of_root_committee(self, _id: Id) -> bool: + """ + :return: returns child committee/s of root committee if present + """ + pass + + @abstractmethod + def leader_super_majority_threshold(self, _id: Id) -> int: + """ + Amount of distinct number of messages for a node with Id _id member of a committee + The return value may change depending on which committee the node is member of, including the leader + :return: + """ + pass + + @abstractmethod + def super_majority_threshold(self, _id: Id) -> int: + pass + + +def download(view) -> Block: + raise NotImplementedError + + +class Carnot: + def __init__(self, _id: Id): + self.id: Id = _id + # Current View counter + # It is the view currently being processed by the node. Once a Qc is received, the view is considered completed + # and the current view is updated to qc.view+1 + self.current_view: View = 0 + # Highest voted view counter. This is used to prevent a node from voting twice or vote after timeout. + self.highest_voted_view: View = -1 + # This is most recent (in terms of view) Standard QC that has been received by the node + self.local_high_qc: Optional[Qc] = None + # Validated blocks with their validated QCs are included here. If commit conditions are satisfied for + # each one of these blocks it will be committed. + self.safe_blocks: Dict[Id, Block] = dict() + # Whether the node timeed out in the last view and corresponding qc + self.last_view_timeout_qc: Optional[TimeoutQc] = None + self.overlay: Overlay = Overlay() # TODO: integrate overlay + + + # Committing conditions for a block + # TODO: explain the conditions in comment + def can_commit_grandparent(self, block) -> bool: + parent = self.safe_blocks.get(block.parent()) + grand_parent = self.safe_blocks.get(parent.parent()) + # this case should just trigger on genesis_case, + # as the preconditions on outer calls should check on block validity + if not parent or not grand_parent: + return False + return ( + parent.view == (grand_parent.view + 1) and + isinstance(block.qc, (StandardQc,)) and + isinstance(parent.qc, (StandardQc,)) + ) + + + # The latest committed view is implicit in the safe blocks tree given + # the committing conditions. + # For convenience, this is an helper method to retrieve that value. + def latest_committed_view(self) -> View: + return self.latest_committed_block().view + + # Return the list of blocks received by a node for a specific view. + # It will return more than one block only in case of a malicious leader + def blocks_in_view(self, view: View) -> List[Block]: + return [block for block in self.safe_blocks.values() if block.view == view] + + def genesis_block(self) -> Block: + return self.blocks_in_view(0)[0] + + def latest_committed_block(self) -> Block: + for view in range(self.current_view, 0, -1): + for block in self.blocks_in_view(view): + if self.can_commit_grandparent(block): + return self.safe_blocks.get(self.safe_blocks.get(block.parent()).parent()) + # genesis blocks is always considered committed + return self.genesis_block() + + # Given committing conditions, the set of committed blocks is implicit + # in the safe blocks tree. For convenience, this is an helper method to + # retrieve that set. + def committed_blocks(self) -> Dict[Id, Block]: + tip = self.latest_committed_block() + committed_blocks = {tip.id(): tip, self.genesis_block().id: self.genesis_block()} + while tip.view > 0: + committed_blocks[tip.id()] = tip + tip = self.safe_blocks.get(tip.parent()) + return committed_blocks + + def block_is_safe(self, block: Block) -> bool: + return ( + block.view >= self.current_view and + block.view == block.qc.view + 1 + ) + + # Ask Dani + def update_high_qc(self, qc: Qc): + match (self.local_high_qc, qc): + case (None, StandardQc() as new_qc): + self.local_high_qc = new_qc + case (None, AggregateQc() as new_qc): + self.local_high_qc = new_qc.high_qc() + case (old_qc, StandardQc() as new_qc) if new_qc.view > old_qc.view: + self.local_high_qc = new_qc + case (old_qc, AggregateQc() as new_qc) if new_qc.high_qc().view != old_qc.view: + self.local_high_qc = new_qc.high_qc() + # if my view is not updated I update it when I see a qc for that view + if qc.view == self.current_view: + self.current_view = self.current_view + 1 + + def update_timeout_qc(self, timeout_qc: TimeoutQc): + match (self.last_view_timeout_qc, timeout_qc): + case (None, timeout_qc): + self.last_view_timeout_qc = timeout_qc + case (self.last_view_timeout_qc, timeout_qc) if timeout_qc.view > self.last_view_timeout_qc.view: + self.last_view_timeout_qc = timeout_qc + + def receive_block(self, block: Block): + assert block.parent() in self.safe_blocks + + if block.id() in self.safe_blocks: + return + if self.blocks_in_view(block.view) != [] or block.view <= self.latest_committed_view(): + # TODO: Report malicious leader + # TODO: it could be possible that a malicious leader send a block to a node and another one to + # the rest of the network. The node should be able to catch up with the rest of the network after having + # validated that the history of the block is correct and diverged from its fork. + # By rejecting any other blocks except the first one received for a view this code does NOT do that. + return + + # TODO: check the proposer of the block is indeed leader for that view + + if self.block_is_safe(block): + self.safe_blocks[block.id()] = block + self.update_high_qc(block.qc) + + def approve_block(self, block: Block, votes: Set[Vote]) -> Event: + assert block.id() in self.safe_blocks + assert len(votes) == self.overlay.super_majority_threshold(self.id) + assert all(self.overlay.is_member_of_child_committee(self.id, vote.voter) for vote in votes) + assert all(vote.block == block.id() for vote in votes) + assert self.highest_voted_view < block.view + + if self.overlay.is_member_of_root_committee(self.id): + qc = self.build_qc(block.view, block, None) + else: + qc = None + + vote: Vote = Vote( + block=block.id(), + voter=self.id, + view=block.view, + qc=qc + ) + + self.highest_voted_view = max(self.highest_voted_view, block.view) + + if self.overlay.is_member_of_root_committee(self.id): + return Send(to=self.overlay.leader(block.view + 1), payload=vote) + return Send(to=self.overlay.parent_committee(self.id), payload=vote) + + def forward_vote(self, vote: Vote) -> Optional[Event]: + assert vote.block in self.safe_blocks + assert self.overlay.is_member_of_child_committee(self.id, vote.voter) + # we only forward votes after we've voted ourselves + assert self.highest_voted_view == vote.view + + if self.overlay.is_member_of_root_committee(self.id): + return Send(to=self.overlay.leader(self.current_view + 1), payload=vote) + + def forward_new_view(self, msg: NewView) -> Optional[Event]: + assert msg.view == self.current_view + assert self.overlay.is_member_of_child_committee(self.id, msg.sender) + # we only forward votes after we've voted ourselves + assert self.highest_voted_view == msg.view + + if self.overlay.is_member_of_root_committee(self.id): + return Send(to=self.overlay.leader(self.current_view + 1), payload=msg) + + def build_qc(self, view: View, block: Optional[Block], new_views: Optional[Set[NewView]]) -> Qc: + # unhappy path + if new_views: + new_views = list(new_views) + return AggregateQc( + qcs=[msg.high_qc.view for msg in new_views], + highest_qc=max(new_views, key=lambda x: x.high_qc.view).high_qc, + view=new_views[0].view + ) + # happy path + return StandardQc( + view=view, + block=block.id() + ) + + def propose_block(self, view: View, quorum: Quorum) -> Event: + assert self.overlay.is_leader(self.id) + assert len(quorum) >= self.overlay.leader_super_majority_threshold(self.id) + + qc = None + quorum = list(quorum) + # happy path + if isinstance(quorum[0], Vote): + vote = quorum[0] + qc = self.build_qc(vote.view, self.safe_blocks[vote.block], None) + # unhappy path + elif isinstance(quorum[0], NewView): + new_view = quorum[0] + qc = self.build_qc(new_view.view, None, quorum) + + block = Block( + view=view, + qc=qc, + # Dummy id for proposing next block + _id=int_to_id(hash( + ( + bytes(f"{view}".encode(encoding="utf8")), + bytes(f"{qc.view}".encode(encoding="utf8")) + ) + )) + ) + return BroadCast(payload=block) + + def is_safe_to_timeout_invariant( + self, + ): + """ + Local timeout is different for the root and its child committees. If other committees timeout, they only + stop taking part in consensus. If a member of root or its child committees timeout it sends its timeout message + to all members of root to build the timeout qc. Using this qc we assume that the new + overlay can be built. Hence, by building the new overlay members of root committee can send the timeout qc + to the leaf committee of the new overlay. Upon receipt of the timeout qc the leaf committee members update + their local_high_qc, last_timeout_view_qc and last_voted_view if the view of qcs + (local_high_qc, last_timeout_view_qc) received is higher than their local view. Similarly last_voted_view is + updated if it is greater than the current last_voted_view. When parent committee member receives more than two + third of timeout messages from its children it also updates its local_high_qc, last_timeout_view_qc and + last_voted_view if needed and then send its timeout message upward. In this way the latest qcs move upward + that makes it possible for the next leader to propose a block with the latest local_high_qcs in aggregated qc + from more than two third members of root committee and its children. + """ + + # Make sure the node doesn't time out continuously without finishing the step to increment the current view. + # Make sure current view is always higher than the local_high_qc so that the node won't timeout unnecessary + # for a previous view. + assert self.current_view > max(self.highest_voted_view - 1, self.local_high_qc.view) + # This condition makes sure a node waits for timeout_qc from root committee to change increment its view with + # a view change. + # A node must change its view after making sure it has the high_Qc or last_timeout_view_qc + # from previous view. + return ( + self.current_view == self.local_high_qc.view + 1 or + self.current_view == self.last_view_timeout_qc.view + 1 or + (self.current_view == self.last_view_timeout_qc.view) + ) + + def local_timeout(self) -> Optional[Event]: + """ + Root committee changes for each failure, so repeated failure will be handled by different + root committees + """ + # avoid voting after we timeout + self.highest_voted_view = self.current_view + + if self.overlay.is_member_of_root_committee(self.id) or self.overlay.is_child_of_root_committee(self.id): + timeout_msg: Timeout = Timeout( + view=self.current_view, + high_qc=self.local_high_qc, + # local_timeout is only true for the root committee or members of its children + # root committee or its children can trigger the timeout. + timeout_qc=self.last_view_timeout_qc, + sender=self.id + ) + return Send(payload=timeout_msg, to=self.overlay.root_committee()) + + def timeout_detected(self, msgs: Set[Timeout]) -> Event: + """ + Root committee detected that supermajority of root + its children has timed out + The view has failed and this information is sent to all participants along with the information + necessary to reconstruct the new overlay + + """ + assert len(msgs) == self.overlay.leader_super_majority_threshold(self.id) + assert all(msg.view >= self.current_view for msg in msgs) + assert len(set(msg.view for msg in msgs)) == 1 + assert self.overlay.is_member_of_root_committee(self.id) + + timeout_qc = self.build_timeout_qc(msgs, self.id) + return BroadCast(payload=timeout_qc) # we broadcast so all nodes can get ready for voting on a new view + # Note that receive_timeout qc should be called for root nodes as well + + # noinspection PyTypeChecker + def approve_new_view(self, timeout_qc: TimeoutQc, new_views: Set[NewView]) -> Event: + """ + We will always need for timeout_qc to have been preprocessed by the received_timeout_qc method when the event + happens before approve_new_view is processed. + """ + # newView.view == self.last_timeout_view_qc.view for member of root committee and its children because + # they have already created the timeout_qc. For other nodes newView.view > self.last_timeout_view_qc.view. + if self.last_view_timeout_qc is not None: + assert all(new_view.view > self.last_view_timeout_qc.view for new_view in new_views) + assert all(new_view.timeout_qc.view == timeout_qc.view for new_view in new_views) + assert len(new_views) == self.overlay.super_majority_threshold(self.id) + assert all(self.overlay.is_member_of_child_committee(self.id, new_view.sender) for new_view in new_views) + # the new view should be for the view successive to the timeout + assert all(timeout_qc.view + 1 == new_view.view for new_view in new_views) + view = timeout_qc.view + 1 + assert self.highest_voted_view < view + + # get the highest qc from the new views + messages_high_qc = (new_view.high_qc for new_view in new_views) + high_qc = max( + [timeout_qc.high_qc, *messages_high_qc], + key=lambda qc: qc.view + ) + self.update_high_qc(high_qc) + timeout_msg = NewView( + view=view, + # TODO: even if this event is processed "later", we should not allow high_qc.view to be >= timeout_qc.view + high_qc=self.local_high_qc, + sender=self.id, + timeout_qc=timeout_qc, + ) + + # This checks if a node has already incremented its voted view by local_timeout. If not then it should + # do it now to avoid voting in this view. + self.highest_voted_view = max(self.highest_voted_view, view) + + if self.overlay.is_member_of_root_committee(self.id): + return Send(payload=timeout_msg, to=[self.overlay.leader(self.current_view + 1)]) + return Send(payload=timeout_msg, to=self.overlay.parent_committee(self.id)) + + + + # Just a suggestion that received_timeout_qc can be reused by each node when the process timeout_qc of the NewView msg. + # TODO: check that receiving (and processing) a timeout qc "in the future" allows to process old(er) blocks + # e.g. we might still need access to the old leader schedule to validate qcs + def receive_timeout_qc(self, timeout_qc: TimeoutQc): + if timeout_qc.view < self.current_view: + return + new_high_qc = timeout_qc.high_qc + self.update_high_qc(new_high_qc) + self.update_timeout_qc(timeout_qc) + # Update our current view and go ahead with the next step + self.update_current_view_from_timeout_qc(timeout_qc) + self.rebuild_overlay_from_timeout_qc(timeout_qc) + + def rebuild_overlay_from_timeout_qc(self, timeout_qc: TimeoutQc): + assert timeout_qc.view >= self.current_view + self.overlay = Overlay() + + @staticmethod + def build_timeout_qc(msgs: Set[Timeout], sender: Id) -> TimeoutQc: + msgs = list(msgs) + return TimeoutQc( + view=msgs[0].view, + high_qc=max(msgs, key=lambda x: x.high_qc.view).high_qc, + qc_views=[msg.view for msg in msgs], + sender_ids={msg.sender for msg in msgs}, + sender=sender, + ) + + def update_current_view_from_timeout_qc(self, timeout_qc: TimeoutQc): + self.current_view = timeout_qc.view + 1 + + +if __name__ == "__main__": + pass diff --git a/carnot/spec.md b/carnot/spec.md index ab4d54f..9dda94b 100644 --- a/carnot/spec.md +++ b/carnot/spec.md @@ -6,6 +6,10 @@ In addition, all types can be expected to have their invariants checked by the t 'Q:' is used to indicate unresolved questions. Notation is loosely based on CDDL. +Similar to the Carnot algorithm, this specification will be event-based, prescribing the actions to perform in response to relevant events in the Carnot consensus. +Events should be processed one at a time, picking any from the available ones. +As for ordering between events, there are some constraints (e.g. can't process a proposal before it's parent) which will likely form a DAG of relations. The expectation is that an implementation will respect these requirements by processing only events which have all preconditions satisfied. + ## Messages A critical piece in the protocol, these are the different kind of messages used by participants during the protocol execution. * `Block`: propose a new block @@ -93,7 +97,6 @@ class Timeout: view: View high_qc: AggregateQc ``` - ## Local Variables Participants in the protocol are expected to mainting the following data in addition to the DAG of received proposal: * `current_view` @@ -105,7 +108,8 @@ Participants in the protocol are expected to mainting the following data in addi CURRENT_VIEW: View LOCAL_HIGH_QC: Qc LATEST_COMMITTED_VIEW: View -COLLECTION: Q? +SAFE_BLOCKS: Set[Block] +LAST_VIEW_TIMEOUT_QC: TimeoutQc ``` @@ -140,38 +144,42 @@ The following functions are expected to be available to participants during the -## Core functions +## Core events + +These are the core events necessary for the Carnot consensus protocol. In response to such events a participant is expected to execute the corresponding handler action. + +* receive block b -> `receive_block(b)` + Preconditions: + * `b.parent() in SAFE_BLOCKS` +* receive a supermajority of votes for block b -> `vote(b, votes)` + Preconditions: + * `b in SAFE_BLOCKS` + * `local_timeout(b.view)` never called +* receive a vote v for block b when a supermajority of votes already exists -> `forward_votes(b, v)` + Preconditions: + * `b in SAFE_BLOCKS` + * `vote(b, some_votes)` already called and `v not in some_votes` + * `local_timeout(b.view)` never called +* `current_time() - time(last view update) > TIMEOUT` and received new overlay -> `local_timeout(last view, new_overlay)` +* leader for view v and leader supermajority for previous proposal -> `propose_block(v, votes)` +* receive a supermajority of timeouts for view v -> `timeout(v, timeouts)` + Preconditions: + * `local_timeout(v)` already called + -These are the core functions necessary for the Carnot consensus protocol, to be executed in response of incoming messages, except for `timeout` which is triggered by a participant configurable timer. - ### Receive block ```python3 def receive_block(block: Block): - if block.id() is known or block.view <=LATEST_COMMITTED_VIEW: - return + # checking preconditions + assert block.parent() in SAFE_BLOCKS - # Recursively make sure that we process blocks in order - if block.parent() is missing: - parent: Block = download(block.parent()) - receive(parent) + if block.id() in SAFE_BLOCKS or block.view <= LATEST_COMMITTED_VIEW: + return if safe_block(block): - # This is not in the original spec, but - # let's validate I have this clear. - assert block.view == current_view - + SAFE_BLOCKS.add(block) update_high_qc(block.qc) - - vote = create_vote() - if member_of_leaf_committee(): - if member_of_root_committee(): - send(vote, leader(current_view + 1)) - else: - send(vote, parent_commitee()) - current_view += 1 - reset_timer() - try_to_commit_grandparent(block) ``` ##### Auxiliary functions @@ -198,6 +206,8 @@ def safe_block(block: Block): ``` ```python +# FIX_ME: Don't think we need to specify this as a function if we don't use +# LAST_COMMITTED_VIEW # Commit a grand parent if the grandparent and # the parent have been added during two consecutive views. def try_to_commit_grand_parent(block: Block): @@ -231,110 +241,96 @@ def update_high_qc(qc: Qc): # Q: same thing about missing views ``` -### Receive Vote - -Q: this whole function needs to be revised +### Vote ```python +def vote(block: Block, votes: Set[Vote]): + # check preconditions + assert block in SAFE_BLOCKS + assert supermajority(votes) + assert all(child_committee(vote.id) for vote in votes) + assert all(vote.block == block for vote in votes) -def receive_vote(vote: Vote): - if vote.block is missing: - block = download(vote.block) - receive(block) + vote = create_vote(votes) - # Q: we should probably return if we already received this vote - if member_of_internal_com() and not_member_of_root(): - if child_commitee(vote.voter): - COLLECTION[vote.block].append(vote) - else: - # Q: not returning here would mean it's extremely easy to - # trigger building a new vote in the following branches - return - - if supermajority(COLLECTION[vote.block]): - # Q: should we send it to everyone in the committee? - self_vote = build_vote() - send(self_vote, parent_committee) - # Q: why here? - current_view += 1 - reset_timer() - # Q: why do we do this here? - try_to_commit_grand_parent(block) - if member_of_root(): - if child_commitee(vote.voter): - COLLECTION[vote.block].append(vote) - else: - # Q: not returning here would mean it's extremely easy to - # trigger building a new vote in the following branches - return - - if supermajority(COLLECTION[vote.block]): - # Q: The vote to send is not the one received but - # the one build by this participant, right? - self_vote = build_vote(); - qc = build_qc(collection[vote.block]) - self_vote.qc=qc - send(self_vote, leader(current_view + 1)) - # Q: why here? - current_view += 1 - reset_timer() - # Q: why here? - try_to_commit_grandparent(block) - - - # Q: this means that we send a message for every incoming - # message after the threshold has been reached, i.e. a vote - # from a node in the leaf committee can trigger - # at least height(tree) messages. - if morethanSsupermajority(collection[vote.block]): - # just forward the vote to the leader - # Q: But then childcommitte(vote.voter) would return false - # in the leader, as it's a granchild, not a child - send(vote, leader(current_view + 1)) - - if leader(view): # Q? Which view? CURRENT_VIEW or vote.view? - if vote.view < CURRENT_VIEW - 1: - return - - # Q: No filtering? I can just create a key and vote? - COLLECTION[vote.block].append(vote) - if supermajority(collection[vote.block]): - qc = build_qc(collection[vote.block]) - block = build_block(qc) - broadcast(block) + vote.qc = build_qc(votes) + send(vote, leader(CURRENT_VIEW + 1)) + else: + send(vote, parent_committee()) + + # Q: what about a node that is joining later and does not + # have access to votes? how does it commit blocks? + current_view += 1 + reset_timer() + try_to_commit_grandparent(block) ``` -### Receive Timeout + +### Forward vote ```python -# Failure Case -def receive(timeout: Timeout): - # download the missing block - if timeout.high_qc().block is missing: - block = download(timeout.high_qc.block) - receive(block) +def forward_vote(vote: Vote): + assert vote.block in SAFE_BLOCKS + assert child_committe(vote.id) + # already supermajority - # It's an old message. Ignore it. - if timeout.view < CURRENT_VIEW: - return - - update_high_qc(timeout.high_qc()) - - if member_of_internal_com(): - COLLECTION[timeout.view].append(timeout) - if supermajority(timeout.view): - new_view_qc = build_qc(COLLECTION[timeout.view]) - if member_of_root(): - send(new_view_qc, leader(CURRENT_VIEW +1)) - CURRENT_VIEW += 1 - else: - send(new_view_qc, parent_committee()) + if member_of_root(): + # just forward the vote to the leader + # Q: But then childcommitte(vote.voter) would return false + # in the leader, as it's a granchild, not a child + send(vote, leader(vote.block.view + 1)) ``` + +### Propose block +```python +def propose_block(view: View, quorum: Set[Vote] | Set[TimeoutMsg]): + assert leader(view) + assert leader_supermajority(quorum) + + qc = build_qc(votes) + block = build_block(qc) + broadcast(block) +``` + + ### Timeout ```python -def timeout(): - raise NotImplementedError -``` +def local_timeout(new_overlay: Overlay): + # make it so we don't vote or forward any more vote after this + LAST_TIMEOUT_VIEW = CURRENT_VIEW + # TODO: change overlay + + if member_of_leaf(): + timeout_msg = create_timeout(CURRENT_VIEW, LOCAL_HIGH_QC, LAST_TIMEOUT_VIEW_QC) + send(timeout_msg, parent_committee()) +``` + +### Receive +this is called *after* local_timeout +```python +def timeout(view: View, msgs: Set[TimeoutMsg]): + assert supermajority(msgs) + assert all(child_committee(msg.id) for msg in msgs) + assert all(timeout.view == view for timeout in msgs) + + + if CURRENT_VIEW > view: + return + if view <= LAST_VIEW_TIMEOUT_QC.view: + return + + if view > LOCAL_HIGH_QC.view: + LOCAL_HIGH_QC = timeout_Msg.high_qc + + timeout_qc = create_timeout_qc(msgs) + increment_view_timeout_qc(timeout_qc.view) + LAST_VIEW_TIMEOUT_QC = timeout_qc + send(timeout_qc, own_committee()) ####helps nodes to sync quicker but not required + if member_of_root(): + send(timeout_qc, leader(view+1)) + else: + send(timeout_qc, parent_committee()) +``` We need to make sure that qcs can't be removed from aggQc when going up the tree \ No newline at end of file diff --git a/carnot/test_happy_path.py b/carnot/test_happy_path.py new file mode 100644 index 0000000..1193731 --- /dev/null +++ b/carnot/test_happy_path.py @@ -0,0 +1,441 @@ +from carnot import * +from unittest import TestCase + + +class TestCarnotHappyPath(TestCase): + @staticmethod + def add_genesis_block(carnot: Carnot) -> Block: + genesis_block = Block(view=0, qc=StandardQc(block=b"", view=0), _id=b"") + carnot.safe_blocks[genesis_block.id()] = genesis_block + return genesis_block + + def test_receive_block(self): + carnot = Carnot(int_to_id(0)) + genesis_block = self.add_genesis_block(carnot) + block = Block(view=1, qc=StandardQc(block=genesis_block.id(), view=0), _id=b"1") + carnot.receive_block(block) + + def test_receive_multiple_blocks_for_the_same_view(self): + carnot = Carnot(int_to_id(0)) + genesis_block = self.add_genesis_block(carnot) + # 1 + block1 = Block(view=1, qc=StandardQc(block=genesis_block.id(), view=0), _id=b"1") + carnot.receive_block(block1) + + # 2 + block2 = Block(view=2, qc=StandardQc(block=block1.id(), view=1), _id=b"2") + carnot.receive_block(block2) + + # 3 + block3 = Block(view=3, qc=StandardQc(block=block2.id(), view=2), _id=b"3") + carnot.receive_block(block3) + # 4 + block4 = Block(view=4, qc=StandardQc(block=block3.id(), view=3), _id=b"4") + carnot.receive_block(block4) + self.assertEqual(len(carnot.safe_blocks), 5) + # next block is duplicated and as it is already processed should be skipped + block5 = Block(view=4, qc=StandardQc(block=block3.id(), view=3), _id=b"4") + carnot.receive_block(block5) + self.assertEqual(len(carnot.safe_blocks), 5) + # next block has a different view but is duplicated and as it is already processed should be skipped + block5 = Block(view=5, qc=StandardQc(block=block3.id(), view=4), _id=b"4") + carnot.receive_block(block5) + self.assertEqual(len(carnot.safe_blocks), 5) + + def test_receive_block_has_old_view_number(self): + carnot = Carnot(int_to_id(0)) + genesis_block = self.add_genesis_block(carnot) + # 1 + block1 = Block(view=1, qc=StandardQc(block=genesis_block.id(), view=0), _id=b"1") + carnot.receive_block(block1) + + # 2 + block2 = Block(view=2, qc=StandardQc(block=block1.id(), view=1), _id=b"2") + carnot.receive_block(block2) + + # 3 + block3 = Block(view=3, qc=StandardQc(block=block2.id(), view=2), _id=b"3") + carnot.receive_block(block3) + # 4 + block4 = Block(view=4, qc=StandardQc(block=block3.id(), view=3), _id=b"4") + carnot.receive_block(block4) + + self.assertEqual(len(carnot.safe_blocks), 5) + # This block should be rejected based on the condition below in block_is_safe(). + # block.view >= self.latest_committed_view and block.view == (standard.view + 1) + # block_is_safe() should return false. + block5 = Block(view=3, qc=StandardQc(block=block4.id(), view=4), _id=b"5") + self.assertFalse(carnot.block_is_safe(block5)) + carnot.receive_block(block5) + self.assertEqual(len(carnot.safe_blocks), 5) + + def test_receive_block_has_an_old_qc(self): + carnot = Carnot(int_to_id(0)) + genesis_block = self.add_genesis_block(carnot) + # 1 + block1 = Block(view=1, qc=StandardQc(block=genesis_block.id(), view=0), _id=b"1") + carnot.receive_block(block1) + + # 2 + block2 = Block(view=2, qc=StandardQc(block=block1.id(), view=1), _id=b"2") + carnot.receive_block(block2) + + # 3 + block3 = Block(view=3, qc=StandardQc(block=block2.id(), view=2), _id=b"3") + carnot.receive_block(block3) + # 4 + block4 = Block(view=4, qc=StandardQc(block=block3.id(), view=3), _id=b"4") + carnot.receive_block(block4) + + self.assertEqual(len(carnot.safe_blocks), 5) + # 5 This is the old standard qc of block number 3. For standard QC we must always have qc.view==block.view-1. + # This block should be rejected based on the condition below in block_is_safe(). + # block.view >= self.latest_committed_view and block.view == (standard.view + 1) + # block_is_safe() should return false. + block5 = Block(view=5, qc=StandardQc(block=block3.id(), view=3), _id=b"5") + self.assertFalse(carnot.block_is_safe(block5)) + carnot.receive_block(block5) + self.assertEqual(len(carnot.safe_blocks), 5) + + def test_receive_block_and_commit_its_grand_parent_chain(self): + """ + Any block with block.view < 4 must be committed + """ + carnot = Carnot(int_to_id(0)) + genesis_block = self.add_genesis_block(carnot) + # 1 + block1 = Block(view=1, qc=StandardQc(block=genesis_block.id(), view=0), _id=b"1") + carnot.receive_block(block1) + + # 2 + block2 = Block(view=2, qc=StandardQc(block=block1.id(), view=1), _id=b"2") + carnot.receive_block(block2) + + # 3 + block3 = Block(view=3, qc=StandardQc(block=block2.id(), view=2), _id=b"3") + carnot.receive_block(block3) + # 4 + block4 = Block(view=4, qc=StandardQc(block=block3.id(), view=3), _id=b"4") + carnot.receive_block(block4) + + block5 = Block(view=5, qc=StandardQc(block=block4.id(), view=4), _id=b"5") + carnot.receive_block(block5) + for block in (block1, block2, block3): + self.assertIn(block.id(), carnot.committed_blocks()) + + def test_receive_block_has_an_old_qc_and_tries_to_revert_a_committed_block(self): + """ + Block3 must be committed as it is the grandparent of block5. Hence, it should not be possible + to avert it. + """ + carnot = Carnot(int_to_id(0)) + genesis_block = self.add_genesis_block(carnot) + # 1 + block1 = Block(view=1, qc=StandardQc(block=genesis_block.id(), view=0), _id=b"1") + carnot.receive_block(block1) + + # 2 + block2 = Block(view=2, qc=StandardQc(block=block1.id(), view=1), _id=b"2") + carnot.receive_block(block2) + + # 3 + block3 = Block(view=3, qc=StandardQc(block=block2.id(), view=2), _id=b"3") + carnot.receive_block(block3) + # 4 + block4 = Block(view=4, qc=StandardQc(block=block3.id(), view=3), _id=b"4") + carnot.receive_block(block4) + + self.assertEqual(len(carnot.safe_blocks), 5) + # 5 This is the old standard qc of block number 2. By using the QC for block2, block5 tries to form a fork + # to avert block3 and block b4. Block3 is a committed block + # block_is_safe() should return false. + block5 = Block(view=5, qc=StandardQc(block=block2.id(), view=2), _id=b"5") + self.assertFalse(carnot.block_is_safe(block5)) + carnot.receive_block(block5) + self.assertEqual(len(carnot.safe_blocks), 5) + + def test_receive_block_and_verify_if_latest_committed_block_and_high_qc_is_updated(self): + carnot = Carnot(int_to_id(0)) + genesis_block = self.add_genesis_block(carnot) + # 1 + block1 = Block(view=1, qc=StandardQc(block=genesis_block.id(), view=0), _id=b"1") + carnot.receive_block(block1) + + # 2 + block2 = Block(view=2, qc=StandardQc(block=block1.id(), view=1), _id=b"2") + carnot.receive_block(block2) + + # 3 + block3 = Block(view=3, qc=StandardQc(block=block2.id(), view=2), _id=b"3") + carnot.receive_block(block3) + # 4 + block4 = Block(view=4, qc=StandardQc(block=block3.id(), view=3), _id=b"4") + carnot.receive_block(block4) + + self.assertEqual(len(carnot.safe_blocks), 5) + block5 = Block(view=5, qc=StandardQc(block=block4.id(), view=4), _id=b"5") + carnot.receive_block(block5) + self.assertEqual(carnot.latest_committed_view(), 3) + self.assertEqual(carnot.local_high_qc.view, 4) + + # Test cases for vote: + def test_vote_for_received_block(self): + """ + 1: Votes received should increment highest_voted_view and current_view but should not change + latest_committed_view and last_timeout_view + """ + + class MockOverlay(Overlay): + def is_member_of_root_committee(self, _id: Id) -> bool: + return False + + def is_member_of_child_committee(self, parent: Id, child: Id) -> bool: + return True + + def super_majority_threshold(self, _id: Id) -> int: + return 10 + + def parent_committee(self, _id: Id) -> Optional[Committee]: + return set() + + carnot = Carnot(int_to_id(0)) + carnot.overlay = MockOverlay() + genesis_block = self.add_genesis_block(carnot) + # 1 + block1 = Block(view=1, qc=StandardQc(block=genesis_block.id(), view=0), _id=b"1") + carnot.receive_block(block1) + votes = set( + Vote( + voter=int_to_id(i), + view=1, + block=block1.id(), + qc=StandardQc(block=block1.id(), view=1) + ) for i in range(10) + ) + carnot.approve_block(block1, votes) + self.assertEqual(carnot.highest_voted_view, 1) + self.assertEqual(carnot.current_view, 1) + self.assertEqual(carnot.latest_committed_view(), 0) + self.assertEqual(carnot.last_view_timeout_qc, None) + + def test_vote_for_received_block_if_threshold_votes_has_not_reached(self): + """ + 2 If last_voted_view is incremented after calling vote with votes lower than. + """ + + class MockOverlay(Overlay): + def is_member_of_root_committee(self, _id: Id) -> bool: + return False + + def is_member_of_child_committee(self, parent: Id, child: Id) -> bool: + return True + + def super_majority_threshold(self, _id: Id) -> int: + return 10 + + def parent_committee(self, _id: Id) -> Optional[Committee]: + return set() + + carnot = Carnot(int_to_id(0)) + carnot.overlay = MockOverlay() + genesis_block = self.add_genesis_block(carnot) + # 1 + block1 = Block(view=1, qc=StandardQc(block=genesis_block.id(), view=0), _id=b"1") + carnot.receive_block(block1) + + votes = set( + Vote( + voter=int_to_id(i), + view=1, + block=block1.id(), + qc=StandardQc(block=block1.id(), view=1) + ) for i in range(3) + ) + + with self.assertRaises((AssertionError, )): + carnot.approve_block(block1, votes) + + # The test passes as asserting fails in len(votes) == self.overlay.super_majority_threshold(self.id) + # when number of votes are < 9 + self.assertEqual(carnot.highest_voted_view, -1) + self.assertEqual(carnot.current_view, 1) + + def test_initial_leader_proposes_and_advance(self): + class MockOverlay(Overlay): + def is_leader(self, _id: Id): + return True + + def is_member_of_root_committee(self, _id: Id): + return True + + def is_member_of_leaf_committee(self, _id: Id): + return True + + def leader(self, view: View) -> Id: + return int_to_id(0) + + def is_member_of_child_committee(self, parent: Id, child: Id) -> bool: + return True + + def leader_super_majority_threshold(self, _id: Id) -> int: + return 10 + + def super_majority_threshold(self, _id: Id) -> int: + return 10 + + def parent_committee(self, _id: Id) -> Optional[Committee]: + return set() + + carnot = Carnot(int_to_id(0)) + carnot.overlay = MockOverlay() + genesis_block = self.add_genesis_block(carnot) + + # votes for genesis block + votes = set( + Vote( + block=genesis_block.id(), + view=0, + voter=int_to_id(i), + qc=StandardQc( + block=genesis_block.id(), + view=0 + ), + ) for i in range(10) + ) + # propose a new block + proposed_block = carnot.propose_block(view=1, quorum=votes).payload + + # process the proposed block as member of a committee + carnot.receive_block(proposed_block) + child_votes = set( + Vote( + block=proposed_block.id(), + view=1, + voter=int_to_id(i), + qc=StandardQc( + block=genesis_block.id(), + view=0 + ), + ) for i in range(10) + ) + # vote with votes from child committee + carnot.approve_block(proposed_block, child_votes) + # check carnot state advanced + self.assertTrue(carnot.current_view, 1) + self.assertEqual(carnot.highest_voted_view, 1) + self.assertEqual(carnot.local_high_qc.view, 0) + self.assertIn(proposed_block.id(), carnot.safe_blocks) + + def test_leaf_member_advance(self): + """ + Leaf committees do not collect votes as they don't have any child. Therefore, leaf committees in happy + path votes and updates state after receipt of a block + """ + class MockOverlay(Overlay): + def is_leader(self, _id: Id): + return False + + def leader(self, view: View) -> Id: + return int_to_id(0) + + def parent_committee(self, _id: Id) -> Optional[Committee]: + return set() + + def is_member_of_leaf_committee(self, _id: Id) -> bool: + return True + + def super_majority_threshold(self, _id: Id) -> int: + return 0 + + carnot = Carnot(int_to_id(0)) + carnot.overlay = MockOverlay() + genesis_block = self.add_genesis_block(carnot) + proposed_block = Block(view=1, qc=StandardQc(block=genesis_block.id(), view=0), _id=b"1") + # Receive the proposed block as a member of the leaf committee + carnot.receive_block(proposed_block) + carnot.approve_block(proposed_block, set()) + proposed_block = Block(view=2, qc=StandardQc(block=genesis_block.id(), view=1), _id=b"2") + carnot.receive_block(proposed_block) + carnot.approve_block(proposed_block, set()) + # Assert that the current view, highest voted view, and local high QC have all been updated correctly + self.assertEqual(carnot.current_view, 2) + self.assertEqual(carnot.highest_voted_view, 2) + self.assertEqual(carnot.local_high_qc.view, 1) + + # Assert that the proposed block has been added to the set of safe blocks + self.assertIn(proposed_block.id(), carnot.safe_blocks) + + def test_single_committee_advance(self): + """ + Test that having a single committee (both root and leaf) and a leader is able to advance + """ + nodes = [Carnot(int_to_id(i)) for i in range(4)] + leader = nodes[0] + + class MockOverlay(Overlay): + def is_member_of_child_committee(self, parent: Id, child: Id) -> bool: + return False + + def leader_super_majority_threshold(self, _id: Id) -> int: + return 3 + + def is_leader(self, _id: Id): + # Leader is the node with id 0, otherwise not + return { + int_to_id(0): True + }.get(_id, False) + + def is_member_of_root_committee(self, _id: Id): + return True + + def leader(self, view: View) -> Id: + return int_to_id(0) + + def parent_committee(self, _id: Id) -> Optional[Committee]: + return None + + def is_member_of_leaf_committee(self, _id: Id) -> bool: + return True + + def super_majority_threshold(self, _id: Id) -> int: + return 0 + + overlay = MockOverlay() + + # inject overlay + genesis_block = None + for node in nodes: + node.overlay = overlay + genesis_block = self.add_genesis_block(node) + + # votes for genesis block + votes = set( + Vote( + block=genesis_block.id(), + view=0, + voter=int_to_id(i), + qc=StandardQc( + block=genesis_block.id(), + view=0 + ), + ) for i in range(3) + ) + proposed_block = leader.propose_block(1, votes).payload + votes = [] + for node in nodes: + node.receive_block(proposed_block) + vote = node.approve_block(proposed_block, set()) + votes.append(vote.payload) + next_proposed_block = leader.propose_block(2, set(votes)).payload + for node in nodes: + # A node receives the second proposed block + node.receive_block(next_proposed_block) + # it hasn't voted for the view 2, so its state is linked to view 1 still + self.assertEqual(node.highest_voted_view, 1) + self.assertEqual(node.current_view, 2) + # when the node approves the vote we update the current view + # and the local high qc, so they need to be increased + node.approve_block(next_proposed_block, set()) + self.assertEqual(node.current_view, 2) + self.assertEqual(node.local_high_qc.view, 1) + self.assertEqual(node.highest_voted_view, 2) diff --git a/carnot/test_unhappy_path.py b/carnot/test_unhappy_path.py new file mode 100644 index 0000000..486fa93 --- /dev/null +++ b/carnot/test_unhappy_path.py @@ -0,0 +1,265 @@ +from carnot import * +from unittest import TestCase +from itertools import chain + + +class MockCarnot(Carnot): + def __init__(self, id): + super(MockCarnot, self).__init__(id) + + def rebuild_overlay_from_timeout_qc(self, timeout_qc: TimeoutQc): + pass + + +class MockOverlay(Overlay): + """ + Overlay for 5 nodes where the leader is the single member of the root committee + 0 + │ + 1◄──┴──►2 + │ + 3◄─┴─►4 + """ + + def __init__(self): + self.parents = { + int_to_id(1): {int_to_id(0)}, + int_to_id(2): {int_to_id(0)}, + int_to_id(3): {int_to_id(1)}, + int_to_id(4): {int_to_id(1)} + } + + self.childs = { + int_to_id(0): { + int_to_id(1), int_to_id(2) + }, + int_to_id(1): { + int_to_id(3), int_to_id(4) + } + } + + self.leafs = { + int_to_id(2), int_to_id(3), int_to_id(4) + } + + def leaf_committees(self) -> Set[Committee]: + return [[leaf] for leaf in self.leafs] + + def root_committee(self) -> Committee: + return {int_to_id(0)} + + def is_child_of_root_committee(self, _id: Id) -> bool: + return _id in {int_to_id(1), int_to_id(2)} + + def is_member_of_child_committee(self, parent: Id, child: Id) -> bool: + return child in childs if (childs := self.childs.get(parent)) else False + + def leader_super_majority_threshold(self, _id: Id) -> int: + return 3 + + def is_leader(self, _id: Id): + # Leader is the node with id 0, otherwise not + return _id == int_to_id(0) + + def is_member_of_root_committee(self, _id: Id): + return _id == int_to_id(0) + + def leader(self, view: View) -> Id: + return int_to_id(0) + + def parent_committee(self, _id: Id) -> Optional[Committee]: + return self.parents.get(_id) + + def is_member_of_leaf_committee(self, _id: Id) -> bool: + return _id in self.leafs + + def super_majority_threshold(self, _id: Id) -> int: + thresholds = { + int_to_id(0): 2, + int_to_id(1): 2, + } + return thresholds.get(_id, 0) + + +def add_genesis_block(carnot: Carnot) -> Block: + genesis_block = Block(view=0, qc=StandardQc(block=b"", view=0), _id=b"") + carnot.safe_blocks[genesis_block.id()] = genesis_block + carnot.receive_block(genesis_block) + carnot.local_high_qc = genesis_block.qc + carnot.current_view = 1 + return genesis_block + + +def setup_initial_setup(test_case: TestCase, overlay: MockOverlay, size: int) -> (Dict[Id, Carnot], MockCarnot, Block): + nodes = {int_to_id(i): MockCarnot(int_to_id(i)) for i in range(size)} + # add overlay + for node in nodes.values(): + node.overlay = overlay + leader: MockCarnot = nodes[overlay.leader(0)] + genesis_block = None + for node in nodes.values(): + genesis_block = add_genesis_block(node) + # votes for genesis block + genesis_votes = set( + Vote( + block=genesis_block.id(), + view=0, + voter=int_to_id(i), + qc=StandardQc( + block=genesis_block.id(), + view=0 + ), + ) for i in range(5) + ) + proposed_block = leader.propose_block(1, genesis_votes).payload + test_case.assertIsNotNone(proposed_block) + return nodes, leader, proposed_block + + +def parents_from_childs(overlay: MockOverlay, childs: List[Id]) -> Set[Id]: + if len(childs) == 0: + return set() + possible_parents = filter( + lambda x: x is not None, + chain.from_iterable(parent for _id in childs if (parent := overlay.parent_committee(_id))) + ) + return set(possible_parents) if possible_parents else set() + + +def succeed(test_case: TestCase, overlay: MockOverlay, nodes: Dict[Id, MockCarnot], proposed_block: Block) -> List[Vote]: + # broadcast the block + for node in nodes.values(): + node.receive_block(proposed_block) + + votes = {} + childs_ids = list(chain.from_iterable(overlay.leaf_committees())) + leafs = [nodes[_id] for _id in childs_ids] + for node in leafs: + vote = node.approve_block(proposed_block, set()).payload + votes[node.id] = vote + + while len(parents := parents_from_childs(overlay, childs_ids)) != 0: + for node_id in parents: + node = nodes[node_id] + child_votes = [votes[_id] for _id in votes.keys() if overlay.is_member_of_child_committee(node_id, _id)] + if len(child_votes) == overlay.super_majority_threshold(node_id) and node_id not in votes: + vote = node.approve_block(proposed_block, child_votes).payload + votes[node_id] = vote + childs_ids = list(set(parents)) + + root_votes = [ + votes[node_id] + for node_id in nodes + if overlay.is_member_of_root_committee(node_id) or overlay.is_child_of_root_committee(node_id) + ] + return root_votes + + +def fail(test_case: TestCase, overlay: MockOverlay, nodes: Dict[Id, MockCarnot], proposed_block: Block) -> List[NewView]: + # broadcast the block + for node in nodes.values(): + node.receive_block(proposed_block) + + node: MockCarnot + timeouts = [] + for node in (nodes[_id] for _id in nodes if overlay.is_member_of_root_committee(_id) or overlay.is_child_of_root_committee(_id)): + timeout = node.local_timeout().payload + timeouts.append(timeout) + + root_member = next(nodes[_id] for _id in nodes if overlay.is_member_of_root_committee(_id)) + timeout_qc = root_member.timeout_detected(timeouts).payload + + for node in nodes.values(): + node.receive_timeout_qc(timeout_qc) + + votes = {} + childs_ids = list(chain.from_iterable(overlay.leaf_committees())) + leafs = [nodes[_id] for _id in childs_ids] + for node in leafs: + vote = node.approve_new_view(timeout_qc, set()).payload + votes[node.id] = vote + + while len(parents := parents_from_childs(overlay, childs_ids)) != 0: + for node_id in parents: + node = nodes[node_id] + child_votes = [votes[_id] for _id in votes.keys() if overlay.is_member_of_child_committee(node_id, _id)] + if len(child_votes) == overlay.super_majority_threshold(node_id) and node_id not in votes: + vote = node.approve_new_view(timeout_qc, child_votes).payload + votes[node_id] = vote + childs_ids = list(set(parents)) + + root_votes = [ + votes[node_id] + for node_id in nodes + if overlay.is_member_of_root_committee(node_id) or overlay.is_child_of_root_committee(node_id) + ] + return root_votes + + +class TestCarnotUnhappyPath(TestCase): + + def test_timeout_high_qc(self): + """ + At the end of the timeout the highQC in the next leader's aggregatedQC should be the highestQC held by the + majority of nodes or a qc higher than th highestQC held by the majority of nodes. + Majority means more than two thirds of total number of nodes, randomly assigned to committees. + """ + + overlay = MockOverlay() + + nodes, leader, proposed_block = setup_initial_setup(self, overlay, 5) + + # In this loop 'view' is the view that fails + for view in range(1, 4, 2): + # When view v fails, a timeout qc is built for view v and nodes jump to view v + 1 + # while aggregating votes for the high qc. Those votes are then forwarded to the leader of view v + 2 + # which can propose a block with those aggregate votes as proof of the previous round completion. + root_votes = fail(self, overlay, nodes, proposed_block) + proposed_block = leader.propose_block(view+2, root_votes).payload + + # Add final assertions on nodes + # Thus, the first block that can be proposed is 2 views after the timeout + self.assertEqual(proposed_block.view, view + 2) + # Its qc is always for the view before the block is proposed for + self.assertEqual(proposed_block.qc.view, view + 1) + # The high qc is 0, since we never had a successful round + self.assertEqual(proposed_block.qc.high_qc().view, 0) + self.assertEqual(leader.last_view_timeout_qc.view, view) + self.assertEqual(leader.local_high_qc.view, 0) + self.assertEqual(leader.highest_voted_view, view+1) + + for node in nodes.values(): + self.assertEqual(node.latest_committed_view(), 0) + + def test_interleave_success_fails(self): + """ + At the end of the timeout the highQC in the next leader's aggregatedQC should be the highestQC held by the + majority of nodes or a qc higher than th highestQC held by the majority of nodes. + Majority means more than two thirds of total number of nodes, randomly assigned to committees. + """ + overlay = MockOverlay() + leader: MockCarnot + nodes, leader, proposed_block = setup_initial_setup(self, overlay, 5) + + for view in range(2, 5): + root_votes = succeed(self, overlay, nodes, proposed_block) + proposed_block = leader.propose_block(view, root_votes).payload + + root_votes = fail(self, overlay, nodes, proposed_block) + proposed_block = leader.propose_block(6, root_votes).payload + + for view in range(7, 8): + root_votes = succeed(self, overlay, nodes, proposed_block) + proposed_block = leader.propose_block(view, root_votes).payload + + root_votes = fail(self, overlay, nodes, proposed_block) + proposed_block = leader.propose_block(9, root_votes).payload + + for view in range(10, 15): + root_votes = succeed(self, overlay, nodes, proposed_block) + proposed_block = leader.propose_block(view, root_votes).payload + + committed_blocks = [view for view in range(1, 11) if view not in (4, 5, 7, 8)] + for node in nodes.values(): + for view in committed_blocks: + self.assertIn(view, [block.view for block in node.committed_blocks().values()])