Skip to main content

xmtp_mls/groups/
mls_sync.rs

1use super::{
2    FailedInstallationIds, GroupError, HmacKey, MlsGroup,
3    change_callbacks::AppDataChange,
4    intents::{
5        CommitPendingProposalsIntentData, Installation, IntentError, PostCommitAction,
6        ProposeMemberUpdateIntentData, SendMessageIntentData, SendWelcomesAction,
7        UpdateAdminListIntentData, UpdateGroupMembershipIntentData, UpdatePermissionIntentData,
8    },
9    summary::{MessageIdentifier, MessageIdentifierBuilder, ProcessSummary, SyncSummary},
10    validated_commit::{CommitValidationError, validate_proposal},
11};
12use crate::{
13    client::ClientError,
14    context::XmtpSharedContext,
15    groups::{
16        group_membership::GroupMembership,
17        intents::MembershipDiffWithKeyPackages,
18        intents::{QueueIntent, ReaddInstallationsIntentData, UpdateMetadataIntentData},
19        mls_ext::{CommitLogStorer, MlsGroupReload},
20        mls_sync::update_group_membership::apply_readd_installations_intent,
21        validated_commit::ValidatedCommit,
22    },
23    identity::{IdentityError, parse_credential},
24    identity_updates::{IdentityUpdates, load_identity_updates_for_client},
25    intents::ProcessIntentError,
26    messages::{decoded_message::MessageBody, enrichment::EnrichMessageError},
27    mls_store::MlsStore,
28    traits::IntoWith,
29    utils::{
30        hash::sha256,
31        id::{calculate_message_id, calculate_message_id_for_intent},
32        time::hmac_epoch,
33    },
34};
35use hkdf::Hkdf;
36use hmac::{Hmac, Mac};
37use openmls::prelude::BasicCredentialError;
38use openmls::{
39    credentials::BasicCredential,
40    framing::ProtocolMessage,
41    group::{
42        CommitToPendingProposalsError, GroupContext, GroupEpoch, ProcessMessageError, StagedCommit,
43        ValidationError,
44    },
45    key_packages::KeyPackage,
46    messages::proposals::Proposal,
47    prelude::{
48        Extensions, LeafNodeIndex, MlsGroup as OpenMlsGroup, ProcessedMessage,
49        ProcessedMessageContent, ProposalType, Sender,
50        tls_codec::{Error as TlsCodecError, Serialize},
51    },
52    treesync::LeafNodeParameters,
53};
54use openmls_traits::OpenMlsProvider;
55use prost::Message;
56use prost::bytes::Bytes;
57use sha2::Sha256;
58use std::{
59    collections::{BTreeSet, HashMap, HashSet},
60    mem::{Discriminant, discriminant},
61    ops::RangeInclusive,
62    time::Duration,
63};
64use thiserror::Error;
65use tracing::debug;
66use update_group_membership::apply_update_group_membership_intent;
67use xmtp_api::PublishUnit;
68use xmtp_common::{Event, ExponentialBackoff, RetryableError, Strategy, log_event, time::now_ns};
69use xmtp_configuration::{
70    HMAC_SALT, MAX_GROUP_SYNC_RETRIES, MAX_PAST_EPOCHS, SYNC_BACKOFF_TOTAL_WAIT_MAX_SECS,
71    SYNC_BACKOFF_WAIT_MS, SYNC_JITTER_MS, SYNC_UPDATE_INSTALLATIONS_INTERVAL_NS,
72    WELCOME_HPKE_LABEL,
73};
74use xmtp_content_types::{CodecError, ContentCodec, group_updated::GroupUpdatedCodec};
75use xmtp_db::TransactionOutcome::{Continue, Rollback};
76use xmtp_db::XmtpMlsStorageProvider;
77use xmtp_db::group::GroupMembershipState;
78use xmtp_db::message_deletion::{QueryMessageDeletion, StoredMessageDeletion};
79use xmtp_db::{
80    Fetch, StorageError, StoreOrIgnore, TransactionOutcome,
81    group::ConversationType,
82    group_intent::{ID, IntentKind, IntentState, StoredGroupIntent},
83    group_message::{ContentType, DeliveryStatus, GroupMessageKind, StoredGroupMessage},
84    remote_commit_log::CommitResult,
85    sql_key_store,
86    user_preferences::StoredUserPreferences,
87};
88use xmtp_db::{NotFound, group_intent::IntentKind::MetadataUpdate};
89use xmtp_db::{XmtpOpenMlsProviderRef, prelude::*};
90use xmtp_db::{
91    group_message::MsgQueryArgs,
92    pending_remove::{PendingRemove, QueryPendingRemove},
93};
94use xmtp_events::{ClientEvent, EventContext, EventWriter};
95use xmtp_id::{InboxId, InboxIdRef};
96use xmtp_mls_common::app_data::component_id::ComponentId;
97use xmtp_mls_common::group_mutable_metadata::MetadataField;
98use xmtp_mls_common::libxmtp_version::LibXMTPVersion;
99use xmtp_mls_common::mls_ext::payload_encryption::{
100    WrapPayloadError, wrap_payload_hpke, wrap_payload_symmetric,
101};
102use xmtp_mls_validation::commit::{
103    CommitRuleError, Inbox, MetadataChanges, extract_group_membership,
104};
105use xmtp_proto::backend_v1::{
106    ClientEnvelope, GroupMessage as BackendGroupMessage, WelcomeMessage as WelcomeMessageInput,
107    client_envelope::Payload,
108    welcome_message::{
109        V1 as WelcomeMessageInputV1, Version as WelcomeMessageInputVersion,
110        WelcomePointer as WelcomePointerInput,
111    },
112};
113use xmtp_proto::types::GroupId;
114use xmtp_proto::xmtp::mls::message_contents::content_types::DeleteMessage;
115use xmtp_proto::xmtp::mls::{
116    database::{ProcessPendingSelfRemove, Task as TaskProto, task::Task as TaskKind},
117    message_contents::{
118        GroupUpdated, PlaintextEnvelope, WelcomeMetadata, WelcomePointer as WelcomePointerProto,
119        group_updated,
120        plaintext_envelope::{Content, V1, V2},
121    },
122};
123use xmtp_proto::{
124    GroupUpdateDeduper,
125    types::{Cursor, GroupMessage},
126};
127use xmtp_proto::{ShortHex, xmtp::mls::message_contents::EncodedContent};
128use zeroize::Zeroizing;
129
130mod envelope_and_app_data;
131mod external_message;
132mod leave_and_delete;
133mod membership_helpers;
134mod own_message;
135mod post_commit;
136mod processing;
137mod receive;
138mod sync_driver;
139pub(in crate::groups) use membership_helpers::*;
140mod processing_policy;
141pub mod update_group_membership;
142pub(crate) use processing::GroupHeadOutcome;
143pub(crate) mod publish;
144
145impl<Context: XmtpSharedContext> MlsGroup<Context> {
146    fn metadata_changed_event(&self, commit: &ValidatedCommit) -> Option<ClientEvent> {
147        let changed: BTreeSet<_> = commit
148            .metadata_component_ids
149            .iter()
150            .copied()
151            .map(ComponentId::from)
152            .map(ComponentId::event_name)
153            .collect();
154        (!changed.is_empty()).then(|| {
155            ClientEvent::ConversationMetadataChanged(xmtp_events::MetadataChanged {
156                group_id: self.group_id.to_vec(),
157                changed: changed.into_iter().collect(),
158            })
159        })
160    }
161
162    pub(in crate::groups) fn emit_message_status_changed(
163        &self,
164        message_id: Vec<u8>,
165        previous: xmtp_events::MessageStatus,
166        current: xmtp_events::MessageStatus,
167        writer: &impl EventWriter<crate::subscriptions::internal::InternalEvent>,
168    ) {
169        if self.conversation_type.is_virtual() || previous == current {
170            return;
171        }
172        writer.emit_with_context(
173            Some(ClientEvent::MessageStatusChanged(
174                xmtp_events::MessageStatusChanged {
175                    group_id: self.group_id.to_vec(),
176                    message_id,
177                    previous,
178                    current,
179                },
180            )),
181            None,
182            EventContext {
183                dm_identifier: self.dm_id.as_ref().map(|id| id.as_bytes().to_vec()),
184                references_own_messages: false,
185                ..Default::default()
186            },
187        );
188    }
189
190    /// Queue the public changes of one applied commit in kind-table order.
191    fn emit_commit_events(
192        &self,
193        commit: &ValidatedCommit,
194        group_active: bool,
195        storage: &impl XmtpMlsStorageProvider,
196        writer: &impl EventWriter<crate::subscriptions::internal::InternalEvent>,
197    ) -> Result<(), GroupMessageProcessingError> {
198        if self.conversation_type.is_virtual() {
199            return Ok(());
200        }
201        let context = EventContext {
202            dm_identifier: self.dm_id.as_ref().map(|id| id.as_bytes().to_vec()),
203            references_own_messages: false,
204            ..Default::default()
205        };
206        if !group_active {
207            let left = storage
208                .db()
209                .get_pending_remove_users(&self.group_id)?
210                .contains(&self.context.inbox_id().to_string());
211            writer.emit_with_context(
212                Some(ClientEvent::ConversationRemoved(
213                    xmtp_events::ConversationRemoved {
214                        group_id: self.group_id.to_vec(),
215                        cause: if left {
216                            xmtp_events::RemovalCause::Left
217                        } else {
218                            xmtp_events::RemovalCause::Removed
219                        },
220                    },
221                )),
222                None,
223                context.clone(),
224            );
225        }
226        if !commit.added_inboxes.is_empty() || !commit.removed_inboxes.is_empty() {
227            writer.emit_with_context(
228                Some(ClientEvent::ConversationMembershipChanged(
229                    xmtp_events::MembershipChanged {
230                        group_id: self.group_id.to_vec(),
231                        added_inbox_ids: commit
232                            .added_inboxes
233                            .iter()
234                            .map(|inbox| inbox.inbox_id.clone())
235                            .collect(),
236                        removed_inbox_ids: commit
237                            .removed_inboxes
238                            .iter()
239                            .map(|inbox| inbox.inbox_id.clone())
240                            .collect(),
241                    },
242                )),
243                None,
244                context.clone(),
245            );
246        }
247        if let Some(event) = self.metadata_changed_event(commit) {
248            writer.emit_with_context(Some(event), None, context);
249        }
250        Ok(())
251    }
252}
253
254#[derive(Debug, Error)]
255pub enum GroupMessageProcessingError {
256    #[error("intent already processed")]
257    IntentAlreadyProcessed,
258    #[error("message with cursor [{}] for group [{}] already processed", _0.cursor, xmtp_common::fmt::debug_hex(_0.group_id)
259    )]
260    MessageAlreadyProcessed(MessageIdentifier),
261    #[error("message identifier not found")]
262    MessageIdentifierNotFound,
263    #[error("welcome with cursor [{0}] already processed")]
264    WelcomeAlreadyProcessed(u64),
265    #[error("[{message_time_ns:?}] invalid sender with credential: {credential:?}")]
266    InvalidSender {
267        message_time_ns: u64,
268        credential: Vec<u8>,
269    },
270    #[error("invalid payload")]
271    InvalidPayload,
272    /// The received prefix changed before this attempt acquired the writer.
273    #[error("incoming group head changed")]
274    IncomingHeadChanged,
275    /// The local pending record cannot be decoded. Processing must stop.
276    #[error("stored incoming envelope is corrupt: {0}")]
277    CorruptIncomingEnvelope(prost::DecodeError),
278    /// The MLS wire version is not supported. Keep this envelope pending.
279    #[error("unsupported MLS wire version")]
280    UnsupportedMlsVersion,
281    /// A commit names a proposal the ordered prefix delivered, but the local
282    /// proposal store no longer holds it. This is lost local state, not
283    /// evidence that the commit is invalid, so the head is held.
284    #[error("a committed proposal was received but is missing from local state")]
285    LostProposalState,
286    /// Supported envelope framing fails validation after its complete prefix.
287    #[error(transparent)]
288    Envelope(xmtp_api_backend::envelope::EnvelopeError),
289    /// Own ciphertext has no durable prepared attempt.
290    #[error("own envelope has no prepared attempt")]
291    OwnMessageWithoutAttempt,
292    /// Our own echo names an intent whose kind a newer build wrote and this
293    /// one cannot decode.
294    ///
295    /// This holds the head rather than rejecting it. The envelope may be a
296    /// commit every other member applied; skipping it would leave this
297    /// installation behind the group with no way back. It is also not an
298    /// external message — treating it as one would validate a commit we
299    /// authored against external-actor rules. Not retryable, so the head is
300    /// marked blocked and reconsidered once a build that can read the kind
301    /// runs again.
302    #[error("own intent kind for payload {0} is not supported by this version")]
303    UnsupportedOwnIntentKind(String),
304    /// A local prepared attempt cannot safely explain this own envelope.
305    #[error("prepared attempt state: {0}")]
306    PreparedAttempt(#[source] Box<GroupError>),
307    /// A stored terminal rejection whose original parameters were not retained.
308    #[error("intent rejected: {0}")]
309    RejectedIntent(&'static str),
310    #[error("storage error: {0}")]
311    Storage(#[from] xmtp_db::StorageError),
312    #[error(transparent)]
313    Identity(#[from] IdentityError),
314    #[error("openmls process message error: {0}")]
315    OpenMlsProcessMessage(
316        #[from] openmls::prelude::ProcessMessageError<sql_key_store::SqlKeyStoreError>,
317    ),
318    /// AppDataUpdate-aware processing wrapper error.
319    ///
320    /// Wraps the same `ProcessMessageError` as the variant above, plus the
321    /// `ComponentSourceError` that fires when an incoming `AppDataUpdate`
322    /// payload can't be decoded under our wire format. Kept distinct from
323    /// `OpenMlsProcessMessage` so the AppData-decode failure mode is
324    /// greppable in logs.
325    #[error("app-data process message error: {0}")]
326    OpenMlsProcessMessageWithAppData(
327        #[from] super::app_data::ProcessMessageWithAppDataError<sql_key_store::SqlKeyStoreError>,
328    ),
329    #[error("merge staged commit: {0}")]
330    MergeStagedCommit(#[from] openmls::group::MergeCommitError<sql_key_store::SqlKeyStoreError>),
331    #[error("TLS Codec error: {0}")]
332    TlsError(#[from] TlsCodecError),
333    #[error("unsupported message type: {0:?}")]
334    UnsupportedMessageType(Discriminant<ProtocolMessage>),
335    /// Processed-message content that cannot legitimately reach the apply
336    /// phase: `UnresolvedAppDataCommit` is always resolved inside
337    /// `process_message_with_app_data`, and `OwnPendingCommit` is only
338    /// produced for public-framed commits, which libxmtp's
339    /// pure-ciphertext wire format policy never emits.
340    #[error("unexpected processed message content: {0}")]
341    UnexpectedProcessedContent(&'static str),
342    #[error("commit validation: {0}")]
343    CommitValidation(#[from] CommitValidationError),
344    #[error("epoch increment not allowed")]
345    EpochIncrementNotAllowed,
346    #[error("clear pending commit error: {0}")]
347    ClearPendingCommit(#[from] sql_key_store::SqlKeyStoreError),
348    #[error("Serialization/Deserialization Error {0}")]
349    Serde(#[from] serde_json::Error),
350    #[error("intent is missing staged_commit field")]
351    IntentMissingStagedCommit,
352    #[error("encode proto: {0}")]
353    EncodeProto(#[from] prost::EncodeError),
354    #[error("proto decode error: {0}")]
355    DecodeProto(#[from] prost::DecodeError),
356    #[error(transparent)]
357    Intent(#[from] IntentError),
358    #[error(transparent)]
359    Codec(#[from] CodecError),
360    #[error("wrong credential type")]
361    WrongCredentialType(#[from] BasicCredentialError),
362    #[error(transparent)]
363    ProcessIntent(#[from] ProcessIntentError),
364    #[error(transparent)]
365    AssociationDeserialization(#[from] xmtp_id::associations::DeserializationError),
366    #[error(transparent)]
367    Client(#[from] ClientError),
368    #[error("Group paused due to minimum protocol version requirement")]
369    GroupPaused,
370    /// Processing stops at the removal commit until a valid rejoin Welcome.
371    #[error("group is inactive")]
372    GroupInactive,
373    #[error("Message epoch [{0}] is too old [{1}]")]
374    OldEpoch(u64, u64),
375    #[error("Message epoch [{0}] is greater than group epoch [{1}]")]
376    FutureEpoch(u64, u64),
377    #[error(transparent)]
378    Db(#[from] xmtp_db::ConnectionError),
379    #[error(transparent)]
380    Builder(#[from] derive_builder::UninitializedFieldError),
381    #[error(transparent)]
382    Diesel(#[from] xmtp_db::diesel::result::Error),
383    #[error(transparent)]
384    EnrichMessage(#[from] EnrichMessageError),
385    #[error("pre-commit proposal phase complete, re-queuing intent")]
386    PreCommitProposalPhaseComplete,
387    #[error(transparent)]
388    Conversion(#[from] xmtp_proto::ConversionError),
389    /// A successful staged-commit merge by a member that remains in the group
390    /// must advance the epoch and therefore change the epoch authenticator.
391    /// If it did not, the group state the commit was merged onto was
392    /// corrupt/torn (e.g. produced by a cross-process race on a shared MLS
393    /// DB). Retryable: the enclosing transaction (cursor + merge + commit
394    /// log) rolls back and the message is reprocessed against settled state.
395    #[error(
396        "staged commit merge for group [{group_id}] at sequence [{commit_sequence_id}] reached \
397         epoch [{epoch}] without advancing the epoch authenticator; refusing to record corrupt \
398         commit log entry"
399    )]
400    EpochAuthenticatorNotAdvanced {
401        group_id: GroupId,
402        commit_sequence_id: i64,
403        epoch: u64,
404    },
405}
406
407impl From<xmtp_mls_validation::commit::CommitRuleError> for GroupMessageProcessingError {
408    fn from(error: xmtp_mls_validation::commit::CommitRuleError) -> Self {
409        Self::CommitValidation(error.into())
410    }
411}
412
413impl RetryableError for GroupMessageProcessingError {
414    fn is_retryable(&self) -> bool {
415        match self {
416            Self::IncomingHeadChanged => true,
417            Self::PreparedAttempt(error) => error.is_retryable(),
418            Self::CorruptIncomingEnvelope(_)
419            | Self::UnsupportedMlsVersion
420            | Self::LostProposalState
421            | Self::Envelope(_)
422            | Self::OwnMessageWithoutAttempt
423            | Self::UnsupportedOwnIntentKind(_) => false,
424            Self::RejectedIntent(_) => false,
425            Self::Storage(err) => err.is_retryable(),
426            Self::Diesel(err) => err.is_retryable(),
427            Self::Identity(err) => err.is_retryable(),
428            Self::OpenMlsProcessMessage(err) => err.is_retryable(),
429            Self::OpenMlsProcessMessageWithAppData(err) => match err {
430                super::app_data::ProcessMessageWithAppDataError::OpenMls(e) => e.is_retryable(),
431                // Decode failures are wire-format violations from the
432                // peer — retrying won't help.
433                super::app_data::ProcessMessageWithAppDataError::AppDataDecode(_) => false,
434                // Resolved by upgrading, not by retrying. In practice
435                // this variant never reaches here: the call sites remap
436                // it to `CommitValidation(ProtocolVersionTooLow)` so
437                // the pause machinery in `post_process_message` fires.
438                super::app_data::ProcessMessageWithAppDataError::ProtocolVersionTooLow {
439                    ..
440                } => false,
441                // Staging failures are validation-shaped — the same
442                // `StageCommitError` on a commit without app-data
443                // proposals surfaces through the `OpenMls` arm as a
444                // non-retryable `InvalidCommit`.
445                super::app_data::ProcessMessageWithAppDataError::ResolveAppDataCommit(_) => false,
446            },
447            Self::MergeStagedCommit(err) => err.is_retryable(),
448            Self::ProcessIntent(err) => err.is_retryable(),
449            Self::CommitValidation(err) => err.is_retryable(),
450            Self::ClearPendingCommit(err) => err.is_retryable(),
451            Self::Client(err) => err.is_retryable(),
452            Self::Db(e) => e.is_retryable(),
453            Self::EnrichMessage(e) => e.is_retryable(),
454            Self::IntentAlreadyProcessed
455            | Self::MessageIdentifierNotFound
456            | Self::WrongCredentialType(_)
457            | Self::Codec(_)
458            | Self::MessageAlreadyProcessed(_)
459            | Self::WelcomeAlreadyProcessed(_)
460            | Self::InvalidSender { .. }
461            | Self::DecodeProto(_)
462            | Self::InvalidPayload
463            | Self::Intent(_)
464            | Self::UnexpectedProcessedContent(_)
465            | Self::EpochIncrementNotAllowed
466            | Self::EncodeProto(_)
467            | Self::IntentMissingStagedCommit
468            | Self::Serde(_)
469            | Self::AssociationDeserialization(_)
470            | Self::TlsError(_)
471            | Self::UnsupportedMessageType(_)
472            | Self::GroupPaused
473            | Self::GroupInactive
474            | Self::FutureEpoch(_, _)
475            | Self::OldEpoch(_, _)
476            | Self::PreCommitProposalPhaseComplete => false,
477            Self::Builder(_) => false,
478            Self::Conversion(_) => false,
479            // Retry so the enclosing transaction rolls back (including the
480            // cursor advance) and the message converges via cursor dedup
481            // instead of persisting a forked commit log entry.
482            Self::EpochAuthenticatorNotAdvanced { .. } => true,
483        }
484    }
485}
486
487impl crate::worker::NeedsDbReconnect for GroupMessageProcessingError {
488    fn needs_db_reconnect(&self) -> bool {
489        match self {
490            Self::Storage(error) => error.db_needs_connection(),
491            Self::Db(error) => error.db_needs_connection(),
492            Self::Client(error) => error.db_needs_connection(),
493            Self::Identity(error) => error.needs_db_reconnect(),
494            Self::PreparedAttempt(error) => error.needs_db_reconnect(),
495            Self::CommitValidation(error) => error.needs_db_reconnect(),
496            _ => false,
497        }
498    }
499}
500
501impl GroupMessageProcessingError {
502    /// Route an app-data processing failure into this error type.
503    ///
504    /// The pre-dispatch floor guard in `process_message_with_app_data`
505    /// surfaces as the same `ProtocolVersionTooLow` commit-validation
506    /// error the validator's post-policy floor check emits, so
507    /// `post_process_message` pauses the group (held cursor,
508    /// `set_group_paused`) instead of treating it like a wire-format
509    /// rejection that advances past the commit. Every other variant
510    /// wraps as `OpenMlsProcessMessageWithAppData`, same as `From`.
511    /// Use this instead of `?`'s implicit conversion at call sites
512    /// that can see commits from newer protocol versions.
513    fn from_app_data_processing(
514        err: super::app_data::ProcessMessageWithAppDataError<sql_key_store::SqlKeyStoreError>,
515    ) -> Self {
516        match err {
517            super::app_data::ProcessMessageWithAppDataError::ProtocolVersionTooLow {
518                min_version,
519                ..
520            } => Self::CommitValidation(CommitValidationError::Rule(
521                CommitRuleError::ProtocolVersionTooLow(min_version),
522            )),
523            other => other.into(),
524        }
525    }
526
527    pub(crate) fn commit_result(&self) -> CommitResult {
528        use super::app_data::ProcessMessageWithAppDataError;
529        match self {
530            GroupMessageProcessingError::OpenMlsProcessMessage(
531                ProcessMessageError::ValidationError(ValidationError::WrongEpoch),
532            ) => CommitResult::WrongEpoch,
533            // Treat the AppData-aware wrapper the same as the bare
534            // OpenMLS error: if it carries a ValidationError(WrongEpoch),
535            // surface as WrongEpoch; if it carries any other OpenMLS
536            // error, surface as Undecryptable. Decode failures (the
537            // AppData-side variant) are treated as Invalid because they
538            // mean the peer's wire format was wrong.
539            GroupMessageProcessingError::OpenMlsProcessMessageWithAppData(
540                ProcessMessageWithAppDataError::OpenMls(ProcessMessageError::ValidationError(
541                    ValidationError::WrongEpoch,
542                )),
543            ) => CommitResult::WrongEpoch,
544            GroupMessageProcessingError::OpenMlsProcessMessageWithAppData(
545                ProcessMessageWithAppDataError::OpenMls(_),
546            ) => CommitResult::Undecryptable,
547            GroupMessageProcessingError::OpenMlsProcessMessageWithAppData(
548                ProcessMessageWithAppDataError::AppDataDecode(_),
549            ) => CommitResult::Invalid,
550            GroupMessageProcessingError::OldEpoch(_, _) => CommitResult::WrongEpoch,
551            GroupMessageProcessingError::FutureEpoch(_, _) => CommitResult::WrongEpoch,
552            GroupMessageProcessingError::CommitValidation(_) => CommitResult::Invalid,
553            GroupMessageProcessingError::OpenMlsProcessMessage(_) => CommitResult::Undecryptable,
554            _ => CommitResult::Unknown,
555        }
556    }
557}
558
559#[derive(Debug, Error)]
560pub struct IntentResolutionError {
561    processing_error: GroupMessageProcessingError,
562}
563
564impl std::fmt::Display for IntentResolutionError {
565    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
566        write!(f, "IntentValidationError: {}", self.processing_error)
567    }
568}
569
570impl RetryableError for IntentResolutionError {
571    fn is_retryable(&self) -> bool {
572        self.processing_error.is_retryable()
573    }
574}
575
576#[derive(Debug)]
577pub(crate) struct PublishIntentData {
578    pub(crate) staged_commit: Option<Vec<u8>>,
579    pub(crate) post_commit_action: Option<Vec<u8>>,
580    /// One or more payloads to publish. Most intents have a single payload (commit or message),
581    /// but proposal intents may have multiple payloads (one per proposal).
582    pub(crate) payloads_to_publish: Vec<Vec<u8>>,
583    pub(crate) should_send_push_notification: bool,
584    pub(crate) group_epoch: u64,
585}
586
587#[cfg(any(test, feature = "test-utils"))]
588impl PublishIntentData {
589    #[allow(dead_code)]
590    pub fn post_commit_data(&self) -> Option<Vec<u8>> {
591        self.post_commit_action.clone()
592    }
593
594    #[allow(dead_code)]
595    pub fn staged_commit(&self) -> Option<Vec<u8>> {
596        self.staged_commit.clone()
597    }
598}
599
600/// Post-commit work from one processed durable group head.
601#[derive(Debug)]
602pub(crate) struct ProcessedMessageOutcome {
603    /// Active state from the same transaction that processed the message.
604    /// The controller retires the group after that transaction commits.
605    pub(crate) group_active: bool,
606    /// True when this message stored a disappearing (expiring) message. The
607    /// disappearing worker is re-armed *after* the storage transaction commits
608    /// (see `process_message`), so the worker's `min_expire_at_ns`
609    /// query is guaranteed to observe the newly written `expire_at_ns`.
610    /// Set when processing this message changed the group's `app_data`.
611    /// Carried out of the state writer so the host callback can be
612    /// awaited after commit; `None` whenever no
613    /// callback is registered, since the snapshot is skipped entirely then.
614    pub(crate) app_data_change: Option<AppDataChange>,
615}
616
617impl ProcessedMessageOutcome {
618    /// An outcome with no worker wake or app-data callback.
619    fn new(group_active: bool) -> Self {
620        Self {
621            group_active,
622            app_data_change: None,
623        }
624    }
625}
626
627#[cfg(test)]
628pub(crate) mod tests {
629
630    use super::*;
631    use crate::{builder::ClientBuilder, utils::TestMlsGroup};
632    use std::sync::Arc;
633    use xmtp_cryptography::utils::generate_local_wallet;
634
635    #[xmtp_common::test(unwrap_try = true)]
636    async fn publish_stores_envelope_metadata_without_sync() {
637        use crate::tester;
638        use xmtp_proto::types::Topic;
639
640        tester!(alix, disable_workers);
641        // Rotate the group key before the message exists.
642        let group = alix.create_group(None, None)?;
643        group.key_update().await?;
644        let id = group.send_message_optimistic(b"publish metadata", Default::default())?;
645        group.publish_intents().await?;
646        let published = alix.context.db().find_group_intents(
647            group.group_id,
648            Some(vec![IntentState::Published]),
649            Some(vec![IntentKind::SendMessage]),
650        )?;
651        assert_eq!(published.len(), 1);
652
653        // Read the local row before any query. Publishing must fill both fields.
654        let stored: StoredGroupMessage = alix.context.db().fetch(&id)?.unwrap();
655        assert_eq!(stored.sequence_id, 0);
656        assert!(
657            stored.envelope_hash.is_some(),
658            "publish left envelope_hash NULL"
659        );
660        assert!(stored.expiry_ns.is_some(), "publish left expiry_ns NULL");
661
662        // Read the wire envelope without processing it into the local database.
663        let envelopes = alix
664            .context
665            .api()
666            .query_all(
667                [(Topic::new_group_message(group.group_id), Cursor(0))].into(),
668                alix.context.api().limits().max_query_limit as u32,
669            )
670            .await?;
671        let envelope = envelopes.last().unwrap();
672        let canonical = xmtp_mls_validation::parse_envelope(envelope.envelope.clone().unwrap())?;
673        assert_eq!(
674            stored.envelope_hash,
675            Some(canonical.canonical.hash.to_vec())
676        );
677        assert_eq!(
678            stored.expiry_ns,
679            Some(i64::try_from(envelope.meta.as_ref().unwrap().expiry_ns)?)
680        );
681    }
682
683    #[xmtp_common::test(unwrap_try = true)]
684    async fn partial_envelope_metadata_preserves_stored_fields() {
685        use crate::tester;
686        use xmtp_proto::types::Topic;
687
688        tester!(alix, disable_workers);
689        let group = alix.create_group(None, None)?;
690        let id = group
691            .send_message(b"partial metadata", Default::default())
692            .await?;
693        let original: StoredGroupMessage = alix.context.db().fetch(&id)?.unwrap();
694        assert!(original.envelope_hash.is_some());
695        assert!(original.expiry_ns.is_some());
696        let mut envelopes = alix
697            .context
698            .api()
699            .query_all(
700                [(Topic::new_group_message(group.group_id), Cursor(0))].into(),
701                alix.context.api().limits().max_query_limit as u32,
702            )
703            .await?;
704        let mut envelope =
705            xmtp_api_backend::envelope::decode_group_message(envelopes.pop().unwrap())?;
706        envelope.envelope_hash = None;
707        TestMlsGroup::save_envelope_metadata_with_db(&alix.context.db(), &envelope)?;
708        let stored: StoredGroupMessage = alix.context.db().fetch(&id)?.unwrap();
709        assert_eq!(stored.envelope_hash, original.envelope_hash);
710        assert_eq!(stored.expiry_ns, original.expiry_ns);
711
712        envelope.envelope_hash = original.envelope_hash.clone();
713        envelope.expiry_ns = None;
714        TestMlsGroup::save_envelope_metadata_with_db(&alix.context.db(), &envelope)?;
715        let stored: StoredGroupMessage = alix.context.db().fetch(&id)?.unwrap();
716        assert_eq!(stored.envelope_hash, original.envelope_hash);
717        assert_eq!(stored.expiry_ns, original.expiry_ns);
718    }
719
720    /// This test is not reproducible in webassembly, b/c webassembly has only one thread.
721    #[cfg_attr(
722        not(target_arch = "wasm32"),
723        tokio::test(flavor = "multi_thread", worker_threads = 10)
724    )]
725    #[cfg(not(target_family = "wasm"))]
726    async fn publish_intents_worst_case_scenario() {
727        use crate::tester;
728
729        tester!(amal_a, triggers);
730        let amal_group_a: Arc<MlsGroup<_>> =
731            Arc::new(amal_a.create_group(None, Default::default()).unwrap());
732
733        let db = amal_a.context.db();
734
735        // create group intent
736        amal_group_a.sync().await.unwrap();
737        assert_eq!(db.intents_processed(), 1);
738
739        for _ in 0..100 {
740            use crate::groups::send_message_opts::SendMessageOpts;
741
742            let s = xmtp_common::rand_string::<100>();
743            amal_group_a
744                .send_message_optimistic(s.as_bytes(), SendMessageOpts::default())
745                .unwrap();
746        }
747
748        let mut set = tokio::task::JoinSet::new();
749        for _ in 0..50 {
750            let g = amal_group_a.clone();
751            set.spawn(async move { g.publish_intents().await });
752        }
753
754        let res = set.join_all().await;
755        let errs: Vec<&Result<_, _>> = res.iter().filter(|r| r.is_err()).collect();
756        errs.iter().for_each(|e| {
757            tracing::error!("{}", e.as_ref().unwrap_err());
758        });
759
760        let published = db.intents_published();
761        assert_eq!(published, 101);
762        let created = db.intents_created();
763        assert_eq!(created, 101);
764        if !errs.is_empty() {
765            panic!("Errors during publish");
766        }
767    }
768
769    #[xmtp_common::test]
770    async fn hmac_keys_work_as_expected() {
771        let wallet = generate_local_wallet();
772        let amal = Arc::new(ClientBuilder::new_test_client(&wallet).await);
773        let amal_group: Arc<TestMlsGroup> =
774            Arc::new(amal.create_group(None, Default::default()).unwrap());
775
776        let hmac_keys = amal_group.hmac_keys(-1..=1).unwrap();
777        let current_hmac_key = amal_group.hmac_keys(0..=0).unwrap().pop().unwrap();
778        assert_eq!(hmac_keys.len(), 3);
779        assert_eq!(hmac_keys[1].key, current_hmac_key.key);
780        assert_eq!(hmac_keys[1].epoch, current_hmac_key.epoch);
781
782        // Make sure the keys are different
783        assert_ne!(hmac_keys[0].key, hmac_keys[1].key);
784        assert_ne!(hmac_keys[0].key, hmac_keys[2].key);
785        assert_ne!(hmac_keys[1].key, hmac_keys[2].key);
786
787        // Make sure the epochs align
788        let current_epoch = hmac_epoch();
789        assert_eq!(hmac_keys[0].epoch, current_epoch - 1);
790        assert_eq!(hmac_keys[1].epoch, current_epoch);
791        assert_eq!(hmac_keys[2].epoch, current_epoch + 1);
792    }
793
794    /// Test that process_delete_message handles completely malformed bytes gracefully
795    ///
796    /// This verifies sync resilience when receiving corrupted DeleteMessage protos.
797    #[xmtp_common::test(unwrap_try = true)]
798    async fn test_process_delete_message_malformed_encoded_content() {
799        use crate::tester;
800        use xmtp_db::group_message::{ContentType, DeliveryStatus, GroupMessageKind};
801
802        tester!(alix);
803        let alix_group = alix.create_group(None, None)?;
804
805        // Create a message with completely invalid EncodedContent proto
806        let malformed_message = xmtp_db::group_message::StoredGroupMessage {
807            id: vec![1, 2, 3],
808            group_id: alix_group.group_id,
809            decrypted_message_bytes: vec![0xFF, 0xFE, 0xFD], // Invalid protobuf
810            sent_at_ns: xmtp_common::time::now_ns(),
811            kind: GroupMessageKind::Application,
812            sender_installation_id: vec![1, 2, 3],
813            sender_inbox_id: alix.inbox_id().to_string(),
814            delivery_status: DeliveryStatus::Published,
815            content_type: ContentType::DeleteMessage,
816            version_major: 1,
817            version_minor: 0,
818            authority_id: "xmtp.org".to_string(),
819            reference_id: None,
820            expire_at_ns: None,
821            sequence_id: 1,
822            envelope_hash: None,
823            expiry_ns: None,
824            inserted_at_ns: 0,
825            should_push: false,
826            idempotency_key: String::new(),
827        };
828
829        // Use load_mls_group_with_lock to get access to the MLS group and call process_delete_message
830        let storage = alix.context.mls_storage();
831        let result: Result<(), crate::groups::GroupError> =
832            alix_group.load_mls_group_with_lock(storage, |mls_group| {
833                let inner_result = alix_group.process_delete_message(
834                    &mls_group,
835                    storage,
836                    &malformed_message,
837                    alix_group.context.events(),
838                );
839                match inner_result {
840                    Ok(()) => Ok(()),
841                    Err(_) => Err(crate::groups::GroupError::InvalidGroupMembership),
842                }
843            });
844
845        assert!(
846            result.is_ok(),
847            "Malformed EncodedContent should not cause error"
848        );
849    }
850
851    /// Test that process_delete_message handles valid EncodedContent with malformed inner proto
852    #[xmtp_common::test(unwrap_try = true)]
853    async fn test_process_delete_message_malformed_inner_proto() {
854        use crate::tester;
855        use prost::Message;
856        use xmtp_db::group_message::{ContentType, DeliveryStatus, GroupMessageKind};
857        use xmtp_proto::xmtp::mls::message_contents::EncodedContent;
858
859        tester!(alix);
860        let alix_group = alix.create_group(None, None)?;
861
862        // Create a valid EncodedContent wrapper but with invalid inner DeleteMessage content
863        let encoded_content = EncodedContent {
864            r#type: Some(xmtp_proto::xmtp::mls::message_contents::ContentTypeId {
865                authority_id: "xmtp.org".to_string(),
866                type_id: "deleteMessage".to_string(),
867                version_major: 1,
868                version_minor: 0,
869            }),
870            parameters: std::collections::HashMap::new(),
871            fallback: None,
872            compression: None,
873            content: vec![0xFF, 0xFE, 0xFD], // Invalid DeleteMessage proto bytes
874        };
875
876        let mut encoded_bytes = Vec::new();
877        encoded_content.encode(&mut encoded_bytes)?;
878
879        let malformed_message = xmtp_db::group_message::StoredGroupMessage {
880            id: vec![4, 5, 6],
881            group_id: alix_group.group_id,
882            decrypted_message_bytes: encoded_bytes,
883            sent_at_ns: xmtp_common::time::now_ns(),
884            kind: GroupMessageKind::Application,
885            sender_installation_id: vec![1, 2, 3],
886            sender_inbox_id: alix.inbox_id().to_string(),
887            delivery_status: DeliveryStatus::Published,
888            content_type: ContentType::DeleteMessage,
889            version_major: 1,
890            version_minor: 0,
891            authority_id: "xmtp.org".to_string(),
892            reference_id: None,
893            expire_at_ns: None,
894            sequence_id: 2,
895            envelope_hash: None,
896            expiry_ns: None,
897            inserted_at_ns: 0,
898            should_push: false,
899            idempotency_key: String::new(),
900        };
901
902        let storage = alix.context.mls_storage();
903        let result: Result<(), crate::groups::GroupError> =
904            alix_group.load_mls_group_with_lock(storage, |mls_group| {
905                let inner_result = alix_group.process_delete_message(
906                    &mls_group,
907                    storage,
908                    &malformed_message,
909                    alix_group.context.events(),
910                );
911                match inner_result {
912                    Ok(()) => Ok(()),
913                    Err(_) => Err(crate::groups::GroupError::InvalidGroupMembership),
914                }
915            });
916
917        assert!(
918            result.is_ok(),
919            "Malformed inner DeleteMessage proto should not cause error"
920        );
921    }
922
923    /// Test that process_delete_message handles invalid hex message_id gracefully
924    #[xmtp_common::test(unwrap_try = true)]
925    async fn test_process_delete_message_invalid_hex_message_id() {
926        use crate::tester;
927        use prost::Message;
928        use xmtp_db::group_message::{ContentType, DeliveryStatus, GroupMessageKind};
929        use xmtp_proto::xmtp::mls::message_contents::EncodedContent;
930        use xmtp_proto::xmtp::mls::message_contents::content_types::DeleteMessage;
931
932        tester!(alix);
933        let alix_group = alix.create_group(None, None)?;
934
935        // Create a valid DeleteMessage but with invalid hex in message_id
936        let delete_msg = DeleteMessage {
937            message_id: "not_valid_hex!!!".to_string(), // Invalid hex
938        };
939
940        let mut delete_bytes = Vec::new();
941        delete_msg.encode(&mut delete_bytes)?;
942
943        let encoded_content = EncodedContent {
944            r#type: Some(xmtp_proto::xmtp::mls::message_contents::ContentTypeId {
945                authority_id: "xmtp.org".to_string(),
946                type_id: "deleteMessage".to_string(),
947                version_major: 1,
948                version_minor: 0,
949            }),
950            parameters: std::collections::HashMap::new(),
951            fallback: None,
952            compression: None,
953            content: delete_bytes,
954        };
955
956        let mut encoded_bytes = Vec::new();
957        encoded_content.encode(&mut encoded_bytes)?;
958
959        let message_with_bad_hex = xmtp_db::group_message::StoredGroupMessage {
960            id: vec![7, 8, 9],
961            group_id: alix_group.group_id,
962            decrypted_message_bytes: encoded_bytes,
963            sent_at_ns: xmtp_common::time::now_ns(),
964            kind: GroupMessageKind::Application,
965            sender_installation_id: vec![1, 2, 3],
966            sender_inbox_id: alix.inbox_id().to_string(),
967            delivery_status: DeliveryStatus::Published,
968            content_type: ContentType::DeleteMessage,
969            version_major: 1,
970            version_minor: 0,
971            authority_id: "xmtp.org".to_string(),
972            reference_id: None,
973            expire_at_ns: None,
974            sequence_id: 3,
975            envelope_hash: None,
976            expiry_ns: None,
977            inserted_at_ns: 0,
978            should_push: false,
979            idempotency_key: String::new(),
980        };
981
982        let storage = alix.context.mls_storage();
983        let result: Result<(), crate::groups::GroupError> =
984            alix_group.load_mls_group_with_lock(storage, |mls_group| {
985                let inner_result = alix_group.process_delete_message(
986                    &mls_group,
987                    storage,
988                    &message_with_bad_hex,
989                    alix_group.context.events(),
990                );
991                match inner_result {
992                    Ok(()) => Ok(()),
993                    Err(_) => Err(crate::groups::GroupError::InvalidGroupMembership),
994                }
995            });
996
997        assert!(
998            result.is_ok(),
999            "Invalid hex message_id should not cause error"
1000        );
1001    }
1002
1003    /// Pin the `CommitResult` mapping for each arm of the AppData-aware
1004    /// wrapper error so future refactors of `commit_result()` can't
1005    /// silently reshuffle what a receiver will write to the remote
1006    /// commit log. In particular: `AppDataDecode` failures must be
1007    /// `Invalid` (non-retriable wire-format violation), not
1008    /// `Undecryptable` (retriable transport failure).
1009    #[test]
1010    fn process_message_with_app_data_error_commit_result_mapping() {
1011        use super::super::app_data::ProcessMessageWithAppDataError;
1012        use openmls::group::ValidationError;
1013        use xmtp_mls_common::app_data::component_source::ComponentSourceError;
1014
1015        let wrong_epoch = GroupMessageProcessingError::OpenMlsProcessMessageWithAppData(
1016            ProcessMessageWithAppDataError::OpenMls(ProcessMessageError::ValidationError(
1017                ValidationError::WrongEpoch,
1018            )),
1019        );
1020        assert_eq!(wrong_epoch.commit_result(), CommitResult::WrongEpoch);
1021
1022        let other_openmls = GroupMessageProcessingError::OpenMlsProcessMessageWithAppData(
1023            ProcessMessageWithAppDataError::OpenMls(ProcessMessageError::IncompatibleWireFormat),
1024        );
1025        assert_eq!(other_openmls.commit_result(), CommitResult::Undecryptable);
1026
1027        // AppData decode failures are deterministic wire-format violations:
1028        // retrying the same bytes can't fix them, and the receiver should
1029        // log them as `Invalid` rather than `Undecryptable`.
1030        let decode_failure = GroupMessageProcessingError::OpenMlsProcessMessageWithAppData(
1031            ProcessMessageWithAppDataError::AppDataDecode(ComponentSourceError::UnknownComponent(
1032                xmtp_mls_common::app_data::component_id::ComponentId::from(0u16),
1033            )),
1034        );
1035        assert_eq!(decode_failure.commit_result(), CommitResult::Invalid);
1036        assert!(
1037            !decode_failure.is_retryable(),
1038            "wire-format violations must not be retriable"
1039        );
1040    }
1041}