Skip to main content

anvil/eth/backend/
tempo_consensus.rs

1//! Deterministic single-member Tempo development consensus.
2//!
3//! This is a local signer, not a running Simplex network. It derives an ed25519 proposer and a
4//! real 1-of-1 BLS sharing from `ChaCha20(keccak256(seed || counter_be_u64))`. The default seed is
5//! `keccak256("anvil/tempo-consensus/" || mnemonic)`. Keys are predictable development identities,
6//! not production trust anchors. Each rotation derives a fresh sharing, rather than resharing an
7//! existing key.
8//!
9//! For epoch length `L`, height `h` uses epoch `h / L`. Signed heights in epoch zero use view `h`,
10//! while later epochs use view `h % L + 1`; the parent view is one less. Genesis and each preceding
11//! epoch boundary serve as view-zero parents. This no-skips development convention does not
12//! inherit a remote chain's rounds. A boundary at `h % L == L - 1` announces the next committee
13//! in a native [`OnchainDkgOutcome`]. The boundary is signed by the old committee; the next epoch
14//! activates the announced key. The outcome's `is_next_full_dkg = false` describes the next
15//! ceremony, not whether this local key changed.
16//!
17//! [`TempoHeader`] owns the full RLP encoding and hash, including all execution fields and the
18//! optional context. Genesis is unsigned and contextless. Context is omitted before T4, following
19//! [TIP-1031's activation gate][tip1031]. The supported wire mapping is pinned to Tempo `2c25f7cd`
20//! and Commonware `2026.9.0`, and rejects unknown hardforks pending review. This is not a claim of
21//! upstream format stability; protocol-owner confirmation remains tracked by OSS-1186. Older
22//! execution hardforks still use the pinned certificate/DKG codec, not historical Commonware
23//! codecs.
24//!
25//! Cloning snapshots the complete controller, including pending rotations. Unwind instead restores
26//! the retained height's checkpoint and discards later certificates and rotations. Checkpoints
27//! retain only their active and future committees. Callers can bound retained history with
28//! [`TempoConsensus::prune`]; unwind cannot cross the pruning boundary. Reset restores the unsigned
29//! initial anchor, including its original rewind floor. Re-signing replacement history can produce
30//! valid certificates for conflicting headers at the same height: this development rewind behavior
31//! is not consensus safety. Callers own canonical commit, execution/body validation, archive
32//! pruning, and persistence; this module has no mining, RPC, CLI, or fork-routing integration.
33//!
34//! [tip1031]: https://github.com/tempoxyz/tempo/blob/b34e701ecc69d91d72ffb3e77114958bb4f8bc38/crates/commonware-node/src/consensus/application/actor.rs
35
36use 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/// A deterministic development committee with one participant and quorum one.
59#[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    /// Creates the development committee at genesis.
72    pub fn new(seed: B256, epoch_length: NonZeroU64) -> Self {
73        Self::at_height(seed, epoch_length, 0)
74    }
75
76    /// Starts at an unsigned checkpoint, without certifying or importing its history.
77    ///
78    /// The local identity is independent of any upstream committee. Only strict successors of
79    /// this height can be signed. Reset restores this anchor; unwind cannot cross it.
80    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    /// Derives the default seed from the account mnemonic with a consensus-specific domain.
99    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    /// Returns the public development identity.
106    pub fn identity(&self) -> TempoConsensusIdentity {
107        self.identity_at(self.state.height / self.epoch_length)
108    }
109
110    /// Installs metadata for the next header before its hash is calculated.
111    ///
112    /// Genesis stays contextless and unsigned. Only its `consensus_context` is cleared; all other
113    /// fields, including caller-provided `extra_data`, are preserved.
114    ///
115    /// For other heights, this owns `consensus_context` and `extra_data`: non-boundaries have no
116    /// dealer logs, while boundaries announce the native next-epoch outcome. Execution fields are
117    /// preserved. Errors leave the header unchanged.
118    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    /// Certifies a prepared native header and checkpoints the resulting local state.
136    ///
137    /// Revalidates the metadata against the current key schedule, so rotating after preparation
138    /// cannot sign a stale boundary announcement. The digest is computed from the complete native
139    /// header. This does not validate execution, body commitments, or canonical chain membership;
140    /// callers must commit the header and consensus state atomically with those checks.
141    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        // Older checkpoints retain the committees needed to replay their own history.
170        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    /// Returns the certificate for a retained signed height, excluding the unsigned anchor.
177    pub fn finalization(&self, height: u64) -> Option<&TempoFinalization> {
178        self.finalizations.get(&height)
179    }
180
181    /// Returns the latest signed height and its certificate.
182    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    /// Schedules a fresh committee after the epoch containing the next block.
241    ///
242    /// Repeated calls replace the pending key for the same epoch, consuming one counter each.
243    /// A key already announced by a finalized boundary cannot be replaced by this operation.
244    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    /// Removes unwound certificates and restores the retained height's key schedule and counter.
258    ///
259    /// Pending rotations after that checkpoint are discarded. Rewinding zero blocks is a no-op;
260    /// use a cloned controller to restore an exact snapshot, including pending rotations.
261    /// Heights below the most recent pruning boundary cannot be restored.
262    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    /// Clears certificates and rotations, restoring the original unsigned anchor and identity.
284    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    /// Returns the epoch, view, and parent view for a non-genesis height.
292    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    /// Removes certificates and checkpoints strictly below `below`, advancing the rewind floor.
299    ///
300    /// The current head and pending rotations are unchanged. Any checkpoint and certificate at
301    /// `below` remain available, so pruning at the head retains its finalized state. Requests at
302    /// or below the existing floor are no-ops; requests above the head fail without changing state.
303    /// [`Self::reset`] still restores the original unsigned anchor and clears the pruning boundary.
304    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    /// Returns the unsigned initial height, independently of the pruning boundary.
320    pub(super) const fn anchor_height(&self) -> u64 {
321        self.initial.height
322    }
323}
324
325/// Public development committee information, without secret key material.
326#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
327#[serde(rename_all = "camelCase")]
328pub struct TempoConsensusIdentity {
329    /// Compressed 96-byte BLS12-381 G2 public key.
330    pub identity: Bytes,
331    /// The committee's ed25519 proposer key.
332    pub proposer: B256,
333    /// The first epoch using this identity.
334    pub from_epoch: u64,
335    /// Number of block heights per epoch.
336    pub epoch_length: u64,
337    /// Number of committee members.
338    pub participants: u32,
339    /// Number of shares required to sign a certificate.
340    pub quorum: u32,
341}
342
343/// Certificate data bound to a complete native Tempo header.
344#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
345pub struct TempoFinalization {
346    /// The proposal's epoch.
347    pub epoch: u64,
348    /// The proposal's view.
349    pub view: u64,
350    /// Keccak-256 of the complete RLP-encoded Tempo header.
351    pub digest: B256,
352    /// Native Simplex finalization bytes with no additional framing.
353    #[serde(with = "alloy_primitives::hex::serde::no_prefix")]
354    pub certificate: Bytes,
355}
356
357/// Local identity state at the anchor or a finalized height.
358#[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/// Invalid local consensus input or exhausted height/identity space.
371#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
372pub enum TempoConsensusError {
373    /// The current height has no representable successor.
374    #[error("Tempo consensus block height overflow")]
375    HeightOverflow,
376    /// The next epoch cannot be represented.
377    #[error("Tempo consensus epoch overflow")]
378    EpochOverflow,
379    /// The header is not the strict successor of the local head.
380    #[error("expected Tempo consensus height {expected}, got {actual}")]
381    UnexpectedHeight { expected: u64, actual: u64 },
382    /// The header's context differs from the selected local round or proposer.
383    #[error("invalid Tempo consensus context")]
384    InvalidContext,
385    /// The header's DKG data differs from the current committee schedule.
386    #[error("invalid Tempo consensus extra data")]
387    InvalidExtraData,
388    /// All development identity derivation counters have been exhausted.
389    #[error("Tempo consensus identity counter overflow")]
390    CounterOverflow,
391    /// The rewind target is outside this controller's retained history.
392    #[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    /// A new hardfork requires review of the pinned header, DKG, and certificate formats.
397    #[error("unsupported Tempo consensus hardfork {0}")]
398    UnsupportedHardfork(TempoHardfork),
399    /// The requested pruning boundary is beyond the current head.
400    #[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        // The counter is an eight-byte big-endian integer. Generate the proposer before the
413        // sharing from the same stream to preserve the spike's deterministic development keys.
414        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    // Upstream appends new hardforks; extending support requires a wire-format review.
429    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;