From c0eba7d8e8ff54fd749c273b37ea0eacc2b04cef Mon Sep 17 00:00:00 2001 From: Giacomo Pasini Date: Thu, 13 Apr 2023 15:40:02 +0200 Subject: [PATCH] Extract implicit information from safe blocks (#19) * extract implicit information from safe blocks * fix test --- carnot/carnot.py | 120 +++++++++++++++++++----------------- carnot/test_happy_path.py | 10 ++- carnot/test_unhappy_path.py | 7 +-- 3 files changed, 72 insertions(+), 65 deletions(-) diff --git a/carnot/carnot.py b/carnot/carnot.py index 0239e11..f4ec9d6 100644 --- a/carnot/carnot.py +++ b/carnot/carnot.py @@ -230,10 +230,6 @@ def download(view) -> Block: raise NotImplementedError -def is_sequential_ascending(view1: View, view2: View): - return view1 == view2 + 1 - - class Carnot: def __init__(self, _id: Id): self.id: Id = _id @@ -241,37 +237,78 @@ class Carnot: self.current_view: View = 0 # Highest voted view counter. This is used to prevent a node from voting twice or vote after timeout. self.highest_voted_view: View = 0 - # This is the qc from the highest view a node has + # This is most recent (in terms of view) Standard QC that has been received by the node 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 + # 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() - # 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 + # Whether the node timeed out in the last view and corresponding qc 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() + + + # 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 - is_sequential_ascending(block.view, standard.view) + standard.view >= self.latest_committed_view() and + block.view == standard.view + 1 ) case AggregateQc() as aggregated: - if aggregated.high_qc().view < self.latest_committed_view: + if aggregated.high_qc().view < self.latest_committed_view(): return False return ( block.view >= self.current_view and - is_sequential_ascending(block.view, aggregated.view) + block.view == aggregated.view + 1 ) # Ask Dani @@ -301,15 +338,17 @@ class Carnot: 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 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 approve_block(self, block: Block, votes: Set[Vote]): assert block.id() in self.safe_blocks @@ -424,8 +463,8 @@ class Carnot: # A node must change its view after making sure it has the high_Qc or last_timeout_view_qc # from previous view. return ( - is_sequential_ascending(self.current_view, self.local_high_qc.view) or - is_sequential_ascending(self.current_view, self.last_timeout_view_qc.view) or + self.current_view == self.local_high_qc.view + 1 or + self.current_view == self.last_timeout_view_qc.view + 1 or (self.current_view == self.last_timeout_view_qc.view) ) @@ -500,7 +539,7 @@ class Carnot: self.increment_voted_view(timeout_qc.view) # Just a suggestion that received_timeout_qc can be reused by each node when the process timeout_qc of the NewView msg. - def received_timeout_qc(self, timeout_qc: TimeoutQc): + def receive_timeout_qc(self, timeout_qc: TimeoutQc): # assert timeout_qc.view >= self.current_view new_high_qc = timeout_qc.high_qc if new_high_qc.view > self.local_high_qc.view: @@ -529,32 +568,9 @@ class Carnot: 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,)) - ) - - while can_commit and grand_parent and grand_parent.id() not in self.committed_blocks: - 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) - def increment_latest_committed_view(self, view: View): - self.latest_committed_view = max(view, self.latest_committed_view) - def reset_last_timeout_view_qc(self, qc: Qc): if qc.view < self.current_view: return @@ -565,12 +581,6 @@ class Carnot: return self.last_timeout_view_qc = timeout_qc self.current_view = self.last_timeout_view_qc.view + 1 - return True - - @staticmethod - def get_max_timeout(timeouts: Set[Timeout]) -> Timeout: - assert len(timeouts) > 0 - return max(timeouts, key=lambda time: time.qc.view) if __name__ == "__main__": diff --git a/carnot/test_happy_path.py b/carnot/test_happy_path.py index 4c14931..1b526c1 100644 --- a/carnot/test_happy_path.py +++ b/carnot/test_happy_path.py @@ -20,7 +20,6 @@ class TestCarnotHappyPath(TestCase): 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.committed_blocks[genesis_block.id()] = genesis_block return genesis_block def test_receive_block(self): @@ -134,9 +133,8 @@ class TestCarnotHappyPath(TestCase): 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): """ @@ -190,7 +188,7 @@ class TestCarnotHappyPath(TestCase): self.assertEqual(len(carnot.safe_blocks), 5) block5 = Block(view=5, qc=StandardQc(block=block4.id(), view=4), _id=b"5") carnot.receive_block(block5) - self.assertEqual(carnot.latest_committed_view, 3) + self.assertEqual(carnot.latest_committed_view(), 3) self.assertEqual(carnot.local_high_qc.view, 4) # Test cases for vote: @@ -230,8 +228,8 @@ class TestCarnotHappyPath(TestCase): 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_timeout_view_qc, None) def test_vote_for_received_block_if_threshold_votes_has_not_reached(self): """ diff --git a/carnot/test_unhappy_path.py b/carnot/test_unhappy_path.py index a5e9ce1..dd888e1 100644 --- a/carnot/test_unhappy_path.py +++ b/carnot/test_unhappy_path.py @@ -95,7 +95,6 @@ def add_genesis_block(carnot: Carnot) -> Block: carnot.increment_voted_view(0) carnot.local_high_qc = genesis_block.qc carnot.current_view = 1 - carnot.committed_blocks[genesis_block.id()] = genesis_block return genesis_block @@ -181,7 +180,7 @@ def fail(test_case: TestCase, overlay: MockOverlay, nodes: Dict[Id, MockCarnot], timeout_qc = root_member.latest_event for node in nodes.values(): - node.received_timeout_qc(timeout_qc) + node.receive_timeout_qc(timeout_qc) votes = {} childs_ids = list(chain.from_iterable(overlay.leaf_committees())) @@ -234,7 +233,7 @@ class TestCarnotUnhappyPath(TestCase): self.assertEqual(leader.highest_voted_view, view) for node in nodes.values(): - self.assertEqual(node.latest_committed_view, 0) + self.assertEqual(node.latest_committed_view(), 0) def test_interleave_success_fails(self): """ @@ -272,4 +271,4 @@ class TestCarnotUnhappyPath(TestCase): committed_blocks = [view for view in range(1, 11) if view not in (4, 7)] for node in nodes.values(): for view in committed_blocks: - self.assertIn(view, [block.view for block in node.committed_blocks.values()]) + self.assertIn(view, [block.view for block in node.committed_blocks().values()])