1use alloy_consensus::Sealable;
37use alloy_primitives::{B256, Bytes, keccak256};
38use commonware_codec::Encode;
39use commonware_cryptography::{
40 Signer,
41 bls12381::{
42 dkg::feldman_desmedt::{self, Output},
43 primitives::{group::Share, sharing::Mode, variant::MinSig},
44 },
45 ed25519::{PrivateKey, PublicKey},
46};
47use commonware_math::algebra::Random;
48use commonware_utils::{N3f1, TryFromIterator, ordered};
49use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng};
50use serde::{Deserialize, Serialize};
51use std::{collections::BTreeMap, num::NonZeroU64, sync::Arc};
52use tempo_dkg_onchain_artifacts::OnchainDkgOutcome;
53use tempo_hardfork::TempoHardfork;
54use tempo_primitives::{TempoConsensusContext, TempoHeader};
55
56mod certificate;
57
58#[derive(Clone)]
60pub struct TempoConsensus {
61 seed: B256,
62 epoch_length: NonZeroU64,
63 initial: Checkpoint,
64 state: Checkpoint,
65 checkpoints: BTreeMap<u64, Checkpoint>,
66 finalizations: BTreeMap<u64, TempoFinalization>,
67 retained_height: u64,
68}
69
70impl TempoConsensus {
71 pub fn new(seed: B256, epoch_length: NonZeroU64) -> Self {
73 Self::at_height(seed, epoch_length, 0)
74 }
75
76 pub fn at_height(seed: B256, epoch_length: NonZeroU64, height: u64) -> Self {
81 let epoch = height / epoch_length;
82 let initial = Checkpoint {
83 height,
84 counter: 0,
85 committees: BTreeMap::from([(epoch, Arc::new(Committee::derive(seed, 0)))]),
86 };
87 Self {
88 seed,
89 epoch_length,
90 initial: initial.clone(),
91 state: initial,
92 checkpoints: BTreeMap::new(),
93 finalizations: BTreeMap::new(),
94 retained_height: height,
95 }
96 }
97
98 pub fn default_seed(mnemonic: &str) -> B256 {
100 let mut input = b"anvil/tempo-consensus/".to_vec();
101 input.extend_from_slice(mnemonic.as_bytes());
102 keccak256(input)
103 }
104
105 pub fn identity(&self) -> TempoConsensusIdentity {
107 self.identity_at(self.state.height / self.epoch_length)
108 }
109
110 pub fn prepare_header(
119 &self,
120 header: &mut TempoHeader,
121 hardfork: TempoHardfork,
122 ) -> Result<(), TempoConsensusError> {
123 ensure_supported_hardfork(hardfork)?;
124 if header.inner.number == 0 && self.state.height == 0 {
125 header.consensus_context = None;
126 return Ok(());
127 }
128 self.check_next_height(header.inner.number)?;
129 let metadata = self.header_metadata(header.inner.number, hardfork.is_t4())?;
130 header.consensus_context = metadata.consensus_context;
131 header.inner.extra_data = metadata.extra_data;
132 Ok(())
133 }
134
135 pub fn finalize(
142 &mut self,
143 header: &TempoHeader,
144 hardfork: TempoHardfork,
145 ) -> Result<TempoFinalization, TempoConsensusError> {
146 self.check_next_height(header.inner.number)?;
147 ensure_supported_hardfork(hardfork)?;
148 let metadata = self.header_metadata(header.inner.number, hardfork.is_t4())?;
149 if metadata.consensus_context != header.consensus_context {
150 return Err(TempoConsensusError::InvalidContext);
151 }
152 if metadata.extra_data != header.inner.extra_data {
153 return Err(TempoConsensusError::InvalidExtraData);
154 }
155 let height = header.inner.number;
156 let (epoch, view, parent_view) = self.round(height);
157 let digest = header.hash_slow();
158 let (&from_epoch, committee) = self.committee_at(epoch);
159 let certificate = certificate::encode(
160 epoch,
161 view,
162 parent_view,
163 digest,
164 &committee.output,
165 &committee.share,
166 );
167 let finalization = TempoFinalization { epoch, view, digest, certificate };
168 self.state.height = height;
169 self.state.committees.retain(|&epoch, _| epoch >= from_epoch);
171 self.finalizations.insert(height, finalization.clone());
172 self.checkpoints.insert(height, self.state.clone());
173 Ok(finalization)
174 }
175
176 pub fn finalization(&self, height: u64) -> Option<&TempoFinalization> {
178 self.finalizations.get(&height)
179 }
180
181 pub fn latest(&self) -> Option<(u64, &TempoFinalization)> {
183 self.finalizations.last_key_value().map(|(&height, certificate)| (height, certificate))
184 }
185
186 fn identity_at(&self, epoch: u64) -> TempoConsensusIdentity {
187 let (&from_epoch, committee) = self.committee_at(epoch);
188 TempoConsensusIdentity {
189 identity: committee.output.public().public().encode().into(),
190 proposer: B256::from(<[u8; 32]>::from(&committee.proposer)),
191 from_epoch,
192 epoch_length: self.epoch_length.get(),
193 participants: 1,
194 quorum: committee.output.quorum::<N3f1>(),
195 }
196 }
197
198 fn committee_at(&self, epoch: u64) -> (&u64, &Arc<Committee>) {
199 self.state.committees.range(..=epoch).next_back().expect("the anchor has a committee")
200 }
201
202 fn check_next_height(&self, height: u64) -> Result<(), TempoConsensusError> {
203 let expected =
204 self.state.height.checked_add(1).ok_or(TempoConsensusError::HeightOverflow)?;
205 if height != expected {
206 return Err(TempoConsensusError::UnexpectedHeight { expected, actual: height });
207 }
208 Ok(())
209 }
210
211 fn header_metadata(
212 &self,
213 height: u64,
214 include_context: bool,
215 ) -> Result<PreparedHeaderMetadata, TempoConsensusError> {
216 let (epoch, view, parent_view) = self.round(height);
217 let extra_data = if height % self.epoch_length == self.epoch_length.get() - 1 {
218 let next_epoch = epoch.checked_add(1).ok_or(TempoConsensusError::EpochOverflow)?;
219 let committee = self.committee_at(next_epoch).1;
220 OnchainDkgOutcome {
221 epoch: next_epoch,
222 output: committee.output.clone(),
223 next_players: committee.output.players().clone(),
224 is_next_full_dkg: false,
225 }
226 .encode()
227 .into()
228 } else {
229 Bytes::new()
230 };
231 let consensus_context = include_context.then(|| {
232 let proposer = B256::from(<[u8; 32]>::from(&self.committee_at(epoch).1.proposer))
233 .try_into()
234 .expect("the committee has a valid ed25519 public key");
235 TempoConsensusContext { epoch, view, parent_view, proposer }
236 });
237 Ok(PreparedHeaderMetadata { consensus_context, extra_data })
238 }
239
240 pub fn rotate(&mut self) -> Result<TempoConsensusIdentity, TempoConsensusError> {
245 let next_height =
246 self.state.height.checked_add(1).ok_or(TempoConsensusError::HeightOverflow)?;
247 let from_epoch = (next_height / self.epoch_length)
248 .checked_add(1)
249 .ok_or(TempoConsensusError::EpochOverflow)?;
250 let counter =
251 self.state.counter.checked_add(1).ok_or(TempoConsensusError::CounterOverflow)?;
252 self.state.committees.insert(from_epoch, Arc::new(Committee::derive(self.seed, counter)));
253 self.state.counter = counter;
254 Ok(self.identity_at(from_epoch))
255 }
256
257 pub fn unwind(&mut self, height: u64) -> Result<(), TempoConsensusError> {
263 if height < self.retained_height || height > self.state.height {
264 return Err(TempoConsensusError::InvalidRewind {
265 earliest: self.retained_height,
266 head: self.state.height,
267 requested: height,
268 });
269 }
270 if height == self.state.height {
271 return Ok(());
272 }
273 self.state = if height == self.initial.height {
274 self.initial.clone()
275 } else {
276 self.checkpoints.get(&height).expect("retained signed heights have checkpoints").clone()
277 };
278 self.checkpoints.retain(|&number, _| number <= height);
279 self.finalizations.retain(|&number, _| number <= height);
280 Ok(())
281 }
282
283 pub fn reset(&mut self) {
285 self.state = self.initial.clone();
286 self.checkpoints.clear();
287 self.finalizations.clear();
288 self.retained_height = self.initial.height;
289 }
290
291 fn round(&self, height: u64) -> (u64, u64, u64) {
293 let epoch = height / self.epoch_length;
294 let view = if epoch == 0 { height } else { height % self.epoch_length + 1 };
295 (epoch, view, view - 1)
296 }
297
298 pub fn prune(&mut self, below: u64) -> Result<(), TempoConsensusError> {
305 if below > self.state.height {
306 return Err(TempoConsensusError::InvalidPrune {
307 head: self.state.height,
308 requested: below,
309 });
310 }
311 if below > self.retained_height {
312 self.checkpoints = self.checkpoints.split_off(&below);
313 self.finalizations = self.finalizations.split_off(&below);
314 self.retained_height = below;
315 }
316 Ok(())
317 }
318
319 pub(super) const fn anchor_height(&self) -> u64 {
321 self.initial.height
322 }
323}
324
325#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
327#[serde(rename_all = "camelCase")]
328pub struct TempoConsensusIdentity {
329 pub identity: Bytes,
331 pub proposer: B256,
333 pub from_epoch: u64,
335 pub epoch_length: u64,
337 pub participants: u32,
339 pub quorum: u32,
341}
342
343#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
345pub struct TempoFinalization {
346 pub epoch: u64,
348 pub view: u64,
350 pub digest: B256,
352 #[serde(with = "alloy_primitives::hex::serde::no_prefix")]
354 pub certificate: Bytes,
355}
356
357#[derive(Clone)]
359struct Checkpoint {
360 height: u64,
361 counter: u64,
362 committees: BTreeMap<u64, Arc<Committee>>,
363}
364
365struct PreparedHeaderMetadata {
366 consensus_context: Option<TempoConsensusContext>,
367 extra_data: Bytes,
368}
369
370#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
372pub enum TempoConsensusError {
373 #[error("Tempo consensus block height overflow")]
375 HeightOverflow,
376 #[error("Tempo consensus epoch overflow")]
378 EpochOverflow,
379 #[error("expected Tempo consensus height {expected}, got {actual}")]
381 UnexpectedHeight { expected: u64, actual: u64 },
382 #[error("invalid Tempo consensus context")]
384 InvalidContext,
385 #[error("invalid Tempo consensus extra data")]
387 InvalidExtraData,
388 #[error("Tempo consensus identity counter overflow")]
390 CounterOverflow,
391 #[error(
393 "cannot rewind Tempo consensus from {head} to {requested} with earliest retained height {earliest}"
394 )]
395 InvalidRewind { earliest: u64, head: u64, requested: u64 },
396 #[error("unsupported Tempo consensus hardfork {0}")]
398 UnsupportedHardfork(TempoHardfork),
399 #[error("cannot prune Tempo consensus below {requested} beyond head {head}")]
401 InvalidPrune { head: u64, requested: u64 },
402}
403
404struct Committee {
405 proposer: PublicKey,
406 output: Output<MinSig, PublicKey>,
407 share: Share,
408}
409
410impl Committee {
411 fn derive(seed: B256, counter: u64) -> Self {
412 let mut input = seed.to_vec();
415 input.extend_from_slice(&counter.to_be_bytes());
416 let mut rng = ChaCha20Rng::from_seed(keccak256(input).0);
417 let proposer = PrivateKey::random(&mut rng).public_key();
418 let players = ordered::Set::try_from_iter([proposer.clone()]).expect("one distinct player");
419 let (output, shares) =
420 feldman_desmedt::deal::<MinSig, _, N3f1>(&mut rng, Mode::NonZeroCounter, players)
421 .expect("dealing to one player cannot fail");
422 let share = shares.values()[0].clone();
423 Self { proposer, output, share }
424 }
425}
426
427const fn ensure_supported_hardfork(hardfork: TempoHardfork) -> Result<(), TempoConsensusError> {
428 if hardfork as u64 <= TempoHardfork::T14 as u64 {
430 Ok(())
431 } else {
432 Err(TempoConsensusError::UnsupportedHardfork(hardfork))
433 }
434}
435
436#[cfg(test)]
437mod tests;