diff --git a/substrate/consensus/src/lib.rs b/substrate/consensus/src/lib.rs index 763e6ff4..c6c902d9 100644 --- a/substrate/consensus/src/lib.rs +++ b/substrate/consensus/src/lib.rs @@ -9,6 +9,7 @@ use sc_service::TaskManager; use serai_runtime::{self, opaque::Block, RuntimeApi}; mod algorithm; +mod tendermint; pub struct ExecutorDispatch; impl sc_executor::NativeExecutionDispatch for ExecutorDispatch { diff --git a/substrate/consensus/src/tendermint/mod.rs b/substrate/consensus/src/tendermint/mod.rs new file mode 100644 index 00000000..3057c01d --- /dev/null +++ b/substrate/consensus/src/tendermint/mod.rs @@ -0,0 +1,352 @@ +use std::collections::HashMap; + +type ValidatorId = u16; +const VALIDATORS: ValidatorId = 5; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +struct Hash; +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +struct Block { + hash: Hash +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum BlockError { + // Invalid behavior entirely + Fatal, + // Potentially valid behavior dependent on unsynchronized state + Temporal, +} + +fn valid(block: &Block) -> Result<(), BlockError> { + Ok(()) +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +enum Step { + Propose, + Prevote, + Precommit, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +enum Data { + Proposal(Option, Block), + Prevote(Option), + Precommit(Option), +} + +impl Data { + fn step(&self) -> Step { + match self { + Data::Proposal(..) => Step::Propose, + Data::Prevote(..) => Step::Prevote, + Data::Precommit(..) => Step::Precommit, + } + } +} + +#[derive(Clone, PartialEq, Eq, Debug)] +struct Message { + sender: ValidatorId, + + height: u32, + round: u32, + + data: Data, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum TendermintError { + MaliciousOrTemporal(u16), // TODO: Remove when we figure this out + Malicious(u16), + Offline(u16), + Temporal, +} + +fn proposer(height: u32, round: u32) -> ValidatorId { + ValidatorId::try_from((height + round) % u32::try_from(VALIDATORS).unwrap()).unwrap() +} + +fn broadcast(msg: Message) { + todo!(); +} + +#[derive(Clone, PartialEq, Eq, Debug)] +struct TendermintMachine { + proposer: ValidatorId, + personal_proposal: Option, + + height: u32, + + log_map: HashMap>>, + precommitted: HashMap, + + round: u32, + step: Step, + locked: Option<(u32, Block)>, + valid: Option<(u32, Block)>, +} + +impl TendermintMachine { + fn broadcast(&self, data: Data) -> Option { + let msg = Message { sender: self.proposer, height: self.height, round: self.round, data }; + let res = self.message(msg).unwrap(); + broadcast(msg); + res + } + + // 14-21 + fn round_propose(&mut self) { + // This will happen if it's a new height and propose hasn't been called yet + if self.personal_proposal.is_none() { + // Ensure it's actually a new height. Else, the caller failed to provide necessary data yet + // is still executing the machine + debug_assert_eq!(self.round, 0); + return; + } + + if proposer(self.height, self.round) == self.proposer { + let (round, block) = if let Some((round, block)) = self.valid { + (Some(round), block) + } else { + (None, self.personal_proposal.unwrap()) + }; + debug_assert!(self.broadcast(Data::Proposal(round, block)).is_none()); + } else { + // TODO schedule timeout propose + } + } + + // 11-13 + fn round(&mut self, round: u32) { + self.round = round; + self.step = Step::Propose; + self.round_propose(); + } + + /// Called whenever a new height occurs + pub fn propose(&mut self, block: Block) { + self.personal_proposal = Some(block); + self.round_propose(); + } + + // 1-9 + fn reset(&mut self) { + self.personal_proposal = None; + + self.height += 1; + + self.log_map = HashMap::new(); + self.precommitted = HashMap::new(); + + self.locked = None; + self.valid = None; + + self.round(0); + } + + // 10 + pub fn new(proposer: ValidatorId, height: u32) -> TendermintMachine { + TendermintMachine { + proposer, + personal_proposal: None, + + height, + + log_map: HashMap::new(), + precommitted: HashMap::new(), + + locked: None, + valid: None, + + round: 0, + step: Step::Propose + } + } + + // Returns true if it's a new message + fn log(&mut self, msg: Message) -> Result { + if matches!(msg.data, Data::Proposal(..)) && (msg.sender != proposer(msg.height, msg.round)) { + Err(TendermintError::Malicious(msg.sender))?; + }; + + if !self.log_map.contains_key(&msg.round) { + self.log_map.insert(msg.round, HashMap::new()); + } + let log = self.log_map.get_mut(&msg.round).unwrap(); + if !log.contains_key(&msg.sender) { + log.insert(msg.sender, HashMap::new()); + } + let log = log.get_mut(&msg.sender).unwrap(); + + // Handle message replays without issue. It's only multiple messages which is malicious + let step = msg.data.step(); + if let Some(existing) = log.get(&step) { + if existing != &msg.data { + Err(TendermintError::Malicious(msg.sender))?; + } + return Ok(false); + } + + // If they already precommitted to a distinct hash, error + if let Data::Precommit(Some(hash)) = msg.data { + if let Some(prev) = self.precommitted.get(&msg.sender) { + if hash != *prev { + Err(TendermintError::Malicious(msg.sender))?; + } + } + self.precommitted.insert(msg.sender, hash); + } + + log.insert(step, msg.data); + Ok(true) + } + + fn message_instances(&self, round: u32, data: Data) -> (usize, usize) { + let participating = 0; + let weight = 0; + for participant in self.log_map[&round].values() { + if let Some(msg) = participant.get(&data.step()) { + let validator_weight = 1; // TODO + participating += validator_weight; + if &data == msg { + weight += validator_weight; + } + + // If the msg exists, yet has a distinct hash, this validator is faulty + // (at least for precommit) + // TODO + } + } + (participating, weight) + } + + fn participation(&self, round: u32, step: Step) -> usize { + let (participating, _) = self.message_instances(round, match step { + Step::Propose => panic!("Checking for participation on Propose"), + Step::Prevote => Data::Prevote(None), + Step::Precommit => Data::Precommit(None), + }); + participating + } + + fn has_participation(&self, round: u32, step: Step) -> bool { + self.participation(round, step) >= ((VALIDATORS / 3 * 2) + 1).into() + } + + fn has_consensus(&self, round: u32, data: Data) -> bool { + let (_, weight) = self.message_instances(round, data); + weight >= ((VALIDATORS / 3 * 2) + 1).into() + } + + // 49-54 + fn check_committed(&mut self, round_num: u32) -> Option { + let proposer = proposer(self.height, round_num); + // Safe as we only check for rounds which we received a message for + let round = self.log_map[&round_num]; + + // Get the proposal + if let Some(proposal) = round.get(&proposer).map(|p| p.get(&Step::Propose)).flatten() { + // Destructure + debug_assert!(matches!(proposal, Data::Proposal(..))); + if let Data::Proposal(_, block) = proposal { + // Check if it has gotten a sufficient amount of precommits + let (participants, weight) = self.message_instances(round_num, Data::Precommit(Some(block.hash))); + + let threshold = ((VALIDATORS / 3) * 2) + 1; + if weight >= threshold.into() { + self.reset(); + return Some(*block); + } + + if (participants >= threshold.into()) && first { + schedule timeoutPrecommit(self.height, round); + } + } + } + + None + } + + pub fn message(&mut self, msg: Message) -> Result, TendermintError> { + if msg.height != self.height { + Err(TendermintError::Temporal)?; + } + + if !self.log(msg)? { + return Ok(None); + } + + // All functions, except for the finalizer and the jump, are locked to the current round + // Run the finalizer to see if it applies + if matches!(msg.data, Data::Proposal(..)) || matches!(msg.data, Data::Precommit(_)) { + let block = self.check_committed(msg.round); + if block.is_some() { + return Ok(block); + } + } + + // Else, check if we need to jump ahead + let round = self.log_map[&self.round]; + if msg.round < self.round { + return Ok(None); + } else if msg.round > self.round { + // 55-56 + // TODO: Move to weight + if round.len() > ((VALIDATORS / 3) + 1).into() { + self.round(msg.round); + } else { + return Ok(None); + } + } + + if self.step == Step::Propose { + if let Some(proposal) = round.get(&proposer(self.height, self.round)).map(|p| p.get(&Step::Propose)).flatten() { + debug_assert!(matches!(proposal, Data::Proposal(..))); + if let Data::Proposal(vr, block) = proposal { + if let Some(vr) = vr { + // 28-33 + let vr = *vr; + if (vr < self.round) && self.has_consensus(vr, Data::Prevote(Some(block.hash))) { + debug_assert!(self.broadcast( + Data::Prevote( + Some(block.hash).filter(|_| self.locked.map(|(round, value)| (round <= vr) || (block == &value)).unwrap_or(true)) + ) + ).is_none()); + self.step = Step::Prevote; + } else { + Err(TendermintError::Malicious(msg.sender))?; + } + } else { + // 22-27 + valid(&block).map_err(|_| TendermintError::Malicious(msg.sender))?; + debug_assert!(self.broadcast(Data::Prevote(Some(block.hash).filter(|_| self.locked.is_none() || self.locked.map(|locked| &locked.1) == Some(block)))).is_none()); + self.step = Step::Prevote; + } + } + } + } + + if self.step == Step::Prevote { + let (participation, weight) = self.message_instances(self.round, Data::Prevote(None)); + // 34-35 + if (participation > (((VALIDATORS / 3) * 2) + 1).into()) && first { + // TODO: Schedule timeout prevote + } + + // 44-46 + if (weight > (((VALIDATORS / 3) * 2) + 1).into()) && first { + debug_assert!(self.broadcast(Data::Precommit(None)).is_none()); + self.step = Step::Precommit; + } + } + + // 47-48 + if self.has_participation(self.round, Step::Precommit) && first { + // TODO: Schedule timeout precommit + } + + Ok(None) + } +} diff --git a/substrate/consensus/src/tendermint/paper.md b/substrate/consensus/src/tendermint/paper.md new file mode 100644 index 00000000..bf8d1031 --- /dev/null +++ b/substrate/consensus/src/tendermint/paper.md @@ -0,0 +1,40 @@ +The [paper](https://arxiv.org/abs/1807.04938) describes the algorithm with +pseudocode on page 6. This pseudocode is written as a series of conditions for +advancement. This is extremely archaic, as its a fraction of the actually +required code. This is due to its hand-waving away of data tracking, lack of +comments (beyond the entire rest of the paper, of course), and lack of +specification regarding faulty nodes. + +While the "hand-waving" is both legitimate and expected, as it's not the paper's +job to describe a full message processing loop nor efficient variable handling, +it does leave behind ambiguities and annoyances, not to mention an overall +structure which cannot be directly translated. This document is meant to be a +description of it enabling translation. + +The described pseudocode segments can be minimally described as follows: + +``` +01-09 Init +10-10 StartRound(0) +11-21 StartRound +22-27 Fresh proposal +28-33 Proposal building off a valid round with prevotes +34-35 2f+1 prevote -> schedule timeout prevote +36-43 First proposal with prevotes -> precommit Some +44-46 2f+1 nil prevote -> precommit nil +47-48 2f+1 precommit -> schedule timeout precommit +49-54 First proposal with precommits -> finalize +55-56 f+1 round > local round, jump +57-60 on timeout propose +61-64 on timeout prevote +65-67 on timeout precommit +``` + +Remaining: + +``` +36-43 First proposal with prevotes -> precommit Some +57-60 on timeout propose +61-64 on timeout prevote +65-67 on timeout precommit +```