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/carnot/__init__.py b/carnot/__init__.py index e69de29..397eb42 100644 --- a/carnot/__init__.py +++ b/carnot/__init__.py @@ -0,0 +1 @@ +from .carnot import * diff --git a/carnot/carnot.py b/carnot/carnot.py index 2919394..1c1a355 100644 --- a/carnot/carnot.py +++ b/carnot/carnot.py @@ -1,7 +1,45 @@ +# 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] @@ -41,7 +79,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: """ @@ -51,10 +89,14 @@ class Block: return self.view > ancestor.view def parent(self) -> Id: - return self.qc.block + match self.qc: + case StandardQc(block): + return block + case AggregateQc() as aqc: + return aqc.high_qc().block def id(self) -> Id: - return int_to_id(hash(self.content)) + return self._id @dataclass(unsafe_hash=True) @@ -74,21 +116,46 @@ class TimeoutQc: sender: Id -# 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. @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 - local_timeout: bool -Quorum: TypeAlias = Set[Vote] | Set[Timeout] +# 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 @@ -102,20 +169,7 @@ class Overlay: """ pass - def is_child_of_root(self, _id: Id): - """ - :param _id: Node id to be checked - :return: true if node is the member of child of the root committee - """ - pass - - def number_of_committees(self, _ids: set[Id]) -> int: - """ - :param _ids: Set of Node id to be checked - :return: Number of committees in the overlay - """ - pass - + @abstractmethod def leader(self, view: View) -> Id: """ :param view: @@ -124,7 +178,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 @@ -132,7 +186,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 @@ -140,23 +194,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: @@ -173,31 +211,36 @@ class Overlay: """ pass + @abstractmethod + def leaf_committees(self) -> Set[Committee]: + pass + + @abstractmethod def root_committee(self) -> Committee: """ :return: returns root committee """ pass - def child_of_root_committee(self) -> Optional[Set[Committee]]: + @abstractmethod + def is_child_of_root_committee(self, _id: Id) -> bool: """ :return: returns child committee/s of root committee if present """ pass @abstractmethod - def super_majority_threshold(self, _id: Id) -> int: + 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: """ - if self.is_leader(_id): - pass - elif self.member_of_root_committee(_id): - pass - else: - pass + pass + + @abstractmethod + def super_majority_threshold(self, _id: Id) -> int: + pass def download(view) -> Block: @@ -207,27 +250,76 @@ def download(view) -> Block: 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 - self.highest_voted_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 - self.latest_committed_view: View = 0 + # 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() - self.seen_view_blocks: Dict[View, bool] = dict() - self.last_timeout_view_qc: Optional[TimeoutQc] = None - self.last_timeout_view: Optional[View] = None + # 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 - self.committed_blocks: Dict[Id, Block] = dict() + + + # 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: - match block.qc: - case StandardQc() as standard: - if standard.view < self.latest_committed_view: - return False - return block.view >= self.latest_committed_view and block.view == (standard.view + 1) - case AggregateQc() as aggregated: - if aggregated.high_qc().view < self.latest_committed_view: - return False - return block.view >= self.current_view and block.view == (aggregated.view + 1) + return ( + block.view >= self.current_view and + block.view == block.qc.view + 1 + ) # Ask Dani def update_high_qc(self, qc: Qc): @@ -240,153 +332,262 @@ class Carnot: 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_timeout_view_qc, timeout_qc): + match (self.last_view_timeout_qc, timeout_qc): case (None, timeout_qc): - self.local_high_qc = timeout_qc - case (self.last_timeout_view_qc, timeout_qc) if timeout_qc.view > self.last_timeout_view_qc.view: - self.last_timeout_view_qc = 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.seen_view_blocks.get(block.view) is not None or block.view <= self.latest_committed_view: + 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.seen_view_blocks[block.view] = True self.update_high_qc(block.qc) - self.try_commit_grand_parent(block) - def vote(self, block: Block, votes: Set[Vote]): + 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.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 + assert self.highest_voted_view < block.view - if self.overlay.member_of_root_com(self.id): - vote: Vote = Vote( - block=block.id(), - voter=self.id, - view=self.current_view, - qc=self.build_qc(votes) - ) - self.send(vote, self.overlay.leader(self.current_view + 1)) + if self.overlay.is_member_of_root_committee(self.id): + qc = self.build_qc(block.view, block, None) else: - vote: Vote = Vote( - block=block.id(), - voter=self.id, - view=self.current_view, - qc=None - ) - 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) + qc = None - def forward_vote(self, vote: Vote): + 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.child_committee(self.id, vote.voter) + 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.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): + return Send(to=self.overlay.leader(self.current_view + 1), payload=vote) - def build_qc(self, quorum: Quorum) -> Qc: - pass + 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 - def propose_block(self, view: View, quorum: Quorum): + 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.super_majority_threshold(self.id) + assert len(quorum) >= self.overlay.leader_super_majority_threshold(self.id) - qc = self.build_qc(quorum) - block = Block(view=view, qc=qc) - self.broadcast(block) + 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) - def local_timeout(self, new_overlay: Overlay): - self.increment_voted_view(self.current_view) - if self.overlay.member_of_leaf_committee(self.id) or self.overlay.is_child_of_root(self.id): + 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=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, + timeout_qc=self.last_view_timeout_qc, sender=self.id ) - self.send(timeout_msg, *self.overlay.root_committee()) - for child_committee in self.overlay.child_of_root_committee(): - self.send(timeout_msg, child_committee) + return Send(payload=timeout_msg, to=self.overlay.root_committee()) - def timeout(self, msgs: Set[Timeout]): - assert len(msgs) == self.overlay.super_majority_threshold(self.id) + 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) - max_msg = self.get_max_timeout(msgs) - if self.local_high_qc.view < max_msg.high_qc.view: - self.update_high_qc(max_msg.high_qc) - if self.overlay.member_of_root_committee(self.id) and self.overlay.member_of_leaf_committee(self.id): - timeout_qc = self.build_timeout_qc(msgs) - self.update_timeout_qc(timeout_qc) - else: - self.update_timeout_qc(msgs.pop().timeout_qc) + 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 - def build_timeout_qc(self, msgs: Set[Timeout]) -> TimeoutQc: - pass + # 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 - def send(self, vote: Vote | Timeout, *ids: Id): - pass - - def broadcast(self, block): - pass - - 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,)) + # 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, ) - if can_commit: - self.committed_blocks[grand_parent.id()] = grand_parent - self.increment_latest_committed_view(grand_parent.view) - def increment_voted_view(self, view: View): - self.highest_voted_view = max(view, self.highest_voted_view) + # 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) - def increment_latest_committed_view(self, view: View): - self.latest_committed_view = max(view, self.latest_committed_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)) - def increment_view_qc(self, qc: Qc) -> bool: - if qc.view < self.current_view: - return False - 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: + + # 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 - self.last_timeout_view_qc = timeout_qc - self.current_view = self.last_timeout_view_qc.view + 1 - return True + 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 get_max_timeout(timeouts: Set[Timeout]) -> Optional[Timeout]: - if not timeouts: - return None - return max(timeouts, key=lambda time: time.qc.view) + 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__": diff --git a/carnot/test_happy_path.py b/carnot/test_happy_path.py index 454769f..1193731 100644 --- a/carnot/test_happy_path.py +++ b/carnot/test_happy_path.py @@ -1,45 +1,44 @@ -from .carnot import * +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), 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 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 +46,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 +73,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 +92,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,25 +104,24 @@ 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): - self.assertIn(block.id(), carnot.committed_blocks) + self.assertIn(block.id(), carnot.committed_blocks()) def test_receive_block_has_an_old_qc_and_tries_to_revert_a_committed_block(self): """ @@ -133,25 +131,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,24 +158,24 @@ 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.latest_committed_view(), 3) self.assertEqual(carnot.local_high_qc.view, 4) # Test cases for vote: @@ -186,11 +184,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 +202,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,21 +212,22 @@ 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) - self.assertEqual(carnot.last_timeout_view, None) + 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 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 +240,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 +253,189 @@ 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) + 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()])