Skip to main content

de_mls/
process_result.rs

1//! [`ProcessResult`] returned by [`decode_inbound_payload`](crate::decode_inbound_payload)
2//! plus protobuf ↔ message-envelope `From` impls.
3
4use hashgraph_like_consensus::{
5    protos::consensus::v1::{Proposal, Vote},
6    types::ConsensusEvent,
7};
8
9use crate::{
10    ConversationError, ScoreEvent, ScoreOp,
11    protos::de_mls::messages::v1::{
12        AppMessage, BanRequest, CommitCandidate, ConversationMessage, ConversationSync,
13        ConversationUpdateRequest, EmergencyCriteriaProposal, EventMembershipChange, MemberWelcome,
14        Outcome, ProposalAdded, UserVote, ViolationEvidence, ViolationType, VotePayload,
15        app_message, conversation_update_request,
16    },
17};
18
19/// Outcome of processing one inbound packet. The app layer matches this
20/// directly and dispatches the side effects.
21///
22/// Heavy protobuf payloads (`AppMessage`, `Proposal`, `Vote`,
23/// `ConversationUpdateRequest`, `ConversationSync` — each 88–144 bytes) are
24/// boxed so the enum stays small.
25#[derive(Debug, Clone)]
26pub enum ProcessResult {
27    /// Decrypted application message ready to deliver to the UI.
28    AppMessage(Box<AppMessage>),
29
30    /// Consensus proposal from a peer — forward to the consensus service.
31    Proposal(Box<Proposal>),
32
33    /// Consensus vote from a peer — forward to the consensus service.
34    Vote(Box<Vote>),
35
36    /// We were removed from the conversation.
37    LeaveConversation,
38
39    /// Steward received a membership change (invite KP / ban) — start a vote.
40    MembershipChangeReceived(Box<ConversationUpdateRequest>),
41
42    /// MLS state advanced (batch commit applied).
43    ConversationUpdated,
44
45    /// Remote commit candidate was buffered in the active freeze round.
46    CommitCandidateReceived { steward_id: Vec<u8> },
47
48    /// Conversation-sync message from the steward.
49    ConversationSyncReceived(Box<ConversationSync>),
50
51    /// Welcome broadcast from the committing steward: every member learns
52    /// the welcome so the application decides who delivers it to the
53    /// joiners and how.
54    WelcomeBroadcastReceived(Box<MemberWelcome>),
55
56    /// Nothing to do.
57    Noop(NoopReason),
58}
59
60/// Why a [`ProcessResult::Noop`] was returned. One variant per producer
61/// site so the dispatch layer can match on the specific case.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum NoopReason {
64    /// Decrypted application message had no recognized payload variant.
65    UnknownAppMessage,
66    /// Fast-path proposal rejected: MLS sender doesn't match the
67    /// self-removal target.
68    FastPathRejected,
69    /// Ban request dropped: target is not a conversation member.
70    BanTargetNotMember,
71    /// Decrypt returned `Ignored` (wrong epoch or wrong conversation).
72    DecryptIgnored,
73    /// Decrypt returned a non-Application MLS payload on the app subtopic.
74    UnexpectedMlsType,
75    /// No approved proposals to commit.
76    NoApprovedProposals,
77    /// Commit hash matches a recent committed batch — duplicate broadcast.
78    AlreadyCommitted,
79    /// Candidate carried an empty proposals or commit payload.
80    EmptyCandidatePayload,
81    /// Candidate carried an empty `steward_member_id` field.
82    EmptyStewardMemberId,
83    /// Candidate's wire kind doesn't match Proposal/Commit.
84    WireKindMismatch,
85    /// Identical commit hash is already buffered for this round.
86    DuplicateBufferedHash,
87    /// Freeze-round buffer is full (one candidate per member already held).
88    CandidateBufferFull,
89    /// Candidate arrived before its proposal was locally approved (consensus
90    /// outcome still in flight). Stashed for replay once approval lands.
91    CandidateStashedEarly,
92    /// Welcome broadcast carried no welcome bytes.
93    EmptyWelcomePayload,
94    /// Welcome broadcast hash was already seen — duplicate gossip delivery.
95    DuplicateWelcomeBroadcast,
96}
97
98// ── ViolationEvidence constructors ────────────────────────────────
99
100impl ViolationEvidence {
101    /// Steward included different proposal IDs than what was voted on,
102    /// or IDs match but content digest differs.
103    pub fn broken_commit(target: Vec<u8>, epoch: u64, payload: impl Into<Vec<u8>>) -> Self {
104        Self {
105            violation_type: ViolationType::BrokenCommit as i32,
106            target_member_id: target,
107            evidence_payload: payload.into(),
108            epoch,
109            creator_member_id: Vec::new(),
110        }
111    }
112
113    /// MLS payload count doesn't match proposal count,
114    /// or an MLS proposal failed to decrypt/store correctly.
115    pub fn broken_mls_proposal(target: Vec<u8>, epoch: u64, payload: impl Into<Vec<u8>>) -> Self {
116        Self {
117            violation_type: ViolationType::BrokenMlsProposal as i32,
118            target_member_id: target,
119            evidence_payload: payload.into(),
120            epoch,
121            creator_member_id: Vec::new(),
122        }
123    }
124
125    /// Member's peer score dropped to or below the removal threshold.
126    pub fn score_below_threshold(target: Vec<u8>, epoch: u64, current_score: i64) -> Self {
127        Self {
128            violation_type: ViolationType::ScoreBelowThreshold as i32,
129            target_member_id: target,
130            evidence_payload: current_score.to_le_bytes().to_vec(),
131            epoch,
132            creator_member_id: Vec::new(),
133        }
134    }
135
136    /// Layer 3 anti-deadlock signal — on YES the steward gate relaxes so
137    /// any member can produce the recovery commit. No specific target.
138    pub fn deadlock(epoch: u64) -> Self {
139        Self {
140            violation_type: ViolationType::Deadlock as i32,
141            target_member_id: Vec::new(),
142            evidence_payload: Vec::new(),
143            epoch,
144            creator_member_id: Vec::new(),
145        }
146    }
147
148    /// Set the creator member_id on this evidence (called by app layer before voting).
149    pub fn with_creator(mut self, creator: Vec<u8>) -> Self {
150        self.creator_member_id = creator;
151        self
152    }
153
154    /// Wrap this evidence into a `ConversationUpdateRequest` for consensus voting.
155    ///
156    /// Returns an error if `creator_member_id` is empty. Call `.with_creator()` before this
157    /// method — every ECP must carry the creator member_id for peer scoring (RFC §"Peer Scoring").
158    pub fn into_update_request(self) -> Result<ConversationUpdateRequest, ConversationError> {
159        if self.creator_member_id.is_empty() {
160            return Err(ConversationError::InvalidConversationUpdateRequest);
161        }
162        Ok(ConversationUpdateRequest {
163            payload: Some(conversation_update_request::Payload::EmergencyCriteria(
164                EmergencyCriteriaProposal {
165                    evidence: Some(self),
166                },
167            )),
168        })
169    }
170
171    /// Peer-score penalty the target takes for this violation, or `None`
172    /// for violation types that have no target-side score (`ScoreBelowThreshold`
173    /// drives a removal, not a penalty; `Deadlock` has no target;
174    /// `Unspecified` and unknown wire values are malformed).
175    pub fn target_score_event(&self) -> Option<ScoreEvent> {
176        match ViolationType::try_from(self.violation_type) {
177            Ok(ViolationType::BrokenCommit) => Some(ScoreEvent::BrokenCommit),
178            Ok(ViolationType::BrokenMlsProposal) => Some(ScoreEvent::BrokenMlsProposal),
179            Ok(ViolationType::CensorshipInactivity) => Some(ScoreEvent::CensorshipInactivity),
180            Ok(ViolationType::ScoreBelowThreshold)
181            | Ok(ViolationType::Deadlock)
182            | Ok(ViolationType::ViolationUnspecified)
183            | Err(_) => None,
184        }
185    }
186
187    /// `ScoreOp` applying [`Self::target_score_event`] to [`Self::target_member_id`].
188    /// `None` when the violation type carries no target-side score.
189    pub fn target_score_op(&self) -> Option<ScoreOp> {
190        Some(ScoreOp {
191            member_id: self.target_member_id.clone(),
192            event: self.target_score_event()?,
193        })
194    }
195}
196
197/// Build `impl From<Inner> for Envelope` where
198/// `Envelope { payload: Some(<variant path>(Inner)) }`.
199macro_rules! impl_payload_from {
200    ($envelope:ty, $( $inner:ty => $variant:path ),+ $(,)?) => {
201        $(
202            impl From<$inner> for $envelope {
203                fn from(value: $inner) -> Self {
204                    Self { payload: Some($variant(value)) }
205                }
206            }
207        )+
208    };
209}
210
211impl_payload_from!(
212    AppMessage,
213    VotePayload         => app_message::Payload::VotePayload,
214    UserVote            => app_message::Payload::UserVote,
215    ConversationMessage => app_message::Payload::ConversationMessage,
216    CommitCandidate     => app_message::Payload::CommitCandidate,
217    BanRequest          => app_message::Payload::BanRequest,
218    Proposal            => app_message::Payload::Proposal,
219    Vote                => app_message::Payload::Vote,
220    ConversationSync    => app_message::Payload::ConversationSync,
221    ProposalAdded       => app_message::Payload::ProposalAdded,
222    MemberWelcome       => app_message::Payload::MemberWelcome,
223    EventMembershipChange => app_message::Payload::MembershipChange,
224);
225
226impl From<ConsensusEvent> for Outcome {
227    fn from(ev: ConsensusEvent) -> Self {
228        match ev {
229            ConsensusEvent::ConsensusReached { result: true, .. } => Outcome::Accepted,
230            ConsensusEvent::ConsensusReached { result: false, .. } => Outcome::Rejected,
231            ConsensusEvent::ConsensusFailed { .. } => Outcome::Unspecified,
232        }
233    }
234}
235
236impl TryFrom<AppMessage> for ProcessResult {
237    type Error = ConversationError;
238    fn try_from(value: AppMessage) -> Result<Self, Self::Error> {
239        match &value.payload {
240            Some(app_message::Payload::ConversationMessage(_)) => {
241                Ok(ProcessResult::AppMessage(Box::new(value)))
242            }
243            Some(app_message::Payload::MembershipChange(_)) => {
244                Ok(ProcessResult::AppMessage(Box::new(value)))
245            }
246            Some(app_message::Payload::Proposal(proposal)) => {
247                Ok(ProcessResult::Proposal(Box::new(proposal.clone())))
248            }
249            Some(app_message::Payload::Vote(vote)) => {
250                Ok(ProcessResult::Vote(Box::new(vote.clone())))
251            }
252            Some(app_message::Payload::BanRequest(ban_request)) => {
253                Ok(ProcessResult::MembershipChangeReceived(Box::new(
254                    ConversationUpdateRequest::remove_member(ban_request.user_to_ban.clone()),
255                )))
256            }
257            Some(app_message::Payload::ConversationSync(sync)) => Ok(
258                ProcessResult::ConversationSyncReceived(Box::new(sync.clone())),
259            ),
260            other => {
261                tracing::debug!(
262                    payload_kind = ?other.as_ref().map(std::mem::discriminant),
263                    "app message ignored: payload variant not consumed by core dispatch"
264                );
265                Ok(ProcessResult::Noop(NoopReason::UnknownAppMessage))
266            }
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    /// `ViolationEvidence::broken_commit` plus `with_creator` plus
276    /// `into_update_request` produce an `EmergencyCriteria` payload that
277    /// preserves target, epoch, and violation type on the wire.
278    #[test]
279    fn broken_commit_evidence_roundtrips_into_update_request() {
280        let evidence = ViolationEvidence::broken_commit(vec![0xAA, 0xBB], 5, vec![0xDE, 0xAD])
281            .with_creator(vec![0x01]);
282        let request = evidence.into_update_request().unwrap();
283
284        let Some(conversation_update_request::Payload::EmergencyCriteria(ec)) = request.payload
285        else {
286            panic!("Expected EmergencyCriteria payload");
287        };
288        let ev = ec.evidence.expect("evidence present");
289        assert_eq!(ev.violation_type, ViolationType::BrokenCommit as i32);
290        assert_eq!(ev.target_member_id, vec![0xAA, 0xBB]);
291        assert_eq!(ev.epoch, 5);
292        assert_eq!(ev.evidence_payload, vec![0xDE, 0xAD]);
293        assert_eq!(ev.creator_member_id, vec![0x01]);
294    }
295
296    /// `into_update_request` rejects evidence with no creator id.
297    #[test]
298    fn into_update_request_errors_without_creator() {
299        let evidence = ViolationEvidence::broken_commit(vec![0xAA], 0, Vec::<u8>::new());
300        let err = evidence
301            .into_update_request()
302            .expect_err("creator required");
303        assert!(matches!(
304            err,
305            ConversationError::InvalidConversationUpdateRequest
306        ));
307    }
308}