From d8d22e7219abc3ad88bfd62efa651ba7d534f035 Mon Sep 17 00:00:00 2001 From: Daniel Sanchez Date: Mon, 10 Apr 2023 10:28:48 +0200 Subject: [PATCH] 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> --- carnot/carnot.py | 321 ++++++++++++++++++++++++++---------- carnot/test_happy_path.py | 289 +++++++++++++++++++++++++++----- carnot/test_unhappy_path.py | 23 +++ 3 files changed, 508 insertions(+), 125 deletions(-) create mode 100644 carnot/test_unhappy_path.py diff --git a/carnot/carnot.py b/carnot/carnot.py index e5645bb..9df75dd 100644 --- a/carnot/carnot.py +++ b/carnot/carnot.py @@ -1,3 +1,38 @@ +# 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. + + from dataclasses import dataclass from typing import TypeAlias, List, Set, Self, Optional, Dict, FrozenSet from abc import abstractmethod @@ -41,7 +76,7 @@ Qc: TypeAlias = StandardQc | AggregateQc class Block: view: View qc: Qc - content: FrozenSet[Id] + _id: Id # this is an abstration over the block id, which should be the hash of the contents def extends(self, ancestor: Self) -> bool: """ @@ -54,7 +89,7 @@ class Block: return self.qc.block def id(self) -> Id: - return int_to_id(hash(self.content)) + return self._id @dataclass(unsafe_hash=True) @@ -74,14 +109,19 @@ class TimeoutQc: sender: Id -# Timeout in the root or direct children committees @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: @@ -129,7 +169,7 @@ class Overlay: pass @abstractmethod - def member_of_leaf_committee(self, _id: Id) -> bool: + 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 @@ -137,7 +177,7 @@ class Overlay: pass @abstractmethod - def member_of_root_committee(self, _id: Id) -> bool: + 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 @@ -145,23 +185,7 @@ class Overlay: pass @abstractmethod - def member_of_root_com(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 member_of_internal_com(self, _id: Id) -> bool: - """ - :param _id: - :return: True if the participant with Id _id is member of internal committees within the committee tree overlay - """ - pass - - @abstractmethod - def child_committee(self, parent: Id, child: Id) -> bool: + def is_member_of_child_committee(self, parent: Id, child: Id) -> bool: """ :param parent: :param child: @@ -187,7 +211,7 @@ class Overlay: """ pass - def child_of_root_committee(self, _id: Id) -> Optional[Set[Committee]]: + def is_child_of_root_committee(self, _id: Id) -> bool: """ :return: returns child committee/s of root committee if present """ @@ -202,10 +226,6 @@ class Overlay: """ pass - @abstractmethod - def root_super_majority_threshold(self, _id: Id) -> int: - pass - @abstractmethod def super_majority_threshold(self, _id: Id) -> int: pass @@ -230,12 +250,16 @@ class Carnot: self.local_high_qc: Optional[Qc] = None # The latest view committed by a node. self.latest_committed_view: View = 0 - # + # Validated blocks with their validated QCs are included here. If commit conditions is satisfied for + # each one of these blocks it will be committed. self.safe_blocks: Dict[Id, Block] = dict() + # Block received for a specific view. Make sure the node doesn't receive duplicate blocks. self.seen_view_blocks: Dict[View, bool] = dict() + # Last timeout QC and its view self.last_timeout_view_qc: Optional[TimeoutQc] = None self.last_timeout_view: Optional[View] = None self.overlay: Overlay = Overlay() # TODO: integrate overlay + # Committed blocks are kept here. self.committed_blocks: Dict[Id, Block] = dict() def block_is_safe(self, block: Block) -> bool: @@ -290,47 +314,54 @@ class Carnot: self.try_commit_grand_parent(block) def receive_timeout_qc(self, timeout_qc: TimeoutQc): - # TODO: we should be more strict with views in the sense that we should not - # accept 'future' events + # TODO: we should be more strict with views in the sense that we should not accept 'future' events assert timeout_qc.view >= self.current_view self.rebuild_overlay_from_timeout_qc(timeout_qc) def approve_block(self, block: Block, votes: Set[Vote]): assert block.id() in self.safe_blocks assert len(votes) == self.overlay.super_majority_threshold(self.id) - assert all(self.overlay.child_committee(self.id, vote.voter) for vote in votes) + 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 block.view > self.highest_voted_view - if self.overlay.member_of_root_com(self.id): + if ( + self.overlay.is_member_of_root_committee(self.id) and + not self.overlay.is_member_of_leaf_committee(self.id) + ): vote: Vote = Vote( block=block.id(), voter=self.id, - view=self.current_view, - qc=self.build_qc(votes) + view=block.view, + qc=self.build_qc(block.view, block) ) self.send(vote, self.overlay.leader(self.current_view + 1)) else: vote: Vote = Vote( block=block.id(), voter=self.id, - view=self.current_view, + view=block.view, qc=None ) - self.send(vote, *self.overlay.parent_committee(self.id)) + if self.overlay.is_member_of_root_committee(self.id): + self.send(vote, self.overlay.leader(block.view + 1)) + else: + self.send(vote, *self.overlay.parent_committee(self.id)) self.increment_voted_view(block.view) # to avoid voting again for this view. self.increment_view_qc(block.qc) - # This step is very similar to approving a block in the happy path - # A goal of this process is to guarantee that the high_qc gathered at the top - # (or a more recent one) has been seen by the supermajority of nodes in the network - # TODO: Check comment def approve_new_view(self, timeouts: Set[NewView]): + """ + This step is very similar to approving a block in the happy path + A goal of this process is to guarantee that the high_qc gathered at the top + (or a more recent one) has been seen by the supermajority of nodes in the network + # TODO: Check comment + """ assert len(set(timeout.view for timeout in timeouts)) == 1 assert all(timeout.view >= self.current_view for timeout in timeouts) assert all(timeout.view == timeout.timeout_qc.view for timeout in timeouts) assert len(timeouts) == self.overlay.super_majority_threshold(self.id) - assert all(self.overlay.child_committee(self.id, timeout.sender) for timeout in timeouts) + assert all(self.overlay.is_member_of_child_committee(self.id, timeout.sender) for timeout in timeouts) timeouts = list(timeouts) timeout_qc = timeouts[0].timeout_qc @@ -341,12 +372,12 @@ class Carnot: self.update_timeout_qc(timeout_qc) self.increment_view_timeout_qc(timeout_qc) - if self.overlay.member_of_root_com(self.id): + if self.overlay.is_member_of_root_committee(self.id): new_view_msg = NewView( view=self.current_view, high_qc=self.local_high_qc, sender=self.id, - timeout_qc=timeout_qc, # should we do some aggregation here? + timeout_qc=timeout_qc, # should we do some aggregation here? ) self.send(new_view_msg, self.overlay.leader(self.current_view + 1)) else: @@ -355,52 +386,103 @@ class Carnot: high_qc=self.local_high_qc, sender=self.id, timeout_qc=timeout_qc - ) + ) self.send(new_view_msg, *self.overlay.parent_committee(self.id)) self.increment_view_timeout_qc(timeout_qc) # This checks if a not has already incremented its voted view by local_timeout. If not then it should # do it now to avoid voting in this view. self.increment_voted_view(timeout_qc.view) - def forward_vote(self, msg: Vote): + def forward_vote(self, vote: Vote): assert vote.block in self.safe_blocks - assert self.overlay.child_committee(self.id, vote.voter) + assert self.overlay.is_member_of_child_committee(self.id, vote.voter) - if self.overlay.member_of_root_com(self.id): + if self.overlay.is_member_of_root_committee(self.id): self.send(vote, self.overlay.leader(self.current_view + 1)) def forward_new_view(self, msg: NewView): assert msg.view == self.current_view - assert self.overlay.child_committee(self.id, vote.voter) + assert self.overlay.is_member_of_child_committee(self.id, msg.sender) - if self.overlay.member_of_root_com(self.id): - self.send(vote, self.overlay.leader(self.current_view + 1)) + if self.overlay.is_member_of_root_committee(self.id): + self.send(msg, self.overlay.leader(self.current_view + 1)) - def build_qc(self, quorum: Quorum) -> Qc: - pass + def build_qc(self, view: View, block: Block) -> Qc: + # TODO: implement unhappy path + # Maybe better do build aggregatedQC for unhappy path? + return StandardQc( + view=view, + block=block.id() + ) def propose_block(self, view: View, quorum: Quorum): assert self.overlay.is_leader(self.id) - assert len(quorum) == self.overlay.leader_super_majority_threshold(self.id) - - qc = self.build_qc(quorum) - block = Block(view=view, qc=qc) + assert len(quorum) >= self.overlay.leader_super_majority_threshold(self.id) + vote = list(quorum)[0] + qc = self.build_qc(vote.view, self.safe_blocks[vote.block]) + 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")) + ) + )) + ) self.broadcast(block) - def local_timeout(self): + def is_safe_to_timeout( + self, + highest_voted_view: View, + local_high_qc: Qc, + last_timeout_view_qc: TimeoutQc, + current_view: View + ): + """ + 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. Similalry 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(highest_voted_view - 1, 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. - assert (is_sequential_ascending(self.current_view, self.local_high_qc.view) or - is_sequential_ascending(self.current_view, self.last_timeout_view_qc.view)) + return ( + is_sequential_ascending(current_view, local_high_qc.view) or + is_sequential_ascending(current_view, last_timeout_view_qc.view) or + (current_view == last_timeout_view_qc.view) + ) + + def local_timeout(self): + assert self.is_safe_to_timeout( + self.highest_voted_view, + self.local_high_qc, + self.last_timeout_view_qc, + self.current_view + ) + self.increment_voted_view(self.current_view) - if self.overlay.member_of_root_committee(self.id) or self.overlay.child_of_root_committee(self.id): + 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=True, # 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_timeout_view_qc, @@ -408,49 +490,121 @@ class Carnot: ) self.send(timeout_msg, *self.overlay.root_committee()) - # 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 def timeout_detected(self, msgs: Set[Timeout]): + """ + 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 + :param msgs: + :return: + """ 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 all(msg.local_timeout for msg in msgs) - assert self.overlay.member_of_root_committee(self.id) + assert self.current_view > max(self.highest_voted_view - 1, self.local_high_qc.view) + assert self.overlay.is_member_of_root_committee(self.id) timeout_qc = self.build_timeout_qc(msgs) self.update_timeout_qc(timeout_qc) self.update_high_qc(timeout_qc.high_qc) self.rebuild_overlay_from_timeout_qc(timeout_qc) - self.broadcast(timeout_qc) # can be sent only to the leafs + self.broadcast(timeout_qc) # we broadcast so all nodes can get ready for voting on a new view + + def gather_new_view(self, timeouts: Set[NewView]): + assert not self.overlay.is_member_of_leaf_committee(self.id) + assert len(set(timeout.view for timeout in timeouts)) == 1 + assert all(timeout.view >= self.current_view for timeout in timeouts) + assert all(timeout.view == timeout.timeout_qc.view for timeout in timeouts) + assert len(timeouts) == self.overlay.super_majority_threshold(self.id) + assert all(self.overlay.is_member_of_child_committee(self.id, timeout.sender) for timeout in timeouts) + + timeouts = list(timeouts) + timeout_qc = timeouts[0].timeout_qc + new_high_qc = timeout_qc.high_qc + + self.rebuild_overlay_from_timeout_qc(timeout_qc) + + if new_high_qc.view >= self.local_high_qc.view: + self.update_high_qc(new_high_qc) + self.update_timeout_qc(timeout_qc) + self.increment_view_timeout_qc(timeout_qc) + + if self.overlay.is_member_of_root_committee(self.id): + timeout_msg = NewView( + view=self.current_view, + high_qc=self.local_high_qc, + sender=self.id, + timeout_qc=timeout_qc, + ) + self.send(timeout_msg, self.overlay.leader(self.current_view + 1)) + else: + timeout_msg = NewView( + view=self.current_view, + high_qc=self.local_high_qc, + sender=self.id, + timeout_qc=timeout_qc, + ) + self.send(timeout_msg, *self.overlay.parent_committee(self.id)) + self.increment_view_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. + if self.highest_voted_view < self.current_view: + self.increment_voted_view(timeout_qc.view) + + def received_timeout_qc(self, timeout_qc: TimeoutQc): + assert timeout_qc.view >= self.current_view + self.rebuild_overlay_from_timeout_qc(timeout_qc) + + if self.overlay.is_member_of_leaf_committee(self.id): + new_high_qc = timeout_qc.high_qc + if new_high_qc.view >= self.local_high_qc.view: + self.update_high_qc(new_high_qc) + self.update_timeout_qc(timeout_qc) + self.increment_view_timeout_qc(timeout_qc) + timeout_msg = NewView( + view=self.current_view, + high_qc=self.local_high_qc, + sender=self.id, + timeout_qc=timeout_qc, + ) + self.send(timeout_msg, *self.overlay.parent_committee(self.id)) + # 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. + if self.highest_voted_view < self.current_view: + self.increment_voted_view(timeout_qc.view) def rebuild_overlay_from_timeout_qc(self, timeout_qc: TimeoutQc): + assert timeout_qc.view >= self.current_view self.overlay = Overlay() def build_timeout_qc(self, msgs: Set[Timeout]) -> TimeoutQc: pass - def send(self, vote: Vote | Timeout | TimeoutQc, *ids: Id): + def send(self, vote: Vote | Timeout | NewView | TimeoutQc, *ids: Id): pass def broadcast(self, block): pass + # todo blocks from latest_committed_block to grand_parent must be committed. def try_commit_grand_parent(self, block: Block): + 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 - can_commit = ( - parent.view == (grand_parent.view + 1) and - isinstance(block.qc, (StandardQc,)) and - isinstance(parent.qc, (StandardQc,)) - ) - if can_commit: - self.committed_blocks[grand_parent.id()] = grand_parent - self.increment_latest_committed_view(grand_parent.view) + while grand_parent and grand_parent.view > self.latest_committed_view: + # 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 + can_commit = ( + parent.view == (grand_parent.view + 1) and + isinstance(block.qc, (StandardQc,)) and + isinstance(parent.qc, (StandardQc,)) + ) + if can_commit: + self.committed_blocks[grand_parent.id()] = grand_parent + self.increment_latest_committed_view(grand_parent.view) + grand_parent = self.safe_blocks.get(grand_parent.parent()) def increment_voted_view(self, view: View): self.highest_voted_view = max(view, self.highest_voted_view) @@ -458,12 +612,11 @@ class Carnot: def increment_latest_committed_view(self, view: View): self.latest_committed_view = max(view, self.latest_committed_view) - def increment_view_qc(self, qc: Qc) -> bool: + def increment_view_qc(self, qc: Qc): if qc.view < self.current_view: - return False + return self.last_timeout_view_qc = None self.current_view = qc.view + 1 - return True def increment_view_timeout_qc(self, timeout_qc: TimeoutQc): if timeout_qc is None or timeout_qc.view < self.current_view: @@ -480,3 +633,5 @@ class Carnot: if __name__ == "__main__": pass + + diff --git a/carnot/test_happy_path.py b/carnot/test_happy_path.py index 454769f..97cb473 100644 --- a/carnot/test_happy_path.py +++ b/carnot/test_happy_path.py @@ -1,11 +1,12 @@ from .carnot import * -from unittest import TestCase +from unittest import TestCase, mock +from unittest.mock import patch class TestCarnotHappyPath(TestCase): @staticmethod def add_genesis_block(carnot: Carnot) -> Block: - genesis_block = Block(view=0, qc=StandardQc(block=b"", view=0), content=frozenset(b"")) + genesis_block = Block(view=0, qc=StandardQc(block=b"", view=0), _id=b"") carnot.safe_blocks[genesis_block.id()] = genesis_block carnot.committed_blocks[genesis_block.id()] = genesis_block return genesis_block @@ -13,33 +14,33 @@ class TestCarnotHappyPath(TestCase): 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), content=frozenset(b"1")) + 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), content=frozenset(b"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), content=frozenset(b"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), content=frozenset(b"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), content=frozenset(b"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), content=frozenset(b"4")) + 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), content=frozenset(b"4")) + 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) @@ -47,25 +48,25 @@ class TestCarnotHappyPath(TestCase): 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), content=frozenset(b"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), content=frozenset(b"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), content=frozenset(b"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), content=frozenset(b"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), content=frozenset(b"5")) + 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) @@ -74,18 +75,18 @@ class TestCarnotHappyPath(TestCase): 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), content=frozenset(b"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), content=frozenset(b"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), content=frozenset(b"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), content=frozenset(b"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) @@ -93,7 +94,7 @@ class TestCarnotHappyPath(TestCase): # 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), content=frozenset(b"5")) + 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) @@ -105,21 +106,21 @@ class TestCarnotHappyPath(TestCase): 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), content=frozenset(b"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), content=frozenset(b"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), content=frozenset(b"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), content=frozenset(b"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), content=frozenset(b"5")) + block5 = Block(view=5, qc=StandardQc(block=block4.id(), view=4), _id=b"5") carnot.receive_block(block5) for block in (block1, block2, block3): @@ -133,25 +134,25 @@ class TestCarnotHappyPath(TestCase): 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), content=frozenset(b"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), content=frozenset(b"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), content=frozenset(b"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), content=frozenset(b"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), content=frozenset(b"5")) + 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) @@ -160,22 +161,22 @@ class TestCarnotHappyPath(TestCase): 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), content=frozenset(b"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), content=frozenset(b"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), content=frozenset(b"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), content=frozenset(b"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), content=frozenset(b"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) @@ -186,11 +187,12 @@ class TestCarnotHappyPath(TestCase): 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 member_of_root_com(self, _id: Id) -> bool: + def is_member_of_root_committee(self, _id: Id) -> bool: return False - def child_committee(self, parent: Id, child: Id) -> bool: + def is_member_of_child_committee(self, parent: Id, child: Id) -> bool: return True def super_majority_threshold(self, _id: Id) -> int: @@ -203,7 +205,7 @@ class TestCarnotHappyPath(TestCase): carnot.overlay = MockOverlay() genesis_block = self.add_genesis_block(carnot) # 1 - block1 = Block(view=1, qc=StandardQc(block=genesis_block.id(), view=0), content=frozenset(b"1")) + block1 = Block(view=1, qc=StandardQc(block=genesis_block.id(), view=0), _id=b"1") carnot.receive_block(block1) votes = set( Vote( @@ -213,7 +215,7 @@ class TestCarnotHappyPath(TestCase): qc=StandardQc(block=block1.id(), view=1) ) for i in range(10) ) - carnot.vote(block1, votes) + 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) @@ -223,11 +225,12 @@ class TestCarnotHappyPath(TestCase): """ 2 If last_voted_view is incremented after calling vote with votes lower than. """ + class MockOverlay(Overlay): - def member_of_root_com(self, _id: Id) -> bool: + def is_member_of_root_committee(self, _id: Id) -> bool: return False - def child_committee(self, parent: Id, child: Id) -> bool: + def is_member_of_child_committee(self, parent: Id, child: Id) -> bool: return True def super_majority_threshold(self, _id: Id) -> int: @@ -240,7 +243,7 @@ class TestCarnotHappyPath(TestCase): carnot.overlay = MockOverlay() genesis_block = self.add_genesis_block(carnot) # 1 - block1 = Block(view=1, qc=StandardQc(block=genesis_block.id(), view=0), content=frozenset(b"1")) + block1 = Block(view=1, qc=StandardQc(block=genesis_block.id(), view=0), _id=b"1") carnot.receive_block(block1) votes = set( @@ -253,9 +256,211 @@ class TestCarnotHappyPath(TestCase): ) with self.assertRaises((AssertionError, )): - carnot.vote(block1, votes) + 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, 0) self.assertEqual(carnot.current_view, 0) + + def test_initial_leader_proposes_and_advance(self): + class MockOverlay(Overlay): + def is_leader(self, _id: Id): + return True + + def is_member_root(self, _id: Id): + return True + + def is_member_leaf(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() + + class MockCarnot(Carnot): + def __init__(self, _id): + super(MockCarnot, self).__init__(_id) + self.proposed_block = None + + def broadcast(self, block): + self.proposed_block = block + + carnot = MockCarnot(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 + carnot.propose_block(view=1, quorum=votes) + proposed_block = carnot.proposed_block + # 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 + """ + class MockCarnot(Carnot): + def __init__(self, id): + super(MockCarnot, self).__init__(id) + self.proposed_block = None + self.latest_vote = None + + def broadcast(self, block): + self.proposed_block = block + + def send(self, vote: Vote | Timeout | TimeoutQc, *ids: Id): + self.latest_vote = vote + + nodes = [MockCarnot(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) + ) + leader.propose_block(1, votes) + proposed_block = leader.proposed_block + votes = [] + for node in nodes: + node.receive_block(proposed_block) + node.approve_block(proposed_block, set()) + votes.append(node.latest_vote) + leader.propose_block(2, set(votes)) + next_proposed_block = leader.proposed_block + 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, 1) + # 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..a8d2cf5 --- /dev/null +++ b/carnot/test_unhappy_path.py @@ -0,0 +1,23 @@ +# Unhappy path tests + +# 1: 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. + + +# 2: Have consecutive view changes and verify the following state variable: +# last_timeout_view_qc.view +# high_qc.view +# current_view +# last_voted_view + +# 3: Due failure consecutive condition between parent and grand parent blocks might not meet. So whenever the +# Consecutive view condition in the try_to_commit fails, then all the blocks between the latest_committed_block and the +# grandparent (including the grandparent) must be committed in order. +# As far as I know current code only excutes the grandparent only. It should also address the case above. + + +# 4: Have consecutive success adding two blocks then a failure and two consecutive success + 1 failure+ 1 success +# S1 <- S2 <- F1 <- S3 <- S4 <-F2 <- S5 + +# At S3, S1 should be committed. At S5, S2 and S3 must be committed \ No newline at end of file