Skip to main content

xmtp_id/associations/
association_log.rs

1use super::member::{HasMemberKind, Identifier, Member, MemberIdentifier, MemberKind};
2use super::serialization::DeserializationError;
3use super::signature::{SignatureError, SignatureKind};
4use super::state::AssociationState;
5use super::verified_signature::VerifiedSignature;
6use thiserror::Error;
7use xmtp_common::ErrorCode;
8
9#[derive(Debug, Error, ErrorCode)]
10pub enum AssociationError {
11    /// Generic association error.
12    ///
13    /// Unclassified association error. Not retryable.
14    #[error("Error creating association {0}")]
15    Generic(String),
16    /// Multiple create operations.
17    ///
18    /// Duplicate inbox creation detected. Not retryable.
19    #[error("Multiple create operations detected")]
20    MultipleCreate,
21    /// XID not yet created.
22    ///
23    /// Operating on inbox that doesn't exist yet. Not retryable.
24    #[error("XID not yet created")]
25    NotCreated,
26    #[error("Signature validation failed {0}")]
27    #[error_code(inherit)]
28    Signature(#[from] SignatureError),
29    /// Member not allowed.
30    ///
31    /// Member kind cannot add the specified kind. Not retryable.
32    #[error("Member of kind {0} not allowed to add {1}")]
33    MemberNotAllowed(MemberKind, MemberKind),
34    /// Missing existing member.
35    ///
36    /// Required signer not found or signer identity mismatch. Not retryable.
37    #[error("Missing existing member")]
38    MissingExistingMember,
39    /// Legacy signature reuse.
40    ///
41    /// Legacy delegated signature used in disallowed context. Not retryable.
42    #[error("Legacy key is only allowed to be associated using a legacy signature with nonce 0")]
43    LegacySignatureReuse,
44    /// New member ID signature mismatch.
45    ///
46    /// Signer doesn't match new member identifier. Not retryable.
47    #[error("The new member identifier does not match the signer")]
48    NewMemberIdSignatureMismatch,
49    /// Wrong Inbox ID.
50    ///
51    /// Incorrect inbox_id in association. Not retryable.
52    #[error("Wrong inbox_id specified on association")]
53    WrongInboxId,
54    /// Signature not allowed.
55    ///
56    /// Signature type not permitted for this role. Not retryable.
57    #[error("Signature not allowed for role {0:?} {1:?}")]
58    SignatureNotAllowed(String, String),
59    /// Replay detected.
60    ///
61    /// Replayed identity update detected. Not retryable.
62    #[error("Replay detected")]
63    Replay,
64    #[error("Deserialization error {0}")]
65    #[error_code(inherit)]
66    Deserialization(#[from] DeserializationError),
67    /// Missing identity update.
68    ///
69    /// Required identity update not provided. Not retryable.
70    #[error("Missing identity update")]
71    MissingIdentityUpdate,
72    /// Chain ID mismatch.
73    ///
74    /// Smart contract wallet chain ID changed. Not retryable.
75    #[error("Wrong chain id. Initially added with {0} but now signing from {1}")]
76    ChainIdMismatch(u64, u64),
77    /// Invalid account address.
78    ///
79    /// Address is not 42-char hex starting with 0x. Not retryable.
80    #[error("Invalid account address: Must be 42 hex characters, starting with '0x'.")]
81    InvalidAccountAddress,
82    /// Not an identifier.
83    ///
84    /// Value is not a valid public identifier. Not retryable.
85    #[error("{0} are not a public identifier")]
86    NotIdentifier(String),
87    #[error(transparent)]
88    #[error_code(inherit)]
89    Convert(#[from] xmtp_proto::ConversionError),
90}
91
92pub trait IdentityAction: Send {
93    fn update_state(
94        &self,
95        existing_state: Option<AssociationState>,
96        client_timestamp_ns: u64,
97    ) -> Result<AssociationState, AssociationError>;
98    fn signatures(&self) -> Vec<Vec<u8>>;
99    fn replay_check(&self, state: &AssociationState) -> Result<(), AssociationError> {
100        let signatures = self.signatures();
101        for signature in signatures {
102            if state.has_seen(&signature) {
103                return Err(AssociationError::Replay);
104            }
105        }
106
107        Ok(())
108    }
109}
110
111/// CreateInbox Action
112#[derive(Debug, Clone)]
113pub struct CreateInbox {
114    pub nonce: u64,
115    pub account_identifier: Identifier,
116    pub initial_identifier_signature: VerifiedSignature,
117}
118
119impl IdentityAction for CreateInbox {
120    fn update_state(
121        &self,
122        existing_state: Option<AssociationState>,
123        _client_timestamp_ns: u64,
124    ) -> Result<AssociationState, AssociationError> {
125        if existing_state.is_some() {
126            return Err(AssociationError::MultipleCreate);
127        }
128
129        let account_address = self.account_identifier.clone();
130        let recovered_signer = self.initial_identifier_signature.signer.clone();
131        if recovered_signer != account_address {
132            return Err(AssociationError::MissingExistingMember);
133        }
134
135        allowed_signature_for_kind(
136            &self.account_identifier.kind(),
137            &self.initial_identifier_signature.kind,
138        )?;
139
140        if self.initial_identifier_signature.kind == SignatureKind::LegacyDelegated
141            && self.nonce != 0
142        {
143            return Err(AssociationError::LegacySignatureReuse);
144        }
145
146        AssociationState::new(
147            account_address,
148            self.nonce,
149            self.initial_identifier_signature.chain_id,
150        )
151    }
152
153    fn signatures(&self) -> Vec<Vec<u8>> {
154        vec![self.initial_identifier_signature.raw_bytes.clone()]
155    }
156}
157
158/// AddAssociation Action
159#[derive(Debug, Clone)]
160pub struct AddAssociation {
161    pub new_member_signature: VerifiedSignature,
162    pub new_member_identifier: MemberIdentifier,
163    pub existing_member_signature: VerifiedSignature,
164}
165
166impl IdentityAction for AddAssociation {
167    fn update_state(
168        &self,
169        maybe_existing_state: Option<AssociationState>,
170        client_timestamp_ns: u64,
171    ) -> Result<AssociationState, AssociationError> {
172        let existing_state = maybe_existing_state.ok_or(AssociationError::NotCreated)?;
173        self.replay_check(&existing_state)?;
174
175        // Validate the new member signature and get the recovered signer
176        let new_member_address = &self.new_member_signature.signer;
177        // Validate the existing member signature and get the recovedred signer
178        let existing_member_identifier = &self.existing_member_signature.signer;
179
180        if new_member_address.ne(&self.new_member_identifier) {
181            return Err(AssociationError::NewMemberIdSignatureMismatch);
182        }
183
184        // You cannot add yourself
185        if new_member_address == existing_member_identifier {
186            return Err(AssociationError::Generic("tried to add self".to_string()));
187        }
188
189        // Only allow LegacyDelegated signatures on XIDs with a nonce of 0
190        // Otherwise the client should use the regular wallet signature to create
191        let existing_member_identifier = existing_member_identifier.clone();
192        let identifier: Option<Identifier> = existing_member_identifier.clone().into();
193        if let Some(identifier) = identifier
194            && (is_legacy_signature(&self.new_member_signature)
195                || is_legacy_signature(&self.existing_member_signature))
196            && existing_state.inbox_id() != identifier.inbox_id(0)?
197        {
198            return Err(AssociationError::LegacySignatureReuse);
199        }
200
201        allowed_signature_for_kind(
202            &self.new_member_identifier.kind(),
203            &self.new_member_signature.kind,
204        )?;
205
206        let existing_member = existing_state.get(&existing_member_identifier);
207
208        if let Some(member) = existing_member {
209            verify_chain_id_matches(member, &self.existing_member_signature)?;
210        }
211
212        let existing_entity_id = match existing_member {
213            // If there is an existing member of the XID, use that member's ID
214            Some(member) => member.identifier.clone(),
215            None => {
216                // Get the recovery address from the state as a MemberIdentifier
217                let recovery_identifier = existing_state.recovery_identifier().clone().into();
218
219                // Check if it is a signature from the recovery address, which is allowed to add members
220                if existing_member_identifier != recovery_identifier {
221                    return Err(AssociationError::MissingExistingMember);
222                }
223                // BUT, the recovery address has to be used with a real wallet signature, can't be delegated
224                if is_legacy_signature(&self.existing_member_signature) {
225                    return Err(AssociationError::LegacySignatureReuse);
226                }
227                // If it is a real wallet signature, then it is allowed to add members
228                recovery_identifier
229            }
230        };
231
232        // Ensure that the existing member signature is correct for the existing member type
233        allowed_signature_for_kind(
234            &existing_entity_id.kind(),
235            &self.existing_member_signature.kind,
236        )?;
237
238        // Ensure that the new member signature is correct for the new member type
239        allowed_association(
240            existing_member_identifier.kind(),
241            self.new_member_identifier.kind(),
242        )?;
243
244        let new_member = Member::new(
245            new_member_address.clone(),
246            Some(existing_entity_id),
247            Some(client_timestamp_ns),
248            self.new_member_signature.chain_id,
249        );
250
251        Ok(existing_state.add(new_member))
252    }
253
254    fn signatures(&self) -> Vec<Vec<u8>> {
255        vec![
256            self.existing_member_signature.raw_bytes.clone(),
257            self.new_member_signature.raw_bytes.clone(),
258        ]
259    }
260}
261
262/// RevokeAssociation Action
263#[derive(Debug, Clone)]
264pub struct RevokeAssociation {
265    pub recovery_identifier_signature: VerifiedSignature,
266    pub revoked_member: MemberIdentifier,
267}
268
269impl IdentityAction for RevokeAssociation {
270    fn update_state(
271        &self,
272        maybe_existing_state: Option<AssociationState>,
273        _client_timestamp_ns: u64,
274    ) -> Result<AssociationState, AssociationError> {
275        let existing_state = maybe_existing_state.ok_or(AssociationError::NotCreated)?;
276        self.replay_check(&existing_state)?;
277
278        // Ensure that the new signature is on the same chain as the signature to create the account
279        let existing_member = existing_state.get(&self.recovery_identifier_signature.signer);
280        if let Some(member) = existing_member {
281            verify_chain_id_matches(member, &self.recovery_identifier_signature)?;
282        }
283
284        if is_legacy_signature(&self.recovery_identifier_signature) {
285            return Err(AssociationError::SignatureNotAllowed(
286                MemberKind::Ethereum.to_string(),
287                SignatureKind::LegacyDelegated.to_string(),
288            ));
289        }
290        // Don't need to check for replay here since revocation is idempotent
291        let recovery_signer = &self.recovery_identifier_signature.signer;
292        // Make sure there is a recovery address set on the state
293        let state_recovery_identifier: MemberIdentifier =
294            existing_state.recovery_identifier.clone().into();
295
296        if *recovery_signer != state_recovery_identifier {
297            return Err(AssociationError::MissingExistingMember);
298        }
299
300        let installations_to_remove: Vec<Member> = existing_state
301            .members_by_parent(&self.revoked_member)
302            .into_iter()
303            // Only remove children if they are installations
304            .filter(|child| child.kind() == MemberKind::Installation)
305            .collect();
306
307        // Actually apply the revocation to the parent
308        let new_state = existing_state.remove(&self.revoked_member);
309
310        Ok(installations_to_remove
311            .iter()
312            .fold(new_state, |state, installation| {
313                state.remove(&installation.identifier)
314            }))
315    }
316
317    fn signatures(&self) -> Vec<Vec<u8>> {
318        vec![self.recovery_identifier_signature.raw_bytes.clone()]
319    }
320}
321
322/// ChangeRecoveryAddress Action
323#[derive(Debug, Clone)]
324pub struct ChangeRecoveryIdentity {
325    pub recovery_identifier_signature: VerifiedSignature,
326    pub new_recovery_identifier: Identifier,
327}
328
329impl IdentityAction for ChangeRecoveryIdentity {
330    fn update_state(
331        &self,
332        existing_state: Option<AssociationState>,
333        _client_timestamp_ns: u64,
334    ) -> Result<AssociationState, AssociationError> {
335        let existing_state = existing_state.ok_or(AssociationError::NotCreated)?;
336        self.replay_check(&existing_state)?;
337
338        let existing_member = existing_state.get(&self.recovery_identifier_signature.signer);
339        if let Some(member) = existing_member {
340            verify_chain_id_matches(member, &self.recovery_identifier_signature)?;
341        }
342
343        if is_legacy_signature(&self.recovery_identifier_signature) {
344            return Err(AssociationError::SignatureNotAllowed(
345                MemberKind::Ethereum.to_string(),
346                SignatureKind::LegacyDelegated.to_string(),
347            ));
348        }
349
350        let recovery_signer = &self.recovery_identifier_signature.signer;
351        if existing_state.recovery_identifier() != recovery_signer {
352            return Err(AssociationError::MissingExistingMember);
353        }
354
355        Ok(existing_state.set_recovery_identifier(self.new_recovery_identifier.clone()))
356    }
357
358    fn signatures(&self) -> Vec<Vec<u8>> {
359        vec![self.recovery_identifier_signature.raw_bytes.clone()]
360    }
361}
362
363/// All possible Action types that can be used inside an `IdentityUpdate`
364#[derive(Debug, Clone)]
365pub enum Action {
366    CreateInbox(CreateInbox),
367    AddAssociation(AddAssociation),
368    RevokeAssociation(RevokeAssociation),
369    ChangeRecoveryIdentity(ChangeRecoveryIdentity),
370}
371
372impl IdentityAction for Action {
373    fn update_state(
374        &self,
375        existing_state: Option<AssociationState>,
376        client_timestamp_ns: u64,
377    ) -> Result<AssociationState, AssociationError> {
378        match self {
379            Action::CreateInbox(event) => event.update_state(existing_state, client_timestamp_ns),
380            Action::AddAssociation(event) => {
381                event.update_state(existing_state, client_timestamp_ns)
382            }
383            Action::RevokeAssociation(event) => {
384                event.update_state(existing_state, client_timestamp_ns)
385            }
386            Action::ChangeRecoveryIdentity(event) => {
387                event.update_state(existing_state, client_timestamp_ns)
388            }
389        }
390    }
391
392    fn signatures(&self) -> Vec<Vec<u8>> {
393        match self {
394            Action::CreateInbox(event) => event.signatures(),
395            Action::AddAssociation(event) => event.signatures(),
396            Action::RevokeAssociation(event) => event.signatures(),
397            Action::ChangeRecoveryIdentity(event) => event.signatures(),
398        }
399    }
400}
401
402/// An `IdentityUpdate` contains one or more Actions that can be applied to the AssociationState
403#[derive(Debug, Clone)]
404pub struct IdentityUpdate {
405    pub inbox_id: String,
406    pub client_timestamp_ns: u64,
407    pub actions: Vec<Action>,
408}
409
410impl IdentityUpdate {
411    pub fn new(actions: Vec<Action>, inbox_id: String, client_timestamp_ns: u64) -> Self {
412        Self {
413            inbox_id,
414            actions,
415            client_timestamp_ns,
416        }
417    }
418
419    /// Get the signature kind used to create this inbox if this update contains a CreateInbox action.
420    /// Returns None if there is no CreateInbox action in this update.
421    ///
422    /// This is useful for determining whether an identity was created with a Smart Contract Wallet (Erc1271)
423    /// or an Externally Owned Account/EOA (Erc191) signature
424    pub fn creation_signature_kind(&self) -> Option<SignatureKind> {
425        self.actions.iter().find_map(|action| match action {
426            Action::CreateInbox(create_inbox) => {
427                Some(create_inbox.initial_identifier_signature.kind.clone())
428            }
429            _ => None,
430        })
431    }
432}
433
434impl IdentityAction for IdentityUpdate {
435    fn update_state(
436        &self,
437        existing_state: Option<AssociationState>,
438        _client_timestamp_ns: u64,
439    ) -> Result<AssociationState, AssociationError> {
440        let mut state = existing_state;
441        for action in &self.actions {
442            state = Some(action.update_state(state, self.client_timestamp_ns)?);
443        }
444
445        let new_state = state.ok_or(AssociationError::NotCreated)?;
446        if new_state.inbox_id().ne(&self.inbox_id) {
447            tracing::error!(
448                "state inbox id mismatch, old: {}, new: {}",
449                self.inbox_id,
450                new_state.inbox_id()
451            );
452            return Err(AssociationError::WrongInboxId);
453        }
454
455        // After all the updates in the LogEntry have been processed, add the list of signatures to the state
456        // so that the signatures can not be re-used in subsequent updates
457        Ok(new_state.add_seen_signatures(self.signatures()))
458    }
459
460    fn signatures(&self) -> Vec<Vec<u8>> {
461        self.actions
462            .iter()
463            .flat_map(|action| action.signatures())
464            .collect()
465    }
466}
467
468#[allow(clippy::borrowed_box)]
469fn is_legacy_signature(signature: &VerifiedSignature) -> bool {
470    signature.kind == SignatureKind::LegacyDelegated
471}
472
473fn allowed_association(
474    existing_member_kind: MemberKind,
475    new_member_kind: MemberKind,
476) -> Result<(), AssociationError> {
477    // The only disallowed association is an installation adding an installation
478    if existing_member_kind == MemberKind::Installation
479        && new_member_kind == MemberKind::Installation
480    {
481        return Err(AssociationError::MemberNotAllowed(
482            existing_member_kind,
483            new_member_kind,
484        ));
485    }
486
487    Ok(())
488}
489
490// Ensure that the type of signature matches the new entity's role.
491fn allowed_signature_for_kind(
492    role: &MemberKind,
493    signature_kind: &SignatureKind,
494) -> Result<(), AssociationError> {
495    let is_ok = match role {
496        MemberKind::Ethereum => matches!(
497            signature_kind,
498            SignatureKind::Erc191 | SignatureKind::Erc1271 | SignatureKind::LegacyDelegated
499        ),
500        MemberKind::Installation => matches!(signature_kind, SignatureKind::InstallationKey),
501        MemberKind::Passkey => matches!(signature_kind, SignatureKind::P256),
502    };
503
504    if !is_ok {
505        return Err(AssociationError::SignatureNotAllowed(
506            role.to_string(),
507            signature_kind.to_string(),
508        ));
509    }
510
511    Ok(())
512}
513
514fn verify_chain_id_matches(
515    member: &Member,
516    signature: &VerifiedSignature,
517) -> Result<(), AssociationError> {
518    if member.added_on_chain_id != signature.chain_id {
519        return Err(AssociationError::ChainIdMismatch(
520            member.added_on_chain_id.unwrap_or(0),
521            signature.chain_id.unwrap_or(0),
522        ));
523    }
524
525    Ok(())
526}