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