Skip to main content

xmtp_id/associations/
member.rs

1use super::{AssociationError, DeserializationError, ident};
2use ed25519_dalek::VerifyingKey;
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::{
6    fmt::{Debug, Display},
7    hash::Hash,
8};
9use xmtp_cryptography::{XmtpInstallationCredential, signature::IdentifierValidationError};
10use xmtp_proto::types::ApiIdentifier;
11use xmtp_proto::{
12    ConversionError,
13    xmtp::identity::{
14        api::v1::get_inbox_ids_request::Request as GetInboxIdsRequestProto,
15        associations::IdentifierKind,
16    },
17};
18
19#[derive(Clone, Eq, PartialEq, Hash)]
20/// All identity logic happens here
21pub enum MemberIdentifier {
22    Installation(ident::Installation),
23    Ethereum(ident::Ethereum),
24    Passkey(ident::Passkey),
25}
26
27#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
28/// MemberIdentifier without the installation variant
29/// is used to enforce parameters.
30/// Not everything in this enum will be able to sign,
31/// which will be enforced on the unverified signature counterparts.
32pub enum Identifier {
33    Ethereum(ident::Ethereum),
34    Passkey(ident::Passkey),
35}
36
37impl From<Identifier> for i32 {
38    fn from(value: Identifier) -> Self {
39        IdentifierKind::from(value).into()
40    }
41}
42
43impl From<&Identifier> for i32 {
44    fn from(value: &Identifier) -> Self {
45        IdentifierKind::from(value).into()
46    }
47}
48impl MemberIdentifier {
49    pub fn sanitize(self) -> Result<Self, IdentifierValidationError> {
50        let ident = match self {
51            Self::Ethereum(addr) => Self::Ethereum(addr.sanitize()?),
52            ident => ident,
53        };
54        Ok(ident)
55    }
56
57    #[cfg(any(test, feature = "test-utils"))]
58    pub fn rand_ethereum() -> Self {
59        Self::Ethereum(ident::Ethereum::rand())
60    }
61
62    #[cfg(any(test, feature = "test-utils"))]
63    pub fn rand_installation() -> Self {
64        Self::Installation(ident::Installation::rand())
65    }
66
67    pub fn eth(addr: impl ToString) -> Result<Self, IdentifierValidationError> {
68        Ok(Identifier::eth(addr)?.into())
69    }
70
71    pub fn installation(key: Vec<u8>) -> Self {
72        Self::Installation(ident::Installation(key))
73    }
74
75    /// Get the value for [`MemberIdentifier::Installation`] variant.
76    /// Returns `None` if the type is not the correct variant.
77    pub fn installation_key(&self) -> Option<&[u8]> {
78        if let Self::Installation(installation) = self {
79            Some(&installation.0)
80        } else {
81            None
82        }
83    }
84
85    /// Get the value for [`MemberIdentifier::Ethereum`] variant.
86    /// Returns `None` if the type is not the correct variant.
87    pub fn eth_address(&self) -> Option<&str> {
88        if let Self::Ethereum(address) = self {
89            Some(&address.0)
90        } else {
91            None
92        }
93    }
94
95    /// Get the value for [`MemberIdentifier::Ethereum`], consuming the [`MemberIdentifier`]
96    /// in the process
97    pub fn to_eth_address(self) -> Option<String> {
98        if let Self::Ethereum(address) = self {
99            Some(address.0)
100        } else {
101            None
102        }
103    }
104
105    /// Get the value for [`MemberIdentifier::Installation`] variant.
106    /// Returns `None` if the type is not the correct variant.
107    pub fn to_installation(&self) -> Option<&[u8]> {
108        if let Self::Installation(installation) = self {
109            Some(&installation.0)
110        } else {
111            None
112        }
113    }
114}
115
116impl Identifier {
117    #[cfg(any(test, feature = "test-utils"))]
118    pub fn rand_ethereum() -> Self {
119        Self::Ethereum(ident::Ethereum::rand())
120    }
121
122    pub fn sanitize(self) -> Result<Self, IdentifierValidationError> {
123        let ident = match self {
124            Self::Ethereum(addr) => Self::Ethereum(addr.sanitize()?),
125            ident => ident,
126        };
127        Ok(ident)
128    }
129
130    pub fn eth(addr: impl ToString) -> Result<Self, IdentifierValidationError> {
131        Self::Ethereum(ident::Ethereum(addr.to_string())).sanitize()
132    }
133
134    pub fn passkey(key: Vec<u8>, relying_party: Option<String>) -> Self {
135        Self::Passkey(ident::Passkey { key, relying_party })
136    }
137
138    pub fn passkey_str(
139        key: &str,
140        relying_party: Option<String>,
141    ) -> Result<Self, IdentifierValidationError> {
142        Ok(Self::Passkey(ident::Passkey {
143            key: hex::decode(key)?,
144            relying_party,
145        }))
146    }
147
148    pub fn from_proto(
149        ident: impl AsRef<str>,
150        kind: IdentifierKind,
151        relying_party: Option<String>,
152    ) -> Result<Self, ConversionError> {
153        let ident = ident.as_ref();
154        let ident = match kind {
155            IdentifierKind::Unspecified | IdentifierKind::Ethereum => {
156                Self::Ethereum(ident::Ethereum(ident.to_string()))
157            }
158            IdentifierKind::Passkey => Self::Passkey(ident::Passkey {
159                key: hex::decode(ident).map_err(|_| ConversionError::InvalidPublicKey {
160                    description: "passkey",
161                    value: None,
162                })?,
163                relying_party,
164            }),
165        };
166        Ok(ident)
167    }
168
169    /// Get the generated inbox_id for this public identifier.
170    /// The same public identifier will always give the same inbox_id.
171    pub fn inbox_id(&self, nonce: u64) -> Result<String, AssociationError> {
172        if !self.is_valid_address() {
173            return Err(AssociationError::InvalidAccountAddress);
174        }
175        let ident: MemberIdentifier = self.clone().into();
176        Ok(sha256_string(format!("{ident}{nonce}")))
177    }
178
179    /// Validates that the account address is exactly 42 characters, starts with "0x",
180    /// and contains only valid hex digits.
181    fn is_valid_address(&self) -> bool {
182        match self {
183            Self::Ethereum(ident::Ethereum(addr)) => {
184                addr.len() == 42
185                    && addr.starts_with("0x")
186                    && addr[2..].chars().all(|c| c.is_ascii_hexdigit())
187            }
188            _ => true,
189        }
190    }
191}
192
193#[derive(Clone, Debug, PartialEq)]
194pub enum MemberKind {
195    Installation,
196    Ethereum,
197    Passkey,
198}
199
200impl Display for MemberKind {
201    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
202        match self {
203            MemberKind::Installation => write!(f, "installation"),
204            MemberKind::Ethereum => write!(f, "ethereum"),
205            MemberKind::Passkey => write!(f, "passkey"),
206        }
207    }
208}
209
210pub trait HasMemberKind {
211    fn kind(&self) -> MemberKind;
212}
213
214impl HasMemberKind for MemberIdentifier {
215    fn kind(&self) -> MemberKind {
216        match self {
217            Self::Installation(_) => MemberKind::Installation,
218            Self::Ethereum(_) => MemberKind::Ethereum,
219            Self::Passkey(_) => MemberKind::Passkey,
220        }
221    }
222}
223impl HasMemberKind for Identifier {
224    fn kind(&self) -> MemberKind {
225        match self {
226            Self::Ethereum(_) => MemberKind::Ethereum,
227            Self::Passkey(_) => MemberKind::Passkey,
228        }
229    }
230}
231
232impl Display for MemberIdentifier {
233    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
234        match self {
235            Self::Ethereum(eth) => write!(f, "{eth}"),
236            Self::Installation(ident) => write!(f, "{ident}"),
237            Self::Passkey(passkey) => write!(f, "{passkey}"),
238        }
239    }
240}
241
242impl Debug for MemberIdentifier {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        match self {
245            Self::Installation(ident::Installation(key)) => f
246                .debug_tuple("Installation")
247                .field(&hex::encode(key))
248                .finish(),
249            Self::Ethereum(ident::Ethereum(addr)) => f.debug_tuple("Address").field(addr).finish(),
250            Self::Passkey(ident::Passkey { key, .. }) => {
251                f.debug_tuple("Passkey").field(&hex::encode(key)).finish()
252            }
253        }
254    }
255}
256
257impl Display for Identifier {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        match self {
260            Self::Ethereum(eth) => write!(f, "{eth}"),
261            Self::Passkey(passkey) => write!(f, "{passkey}"),
262        }
263    }
264}
265
266impl From<VerifyingKey> for MemberIdentifier {
267    fn from(installation: VerifyingKey) -> Self {
268        Self::Installation(ident::Installation(installation.as_bytes().to_vec()))
269    }
270}
271
272impl<'a> From<&'a XmtpInstallationCredential> for MemberIdentifier {
273    fn from(cred: &'a XmtpInstallationCredential) -> MemberIdentifier {
274        MemberIdentifier::Installation(ident::Installation(cred.public_slice().to_vec()))
275    }
276}
277
278impl From<XmtpInstallationCredential> for MemberIdentifier {
279    fn from(cred: XmtpInstallationCredential) -> MemberIdentifier {
280        MemberIdentifier::Installation(ident::Installation(cred.public_slice().to_vec()))
281    }
282}
283
284impl From<Identifier> for MemberIdentifier {
285    fn from(ident: Identifier) -> Self {
286        match ident {
287            Identifier::Ethereum(addr) => Self::Ethereum(addr),
288            Identifier::Passkey(passkey) => Self::Passkey(passkey),
289        }
290    }
291}
292impl From<MemberIdentifier> for Option<Identifier> {
293    fn from(ident: MemberIdentifier) -> Self {
294        let ident = match ident {
295            MemberIdentifier::Passkey(passkey) => Identifier::Passkey(passkey),
296            MemberIdentifier::Ethereum(eth) => Identifier::Ethereum(eth),
297            _ => {
298                return None;
299            }
300        };
301        Some(ident)
302    }
303}
304impl From<&Identifier> for GetInboxIdsRequestProto {
305    fn from(ident: &Identifier) -> Self {
306        Self {
307            identifier: format!("{ident}"),
308            identifier_kind: {
309                let kind: IdentifierKind = ident.into();
310                kind as i32
311            },
312        }
313    }
314}
315
316impl From<&Identifier> for ApiIdentifier {
317    fn from(ident: &Identifier) -> Self {
318        Self {
319            identifier: format!("{ident}"),
320            identifier_kind: ident.into(),
321        }
322    }
323}
324impl From<Identifier> for ApiIdentifier {
325    fn from(ident: Identifier) -> Self {
326        (&ident).into()
327    }
328}
329impl TryFrom<ApiIdentifier> for Identifier {
330    type Error = DeserializationError;
331    fn try_from(ident: ApiIdentifier) -> Result<Self, Self::Error> {
332        let ident = match ident.identifier_kind {
333            IdentifierKind::Unspecified | IdentifierKind::Ethereum => {
334                Identifier::eth(ident.identifier)?
335            }
336            IdentifierKind::Passkey => Identifier::Passkey(ident::Passkey {
337                key: hex::decode(ident.identifier)
338                    .map_err(|_| DeserializationError::InvalidPasskey)?,
339                relying_party: None,
340            }),
341        };
342        Ok(ident)
343    }
344}
345
346/// A Member of Inbox
347#[derive(Clone, PartialEq)]
348pub struct Member {
349    pub identifier: MemberIdentifier,
350    pub added_by_entity: Option<MemberIdentifier>,
351    pub client_timestamp_ns: Option<u64>,
352    pub added_on_chain_id: Option<u64>,
353}
354
355impl std::fmt::Debug for Member {
356    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357        write!(
358            f,
359            "Member {{ identifier: {:?}, added_by {:?}, client_timestamp: {:?}, added_on_chain: {:?} }}",
360            self.identifier, self.added_by_entity, self.client_timestamp_ns, self.added_on_chain_id
361        )
362    }
363}
364
365impl Member {
366    pub fn new(
367        identifier: MemberIdentifier,
368        added_by_entity: Option<MemberIdentifier>,
369        client_timestamp_ns: Option<u64>,
370        added_on_chain_id: Option<u64>,
371    ) -> Self {
372        Self {
373            identifier,
374            added_by_entity,
375            client_timestamp_ns,
376            added_on_chain_id,
377        }
378    }
379
380    pub fn kind(&self) -> MemberKind {
381        self.identifier.kind()
382    }
383}
384
385impl PartialEq<MemberIdentifier> for Member {
386    fn eq(&self, other: &MemberIdentifier) -> bool {
387        self.identifier.eq(other)
388    }
389}
390impl PartialEq<MemberIdentifier> for Identifier {
391    fn eq(&self, other: &MemberIdentifier) -> bool {
392        match (self, other) {
393            (Self::Ethereum(ident), MemberIdentifier::Ethereum(other_ident)) => {
394                ident == other_ident
395            }
396            (Self::Passkey(ident), MemberIdentifier::Passkey(other_ident)) => ident == other_ident,
397            _ => false,
398        }
399    }
400}
401impl PartialEq<Identifier> for MemberIdentifier {
402    fn eq(&self, other: &Identifier) -> bool {
403        other == self
404    }
405}
406
407/// Helper function to generate a SHA256 hash as a hex string.
408fn sha256_string(input: String) -> String {
409    let mut hasher = Sha256::new();
410    hasher.update(input.as_bytes());
411    format!("{:x}", hasher.finalize())
412}
413
414#[cfg(test)]
415pub(crate) mod tests {
416    use super::*;
417
418    #[allow(clippy::derivable_impls)]
419    impl Default for Member {
420        fn default() -> Self {
421            Self {
422                identifier: MemberIdentifier::rand_ethereum(),
423                added_by_entity: None,
424                client_timestamp_ns: None,
425                added_on_chain_id: None,
426            }
427        }
428    }
429
430    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
431    #[cfg_attr(not(target_arch = "wasm32"), test)]
432    fn test_identifier_comparisons() {
433        let address_1 = MemberIdentifier::rand_ethereum();
434        let address_2 = MemberIdentifier::rand_ethereum();
435        let address_1_copy = address_1.clone();
436
437        assert!(address_1 != address_2);
438        assert!(address_1.ne(&address_2));
439        assert!(address_1 == address_1_copy);
440
441        let installation_1 = MemberIdentifier::installation([1, 2, 3].to_vec());
442        let installation_2 = MemberIdentifier::installation([4, 5, 6].to_vec());
443        let installation_1_copy = MemberIdentifier::installation([1, 2, 3].to_vec());
444
445        assert!(installation_1 != installation_2);
446        assert!(installation_1.ne(&installation_2));
447        assert!(installation_1 == installation_1_copy);
448    }
449}