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