Skip to main content

de_mls/consensus/
voting.rs

1//! Opening proposals and casting votes.
2//!
3//! Everything runs inline on the caller's thread: opening a proposal
4//! starts the consensus session immediately; the time-based follow-ups
5//! (auto-votes, consensus timeouts) are deadlines that `tick_deadlines`
6//! fires on a later poll.
7
8use std::error::Error as StdError;
9
10use hashgraph_like_consensus::{error::ConsensusError, storage::ConsensusStorage};
11use openmls_traits::signatures::Signer;
12use openmls_traits::{OpenMlsProvider, storage::StorageProvider};
13use tracing::info;
14
15use crate::{
16    ConsensusPlugin, Conversation, ConversationError, ConversationEvent, ConversationState,
17    PeerScoringPlugin, ProposalKind, StewardListPlugin, SyncConsensusReceiver,
18    consensus::bridge::{ProposalParams, cast_vote, submit_proposal, submit_self_leave_proposal},
19    mls_crypto::MlsService,
20    protos::de_mls::messages::v1::{AppMessage, ConversationUpdateRequest},
21    self_leave_proposal_id,
22};
23
24/// The creator's intent at proposal submit time.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CreatorVote {
27    /// Bundle a YES vote with the proposal in one atomic wire message; the
28    /// integrator gets `OwnProposalSubmitted`, no vote request. For actions
29    /// where submitting already expresses the vote (member add, ban,
30    /// self-executing protocol moves).
31    Yes,
32    /// Broadcast the proposal unbundled and treat the creator like any
33    /// other voter: `VoteRequested` plus the auto-vote timer. For steward
34    /// auto-propose paths, where the steward forwards peer intent without
35    /// endorsing it.
36    Deferred,
37}
38
39impl<C, Sc, St> Conversation<C, Sc, St>
40where
41    C: ConsensusPlugin,
42    Sc: PeerScoringPlugin,
43    St: StewardListPlugin,
44{
45    // ── Public API ───────────────────────────────────────────────────
46
47    /// Open a consensus vote for `request`; [`CreatorVote`] picks the wire
48    /// shape and the integrator event.
49    ///
50    /// Errors when the state machine forbids new proposals (freeze phases,
51    /// partial freeze during an active emergency). On success the proposal
52    /// is on the wire and a consensus-timeout deadline is armed for
53    /// `tick_deadlines` to fire.
54    ///
55    /// Local ownership is recorded before any vote is cast: with a single
56    /// expected voter the bundled YES resolves the session synchronously,
57    /// and the outcome handler must already see us as the owner by then.
58    pub fn initiate_proposal<Pr>(
59        &mut self,
60        provider: &Pr,
61        request: ConversationUpdateRequest,
62        creator_vote: CreatorVote,
63        signer: &impl Signer,
64    ) -> Result<(), ConversationError>
65    where
66        Pr: OpenMlsProvider,
67        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
68    {
69        let kind = ProposalKind::of(&request);
70        let expected_voters = self.check_proposal_allowed(kind)?;
71
72        let liveness_criteria_yes = self.config.liveness_criteria_yes;
73        let consensus_timeout = self.config.consensus_timeout;
74        let voting_delay = self.config.voting_delay_for(kind);
75
76        let (proposal_id, unbundled) = submit_proposal::<C>(
77            &self.conversation_id,
78            &request,
79            &self.self_member_id,
80            &self.services.consensus,
81            ProposalParams {
82                expected_voters,
83                proposal_expiration: self.config.proposal_expiration,
84                consensus_timeout,
85                liveness_criteria_yes,
86            },
87        )?;
88
89        self.queues
90            .insert_voting_proposal(proposal_id, request.clone());
91        if kind.is_emergency() {
92            self.queues.insert_emergency(proposal_id);
93        }
94        // Removed again by `apply_consensus_outcome` if an outcome lands
95        // before the deadline fires.
96        self.register_consensus_timeout(proposal_id, consensus_timeout);
97
98        match creator_vote {
99            CreatorVote::Yes => {
100                // Owner-bundling API, not the `cast_vote` helper: peers don't
101                // have the proposal yet, so a Vote-only message would be
102                // undeliverable.
103                let scope = C::Scope::from(self.conversation_id.clone());
104                let proposal = self.services.consensus.cast_vote_and_get_proposal(
105                    &scope,
106                    proposal_id,
107                    true,
108                )?;
109                info!(
110                    conversation = %self.conversation_id,
111                    proposal_id,
112                    actor = "owner",
113                    "YES vote cast (bundled at submit)"
114                );
115                let outbound: AppMessage = proposal.into();
116                let payload = self.mls_mut().build_message(provider, signer, &outbound)?;
117                self.broadcast(payload);
118                self.emit_event(ConversationEvent::OwnProposalSubmitted {
119                    proposal_id,
120                    request,
121                });
122            }
123            CreatorVote::Deferred => {
124                let payload = self.mls_mut().build_message(provider, signer, &unbundled)?;
125                self.broadcast(payload);
126                self.emit_event(ConversationEvent::VoteRequested {
127                    proposal_id,
128                    request,
129                });
130                self.register_auto_vote(proposal_id, voting_delay, liveness_criteria_yes);
131            }
132        }
133
134        Ok(())
135    }
136
137    /// Cast the local member's vote. Cancels the pending auto-vote so the
138    /// manual choice wins. Blocked while an epoch rotation is in flight
139    /// (`Freezing`/`Selection`) — the encrypted vote might not decrypt on
140    /// peers that already merged the next commit.
141    pub fn vote<Pr>(
142        &mut self,
143        provider: &Pr,
144        proposal_id: u32,
145        vote: bool,
146        signer: &impl Signer,
147    ) -> Result<(), ConversationError>
148    where
149        Pr: OpenMlsProvider,
150        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
151    {
152        let state = self.current_state();
153        if state == ConversationState::Freezing || state == ConversationState::Selection {
154            return Err(ConversationError::ConversationBlocked(state.to_string()));
155        }
156        self.cancel_auto_vote(proposal_id);
157        self.broadcast_vote(provider, proposal_id, vote, signer)
158    }
159
160    /// Fire elapsed auto-votes and consensus timeouts, then drain the
161    /// event bus into `apply_consensus_outcome`. Per-proposal errors are
162    /// logged and skipped so one stuck proposal can't block the rest.
163    pub(crate) fn tick_deadlines<Pr>(&mut self, provider: &Pr, signer: &impl Signer)
164    where
165        Pr: OpenMlsProvider,
166        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
167    {
168        let now = std::time::Instant::now();
169        let auto_votes_due: Vec<(u32, bool)> = self
170            .timing
171            .pending_auto_votes
172            .iter()
173            .filter(|(_, e)| e.fire_at <= now)
174            .map(|(id, e)| (*id, e.vote))
175            .collect();
176        for (id, _) in &auto_votes_due {
177            self.timing.pending_auto_votes.remove(id);
178        }
179        let timeouts_due: Vec<u32> = self
180            .timing
181            .pending_consensus_timeouts
182            .iter()
183            .filter(|(_, fire_at)| **fire_at <= now)
184            .map(|(id, _)| *id)
185            .collect();
186        for id in &timeouts_due {
187            self.timing.pending_consensus_timeouts.remove(id);
188        }
189
190        for (proposal_id, vote) in auto_votes_due {
191            if let Err(e) = self.broadcast_vote(provider, proposal_id, vote, signer) {
192                tracing::debug!(
193                    proposal_id,
194                    error = %e,
195                    "auto-vote skipped (already voted or session resolved)"
196                );
197            }
198        }
199        for proposal_id in timeouts_due {
200            self.resolve_on_timeout(proposal_id);
201        }
202
203        loop {
204            // The bus is private to this conversation's service, so the
205            // scope on each event is always ours — drained, not matched.
206            let Some((_scope, event)) =
207                <_ as SyncConsensusReceiver<_>>::try_recv(&mut self.services.consensus_rx)
208            else {
209                break;
210            };
211            if let Err(e) = self.apply_consensus_outcome(provider, event, signer) {
212                tracing::warn!(
213                    conversation = %self.conversation_id,
214                    error = %e,
215                    "apply_consensus_outcome failed"
216                );
217            }
218        }
219    }
220
221    // ── Crate-internal ───────────────────────────────────────────────
222
223    /// Open a self-leave round: `RemoveMember(self)` with one expected
224    /// voter and the leaver's YES bundled, so it resolves synchronously and
225    /// lands in `approved_proposals` for the next steward commit.
226    ///
227    /// Safe to repeat: the pending-leave check catches local duplicates,
228    /// and the deterministic [`self_leave_proposal_id`] dedupes
229    /// retransmits inside the consensus library.
230    pub(crate) fn initiate_self_leave<Pr>(
231        &mut self,
232        provider: &Pr,
233        signer: &impl Signer,
234    ) -> Result<(), ConversationError>
235    where
236        Pr: OpenMlsProvider,
237        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
238    {
239        if self.queues.is_pending_self_leave(&self.self_member_id) {
240            info!(
241                conversation = %self.conversation_id,
242                "self-leave already in flight, ignoring duplicate"
243            );
244            return Ok(());
245        }
246
247        let request = ConversationUpdateRequest::remove_member(self.self_member_id.to_vec());
248        let proposal_id = self_leave_proposal_id(&self.self_member_id);
249
250        // Ownership must be recorded before the session opens: the bundled
251        // YES fires `ConsensusReached` synchronously, and the outcome
252        // handler needs `is_owner_of_proposal` to already be true.
253        self.queues
254            .insert_voting_proposal(proposal_id, request.clone());
255
256        let submitted = submit_self_leave_proposal::<C>(
257            &self.conversation_id,
258            &self.self_member_id,
259            &self.services.consensus,
260            ProposalParams {
261                expected_voters: 1,
262                proposal_expiration: self.config.proposal_expiration,
263                consensus_timeout: self.config.consensus_timeout,
264                liveness_criteria_yes: true,
265            },
266        )?;
267
268        // `None`: an earlier submit is already driving this proposal_id;
269        // our voting entry resolves on that session.
270        let Some((_proposal_id, app_msg)) = submitted else {
271            return Ok(());
272        };
273
274        let payload = self.mls_mut().build_message(provider, signer, &app_msg)?;
275        self.broadcast(payload);
276        Ok(())
277    }
278
279    // ── Private ──────────────────────────────────────────────────────
280
281    /// Gate a new proposal on the current state and return the expected
282    /// voter count (the full member set). During `Reelection` only
283    /// emergency and election proposals pass; an active emergency
284    /// partial-freezes everything below its priority.
285    fn check_proposal_allowed(&self, kind: ProposalKind) -> Result<u32, ConversationError> {
286        let state = self.current_state();
287
288        match state {
289            ConversationState::Reelection => {
290                if !kind.is_emergency() && !kind.is_steward_election() {
291                    return Err(ConversationError::ConversationBlocked(state.to_string()));
292                }
293                if self.queues.partial_freeze_blocks(kind) {
294                    return Err(ConversationError::PartialFreeze);
295                }
296            }
297            ConversationState::Freezing | ConversationState::Selection => {
298                return Err(ConversationError::ConversationBlocked(state.to_string()));
299            }
300            _ => {
301                if self.queues.partial_freeze_blocks(kind) {
302                    return Err(ConversationError::PartialFreeze);
303                }
304            }
305        }
306
307        let members = self.mls().members()?;
308        Ok(members.len() as u32)
309    }
310
311    /// Push a proposal whose deadline elapsed into the consensus library's
312    /// timeout resolution. `SessionNotFound`/`SessionNotActive` despite the
313    /// `still_active` guard means the session resolved in between — benign
314    /// when the proposal is in the resolved cache, a logic bug worth a
315    /// warning when it isn't.
316    fn resolve_on_timeout(&self, proposal_id: u32) {
317        let scope = C::Scope::from(self.conversation_id.clone());
318        let still_active = self
319            .services
320            .consensus
321            .storage()
322            .get_active_proposals(&scope)
323            .map(|active| active.iter().any(|p| p.proposal_id == proposal_id))
324            .unwrap_or(false);
325        if !still_active {
326            return;
327        }
328        match self
329            .services
330            .consensus
331            .handle_consensus_timeout(&scope, proposal_id)
332        {
333            Ok(_) => {}
334            Err(ConsensusError::SessionNotFound) | Err(ConsensusError::SessionNotActive) => {
335                let resolved_locally = self.queues.is_consensus_outcome_applied(proposal_id);
336                if resolved_locally {
337                    tracing::debug!(
338                        conversation = %self.conversation_id,
339                        proposal_id,
340                        "timeout fired for already-resolved proposal: ignoring"
341                    );
342                } else {
343                    tracing::warn!(
344                        conversation = %self.conversation_id,
345                        proposal_id,
346                        "timeout fired for unknown proposal id: no session and not in resolved cache"
347                    );
348                }
349            }
350            Err(e) => {
351                info!(proposal_id, error = %e, "timeout resolution skipped");
352            }
353        }
354    }
355
356    /// Cast a vote in the local session, encrypt the Vote-only wire
357    /// message, and buffer it for broadcast. Manual votes and the auto-vote
358    /// timer share this path; the consensus library can't tell them apart.
359    fn broadcast_vote<Pr>(
360        &mut self,
361        provider: &Pr,
362        proposal_id: u32,
363        vote: bool,
364        signer: &impl Signer,
365    ) -> Result<(), ConversationError>
366    where
367        Pr: OpenMlsProvider,
368        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
369    {
370        let app_message = cast_vote::<C>(
371            &self.conversation_id,
372            proposal_id,
373            vote,
374            &self.services.consensus,
375        )?;
376        let payload = self
377            .mls_mut()
378            .build_message(provider, signer, &app_message)?;
379        self.broadcast(payload);
380        Ok(())
381    }
382}