Skip to main content

auths_core/witness/
collector.rs

1//! Receipt collection from multiple witnesses.
2//!
3//! This module provides the `ReceiptCollector` which coordinates receipt
4//! collection from multiple witnesses in parallel, enforcing threshold
5//! requirements for security.
6//!
7//! # Threshold Semantics
8//!
9//! KERI uses k-of-n witness thresholds:
10//! - n = total number of witnesses
11//! - k = minimum receipts required (threshold)
12//!
13//! For example, with 3 witnesses and threshold 2:
14//! - Need at least 2 receipts to succeed
15//! - Can tolerate 1 witness being unavailable
16//!
17//! # Parallel Collection
18//!
19//! Receipts are collected in parallel for efficiency. The collector
20//! returns as soon as the threshold is met, or waits for all witnesses
21//! if the threshold cannot be met.
22
23use std::sync::Arc;
24
25use auths_keri::{Prefix, Said};
26use tokio::time::{Duration, timeout};
27
28use super::async_provider::AsyncWitnessProvider;
29use super::error::{DuplicityEvidence, WitnessError};
30use super::receipt::StoredReceipt;
31use super::verify::{VerifiedReceipt, verify_receipt};
32
33/// Error during receipt collection.
34#[derive(Debug, thiserror::Error)]
35#[non_exhaustive]
36pub enum CollectionError {
37    /// Duplicity detected during collection.
38    #[error("duplicity detected: {0}")]
39    Duplicity(DuplicityEvidence),
40
41    /// Threshold not met - insufficient receipts collected.
42    #[error("threshold not met: got {got} receipts, need {required}")]
43    ThresholdNotMet {
44        /// Number of receipts successfully collected
45        got: usize,
46        /// Number of receipts required
47        required: usize,
48        /// Errors from failed witness requests
49        errors: Vec<(String, WitnessError)>,
50    },
51
52    /// All witnesses failed.
53    #[error("all witnesses failed")]
54    AllFailed {
55        /// Errors from each witness
56        errors: Vec<(String, WitnessError)>,
57    },
58
59    /// No witnesses configured.
60    #[error("no witnesses configured")]
61    NoWitnesses,
62}
63
64/// Collects receipts from multiple witnesses.
65///
66/// The collector queries witnesses in parallel and returns when either:
67/// - Threshold receipts have been collected (success)
68/// - Duplicity is detected (error)
69/// - Not enough witnesses respond successfully (error)
70///
71/// Each witness is pinned to its CESR verkey AID. `collect` returns only
72/// [`VerifiedReceipt`]s — receipts whose signatures verify against that pinned
73/// AID for the submitted event — so forged or wrong-event receipts can never
74/// reach the threshold.
75///
76/// # Example
77///
78/// ```rust,ignore
79/// use auths_core::witness::{ReceiptCollector, VerifiedReceipt};
80///
81/// let witnesses: Vec<(Prefix, Arc<dyn AsyncWitnessProvider>)> = pinned_witnesses();
82/// let collector = ReceiptCollector::new(witnesses, 2, 5000);
83///
84/// let receipts: Vec<VerifiedReceipt> =
85///     collector.collect(&prefix, &event_said, &event_json).await?;
86/// assert!(receipts.len() >= 2);
87/// ```
88pub struct ReceiptCollector {
89    /// Witnesses to query, each paired with its pinned AID so collected
90    /// receipts can be attributed without an extra `/health` round-trip.
91    witnesses: Vec<(Prefix, Arc<dyn AsyncWitnessProvider>)>,
92    /// Minimum receipts required
93    threshold: usize,
94    /// Timeout per witness in milliseconds
95    timeout_ms: u64,
96}
97
98impl ReceiptCollector {
99    /// Create a new receipt collector.
100    ///
101    /// # Arguments
102    ///
103    /// * `witnesses` - Witnesses to query, each as a `(witness_aid, provider)`
104    ///   pair. The AID is the witness's pinned CESR verkey prefix; each collected
105    ///   receipt's signature is verified against it before it counts toward quorum.
106    /// * `threshold` - Minimum number of receipts required
107    /// * `timeout_ms` - Timeout per witness operation in milliseconds
108    pub fn new(
109        witnesses: Vec<(Prefix, Arc<dyn AsyncWitnessProvider>)>,
110        threshold: usize,
111        timeout_ms: u64,
112    ) -> Self {
113        Self {
114            witnesses,
115            threshold,
116            timeout_ms,
117        }
118    }
119
120    /// Get the number of witnesses.
121    pub fn witness_count(&self) -> usize {
122        self.witnesses.len()
123    }
124
125    /// Get the threshold.
126    pub fn threshold(&self) -> usize {
127        self.threshold
128    }
129
130    /// Collect receipts from witnesses.
131    ///
132    /// Queries all witnesses in parallel and returns when threshold is met
133    /// or all witnesses have responded.
134    ///
135    /// # Arguments
136    ///
137    /// * `prefix` - The KERI prefix of the identity
138    /// * `event_said` - SAID of the event being receipted; every counted receipt
139    ///   must reference exactly this event
140    /// * `event_json` - The canonicalized JSON bytes of the event
141    ///
142    /// # Returns
143    ///
144    /// * `Ok(receipts)` - At least `threshold` *verified* receipts, each carrying
145    ///   the AID of the witness that produced it
146    /// * `Err(CollectionError::Duplicity(_))` - A witness reported duplicity
147    /// * `Err(CollectionError::ThresholdNotMet { .. })` - Too few verified receipts
148    pub async fn collect(
149        &self,
150        prefix: &Prefix,
151        event_said: &Said,
152        event_json: &[u8],
153    ) -> Result<Vec<VerifiedReceipt>, CollectionError> {
154        if self.witnesses.is_empty() {
155            return Err(CollectionError::NoWitnesses);
156        }
157
158        // Spawn tasks for all witnesses
159        let mut handles = Vec::with_capacity(self.witnesses.len());
160        let timeout_duration = Duration::from_millis(self.timeout_ms);
161
162        for (idx, (aid, witness)) in self.witnesses.iter().enumerate() {
163            let witness = Arc::clone(witness);
164            let aid = aid.clone();
165            let prefix = prefix.clone();
166            let event_json = event_json.to_vec();
167
168            let handle = tokio::spawn(async move {
169                let result =
170                    timeout(timeout_duration, witness.submit_event(&prefix, &event_json)).await;
171
172                match result {
173                    Ok(Ok(signed)) => Ok((
174                        idx,
175                        StoredReceipt {
176                            signed,
177                            witness: aid,
178                        },
179                    )),
180                    Ok(Err(e)) => Err((idx, e)),
181                    Err(_) => Err((
182                        idx,
183                        WitnessError::Timeout(timeout_duration.as_millis() as u64),
184                    )),
185                }
186            });
187
188            handles.push(handle);
189        }
190
191        // Collect results, verifying each receipt before it can count.
192        let mut receipts: Vec<VerifiedReceipt> = Vec::new();
193        let mut errors: Vec<(String, WitnessError)> = Vec::new();
194
195        for handle in handles {
196            match handle.await {
197                Ok(Ok((idx, stored))) => match verify_receipt(stored, event_said) {
198                    Ok(verified) => receipts.push(verified),
199                    // Forged, foreign-key, or wrong-event receipts are dropped
200                    // here — never counted toward the threshold.
201                    Err(e) => errors.push((format!("witness_{idx}"), e)),
202                },
203                Ok(Err((idx, e))) => {
204                    // A witness that itself reports duplicity is a hard stop.
205                    if let WitnessError::Duplicity(evidence) = e {
206                        return Err(CollectionError::Duplicity(evidence));
207                    }
208                    errors.push((format!("witness_{}", idx), e));
209                }
210                Err(join_err) => {
211                    errors.push((
212                        "unknown".to_string(),
213                        WitnessError::Network(format!("task join error: {}", join_err)),
214                    ));
215                }
216            }
217        }
218
219        // Check if we met the threshold
220        if receipts.len() >= self.threshold {
221            Ok(receipts)
222        } else if receipts.is_empty() && !errors.is_empty() {
223            Err(CollectionError::AllFailed { errors })
224        } else {
225            Err(CollectionError::ThresholdNotMet {
226                got: receipts.len(),
227                required: self.threshold,
228                errors,
229            })
230        }
231    }
232}
233
234/// Builder for ReceiptCollector.
235#[derive(Default)]
236pub struct ReceiptCollectorBuilder {
237    witnesses: Vec<(Prefix, Arc<dyn AsyncWitnessProvider>)>,
238    threshold: Option<usize>,
239    timeout_ms: Option<u64>,
240}
241
242impl std::fmt::Debug for ReceiptCollectorBuilder {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        f.debug_struct("ReceiptCollectorBuilder")
245            .field("witnesses_count", &self.witnesses.len())
246            .field("threshold", &self.threshold)
247            .field("timeout_ms", &self.timeout_ms)
248            .finish()
249    }
250}
251
252impl ReceiptCollectorBuilder {
253    /// Create a new builder.
254    pub fn new() -> Self {
255        Self::default()
256    }
257
258    /// Add a witness paired with its pinned AID.
259    pub fn witness(mut self, aid: Prefix, witness: Arc<dyn AsyncWitnessProvider>) -> Self {
260        self.witnesses.push((aid, witness));
261        self
262    }
263
264    /// Add multiple `(witness_aid, provider)` pairs.
265    pub fn witnesses(
266        mut self,
267        witnesses: impl IntoIterator<Item = (Prefix, Arc<dyn AsyncWitnessProvider>)>,
268    ) -> Self {
269        self.witnesses.extend(witnesses);
270        self
271    }
272
273    /// Set the threshold.
274    pub fn threshold(mut self, threshold: usize) -> Self {
275        self.threshold = Some(threshold);
276        self
277    }
278
279    /// Set the timeout in milliseconds.
280    pub fn timeout_ms(mut self, timeout_ms: u64) -> Self {
281        self.timeout_ms = Some(timeout_ms);
282        self
283    }
284
285    /// Build the collector.
286    ///
287    /// Uses default timeout of 5000ms if not specified.
288    /// Threshold defaults to 1 if not specified.
289    pub fn build(self) -> ReceiptCollector {
290        ReceiptCollector {
291            witnesses: self.witnesses,
292            threshold: self.threshold.unwrap_or(1),
293            timeout_ms: self.timeout_ms.unwrap_or(5000),
294        }
295    }
296}
297
298#[cfg(test)]
299#[allow(clippy::unwrap_used)]
300mod tests {
301    use super::*;
302    use crate::witness::NoOpAsyncWitness;
303    use async_trait::async_trait;
304    use auths_keri::KeriPublicKey;
305    use auths_keri::witness::{EventHash, Receipt, ReceiptTag, SignedReceipt};
306    use auths_keri::{KeriSequence, VersionString};
307    use ring::signature::{Ed25519KeyPair, KeyPair};
308
309    const EVENT_SAID: &str = "EEvent00000000000000000000000000000000000000";
310    const OTHER_SAID: &str = "EOther00000000000000000000000000000000000000";
311
312    fn event_said() -> Said {
313        Said::new_unchecked(EVENT_SAID.to_string())
314    }
315
316    /// A fresh Ed25519 keypair and its CESR `D…` verkey AID.
317    fn keypair_and_aid() -> (Arc<Ed25519KeyPair>, Prefix) {
318        let rng = ring::rand::SystemRandom::new();
319        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
320        let kp = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
321        let aid = KeriPublicKey::ed25519(kp.public_key().as_ref())
322            .unwrap()
323            .to_qb64()
324            .unwrap();
325        (Arc::new(kp), Prefix::new_unchecked(aid))
326    }
327
328    /// A witness that signs a receipt for `said` with `kp` on every submission.
329    struct SigningWitness {
330        kp: Arc<Ed25519KeyPair>,
331        said: Said,
332    }
333
334    #[async_trait]
335    impl AsyncWitnessProvider for SigningWitness {
336        async fn submit_event(
337            &self,
338            prefix: &Prefix,
339            _event_json: &[u8],
340        ) -> Result<SignedReceipt, WitnessError> {
341            let receipt = Receipt {
342                v: VersionString::placeholder(),
343                t: ReceiptTag,
344                d: self.said.clone(),
345                i: prefix.clone(),
346                s: KeriSequence::new(0),
347            };
348            let payload = serde_json::to_vec(&receipt)
349                .map_err(|e| WitnessError::Serialization(e.to_string()))?;
350            let signature = self.kp.sign(&payload).as_ref().to_vec();
351            Ok(SignedReceipt { receipt, signature })
352        }
353
354        async fn observe_identity_head(
355            &self,
356            _prefix: &Prefix,
357        ) -> Result<Option<EventHash>, WitnessError> {
358            Ok(None)
359        }
360
361        async fn get_receipt(
362            &self,
363            _prefix: &Prefix,
364            _event_said: &Said,
365        ) -> Result<Option<Receipt>, WitnessError> {
366            Ok(None)
367        }
368
369        fn quorum(&self) -> usize {
370            0
371        }
372    }
373
374    fn witness_pair(
375        kp: Arc<Ed25519KeyPair>,
376        aid: Prefix,
377        said: Said,
378    ) -> (Prefix, Arc<dyn AsyncWitnessProvider>) {
379        (
380            aid,
381            Arc::new(SigningWitness { kp, said }) as Arc<dyn AsyncWitnessProvider>,
382        )
383    }
384
385    /// An honest witness: signs the expected event with the key its AID pins.
386    fn honest() -> (Prefix, Arc<dyn AsyncWitnessProvider>) {
387        let (kp, aid) = keypair_and_aid();
388        witness_pair(kp, aid, event_said())
389    }
390
391    /// A witness pinned to one AID but signing with a *different* key (forged).
392    fn forged() -> (Prefix, Arc<dyn AsyncWitnessProvider>) {
393        let (signing_kp, _signing_aid) = keypair_and_aid();
394        let (_pinned_kp, pinned_aid) = keypair_and_aid();
395        witness_pair(signing_kp, pinned_aid, event_said())
396    }
397
398    #[tokio::test]
399    async fn verified_receipts_reach_quorum() {
400        let collector = ReceiptCollector::new(vec![honest(), honest(), honest()], 2, 5000);
401        let prefix = Prefix::new_unchecked("EPrefix".into());
402        let receipts = collector
403            .collect(&prefix, &event_said(), b"{}")
404            .await
405            .unwrap();
406
407        assert!(receipts.len() >= 2);
408        assert!(receipts.iter().all(|r| !r.witness.as_str().is_empty()));
409    }
410
411    #[tokio::test]
412    async fn forged_receipt_does_not_count() {
413        // Two honest + one forged; threshold 2 succeeds with only the honest two.
414        let collector = ReceiptCollector::new(vec![honest(), honest(), forged()], 2, 5000);
415        let prefix = Prefix::new_unchecked("EPrefix".into());
416        let receipts = collector
417            .collect(&prefix, &event_said(), b"{}")
418            .await
419            .unwrap();
420
421        assert_eq!(receipts.len(), 2, "forged receipt must be dropped");
422    }
423
424    #[tokio::test]
425    async fn forged_receipt_drops_below_threshold() {
426        // One honest + one forged, threshold 2: only 1 verifies → not met.
427        let collector = ReceiptCollector::new(vec![honest(), forged()], 2, 5000);
428        let prefix = Prefix::new_unchecked("EPrefix".into());
429        let result = collector.collect(&prefix, &event_said(), b"{}").await;
430
431        assert!(matches!(
432            result,
433            Err(CollectionError::ThresholdNotMet {
434                got: 1,
435                required: 2,
436                ..
437            })
438        ));
439    }
440
441    #[tokio::test]
442    async fn wrong_said_receipt_dropped() {
443        let (kp, aid) = keypair_and_aid();
444        let wrong = witness_pair(kp, aid, Said::new_unchecked(OTHER_SAID.to_string()));
445        let collector = ReceiptCollector::new(vec![honest(), wrong], 2, 5000);
446        let prefix = Prefix::new_unchecked("EPrefix".into());
447        let result = collector.collect(&prefix, &event_said(), b"{}").await;
448
449        assert!(matches!(
450            result,
451            Err(CollectionError::ThresholdNotMet { got: 1, .. })
452        ));
453    }
454
455    #[tokio::test]
456    async fn no_witnesses_error() {
457        let collector = ReceiptCollector::new(vec![], 1, 5000);
458        let prefix = Prefix::new_unchecked("EPrefix".into());
459        let result = collector.collect(&prefix, &event_said(), b"{}").await;
460
461        assert!(matches!(result, Err(CollectionError::NoWitnesses)));
462    }
463
464    #[tokio::test]
465    async fn builder_pattern() {
466        let (_, aid0) = keypair_and_aid();
467        let (_, aid1) = keypair_and_aid();
468        let collector = ReceiptCollectorBuilder::new()
469            .witness(aid0, Arc::new(NoOpAsyncWitness))
470            .witness(aid1, Arc::new(NoOpAsyncWitness))
471            .threshold(2)
472            .timeout_ms(1000)
473            .build();
474
475        assert_eq!(collector.witness_count(), 2);
476        assert_eq!(collector.threshold(), 2);
477    }
478
479    #[test]
480    fn collection_error_display() {
481        let err = CollectionError::ThresholdNotMet {
482            got: 1,
483            required: 2,
484            errors: vec![],
485        };
486        assert!(format!("{}", err).contains("threshold not met"));
487
488        let err = CollectionError::NoWitnesses;
489        assert!(format!("{}", err).contains("no witnesses"));
490    }
491}