diff --git a/common/blockchain/Cargo.toml b/common/blockchain/Cargo.toml new file mode 100644 index 00000000..b3233a9c --- /dev/null +++ b/common/blockchain/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "blockchain" +authors = ["Michal Nazarewicz "] +version = "0.0.0" +edition = "2021" + +[dependencies] +borsh.workspace = true +derive_more.workspace = true + +lib = { workspace = true, features = ["borsh"] } + +[dev-dependencies] +lib = { workspace = true, features = ["borsh", "test_utils"] } +rand.workspace = true +stdx.workspace = true + +[features] +std = [] diff --git a/common/blockchain/src/block.rs b/common/blockchain/src/block.rs new file mode 100644 index 00000000..2b11aa48 --- /dev/null +++ b/common/blockchain/src/block.rs @@ -0,0 +1,243 @@ +use lib::hash::CryptoHash; + +use crate::epoch; +use crate::height::{BlockHeight, HostHeight}; +use crate::validators::PubKey; + +type Result = core::result::Result; + +/// A single block of the emulated blockchain. +/// +/// Emulated block’s height and timestamp are taken directly from the host +/// chain. Emulated blocks don’t have their own timestamps. +/// +/// A block is uniquely identified by its hash which can be obtained via +/// [`Block::calc_hash`]. +/// +/// Each block belongs to an epoch (identifier by `epoch_id`) which describes +/// set of validators which can sign the block. A new epoch is introduced by +/// setting `next_epoch` field; epoch becomes current one starting from the +/// following block. +#[derive( + Clone, Debug, PartialEq, Eq, borsh::BorshSerialize, borsh::BorshDeserialize, +)] +pub struct Block { + /// Version of the structure. At the moment always zero byte. + version: crate::common::VersionZero, + + /// Hash of the previous block. + pub prev_block_hash: CryptoHash, + /// Height of the emulated blockchain’s block. + pub block_height: BlockHeight, + /// Height of the host blockchain’s block in which this block was created. + pub host_height: HostHeight, + /// Timestamp of the host blockchani’s block in which this block was created. + pub host_timestamp: u64, + /// Hash of the root node of the state trie, i.e. the commitment + /// of the state. + pub state_root: CryptoHash, + + /// Hash of the block in which current epoch has been defined. + /// + /// Epoch determines validators set signing each block. If epoch is about + /// to change, the new epoch is defined in `next_epoch` field. Then, the + /// very next block will use current’s block hash as `epoch_id`. + pub epoch_id: CryptoHash, + + /// If present, epoch *the next* block will belong to. + pub next_epoch: Option>, +} + +/// Error while generating new block. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GenerateError { + /// Host height went backwards. + BadHostHeight, + /// Host timestamp went backwards. + BadHostTimestamp, +} + +impl Block { + /// Returns whether the block is a valid genesis block. + pub fn is_genesis(&self) -> bool { + self.prev_block_hash == CryptoHash::DEFAULT && + self.epoch_id == CryptoHash::DEFAULT + } + + /// Calculates hash of the block. + pub fn calc_hash(&self) -> CryptoHash { + let mut builder = CryptoHash::builder(); + borsh::to_writer(&mut builder, self).unwrap(); + builder.build() + } + + /// Sign the block using provided signer function. + pub fn sign( + &self, + // TODO(mina86): Consider using signature::Signer. + signer: impl FnOnce(&[u8]) -> Result, + ) -> Result { + borsh::to_vec(self).and_then(|vec| signer(vec.as_slice())) + } + + #[cfg(test)] + fn verify(&self, pk: &PK, signature: &PK::Signature) -> bool { + crate::validators::Signature::verify(signature, &self.calc_hash(), pk) + } + + /// Constructs next block. + /// + /// Returns a new block with `self` as the previous block. Verifies that + /// `host_height` and `host_timestamp` don’t go backwards but otherwise they + /// can increase by any amount. The new block will have `block_height` + /// incremented by one. + pub fn generate_next( + &self, + host_height: HostHeight, + host_timestamp: u64, + state_root: CryptoHash, + next_epoch: Option>, + ) -> Result { + if host_height <= self.host_height { + return Err(GenerateError::BadHostHeight); + } else if host_timestamp <= self.host_timestamp { + return Err(GenerateError::BadHostTimestamp); + } + + let prev_block_hash = self.calc_hash(); + // If self defines a new epoch than the new block starts a new epoch + // with epoch id equal to self’s block hash. Otherwise, epoch doesn’t + // change and the new block uses the same epoch id as self. + let epoch_id = match self.next_epoch.is_some() { + false => self.epoch_id.clone(), + true => prev_block_hash.clone(), + }; + Ok(Self { + version: crate::common::VersionZero, + prev_block_hash, + block_height: self.block_height.next(), + host_height, + host_timestamp, + state_root, + epoch_id, + next_epoch, + }) + } + + /// Constructs a new genesis block. + /// + /// A genesis block is identified by previous block hash and epoch id both + /// being all-zero hash. + pub fn generate_genesis( + block_height: BlockHeight, + host_height: HostHeight, + host_timestamp: u64, + state_root: CryptoHash, + next_epoch: epoch::Epoch, + ) -> Result { + Ok(Self { + version: crate::common::VersionZero, + prev_block_hash: CryptoHash::DEFAULT, + block_height, + host_height, + host_timestamp, + state_root, + epoch_id: CryptoHash::DEFAULT, + next_epoch: Some(next_epoch), + }) + } +} + +#[test] +fn test_block_generation() { + use crate::validators::{MockPubKey, MockSignature}; + + // Generate a genesis block and test it’s behaviour. + let genesis_hash = "Zq3s+b7x6R8tKV1iQtByAWqlDMXVVD9tSDOlmuLH7wI="; + let genesis_hash = CryptoHash::from_base64(genesis_hash).unwrap(); + + let genesis = Block::generate_genesis( + BlockHeight::from(0), + HostHeight::from(42), + 24, + CryptoHash::test(66), + epoch::Epoch::test(&[(0, 10), (1, 10)]), + ) + .unwrap(); + + assert!(genesis.is_genesis()); + + let mut block = genesis.clone(); + block.prev_block_hash = genesis_hash.clone(); + assert!(!block.is_genesis()); + + let mut block = genesis.clone(); + block.epoch_id = genesis_hash.clone(); + assert!(!block.is_genesis()); + + assert_eq!(genesis_hash, genesis.calc_hash()); + assert_ne!(genesis_hash, block.calc_hash()); + + let pk = MockPubKey(77); + let signature = + genesis.sign(|msg| Ok(MockSignature::new(msg, pk))).unwrap(); + assert_eq!(MockSignature(1722674425, pk), signature); + assert!(genesis.verify(&pk, &signature)); + assert!(!genesis.verify(&MockPubKey(88), &signature)); + assert!(!genesis.verify(&pk, &MockSignature(0, pk))); + + let mut block = genesis.clone(); + block.host_timestamp += 1; + assert_ne!(genesis_hash, block.calc_hash()); + assert!(!block.verify(&pk, &signature)); + + // Try creating invalid next block. + assert_eq!( + Err(GenerateError::BadHostHeight), + genesis.generate_next( + HostHeight::from(42), + 100, + CryptoHash::test(99), + None + ) + ); + assert_eq!( + Err(GenerateError::BadHostTimestamp), + genesis.generate_next( + HostHeight::from(43), + 24, + CryptoHash::test(99), + None + ) + ); + + // Create next block and test its behaviour. + let block = genesis + .generate_next(HostHeight::from(50), 50, CryptoHash::test(99), None) + .unwrap(); + assert!(!block.is_genesis()); + assert_eq!(BlockHeight::from(1), block.block_height); + assert_eq!(genesis_hash, block.prev_block_hash); + assert_eq!(genesis_hash, block.epoch_id); + let hash = "uv7IaNMkac36VYAD/RNtDF14wY/DXxlxzsS2Qi+d4uw="; + let hash = CryptoHash::from_base64(hash).unwrap(); + assert_eq!(hash, block.calc_hash()); + + // Create next block within and introduce a new epoch. + let epoch = Some(epoch::Epoch::test(&[(0, 20), (1, 10)])); + let block = block + .generate_next(HostHeight::from(60), 60, CryptoHash::test(99), epoch) + .unwrap(); + assert_eq!(hash, block.prev_block_hash); + assert_eq!(genesis_hash, block.epoch_id); + let hash = "JWVBe5GotaDzyClzBuArPLjcAQTRElMCxvstyZ0bMtM="; + let hash = CryptoHash::from_base64(hash).unwrap(); + assert_eq!(hash, block.calc_hash()); + + // Create next block which belongs to the new epoch. + let block = block + .generate_next(HostHeight::from(65), 65, CryptoHash::test(99), None) + .unwrap(); + assert_eq!(hash, block.prev_block_hash); + assert_eq!(hash, block.epoch_id); +} diff --git a/common/blockchain/src/candidates.rs b/common/blockchain/src/candidates.rs new file mode 100644 index 00000000..31f2795d --- /dev/null +++ b/common/blockchain/src/candidates.rs @@ -0,0 +1,376 @@ +use alloc::vec::Vec; +use core::num::{NonZeroU128, NonZeroU16}; + +use crate::chain; +use crate::validators::{PubKey, Validator}; + +#[cfg(test)] +mod tests; + +/// Set of candidate validators to consider when creating a new epoch. +/// +/// Whenever epoch changes, candidates with most stake are included in +/// validators set. +#[derive(Clone, PartialEq, Eq)] +pub struct Candidates { + /// Maximum number of validators in a validator set. + max_validators: NonZeroU16, + + /// Set of validators which are interested in participating in the + /// blockchain. + /// + /// The vector is kept sorted with candidates with most stake first. + candidates: Vec>, + + /// Whether the set changed in a way which affects the epoch. + /// + /// If this is true, as soon as possible a new epoch will be started. + changed: bool, + + /// Sum of the top `max_validators` stakes. + head_stake: u128, +} + +/// A candidate to become a validator. +#[derive(Clone, PartialEq, Eq)] +struct Candidate { + /// Public key of the candidate. + pubkey: PK, + + /// Candidate’s stake. + stake: NonZeroU128, +} + +/// Error while updating candidate. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum UpdateCandidateError { + /// Candidate’s stake is below required minimum. + NotEnoughValidatorStake, + + /// After removing a candidate or reducing candidate’s stake, the total + /// stake would fall below required minimum. + NotEnoughTotalStake, + + /// After removing a candidate, the total number of validators would fall + /// below required minimum. + NotEnoughValidators, +} + +impl Candidates { + /// Creates a new candidates set from the given list. + /// + /// If the list is longer than `max_validators` marks the set as `changed`. + /// I.e. on the next epoch change, the validators set will be changed to + /// only the top `max_validators`. + /// + /// Note that the value of `max_validators` is preserved. All methods which + /// take `cfg: &chain::Config` as an argument ignore `cfg.max_validators` + /// value and use value of this `max_validators` argument instead. + pub fn new( + max_validators: NonZeroU16, + validators: &[Validator], + ) -> Self { + Self::from_candidates( + max_validators, + validators.iter().map(Candidate::from).collect::>(), + ) + } + + fn from_candidates( + max_validators: NonZeroU16, + mut candidates: Vec>, + ) -> Self { + candidates.sort_unstable(); + // If validator set in the genesis block is larger than maximum size + // specified in configuration, than we need to reduce the number on next + // epoch change. + let changed = candidates.len() > usize::from(max_validators.get()); + let head_stake = Self::sum_head_stake(max_validators, &candidates); + let this = Self { max_validators, candidates, changed, head_stake }; + this.debug_verify_state(); + this + } + + /// Sums stake of the first `count` candidates. + fn sum_head_stake(count: NonZeroU16, candidates: &[Candidate]) -> u128 { + let count = usize::from(count.get()).min(candidates.len()); + candidates[..count] + .iter() + .fold(0, |sum, c| sum.checked_add(c.stake.get()).unwrap()) + } + + /// Returns top validators together with their total stake if changed since + /// last call. + pub fn maybe_get_head(&mut self) -> Option<(Vec>, u128)> { + if !self.changed { + return None; + } + let mut total: u128 = 0; + let validators = self + .candidates + .iter() + .take(self.max_validators()) + .map(|candidate| { + total = total.checked_add(candidate.stake.get())?; + Some(Validator::from(candidate)) + }) + .collect::>>() + .unwrap(); + self.changed = false; + self.debug_verify_state(); + Some((validators, total)) + } + + /// Adds a new candidates or updates existing candidate’s stake. + pub fn update( + &mut self, + cfg: &chain::Config, + pubkey: PK, + stake: u128, + ) -> Result<(), UpdateCandidateError> { + let stake = NonZeroU128::new(stake) + .filter(|stake| *stake >= cfg.min_validator_stake) + .ok_or(UpdateCandidateError::NotEnoughValidatorStake)?; + let candidate = Candidate { pubkey, stake }; + let old_pos = + self.candidates.iter().position(|el| el.pubkey == candidate.pubkey); + let mut new_pos = + self.candidates.binary_search(&candidate).map_or_else(|p| p, |p| p); + let res = match old_pos { + None => Ok(self.add_impl(new_pos, candidate)), + Some(old_pos) => { + if new_pos > old_pos { + new_pos -= 1; + } + self.update_impl(cfg, old_pos, new_pos, candidate) + } + }; + self.debug_verify_state(); + res + } + + /// Removes an existing candidate. + pub fn remove( + &mut self, + cfg: &chain::Config, + pubkey: &PK, + ) -> Result<(), UpdateCandidateError> { + let pos = self.candidates.iter().position(|el| &el.pubkey == pubkey); + if let Some(pos) = pos { + if self.candidates.len() <= cfg.min_validators.get().into() { + return Err(UpdateCandidateError::NotEnoughValidators); + } + self.update_stake_for_remove(cfg, pos)?; + self.candidates.remove(pos); + self.debug_verify_state(); + } + Ok(()) + } + + fn max_validators(&self) -> usize { usize::from(self.max_validators.get()) } + + /// Adds a new candidate at given position. + /// + /// It’s caller’s responsibility to guarantee that `new_pos` is correct + /// position for the `candidate` to be added and that there’s no candidate + /// with the same public key already on the list. + fn add_impl(&mut self, new_pos: usize, candidate: Candidate) { + let new = candidate.stake.get(); + let max = self.max_validators(); + self.candidates.insert(new_pos, candidate); + if new_pos < max { + let old = self.candidates.get(max).map_or(0, |c| c.stake.get()); + self.add_head_stake(new - old); + } + } + + /// Updates a candidate by changing its position and stake. + fn update_impl( + &mut self, + cfg: &chain::Config, + old_pos: usize, + new_pos: usize, + candidate: Candidate, + ) -> Result<(), UpdateCandidateError> { + let max = self.max_validators(); + if new_pos >= max { + // Candidate’s new position is outside of the first max_validators. + // Verify it the same way we verify removal of a candidate since + // in next epoch they won’t be in the validators set. + self.update_stake_for_remove(cfg, old_pos)?; + } else if old_pos >= max { + // The candidate graduates to the top max_validators. This may + // change head_stake but never by decreasing it. + let new = candidate.stake.get(); + let old = self.candidates.get(max - 1).map_or(0, |c| c.stake.get()); + self.add_head_stake(new - old); + } else { + // The candidate moves within the top max_validators. We need to + // update head_stake. + let old_stake = self.candidates[old_pos].stake.get(); + let new_stake = candidate.stake.get(); + if old_stake < new_stake { + self.add_head_stake(new_stake - old_stake); + } else if old_stake > new_stake { + self.sub_head_stake(cfg, old_stake - new_stake)?; + } else { + return Ok(()); + }; + } + rotate(self.candidates.as_mut_slice(), old_pos, new_pos).stake = + candidate.stake; + Ok(()) + } + + /// Verifies whether removing candidate at given position adheres to + /// configuration and, if it does, updates head stake if necessary. + /// + /// Returns an error, if removing validator at given position would reduce + /// number of candidates or stake of the head candidates below minimums from + /// the configuration. + /// + /// Otherwise, acts as if the candidate at the position got removed and + /// updates `self.head_stake` and `self.changed` if necessary. + fn update_stake_for_remove( + &mut self, + cfg: &chain::Config, + pos: usize, + ) -> Result<(), UpdateCandidateError> { + let max = self.max_validators(); + if pos >= max { + return Ok(()); + } + let old = self.candidates[pos].stake.get(); + let new = self.candidates.get(max).map_or(0, |c| c.stake.get()); + self.sub_head_stake(cfg, old - new) + } + + /// Adds given amount of stake to `head_stake`. + fn add_head_stake(&mut self, stake: u128) { + self.head_stake = self.head_stake.checked_add(stake).unwrap(); + self.changed = true; + } + + /// Subtracts given amount of stake from `head_stake`. + fn sub_head_stake( + &mut self, + cfg: &chain::Config, + stake: u128, + ) -> Result<(), UpdateCandidateError> { + let head_stake = self.head_stake.checked_sub(stake).unwrap(); + if head_stake < cfg.min_total_stake.get() { + return Err(UpdateCandidateError::NotEnoughTotalStake); + } + self.head_stake = head_stake; + self.changed = true; + Ok(()) + } + + /// If debug assertions are enabled, checks whether all invariants are held. + /// + /// Verifies that a) candidates are sorted, b) contain no duplicates and c) + /// `self.head_stake` is sum of stake of first `self.max_validators` + /// candidates. + #[track_caller] + fn debug_verify_state(&self) { + if !cfg!(debug_assertions) { + return; + } + for (idx, wnd) in self.candidates.windows(2).enumerate() { + assert!(wnd[0] < wnd[1], "{idx}"); + } + + let mut pks = self + .candidates + .iter() + .map(|c| c.pubkey.clone()) + .collect::>(); + pks.sort_unstable(); + for wnd in pks.windows(2) { + assert!(wnd[0] != wnd[1]); + } + + let got = Self::sum_head_stake(self.max_validators, &self.candidates); + assert_eq!(self.head_stake, got); + } +} + +/// Rotates subslice such that element at `old_pos` moves to `new_pos`. +/// +/// Depending whether `old_pos` is less than or greater than `new_pos`, performs +/// a left or right rotation of a `min(old_pos, new_pos)..=max(old_pos, +/// new_pos)` subslice. +/// +/// Returns reference to the element at `new_pos`. +fn rotate(slice: &mut [T], old_pos: usize, new_pos: usize) -> &mut T { + use core::cmp::Ordering; + match old_pos.cmp(&new_pos) { + Ordering::Less => slice[old_pos..=new_pos].rotate_left(1), + Ordering::Equal => (), + Ordering::Greater => slice[new_pos..=old_pos].rotate_right(1), + } + &mut slice[new_pos] +} + +impl core::cmp::PartialOrd> for Candidate { + /// Compares two candidates sorting by `(-stake, pubkey)` pair. + /// + /// That is orders candidates by their stake in descending order and (in + /// case of equal stakes) by public key in ascending order. + fn partial_cmp(&self, rhs: &Self) -> Option { + match rhs.stake.cmp(&self.stake) { + core::cmp::Ordering::Equal => self.pubkey.partial_cmp(&rhs.pubkey), + ord => Some(ord), + } + } +} + +impl core::cmp::Ord for Candidate { + /// Compares two candidates sorting by `(-stake, pubkey)` pair. + /// + /// That is orders candidates by their stake in descending order and (in + /// case of equal stakes) by public key in ascending order. + fn cmp(&self, rhs: &Self) -> core::cmp::Ordering { + rhs.stake.cmp(&self.stake).then_with(|| self.pubkey.cmp(&rhs.pubkey)) + } +} + +impl From<&Candidate> for Validator { + fn from(candidate: &Candidate) -> Self { + Self::new(candidate.pubkey.clone(), candidate.stake) + } +} + +impl From<&Validator> for Candidate { + fn from(validator: &Validator) -> Self { + Self { pubkey: validator.pubkey().clone(), stake: validator.stake() } + } +} + +impl core::fmt::Debug for Candidate { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(fmt, "{{{:?} staking {}}}", self.pubkey, self.stake.get()) + } +} + +impl core::fmt::Debug for Candidates { + fn fmt(&self, fmtr: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + if self.candidates.is_empty() { + fmtr.write_str("{[]")?; + } else { + let max = usize::from(self.max_validators.get()); + for (index, candidate) in self.candidates.iter().enumerate() { + let sep = if index == 0 { + "{[" + } else if index == max { + " | " + } else { + ", " + }; + write!(fmtr, "{sep}{candidate:?}")?; + } + } + let changed = ["", " (changed)"][usize::from(self.changed)]; + write!(fmtr, "]; head_stake: {}{changed}}}", self.head_stake) + } +} diff --git a/common/blockchain/src/candidates/tests.rs b/common/blockchain/src/candidates/tests.rs new file mode 100644 index 00000000..7078781c --- /dev/null +++ b/common/blockchain/src/candidates/tests.rs @@ -0,0 +1,432 @@ +use super::*; +use crate::validators::test_utils::MockPubKey; + +fn candidate(pubkey: char, stake: u128) -> Candidate { + Candidate { + pubkey: MockPubKey(pubkey as u32), + stake: NonZeroU128::new(stake).unwrap(), + } +} + +#[test] +fn test_rotate() { + #[track_caller] + fn run(old_pos: usize, new_pos: usize) -> [u8; 8] { + let mut arr = [0, 1, 2, 3, 4, 5, 6, 7]; + *rotate(&mut arr[..], old_pos, new_pos) = 42; + arr + } + + assert_eq!([42, 1, 2, 3, 4, 5, 6, 7], run(0, 0)); + assert_eq!([0, 1, 2, 3, 4, 5, 6, 42], run(7, 7)); + assert_eq!([1, 2, 3, 4, 5, 6, 7, 42], run(0, 7)); + assert_eq!([42, 0, 1, 2, 3, 4, 5, 6], run(7, 0)); + assert_eq!([0, 1, 3, 4, 5, 42, 6, 7], run(2, 5)); + assert_eq!([0, 1, 42, 2, 3, 4, 6, 7], run(5, 2)); +} + +#[test] +fn test_ord() { + use core::cmp::Ordering::*; + + fn test(want: core::cmp::Ordering, rhs: (char, u128), lhs: (char, u128)) { + let rhs = candidate(rhs.0, rhs.1); + let lhs = candidate(lhs.0, lhs.1); + if want == Equal { + assert_eq!(rhs, lhs); + } else { + assert_ne!(rhs, lhs); + } + assert_eq!(want, rhs.cmp(&lhs)); + } + + test(Less, ('C', 20), ('A', 10)); + test(Less, ('C', 20), ('C', 10)); + test(Less, ('C', 20), ('A', 10)); + test(Greater, ('C', 20), ('A', 20)); + test(Equal, ('C', 20), ('C', 20)); +} + +struct Cfg { + min_validators: u16, + min_validator_stake: u128, + min_total_stake: u128, +} + +impl Default for Cfg { + fn default() -> Self { + Self { min_validators: 1, min_validator_stake: 1, min_total_stake: 1 } + } +} + +impl From for chain::Config { + fn from(cfg: Cfg) -> Self { + chain::Config { + max_validators: NonZeroU16::MAX, + min_validators: NonZeroU16::new(cfg.min_validators).unwrap(), + min_validator_stake: NonZeroU128::new(cfg.min_validator_stake) + .unwrap(), + min_total_stake: NonZeroU128::new(cfg.min_total_stake).unwrap(), + min_quorum_stake: NonZeroU128::MIN, + min_block_length: crate::height::HostDelta::from(1), + min_epoch_length: crate::height::HostDelta::from(1), + } + } +} + +fn cfg_with_min_validators(min_validators: u16) -> chain::Config { + Cfg { min_validators, ..Default::default() }.into() +} + +fn cfg_with_min_validator_stake(min_validator_stake: u128) -> chain::Config { + Cfg { min_validator_stake, ..Default::default() }.into() +} + +fn cfg_with_min_total_stake(min_total_stake: u128) -> chain::Config { + Cfg { min_total_stake, ..Default::default() }.into() +} + +#[track_caller] +fn check( + want_candidates: [(char, u128); N], + candidates: &Candidates, +) { + let max = usize::from(candidates.max_validators.get()); + let want_stake = + want_candidates.iter().take(max).map(|(_, stake)| stake).sum::(); + let want_candidates = want_candidates + .into_iter() + .map(|(pubkey, stake)| candidate(pubkey, stake)) + .collect::>(); + assert_eq!( + (want_stake, want_candidates.as_slice()), + (candidates.head_stake, candidates.candidates.as_slice()) + ) +} + +#[test] +fn test_candidates_0() { + use candidate as c; + use UpdateCandidateError::*; + + fn pk(pubkey: char) -> MockPubKey { MockPubKey(pubkey as u32) } + + // Create candidates set + let mut candidates = Candidates::from_candidates( + NonZeroU16::new(3).unwrap(), + [c('A', 1), c('B', 2), c('C', 3), c('D', 4), c('E', 5)].to_vec(), + ); + check([('E', 5), ('D', 4), ('C', 3), ('B', 2), ('A', 1)], &candidates); + + // Check minimum total stake and count are checked + assert_eq!( + Err(NotEnoughTotalStake), + candidates.remove(&cfg_with_min_total_stake(10), &pk('E')), + ); + assert_eq!( + Err(NotEnoughValidators), + candidates.remove(&cfg_with_min_validators(5), &pk('E')), + ); + + // Removal is idempotent + for _ in 0..2 { + candidates.remove(&cfg_with_min_validators(2), &pk('E')).unwrap(); + check([('D', 4), ('C', 3), ('B', 2), ('A', 1)], &candidates); + } + + // Go below max_validators of candidates. + candidates.remove(&cfg_with_min_validators(1), &pk('C')).unwrap(); + candidates.remove(&cfg_with_min_validators(1), &pk('B')).unwrap(); + candidates.remove(&cfg_with_min_validators(1), &pk('A')).unwrap(); + check([('D', 4)], &candidates); + + // Minimum validator stake is checked + assert_eq!( + Err(NotEnoughValidatorStake), + candidates.update(&cfg_with_min_validator_stake(4), pk('C'), 3), + ); + + // Add back to have over max. Minimums are not checked since we’re + // adding candidates and stake. This theoretically may be a situation + // after chain configuration change so we need to support it. + candidates.update(&cfg_with_min_total_stake(20), pk('A'), 3).unwrap(); + candidates.update(&cfg_with_min_total_stake(20), pk('B'), 2).unwrap(); + candidates.update(&cfg_with_min_total_stake(20), pk('C'), 3).unwrap(); + check([('D', 4), ('A', 3), ('C', 3), ('B', 2)], &candidates); + + // Increase stake. Again, minimums are not checked. + candidates.update(&cfg_with_min_total_stake(20), pk('C'), 4).unwrap(); + check([('C', 4), ('D', 4), ('A', 3), ('B', 2)], &candidates); + + // Reduce stake. Now, minimums are checked. + assert_eq!( + Err(NotEnoughValidatorStake), + candidates.update(&cfg_with_min_validator_stake(3), pk('C'), 2), + ); + assert_eq!( + Err(NotEnoughTotalStake), + candidates.update(&cfg_with_min_total_stake(10), pk('C'), 2), + ); + check([('C', 4), ('D', 4), ('A', 3), ('B', 2)], &candidates); + + candidates.update(&cfg_with_min_total_stake(10), pk('B'), 3).unwrap(); + check([('C', 4), ('D', 4), ('A', 3), ('B', 3)], &candidates); + // `C` is moved out of validators but incoming `B` candidate has enough + // stake to meet min total stake limit. + candidates.update(&cfg_with_min_total_stake(10), pk('C'), 2).unwrap(); + check([('D', 4), ('A', 3), ('B', 3), ('C', 2)], &candidates); +} + +#[test] +fn test_candidiates_1() { + use candidate as c; + + let mut candidates = Candidates::from_candidates( + NonZeroU16::new(4).unwrap(), + [c('F', 168), c('D', 95), c('E', 81), c('C', 68), c('I', 63)].to_vec(), + ); + + let cfg = TestCtx::make_config(); + candidates.update(&cfg, MockPubKey('I' as u32), 254).unwrap(); + check( + [('I', 254), ('F', 168), ('D', 95), ('E', 81), ('C', 68)], + &candidates, + ); +} + +struct TestCtx { + config: chain::Config, + candidates: Candidates, + by_key: alloc::collections::BTreeMap, +} + +impl TestCtx { + /// Generates a new candidates set with random set of candidiates. + fn new(rng: &mut impl rand::Rng) -> Self { + let config = Self::make_config(); + + let candidates = (0..150) + .map(|idx| { + let pubkey = MockPubKey(idx); + let stake = NonZeroU128::new(rng.gen_range(100..255)).unwrap(); + Candidate { pubkey, stake } + }) + .collect::>(); + + let by_key = candidates + .iter() + .map(|c| (c.pubkey, c.stake.get())) + .collect::>(); + + let candidates = + Candidates::from_candidates(config.max_validators, candidates); + + Self { config, candidates, by_key } + } + + /// Generates a test config. + fn make_config() -> chain::Config { + let mut config = chain::Config::from(Cfg { + min_validators: 64, + min_validator_stake: 128, + min_total_stake: 16000, + }); + config.max_validators = NonZeroU16::new(128).unwrap(); + config + } + + /// Checks that total stake and number of validators respect the limits from + /// configuration file. + fn check(&self) { + assert!( + self.candidates.candidates.len() >= + self.config.min_validators.get().into(), + "Violated min validators constraint: {} < {}", + self.candidates.candidates.len(), + self.config.min_validators.get(), + ); + assert!( + self.candidates.head_stake >= self.config.min_total_stake.get(), + "Violated min total stake constraint: {} < {}", + self.candidates.head_stake, + self.config.min_total_stake.get(), + ); + } + + /// Attempts to removes a candidate from candidates set and verifies result + /// of the operation. + fn test_remove(&mut self, pubkey: MockPubKey) { + use super::UpdateCandidateError::*; + + let count = self.candidates.candidates.len(); + let head_stake = self.candidates.head_stake; + + let res = self.candidates.remove(&self.config, &pubkey); + self.check(); + + if let Err(err) = res { + let old_stake = self.by_key.get(&pubkey).unwrap().clone(); + assert_eq!(count, self.candidates.candidates.len()); + assert_eq!(head_stake, self.candidates.head_stake); + + match err { + NotEnoughValidatorStake => unreachable!(), + NotEnoughTotalStake => { + // What would be promoted candidate’s stake after + // removal. + let new_stake = self + .candidates + .candidates + .get(usize::from(self.config.max_validators.get())) + .map_or(0, |c: &Candidate<_>| c.stake.get()); + assert!( + head_stake - old_stake + new_stake < + self.config.min_total_stake.get() + ); + } + NotEnoughValidators => { + assert!( + self.candidates.candidates.len() <= + self.config.min_validators.get().into() + ); + } + } + } else if self.by_key.remove(&pubkey).is_some() { + assert_eq!(count - 1, self.candidates.candidates.len()); + assert!(head_stake >= self.candidates.head_stake); + } else { + assert_eq!(count, self.candidates.candidates.len()); + assert_eq!(head_stake, self.candidates.head_stake); + } + } + + /// Attempts to update candidate’s stake and verifies result of the + /// operation. + fn test_update(&mut self, pubkey: MockPubKey, new_stake: u128) { + use alloc::collections::btree_map::Entry; + + let count = self.candidates.candidates.len(); + let head_stake = self.candidates.head_stake; + + let res = + self.candidates.update(&self.config, pubkey.clone(), new_stake); + self.check(); + + if let Err(err) = res { + assert_eq!(count, self.candidates.candidates.len()); + assert_eq!(head_stake, self.candidates.head_stake); + self.verify_update_error(err, pubkey, new_stake); + } else { + let entry = self.by_key.entry(pubkey.clone()); + let new = matches!(&entry, Entry::Vacant(_)); + assert_eq!( + count + usize::from(new), + self.candidates.candidates.len() + ); + if new { + assert!(head_stake <= self.candidates.head_stake); + } + *entry.or_default() = new_stake; + } + } + + /// Verifies failed attempt at updating candidate’s stake. + fn verify_update_error( + &self, + err: UpdateCandidateError, + pubkey: MockPubKey, + new_stake: u128, + ) { + use super::UpdateCandidateError::*; + + match err { + NotEnoughValidatorStake => { + assert!(new_stake < self.config.min_validator_stake.get()); + return; + } + NotEnoughValidators => unreachable!(), + NotEnoughTotalStake => (), + } + + let old_stake = self.by_key.get(&pubkey).unwrap(); + + // There are two possibilities. We are in head and would stay there or + // we would be moved outside of it (replaced by whoever is just past the + // head). We can determine those cases by comparing updated state to + // the state just outside the head. + let last = self + .candidates + .candidates + .get(usize::from(self.config.max_validators.get())); + let kicked_out = last.clone().map_or(false, |candidiate| { + candidiate < + &Candidate { + pubkey, + stake: NonZeroU128::new(new_stake).unwrap(), + } + }); + + let new_stake = + if kicked_out { last.unwrap().stake.get() } else { new_stake }; + + assert!( + self.candidates.head_stake - old_stake + new_stake < + self.config.min_total_stake.get() + ); + } + + /// Performs a random test. `data` must be a three-element slice. The + /// random test is determined from values in the slice. + fn test(&mut self, data: &[u8]) { + let old_state = self.candidates.clone(); + let pubkey = MockPubKey((data[0]).into()); + let op = if data[2] % 2 == 0 { + Ok(pubkey) + } else { + Err((pubkey, u128::from(data[1]))) + }; + + let this = self as *mut TestCtx; + let res = std::panic::catch_unwind(|| { + // SAFETY: It’s test code. I don’t care. ;) It’s probably safe but + // self.candidates may be in inconsistent state. This is fine since + // we’re panicking anyway. + let this = unsafe { &mut *this }; + match &op { + Ok(pubkey) => this.test_remove(pubkey.clone()), + Err((pubkey, stake)) => { + this.test_update(pubkey.clone(), *stake) + } + } + }); + + if let Err(err) = res { + std::eprintln!("{:?}", old_state); + match op { + Ok(pubkey) => std::eprintln!(" Remove {pubkey:?}"), + Err((pubkey, stake)) => { + std::eprintln!(" Update {pubkey:?} staking {stake}") + } + } + std::eprintln!("{:?}", self.candidates); + std::panic::resume_unwind(err); + } + } +} + +#[test] +fn stress_test() { + use rand::Rng; + + let mut rng = rand::thread_rng(); + let mut ctx = TestCtx::new(&mut rng); + let mut n = lib::test_utils::get_iteration_count(1); + let mut buf = [0u8; 3 * 1024]; + while n > 0 { + rng.fill(&mut buf[..]); + for data in buf.chunks_exact(3).take(n) { + ctx.test(data); + } + n = n.saturating_sub(1024); + } +} diff --git a/common/blockchain/src/chain.rs b/common/blockchain/src/chain.rs new file mode 100644 index 00000000..a25e263a --- /dev/null +++ b/common/blockchain/src/chain.rs @@ -0,0 +1,74 @@ +use core::num::{NonZeroU128, NonZeroU16}; + +/// Chain policies configuration. +/// +/// Those are not encoded within a blockchain and only matter when generating +/// a new block. +// TODO(mina86): Do those configuration options make sense? +pub struct Config { + /// Minimum number of validators allowed in an epoch. + /// + /// The purpose of the minimum is to make sure that the blockchain isn’t + /// controlled by a small group of validators. + pub min_validators: NonZeroU16, + + /// Maximum number of validators allowed in an epoch. + /// + /// The purpose of the maximum is to bound size of the validators set. + /// Large sets may impact performance of the blockchain as epoch definition + /// becomes larger and iterating through all validators becomes slower. + pub max_validators: NonZeroU16, + + /// Minimum stake allowed for a single validator. + /// + /// The purpose of the minimum is to prevent large validators from taking + /// validator seats by splitting their stake into many small stakes as well + /// as limit for only entities with small stake from unnecessarily enlarging + /// the candidates set. + pub min_validator_stake: NonZeroU128, + + /// Minimum total stake allowed for an epoch. + /// + /// The purpose of the minimum is to make sure that there’s always + /// a significant stake guaranteeing each block. Since quorum is defined at + /// over half stake, this also defines a lower bound on quorum stake. + /// + /// Note that `min_validators * min_validator_stake` imposes a lower bound + /// on the minimum total stake. This field allows to raise the total stake + /// minimum above value coming from that calculation. If this is not + /// necessary, this may be set to `1`. + pub min_total_stake: NonZeroU128, + + /// Minimum quorum for an epoch. + /// + /// The purpose of the minimum is to make sure that there’s always + /// a significant stake guaranteeing each block. + /// + /// Note that in contrast to `min_total_stake` and other minimums, this + /// value doesn’t limit what kind of stake validators can have. Instead, it + /// affects `quorum_stake` value for an epoch by making it at least this + /// value. + /// + /// Note that `min_total_stake` imposes additional requirement for minimum + /// quorum stake, i.e. it must be greater than `min_total_stake / 2`. With + /// `min_quorum_stake` it’s possible to configure dynamic quorum ratio: if + /// there’s not enough total stake, the ratio will be increased making it + /// necessary for more validators to sign the blocks. If that feature is + /// not necessary, this may be set to `1`. + pub min_quorum_stake: NonZeroU128, + + /// Minimum number of host blocks before new emulated block can be created. + /// + /// The purpose of the minimum is to limit speed in which emulated blocks + /// are generated. Typically generating them as fast as host block’s isn’t + /// necessary and may even degrade performance when many blocks with small + /// changes are introduced rather bundling them together. + pub min_block_length: crate::height::HostDelta, + + /// Minimum length of an epoch. + /// + /// The purpose of the minimum is to make it possible for light clients to + /// catch up verification by only having to verify blocks at end of each + /// epoch. + pub min_epoch_length: crate::height::HostDelta, +} diff --git a/common/blockchain/src/common.rs b/common/blockchain/src/common.rs new file mode 100644 index 00000000..b98ae77a --- /dev/null +++ b/common/blockchain/src/common.rs @@ -0,0 +1,77 @@ +use borsh::maybestd::io; + +/// A discriminant to include at the beginning of data structures which need to +/// be versioned for forwards compatibility. +/// +/// It’s serialised as a single byte zero and when deserialising it fails if the +/// single read byte isn’t zero. Since at the moment all structures in the code +/// base are at version zero, no other versions are supported. +/// +/// In the future, the zero byte can be used to distinguish versions of data +/// structures. One idea is to provide `VersionUpTo` type which will +/// serialise as specified version and verify version ≤ `MAX` when +/// deserialising: +/// +/// ```ignore +/// #[derive(borsh::BorshSerialize, borsh::BorshDeserialize)] +/// struct Foo { +/// version: VersionZero, +/// pub count: usize, +/// } +/// +/// struct Bar { +/// version: VersionUpTo<1>, +/// pub drinks: Vec, +/// pub dishes: Vec, +/// } +/// ``` +/// +/// With that scheme, borsh serialisation and deserialisation will need to be +/// implemented manually and will have to take into account the version. +/// Another approach is to use an enum with variants for each version: +/// +/// ```ignore +/// #[derive(borsh::BorshSerialize, borsh::BorshDeserialize)] +/// enum Bar { +/// V1(v1::Bar), +/// V2(v2::Bar), +/// } +/// +/// mod v1 { struct Bar { pub drinks: Vec } } +/// mod v2 { struct Bar { pub drinks: Vec, pub dishes: Vec } } +/// ``` +/// +/// Whatever the case, having `version: VersionZero` field as the first one in +/// a structure allows it to be versioned in the future. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct VersionZero; + +impl borsh::BorshSerialize for VersionZero { + fn serialize(&self, writer: &mut W) -> io::Result<()> { + writer.write_all(&[0]) + } +} + +impl borsh::BorshDeserialize for VersionZero { + fn deserialize_reader(reader: &mut R) -> io::Result { + u8::deserialize_reader(reader).and_then(|byte| { + if byte == 0 { + Ok(Self) + } else { + let msg = alloc::format!("Invalid version: {byte}"); + Err(io::Error::new(io::ErrorKind::InvalidData, msg)) + } + }) + } +} + +#[test] +fn test_version_zero() { + use borsh::BorshDeserialize; + + assert_eq!(&[0], borsh::to_vec(&VersionZero).unwrap().as_slice()); + VersionZero::try_from_slice(&[0]).unwrap(); + VersionZero::try_from_slice(&[1]).unwrap_err(); + VersionZero::try_from_slice(&[]).unwrap_err(); + VersionZero::try_from_slice(&[0, 0]).unwrap_err(); +} diff --git a/common/blockchain/src/epoch.rs b/common/blockchain/src/epoch.rs new file mode 100644 index 00000000..6b6355ab --- /dev/null +++ b/common/blockchain/src/epoch.rs @@ -0,0 +1,120 @@ +use alloc::vec::Vec; +use core::num::NonZeroU128; + +use crate::validators::{PubKey, Validator}; + +/// An epoch describing configuration applying to all blocks within an epoch. +/// +/// An epoch is identified by hash of the block it was introduced in. As such, +/// epoch’s identifier is unknown until block which defines it in +/// [`crate::block::Block::next_blok`] field is created. +#[derive( + Clone, Debug, PartialEq, Eq, borsh::BorshSerialize, borsh::BorshDeserialize, +)] +pub struct Epoch { + /// Version of the structure. Used to support forward-compatibility. At + /// the moment this is always zero. + version: crate::common::VersionZero, + + /// Validators set. + validators: Vec>, + + /// Minimum stake to consider block signed. + quorum_stake: NonZeroU128, +} + +impl Epoch { + /// Creates a new epoch. + /// + /// Returns `None` if the epoch is invalid, i.e. if quorum stake is greater + /// than total stake of all validators. An invalid epoch leads to + /// a blockchain which cannot generate new blocks since signing them is no + /// longer possible. + pub fn new( + validators: Vec>, + quorum_stake: NonZeroU128, + ) -> Option { + let version = crate::common::VersionZero; + let this = Self { version, validators, quorum_stake }; + Some(this).filter(Self::is_valid) + } + + /// Creates a new epoch without checking whether it’s valid. + /// + /// It’s caller’s responsibility to guarantee that total stake of all + /// validators is no more than quorum stake. + /// + /// In debug builds panics if the result is an invalid epoch. + pub(crate) fn new_unchecked( + validators: Vec>, + quorum_stake: NonZeroU128, + ) -> Self { + let version = crate::common::VersionZero; + let this = Self { version, validators, quorum_stake }; + debug_assert!(this.is_valid()); + this + } + + /// Checks whether the epoch is valid. + fn is_valid(&self) -> bool { + let mut left = self.quorum_stake.get(); + for validator in self.validators.iter() { + left = left.saturating_sub(validator.stake().get()); + if left == 0 { + return true; + } + } + false + } + + /// Returns list of all validators in the epoch. + pub fn validators(&self) -> &[Validator] { self.validators.as_slice() } + + /// Returns stake needed to reach quorum. + pub fn quorum_stake(&self) -> NonZeroU128 { self.quorum_stake } + + /// Finds a validator by their public key. + pub fn validator(&self, pk: &PK) -> Option<&Validator> { + self.validators.iter().find(|validator| validator.pubkey() == pk) + } +} + +#[cfg(test)] +impl Epoch { + /// Creates an epoch calculating quorum as >50% of total stake. + /// + /// Panics if `validators` is empty or any of the stake is zero. + pub fn test(validators: &[(u32, u128)]) -> Self { + let mut total: u128 = 0; + let validators = validators + .iter() + .copied() + .map(|(pk, stake)| { + total += stake; + Validator::new(pk.into(), NonZeroU128::new(stake).unwrap()) + }) + .collect(); + Self::new(validators, NonZeroU128::new(total / 2 + 1).unwrap()).unwrap() + } +} + +#[test] +fn test_creation() { + use crate::validators::MockPubKey; + + let validators = [ + Validator::new(MockPubKey(0), NonZeroU128::new(5).unwrap()), + Validator::new(MockPubKey(1), NonZeroU128::new(5).unwrap()), + ]; + + assert_eq!(None, Epoch::::new(Vec::new(), NonZeroU128::MIN)); + assert_eq!( + None, + Epoch::new(validators.to_vec(), NonZeroU128::new(11).unwrap()) + ); + + let epoch = + Epoch::new(validators.to_vec(), NonZeroU128::new(10).unwrap()).unwrap(); + assert_eq!(Some(&validators[0]), epoch.validator(&MockPubKey(0))); + assert_eq!(None, epoch.validator(&MockPubKey(2))); +} diff --git a/common/blockchain/src/height.rs b/common/blockchain/src/height.rs new file mode 100644 index 00000000..20f5f1dc --- /dev/null +++ b/common/blockchain/src/height.rs @@ -0,0 +1,126 @@ +/// Block height. +/// +/// The generic argument allows the value to be tagged to distinguish it from +/// host blockchain height and emulated blockchain height. +#[derive( + Clone, + Copy, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + borsh::BorshSerialize, + borsh::BorshDeserialize, +)] +pub struct Height(u64, core::marker::PhantomData<*const T>); + +/// Delta between two host heights. +/// +/// Always expressed as positive value. +/// +/// The generic argument allows the value to be tagged to distinguish it from +/// host blockchain height and emulated blockchain height. +#[derive( + Clone, + Copy, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + borsh::BorshSerialize, + borsh::BorshDeserialize, +)] +pub struct Delta(u64, core::marker::PhantomData<*const T>); + +/// Tag for use with [`Height`] and [`Delta`] to indicate it’s host blockchain +/// height. +#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)] +pub struct Host; + +/// Tag for use with [`Height`] and [`Delta`] to indicate it’s emulated +/// blockchain height. +#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)] +pub struct Block; + +pub type HostHeight = Height; +pub type HostDelta = Delta; +pub type BlockHeight = Height; +pub type BlockDelta = Delta; + +impl Height { + /// Returns the next height, i.e. `self + 1`. + pub fn next(self) -> Self { Self(self.0.checked_add(1).unwrap(), self.1) } + + /// Checks whether delta between two heights is at least `min`. + /// + /// In essence, returns `self - past_height >= min`. + pub fn check_delta_from(self, past_height: Self, min: Delta) -> bool { + self.0.checked_sub(past_height.0).map_or(false, |age| age >= min.0) + } +} + +impl From for Height { + fn from(value: u64) -> Self { Self(value, Default::default()) } +} + +impl From for Delta { + fn from(value: u64) -> Self { Self(value, Default::default()) } +} + +impl From> for u64 { + fn from(value: Height) -> u64 { value.0 } +} + +impl From> for u64 { + fn from(value: Delta) -> u64 { value.0 } +} + +impl core::fmt::Display for Height { + fn fmt(&self, fmtr: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + self.0.fmt(fmtr) + } +} + +impl core::fmt::Debug for Height { + fn fmt(&self, fmtr: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + self.0.fmt(fmtr) + } +} + +impl core::fmt::Display for Delta { + fn fmt(&self, fmtr: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + self.0.fmt(fmtr) + } +} + +impl core::fmt::Debug for Delta { + fn fmt(&self, fmtr: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + self.0.fmt(fmtr) + } +} + +#[test] +fn test_sanity() { + assert!(HostHeight::from(42) == HostHeight::from(42)); + assert!(HostHeight::from(42) <= HostHeight::from(42)); + assert!(HostHeight::from(42) != HostHeight::from(24)); + assert!(HostHeight::from(42) > HostHeight::from(24)); + + assert!(HostDelta::from(42) == HostDelta::from(42)); + assert!(HostDelta::from(42) <= HostDelta::from(42)); + assert!(HostDelta::from(42) != HostDelta::from(24)); + assert!(HostDelta::from(42) > HostDelta::from(24)); + + assert_eq!(HostHeight::from(43), HostHeight::from(42).next()); + + let old = HostHeight::from(24); + let new = HostHeight::from(42); + assert!(new.check_delta_from(old, HostDelta::from(17))); + assert!(new.check_delta_from(old, HostDelta::from(18))); + assert!(!new.check_delta_from(old, HostDelta::from(19))); + assert!(!old.check_delta_from(new, HostDelta::from(0))); +} diff --git a/common/blockchain/src/lib.rs b/common/blockchain/src/lib.rs new file mode 100644 index 00000000..6dd3a1d3 --- /dev/null +++ b/common/blockchain/src/lib.rs @@ -0,0 +1,14 @@ +#![allow(clippy::unit_arg, clippy::comparison_chain)] +#![no_std] +extern crate alloc; +#[cfg(any(feature = "std", test))] +extern crate std; + +pub mod block; +mod candidates; +pub mod chain; +mod common; +pub mod epoch; +pub mod height; +pub mod manager; +pub mod validators; diff --git a/common/blockchain/src/manager.rs b/common/blockchain/src/manager.rs new file mode 100644 index 00000000..a0139d7c --- /dev/null +++ b/common/blockchain/src/manager.rs @@ -0,0 +1,249 @@ +#[cfg(not(feature = "std"))] +use alloc::collections::BTreeSet as Set; +use core::num::NonZeroU128; +#[cfg(feature = "std")] +use std::collections::HashSet as Set; + +use lib::hash::CryptoHash; + +use crate::candidates::Candidates; +pub use crate::candidates::UpdateCandidateError; +use crate::height::HostHeight; +use crate::validators::{PubKey, Signature}; +use crate::{block, chain, epoch}; + +pub struct ChainManager { + /// Configuration specifying limits for block generation. + config: chain::Config, + + /// Current latest block which has been signed by quorum of validators. + block: block::Block, + + /// Epoch of the next block. + /// + /// If `block` defines new epoch, this is copy of `block.next_epoch` + /// otherwise this is epoch of the current block. In other words, this is + /// epoch which specifies validators set for `pending_block`. + next_epoch: epoch::Epoch, + + /// Next block which is waiting for quorum of validators to sign. + pending_block: Option>, + + /// Height at which current epoch was defined. + epoch_height: HostHeight, + + /// Current state root. + state_root: CryptoHash, + + /// Set of validator candidates to consider for the next epoch. + candidates: Candidates, +} + +/// Pending block waiting for signatures. +/// +/// Once quorum of validators sign the block it’s promoted to the current block. +struct PendingBlock { + /// The block that waits for signatures. + next_block: block::Block, + /// Hash of the block. + /// + /// This is what validators are signing. It equals `next_block.calc_hash()` + /// and we’re keeping it as a field to avoid having to hash the block each + /// time. + hash: CryptoHash, + /// Validators who so far submitted valid signatures for the block. + signers: Set, + /// Sum of stake of validators who have signed the block. + signing_stake: u128, +} + +/// Provided genesis block is invalid. +#[derive(Clone, PartialEq, Eq)] +pub struct BadGenesis; + +/// Error while generating a new block. +#[derive(derive_more::From)] +pub enum GenerateError { + /// Last block hasn’t been signed by enough validators yet. + HasPendingBlock, + /// Block isn’t old enough (see [`chain::config::min_block_length`] field). + BlockTooYoung, + Inner(block::GenerateError), +} + +/// Error while accepting a signature from a validator. +pub enum AddSignatureError { + /// There’s no pending block. + NoPendingBlock, + /// The signature is invalid. + BadSignature, + /// The validator is not known. + BadValidator, +} + +impl ChainManager { + pub fn new( + config: chain::Config, + genesis: block::Block, + ) -> Result { + if !genesis.is_genesis() { + return Err(BadGenesis); + } + let next_epoch = genesis.next_epoch.clone().ok_or(BadGenesis)?; + let candidates = + Candidates::new(config.max_validators, next_epoch.validators()); + let state_root = genesis.state_root.clone(); + let epoch_height = genesis.host_height; + Ok(Self { + config, + block: genesis, + next_epoch, + pending_block: None, + epoch_height, + state_root, + candidates, + }) + } + + /// Sets value of state root to use in the next block. + pub fn update_state_root(&mut self, state_root: CryptoHash) { + self.state_root = state_root; + } + + /// Generates a new block and sets it as pending. + /// + /// Returns an error if there’s already a pending block. Previous pending + /// block must first be signed by quorum of validators before next block is + /// generated. + pub fn generate_next( + &mut self, + host_height: HostHeight, + host_timestamp: u64, + ) -> Result<(), GenerateError> { + if self.pending_block.is_some() { + return Err(GenerateError::HasPendingBlock); + } + if !host_height.check_delta_from( + self.block.host_height, + self.config.min_block_length, + ) { + return Err(GenerateError::BlockTooYoung); + } + + let next_epoch = self.maybe_generate_next_epoch(host_height); + let next_block = self.block.generate_next( + host_height, + host_timestamp, + self.state_root.clone(), + next_epoch, + )?; + self.pending_block = Some(PendingBlock { + hash: next_block.calc_hash(), + next_block, + signers: Set::new(), + signing_stake: 0, + }); + Ok(()) + } + + /// Generates a new epoch with the top validators from the candidates set if + /// necessary. + /// + /// Returns `None` if the current epoch is too short to change to new epoch + /// or the validators set hasn’t changed. Otherwise constructs and returns + /// a new epoch by picking top validators from `self.candidates` as the + /// validators set in the new epoch. + /// + /// Panics if there are no candidates, i.e. will always return a valid + /// epoch. However, it doesn’t check minimum number of validators (other + /// than non-zero) or minimum quorum stake (again, other than non-zero). + /// Those conditions are assumed to hold by construction of + /// `self.candidates`. + fn maybe_generate_next_epoch( + &mut self, + host_height: HostHeight, + ) -> Option> { + if !host_height + .check_delta_from(self.epoch_height, self.config.min_epoch_length) + { + return None; + } + let (validators, total) = self.candidates.maybe_get_head()?; + // 1. We validate that genesis has a valid epoch (at least 1 stake). + // 2. We never allow fewer than config.min_validators candidates. + // 3. We never allow candidates with zero stake. + // Therefore, total should always be positive. + let total = NonZeroU128::new(total).unwrap(); + // SAFETY: anything_unsigned + 1 > 0 + let quorum = unsafe { NonZeroU128::new_unchecked(total.get() / 2 + 1) } + .clamp(self.config.min_quorum_stake, total); + Some(epoch::Epoch::new_unchecked(validators, quorum)) + } + + /// Adds a signature to pending block. + /// + /// Returns `true` if quorum has been reached and the pending block has + /// graduated to the current block. + pub fn add_signature( + &mut self, + pubkey: PK, + signature: &PK::Signature, + ) -> Result { + let pending = self + .pending_block + .as_mut() + .ok_or(AddSignatureError::NoPendingBlock)?; + if pending.signers.contains(&pubkey) { + return Ok(false); + } + if !signature.verify(&pending.hash, &pubkey) { + return Err(AddSignatureError::BadSignature); + } + + pending.signing_stake += self + .next_epoch + .validator(&pubkey) + .ok_or(AddSignatureError::BadValidator)? + .stake() + .get(); + assert!(pending.signers.insert(pubkey)); + + if pending.signing_stake < self.next_epoch.quorum_stake().get() { + return Ok(false); + } + + self.block = self.pending_block.take().unwrap().next_block; + if let Some(ref epoch) = self.block.next_epoch { + self.next_epoch = epoch.clone(); + self.epoch_height = self.block.host_height; + } + Ok(true) + } + + /// Adds a new validator candidate or updates existing candidate’s stake. + /// + /// Reducing candidates stake may fail if that would result in quorum or + /// total stake among the top `self.config.max_validators` to drop below + /// limits configured in `self.config`. + pub fn update_candidate( + &mut self, + pubkey: PK, + stake: u128, + ) -> Result<(), UpdateCandidateError> { + self.candidates.update(&self.config, pubkey, stake) + } + + /// Removes an existing validator candidate. + /// + /// Note that removing a candidate may fail if the result candidate set + /// would no longer satisfy minimums in the chain configuration. See also + /// [`Self::update_candidate`]. + /// + /// Does nothing if the candidate is not found. + pub fn remove_candidate( + &mut self, + pubkey: &PK, + ) -> Result<(), UpdateCandidateError> { + self.candidates.remove(&self.config, pubkey) + } +} diff --git a/common/blockchain/src/validators.rs b/common/blockchain/src/validators.rs new file mode 100644 index 00000000..845cabc3 --- /dev/null +++ b/common/blockchain/src/validators.rs @@ -0,0 +1,140 @@ +use core::num::NonZeroU128; + +/// A cryptographic public key used to identify validators and verify block +/// signatures. +pub trait PubKey: + Clone + + Eq + + Ord + + core::hash::Hash + + borsh::BorshSerialize + + borsh::BorshDeserialize +{ + /// Signature corresponding to this public key type. + type Signature: Signature; +} + +/// A cryptographic signature. +pub trait Signature: + Clone + borsh::BorshSerialize + borsh::BorshDeserialize +{ + /// Public key type which can verify the signature. + type PubKey: PubKey; + + /// Verifies that the signature of a given hash is correct. + fn verify( + &self, + message: &lib::hash::CryptoHash, + pk: &Self::PubKey, + ) -> bool; +} + +/// A validator +#[derive( + Clone, Debug, PartialEq, Eq, borsh::BorshSerialize, borsh::BorshDeserialize, +)] +pub struct Validator { + /// Version of the structure. Used to support forward-compatibility. At + /// the moment this is always zero. + version: crate::common::VersionZero, + + /// Public key of the validator. + pubkey: PK, + + /// Validator’s stake. + stake: NonZeroU128, +} + +impl Validator { + pub fn new(pubkey: PK, stake: NonZeroU128) -> Self { + Self { version: crate::common::VersionZero, pubkey, stake } + } + + pub fn pubkey(&self) -> &PK { &self.pubkey } + + pub fn stake(&self) -> NonZeroU128 { self.stake } +} + +#[cfg(test)] +pub(crate) mod test_utils { + + use super::*; + + /// A mock implementation of a PubKey. Offers no security; intended for + /// tests only. + #[derive( + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + borsh::BorshSerialize, + borsh::BorshDeserialize, + derive_more::From, + )] + pub struct MockPubKey(pub u32); + + /// A mock implementation of a Signature. Offers no security; intended for + /// tests only. + #[derive( + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + borsh::BorshSerialize, + borsh::BorshDeserialize, + )] + pub struct MockSignature(pub u32, pub MockPubKey); + + impl core::fmt::Debug for MockPubKey { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(fmt, "⚷{}", self.0) + } + } + + impl core::fmt::Debug for MockSignature { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(fmt, "Sig({} by {:?})", self.0, self.1) + } + } + + impl super::PubKey for MockPubKey { + type Signature = MockSignature; + } + + impl MockSignature { + pub fn new(message: &[u8], pk: MockPubKey) -> Self { + Self(Self::hash_message(message), pk) + } + + fn hash_message(message: &[u8]) -> u32 { + Self::cut_hash(&lib::hash::CryptoHash::digest(message)) + } + + fn cut_hash(hash: &lib::hash::CryptoHash) -> u32 { + let hash = hash.into(); + let (head, _) = stdx::split_array_ref::<4, 28, 32>(&hash); + u32::from_be_bytes(*head) + } + } + + impl Signature for MockSignature { + type PubKey = MockPubKey; + + fn verify( + &self, + message: &lib::hash::CryptoHash, + pk: &Self::PubKey, + ) -> bool { + self.0 == Self::cut_hash(message) && &self.1 == pk + } + } +} + +#[cfg(test)] +pub(crate) use test_utils::{MockPubKey, MockSignature}; diff --git a/common/lib/Cargo.toml b/common/lib/Cargo.toml index 46a0204c..6469f421 100644 --- a/common/lib/Cargo.toml +++ b/common/lib/Cargo.toml @@ -10,6 +10,8 @@ borsh = { workspace = true, optional = true } derive_more.workspace = true sha2.workspace = true +stdx.workspace = true + [dev-dependencies] rand.workspace = true diff --git a/common/lib/src/hash.rs b/common/lib/src/hash.rs index 26eda41a..8e375f4d 100644 --- a/common/lib/src/hash.rs +++ b/common/lib/src/hash.rs @@ -1,5 +1,7 @@ use base64::engine::general_purpose::STANDARD as BASE64_ENGINE; use base64::Engine; +#[cfg(feature = "borsh")] +use borsh::maybestd::io; use sha2::Digest; /// A cryptographic hash. @@ -54,6 +56,19 @@ impl CryptoHash { builder.build() } + /// Decodes a base64 string representation of the hash. + pub fn from_base64(base64: &str) -> Option { + // base64 API is kind of garbage. In certain situations the output + // buffer must be larger than the size of the decoded data or else + // decoding will fail. + let mut buf = [0; 34]; + match BASE64_ENGINE.decode_slice(base64.as_bytes(), &mut buf[..]) { + Ok(CryptoHash::LENGTH) => { + Some(Self(*stdx::split_array_ref::<32, 2, 34>(&buf).0)) + } + _ => None, + } + } /// Creates a new hash with given number encoded in its first bytes. /// @@ -176,6 +191,20 @@ impl Builder { pub fn build(self) -> CryptoHash { CryptoHash(self.0.finalize().into()) } } +#[cfg(feature = "borsh")] +impl io::Write for Builder { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.update(buf); + Ok(buf.len()) + } + + fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { + Ok(self.update(buf)) + } + + fn flush(&mut self) -> io::Result<()> { Ok(()) } +} + #[test] fn test_new_hash() { assert_eq!(CryptoHash::from([0; 32]), CryptoHash::default()); diff --git a/common/lib/src/lib.rs b/common/lib/src/lib.rs index 0d58dc38..ca82c495 100644 --- a/common/lib/src/lib.rs +++ b/common/lib/src/lib.rs @@ -1,3 +1,4 @@ +#![allow(clippy::unit_arg, clippy::comparison_chain)] #![no_std] extern crate alloc; #[cfg(any(feature = "test_utils", test))]