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