Skip to main content

xmtp_mls/
identity_updates.rs

1use crate::{
2    XmtpApi,
3    client::ClientError,
4    context::XmtpSharedContext,
5    groups::group_membership::{GroupMembership, MembershipDiff},
6    identity::{IdentityError, IdentityExt},
7};
8use futures::future::try_join_all;
9use std::collections::{HashMap, HashSet};
10use thiserror::Error;
11use xmtp_common::{Event, Retry, RetryableError, retry_async, retryable};
12use xmtp_cryptography::CredentialSign;
13use xmtp_db::StorageError;
14use xmtp_db::XmtpDb;
15use xmtp_db::prelude::*;
16use xmtp_db::{db_connection::DbConnection, identity_update::StoredIdentityUpdate};
17use xmtp_id::{
18    AsIdRef, InboxIdRef,
19    associations::{
20        AssociationError, AssociationState, Handle, Identifier, IdentityAction,
21        InstallationKeyContext, MemberIdentifier,
22        builder::{SignatureRequest, SignatureRequestBuilder, SignatureRequestError},
23        unverified::{
24            UnverifiedIdentityUpdate, UnverifiedInstallationKeySignature, UnverifiedSignature,
25        },
26    },
27    scw_verifier::SmartContractSignatureVerifier,
28};
29use xmtp_macro::log_event;
30use xmtp_proto::{
31    ShortHex,
32    api_client::XmtpBackendClient,
33    types::{Cursor, GroupId},
34};
35
36use xmtp_api::{ApiClientWrapper, GetIdentityUpdatesV2Filter};
37use xmtp_id::InboxUpdate;
38
39mod dependencies;
40mod state_store;
41pub use dependencies::{IdentityDependencyError, IdentityRequirement, IdentityResolutionRegistry};
42pub(crate) use dependencies::{
43    require_association_state, resolve_identity_requirement, resolve_identity_requirements,
44};
45pub(crate) use state_store::get_association_state_for_client;
46#[cfg(test)]
47pub(crate) use state_store::store_association_state;
48use state_store::{ResolvedState, resolve_association_state};
49
50#[derive(Debug, Error)]
51pub enum IdentityUpdateError {
52    #[error(transparent)]
53    InvalidSignatureRequest(#[from] SignatureRequestError),
54    #[error(transparent)]
55    Api(#[from] xmtp_api::ApiError),
56    #[error(transparent)]
57    Validation(#[from] xmtp_mls_validation::ValidationError),
58    #[error(transparent)]
59    Load(Box<ClientError>),
60}
61
62const IDENTITY_UPDATE_CONFLICT_RETRIES: usize = 3;
63
64/// Reload and validate the signed update after a commit-time conflict.
65/// Retry the same signed bytes at most three times. Other errors are terminal.
66pub(crate) async fn publish_with_conflict_retry<ApiClient: XmtpApi>(
67    api_client: &ApiClientWrapper<ApiClient>,
68    conn: &impl DbQuery,
69    update: UnverifiedIdentityUpdate,
70    verifier: &impl SmartContractSignatureVerifier,
71) -> Result<Cursor, IdentityUpdateError> {
72    for attempt in 0..=IDENTITY_UPDATE_CONFLICT_RETRIES {
73        match api_client.publish_identity_update(update.clone()).await {
74            Ok(cursor) => return Ok(cursor),
75            Err(xmtp_api::ApiError::IdentityUpdateConflict)
76                if attempt < IDENTITY_UPDATE_CONFLICT_RETRIES =>
77            {
78                load_identity_updates(api_client, conn, &[update.inbox_id.as_str()])
79                    .await
80                    .map_err(|error| IdentityUpdateError::Load(Box::new(error)))?;
81                let history = conn
82                    .get_identity_updates(&update.inbox_id, None, None)
83                    .map_err(|error| IdentityUpdateError::Load(Box::new(error.into())))?;
84                let history = history
85                    .into_iter()
86                    .map(|stored| stored.to_unverified().map(Into::into))
87                    .collect::<Result<Vec<_>, _>>()
88                    .map_err(|error| IdentityUpdateError::Load(Box::new(error.into())))?;
89                xmtp_mls_validation::validate_identity_updates(
90                    history,
91                    vec![update.clone().into()],
92                    verifier,
93                )
94                .await?;
95            }
96            Err(error) => return Err(error.into()),
97        }
98    }
99    unreachable!("the final publish attempt returns its result")
100}
101
102#[derive(Debug)]
103pub struct InstallationDiff {
104    pub added_installations: HashSet<Vec<u8>>,
105    pub removed_installations: HashSet<Vec<u8>>,
106}
107
108#[derive(Debug, Error)]
109pub enum InstallationDiffError {
110    #[error(transparent)]
111    IdentityDependency(#[from] IdentityDependencyError),
112    #[error(transparent)]
113    Client(#[from] ClientError),
114    #[error(transparent)]
115    Db(#[from] xmtp_db::ConnectionError),
116    #[error(transparent)]
117    Storage(#[from] StorageError),
118}
119
120impl RetryableError for InstallationDiffError {
121    fn is_retryable(&self) -> bool {
122        match self {
123            InstallationDiffError::IdentityDependency(error) => retryable!(error),
124            InstallationDiffError::Client(client_error) => retryable!(client_error),
125            InstallationDiffError::Storage(e) => retryable!(e),
126            InstallationDiffError::Db(e) => retryable!(e),
127        }
128    }
129}
130
131pub struct IdentityUpdates<Context> {
132    context: Context,
133}
134
135impl<Context> IdentityUpdates<Context> {
136    pub fn new(context: Context) -> Self {
137        Self { context }
138    }
139}
140
141/// Get the association state for a given inbox_id up to the (and inclusive of) the `to_sequence_id`
142/// If no `to_sequence_id` is provided, use the latest value in the database
143///
144/// This path has no client, so storing a computed state emits no event. A
145/// client stores through [`IdentityUpdates::get_association_state`].
146pub async fn get_association_state_with_verifier(
147    conn: &impl DbQuery,
148    inbox_id: &str,
149    to_sequence_id: Option<i64>,
150    scw_verifier: &impl SmartContractSignatureVerifier,
151) -> Result<AssociationState, ClientError> {
152    match resolve_association_state(conn, inbox_id, to_sequence_id, scw_verifier).await? {
153        ResolvedState::Cached(state) => Ok(state),
154        ResolvedState::Computed { sequence_id, state } => {
155            conn.write_to_cache(inbox_id.to_owned(), sequence_id, state.clone().into())?;
156            Ok(state)
157        }
158    }
159}
160
161/// Whether the association state of the inbox in the database holds the
162/// identifier, or `None` when the database has no identity update of the
163/// inbox. Computing the state caches it in the database.
164pub async fn identifier_membership(
165    conn: &impl DbQuery,
166    inbox_id: &str,
167    identifier: &Identifier,
168    scw_verifier: &impl SmartContractSignatureVerifier,
169) -> Result<Option<bool>, ClientError> {
170    match get_association_state_with_verifier(conn, inbox_id, None, scw_verifier).await {
171        Ok(state) => Ok(Some(state.get(&identifier.clone().into()).is_some())),
172        Err(ClientError::Association(AssociationError::MissingIdentityUpdate)) => Ok(None),
173        Err(error) => Err(error),
174    }
175}
176
177/// Whether the identifier may open the database of the inbox, given its
178/// [`identifier_membership`]. Only the identity that creates an inbox belongs
179/// to it before its first identity update.
180pub fn identifier_opens_inbox(
181    membership: Option<bool>,
182    identifier: &Identifier,
183    nonce: u64,
184    inbox_id: &str,
185) -> bool {
186    membership.unwrap_or_else(|| identifier.inbox_id(nonce).is_ok_and(|own| own == inbox_id))
187}
188
189/// Revoke the given installations from the association state for the client's inbox
190pub fn revoke_installations_with_verifier(
191    identifier: &Identifier,
192    inbox_id: &str,
193    installation_ids: Vec<Vec<u8>>,
194) -> Result<SignatureRequest, ClientError> {
195    let mut builder = SignatureRequestBuilder::new(inbox_id);
196
197    for installation_id in installation_ids {
198        builder = builder.revoke_association(
199            identifier.clone().into(),
200            MemberIdentifier::installation(installation_id),
201        )
202    }
203
204    Ok(builder.build())
205}
206
207/**
208 * Apply a signature request to the client's inbox by publishing the identity update to the network.
209 *
210 * This will error if the signature request is missing signatures, if the signatures are invalid,
211 * if the update fails other verifications, or if the update fails to be published to the network.
212 **/
213pub async fn apply_signature_request_with_verifier<ApiClient: XmtpApi>(
214    api_client: &ApiClientWrapper<ApiClient>,
215    conn: &impl DbQuery,
216    signature_request: SignatureRequest,
217    scw_verifier: &impl SmartContractSignatureVerifier,
218) -> Result<(), ClientError> {
219    // If the signature request isn't completed, this will error
220    let identity_update = signature_request
221        .build_identity_update()
222        .map_err(IdentityUpdateError::from)?;
223
224    identity_update.to_verified(scw_verifier).await?;
225
226    publish_with_conflict_retry(api_client, conn, identity_update, scw_verifier).await?;
227
228    Ok(())
229}
230
231/// Get the association state for all provided `inbox_id`/optional `sequence_id` tuples, using the cache when available
232/// If the association state is not available in the cache, this falls back to reconstructing the association state
233/// from Identity Updates in the network.
234pub async fn batch_get_association_state_with_verifier(
235    conn: &impl DbQuery,
236    identifiers: &[(impl AsIdRef, Option<i64>)],
237    scw_verifier: &impl SmartContractSignatureVerifier,
238) -> Result<Vec<AssociationState>, ClientError> {
239    let association_states = try_join_all(
240        identifiers
241            .iter()
242            .map(|(inbox_id, to_sequence_id)| {
243                get_association_state_with_verifier(
244                    conn,
245                    inbox_id.as_ref(),
246                    *to_sequence_id,
247                    scw_verifier,
248                )
249            })
250            .collect::<Vec<_>>(),
251    )
252    .await?;
253
254    Ok(association_states)
255}
256
257impl<'a, Context> IdentityUpdates<Context>
258where
259    Context: XmtpSharedContext,
260{
261    /// Get the association state for all provided `inbox_id`/optional `sequence_id` tuples, using the cache when available
262    /// If the association state is not available in the cache, this falls back to reconstructing the association state
263    /// from Identity Updates in the network.
264    pub async fn batch_get_association_state(
265        &self,
266        conn: &impl DbQuery,
267        identifiers: &[(impl AsIdRef, Option<i64>)],
268    ) -> Result<Vec<AssociationState>, ClientError> {
269        try_join_all(identifiers.iter().map(|(inbox_id, to_sequence_id)| {
270            get_association_state_for_client(
271                &self.context,
272                conn,
273                inbox_id.as_ref(),
274                *to_sequence_id,
275            )
276        }))
277        .await
278    }
279
280    /// Get the latest association state available on the network for the given `inbox_id`
281    #[tracing::instrument(level = "trace", skip_all)]
282    pub async fn get_latest_association_state(
283        &self,
284        conn: &DbConnection<<Context::Db as XmtpDb>::Connection>,
285        inbox_id: InboxIdRef<'a>,
286    ) -> Result<AssociationState, ClientError> {
287        load_identity_updates_for_client(&self.context, conn, &[inbox_id]).await?;
288        self.get_association_state(conn, inbox_id, None).await
289    }
290
291    fn emit_own_installation_changes(
292        &self,
293        inbox_id: InboxIdRef<'_>,
294        before: Option<&AssociationState>,
295        after: &AssociationState,
296    ) {
297        use xmtp_events::{ClientEvent, EventWriter, InstallationRef, InstallationRevoked};
298        if inbox_id != self.context.inbox_id() {
299            return;
300        }
301        let Some(before) = before else {
302            return;
303        };
304        let old: HashSet<_> = before.installation_ids().into_iter().collect();
305        let new: HashSet<_> = after.installation_ids().into_iter().collect();
306        for installation_key in new.difference(&old) {
307            self.context.events().emit(
308                Some(ClientEvent::IdentityOwnInstallationAdded(InstallationRef {
309                    installation_key: installation_key.clone(),
310                })),
311                None,
312            );
313        }
314        for installation_key in old.difference(&new) {
315            self.context.events().emit(
316                Some(ClientEvent::IdentityOwnInstallationRevoked(
317                    InstallationRevoked {
318                        installation_key: installation_key.clone(),
319                        is_this_installation: installation_key.as_slice()
320                            == self.context.installation_id().to_vec().as_slice(),
321                    },
322                )),
323                None,
324            );
325        }
326    }
327
328    pub(crate) async fn emit_own_installation_updates(
329        &self,
330        conn: &impl DbQuery,
331        inbox_id: InboxIdRef<'_>,
332        before: Option<AssociationState>,
333        previous_sequence: Option<i64>,
334    ) -> Result<(), ClientError> {
335        let (Some(mut state), Some(previous_sequence)) = (before, previous_sequence) else {
336            return Ok(());
337        };
338        if inbox_id != self.context.inbox_id() {
339            return Ok(());
340        }
341        let updates = conn.get_identity_updates(inbox_id, None, None)?;
342        for update in updates
343            .into_iter()
344            .filter(|update| update.sequence_id > previous_sequence)
345        {
346            let next = self
347                .get_association_state(conn, inbox_id, Some(update.sequence_id))
348                .await?;
349            self.emit_own_installation_changes(inbox_id, Some(&state), &next);
350            state = next;
351        }
352        Ok(())
353    }
354
355    /// Get the association state for a given inbox_id up to the (and inclusive of) the `to_sequence_id`
356    /// If no `to_sequence_id` is provided, use the latest value in the database
357    pub async fn get_association_state(
358        &self,
359        conn: &impl xmtp_db::DbQuery,
360        inbox_id: InboxIdRef<'_>,
361        to_sequence_id: Option<i64>,
362    ) -> Result<AssociationState, ClientError> {
363        get_association_state_for_client(&self.context, conn, inbox_id, to_sequence_id).await
364    }
365
366    /// Generate a `CreateInbox` signature request for the given wallet address.
367    /// If no nonce is provided, use 0
368    #[tracing::instrument(level = "trace", skip_all)]
369    pub async fn create_inbox(
370        &self,
371        identifier: Identifier,
372        maybe_nonce: Option<u64>,
373    ) -> Result<SignatureRequest, ClientError> {
374        let nonce = maybe_nonce.unwrap_or(0);
375        let inbox_id = identifier.inbox_id(nonce)?;
376        let installation_public_key = self.context.identity().installation_keys.verifying_key();
377
378        let builder = SignatureRequestBuilder::new(inbox_id);
379        let mut signature_request = builder
380            .create_inbox(identifier.clone(), nonce)
381            .add_association(
382                MemberIdentifier::installation(installation_public_key.as_bytes().to_vec()),
383                identifier.into(),
384            )
385            .build();
386
387        let sig_bytes = self
388            .context
389            .identity()
390            .sign_identity_update(signature_request.signature_text())?
391            .to_vec();
392        // We can pre-sign the request with an installation key signature, since we have access to the key
393        signature_request
394            .add_signature(
395                UnverifiedSignature::InstallationKey(UnverifiedInstallationKeySignature::new(
396                    sig_bytes,
397                    installation_public_key,
398                )),
399                &self.context.scw_verifier(),
400            )
401            .await?;
402
403        // The app fills this request in, so bind it to
404        // the chains the deployment accepts before it leaves the client.
405        self.context
406            .server_configuration()
407            .restrict(&mut signature_request);
408
409        Ok(signature_request)
410    }
411
412    /// Generate a `AssociateWallet` signature request using an existing wallet and a new wallet address
413    #[tracing::instrument(level = "trace", skip_all)]
414    pub async fn associate_identity(
415        &self,
416        new_identifier: Identifier,
417    ) -> Result<SignatureRequest, ClientError> {
418        tracing::info!(
419            "Associating new wallet with inbox_id {}",
420            self.context.inbox_id()
421        );
422        let inbox_id = self.context.inbox_id();
423        let builder = SignatureRequestBuilder::new(inbox_id);
424        let installation_public_key = self.context.identity().installation_keys.verifying_key();
425
426        let mut signature_request = builder
427            .add_association(new_identifier.into(), installation_public_key.into())
428            .build();
429
430        let signature = self
431            .context
432            .identity()
433            .installation_keys
434            .credential_sign::<InstallationKeyContext>(signature_request.signature_text())?;
435
436        signature_request
437            .add_signature(
438                UnverifiedSignature::new_installation_key(signature, installation_public_key),
439                &self.context.scw_verifier(),
440            )
441            .await?;
442
443        // the app fills this request in, so bind it to
444        // the chains the deployment accepts before it leaves the client.
445        self.context
446            .server_configuration()
447            .restrict(&mut signature_request);
448
449        Ok(signature_request)
450    }
451
452    /// Generate a `ClaimHandle` signature request for `handle`, signed by this
453    /// installation's key.
454    pub async fn claim_handle_request(
455        &self,
456        handle: Handle,
457    ) -> Result<SignatureRequest, ClientError> {
458        let installation_keys = &self.context.identity().installation_keys;
459        let installation_public_key = installation_keys.verifying_key();
460        let mut signature_request = SignatureRequestBuilder::new(self.context.inbox_id())
461            .claim_handle(handle, installation_public_key.into())
462            .build();
463        let signature = installation_keys
464            .credential_sign::<InstallationKeyContext>(signature_request.signature_text())?;
465        signature_request
466            .add_signature(
467                UnverifiedSignature::new_installation_key(signature, installation_public_key),
468                &self.context.scw_verifier(),
469            )
470            .await?;
471        Ok(signature_request)
472    }
473
474    /// Revoke the given identities from the association state for the client's inbox
475    pub async fn revoke_identities(
476        &self,
477        identities_to_revoke: Vec<Identifier>,
478    ) -> Result<SignatureRequest, ClientError> {
479        let inbox_id = self.context.inbox_id();
480        let current_state = retry_async!(
481            Retry::default(),
482            (async {
483                self.get_association_state(&self.context.db(), inbox_id, None)
484                    .await
485            })
486        )?;
487        let mut builder = SignatureRequestBuilder::new(inbox_id);
488
489        for ident in identities_to_revoke {
490            builder = builder.revoke_association(
491                current_state.recovery_identifier().clone().into(),
492                ident.into(),
493            )
494        }
495
496        let mut signature_request = builder.build();
497        self.context
498            .server_configuration()
499            .restrict(&mut signature_request);
500        Ok(signature_request)
501    }
502
503    /// Revoke the given installations from the association state for the client's inbox
504    pub async fn revoke_installations(
505        &self,
506        installation_ids: Vec<Vec<u8>>,
507    ) -> Result<SignatureRequest, ClientError> {
508        let inbox_id = self.context.inbox_id();
509        let current_state = retry_async!(
510            Retry::default(),
511            (async {
512                self.get_association_state(&self.context.db(), inbox_id, None)
513                    .await
514            })
515        )?;
516
517        let mut result = revoke_installations_with_verifier(
518            &current_state.recovery_identifier().clone(),
519            inbox_id,
520            installation_ids,
521        )?;
522        // Every request this client hands back is bound to the
523        // deployment's accepted chains, so an app cannot sign it from a chain
524        // the deployment refuses.
525        self.context.server_configuration().restrict(&mut result);
526
527        Ok(result)
528    }
529
530    /// Generate a `ChangeRecoveryAddress` signature request using a new identifier
531    pub async fn change_recovery_identifier(
532        &self,
533        new_recovery_identifier: Identifier,
534    ) -> Result<SignatureRequest, ClientError> {
535        let inbox_id = self.context.inbox_id();
536        let current_state = retry_async!(
537            Retry::default(),
538            (async {
539                self.get_association_state(&self.context.db(), inbox_id, None)
540                    .await
541            })
542        )?;
543        let mut builder = SignatureRequestBuilder::new(inbox_id);
544        let member_identifier: MemberIdentifier =
545            current_state.recovery_identifier().clone().into();
546        builder = builder.change_recovery_address(member_identifier, new_recovery_identifier);
547        let mut signature_request = builder.build();
548        self.context
549            .server_configuration()
550            .restrict(&mut signature_request);
551        Ok(signature_request)
552    }
553
554    /**
555     * Apply a signature request to the client's inbox by publishing the identity update to the network.
556     *
557     * This will error if the signature request is missing signatures, if the signatures are invalid,
558     * if the update fails other verifications, or if the update fails to be published to the network.
559     **/
560    pub async fn apply_signature_request(
561        &self,
562        signature_request: SignatureRequest,
563    ) -> Result<(), ClientError> {
564        // A client with a blocked connection publishes no identity update.
565        self.context.server_configuration().check()?;
566        let inbox_id = signature_request.inbox_id().to_string();
567        let _own_refresh = if inbox_id == self.context.inbox_id() {
568            Some(
569                self.context
570                    .identity_resolution_registry()
571                    .own_inbox_refresh()
572                    .lock()
573                    .await,
574            )
575        } else {
576            None
577        };
578        let previous_sequence = self
579            .context
580            .db()
581            .get_latest_sequence_id(&[inbox_id.as_str()])?
582            .get(&inbox_id)
583            .copied();
584        let before = if previous_sequence.is_some() {
585            Some(
586                self.get_association_state(&self.context.db(), &inbox_id, None)
587                    .await?,
588            )
589        } else {
590            None
591        };
592
593        apply_signature_request_with_verifier(
594            self.context.api(),
595            &self.context.db(),
596            signature_request,
597            &self.context.scw_verifier(),
598        )
599        .await?;
600
601        // Load the identity updates for the inbox so that we have a record in our DB
602        retry_async!(
603            Retry::default(),
604            (async {
605                load_identity_updates(self.context.api(), &self.context.db(), &[inbox_id.as_str()])
606                    .await
607            })
608        )?;
609
610        self.emit_own_installation_updates(
611            &self.context.db(),
612            &inbox_id,
613            before,
614            previous_sequence,
615        )
616        .await?;
617
618        Ok(())
619    }
620
621    /// Given two group memberships and the diff, get the list of installations that were added or removed
622    /// between the two membership states.
623    #[tracing::instrument(level = "trace", skip_all)]
624    pub async fn get_installation_diff(
625        &self,
626        conn: &impl DbQuery,
627        group_id: &GroupId, // used for logging
628        old_group_membership: &GroupMembership,
629        new_group_membership: &GroupMembership,
630        membership_diff: &MembershipDiff<'_>,
631    ) -> Result<InstallationDiff, InstallationDiffError> {
632        let diff = loop {
633            match get_installation_diff_local(
634                conn,
635                old_group_membership,
636                new_group_membership,
637                membership_diff,
638            ) {
639                Ok(diff) => break diff,
640                Err(IdentityDependencyError::Need(requirement)) => {
641                    resolve_identity_requirement(&self.context, &requirement).await?;
642                }
643                Err(error) => return Err(error.into()),
644            }
645        };
646        let InstallationDiff {
647            added_installations,
648            removed_installations,
649        } = diff;
650
651        if !added_installations.is_empty() || !removed_installations.is_empty() {
652            let added_installations: Vec<_> =
653                added_installations.iter().map(|i| i.short_hex()).collect();
654            let removed_installations: Vec<_> = removed_installations
655                .iter()
656                .map(|i| i.short_hex())
657                .collect();
658            log_event!(
659                Event::UpdatedGroupMembership,
660                self.context.installation_id(),
661                #group_id,
662                ?added_installations,
663                ?removed_installations
664            );
665        }
666
667        Ok(InstallationDiff {
668            added_installations,
669            removed_installations,
670        })
671    }
672}
673
674/// Compare exact verified snapshots without network or signature-verifier calls.
675/// A creation placeholder has an empty identity baseline. It never selects the
676/// receiver's latest identity state. New membership references must be nonzero.
677pub(crate) fn get_installation_diff_local(
678    conn: &impl DbQuery,
679    old_membership: &GroupMembership,
680    new_membership: &GroupMembership,
681    membership_diff: &MembershipDiff<'_>,
682) -> Result<InstallationDiff, IdentityDependencyError> {
683    let mut added_installations = HashSet::new();
684    let mut removed_installations = HashSet::new();
685    for inbox_id in membership_diff
686        .added_inboxes
687        .iter()
688        .chain(&membership_diff.updated_inboxes)
689    {
690        let final_state = require_association_state(
691            conn,
692            &IdentityRequirement {
693                inbox_id: (*inbox_id).clone(),
694                sequence_id: new_membership.get(inbox_id).copied().unwrap_or(0),
695            },
696        )?;
697        let diff = match old_membership.get(inbox_id) {
698            None | Some(0) => final_state.as_diff(),
699            Some(sequence_id) => require_association_state(
700                conn,
701                &IdentityRequirement {
702                    inbox_id: (*inbox_id).clone(),
703                    sequence_id: *sequence_id,
704                },
705            )?
706            .diff(&final_state),
707        };
708        added_installations.extend(diff.new_installations());
709        removed_installations.extend(diff.removed_installations());
710    }
711    for inbox_id in &membership_diff.removed_inboxes {
712        let state = require_association_state(
713            conn,
714            &IdentityRequirement {
715                inbox_id: (*inbox_id).clone(),
716                sequence_id: old_membership.get(inbox_id).copied().unwrap_or(0),
717            },
718        )?;
719        removed_installations.extend(state.installation_ids());
720    }
721    Ok(InstallationDiff {
722        added_installations,
723        removed_installations,
724    })
725}
726
727/// Load identity updates and report changes to this client's own installations.
728pub(crate) async fn load_identity_updates_for_client<Context: XmtpSharedContext>(
729    context: &Context,
730    conn: &impl DbQuery,
731    inbox_ids: &[&str],
732) -> Result<HashMap<String, Vec<InboxUpdate>>, ClientError> {
733    let own_inbox = context.inbox_id();
734    let includes_own = inbox_ids.contains(&own_inbox);
735    let _own_refresh = if includes_own {
736        Some(
737            context
738                .identity_resolution_registry()
739                .own_inbox_refresh()
740                .lock()
741                .await,
742        )
743    } else {
744        None
745    };
746    let previous_sequence = if includes_own {
747        conn.get_latest_sequence_id(&[own_inbox])?
748            .get(own_inbox)
749            .copied()
750    } else {
751        None
752    };
753    let before = if previous_sequence.is_some() {
754        Some(get_association_state_for_client(context, conn, own_inbox, None).await?)
755    } else {
756        None
757    };
758    let updates = load_identity_updates(context.api(), conn, inbox_ids).await?;
759    IdentityUpdates::new(context)
760        .emit_own_installation_updates(conn, own_inbox, before, previous_sequence)
761        .await?;
762    Ok(updates)
763}
764
765/// For the given list of `inbox_id`s get all updates from the network that are newer than the last known `sequence_id`,
766/// write them in the db, and return the updates
767#[tracing::instrument(level = "trace", skip_all)]
768pub async fn load_identity_updates<ApiClient: XmtpApi>(
769    api_client: &ApiClientWrapper<ApiClient>,
770    conn: &impl xmtp_db::DbQuery,
771    inbox_ids: &[&str],
772) -> Result<HashMap<String, Vec<InboxUpdate>>, ClientError> {
773    if inbox_ids.is_empty() {
774        return Ok(HashMap::new());
775    }
776    tracing::debug!("Fetching identity updates for: {:?}", inbox_ids);
777
778    let existing_sequence_ids = conn.get_latest_sequence_id(inbox_ids)?;
779    let filters: Vec<GetIdentityUpdatesV2Filter> = inbox_ids
780        .iter()
781        .map(|inbox_id| GetIdentityUpdatesV2Filter {
782            sequence_id: existing_sequence_ids.get(*inbox_id).map(|i| *i as u64),
783            inbox_id: inbox_id.to_string(),
784        })
785        .collect();
786
787    let updates = api_client.get_identity_updates_v2(filters).await?;
788    let updates = updates
789        .into_iter()
790        .map(|(inbox_id, entries)| {
791            let entries = entries
792                .into_iter()
793                .map(|entry| {
794                    Ok(InboxUpdate {
795                        sequence_id: entry
796                            .meta
797                            .cursor
798                            .ok_or(xmtp_api::ApiError::InvalidResponse("identity cursor"))?
799                            .sequence_id,
800                        server_timestamp_ns: entry.meta.server_ns,
801                        update: entry.update.try_into()?,
802                    })
803                })
804                .collect::<Result<Vec<_>, ClientError>>()?;
805            Ok((inbox_id, entries))
806        })
807        .collect::<Result<HashMap<_, _>, ClientError>>()?;
808    let to_store = updates
809        .iter()
810        .flat_map(move |(inbox_id, updates)| {
811            updates.iter().map(move |update| StoredIdentityUpdate {
812                inbox_id: inbox_id.clone(),
813                sequence_id: update.sequence_id as i64,
814                server_timestamp_ns: update.server_timestamp_ns as i64,
815                payload: update.update.clone().into(),
816            })
817        })
818        .collect::<Vec<StoredIdentityUpdate>>();
819
820    conn.insert_or_ignore_identity_updates(&to_store)?;
821    Ok(updates)
822}
823
824/// A static lookup method to verify if an identity is a member of an inbox
825pub async fn is_member_of_association_state<Client>(
826    api_client: &ApiClientWrapper<Client>,
827    inbox_id: &str,
828    identifier: &MemberIdentifier,
829    scw_verifier: Option<Box<dyn SmartContractSignatureVerifier>>,
830) -> Result<bool, ClientError>
831where
832    Client: XmtpBackendClient + Clone,
833{
834    let filters = vec![GetIdentityUpdatesV2Filter {
835        inbox_id: inbox_id.to_string(),
836        sequence_id: None,
837    }];
838    let mut updates = api_client.get_identity_updates_v2(filters).await?;
839
840    let Some(updates) = updates.remove(inbox_id) else {
841        return Err(ClientError::Generic(
842            "Unable to find provided inbox_id".to_string(),
843        ));
844    };
845    let updates: Vec<UnverifiedIdentityUpdate> = updates
846        .into_iter()
847        .map(|u| u.update.try_into())
848        .collect::<Result<_, _>>()?;
849
850    let mut association_state = None;
851
852    let scw_verifier = scw_verifier
853        .unwrap_or_else(|| Box::new(api_client.clone()) as Box<dyn SmartContractSignatureVerifier>);
854
855    let updates: Vec<_> = updates
856        .iter()
857        .map(|u| u.to_verified(&scw_verifier))
858        .collect();
859    let updates = try_join_all(updates).await?;
860
861    for update in updates {
862        association_state =
863            Some(update.update_state(association_state, update.client_timestamp_ns)?);
864    }
865    let association_state = association_state.ok_or(ClientError::Generic(
866        "Unable to create association state".to_string(),
867    ))?;
868
869    Ok(association_state.get(identifier).is_some())
870}
871
872#[tracing::instrument(level = "trace", skip_all)]
873pub async fn get_creation_signature_kind(
874    conn: &impl xmtp_db::DbQuery,
875    scw_verifier: impl SmartContractSignatureVerifier,
876    inbox_id: InboxIdRef<'_>,
877) -> Result<Option<xmtp_id::associations::SignatureKind>, ClientError> {
878    let updates = conn.get_identity_updates(inbox_id, None, None)?;
879
880    let first_update = updates
881        .first()
882        .ok_or_else(|| ClientError::Identity(IdentityError::RequiredIdentityNotFound))?;
883
884    let unverified_update: UnverifiedIdentityUpdate = first_update.clone().to_unverified()?;
885
886    let verified = unverified_update.to_verified(scw_verifier).await?;
887
888    Ok(verified.creation_signature_kind())
889}
890
891#[cfg(test)]
892pub(crate) mod tests {
893    #![allow(unused)] // b/c wasm & native
894    use crate::{
895        Client, XmtpApi,
896        builder::ClientBuilder,
897        context::XmtpSharedContext,
898        groups::group_membership::GroupMembership,
899        identity_updates::IdentityUpdates,
900        tester,
901        utils::{FullXmtpClient, Tester},
902    };
903    use alloy::signers::Signer;
904    use xmtp_cryptography::utils::generate_local_wallet;
905    use xmtp_id::{
906        InboxOwner,
907        associations::{
908            AssociationState, MemberIdentifier,
909            builder::{SignatureRequest, SignatureRequestError},
910            test_utils::{MockSmartContractSignatureVerifier, WalletTestExt, add_wallet_signature},
911            unverified::UnverifiedSignature,
912        },
913    };
914
915    use xmtp_db::{
916        ConnectionExt, db_connection::DbConnection, identity_update::StoredIdentityUpdate,
917        prelude::*,
918    };
919    use xmtp_proto::types::GroupId;
920
921    use xmtp_common::rand_vec;
922
923    use super::{is_member_of_association_state, load_identity_updates};
924
925    async fn get_association_state<Context>(
926        client: &Client<Context>,
927        inbox_id: &str,
928    ) -> AssociationState
929    where
930        Context: XmtpSharedContext,
931    {
932        let conn = client.context.db();
933        load_identity_updates(client.context.api(), &conn, &[inbox_id])
934            .await
935            .unwrap();
936
937        IdentityUpdates::new(&client.context)
938            .get_association_state(&conn, inbox_id, None)
939            .await
940            .unwrap()
941    }
942
943    fn insert_identity_update<C>(conn: &DbConnection<C>, inbox_id: &str, sequence_id: i64)
944    where
945        C: ConnectionExt,
946    {
947        let identity_update =
948            StoredIdentityUpdate::new(inbox_id.to_string(), sequence_id, 0, rand_vec::<24>());
949
950        conn.insert_or_ignore_identity_updates(&[identity_update])
951            .expect("insert should succeed");
952    }
953
954    #[rstest::rstest]
955    #[xmtp_common::test]
956    async fn test_is_member_of_association_state() {
957        let wallet = generate_local_wallet();
958        let client = ClientBuilder::new_test_client(&wallet).await;
959
960        let wallet2 = generate_local_wallet();
961        let client_identity_updates = IdentityUpdates::new(&client.context);
962
963        let mut request = client_identity_updates
964            .associate_identity(wallet2.identifier())
965            .await
966            .unwrap();
967        add_wallet_signature(&mut request, &wallet2).await;
968        client_identity_updates
969            .apply_signature_request(request)
970            .await
971            .unwrap();
972
973        let conn = client.context.db();
974
975        // The installation, wallet1 address, and the newly associated wallet2 address
976        // Use wait_for_eq to handle eventual consistency after apply_signature_request
977        xmtp_common::wait_for_eq(
978            || async {
979                client_identity_updates
980                    .get_latest_association_state(&conn, client.inbox_id())
981                    .await
982                    .unwrap()
983                    .members()
984                    .len()
985            },
986            3,
987        )
988        .await
989        .unwrap();
990
991        let api_client = client.context.api();
992
993        // Check that the second wallet is associated with our new static helper
994        let is_member = is_member_of_association_state(
995            api_client,
996            client.inbox_id(),
997            &wallet2.member_identifier(),
998            None,
999        )
1000        .await
1001        .unwrap();
1002
1003        assert!(is_member);
1004    }
1005
1006    #[rstest::rstest]
1007    #[xmtp_common::test]
1008    async fn create_inbox_round_trip() {
1009        let wallet = generate_local_wallet();
1010        let wallet_ident = wallet.identifier();
1011        let client = ClientBuilder::new_test_client(&wallet).await;
1012
1013        let mut signature_request: SignatureRequest = client
1014            .identity_updates()
1015            .create_inbox(wallet_ident.clone(), None)
1016            .await
1017            .unwrap();
1018        let inbox_id = signature_request.inbox_id().to_string();
1019
1020        add_wallet_signature(&mut signature_request, &wallet).await;
1021
1022        client
1023            .identity_updates()
1024            .apply_signature_request(signature_request)
1025            .await
1026            .unwrap();
1027
1028        let association_state = get_association_state(&client, &inbox_id).await;
1029
1030        assert_eq!(association_state.members().len(), 2);
1031        assert_eq!(association_state.recovery_identifier(), &wallet_ident);
1032        assert!(association_state.get(&wallet_ident.into()).is_some())
1033    }
1034
1035    #[rstest::rstest]
1036    #[xmtp_common::test]
1037    async fn add_association() {
1038        let wallet_2 = generate_local_wallet();
1039        let wallet2_ident = wallet_2.identifier();
1040
1041        tester!(client);
1042
1043        let mut add_association_request = client
1044            .identity_updates()
1045            .associate_identity(wallet2_ident.clone())
1046            .await
1047            .unwrap();
1048
1049        add_wallet_signature(&mut add_association_request, &wallet_2).await;
1050
1051        client
1052            .identity_updates()
1053            .apply_signature_request(add_association_request)
1054            .await
1055            .unwrap();
1056        let association_state = get_association_state(&client, client.inbox_id()).await;
1057
1058        let members = association_state
1059            .members_by_parent(&client.builder.owner.get_identifier().unwrap().into());
1060        // Those members should have timestamps
1061        for member in members {
1062            assert!(member.client_timestamp_ns.is_some());
1063        }
1064
1065        assert_eq!(association_state.members().len(), 3);
1066        assert_eq!(
1067            *association_state.recovery_identifier(),
1068            client.builder.owner.get_identifier().unwrap()
1069        );
1070        assert!(association_state.get(&wallet2_ident.into()).is_some());
1071    }
1072
1073    #[cfg_attr(not(target_arch = "wasm32"), test)]
1074    #[cfg(not(target_arch = "wasm32"))]
1075    fn cache_association_state() {
1076        use std::sync::Arc;
1077
1078        use xmtp_common::{assert_logged, traced_test::LOG_BUFFER};
1079
1080        use crate::{
1081            utils::LocalTester, worker::device_sync::DeviceSyncClient,
1082            worker::metrics::WorkerMetrics,
1083        };
1084
1085        xmtp_common::traced_test!(async {
1086            let client = Tester::new().await;
1087            let inbox_id = client.inbox_id();
1088            let metrics = WorkerMetrics::new(client.context.installation_id());
1089            let device_sync = DeviceSyncClient::new(&client.context, Arc::new(metrics));
1090            device_sync.wait_for_sync_worker_init().await;
1091
1092            let wallet_2 = generate_local_wallet();
1093
1094            get_association_state(&client, inbox_id).await;
1095
1096            assert_logged!("Loaded association", 0);
1097            // TODO: Verify state is actually in db instead of just checking logs
1098            assert_logged!("Wrote association", 1);
1099
1100            let association_state = get_association_state(&client, inbox_id).await;
1101
1102            assert_eq!(association_state.members().len(), 2);
1103            assert_eq!(
1104                association_state.recovery_identifier(),
1105                &client.builder.owner.identifier()
1106            );
1107            assert!(
1108                association_state
1109                    .get(&client.builder.owner.identifier().into())
1110                    .is_some()
1111            );
1112
1113            assert_logged!("Loaded association", 1);
1114            assert_logged!("Wrote association", 1);
1115
1116            let mut add_association_request = client
1117                .identity_updates()
1118                .associate_identity(wallet_2.identifier())
1119                .await
1120                .unwrap();
1121
1122            add_wallet_signature(&mut add_association_request, &wallet_2).await;
1123
1124            client
1125                .identity_updates()
1126                .apply_signature_request(add_association_request)
1127                .await
1128                .unwrap();
1129
1130            assert_logged!("Wrote association", 2);
1131            // Count cache reads from the following calls.
1132            LOG_BUFFER.with(|buf| buf.clear());
1133            get_association_state(&client, inbox_id).await;
1134
1135            assert_logged!("Loaded association", 1);
1136            assert_logged!("Wrote association", 0);
1137
1138            let association_state = get_association_state(&client, inbox_id).await;
1139
1140            assert_logged!("Loaded association", 2);
1141            assert_logged!("Wrote association", 0);
1142
1143            assert_eq!(association_state.members().len(), 3);
1144            assert_eq!(
1145                association_state.recovery_identifier(),
1146                &client.builder.owner.identifier()
1147            );
1148            assert!(
1149                association_state
1150                    .get(&wallet_2.member_identifier())
1151                    .is_some()
1152            );
1153        });
1154    }
1155
1156    #[rstest::rstest]
1157    #[xmtp_common::test]
1158    async fn load_identity_updates_if_needed() {
1159        let wallet = generate_local_wallet();
1160        let client = ClientBuilder::new_test_client(&wallet).await;
1161        let conn = client.context.db();
1162
1163        insert_identity_update(&conn, "inbox_1", 1);
1164        insert_identity_update(&conn, "inbox_2", 2);
1165        insert_identity_update(&conn, "inbox_3", 3);
1166
1167        let filtered =
1168            // Inbox 1 is requesting an inbox ID higher than what is in the DB. Inbox 2 is requesting one that matches the DB.
1169            // Inbox 3 is requesting one lower than what is in the DB
1170            crate::groups::filter_inbox_ids_needing_updates(&conn, &[("inbox_1", 3), ("inbox_2", 2), ("inbox_3", 2)]);
1171        assert_eq!(filtered.unwrap(), vec!["inbox_1"]);
1172    }
1173
1174    #[rstest::rstest]
1175    #[xmtp_common::test]
1176    async fn get_installation_diff() {
1177        let wallet_1 = generate_local_wallet();
1178        let wallet_2 = generate_local_wallet();
1179        let wallet_3 = generate_local_wallet();
1180
1181        let client_1 = ClientBuilder::new_test_client(&wallet_1).await;
1182        let client_2 = ClientBuilder::new_test_client(&wallet_2).await;
1183        let client_3 = ClientBuilder::new_test_client(&wallet_3).await;
1184
1185        let client_2_installation_key = client_2.installation_public_key().to_vec();
1186        let client_3_installation_key = client_3.installation_public_key().to_vec();
1187
1188        let mut inbox_ids: Vec<String> = vec![];
1189
1190        // Create an inbox with 2 history items for each client
1191        for (client, wallet) in [
1192            (client_1, wallet_1),
1193            (client_2, wallet_2),
1194            (client_3, wallet_3),
1195        ] {
1196            let mut signature_request: SignatureRequest = client
1197                .identity_updates()
1198                .create_inbox(wallet.identifier(), None)
1199                .await
1200                .unwrap();
1201            let inbox_id = signature_request.inbox_id().to_string();
1202            inbox_ids.push(inbox_id);
1203
1204            add_wallet_signature(&mut signature_request, &wallet).await;
1205            client
1206                .identity_updates()
1207                .apply_signature_request(signature_request)
1208                .await
1209                .unwrap();
1210            let new_wallet = generate_local_wallet();
1211            let mut add_association_request = client
1212                .identity_updates()
1213                .associate_identity(new_wallet.identifier())
1214                .await
1215                .unwrap();
1216
1217            add_wallet_signature(&mut add_association_request, &new_wallet).await;
1218
1219            client
1220                .identity_updates()
1221                .apply_signature_request(add_association_request)
1222                .await
1223                .unwrap();
1224        }
1225
1226        // Create a new client to test group operations with
1227        let other_client = ClientBuilder::new_test_client(&generate_local_wallet()).await;
1228        let other_conn = other_client.context.db();
1229        let ids = inbox_ids.iter().map(AsRef::as_ref).collect::<Vec<&str>>();
1230        // Load all the identity updates for the new inboxes
1231        load_identity_updates(other_client.context.api(), &other_conn, ids.as_slice())
1232            .await
1233            .expect("load should succeed");
1234
1235        // Get the latest sequence IDs so we can construct the updates
1236        let latest_sequence_ids = other_conn.get_latest_sequence_id(ids.as_slice()).unwrap();
1237
1238        let inbox_1_first_sequence_id = other_conn
1239            .get_identity_updates(inbox_ids[0].clone(), None, None)
1240            .unwrap()
1241            .first()
1242            .unwrap()
1243            .sequence_id;
1244
1245        let mut original_group_membership = GroupMembership::new();
1246        original_group_membership.add(inbox_ids[0].to_string(), inbox_1_first_sequence_id as u64);
1247        original_group_membership.add(
1248            inbox_ids[1].to_string(),
1249            *latest_sequence_ids.get(&inbox_ids[1]).unwrap() as u64,
1250        );
1251
1252        let mut new_group_membership = original_group_membership.clone();
1253        // Update the first inbox to have a higher sequence ID, but no new installations
1254        new_group_membership.add(
1255            inbox_ids[0].to_string(),
1256            *latest_sequence_ids.get(&inbox_ids[0]).unwrap() as u64,
1257        );
1258        new_group_membership.add(
1259            inbox_ids[2].to_string(),
1260            *latest_sequence_ids.get(&inbox_ids[2]).unwrap() as u64,
1261        );
1262        new_group_membership.remove(&inbox_ids[1]);
1263
1264        let membership_diff = original_group_membership.diff(&new_group_membership);
1265
1266        let installation_diff = other_client
1267            .identity_updates()
1268            .get_installation_diff(
1269                &other_conn,
1270                &GroupId::default(),
1271                &original_group_membership,
1272                &new_group_membership,
1273                &membership_diff,
1274            )
1275            .await
1276            .unwrap();
1277
1278        assert_eq!(installation_diff.added_installations.len(), 1);
1279        assert!(
1280            installation_diff
1281                .added_installations
1282                .contains(&client_3_installation_key.to_vec()),
1283        );
1284        assert_eq!(installation_diff.removed_installations.len(), 1);
1285        assert!(
1286            installation_diff
1287                .removed_installations
1288                .contains(&client_2_installation_key.to_vec())
1289        );
1290    }
1291
1292    /// The diff must reject an inbox that a commit adds at `sequence_id: 0`.
1293    /// It must not read the receiver's latest identity state. If it does,
1294    /// receivers with different identity history fork the group.
1295    #[rstest::rstest]
1296    #[xmtp_common::test]
1297    async fn get_installation_diff_rejects_added_inbox_at_sequence_zero() {
1298        let wallet = generate_local_wallet();
1299        let client = ClientBuilder::new_test_client(&wallet).await;
1300
1301        let mut signature_request: SignatureRequest = client
1302            .identity_updates()
1303            .create_inbox(wallet.identifier(), None)
1304            .await
1305            .unwrap();
1306        let inbox_id = signature_request.inbox_id().to_string();
1307        add_wallet_signature(&mut signature_request, &wallet).await;
1308        client
1309            .identity_updates()
1310            .apply_signature_request(signature_request)
1311            .await
1312            .unwrap();
1313
1314        // This client holds all the identity updates. A read of the latest
1315        // state would succeed. So the `0` causes the rejection below.
1316        let other_client = ClientBuilder::new_test_client(&generate_local_wallet()).await;
1317        let other_conn = other_client.context.db();
1318        load_identity_updates(
1319            other_client.context.api(),
1320            &other_conn,
1321            &[inbox_id.as_str()],
1322        )
1323        .await
1324        .expect("load should succeed");
1325        let latest_sequence_id = *other_conn
1326            .get_latest_sequence_id(&[inbox_id.as_str()])
1327            .unwrap()
1328            .get(&inbox_id)
1329            .unwrap();
1330
1331        let old_membership = GroupMembership::new();
1332
1333        // The same add at the real sequence id succeeds.
1334        let mut honest_membership = GroupMembership::new();
1335        honest_membership.add(inbox_id.clone(), latest_sequence_id as u64);
1336        other_client
1337            .identity_updates()
1338            .get_installation_diff(
1339                &other_conn,
1340                &GroupId::default(),
1341                &old_membership,
1342                &honest_membership,
1343                &old_membership.diff(&honest_membership),
1344            )
1345            .await
1346            .expect("an add at a real sequence id is valid");
1347
1348        // Now the attack. Same inbox, same receiver, but sequence id 0.
1349        let mut crafted_membership = GroupMembership::new();
1350        crafted_membership.add(inbox_id.clone(), 0);
1351        let crafted_diff = old_membership.diff(&crafted_membership);
1352        assert_eq!(crafted_diff.added_inboxes, vec![&inbox_id]);
1353
1354        let result = other_client
1355            .identity_updates()
1356            .get_installation_diff(
1357                &other_conn,
1358                &GroupId::default(),
1359                &old_membership,
1360                &crafted_membership,
1361                &crafted_diff,
1362            )
1363            .await;
1364
1365        assert!(
1366            result.is_err(),
1367            "an inbox added at sequence_id 0 must be rejected, not resolved at latest"
1368        );
1369    }
1370
1371    #[rstest::rstest]
1372    #[xmtp_common::test]
1373    pub async fn revoke_wallet() {
1374        let recovery_wallet = generate_local_wallet();
1375        let second_wallet = generate_local_wallet();
1376        let client = ClientBuilder::new_test_client(&recovery_wallet).await;
1377
1378        let mut add_wallet_signature_request = client
1379            .identity_updates()
1380            .associate_identity(second_wallet.identifier())
1381            .await
1382            .unwrap();
1383
1384        add_wallet_signature(&mut add_wallet_signature_request, &second_wallet).await;
1385
1386        client
1387            .identity_updates()
1388            .apply_signature_request(add_wallet_signature_request)
1389            .await
1390            .unwrap();
1391
1392        let association_state_after_add = get_association_state(&client, client.inbox_id()).await;
1393        assert_eq!(association_state_after_add.identifiers().len(), 2);
1394
1395        // Make sure the inbox ID is correctly registered
1396        let inbox_ids = client
1397            .context
1398            .api()
1399            .get_inbox_ids(vec![second_wallet.identifier().into()])
1400            .await
1401            .unwrap();
1402        assert_eq!(inbox_ids.len(), 1);
1403
1404        // Now revoke the second wallet
1405
1406        let mut revoke_signature_request = client
1407            .identity_updates()
1408            .revoke_identities(vec![second_wallet.identifier()])
1409            .await
1410            .unwrap();
1411        add_wallet_signature(&mut revoke_signature_request, &recovery_wallet).await;
1412
1413        client
1414            .identity_updates()
1415            .apply_signature_request(revoke_signature_request)
1416            .await
1417            .unwrap();
1418
1419        // Make sure that the association state has removed the second wallet
1420        let association_state_after_revoke =
1421            get_association_state(&client, client.inbox_id()).await;
1422        assert_eq!(association_state_after_revoke.identifiers().len(), 1);
1423
1424        // Make sure the inbox ID is correctly unregistered
1425        let inbox_ids = client
1426            .context
1427            .api()
1428            .get_inbox_ids(vec![second_wallet.identifier().into()])
1429            .await
1430            .unwrap();
1431        assert_eq!(inbox_ids, vec![None]);
1432    }
1433
1434    #[rstest::rstest]
1435    #[xmtp_common::test]
1436    pub async fn revoke_installation() {
1437        use xmtp_events::{ClientEvent, EventFilter, EventKind};
1438        let wallet = generate_local_wallet();
1439        let client1: FullXmtpClient = ClientBuilder::new_test_client(&wallet).await;
1440        let client2: FullXmtpClient = ClientBuilder::new_test_client(&wallet).await;
1441
1442        let association_state = get_association_state(&client1, client1.inbox_id()).await;
1443        // Ensure there are two installations on the inbox
1444        assert_eq!(association_state.installation_ids().len(), 2);
1445        let events = client1.context.events().subscribe(
1446            EventFilter::new([EventKind::IdentityOwnInstallationRevoked]),
1447            Some(10),
1448        );
1449
1450        // Now revoke the second client
1451        let mut revoke_installation_request = client1
1452            .identity_updates()
1453            .revoke_installations(vec![client2.installation_public_key().to_vec()])
1454            .await
1455            .unwrap();
1456        add_wallet_signature(&mut revoke_installation_request, &wallet).await;
1457        assert!(events.drain().is_empty());
1458        client1
1459            .identity_updates()
1460            .apply_signature_request(revoke_installation_request)
1461            .await
1462            .unwrap();
1463
1464        // Make sure there is only one installation on the inbox
1465        let association_state = get_association_state(&client1, client1.inbox_id()).await;
1466        assert_eq!(association_state.installation_ids().len(), 1);
1467        let revoked = events.drain();
1468        assert_eq!(revoked.len(), 1);
1469        assert!(matches!(&revoked[0].client,
1470            Some(ClientEvent::IdentityOwnInstallationRevoked(event))
1471                if event.installation_key == client2.installation_public_key()
1472                   && !event.is_this_installation));
1473    }
1474
1475    // verifies: EVENT-001
1476    #[rstest::rstest]
1477    #[xmtp_common::test(unwrap_try = true)]
1478    async fn direct_own_inbox_refresh_reports_each_installation_transition() {
1479        use xmtp_events::{ClientEvent, EventFilter, EventKind};
1480        let wallet = generate_local_wallet();
1481        let observer: FullXmtpClient = ClientBuilder::new_test_client(&wallet).await;
1482        observer.inbox_state(false).await?;
1483        let events = observer.context.events().subscribe(
1484            EventFilter::new([
1485                EventKind::IdentityOwnInstallationAdded,
1486                EventKind::IdentityOwnInstallationRevoked,
1487            ]),
1488            Some(10),
1489        );
1490
1491        let transient: FullXmtpClient = ClientBuilder::new_test_client(&wallet).await;
1492        let actor: FullXmtpClient = ClientBuilder::new_test_client(&wallet).await;
1493        let mut request = actor
1494            .identity_updates()
1495            .revoke_installations(vec![transient.installation_public_key().to_vec()])
1496            .await?;
1497        add_wallet_signature(&mut request, &wallet).await;
1498        actor
1499            .identity_updates()
1500            .apply_signature_request(request)
1501            .await?;
1502
1503        observer.inbox_state(true).await?;
1504        let changes = events.drain();
1505        assert_eq!(changes.len(), 3);
1506        assert!(changes.iter().any(|event| matches!(
1507            &event.client,
1508            Some(ClientEvent::IdentityOwnInstallationAdded(added))
1509                if added.installation_key == transient.installation_public_key().to_vec()
1510        )));
1511        assert!(changes.iter().any(|event| matches!(
1512            &event.client,
1513            Some(ClientEvent::IdentityOwnInstallationRevoked(revoked))
1514                if revoked.installation_key == transient.installation_public_key().to_vec()
1515        )));
1516        assert!(changes.iter().any(|event| matches!(
1517            &event.client,
1518            Some(ClientEvent::IdentityOwnInstallationAdded(added))
1519                if added.installation_key == actor.installation_public_key().to_vec()
1520        )));
1521    }
1522
1523    // verifies: EVENT-001
1524    #[rstest::rstest]
1525    #[xmtp_common::test(unwrap_try = true)]
1526    async fn concurrent_own_inbox_refresh_emits_one_addition() {
1527        use xmtp_events::{ClientEvent, EventFilter, EventKind};
1528
1529        let wallet = generate_local_wallet();
1530        let observer: FullXmtpClient = ClientBuilder::new_test_client(&wallet).await;
1531        observer.inbox_state(false).await?;
1532        let events = observer.context.events().subscribe(
1533            EventFilter::new([EventKind::IdentityOwnInstallationAdded]),
1534            Some(10),
1535        );
1536        let added: FullXmtpClient = ClientBuilder::new_test_client(&wallet).await;
1537
1538        let (first, second) = tokio::join!(observer.inbox_state(true), observer.inbox_state(true));
1539        first?;
1540        second?;
1541        let additions = events.drain();
1542        assert!(matches!(additions.as_slice(), [event]
1543            if matches!(&event.client,
1544                Some(ClientEvent::IdentityOwnInstallationAdded(value))
1545                    if value.installation_key == added.installation_public_key().to_vec()
1546            )
1547        ));
1548    }
1549
1550    #[cfg(not(target_arch = "wasm32"))]
1551    #[tokio::test(flavor = "multi_thread")]
1552    pub async fn revoke_installation_with_malformed_keypackage() {
1553        use crate::utils::test_mocks_helpers::set_test_mode_upload_malformed_keypackage;
1554
1555        let wallet = generate_local_wallet();
1556        let client1: FullXmtpClient = ClientBuilder::new_test_client(&wallet).await;
1557        let client2: FullXmtpClient = ClientBuilder::new_test_client(&wallet).await;
1558
1559        let association_state = get_association_state(&client1, client1.inbox_id()).await;
1560        // Ensure there are two installations on the inbox
1561        assert_eq!(association_state.installation_ids().len(), 2);
1562
1563        set_test_mode_upload_malformed_keypackage(
1564            true,
1565            Some(vec![client2.installation_public_key().to_vec()]),
1566        );
1567
1568        // Now revoke the second client
1569        let mut revoke_installation_request = client1
1570            .identity_updates()
1571            .revoke_installations(vec![client2.installation_public_key().to_vec()])
1572            .await
1573            .unwrap();
1574        add_wallet_signature(&mut revoke_installation_request, &wallet).await;
1575        client1
1576            .identity_updates()
1577            .apply_signature_request(revoke_installation_request)
1578            .await
1579            .unwrap();
1580
1581        // Make sure there is only one installation on the inbox
1582        let association_state = get_association_state(&client1, client1.inbox_id()).await;
1583        assert_eq!(association_state.installation_ids().len(), 1);
1584    }
1585
1586    #[cfg(not(target_arch = "wasm32"))]
1587    #[tokio::test(flavor = "multi_thread")]
1588    pub async fn revoke_good_installation_with_other_malformed_keypackage() {
1589        use crate::utils::test_mocks_helpers::set_test_mode_upload_malformed_keypackage;
1590
1591        let wallet = generate_local_wallet();
1592        let client1: FullXmtpClient = ClientBuilder::new_test_client(&wallet).await;
1593        let client2: FullXmtpClient = ClientBuilder::new_test_client(&wallet).await;
1594        let client3: FullXmtpClient = ClientBuilder::new_test_client(&wallet).await;
1595
1596        let association_state = get_association_state(&client1, client1.inbox_id()).await;
1597        // Ensure there are two installations on the inbox
1598        assert_eq!(association_state.installation_ids().len(), 3);
1599
1600        set_test_mode_upload_malformed_keypackage(
1601            true,
1602            Some(vec![client2.installation_public_key().to_vec()]),
1603        );
1604
1605        // Now revoke the second client
1606        let mut revoke_installation_request = client1
1607            .identity_updates()
1608            .revoke_installations(vec![client3.installation_public_key().to_vec()])
1609            .await
1610            .unwrap();
1611        add_wallet_signature(&mut revoke_installation_request, &wallet).await;
1612        client1
1613            .identity_updates()
1614            .apply_signature_request(revoke_installation_request)
1615            .await
1616            .unwrap();
1617
1618        // Make sure there is only one installation on the inbox
1619        let association_state = get_association_state(&client1, client1.inbox_id()).await;
1620        assert_eq!(association_state.installation_ids().len(), 2);
1621    }
1622
1623    #[rstest::rstest]
1624    #[xmtp_common::test]
1625    pub async fn change_recovery_address() {
1626        let original_wallet = generate_local_wallet();
1627        let new_recovery_wallet = generate_local_wallet();
1628        let client = ClientBuilder::new_test_client(&original_wallet).await;
1629
1630        // Verify initial state has the original wallet as recovery identifier
1631        let association_state_before = get_association_state(&client, client.inbox_id()).await;
1632        assert_eq!(
1633            association_state_before.recovery_identifier(),
1634            &original_wallet.identifier()
1635        );
1636
1637        // Verify that the associated wallet at this stage includes the recovery address
1638        assert!(association_state_before.members().len() == 2);
1639        // Verify that one of the members is the recovery address
1640        let binding = association_state_before.members();
1641        let recovery_member = binding
1642            .iter()
1643            .find(|m| m.identifier == original_wallet.identifier());
1644        assert!(recovery_member.is_some());
1645        let recovery_member_timestamp = recovery_member.unwrap().client_timestamp_ns;
1646        // Right now we are not saving client side timestamps for recovery address, so this will be None
1647        assert!(recovery_member_timestamp.is_none());
1648        // Verify the other member is an installation key
1649        let installation_member = binding
1650            .iter()
1651            .find(|m| matches!(m.identifier, MemberIdentifier::Installation(_)));
1652        assert!(installation_member.is_some());
1653        assert!(
1654            installation_member
1655                .unwrap()
1656                .identifier
1657                .installation_key()
1658                .unwrap()
1659                == client.installation_public_key().to_vec()
1660        );
1661        let installation_member_timestamp = installation_member.unwrap().client_timestamp_ns;
1662        assert!(installation_member_timestamp.is_some());
1663
1664        // Create a signature request to change the recovery address
1665        let mut change_recovery_request = client
1666            .identity_updates()
1667            .change_recovery_identifier(new_recovery_wallet.identifier())
1668            .await
1669            .unwrap();
1670
1671        // Add the original wallet's signature (since it's the current recovery address)
1672        add_wallet_signature(&mut change_recovery_request, &original_wallet).await;
1673
1674        // Apply the signature request
1675        client
1676            .identity_updates()
1677            .apply_signature_request(change_recovery_request)
1678            .await
1679            .unwrap();
1680
1681        // Verify the recovery address has been updated
1682        let association_state_after = get_association_state(&client, client.inbox_id()).await;
1683        assert_eq!(
1684            association_state_after.recovery_identifier(),
1685            &new_recovery_wallet.identifier()
1686        );
1687
1688        // Verify that the associated wallet still includes the original wallet
1689        assert!(association_state_after.members().len() == 2);
1690        // Verify that one of the members is the recovery address
1691        let binding = association_state_after.members();
1692        let recovery_member = binding
1693            .iter()
1694            .find(|m| m.identifier == original_wallet.identifier());
1695        assert!(recovery_member.is_some());
1696        let recovery_member_timestamp = recovery_member.unwrap().client_timestamp_ns;
1697        // Right now we are not saving client side timestamps for recovery address, so this will be None
1698        assert!(recovery_member_timestamp.is_none());
1699        // Verify the other member is an installation key
1700        let installation_member = binding
1701            .iter()
1702            .find(|m| matches!(m.identifier, MemberIdentifier::Installation(_)));
1703        assert!(installation_member.is_some());
1704        assert!(
1705            installation_member
1706                .unwrap()
1707                .identifier
1708                .installation_key()
1709                .unwrap()
1710                == client.installation_public_key().to_vec()
1711        );
1712        let installation_member_timestamp = installation_member.unwrap().client_timestamp_ns;
1713        assert!(installation_member_timestamp.is_some());
1714
1715        // Verify that the original wallet can no longer perform recovery operations
1716        // by attempting to revoke an installation with the original wallet
1717        let installation_id = client.installation_public_key().to_vec();
1718        let mut revoke_installation_request = client
1719            .identity_updates()
1720            .revoke_installations(vec![installation_id])
1721            .await
1722            .unwrap();
1723
1724        // Try to sign with the original wallet (will error since signer is not in the request)
1725        // add_wallet_signature(&mut revoke_installation_request, &original_wallet).await;
1726        let signature_text = revoke_installation_request.signature_text();
1727        let sig = original_wallet
1728            .sign_message(signature_text.as_bytes())
1729            .await
1730            .unwrap()
1731            .as_bytes()
1732            .to_vec();
1733        let unverified_sig = UnverifiedSignature::new_recoverable_ecdsa(sig);
1734        let scw_verifier = MockSmartContractSignatureVerifier::new(false);
1735
1736        let attempt_to_revoke_with_original_wallet = revoke_installation_request
1737            .add_signature(unverified_sig, &scw_verifier)
1738            .await;
1739
1740        assert!(matches!(
1741            attempt_to_revoke_with_original_wallet,
1742            Err(SignatureRequestError::UnknownSigner)
1743        ));
1744
1745        // Now try with the new recovery wallet (which should succeed)
1746        let installation_id = client.installation_public_key().to_vec();
1747        let mut revoke_installation_request = client
1748            .identity_updates()
1749            .revoke_installations(vec![installation_id])
1750            .await
1751            .unwrap();
1752
1753        // Sign with the new recovery wallet
1754        add_wallet_signature(&mut revoke_installation_request, &new_recovery_wallet).await;
1755
1756        // This should succeed because the new wallet is now the recovery address
1757        client
1758            .identity_updates()
1759            .apply_signature_request(revoke_installation_request)
1760            .await
1761            .unwrap();
1762
1763        // Verify the installation was revoked
1764        let association_state_final = get_association_state(&client, client.inbox_id()).await;
1765        assert_eq!(association_state_final.installation_ids().len(), 0);
1766    }
1767}
1768
1769#[cfg(test)]
1770mod conflict_tests {
1771    use super::*;
1772    use crate::tester;
1773    use xmtp_cryptography::utils::generate_local_wallet;
1774    use xmtp_id::associations::test_utils::{
1775        MockSmartContractSignatureVerifier, WalletTestExt, add_wallet_signature,
1776    };
1777    use xmtp_proto::{api::ApiClientError, backend_v1 as wire, types::Topic};
1778
1779    #[rstest::rstest]
1780    #[case(0)]
1781    #[case(1)]
1782    #[case(3)]
1783    #[case(4)]
1784    #[xmtp_common::test(unwrap_try = true)]
1785    async fn conflict_reloads_validates_and_bounds_identical_resends(#[case] conflicts: usize) {
1786        tester!(alix, disable_workers);
1787        let wallet = generate_local_wallet();
1788        let mut request = alix
1789            .identity_updates()
1790            .associate_identity(wallet.identifier())
1791            .await
1792            .unwrap();
1793        add_wallet_signature(&mut request, &wallet).await;
1794        let update = request.build_identity_update().unwrap();
1795        let expected: xmtp_proto::xmtp::identity::associations::IdentityUpdate =
1796            update.clone().into();
1797        let history = alix
1798            .context
1799            .api()
1800            .query_all(
1801                [(
1802                    Topic::new_identity_update(hex::decode(alix.inbox_id()).unwrap()),
1803                    Cursor(0),
1804                )]
1805                .into(),
1806                alix.context.api().limits().max_query_limit as u32,
1807            )
1808            .await
1809            .unwrap();
1810        let last_sequence = history
1811            .last()
1812            .unwrap()
1813            .meta
1814            .as_ref()
1815            .unwrap()
1816            .cursor
1817            .as_ref()
1818            .unwrap()
1819            .sequence_id;
1820        let mut mock = xmtp_api_backend::MockBackendClient::new();
1821        let mut calls = 0;
1822        mock.expect_publish()
1823            .times((conflicts + 1).min(4))
1824            .returning(move |request| {
1825                calls += 1;
1826                assert_eq!(request.envelopes.len(), 1);
1827                assert_eq!(
1828                    request.envelopes[0].payload,
1829                    Some(wire::client_envelope::Payload::IdentityUpdate(
1830                        expected.clone()
1831                    ))
1832                );
1833                if calls <= conflicts {
1834                    return Err(ApiClientError::client(
1835                        xmtp_api_grpc::error::GrpcError::Status(tonic::Status::aborted(
1836                            "identity conflict",
1837                        )),
1838                    ));
1839                }
1840                let parsed =
1841                    xmtp_mls_validation::parse_envelope(request.envelopes[0].clone()).unwrap();
1842                Ok(wire::PublishResponse {
1843                    envelope_metas: vec![wire::EnvelopeMeta {
1844                        cursor: Some(wire::Cursor {
1845                            sequence_id: last_sequence + 1,
1846                        }),
1847                        topic: Some(wire::Topic {
1848                            topic: parsed.topic.cloned_vec(),
1849                        }),
1850                        message_hash: Some(wire::MessageHash {
1851                            hash: Some(wire::message_hash::Hash::Sha256(
1852                                parsed.canonical.hash.to_vec(),
1853                            )),
1854                        }),
1855                        ..Default::default()
1856                    }],
1857                })
1858            });
1859        mock.expect_query()
1860            .times(conflicts.min(3))
1861            .returning(move |request| {
1862                assert_eq!(request.queries.len(), 1);
1863                let floor = request.queries[0].cursor.as_ref().unwrap().sequence_id;
1864                Ok(wire::QueryResponse {
1865                    envelopes: history
1866                        .iter()
1867                        .filter(|entry| {
1868                            entry
1869                                .meta
1870                                .as_ref()
1871                                .unwrap()
1872                                .cursor
1873                                .as_ref()
1874                                .unwrap()
1875                                .sequence_id
1876                                > floor
1877                        })
1878                        .cloned()
1879                        .collect(),
1880                    continuation: Some(wire::Continuation { has_more: false }),
1881                })
1882            });
1883        let api = ApiClientWrapper::new(mock, Default::default());
1884        let result = publish_with_conflict_retry(
1885            &api,
1886            &alix.context.db(),
1887            update,
1888            &MockSmartContractSignatureVerifier::new(true),
1889        )
1890        .await;
1891        if conflicts > 3 {
1892            assert!(matches!(
1893                result,
1894                Err(IdentityUpdateError::Api(
1895                    xmtp_api::ApiError::IdentityUpdateConflict
1896                ))
1897            ));
1898        } else {
1899            assert_eq!(result.unwrap(), Cursor(last_sequence + 1));
1900        }
1901    }
1902
1903    #[xmtp_common::test(flavor = "multi_thread", worker_threads = 4, unwrap_try = true)]
1904    async fn two_clients_racing_identity_updates_keep_both_associations() {
1905        tester!(alix, disable_workers);
1906        tester!(alix2, from: alix, disable_workers);
1907        use xmtp_proto::api::HasStats;
1908        let first_stats = alix.context.api().api_client.mls_stats();
1909        let second_stats = alix2.context.api().api_client.mls_stats();
1910        const MAX_RACE_ROUNDS: usize = 16;
1911        const UPDATES_PER_RACE: usize = 2;
1912        for _ in 0..MAX_RACE_ROUNDS {
1913            let first_wallet = generate_local_wallet();
1914            let second_wallet = generate_local_wallet();
1915            let mut first = alix
1916                .identity_updates()
1917                .associate_identity(first_wallet.identifier())
1918                .await?;
1919            let mut second = alix2
1920                .identity_updates()
1921                .associate_identity(second_wallet.identifier())
1922                .await?;
1923            add_wallet_signature(&mut first, &first_wallet).await;
1924            add_wallet_signature(&mut second, &second_wallet).await;
1925            let first_updates = alix.identity_updates();
1926            let second_updates = alix2.identity_updates();
1927            first_stats.clear();
1928            second_stats.clear();
1929            let (first, second) = {
1930                xmtp_common::wasm_or_native! {
1931                    native => {
1932                        // Start both RPCs on separate threads. Require a retry below.
1933                        let barrier = std::sync::Barrier::new(UPDATES_PER_RACE);
1934                        let runtime = tokio::runtime::Handle::current();
1935                        std::thread::scope(|scope| {
1936                            let first = scope.spawn(|| {
1937                                barrier.wait();
1938                                runtime.block_on(first_updates.apply_signature_request(first))
1939                            });
1940                            let second = scope.spawn(|| {
1941                                barrier.wait();
1942                                runtime.block_on(second_updates.apply_signature_request(second))
1943                            });
1944                            (first.join().unwrap(), second.join().unwrap())
1945                        })
1946                    },
1947                    wasm => {
1948                        futures::join!(
1949                            first_updates.apply_signature_request(first),
1950                            second_updates.apply_signature_request(second),
1951                        )
1952                    }
1953                }
1954            };
1955            first?;
1956            second?;
1957            let publishes = first_stats.publish.get_count() + second_stats.publish.get_count();
1958            let conn = alix.context.db();
1959            load_identity_updates(alix.context.api(), &conn, &[alix.inbox_id()]).await?;
1960            let state = alix
1961                .identity_updates()
1962                .get_association_state(&conn, alix.inbox_id(), None)
1963                .await?;
1964            assert!(state.get(&first_wallet.identifier().into()).is_some());
1965            assert!(state.get(&second_wallet.identifier().into()).is_some());
1966            if publishes > UPDATES_PER_RACE {
1967                tracing::info!(publishes, "Both identity updates survived a publish retry");
1968                return;
1969            }
1970        }
1971        panic!("No identity publish retry occurred in {MAX_RACE_ROUNDS} races");
1972    }
1973}