Skip to main content

iota_core/
stake_aggregator.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2024 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5use std::{
6    collections::{BTreeMap, HashMap, hash_map::Entry},
7    hash::Hash,
8    sync::Arc,
9};
10
11use iota_sdk_types::crypto::Intent;
12use iota_types::{
13    base_types::{AuthorityName, ConciseableName},
14    committee::{Committee, CommitteeTrait, StakeUnit},
15    crypto::{AuthorityQuorumSignInfo, AuthoritySignInfo, AuthoritySignInfoTrait},
16    error::{IotaError, IotaResult},
17    message_envelope::{Envelope, Message},
18};
19use serde::Serialize;
20use tracing::warn;
21use typed_store::TypedStoreError;
22
23/// StakeAggregator allows us to keep track of the total stake of a set of
24/// validators. STRENGTH indicates whether we want a strong quorum (2f+1) or a
25/// weak quorum (f+1).
26#[derive(Debug)]
27pub struct StakeAggregator<S, const STRENGTH: bool> {
28    data: HashMap<AuthorityName, S>,
29    total_votes: StakeUnit,
30    committee: Arc<Committee>,
31}
32
33/// StakeAggregator is a utility data structure that allows us to aggregate a
34/// list of validator signatures over time. A committee is used to determine
35/// whether we have reached sufficient quorum (defined based on `STRENGTH`). The
36/// generic implementation does not require `S` to be an actual signature, but
37/// just an indication that a specific validator has voted. A specialized
38/// implementation for `AuthoritySignInfo` is followed below.
39impl<S: Clone + Eq, const STRENGTH: bool> StakeAggregator<S, STRENGTH> {
40    pub fn new(committee: Arc<Committee>) -> Self {
41        Self {
42            data: Default::default(),
43            total_votes: Default::default(),
44            committee,
45        }
46    }
47
48    pub fn from_iter<I: Iterator<Item = Result<(AuthorityName, S), TypedStoreError>>>(
49        committee: Arc<Committee>,
50        data: I,
51    ) -> IotaResult<Self> {
52        let mut this = Self::new(committee);
53        for item in data {
54            let (authority, s) = item?;
55            this.insert_generic(authority, s);
56        }
57        Ok(this)
58    }
59
60    /// A generic version of inserting arbitrary type of V (e.g. void type).
61    /// If V is AuthoritySignInfo, the `insert` function should be used instead
62    /// since it does extra checks and aggregations in the end.
63    /// Returns Map authority -> S, without aggregating it.
64    /// If you want to get an aggregated signature instead, use
65    /// `StakeAggregator::insert`
66    pub fn insert_generic(
67        &mut self,
68        authority: AuthorityName,
69        s: S,
70    ) -> InsertResult<&HashMap<AuthorityName, S>> {
71        match self.data.entry(authority) {
72            Entry::Occupied(oc) => {
73                return InsertResult::Failed {
74                    error: IotaError::StakeAggregatorRepeatedSigner {
75                        signer: authority,
76                        conflicting_sig: oc.get() != &s,
77                    },
78                };
79            }
80            Entry::Vacant(va) => {
81                va.insert(s);
82            }
83        }
84        let votes = self.committee.weight(&authority);
85        if votes > 0 {
86            self.total_votes += votes;
87            if self.total_votes >= self.committee.threshold::<STRENGTH>() {
88                InsertResult::QuorumReached(&self.data)
89            } else {
90                InsertResult::NotEnoughVotes {
91                    bad_votes: 0,
92                    bad_authorities: vec![],
93                }
94            }
95        } else {
96            InsertResult::Failed {
97                error: IotaError::InvalidAuthenticator,
98            }
99        }
100    }
101
102    pub fn contains_key(&self, authority: &AuthorityName) -> bool {
103        self.data.contains_key(authority)
104    }
105
106    pub fn keys(&self) -> impl Iterator<Item = &AuthorityName> {
107        self.data.keys()
108    }
109
110    pub fn committee(&self) -> &Committee {
111        &self.committee
112    }
113
114    pub fn total_votes(&self) -> StakeUnit {
115        self.total_votes
116    }
117
118    pub fn validator_sig_count(&self) -> usize {
119        self.data.len()
120    }
121}
122
123impl<const STRENGTH: bool> StakeAggregator<AuthoritySignInfo, STRENGTH> {
124    /// Insert an authority signature. This is the primary way to use the
125    /// aggregator and a few dedicated checks are performed to make sure
126    /// things work. If quorum is reached, we return AuthorityQuorumSignInfo
127    /// directly.
128    pub fn insert<T: Message + Serialize>(
129        &mut self,
130        envelope: Envelope<T, AuthoritySignInfo>,
131    ) -> InsertResult<AuthorityQuorumSignInfo<STRENGTH>> {
132        let (data, sig) = envelope.into_data_and_sig();
133        if self.committee.epoch != sig.epoch {
134            return InsertResult::Failed {
135                error: IotaError::WrongEpoch {
136                    expected_epoch: self.committee.epoch,
137                    actual_epoch: sig.epoch,
138                },
139            };
140        }
141        match self.insert_generic(sig.authority, sig) {
142            InsertResult::QuorumReached(_) => {
143                match AuthorityQuorumSignInfo::<STRENGTH>::new_from_auth_sign_infos(
144                    self.data.values().cloned().collect(),
145                    self.committee(),
146                ) {
147                    Ok(aggregated) => {
148                        match aggregated.verify_secure(
149                            &data,
150                            Intent::iota_app(T::SCOPE),
151                            self.committee(),
152                        ) {
153                            // In the happy path, the aggregated signature verifies ok and no need
154                            // to verify individual.
155                            Ok(_) => InsertResult::QuorumReached(aggregated),
156                            Err(_) => {
157                                // If the aggregated signature fails to verify, fallback to
158                                // iterating through all signatures
159                                // and verify individually. Decrement total votes and continue
160                                // to find new authority for signature to reach the quorum.
161                                //
162                                // TODO(joyqvq): It is possible for the aggregated signature to fail
163                                // every time when the latest one
164                                // single signature fails to verify repeatedly, and trigger
165                                // this for loop to run. This can be optimized by caching single sig
166                                // verification result only verify
167                                // the net new ones.
168                                let mut bad_votes = 0;
169                                let mut bad_authorities = vec![];
170                                for (name, sig) in &self.data.clone() {
171                                    if let Err(err) = sig.verify_secure(
172                                        &data,
173                                        Intent::iota_app(T::SCOPE),
174                                        self.committee(),
175                                    ) {
176                                        // TODO(joyqvq): Currently, the aggregator cannot do much
177                                        // with an authority that
178                                        // always returns an invalid signature other than saving to
179                                        // errors in state. It
180                                        // is possible to add the authority to a denylist or  punish
181                                        // the byzantine authority.
182                                        warn!(name=?name.concise(), "Bad stake from validator: {:?}", err);
183                                        self.data.remove(name);
184                                        let votes = self.committee.weight(name);
185                                        self.total_votes -= votes;
186                                        bad_votes += votes;
187                                        bad_authorities.push(*name);
188                                    }
189                                }
190                                // After evicting invalid sigs, the remaining valid sigs may
191                                // still constitute a quorum on their own.
192                                if self.total_votes >= self.committee.threshold::<STRENGTH>() {
193                                    match AuthorityQuorumSignInfo::<STRENGTH>::new_from_auth_sign_infos(
194                                        self.data.values().cloned().collect(),
195                                        self.committee(),
196                                    ) {
197                                        Ok(aggregated) => InsertResult::QuorumReached(aggregated),
198                                        Err(error) => InsertResult::Failed { error },
199                                    }
200                                } else {
201                                    InsertResult::NotEnoughVotes {
202                                        bad_votes,
203                                        bad_authorities,
204                                    }
205                                }
206                            }
207                        }
208                    }
209                    Err(error) => InsertResult::Failed { error },
210                }
211            }
212            // The following is necessary to change the template type of InsertResult.
213            InsertResult::Failed { error } => InsertResult::Failed { error },
214            InsertResult::NotEnoughVotes {
215                bad_votes,
216                bad_authorities,
217            } => InsertResult::NotEnoughVotes {
218                bad_votes,
219                bad_authorities,
220            },
221        }
222    }
223}
224
225pub enum InsertResult<CertT> {
226    QuorumReached(CertT),
227    Failed {
228        error: IotaError,
229    },
230    NotEnoughVotes {
231        bad_votes: u64,
232        bad_authorities: Vec<AuthorityName>,
233    },
234}
235
236impl<CertT> InsertResult<CertT> {
237    pub fn is_quorum_reached(&self) -> bool {
238        matches!(self, Self::QuorumReached(..))
239    }
240}
241
242/// MultiStakeAggregator is a utility data structure that tracks the stake
243/// accumulation of potentially multiple different values (usually due to
244/// byzantine/corrupted responses). Each value is tracked using a
245/// StakeAggregator and determine whether it has reached a quorum. Once quorum
246/// is reached, the aggregated signature is returned.
247#[derive(Debug)]
248pub struct MultiStakeAggregator<K, V, const STRENGTH: bool> {
249    committee: Arc<Committee>,
250    stake_maps: HashMap<K, (V, StakeAggregator<AuthoritySignInfo, STRENGTH>)>,
251}
252
253impl<K, V, const STRENGTH: bool> MultiStakeAggregator<K, V, STRENGTH> {
254    pub fn new(committee: Arc<Committee>) -> Self {
255        Self {
256            committee,
257            stake_maps: Default::default(),
258        }
259    }
260
261    pub fn unique_key_count(&self) -> usize {
262        self.stake_maps.len()
263    }
264
265    pub fn total_votes(&self) -> StakeUnit {
266        self.stake_maps
267            .values()
268            .map(|(_, stake_aggregator)| stake_aggregator.total_votes())
269            .sum()
270    }
271}
272
273impl<K, V, const STRENGTH: bool> MultiStakeAggregator<K, V, STRENGTH>
274where
275    K: Hash + Eq,
276    V: Message + Serialize + Clone,
277{
278    pub fn insert(
279        &mut self,
280        k: K,
281        envelope: Envelope<V, AuthoritySignInfo>,
282    ) -> InsertResult<AuthorityQuorumSignInfo<STRENGTH>> {
283        if let Some(entry) = self.stake_maps.get_mut(&k) {
284            entry.1.insert(envelope)
285        } else {
286            let mut new_entry = StakeAggregator::new(self.committee.clone());
287            let result = new_entry.insert(envelope.clone());
288            if !matches!(result, InsertResult::Failed { .. }) {
289                // This is very important: ensure that if the insert fails, we don't even add
290                // the new entry to the map.
291                self.stake_maps.insert(k, (envelope.into_data(), new_entry));
292            }
293            result
294        }
295    }
296}
297
298impl<K, V, const STRENGTH: bool> MultiStakeAggregator<K, V, STRENGTH>
299where
300    K: Clone + Ord,
301{
302    pub fn get_all_unique_values(&self) -> BTreeMap<K, (Vec<AuthorityName>, StakeUnit)> {
303        self.stake_maps
304            .iter()
305            .map(|(k, (_, s))| (k.clone(), (s.data.keys().copied().collect(), s.total_votes)))
306            .collect()
307    }
308}
309
310impl<K, V, const STRENGTH: bool> MultiStakeAggregator<K, V, STRENGTH>
311where
312    K: Hash + Eq,
313{
314    #[expect(dead_code)]
315    pub fn authorities_for_key(&self, k: &K) -> Option<impl Iterator<Item = &AuthorityName>> {
316        self.stake_maps.get(k).map(|(_, agg)| agg.keys())
317    }
318
319    /// The sum of all remaining stake, i.e. all stake not yet
320    /// committed by vote to a specific value
321    pub fn uncommitted_stake(&self) -> StakeUnit {
322        self.committee.total_votes() - self.total_votes()
323    }
324
325    /// Total stake of the largest faction
326    pub fn plurality_stake(&self) -> StakeUnit {
327        self.stake_maps
328            .values()
329            .map(|(_, agg)| agg.total_votes())
330            .max()
331            .unwrap_or_default()
332    }
333
334    /// If true, there isn't enough uncommitted stake to reach quorum for any
335    /// value
336    pub fn quorum_unreachable(&self) -> bool {
337        self.uncommitted_stake() + self.plurality_stake() < self.committee.threshold::<STRENGTH>()
338    }
339}
340
341#[cfg(test)]
342mod stake_aggregator_insert_tests {
343    use std::{collections::BTreeMap, sync::Arc};
344
345    use fastcrypto::{
346        hash::{HashFunction, Sha3_256},
347        traits::KeyPair,
348    };
349    use iota_sdk_types::crypto::IntentScope;
350    use iota_types::{
351        base_types::AuthorityName,
352        committee::Committee,
353        crypto::{AuthoritySignInfo, random_committee_key_pairs_of_size},
354        message_envelope::{Envelope, Message},
355    };
356    use serde::Serialize;
357
358    use super::*;
359
360    #[derive(Clone, Debug, Serialize, PartialEq, Eq, Hash)]
361    struct TestMessage {
362        value: String,
363    }
364
365    impl Message for TestMessage {
366        type DigestType = [u8; 32];
367        const SCOPE: IntentScope = IntentScope::SenderSignedTransaction;
368
369        fn digest(&self) -> Self::DigestType {
370            let mut hasher = Sha3_256::default();
371            hasher.update(self.value.as_bytes());
372            hasher.finalize().digest
373        }
374    }
375
376    /// Regression test: `StakeAggregator::insert` must not return
377    /// `NotEnoughVotes` when the remaining valid sigs (after bad-sig
378    /// eviction) still form a quorum.
379    #[test]
380    fn test_quorum_not_lost_after_bad_sig_eviction() {
381        // Two-validator committee: first sorted authority has ~7000 weight
382        // (> QUORUM_THRESHOLD ~6667), second has ~3000 weight.
383        let key_pairs = random_committee_key_pairs_of_size(2);
384        let mut names: Vec<AuthorityName> = key_pairs
385            .iter()
386            .map(|kp| AuthorityName::from(kp.public()))
387            .collect();
388        names.sort();
389
390        let voting_rights: BTreeMap<AuthorityName, u64> = names
391            .iter()
392            .enumerate()
393            .map(|(i, name)| (*name, if i == 0 { 7 } else { 3 }))
394            .collect();
395        let committee = Arc::new(Committee::new_for_testing_with_normalized_voting_power(
396            0,
397            voting_rights,
398        ));
399
400        let find_kp = |name: &AuthorityName| {
401            key_pairs
402                .iter()
403                .find(|kp| &AuthorityName::from(kp.public()) == name)
404                .unwrap()
405        };
406        let (auth0, key0) = (names[0], find_kp(&names[0]));
407        let (auth1, key1) = (names[1], find_kp(&names[1]));
408
409        let mut agg: StakeAggregator<AuthoritySignInfo, true> = StakeAggregator::new(committee);
410
411        let msg = TestMessage {
412            value: "real".to_string(),
413        };
414        let msg_bad = TestMessage {
415            value: "wrong".to_string(),
416        };
417
418        // auth1 signs the wrong message — weight (~3000) < threshold, no quorum yet.
419        let envelope_bad = Envelope::<TestMessage, AuthoritySignInfo>::new(0, msg_bad, key1, auth1);
420        assert!(matches!(
421            agg.insert(envelope_bad),
422            InsertResult::NotEnoughVotes { .. }
423        ));
424
425        // auth0 signs the real message — total weight crosses threshold, triggering
426        // batch verify. Batch fails (auth1's sig is for the wrong message);
427        // individual verify evicts auth1. auth0's weight alone (~7000) still
428        // exceeds the threshold, so the result must be QuorumReached, not
429        // NotEnoughVotes.
430        let envelope_good = Envelope::<TestMessage, AuthoritySignInfo>::new(0, msg, key0, auth0);
431        assert!(
432            agg.insert(envelope_good).is_quorum_reached(),
433            "valid sig with weight > quorum threshold must yield QuorumReached after bad sig eviction"
434        );
435    }
436}