Skip to main content

xmtp_id/associations/
association_log.rs

1use super::handle::{Handle, InvalidHandle, MAX_HANDLE_CLAIMS};
2use super::member::{HasMemberKind, Identifier, Member, MemberIdentifier, MemberKind};
3use super::serialization::DeserializationError;
4use super::signature::{SignatureError, SignatureKind};
5use super::state::AssociationState;
6use super::verified_signature::VerifiedSignature;
7use thiserror::Error;
8use xmtp_common::ErrorCode;
9
10#[derive(Debug, Error, ErrorCode)]
11pub enum AssociationError {
12    /// Generic association error.
13    ///
14    /// Unclassified association error. Not retryable.
15    #[error("Error creating association {0}")]
16    Generic(String),
17    /// Multiple create operations.
18    ///
19    /// Duplicate inbox creation detected. Not retryable.
20    #[error("Multiple create operations detected")]
21    MultipleCreate,
22    /// XID not yet created.
23    ///
24    /// Operating on inbox that doesn't exist yet. Not retryable.
25    #[error("XID not yet created")]
26    NotCreated,
27    #[error("Signature validation failed {0}")]
28    #[error_code(inherit)]
29    Signature(#[from] SignatureError),
30    /// Member not allowed.
31    ///
32    /// Member kind cannot add the specified kind. Not retryable.
33    #[error("Member of kind {0} not allowed to add {1}")]
34    MemberNotAllowed(MemberKind, MemberKind),
35    /// Missing existing member.
36    ///
37    /// Required signer not found or signer identity mismatch. Not retryable.
38    #[error("Missing existing member")]
39    MissingExistingMember,
40    /// Already a member.
41    ///
42    /// The member being added is already a member of the inbox. Not retryable.
43    #[error("The new member is already a member of the inbox")]
44    AlreadyMember,
45    /// Empty update.
46    ///
47    /// The identity update carries no actions. Not retryable.
48    #[error("Identity update has no actions")]
49    EmptyUpdate,
50    /// New member ID signature mismatch.
51    ///
52    /// Signer doesn't match new member identifier. Not retryable.
53    #[error("The new member identifier does not match the signer")]
54    NewMemberIdSignatureMismatch,
55    /// Wrong Inbox ID.
56    ///
57    /// Incorrect inbox_id in association. Not retryable.
58    #[error("Wrong inbox_id specified on association")]
59    WrongInboxId,
60    /// Signature not allowed.
61    ///
62    /// Signature type not permitted for this role. Not retryable.
63    #[error("Signature not allowed for role {0:?} {1:?}")]
64    SignatureNotAllowed(String, String),
65    /// Replay detected.
66    ///
67    /// Replayed identity update detected. Not retryable.
68    #[error("Replay detected")]
69    Replay,
70    #[error("Deserialization error {0}")]
71    #[error_code(inherit)]
72    Deserialization(#[from] DeserializationError),
73    /// Missing identity update.
74    ///
75    /// Required identity update not provided. Not retryable.
76    #[error("Missing identity update")]
77    MissingIdentityUpdate,
78    /// Chain ID mismatch.
79    ///
80    /// Smart contract wallet chain ID changed. Not retryable.
81    #[error("Wrong chain id. Initially added with {0} but now signing from {1}")]
82    ChainIdMismatch(u64, u64),
83    /// Invalid account address.
84    ///
85    /// Address is not 42-char hex starting with 0x. Not retryable.
86    #[error("Invalid account address: Must be 42 hex characters, starting with '0x'.")]
87    InvalidAccountAddress,
88    /// Not an identifier.
89    ///
90    /// Value is not a valid public identifier. Not retryable.
91    #[error("{0} are not a public identifier")]
92    NotIdentifier(String),
93    #[error(transparent)]
94    #[error_code(inherit)]
95    Convert(#[from] xmtp_proto::ConversionError),
96    #[error(transparent)]
97    #[error_code(inherit)]
98    InvalidHandle(#[from] InvalidHandle),
99    /// Claim not alone.
100    ///
101    /// An identity update that claims a handle carries another action. Not retryable.
102    #[error("an identity update that claims a handle must carry no other action")]
103    ClaimNotAlone,
104    /// Handle claim limit reached.
105    ///
106    /// The inbox's log already holds the most handle claims it can hold. Not retryable.
107    #[error("the inbox has reached the limit of handle claims")]
108    HandleClaimLimit,
109}
110
111pub trait IdentityAction: Send {
112    fn update_state(
113        &self,
114        existing_state: Option<AssociationState>,
115        client_timestamp_ns: u64,
116    ) -> Result<AssociationState, AssociationError>;
117    /// The canonical replay key of every signature the action carries (see [`VerifiedSignature`]).
118    fn replay_keys(&self) -> Vec<Vec<u8>>;
119    // implements: IDENT-050
120    fn replay_check(&self, state: &AssociationState) -> Result<(), AssociationError> {
121        if self.replay_keys().iter().any(|key| state.has_seen(key)) {
122            return Err(AssociationError::Replay);
123        }
124        Ok(())
125    }
126}
127
128/// CreateInbox Action
129#[derive(Debug, Clone)]
130pub struct CreateInbox {
131    pub nonce: u64,
132    pub account_identifier: Identifier,
133    pub initial_identifier_signature: VerifiedSignature,
134}
135
136impl IdentityAction for CreateInbox {
137    // implements: IDENT-011, IDENT-012
138    fn update_state(
139        &self,
140        existing_state: Option<AssociationState>,
141        _client_timestamp_ns: u64,
142    ) -> Result<AssociationState, AssociationError> {
143        if existing_state.is_some() {
144            return Err(AssociationError::MultipleCreate);
145        }
146
147        let account_address = self.account_identifier.clone();
148        let recovered_signer = self.initial_identifier_signature.signer.clone();
149        if recovered_signer != account_address {
150            return Err(AssociationError::MissingExistingMember);
151        }
152
153        allowed_signature_for_kind(
154            &self.account_identifier.kind(),
155            &self.initial_identifier_signature.kind,
156        )?;
157
158        AssociationState::new(
159            account_address,
160            self.nonce,
161            self.initial_identifier_signature.chain_id,
162        )
163    }
164
165    fn replay_keys(&self) -> Vec<Vec<u8>> {
166        vec![self.initial_identifier_signature.replay_key.clone()]
167    }
168}
169
170/// AddAssociation Action
171#[derive(Debug, Clone)]
172pub struct AddAssociation {
173    pub new_member_signature: VerifiedSignature,
174    pub new_member_identifier: MemberIdentifier,
175    pub existing_member_signature: VerifiedSignature,
176}
177
178impl IdentityAction for AddAssociation {
179    // implements: IDENT-040, IDENT-042
180    fn update_state(
181        &self,
182        maybe_existing_state: Option<AssociationState>,
183        client_timestamp_ns: u64,
184    ) -> Result<AssociationState, AssociationError> {
185        let existing_state = maybe_existing_state.ok_or(AssociationError::NotCreated)?;
186        self.replay_check(&existing_state)?;
187
188        // Validate the new member signature and get the recovered signer
189        let new_member_address = &self.new_member_signature.signer;
190        // Validate the existing member signature and get the recovedred signer
191        let existing_member_identifier = &self.existing_member_signature.signer;
192
193        if new_member_address.ne(&self.new_member_identifier) {
194            return Err(AssociationError::NewMemberIdSignatureMismatch);
195        }
196
197        // You cannot add yourself
198        if new_member_address == existing_member_identifier {
199            return Err(AssociationError::Generic("tried to add self".to_string()));
200        }
201
202        // Re-adding a member would rewrite its adder and spend a log slot
203        if existing_state.get(&self.new_member_identifier).is_some() {
204            return Err(AssociationError::AlreadyMember);
205        }
206
207        allowed_signature_for_kind(
208            &self.new_member_identifier.kind(),
209            &self.new_member_signature.kind,
210        )?;
211
212        let existing_member = existing_state.get(existing_member_identifier);
213
214        if let Some(member) = existing_member {
215            verify_chain_id_matches(member, &self.existing_member_signature)?;
216        }
217
218        let existing_entity_id = match existing_member {
219            // If there is an existing member of the XID, use that member's ID
220            Some(member) => member.identifier.clone(),
221            None => {
222                // Get the recovery address from the state as a MemberIdentifier
223                let recovery_identifier = existing_state.recovery_identifier().clone().into();
224
225                // Check if it is a signature from the recovery address, which is allowed to add members
226                if *existing_member_identifier != recovery_identifier {
227                    return Err(AssociationError::MissingExistingMember);
228                }
229                recovery_identifier
230            }
231        };
232
233        // Ensure that the existing member signature is correct for the existing member type
234        allowed_signature_for_kind(
235            &existing_entity_id.kind(),
236            &self.existing_member_signature.kind,
237        )?;
238
239        // Ensure that the new member signature is correct for the new member type
240        allowed_association(
241            existing_member_identifier.kind(),
242            self.new_member_identifier.kind(),
243        )?;
244
245        let new_member = Member::new(
246            new_member_address.clone(),
247            Some(existing_entity_id),
248            Some(client_timestamp_ns),
249            self.new_member_signature.chain_id,
250        );
251
252        Ok(existing_state.add(new_member))
253    }
254
255    fn replay_keys(&self) -> Vec<Vec<u8>> {
256        vec![
257            self.existing_member_signature.replay_key.clone(),
258            self.new_member_signature.replay_key.clone(),
259        ]
260    }
261}
262
263/// RevokeAssociation Action
264#[derive(Debug, Clone)]
265pub struct RevokeAssociation {
266    pub recovery_identifier_signature: VerifiedSignature,
267    pub revoked_member: MemberIdentifier,
268}
269
270impl IdentityAction for RevokeAssociation {
271    // implements: IDENT-043, IDENT-044
272    fn update_state(
273        &self,
274        maybe_existing_state: Option<AssociationState>,
275        _client_timestamp_ns: u64,
276    ) -> Result<AssociationState, AssociationError> {
277        let existing_state = maybe_existing_state.ok_or(AssociationError::NotCreated)?;
278        self.replay_check(&existing_state)?;
279
280        // Ensure that the new signature is on the same chain as the signature to create the account
281        let existing_member = existing_state.get(&self.recovery_identifier_signature.signer);
282        if let Some(member) = existing_member {
283            verify_chain_id_matches(member, &self.recovery_identifier_signature)?;
284        }
285
286        // Don't need to check for replay here since revocation is idempotent
287        let recovery_signer = &self.recovery_identifier_signature.signer;
288        // Make sure there is a recovery address set on the state
289        let state_recovery_identifier: MemberIdentifier =
290            existing_state.recovery_identifier.clone().into();
291
292        if *recovery_signer != state_recovery_identifier {
293            return Err(AssociationError::MissingExistingMember);
294        }
295
296        let installations_to_remove: Vec<Member> = existing_state
297            .members_by_parent(&self.revoked_member)
298            .into_iter()
299            // Only remove children if they are installations
300            .filter(|child| child.kind() == MemberKind::Installation)
301            .collect();
302
303        // Actually apply the revocation to the parent
304        let new_state = existing_state.remove(&self.revoked_member);
305
306        Ok(installations_to_remove
307            .iter()
308            .fold(new_state, |state, installation| {
309                state.remove(&installation.identifier)
310            }))
311    }
312
313    fn replay_keys(&self) -> Vec<Vec<u8>> {
314        vec![self.recovery_identifier_signature.replay_key.clone()]
315    }
316}
317
318/// ChangeRecoveryAddress Action
319#[derive(Debug, Clone)]
320pub struct ChangeRecoveryIdentity {
321    pub recovery_identifier_signature: VerifiedSignature,
322    pub new_recovery_identifier: Identifier,
323}
324
325impl IdentityAction for ChangeRecoveryIdentity {
326    fn update_state(
327        &self,
328        existing_state: Option<AssociationState>,
329        _client_timestamp_ns: u64,
330    ) -> Result<AssociationState, AssociationError> {
331        let existing_state = existing_state.ok_or(AssociationError::NotCreated)?;
332        self.replay_check(&existing_state)?;
333
334        let existing_member = existing_state.get(&self.recovery_identifier_signature.signer);
335        if let Some(member) = existing_member {
336            verify_chain_id_matches(member, &self.recovery_identifier_signature)?;
337        }
338
339        let recovery_signer = &self.recovery_identifier_signature.signer;
340        if existing_state.recovery_identifier() != recovery_signer {
341            return Err(AssociationError::MissingExistingMember);
342        }
343
344        Ok(existing_state.set_recovery_identifier(self.new_recovery_identifier.clone()))
345    }
346
347    fn replay_keys(&self) -> Vec<Vec<u8>> {
348        vec![self.recovery_identifier_signature.replay_key.clone()]
349    }
350}
351
352/// ClaimHandle Action
353///
354/// `handle` is the text as carried. The grammar check runs when the action is
355/// applied, so a claim outside the grammar fails as an invalid identity update.
356#[derive(Debug, Clone)]
357pub struct ClaimHandle {
358    pub handle: String,
359    pub signature: VerifiedSignature,
360}
361
362impl IdentityAction for ClaimHandle {
363    /// Applies the claim: the grammar, a signer that is a current member on the
364    /// chain it was added on, and the claim limit. The backend's uniqueness,
365    /// name, and interval checks are not applied here, so a stored log always
366    /// replays.
367    // implements: IDENT-078, IDENT-080, IDENT-081, IDENT-082
368    fn update_state(
369        &self,
370        existing_state: Option<AssociationState>,
371        _client_timestamp_ns: u64,
372    ) -> Result<AssociationState, AssociationError> {
373        let existing_state = existing_state.ok_or(AssociationError::NotCreated)?;
374        self.replay_check(&existing_state)?;
375        let handle = Handle::parse(&self.handle)?;
376
377        // A recovery identifier that is not a member cannot sign a claim.
378        let signer = existing_state
379            .get(&self.signature.signer)
380            .ok_or(AssociationError::MissingExistingMember)?;
381        verify_chain_id_matches(signer, &self.signature)?;
382        allowed_signature_for_kind(&signer.kind(), &self.signature.kind)?;
383
384        if existing_state.handle_claims() >= MAX_HANDLE_CLAIMS {
385            return Err(AssociationError::HandleClaimLimit);
386        }
387
388        Ok(existing_state.claim_handle(handle))
389    }
390
391    fn replay_keys(&self) -> Vec<Vec<u8>> {
392        vec![self.signature.replay_key.clone()]
393    }
394}
395
396/// All possible Action types that can be used inside an `IdentityUpdate`
397#[derive(Debug, Clone)]
398pub enum Action {
399    CreateInbox(CreateInbox),
400    AddAssociation(AddAssociation),
401    RevokeAssociation(RevokeAssociation),
402    ChangeRecoveryIdentity(ChangeRecoveryIdentity),
403    ClaimHandle(ClaimHandle),
404}
405
406impl IdentityAction for Action {
407    fn update_state(
408        &self,
409        existing_state: Option<AssociationState>,
410        client_timestamp_ns: u64,
411    ) -> Result<AssociationState, AssociationError> {
412        match self {
413            Action::CreateInbox(event) => event.update_state(existing_state, client_timestamp_ns),
414            Action::AddAssociation(event) => {
415                event.update_state(existing_state, client_timestamp_ns)
416            }
417            Action::RevokeAssociation(event) => {
418                event.update_state(existing_state, client_timestamp_ns)
419            }
420            Action::ChangeRecoveryIdentity(event) => {
421                event.update_state(existing_state, client_timestamp_ns)
422            }
423            Action::ClaimHandle(event) => event.update_state(existing_state, client_timestamp_ns),
424        }
425    }
426
427    fn replay_keys(&self) -> Vec<Vec<u8>> {
428        match self {
429            Action::CreateInbox(event) => event.replay_keys(),
430            Action::AddAssociation(event) => event.replay_keys(),
431            Action::RevokeAssociation(event) => event.replay_keys(),
432            Action::ChangeRecoveryIdentity(event) => event.replay_keys(),
433            Action::ClaimHandle(event) => event.replay_keys(),
434        }
435    }
436}
437
438/// An `IdentityUpdate` contains one or more Actions that can be applied to the AssociationState
439#[derive(Debug, Clone)]
440pub struct IdentityUpdate {
441    pub inbox_id: String,
442    pub client_timestamp_ns: u64,
443    pub actions: Vec<Action>,
444}
445
446impl IdentityUpdate {
447    pub fn new(actions: Vec<Action>, inbox_id: String, client_timestamp_ns: u64) -> Self {
448        Self {
449            inbox_id,
450            actions,
451            client_timestamp_ns,
452        }
453    }
454
455    /// Get the signature kind used to create this inbox if this update contains a CreateInbox action.
456    /// Returns None if there is no CreateInbox action in this update.
457    ///
458    /// This is useful for determining whether an identity was created with a Smart Contract Wallet (Erc1271)
459    /// or an Externally Owned Account/EOA (Erc191) signature
460    pub fn creation_signature_kind(&self) -> Option<SignatureKind> {
461        self.actions.iter().find_map(|action| match action {
462            Action::CreateInbox(create_inbox) => {
463                Some(create_inbox.initial_identifier_signature.kind.clone())
464            }
465            _ => None,
466        })
467    }
468
469    /// Whether this update carries a `ClaimHandle`. An update that carries one
470    /// and applies carries no other action.
471    pub fn claims_handle(&self) -> bool {
472        self.actions
473            .iter()
474            .any(|action| matches!(action, Action::ClaimHandle(_)))
475    }
476}
477
478impl IdentityAction for IdentityUpdate {
479    // implements: IDENT-001, IDENT-002, IDENT-003, IDENT-079
480    fn update_state(
481        &self,
482        existing_state: Option<AssociationState>,
483        _client_timestamp_ns: u64,
484    ) -> Result<AssociationState, AssociationError> {
485        if self.actions.is_empty() {
486            return Err(AssociationError::EmptyUpdate);
487        }
488        // Checked before any action applies, so one update cannot pass the
489        // backend's interval between claims for several claims.
490        if self.actions.len() > 1 && self.claims_handle() {
491            return Err(AssociationError::ClaimNotAlone);
492        }
493        let mut state = existing_state;
494        for action in &self.actions {
495            state = Some(action.update_state(state, self.client_timestamp_ns)?);
496        }
497
498        let new_state = state.ok_or(AssociationError::NotCreated)?;
499        if new_state.inbox_id().ne(&self.inbox_id) {
500            tracing::error!(
501                "state inbox id mismatch, old: {}, new: {}",
502                self.inbox_id,
503                new_state.inbox_id()
504            );
505            return Err(AssociationError::WrongInboxId);
506        }
507
508        // After all the updates in the LogEntry have been processed, add the list of signatures to the state
509        // so that the signatures can not be re-used in subsequent updates
510        Ok(new_state.add_seen_signatures(self.replay_keys()))
511    }
512
513    fn replay_keys(&self) -> Vec<Vec<u8>> {
514        self.actions
515            .iter()
516            .flat_map(|action| action.replay_keys())
517            .collect()
518    }
519}
520
521// implements: IDENT-041
522fn allowed_association(
523    existing_member_kind: MemberKind,
524    new_member_kind: MemberKind,
525) -> Result<(), AssociationError> {
526    // The only disallowed association is an installation adding an installation
527    if existing_member_kind == MemberKind::Installation
528        && new_member_kind == MemberKind::Installation
529    {
530        return Err(AssociationError::MemberNotAllowed(
531            existing_member_kind,
532            new_member_kind,
533        ));
534    }
535
536    Ok(())
537}
538
539// Ensure that the type of signature matches the new entity's role.
540// implements: IDENT-031
541fn allowed_signature_for_kind(
542    role: &MemberKind,
543    signature_kind: &SignatureKind,
544) -> Result<(), AssociationError> {
545    let is_ok = match role {
546        MemberKind::Ethereum => matches!(
547            signature_kind,
548            SignatureKind::Erc191 | SignatureKind::Erc1271
549        ),
550        MemberKind::Installation => matches!(signature_kind, SignatureKind::InstallationKey),
551        MemberKind::Passkey => matches!(signature_kind, SignatureKind::P256),
552    };
553
554    if !is_ok {
555        return Err(AssociationError::SignatureNotAllowed(
556            role.to_string(),
557            signature_kind.to_string(),
558        ));
559    }
560
561    Ok(())
562}
563
564fn verify_chain_id_matches(
565    member: &Member,
566    signature: &VerifiedSignature,
567) -> Result<(), AssociationError> {
568    if member.added_on_chain_id != signature.chain_id {
569        return Err(AssociationError::ChainIdMismatch(
570            member.added_on_chain_id.unwrap_or(0),
571            signature.chain_id.unwrap_or(0),
572        ));
573    }
574
575    Ok(())
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use crate::{
582        InboxOwner,
583        associations::{
584            apply_update,
585            builder::SignatureRequestBuilder,
586            get_state,
587            test_utils::{
588                MockSmartContractSignatureVerifier, WalletTestExt, ecdsa_negated_s_alias,
589                ecdsa_recovery_byte_alias, p256_negated_s_alias,
590            },
591            unverified::{UnverifiedAction, UnverifiedIdentityUpdate, UnverifiedSignature},
592        },
593        utils::passkey::PasskeyUser,
594    };
595    use alloy::signers::local::PrivateKeySigner;
596
597    async fn sign(
598        builder: SignatureRequestBuilder,
599        owners: &[&dyn InboxOwner],
600    ) -> UnverifiedIdentityUpdate {
601        let mut request = builder.build();
602        for owner in owners {
603            let signature = owner.sign(&request.signature_text()).unwrap();
604            request
605                .add_signature(signature, MockSmartContractSignatureVerifier::new(false))
606                .await
607                .unwrap();
608        }
609        request.build_identity_update().unwrap()
610    }
611
612    async fn verify(update: &UnverifiedIdentityUpdate) -> IdentityUpdate {
613        update
614            .to_verified(MockSmartContractSignatureVerifier::new(false))
615            .await
616            .unwrap()
617    }
618
619    /// `update` with every signature rewritten by `alias`; what was signed is untouched.
620    fn reencode(
621        update: &UnverifiedIdentityUpdate,
622        alias: impl Fn(&mut UnverifiedSignature),
623    ) -> UnverifiedIdentityUpdate {
624        let mut update = update.clone();
625        for action in &mut update.actions {
626            match action {
627                UnverifiedAction::CreateInbox(a) => alias(&mut a.initial_identifier_signature),
628                UnverifiedAction::AddAssociation(a) => {
629                    alias(&mut a.existing_member_signature);
630                    alias(&mut a.new_member_signature);
631                }
632                UnverifiedAction::RevokeAssociation(a) => {
633                    alias(&mut a.recovery_identifier_signature)
634                }
635                UnverifiedAction::ChangeRecoveryAddress(a) => {
636                    alias(&mut a.recovery_identifier_signature)
637                }
638                UnverifiedAction::ClaimHandle(a) => alias(&mut a.signature),
639            }
640        }
641        update
642    }
643
644    fn recovery_byte_form(signature: &mut UnverifiedSignature) {
645        if let UnverifiedSignature::RecoverableEcdsa(s) = signature {
646            s.signature_bytes = ecdsa_recovery_byte_alias(&s.signature_bytes);
647        }
648    }
649
650    fn negated_s_form(signature: &mut UnverifiedSignature) {
651        match signature {
652            UnverifiedSignature::RecoverableEcdsa(s) => {
653                s.signature_bytes = ecdsa_negated_s_alias(&s.signature_bytes)
654            }
655            UnverifiedSignature::Passkey(s) => s.signature = p256_negated_s_alias(&s.signature),
656            _ => {}
657        }
658    }
659
660    /// Adds `member` with `owner`'s signature, then revokes it. Returns the state just before the
661    /// add, the state after the revoke, and the signed add for replaying.
662    async fn add_then_revoke(
663        recovery: &PrivateKeySigner,
664        member: &dyn InboxOwner,
665    ) -> (AssociationState, AssociationState, UnverifiedIdentityUpdate) {
666        let inbox_id = recovery.get_inbox_id(0);
667        let recovery_id = recovery.identifier();
668        let member_id: MemberIdentifier = member.get_identifier().unwrap().into();
669        let create = sign(
670            SignatureRequestBuilder::new(&inbox_id).create_inbox(recovery_id.clone(), 0),
671            &[recovery],
672        )
673        .await;
674        let add = sign(
675            SignatureRequestBuilder::new(&inbox_id)
676                .add_association(member_id.clone(), recovery_id.clone().into()),
677            &[recovery, member],
678        )
679        .await;
680        let revoke = sign(
681            SignatureRequestBuilder::new(&inbox_id)
682                .revoke_association(recovery_id.into(), member_id.clone()),
683            &[recovery],
684        )
685        .await;
686
687        let created = get_state([verify(&create).await]).unwrap();
688        let added = apply_update(created.clone(), verify(&add).await).unwrap();
689        assert!(added.get(&member_id).is_some());
690        let revoked = apply_update(added, verify(&revoke).await).unwrap();
691        assert!(revoked.get(&member_id).is_none());
692        (created, revoked, add)
693    }
694
695    /// Replays `add` re-encoded by `alias` after the revoke. The re-encoding is a valid add in its
696    /// own right, yet shares its replay keys with the original, so it is rejected and the member
697    /// stays revoked.
698    async fn assert_alias_is_replay(
699        created: &AssociationState,
700        revoked: &AssociationState,
701        add: &UnverifiedIdentityUpdate,
702        member: &MemberIdentifier,
703        alias: impl Fn(&mut UnverifiedSignature),
704    ) {
705        let replay = reencode(add, alias);
706        assert_ne!(&replay, add, "the alias must change the signature bytes");
707        let replay = verify(&replay).await;
708        let fresh = apply_update(created.clone(), replay.clone()).unwrap();
709        assert!(fresh.get(member).is_some(), "the alias is a valid add");
710
711        let result = apply_update(revoked.clone(), replay);
712        assert!(
713            matches!(result, Err(AssociationError::Replay)),
714            "{result:?}"
715        );
716        assert!(revoked.get(member).is_none());
717    }
718
719    /// A revoked wallet's add, republished with its wallet signatures moved between the 0/1 and
720    /// 27/28 recovery-byte forms or with `s` negated, is the same add and must not re-add the
721    /// wallet.
722    #[xmtp_common::test]
723    // verifies: IDENT-050
724    async fn identity_replay_aliases_wallet_add_after_revoke() {
725        let recovery = PrivateKeySigner::random();
726        let member = PrivateKeySigner::random();
727        let member_id = member.member_identifier();
728        let (created, revoked, add) = add_then_revoke(&recovery, &member).await;
729
730        assert_alias_is_replay(&created, &revoked, &add, &member_id, recovery_byte_form).await;
731        assert_alias_is_replay(&created, &revoked, &add, &member_id, negated_s_form).await;
732        assert_alias_is_replay(&created, &revoked, &add, &member_id, |s| {
733            negated_s_form(s);
734            recovery_byte_form(s);
735        })
736        .await;
737    }
738
739    /// A revoked passkey's add, republished with the passkey's `s` negated and the recovery
740    /// wallet's recovery byte re-encoded, is the same add and must not re-add the passkey.
741    #[xmtp_common::test]
742    // verifies: IDENT-050
743    async fn identity_replay_aliases_passkey_add_after_revoke() {
744        let recovery = PrivateKeySigner::random();
745        let passkey = PasskeyUser::new().await;
746        let member_id: MemberIdentifier = passkey.identifier().into();
747        let (created, revoked, add) = add_then_revoke(&recovery, &passkey).await;
748
749        assert_alias_is_replay(&created, &revoked, &add, &member_id, |s| match s {
750            UnverifiedSignature::Passkey(_) => negated_s_form(s),
751            _ => recovery_byte_form(s),
752        })
753        .await;
754    }
755}