17 KiB
Carnot Specification
This is the pseudocode specification of the Carnot consensus algorithm.
In this specification we will omit any cryptographic material, block validity and proof checks. A real implementation is expected to check those before hitting this code.
In addition, all types can be expected to have their invariants checked by the type contract, e.g. in an instance of type Qc::Aggregate the high_qc field is always the most recent qc among the aggregate qcs and the code can skip this check.
'Q:' is used to indicate unresolved questions. Notation is loosely based on CDDL.
Messages
A critical piece in the protocol, these are the different kind of messages used by participants during the protocol execution.
Block: propose a new blockVote: vote for a block proposalTimeout: propose to jump to a new view after a proposal for the current one was not received before a configurable timeout.
Block
(sometimes also called proposal)
We assume an unique identifier of the block can be obtained, for example by hashing its contents. We will use the id() function to access the identifier of the current block.
We also assume that a unique tree order of blocks can be determined, and in particular each participant can identify the parent of each block. We will use the parent() function to access such parent block.
@dataclass
class Block:
view: View
qc: Qc
View
A monotonically increasing number (considerations about the size?)
View = int
Qc
There are currently two different types of QC:
Qc = StandardQc | AggregateQc
Standard
Q: there can only be one block on which consensus in achieved for a view, so maybe the block field is redundant?
class StandardQc:
view: View
block: Id
Aggregate
high_qc is Qc for the most recent view among the aggregated ones. The rest of the qcs are ignored in the rest of this algorithm.
We assume there is a block function available that returns the block for the Qc. In case of a standard qc, this is trivially qc.block, while for aggregate it can be obtained by accessing high_qc. high_qc is guaranteed to be a 'Standard' qc.
class AggregateQc:
view: View
qcs: List[Qc]
def high_qc(self) -> Qc:
return max(self.qcs, key=lambda qc: qc.view)
Id
undef, will assume a 32-byte opaque string
Id: bytes = bytearray(32)
Vote
A vote for block in view
qc is the optional field containing the QC built by root nodes from 2/3 + 1 votes from their child committees and forwarded the the next view leader.
class Vote:
block: Id
view: View
voter: Id
qc: Option[Qc]
TimeoutMsg
view: View ### Currently the 3 QC fields are not being used. But I am keeping it as it might be useful to save a roundtrip during unhappy path. high_qc: Qc timeout_qc: TimeoutMsg_qc sender: Id
```python
@dataclass
class TimeoutMsg:
view: View
high_qc: Qc
timeout_qc: Qc
sender: Id
### Timeout
```python
class Timeout:
view: View
high_qc: Qc
TimeoutMsg_qc
view: View ### Currently the 3 QC fields are not being used. But I am keeping it as it might be useful to save a roundtrip during unhappy path. high_qcs: list[Qc] sender: Id
```python
@dataclass
class TimeoutMsg_qc:
high_qcs: list[Qc]
timeout_qc: Qc
sender: Id
Local Variables
Participants in the protocol are expected to mainting the following data in addition to the DAG of received proposal:
current_viewlocal_high_qclatest_committed_viewcollection: TODO rename
CURRENT_VIEW: View
LOCAL_HIGH_QC: Qc
LATEST_COMMITTED_VIEW: View
PENDING_VOTE_COLLECTION: dict() #id here presents Hash of the block returnd by block.Id()
PENDING_TIMEOUTMSG_COLLECTION: dict()
LAST_VIEW_TIMEOUT_QC: Timeout_qc
Available Functions
The following functions are expected to be available to participants during the execution of the protocol:
-
leader(view): returns the leader of the view. -
reset_timer(view): resets timer for a specific view. If the timer expires thetimeoutroutine is triggered. -
extends(block, ancestor): returns true if block is descendant of the ancestor in the chain. -
download(view): Download missing block for the view. getMaxViewQC(qcs): returns the qc with the highest view number. -
member_of_leaf_committee(): returns true if the participant executing the function is in the leaf committee of the committee overlay. -
member_of_root_com(): returns true if the participant executing the function is member of the root committee withing the tree overlay. -
member_of_internal_com(): returns true if the participant executing the function is member of internal committees within the committee tree overlay -
child_committee(participant): returns true if the participant passed as argument is member of the child committee of the participant executing the function. -
supermajority(): the behavior changes with the position of a participant in the overlay:- committee members: returns if the number of distinctive signers of votes, for a block in the child committee is equal to the threshold.
- For leader: returns if the number of distinct voters for a block is 2/3 + 1 for both children committees of root committee and overall 2/3 + 1
-
morethanSsupermajority(votes): returns if the number of distinctive signers of votes for a block is is more than the threshold: TODO -
parent_committee: return the parent committee of the participant executing the function withing the committee tree overlay. Result is undefined if called from a participant in the root committee. -
oblivious_adjacent_committees(): returns the committees from which a node has not received the timeout_qc.
Core functions
These are the core functions necessary for the Carnot consensus protocol, to be executed in response of incoming messages, except for timeout which is triggered by a participant configurable timer.
Receive block
def receive_block(block: Block):
if block.id() is known or block.view <=LATEST_COMMITTED_VIEW:
return
# Recursively make sure that we process blocks in order
if block.parent() is missing:
parent: Block = download(block.parent())
receive(parent)
if safe_block(block):
# This is not in the original spec, but
# let's validate I have this clear.
assert block.view == current_view
update_high_qc(block.qc)
vote = create_vote()
if member_of_leaf_committee():
if member_of_root_committee():
send(vote, leader(current_view + 1))
else:
send(vote, parent_commitee())
# current_view += 1
# reset_timer()
increment_view_qc(block.qc)
Auxiliary functions
def safe_block_qc (block: Block):
match block.qc:
case StandardQc() as standard:
# Previous leader did not fail and its proposal was certified
# if standard.view <= LATEST_COMMITED_BLOCK:
if standard.view < CURRENT_VIEW:
return False
# increment_view_qc(standard.view+1)
# this check makes sure block is not old
# and the previous leader did not fail
return block.view >= CURRENT_VIEW and block.view == (standard.view + 1)
case AggregateQc() as aggregated_qc:
# Verification of block.aggQC.highQC along
# with signature or block.aggQC.signature is sufficient.
# No need to verify each qc inside block.aggQC
if aggregated_qc.high_qc().view <= LATEST_COMMITED_BLOCK:
return False
return block.view >= CURRENT_VIEW
# we ensure by construction this extends the block in
# high_qc since that is by definition the parent of this block
# Commit a grand parent if the grandparent and
# the parent have been added during two consecutive views.
def try_to_commit_grand_parent(block: Block):
parent = block.parent()
grand_parent = parent.parent()
if (parent.view == (grand_parent.view + 1) and
isinstance(block.qc, (StandardQc, )) and # Q: Is this necessary? Yes, this is very important for safety.
isinstance(parent.qc, (StandardQc, )) # Q: Is this necessary?
):
LAST_COMMITTED_VIEW=grand_parent.view
return true
else:
return false
# Update last_committed_view ? (Ans: Yes, last_committed_view has to be updated.)
# Update the latest certification (qc)
def update_high_qc(qc: Qc):
match qc:
# Happy case
case Standard() as qc:
# TODO: revise
if qc.view > LOCAL_HIGH_QC.view:
LOCAL_HIGH_QC = qc
# Q: The original pseudocde checked for possilbly
# missing view and downloaded them, but I think
# we already dealt with this in receive_block (You are right!)
# Unhappy case
case Aggregate() as qc:
high_qc = qc.high_qc()
if high_qc.view != LOCAL_HIGH_QC.view:
LOCAL_HIGH_QC = high_qc
# Q: same thing about missing views. (Ans: This is a different case. A node might have a higher local_high_qc but other
# nodes where not able to see it due to failure. Now since a new leader is being selected, we need to make sure the high_qc is
# synched across network.)
Receive Vote
Q: this whole function needs to be revised
def receive_vote(vote: Vote):
if vote.block is missing:
block = download(vote.block)
receive(block)
if vote.view < CURRENT_VIEW: # Unless we collect votes for attestation for rewards (PoS)
return
if leader(vote.view+1): # Q? Which view? CURRENT_VIEW or vote.view? => Ans: vote.view+1 Vote for the view
if root_commitee(vote.voter):
# Q: No filtering? I can just create a key and vote? (Ans: A check is added to confirm the root_commitee voter)
PENDING_VOTE_COLLECTION[vote.block].append(vote)
if len(PENDING_VOTE_COLLECTION[vote.block])==supermajority():
qc = build_qc(PENDING_VOTE_COLLECTION[vote.block])
block = build_block(txs,CURRENT_VIEW+1,qc, AggQC=None)
broadcast(block)
# Q: we should probably return if we already received this vote (Ans: Yes. It should be done, for all types of messages received. votes should be counted from )
if member_of_internal_com() and not member_of_root():
if child_commitee(vote.voter):
PENDING_VOTE_COLLECTION[vote.block].append(vote)
# else:
# Q: not returning here would mean it's extremely easy to
# trigger building a new vote in the following branches
# return
# (return is not necessary as long as it stays within if scope)
if len(PENDING_VOTE_COLLECTION[vote.block])==supermajority():
# Q: should we send it to everyone in the committee?
self_vote = build_vote()
send(self_vote, parent_committee)
if member_of_root():
if child_commitee(vote.voter):
PENDING_VOTE_COLLECTION[vote.block].append(vote)
# else:
# Q: not returning here would mean it's extremely easy to
# trigger building a new vote in the following branches
# return
# Ans: return is not necessary as long as it stays within if scope
# Q: The vote to send is not the one received but
if len(PENDING_VOTE_COLLECTION[vote.block])==supermajority():
# Q: The vote to send is not the one received but
# the one build by this participant, right?
self_vote = build_vote();
qc = build_qc(PENDING_VOTE_COLLECTION[vote.block])
self_vote.qc=qc
send(self_vote, leader(current_view + 1))
# Q: this means that we send a message for every incoming
# message after the threshold has been reached, i.e. a vote
# from a node in the leaf committee can trigger
# at least height(tree) messages.
if len(PENDING_VOTE_COLLECTION[vote.block]) > supermajority():
# just forward the vote to the leader
# Q: But then childcommitte(vote.voter) would return false
# in the leader, as it's a granchild, not a child
send(vote, leader(current_view + 1))
This is all part of pacemaker (liveness) module
Timeout
def local_timeout():
save_consensus_state()
if (member_of_internal_com() and not member_of_root()) or member_of_leaf():
timeoutMsg = create_timeout(CURRENT_VIEW,LOCAL_HIGH_QC, TIMEOUT_QC)
send(timeoutMsg, parent_committee())
if member_of_root():
timeoutMsg = create_timeout(CURRENT_VIEW,LOCAL_HIGH_QC, TIMEOUT_QC)
send(timeoutMsg, root_committee()) # Need to be discussed. It can only be sent to the next leader but since the RB needs agreement to generate the seed for the leader+overlay, therefore newView is sent to the root_committee().
def receive_timeoutMsgs(timeoutMsg: TimeoutMsg):
if CURRENT_VIEW > timeoutMsg.view:
return
if timeoutMsg.view <= LAST_VIEW_TIMEOUT_QC.view:
return
#update_high_qc(timeoutMsg.high_qc)
PENDING_TIMEOUTMSG_COLLECTION[timeoutMsg.view].append(timeoutMsg)
#Supermajority
if len(PENDING_TIMEOUTMSG_COLLECTION[timeoutMsg.view])== supermajority():
timeout_qc = create_timeout_qc(PENDING_TIMEOUTMSG_COLLECTION[timeoutMsg.view])
send(timeout_qc, own_committee()) ####helps nodes to sync quicker but not required
if member_of_root():
# send(timeout_qc, leader(view+1))
send(timeout_qc, child_committee())
else:
send(timeout_qc, parent_committee())
send(timeout_qc, child_committee())
return timeout_qc
return
def receive_timeout_qc(timeout_qc: Timeout_qc):
if Timeout_qc.view < CURRENT_VIEW:
return
if Timeout_qc.view <= LAST_VIEW_TIMEOUT_QC.view: # Do not propagate and old timeout QC or the one the node already have propagated.
return
#stop_timer(current_view)
if member_of_internal_com():
send(timeout_qc, oblivious_adjacent_committees())
increment_view_timeout_qc(timeout_qc.view)
#Recalculate New Overlay
if member_of_Leaf():
sync_Msg = create_sync_Msg (CURRENT_VIEW, LOCAL_High_QC)
send(sync_Msg, parent_committee())
# else:
# send(timeout_qc, parent_committee())
def receive_syncMsg(sync_Msg: Sync_Msg):
if CURRENT_VIEW > sync_Msg.view:
return
# Update and download missing blocks.
if sync_Msg.qc.view>LOCAL_HIGH_QC.view:
LOCAL_HIGH_QC=sync_Msg.qc
if LAST_VIEW_TIMEOUT_QC.view<sync_Msg.last_view_timeout_Msg_qc.view: # This is not needed for consensus. But helps a node catchup if it missed timeout msgs.
LAST_VIEW_TIMEOUT_QC = sync_Msg.last_view_timeout_Msg_qc
PENDING_SYNCMSG_COLLECTION[sync_Msg.view].append(sync_Msg)
if len(PENDING_SYNCMSG_COLLECTION[sync_Msg.view])== supermajority():
if member_of_internal() and not member_of_root():
sync_Msg = create_sync_Msg (CURRENT_VIEW, LOCAL_HIGH_QC)
send(sync_Msg, parent_committee())
if member_of_root():
sync_Msg = create_sync_Msg (CURRENT_VIEW, LOCAL_HIGH_QC)
sync_Msg_qc = create_sync_Msg_qc (PENDING_SYNCMSG_COLLECTION[sync_Msg.view])
sync_Msg.sync_qc = sync_Msg_qc
send(sync_Msg, leader(sync_Msg.view))
if leader(sync_Msg.view):
AggQC = build_AggQC(PENDING_SYNCMSG_COLLECTION[sync_Msg.view])
block = build_block(txs,CURRENT_VIEW+1,qc=None, AggQC)
broadcast(block)
def increment_view_qc (qc: Qc):
if qc.view < CURRENT_VIEW:
return false
LAST_VIEW_TIMEOUT_QC = None
start_timer(qc.view+1)
return true
def increment_view_timeout_qc (timeout_qc: Timeout_qc):
if timeout_qc==None or timeout_qc.view < CURRENT_VIEW:
return false
LAST_VIEW_TIMEOUT_QC = timeout_qc
start_timer(timeout_qc.view+1)
return true
def start_timer(next_view: int):
stop_timer(CURRENT_VIEW)
CURRENT_VIEW = next_view
timer_start(duration)
We need to make sure that qcs can't be removed from aggQc when going up the tree