Skip to main content

tendermint_machine/
commit.rs

1use crate::{
2  BlockNumber, RoundNumber, Validator, ValidatorSet, AggregateSignature, SignatureScheme, Signer,
3  Blockchain,
4};
5
6/// A commit for a specific block.
7///
8/// In order for this to be valid, the signature MUST be valid and aggregated from signatures by
9/// validators whose weight is sufficient for the threshold. Deserialization or instantiation alone
10/// DOES NOT signify validity.
11#[derive(Debug)]
12#[cfg_attr(feature = "alloc", derive(borsh::BorshSerialize, borsh::BorshDeserialize))]
13pub struct Commit<A: AggregateSignature> {
14  /// The block number this is a commit for.
15  pub(crate) block_number: BlockNumber,
16
17  /// The round number which produced this commit.
18  ///
19  /// This is not a canonical round number and there may be multiple valid commits, for the same
20  /// block, with differing `round_number` values without implying a break in soundness. This is
21  /// only here to provide separation such that precommits from distinct rounds cannot be used in
22  /// conjunction with each other.
23  pub(crate) round_number: RoundNumber,
24
25  /// The aggregate signature for the validators used to create this commit.
26  pub(crate) aggregate_signature: A,
27}
28
29/// The [`Commit`] type for a [`Blockchain`].
30pub type CommitFor<B> =
31  Commit<<<B as Blockchain>::SignatureScheme as SignatureScheme>::AggregateSignature>;
32
33impl<A: AggregateSignature> Clone for Commit<A> {
34  fn clone(&self) -> Self {
35    Self {
36      block_number: self.block_number,
37      round_number: self.round_number,
38      aggregate_signature: self.aggregate_signature.clone(),
39    }
40  }
41}
42
43/// Check if a list of validators have a sum weight satisfying the threshold.
44///
45/// This returns `false` if any validator present was not actually a validator. This DOES NOT check
46/// the validators were unique however.
47#[must_use]
48pub(crate) fn validators_satisfy_threshold<V: Validator>(
49  validators: impl IntoIterator<Item = V>,
50  validator_set: &(impl ?Sized + ValidatorSet<Validator = V>),
51) -> bool {
52  // Ensure every validator is in fact a validator and their sum weight satisfies the threshold
53  validators
54    .into_iter()
55    .try_fold(0u16, |accum, validator| {
56      validator_set.weight(&validator).and_then(|weight| accum.checked_add(u16::from(weight)))
57    })
58    .is_some_and(|sum| sum >= validator_set.threshold())
59}
60
61#[doc(hidden)]
62pub(crate) enum CommitSegment<'genesis, 'block_hash> {
63  Dst([u8; 1]),
64  Genesis(&'genesis [u8]),
65  U64([u8; 8]),
66  Block(&'block_hash [u8]),
67}
68impl AsRef<[u8]> for CommitSegment<'_, '_> {
69  fn as_ref(&self) -> &[u8] {
70    match self {
71      Self::Dst(dst) => dst.as_slice(),
72      Self::Genesis(genesis) => genesis,
73      Self::U64(number) => number.as_slice(),
74      Self::Block(block_hash) => block_hash,
75    }
76  }
77}
78
79impl<A: AggregateSignature> Commit<A> {
80  /// The block number this commit is for.
81  #[must_use]
82  pub fn block_number(&self) -> BlockNumber {
83    self.block_number
84  }
85
86  #[must_use]
87  pub(crate) fn signature_message<'genesis, 'block_hash>(
88    genesis: &'genesis [u8],
89    block_number: BlockNumber,
90    round_number: RoundNumber,
91    block_hash: &'block_hash [u8],
92  ) -> <[CommitSegment<'genesis, 'block_hash>; 6] as IntoIterator>::IntoIter {
93    [
94      CommitSegment::Dst([0]),
95      /*
96        Length-prefix the genesis to prevent one genesis from being a valid prefix of another,
97        breaking the intended domain separation of this.
98      */
99      CommitSegment::Dst([u8::try_from(genesis.as_ref().len()).unwrap()]),
100      CommitSegment::Genesis(genesis),
101      CommitSegment::U64(u64::from(block_number.0).to_le_bytes()),
102      CommitSegment::U64(u64::from(round_number.0).to_le_bytes()),
103      /*
104        This doesn't length-prefix the block hash as it's presumably fixed-length and is definitely
105        unnecessary.
106      */
107      CommitSegment::Block(block_hash),
108    ]
109    .into_iter()
110  }
111
112  #[must_use]
113  pub(crate) async fn sign<S: ?Sized + SignatureScheme<AggregateSignature = A>>(
114    signer: &(impl ?Sized + Signer<Signature = <S as SignatureScheme>::Signature>),
115    genesis: &[u8],
116    block_number: BlockNumber,
117    round_number: RoundNumber,
118    block_hash: &[u8],
119  ) -> <S as SignatureScheme>::Signature {
120    signer.sign(Self::signature_message(genesis, block_number, round_number, block_hash)).await
121  }
122
123  #[must_use]
124  pub(crate) fn verify_precommit<S: ?Sized + SignatureScheme<AggregateSignature = A>>(
125    signature_scheme: &S,
126    validator: &S::Validator,
127    genesis: &[u8],
128    block_number: BlockNumber,
129    round_number: RoundNumber,
130    block_hash: &[u8],
131    signature: &S::Signature,
132  ) -> bool {
133    signature_scheme.verify(
134      validator,
135      Self::signature_message(genesis, block_number, round_number, block_hash),
136      signature,
137    )
138  }
139
140  /// Verify a commit.
141  #[must_use]
142  pub fn verify<S: ?Sized + SignatureScheme<AggregateSignature = A>>(
143    &self,
144    validator_set: &(impl ?Sized + ValidatorSet<Validator = S::Validator>),
145    signature_scheme: &S,
146    genesis: impl AsRef<[u8]>,
147    block_hash: impl AsRef<[u8]>,
148  ) -> bool {
149    // Ensure the signature was valid
150    let Ok(validators) = signature_scheme.verify_aggregate(
151      Self::signature_message(
152        genesis.as_ref(),
153        self.block_number,
154        self.round_number,
155        block_hash.as_ref(),
156      ),
157      &self.aggregate_signature,
158    ) else {
159      return false;
160    };
161
162    // Ensure the signers satisfy the threshold
163    validators_satisfy_threshold(validators, validator_set)
164  }
165}
166
167#[cfg(test)]
168mod tests {
169  use super::*;
170
171  struct RandomCommit {
172    genesis_len: u8,
173    #[expect(clippy::as_conversions)]
174    genesis: [u8; u8::MAX as usize],
175
176    block_number: BlockNumber,
177    round_number: RoundNumber,
178
179    block_hash_len: u16,
180    #[expect(clippy::as_conversions)]
181    block_hash: [u8; u16::MAX as usize],
182  }
183
184  impl RandomCommit {
185    fn new() -> Self {
186      use core::num::NonZero;
187      use rand_core::{TryRngCore as _, OsRng};
188
189      #[expect(clippy::as_conversions, clippy::cast_possible_truncation)]
190      let genesis_len = OsRng.try_next_u64().unwrap() as u8;
191      #[expect(clippy::as_conversions)]
192      let mut genesis = [0xff; u8::MAX as usize];
193      OsRng.try_fill_bytes(&mut genesis[.. usize::from(genesis_len)]).unwrap();
194
195      let block_number =
196        BlockNumber(NonZero::new(OsRng.try_next_u64().unwrap().saturating_add(1)).unwrap());
197      let round_number =
198        RoundNumber(NonZero::new(OsRng.try_next_u64().unwrap().saturating_add(1)).unwrap());
199
200      #[expect(clippy::as_conversions, clippy::cast_possible_truncation)]
201      let block_hash_len = OsRng.try_next_u64().unwrap() as u16;
202      #[expect(clippy::as_conversions, clippy::large_stack_arrays)]
203      let mut block_hash = [0xff; u16::MAX as usize];
204      OsRng.try_fill_bytes(&mut block_hash[.. usize::from(block_hash_len)]).unwrap();
205
206      Self { genesis_len, genesis, block_number, round_number, block_hash_len, block_hash }
207    }
208
209    fn genesis(&self) -> &[u8] {
210      &self.genesis[.. usize::from(self.genesis_len)]
211    }
212
213    fn block_hash(&self) -> &[u8] {
214      &self.block_hash[.. usize::from(self.block_hash_len)]
215    }
216  }
217
218  #[cfg(feature = "alloc")]
219  #[test]
220  fn signature_message() {
221    for _ in 0 .. 128 {
222      let commit = RandomCommit::new();
223
224      let expected = [
225        [0].as_slice(),
226        &[commit.genesis_len],
227        commit.genesis(),
228        &u64::from(commit.block_number).to_le_bytes(),
229        &u64::from(commit.round_number).to_le_bytes(),
230        commit.block_hash(),
231      ]
232      .concat();
233
234      let mut concatenated = alloc::vec![];
235      for chunk in Commit::<
236        <crate::TestSignatureScheme as SignatureScheme>::AggregateSignature
237      >::signature_message(
238        commit.genesis(),
239        commit.block_number,
240        commit.round_number,
241        commit.block_hash(),
242      ) {
243        concatenated.extend(chunk.as_ref());
244      }
245
246      assert_eq!(expected, concatenated);
247    }
248  }
249
250  #[cfg(feature = "alloc")]
251  #[test]
252  fn sign_and_verify_precommit() {
253    use core::{
254      pin::pin,
255      task::{Poll, Waker, Context},
256      future::Future as _,
257    };
258
259    use crate::TestSignatureScheme;
260
261    let mut context = Context::from_waker(Waker::noop());
262    let signature_scheme = TestSignatureScheme::new();
263
264    for i in 0 .. u8::MAX {
265      let signer = signature_scheme.signer(i);
266      let commit = RandomCommit::new();
267      let Poll::Ready(mut signature) = pin!(Commit::<
268        <TestSignatureScheme as SignatureScheme>::AggregateSignature,
269      >::sign::<TestSignatureScheme>(
270        &signer,
271        commit.genesis(),
272        commit.block_number,
273        commit.round_number,
274        commit.block_hash(),
275      ))
276      .poll(&mut context) else {
277        panic!("`TestSignatureScheme::sign` returned `Poll::Pending`")
278      };
279      assert!(
280        Commit::<<TestSignatureScheme as SignatureScheme>::AggregateSignature>::verify_precommit(
281          &signature_scheme,
282          &i,
283          commit.genesis(),
284          commit.block_number,
285          commit.round_number,
286          commit.block_hash(),
287          &signature
288        )
289      );
290      signature[0] ^= 1;
291      assert!(
292        !Commit::<<TestSignatureScheme as SignatureScheme>::AggregateSignature>::verify_precommit(
293          &signature_scheme,
294          &i,
295          commit.genesis(),
296          commit.block_number,
297          commit.round_number,
298          commit.block_hash(),
299          &signature
300        )
301      );
302    }
303  }
304
305  #[test]
306  fn verify_commit() {
307    use core::{
308      num::NonZero,
309      pin::pin,
310      task::{Poll, Waker, Context},
311      future::Future as _,
312    };
313    use alloc::{vec::Vec, vec, collections::BTreeMap};
314
315    use crate::TestSignatureScheme;
316
317    let signature_scheme = TestSignatureScheme::new();
318    let commit = RandomCommit::new();
319
320    let signature = |validator| {
321      let mut context = Context::from_waker(Waker::noop());
322      let signer = signature_scheme.signer(validator);
323      let Poll::Ready(signature) = pin!(Commit::<
324        <TestSignatureScheme as SignatureScheme>::AggregateSignature,
325      >::sign::<TestSignatureScheme>(
326        &signer,
327        commit.genesis(),
328        commit.block_number,
329        commit.round_number,
330        commit.block_hash(),
331      ))
332      .poll(&mut context) else {
333        panic!("`TestSignatureScheme::sign` returned `Poll::Pending`")
334      };
335      signature
336    };
337    let signatures = [signature(0), signature(2), signature(3)];
338
339    let aggregate_signature = signature_scheme.aggregate(
340      Commit::<<TestSignatureScheme as SignatureScheme>::AggregateSignature>::signature_message(
341        commit.genesis(),
342        commit.block_number,
343        commit.round_number,
344        commit.block_hash(),
345      ),
346      [(&0, &signatures[0])],
347    );
348
349    let actual_commit = Commit {
350      block_number: commit.block_number,
351      round_number: commit.round_number,
352      aggregate_signature,
353    };
354    assert_eq!(actual_commit.block_number(), commit.block_number);
355
356    let verify = |valid, actual_commit: &Commit<_>, weights: Vec<(_, u16)>| {
357      assert_eq!(
358        actual_commit.verify(
359          &weights
360            .into_iter()
361            .map(|(validator, weight)| (validator, NonZero::new(weight).unwrap()))
362            .collect::<BTreeMap<_, _>>(),
363          &signature_scheme,
364          commit.genesis(),
365          commit.block_hash()
366        ),
367        valid
368      );
369    };
370    // This should verify if the threshold is satisfied
371    verify(true, &actual_commit, vec![(0, 1)]);
372    // It shouldn't if the threshold isn't satisfied
373    verify(false, &actual_commit, vec![(0, 1), (1, 1)]);
374    // But this is a weighted threshold, so increasing the validator's weight should be sufficient
375    verify(false, &actual_commit, vec![(0, 2), (1, 1)]);
376    verify(true, &actual_commit, vec![(0, 3), (1, 1)]);
377
378    // Malleating the signature should cause the commit's verification to fail
379    {
380      let mut actual_commit = actual_commit.clone();
381      *actual_commit.aggregate_signature.last_mut().unwrap() ^= 1;
382      verify(false, &actual_commit, vec![(0, 1)]);
383    }
384
385    // Test a commit which requires the sum weight from multiple validators
386    {
387      let aggregate_signature = signature_scheme.aggregate(
388        Commit::<<TestSignatureScheme as SignatureScheme>::AggregateSignature>::signature_message(
389          commit.genesis(),
390          commit.block_number,
391          commit.round_number,
392          commit.block_hash(),
393        ),
394        [0, 2, 3].iter().zip(signatures.iter()),
395      );
396      let actual_commit = Commit {
397        block_number: commit.block_number,
398        round_number: commit.round_number,
399        aggregate_signature,
400      };
401      assert_eq!(actual_commit.block_number(), commit.block_number);
402      verify(true, &actual_commit, vec![(0, 1), (1, 1), (2, 1), (3, 1)]);
403    }
404  }
405}