Skip to main content

confium_coordinator/
round_coordinator.rs

1//! Multi-round signing state machine.
2//!
3//! CMP20 and GG18 are multi-round protocols:
4//!
5//! - **CMP20**: Round 1 (nonce commitment) → Round 2 (MtA) → Round 3 (partial sig)
6//! - **GG18**: Round 1 → Round 2 → Round 3 → Round 4
7//!
8//! The [`RoundCoordinator`] tracks which signers have responded in
9//! each round and advances when the threshold is met.
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use std::collections::HashSet;
14
15/// Which round the protocol is in.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum SigningRound {
19    /// Round 1: nonce commitment.
20    Round1,
21    /// Round 2: MtA exchange (CMP20) or nonce reveal (FROST).
22    Round2,
23    /// Round 3: partial signature.
24    Round3,
25    /// Round 4: GG18 final combine.
26    Round4,
27    /// Protocol completed.
28    Completed,
29    /// Protocol aborted.
30    Aborted,
31}
32
33impl SigningRound {
34    /// Advance to the next round.
35    pub fn next(&self) -> Option<Self> {
36        match self {
37            Self::Round1 => Some(Self::Round2),
38            Self::Round2 => Some(Self::Round3),
39            Self::Round3 => Some(Self::Round4),
40            Self::Round4 => Some(Self::Completed),
41            Self::Completed | Self::Aborted => None,
42        }
43    }
44
45    /// 1-based round number.
46    pub fn number(&self) -> u32 {
47        match self {
48            Self::Round1 => 1,
49            Self::Round2 => 2,
50            Self::Round3 => 3,
51            Self::Round4 => 4,
52            Self::Completed => 0,
53            Self::Aborted => 0,
54        }
55    }
56
57    /// Is this a terminal state?
58    pub fn is_terminal(&self) -> bool {
59        matches!(self, Self::Completed | Self::Aborted)
60    }
61}
62
63/// State for a single round: which signers have responded.
64#[derive(Debug, Clone)]
65pub struct RoundState {
66    /// The current round.
67    pub round: SigningRound,
68    /// Signers who have responded in this round.
69    pub responded: HashSet<String>,
70    /// When this round started.
71    pub started_at: DateTime<Utc>,
72    /// Round data collected so far (opaque bytes per signer).
73    pub round_data: Vec<(String, Vec<u8>)>,
74}
75
76impl RoundState {
77    fn new(round: SigningRound) -> Self {
78        Self {
79            round,
80            responded: HashSet::new(),
81            started_at: Utc::now(),
82            round_data: Vec::new(),
83        }
84    }
85}
86
87/// Errors during round coordination.
88#[derive(Debug, thiserror::Error)]
89pub enum RoundError {
90    /// Signer submitted in the wrong round.
91    #[error(
92        "signer {signer} submitted in round {:?}, expected {:?}",
93        actual,
94        expected
95    )]
96    WrongRound {
97        /// Signer ID.
98        signer: String,
99        /// Actual round.
100        actual: SigningRound,
101        /// Expected round.
102        expected: SigningRound,
103    },
104    /// Duplicate submission in the same round.
105    #[error("signer {0} already responded in this round")]
106    DuplicateResponse(String),
107    /// Protocol already completed.
108    #[error("protocol already completed")]
109    AlreadyCompleted,
110    /// Protocol was aborted.
111    #[error("protocol aborted: {0}")]
112    Aborted(String),
113    /// Not enough signers to advance.
114    #[error("not enough responses: {have}/{need}")]
115    InsufficientResponses { have: usize, need: usize },
116}
117
118/// The multi-round coordinator. Tracks protocol progress across
119/// rounds and enforces the threshold requirement per round.
120pub struct RoundCoordinator {
121    threshold: u32,
122    party_count: u32,
123    current: RoundState,
124    history: Vec<RoundState>,
125}
126
127impl RoundCoordinator {
128    /// Create a new coordinator for a T-of-N protocol.
129    pub fn new(threshold: u32, party_count: u32) -> Self {
130        Self {
131            threshold,
132            party_count,
133            current: RoundState::new(SigningRound::Round1),
134            history: Vec::new(),
135        }
136    }
137
138    /// Current round.
139    pub fn current_round(&self) -> SigningRound {
140        self.current.round
141    }
142
143    /// Number of signers who responded in the current round.
144    pub fn response_count(&self) -> usize {
145        self.current.responded.len()
146    }
147
148    /// Has this signer responded in the current round?
149    pub fn has_responded(&self, signer_id: &str) -> bool {
150        self.current.responded.contains(signer_id)
151    }
152
153    /// Submit a response for the current round. If the threshold is
154    /// met, advances to the next round automatically.
155    pub fn submit(
156        &mut self,
157        signer_id: &str,
158        data: Vec<u8>,
159    ) -> Result<Option<SigningRound>, RoundError> {
160        if self.current.round.is_terminal() {
161            return Err(RoundError::AlreadyCompleted);
162        }
163        if self.current.responded.contains(signer_id) {
164            return Err(RoundError::DuplicateResponse(signer_id.into()));
165        }
166        self.current.responded.insert(signer_id.into());
167        self.current.round_data.push((signer_id.into(), data));
168
169        if self.current.responded.len() >= self.threshold as usize {
170            let next = self
171                .current
172                .round
173                .next()
174                .ok_or(RoundError::AlreadyCompleted)?;
175            let old = std::mem::replace(&mut self.current, RoundState::new(next));
176            self.history.push(old);
177            return Ok(Some(next));
178        }
179        Ok(None)
180    }
181
182    /// Collect all round data from a completed round.
183    pub fn round_data(&self, round: SigningRound) -> Vec<(String, Vec<u8>)> {
184        if round == self.current.round {
185            return self.current.round_data.clone();
186        }
187        self.history
188            .iter()
189            .find(|s| s.round == round)
190            .map(|s| s.round_data.clone())
191            .unwrap_or_default()
192    }
193
194    /// Number of rounds completed.
195    pub fn rounds_completed(&self) -> usize {
196        self.history.len()
197    }
198
199    /// Abort the protocol.
200    pub fn abort(&mut self, reason: &str) {
201        self.current = RoundState::new(SigningRound::Aborted);
202        let _ = reason;
203    }
204
205    /// Force-advance to the next round (admin/debug only). Does not
206    /// check threshold.
207    pub fn force_advance(&mut self) -> Option<SigningRound> {
208        let next = self.current.round.next()?;
209        let old = std::mem::replace(&mut self.current, RoundState::new(next));
210        self.history.push(old);
211        Some(next)
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn starts_in_round_1() {
221        let rc = RoundCoordinator::new(2, 3);
222        assert_eq!(rc.current_round(), SigningRound::Round1);
223    }
224
225    #[test]
226    fn submit_advances_at_threshold() {
227        let mut rc = RoundCoordinator::new(2, 3);
228        assert!(rc.submit("alice", vec![1]).unwrap().is_none());
229        assert_eq!(rc.current_round(), SigningRound::Round1);
230        let advanced = rc.submit("bob", vec![2]).unwrap();
231        assert_eq!(advanced, Some(SigningRound::Round2));
232        assert_eq!(rc.current_round(), SigningRound::Round2);
233    }
234
235    #[test]
236    fn duplicate_response_rejected() {
237        let mut rc = RoundCoordinator::new(2, 3);
238        rc.submit("alice", vec![1]).unwrap();
239        assert!(matches!(
240            rc.submit("alice", vec![2]),
241            Err(RoundError::DuplicateResponse(_))
242        ));
243    }
244
245    #[test]
246    fn full_protocol_progression() {
247        let mut rc = RoundCoordinator::new(2, 3);
248        for round in [
249            SigningRound::Round1,
250            SigningRound::Round2,
251            SigningRound::Round3,
252        ] {
253            assert_eq!(rc.current_round(), round);
254            rc.submit("alice", vec![0xAA]).unwrap();
255            rc.submit("bob", vec![0xBB]).unwrap();
256        }
257        assert_eq!(rc.current_round(), SigningRound::Round4);
258        rc.submit("alice", vec![]).unwrap();
259        rc.submit("bob", vec![]).unwrap();
260        assert_eq!(rc.current_round(), SigningRound::Completed);
261    }
262
263    #[test]
264    fn completed_rejects_submissions() {
265        let mut rc = RoundCoordinator::new(1, 1);
266        rc.submit("alice", vec![]).unwrap();
267        assert_eq!(rc.current_round(), SigningRound::Round2);
268        rc.force_advance();
269        rc.force_advance();
270        rc.force_advance();
271        assert_eq!(rc.current_round(), SigningRound::Completed);
272        assert!(rc.submit("alice", vec![]).is_err());
273    }
274
275    #[test]
276    fn round_data_collected() {
277        let mut rc = RoundCoordinator::new(2, 3);
278        rc.submit("alice", vec![0x11]).unwrap();
279        rc.submit("bob", vec![0x22]).unwrap();
280        let r1_data = rc.round_data(SigningRound::Round1);
281        assert_eq!(r1_data.len(), 2);
282    }
283
284    #[test]
285    fn force_advance_skips_threshold() {
286        let mut rc = RoundCoordinator::new(3, 5);
287        rc.submit("alice", vec![]).unwrap();
288        rc.force_advance();
289        assert_eq!(rc.current_round(), SigningRound::Round2);
290    }
291
292    #[test]
293    fn rounds_completed_tracks_history() {
294        let mut rc = RoundCoordinator::new(2, 3);
295        assert_eq!(rc.rounds_completed(), 0);
296        rc.submit("a", vec![]).unwrap();
297        rc.submit("b", vec![]).unwrap();
298        assert_eq!(rc.rounds_completed(), 1);
299    }
300
301    #[test]
302    fn abort_sets_terminal_state() {
303        let mut rc = RoundCoordinator::new(2, 3);
304        rc.abort("test failure");
305        assert_eq!(rc.current_round(), SigningRound::Aborted);
306        assert!(rc.current_round().is_terminal());
307    }
308
309    #[test]
310    fn round_number_correct() {
311        assert_eq!(SigningRound::Round1.number(), 1);
312        assert_eq!(SigningRound::Round4.number(), 4);
313        assert_eq!(SigningRound::Completed.number(), 0);
314    }
315
316    #[test]
317    fn terminal_states_have_no_next() {
318        assert!(SigningRound::Completed.next().is_none());
319        assert!(SigningRound::Aborted.next().is_none());
320    }
321
322    #[test]
323    fn has_responded_tracks_signers() {
324        let mut rc = RoundCoordinator::new(2, 3);
325        rc.submit("alice", vec![]).unwrap();
326        assert!(rc.has_responded("alice"));
327        assert!(!rc.has_responded("bob"));
328    }
329}