From 5a169039b5e264722710f622efa380ffa05202ee Mon Sep 17 00:00:00 2001 From: Daniel Sanchez Date: Thu, 18 May 2023 18:29:28 +0200 Subject: [PATCH] Random beacon v1 (#24) * Implement beacon verification and handling module * Create beacon tests and fix encountered problems * Refactor tests * Add mixed happy/unhappy test * Clean unused import * Add requirements.txt * Add beacon to package * Resolve relative import * Fmt * Refactor BeaconHandler -> RandomBeaconHandler Remove unused verification calls * Change view bytes encoding Extract generating private key * Bring back old trusty carnot * Added beaconized carnot module * Implement flat overlay * Refactor overlay next leader * Implement beaconized carnot * Fill proposed block on beaconized carnot * Sketch and update for testing purposes * Step up beaconized test * Fix missing leader selection * Fix random beacon test * Use recovery mode for random beacon initialization * Expose entropy as constructor parameter --- .github/workflows/ci.yml | 2 + carnot/__init__.py | 1 + carnot/beacon.py | 90 ++++++++++++++ carnot/beconized_carnot.py | 85 +++++++++++++ carnot/carnot.py | 30 +++-- carnot/overlay.py | 56 +++++++++ carnot/test_beacon_verification.py | 73 ++++++++++++ carnot/test_beaconized_carnot.py | 184 +++++++++++++++++++++++++++++ carnot/test_unhappy_path.py | 4 +- requirements.txt | 1 + 10 files changed, 511 insertions(+), 15 deletions(-) create mode 100644 carnot/beacon.py create mode 100644 carnot/beconized_carnot.py create mode 100644 carnot/overlay.py create mode 100644 carnot/test_beacon_verification.py create mode 100644 carnot/test_beaconized_carnot.py create mode 100644 requirements.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 744d6d3..e9f4522 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,5 +13,7 @@ jobs: with: # Semantic version range syntax or exact version of a Python version python-version: '3.x' + - name: Install dependencies + run: pip install -r requirements.txt - name: Run tests run: cd carnot && python -m unittest diff --git a/carnot/__init__.py b/carnot/__init__.py index 397eb42..7ccf359 100644 --- a/carnot/__init__.py +++ b/carnot/__init__.py @@ -1 +1,2 @@ from .carnot import * +from .beacon import RandomBeaconHandler diff --git a/carnot/beacon.py b/carnot/beacon.py new file mode 100644 index 0000000..ea96607 --- /dev/null +++ b/carnot/beacon.py @@ -0,0 +1,90 @@ +# typing imports +from dataclasses import dataclass +from random import randint +from typing import TypeAlias + +# carnot imports +# lib imports +from blspy import PrivateKey, Util, PopSchemeMPL, G2Element, G1Element + +# stdlib imports +from hashlib import sha256 + +View: TypeAlias = int +Beacon: TypeAlias = bytes +Proof: TypeAlias = bytes # For now this is gonna be a public key, in future research we may pivot to zk proofs. + + +def generate_random_sk() -> PrivateKey: + seed = bytes([randint(0, 255) for _ in range(32)]) + return PopSchemeMPL.key_gen(seed) + + +@dataclass +class RandomBeacon: + version: int + context: View + entropy: Beacon + # TODO: Just the happy path beacons owns a proof, we can set the proof to empty bytes for now. + # Probably we should separate this into two kinds of beacons and group them under a single type later on. + proof: Proof + + +class NormalMode: + + @staticmethod + def verify(beacon: RandomBeacon) -> bool: + """ + :param proof: BLS signature + :param beacon: Beacon is signature for current view + :param view: View to verify beacon upon + :return: + """ + # TODO: Actually verify that the message is propoerly signed + sig = G2Element.from_bytes(beacon.entropy) + proof = G1Element.from_bytes(beacon.proof) + return PopSchemeMPL.verify(proof, Util.hash256(str(beacon.context).encode()), sig) + + @staticmethod + def generate_beacon(private_key: PrivateKey, view: View) -> Beacon: + return bytes(PopSchemeMPL.sign(private_key, Util.hash256(str(view).encode()))) + + +class RecoveryMode: + + @staticmethod + def verify(last_beacon: RandomBeacon, beacon: RandomBeacon) -> bool: + """ + :param last_beacon: Unhappy -> last working beacon (signature), Happy -> Hash of previous beacon and next view number + :param beacon: + :param view: + :return: + """ + b = sha256(last_beacon.entropy + str(beacon.context).encode()).digest() + return b == beacon.entropy + + @staticmethod + def generate_beacon(last_beacon: Beacon, view: View) -> Beacon: + return sha256(last_beacon + str(view).encode()).digest() + + +class RandomBeaconHandler: + def __init__(self, beacon: RandomBeacon): + """ + :param beacon: Beacon should be initialized with either the last known working beacon from recovery. + Or the hash of the genesis block in case of first consensus round. + :return: Self + """ + self.last_beacon: RandomBeacon = beacon + + def verify_happy(self, new_beacon: RandomBeacon) -> bool: + if NormalMode.verify(new_beacon): + self.last_beacon = new_beacon + return True + return False + + def verify_unhappy(self, new_beacon: RandomBeacon) -> bool: + if RecoveryMode.verify(self.last_beacon, new_beacon): + self.last_beacon = new_beacon + return True + return False diff --git a/carnot/beconized_carnot.py b/carnot/beconized_carnot.py new file mode 100644 index 0000000..34e341d --- /dev/null +++ b/carnot/beconized_carnot.py @@ -0,0 +1,85 @@ +from typing import Set + +from carnot import Carnot, Block, TimeoutQc, Vote, Event, Send, Quorum +from beacon import * +from overlay import EntropyOverlay + +@dataclass +class BeaconizedBlock(Block): + beacon: RandomBeacon + + +class BeaconizedCarnot(Carnot): + def __init__(self, sk: PrivateKey, overlay: EntropyOverlay, entropy: bytes = b""): + self.sk = sk + self.pk = bytes(self.sk.get_g1()) + self.random_beacon = RandomBeaconHandler( + RandomBeacon( + version=0, + context=-1, + entropy=RecoveryMode.generate_beacon(entropy, -1), + proof=self.pk + ) + ) + overlay.set_entropy(self.random_beacon.last_beacon.entropy) + super().__init__(self.pk, overlay=overlay) + + def approve_block(self, block: BeaconizedBlock, votes: Set[Vote]) -> Event: + assert block.id() in self.safe_blocks + assert len(votes) == self.overlay.super_majority_threshold(self.id) + assert all(self.overlay.is_member_of_child_committee(self.id, vote.voter) for vote in votes) + assert all(vote.block == block.id() for vote in votes) + assert self.highest_voted_view < block.view + + if self.overlay.is_member_of_root_committee(self.id): + qc = self.build_qc(block.view, block, None) + else: + qc = None + + vote: Vote = Vote( + block=block.id(), + voter=self.id, + view=block.view, + qc=qc + ) + + self.highest_voted_view = max(self.highest_voted_view, block.view) + + # root members send votes to next leader, we update our beacon first + if self.overlay.is_member_of_root_committee(self.id): + self.random_beacon.verify_happy(block.beacon) + self.overlay.set_entropy(self.random_beacon.last_beacon.entropy) + return Send(to=self.overlay.leader(), payload=vote) + + # otherwise we send to the parent committee and update the beacon second + return_event = Send(to=self.overlay.parent_committee(self.id), payload=vote) + self.random_beacon.verify_happy(block.beacon) + self.overlay.set_entropy(self.random_beacon.last_beacon.entropy) + return return_event + + def receive_timeout_qc(self, timeout_qc: TimeoutQc): + super().receive_timeout_qc(timeout_qc) + if timeout_qc.view < self.current_view: + return + entropy = RecoveryMode.generate_beacon(self.random_beacon.last_beacon.entropy, timeout_qc.view) + new_beacon = RandomBeacon( + version=0, + context=self.current_view, + entropy=entropy, + proof=b"" + ) + self.random_beacon.verify_unhappy(new_beacon) + self.overlay.set_entropy(self.random_beacon.last_beacon.entropy) + + def propose_block(self, view: View, quorum: Quorum) -> Event: + beacon = RandomBeacon( + version=0, + context=self.current_view, + entropy=NormalMode.generate_beacon(self.sk, self.current_view), + proof=self.pk + ) + event: Event = super().propose_block(view, quorum) + block = event.payload + block = BeaconizedBlock(view=block.view, qc=block.qc, _id=block._id, beacon=beacon) + event.payload = block + return event diff --git a/carnot/carnot.py b/carnot/carnot.py index 1c1a355..5c19eab 100644 --- a/carnot/carnot.py +++ b/carnot/carnot.py @@ -36,9 +36,8 @@ # 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 - +from typing import TypeAlias, List, Set, Self, Optional, Dict +from abc import abstractmethod, ABC Id: TypeAlias = bytes View: TypeAlias = int @@ -156,6 +155,7 @@ class Send: Event: TypeAlias = BroadCast | Send + class Overlay: """ Overlay structure for a View @@ -167,16 +167,20 @@ class Overlay: :param _id: Node id to be checked :return: true if node is the leader of the current view """ - pass + return _id == self.leader() @abstractmethod - def leader(self, view: View) -> Id: + def leader(self) -> Id: """ :param view: :return: the leader Id of the specified view """ pass + @abstractmethod + def next_leader(self) -> Id: + pass + @abstractmethod def is_member_of_leaf_committee(self, _id: Id) -> bool: """ @@ -248,7 +252,7 @@ def download(view) -> Block: class Carnot: - def __init__(self, _id: Id): + def __init__(self, _id: Id, overlay=Overlay()): 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 @@ -261,9 +265,9 @@ class Carnot: # Validated blocks with their validated QCs are included here. If commit conditions are satisfied for # each one of these blocks it will be committed. self.safe_blocks: Dict[Id, Block] = dict() - # Whether the node timeed out in the last view and corresponding qc + # Whether the node time out in the last view and corresponding qc self.last_view_timeout_qc: Optional[TimeoutQc] = None - self.overlay: Overlay = Overlay() # TODO: integrate overlay + self.overlay: Overlay = overlay # Committing conditions for a block @@ -394,7 +398,7 @@ class Carnot: assert self.highest_voted_view == vote.view if self.overlay.is_member_of_root_committee(self.id): - return Send(to=self.overlay.leader(self.current_view + 1), payload=vote) + return Send(to=self.overlay.next_leader(), payload=vote) def forward_new_view(self, msg: NewView) -> Optional[Event]: assert msg.view == self.current_view @@ -403,7 +407,7 @@ class Carnot: assert self.highest_voted_view == msg.view if self.overlay.is_member_of_root_committee(self.id): - return Send(to=self.overlay.leader(self.current_view + 1), payload=msg) + return Send(to=self.overlay.next_leader(), payload=msg) def build_qc(self, view: View, block: Optional[Block], new_views: Optional[Set[NewView]]) -> Qc: # unhappy path @@ -475,7 +479,7 @@ 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 ( - self.current_view == self.local_high_qc.view + 1 or + 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) ) @@ -553,7 +557,7 @@ class Carnot: self.highest_voted_view = max(self.highest_voted_view, view) if self.overlay.is_member_of_root_committee(self.id): - return Send(payload=timeout_msg, to=[self.overlay.leader(self.current_view + 1)]) + return Send(payload=timeout_msg, to=[self.overlay.next_leader()]) return Send(payload=timeout_msg, to=self.overlay.parent_committee(self.id)) @@ -569,7 +573,7 @@ class Carnot: 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) + # 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 diff --git a/carnot/overlay.py b/carnot/overlay.py new file mode 100644 index 0000000..c68b3ea --- /dev/null +++ b/carnot/overlay.py @@ -0,0 +1,56 @@ +import random +from abc import abstractmethod +from typing import Set, Optional, List +from carnot import Overlay, Id, Committee, View + + +class EntropyOverlay(Overlay): + @abstractmethod + def set_entropy(self, entropy: bytes): + pass + + +class FlatOverlay(EntropyOverlay): + def set_entropy(self, entropy: bytes): + self.entropy = entropy + + def is_leader(self, _id: Id): + return _id == self.leader() + + def leader(self) -> Id: + random.seed(a=self.entropy, version=2) + return random.choice(self.nodes) + + def is_member_of_leaf_committee(self, _id: Id) -> bool: + return True + + def is_member_of_root_committee(self, _id: Id) -> bool: + return True + + def is_member_of_child_committee(self, parent: Id, child: Id) -> bool: + return False + + def parent_committee(self, _id: Id) -> Optional[Committee]: + return None + + def leaf_committees(self) -> Set[Committee]: + return {frozenset(self.nodes)} + + def root_committee(self) -> Committee: + return set(self.nodes) + + def is_child_of_root_committee(self, _id: Id) -> bool: + return True + + def leader_super_majority_threshold(self, _id: Id) -> int: + return ((len(self.nodes) * 2) // 3) + 1 + + def super_majority_threshold(self, _id: Id) -> int: + return 0 + + def __init__(self, nodes: List[Id]): + self.nodes = nodes + self.entropy = None + + + diff --git a/carnot/test_beacon_verification.py b/carnot/test_beacon_verification.py new file mode 100644 index 0000000..0a948b1 --- /dev/null +++ b/carnot/test_beacon_verification.py @@ -0,0 +1,73 @@ +from typing import Tuple +from unittest import TestCase + +from beacon import * +from random import randint + + +class TestRandomBeaconVerification(TestCase): + + @staticmethod + def happy_entropy_and_proof(view: View) -> Tuple[Beacon, Proof]: + sk = generate_random_sk() + beacon = NormalMode.generate_beacon(sk, view) + return bytes(beacon), bytes(sk.get_g1()) + + @staticmethod + def unhappy_entropy(last_beacon: Beacon, view: View) -> Beacon: + return RecoveryMode.generate_beacon(last_beacon, view) + + def setUp(self): + entropy, proof = self.happy_entropy_and_proof(0) + self.beacon = RandomBeaconHandler( + beacon=RandomBeacon( + version=0, + context=0, + entropy=entropy, + proof=proof + ) + ) + + def test_happy(self): + for i in range(3): + entropy, proof = self.happy_entropy_and_proof(i) + new_beacon = RandomBeacon( + version=0, + context=i, + entropy=entropy, + proof=proof + ) + self.beacon.verify_happy(new_beacon) + self.assertEqual(self.beacon.last_beacon.context, 2) + + def test_unhappy(self): + for i in range(1, 3): + entropy = self.unhappy_entropy(self.beacon.last_beacon.entropy, i) + new_beacon = RandomBeacon( + version=0, + context=i, + entropy=entropy, + proof=b"" + ) + self.beacon.verify_unhappy(new_beacon) + self.assertEqual(self.beacon.last_beacon.context, 2) + + def test_mixed(self): + for i in range(1, 6, 2): + entropy, proof = self.happy_entropy_and_proof(i) + new_beacon = RandomBeacon( + version=0, + context=i, + entropy=entropy, + proof=proof + ) + self.beacon.verify_happy(new_beacon) + entropy = self.unhappy_entropy(self.beacon.last_beacon.entropy, i+1) + new_beacon = RandomBeacon( + version=0, + context=i+1, + entropy=entropy, + proof=b"" + ) + self.beacon.verify_unhappy(new_beacon) + self.assertEqual(self.beacon.last_beacon.context, 6) diff --git a/carnot/test_beaconized_carnot.py b/carnot/test_beaconized_carnot.py new file mode 100644 index 0000000..f5b57e5 --- /dev/null +++ b/carnot/test_beaconized_carnot.py @@ -0,0 +1,184 @@ +from typing import Dict, List +from unittest import TestCase +from itertools import chain + +from blspy import PrivateKey + +from carnot import Id, Carnot, Block, Overlay, Vote, StandardQc, NewView +from beacon import generate_random_sk, RandomBeacon, NormalMode +from beconized_carnot import BeaconizedCarnot, BeaconizedBlock +from overlay import FlatOverlay, EntropyOverlay +from test_unhappy_path import parents_from_childs + + +def gen_node(sk: PrivateKey, overlay: Overlay, entropy: bytes = b""): + node = BeaconizedCarnot(sk, overlay) + return node.id, node + + +def succeed(nodes: Dict[Id, BeaconizedCarnot], proposed_block: BeaconizedBlock) -> (List[Vote], EntropyOverlay): + overlay = FlatOverlay(list(nodes.keys())) + overlay.set_entropy(proposed_block.beacon.entropy) + + # 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, overlay + + +def fail(nodes: Dict[Id, BeaconizedCarnot], proposed_block: BeaconizedBlock) -> (List[NewView], EntropyOverlay): + overlay = FlatOverlay(list(nodes.keys())) + overlay.set_entropy(proposed_block.beacon.entropy) + # broadcast the block + for node in nodes.values(): + node.receive_block(proposed_block) + + node: BeaconizedCarnot + 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)) + timeouts = [timeouts[i] for i in range(overlay.leader_super_majority_threshold(root_member.id))] + timeout_qc = root_member.timeout_detected(timeouts).payload + + for node in nodes.values(): + node.receive_timeout_qc(timeout_qc) + + overlay = next(iter(nodes.values())).overlay + + 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, overlay + + +def add_genesis_block(carnot: BeaconizedCarnot, sk: PrivateKey) -> Block: + entropy = NormalMode.generate_beacon(sk, -1) + genesis_block = BeaconizedBlock( + view=0, + qc=StandardQc(block=b"", view=0), + _id=b"", + beacon=RandomBeacon( + version=0, + context=-1, + entropy=entropy, + proof=bytes(sk.get_g1()) + ) + ) + carnot.safe_blocks[genesis_block.id()] = genesis_block + carnot.receive_block(genesis_block) + carnot.local_high_qc = genesis_block.qc + carnot.current_view = 1 + carnot.overlay.set_entropy(entropy) + return genesis_block + + +def setup_initial_setup(test_case: TestCase, size: int) -> (Dict[Id, Carnot], Carnot, Block, EntropyOverlay): + keys = [generate_random_sk() for _ in range(size)] + nodes_ids = [bytes(key.get_g1()) for key in keys] + genesis_sk = generate_random_sk() + nodes = dict(gen_node(key, FlatOverlay(nodes_ids), bytes(genesis_sk.get_g1())) for key in keys) + genesis_block = None + overlay = FlatOverlay(nodes_ids) + overlay.set_entropy(NormalMode.generate_beacon(genesis_sk, -1)) + leader: Carnot = nodes[overlay.leader()] + for node in nodes.values(): + genesis_block = add_genesis_block(node, genesis_sk) + # votes for genesis block + genesis_votes = set( + Vote( + block=genesis_block.id(), + view=0, + voter=nodes_ids[i], + qc=StandardQc( + block=genesis_block.id(), + view=0 + ), + ) for i in range(overlay.leader_super_majority_threshold(overlay.leader())) + ) + proposed_block = leader.propose_block(1, genesis_votes).payload + test_case.assertIsNotNone(proposed_block) + overlay = FlatOverlay(nodes_ids) + overlay.set_entropy(NormalMode.generate_beacon(genesis_sk, -1)) + return nodes, leader, proposed_block, overlay + + +class TestBeaconizedCarnot(TestCase): + 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. + """ + leader: BeaconizedCarnot + nodes, leader, proposed_block, overlay = setup_initial_setup(self, 5) + + for view in range(2, 5): + root_votes, overlay = succeed(nodes, proposed_block) + leader = nodes[overlay.leader()] + proposed_block = leader.propose_block(view, root_votes).payload + + root_votes, overlay = fail(nodes, proposed_block) + leader = nodes[overlay.leader()] + proposed_block = leader.propose_block(6, root_votes).payload + + for view in range(7, 8): + root_votes, overlay = succeed(nodes, proposed_block) + leader = nodes[overlay.leader()] + proposed_block = leader.propose_block(view, root_votes).payload + + root_votes, overlay = fail(nodes, proposed_block) + leader = nodes[overlay.leader()] + proposed_block = leader.propose_block(9, root_votes).payload + + for view in range(10, 15): + root_votes, overlay = succeed(nodes, proposed_block) + leader = nodes[overlay.leader()] + 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()]) diff --git a/carnot/test_unhappy_path.py b/carnot/test_unhappy_path.py index 486fa93..2012453 100644 --- a/carnot/test_unhappy_path.py +++ b/carnot/test_unhappy_path.py @@ -126,7 +126,7 @@ def parents_from_childs(overlay: MockOverlay, childs: List[Id]) -> Set[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]: +def succeed(test_case: TestCase, overlay: Overlay, nodes: Dict[Id, Carnot], proposed_block: Block) -> List[Vote]: # broadcast the block for node in nodes.values(): node.receive_block(proposed_block) @@ -155,7 +155,7 @@ def succeed(test_case: TestCase, overlay: MockOverlay, nodes: Dict[Id, MockCarno return root_votes -def fail(test_case: TestCase, overlay: MockOverlay, nodes: Dict[Id, MockCarnot], proposed_block: Block) -> List[NewView]: +def fail(test_case: TestCase, overlay: Overlay, nodes: Dict[Id, Carnot], proposed_block: Block) -> List[NewView]: # broadcast the block for node in nodes.values(): node.receive_block(proposed_block) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..058bb11 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +blspy~=1.0.16 \ No newline at end of file