Skip to main content

xmtp_id/associations/
serialization.rs

1use super::{
2    Handle, InvalidHandle, MemberIdentifier, ident,
3    member::{Identifier, Member},
4    signature::AccountId,
5    state::{AssociationState, AssociationStateDiff},
6    unsigned_actions::{
7        UnsignedAddAssociation, UnsignedChangeRecoveryAddress, UnsignedClaimHandle,
8        UnsignedCreateInbox, UnsignedRevokeAssociation,
9    },
10    unverified::{
11        UnverifiedAction, UnverifiedAddAssociation, UnverifiedChangeRecoveryAddress,
12        UnverifiedClaimHandle, UnverifiedCreateInbox, UnverifiedIdentityUpdate,
13        UnverifiedInstallationKeySignature, UnverifiedRecoverableEcdsaSignature,
14        UnverifiedRevokeAssociation, UnverifiedSignature, UnverifiedSmartContractWalletSignature,
15    },
16};
17use crate::scw_verifier::ValidationResponse;
18use prost::{DecodeError, Message};
19use regex::Regex;
20use std::collections::{HashMap, HashSet};
21use thiserror::Error;
22use xmtp_common::ErrorCode;
23use xmtp_cryptography::signature::IdentifierValidationError;
24use xmtp_proto::ConversionError;
25use xmtp_proto::xmtp::identity::{
26    api::v1::verify_smart_contract_wallet_signatures_response::ValidationResponse as SmartContractWalletValidationResponseProto,
27    associations::{
28        AddAssociation as AddAssociationProto, AssociationState as AssociationStateProto,
29        AssociationStateDiff as AssociationStateDiffProto,
30        ChangeRecoveryAddress as ChangeRecoveryAddressProto, ClaimHandle as ClaimHandleProto,
31        CreateInbox as CreateInboxProto, IdentifierKind, IdentityAction as IdentityActionProto,
32        IdentityUpdate as IdentityUpdateProto, Member as MemberProto,
33        MemberIdentifier as MemberIdentifierProto, MemberMap as MemberMapProto,
34        Passkey as PasskeyProto, RecoverableEcdsaSignature as RecoverableEcdsaSignatureProto,
35        RecoverableEd25519Signature as RecoverableEd25519SignatureProto,
36        RecoverablePasskeySignature as RecoverablePasskeySignatureProto,
37        RevokeAssociation as RevokeAssociationProto, Signature as SignatureWrapperProto,
38        SmartContractWalletSignature as SmartContractWalletSignatureProto,
39        identity_action::Kind as IdentityActionKindProto,
40        member_identifier::Kind as MemberIdentifierKindProto,
41        signature::Signature as SignatureKindProto,
42    },
43};
44
45#[derive(Error, Debug, ErrorCode)]
46pub enum DeserializationError {
47    #[error(transparent)]
48    #[error_code(inherit)]
49    SignatureError(#[from] crate::associations::SignatureError),
50    /// Missing action.
51    ///
52    /// Identity action not present in update. Not retryable.
53    #[error("Missing action")]
54    MissingAction,
55    /// Missing update.
56    ///
57    /// Identity update not present. Not retryable.
58    #[error("Missing update")]
59    MissingUpdate,
60    /// Missing member identifier.
61    ///
62    /// Member identifier field empty. Not retryable.
63    #[error("Missing member identifier")]
64    MissingMemberIdentifier,
65    /// Missing signature.
66    ///
67    /// Signature field not present. Not retryable.
68    #[error("Missing signature")]
69    Signature,
70    /// Missing member.
71    ///
72    /// Member field not present. Not retryable.
73    #[error("Missing Member")]
74    MissingMember,
75    /// Decode error.
76    ///
77    /// Protobuf decoding failed. Not retryable.
78    #[error("Decode error {0}")]
79    Decode(#[from] DecodeError),
80    /// Invalid account ID.
81    ///
82    /// CAIP-10 account ID is malformed. Not retryable.
83    #[error("Invalid account id")]
84    InvalidAccountId,
85    /// Invalid passkey.
86    ///
87    /// Passkey data is malformed. Not retryable.
88    #[error("Invalid passkey")]
89    InvalidPasskey,
90    /// Invalid hash.
91    ///
92    /// Hash must be 32 bytes. Not retryable.
93    #[error("Invalid hash (needs to be 32 bytes)")]
94    InvalidHash,
95    /// Unspecified value.
96    ///
97    /// An unrecognized or unsupported value was encountered. Not retryable.
98    #[error("A required field is unspecified: {0}")]
99    Unspecified(&'static str),
100    /// Deprecated field.
101    ///
102    /// A deprecated field was used. Not retryable.
103    #[error("Field is deprecated: {0}")]
104    Deprecated(&'static str),
105    /// Handle is not an identifier.
106    ///
107    /// A handle names an inbox in a lookup, but it is not an account
108    /// identifier. Not retryable.
109    #[error("a handle is not an account identifier")]
110    HandleNotIdentifier,
111    /// Ed25519 key error.
112    ///
113    /// Failed to create public key from bytes. Not retryable.
114    #[error("Error creating public key from proto bytes")]
115    Ed25519(#[from] ed25519_dalek::ed25519::Error),
116    /// Unable to deserialize.
117    ///
118    /// Bincode deserialization failed. Not retryable.
119    #[error("Unable to deserialize")]
120    Bincode,
121    #[error(transparent)]
122    #[error_code(inherit)]
123    AddressValidation(#[from] IdentifierValidationError),
124}
125
126impl TryFrom<IdentityUpdateProto> for UnverifiedIdentityUpdate {
127    type Error = ConversionError;
128
129    fn try_from(proto: IdentityUpdateProto) -> Result<Self, Self::Error> {
130        let IdentityUpdateProto {
131            client_timestamp_ns,
132            inbox_id,
133            actions,
134        } = proto;
135        let all_actions = actions
136            .into_iter()
137            .map(|action| match action.kind {
138                Some(action) => Ok(action),
139                None => Err(ConversionError::Missing {
140                    item: "action",
141                    r#type: std::any::type_name::<IdentityActionKindProto>(),
142                }),
143            })
144            .collect::<Result<Vec<IdentityActionKindProto>, ConversionError>>()?;
145
146        let processed_actions: Vec<UnverifiedAction> = all_actions
147            .into_iter()
148            .map(UnverifiedAction::try_from)
149            .collect::<Result<Vec<UnverifiedAction>, ConversionError>>()?;
150
151        Ok(UnverifiedIdentityUpdate::new(
152            inbox_id,
153            client_timestamp_ns,
154            processed_actions,
155        ))
156    }
157}
158
159impl TryFrom<IdentityActionKindProto> for UnverifiedAction {
160    type Error = ConversionError;
161
162    fn try_from(action: IdentityActionKindProto) -> Result<Self, Self::Error> {
163        Ok(match action {
164            IdentityActionKindProto::Add(add_action) => {
165                UnverifiedAction::AddAssociation(UnverifiedAddAssociation {
166                    new_member_signature: add_action.new_member_signature.try_into()?,
167                    existing_member_signature: add_action.existing_member_signature.try_into()?,
168                    unsigned_action: UnsignedAddAssociation {
169                        new_member_identifier: canonical(
170                            add_action
171                                .new_member_identifier
172                                .ok_or(ConversionError::Missing {
173                                    item: "member_identifier",
174                                    r#type: std::any::type_name::<MemberIdentifierProto>(),
175                                })?
176                                .try_into()?,
177                        )?,
178                    },
179                })
180            }
181            IdentityActionKindProto::CreateInbox(action_proto) => {
182                let kind = signing_kind(action_proto.initial_identifier_kind)?;
183                let account_identifier = canonical(Identifier::from_proto(
184                    &action_proto.initial_identifier,
185                    kind,
186                    action_proto.relying_party,
187                )?)?;
188
189                UnverifiedAction::CreateInbox(UnverifiedCreateInbox {
190                    initial_identifier_signature: action_proto
191                        .initial_identifier_signature
192                        .try_into()?,
193                    unsigned_action: UnsignedCreateInbox {
194                        nonce: action_proto.nonce,
195                        account_identifier,
196                    },
197                })
198            }
199            IdentityActionKindProto::ChangeRecoveryAddress(action_proto) => {
200                let kind = signing_kind(action_proto.new_recovery_identifier_kind)?;
201                let new_recovery_identifier = canonical(Identifier::from_proto(
202                    &action_proto.new_recovery_identifier,
203                    kind,
204                    action_proto.relying_party,
205                )?)?;
206                UnverifiedAction::ChangeRecoveryAddress(UnverifiedChangeRecoveryAddress {
207                    recovery_identifier_signature: action_proto
208                        .existing_recovery_identifier_signature
209                        .try_into()?,
210                    unsigned_action: UnsignedChangeRecoveryAddress {
211                        new_recovery_identifier,
212                    },
213                })
214            }
215            IdentityActionKindProto::Revoke(action_proto) => {
216                UnverifiedAction::RevokeAssociation(UnverifiedRevokeAssociation {
217                    recovery_identifier_signature: action_proto
218                        .recovery_identifier_signature
219                        .try_into()?,
220                    unsigned_action: UnsignedRevokeAssociation {
221                        revoked_member: canonical(
222                            action_proto
223                                .member_to_revoke
224                                .ok_or(ConversionError::Missing {
225                                    item: "member_to_revoke",
226                                    r#type: std::any::type_name::<MemberIdentifierProto>(),
227                                })?
228                                .try_into()?,
229                        )?,
230                    },
231                })
232            }
233            IdentityActionKindProto::ClaimHandle(action_proto) => {
234                UnverifiedAction::ClaimHandle(UnverifiedClaimHandle {
235                    signature: action_proto.signature.try_into()?,
236                    // Kept as carried: the grammar check runs when the claim
237                    // is applied, never here, and never rewrites the text.
238                    unsigned_action: UnsignedClaimHandle {
239                        handle: action_proto.handle,
240                    },
241                })
242            }
243        })
244    }
245}
246
247/// The kind of an identifier that signs, which an inbox's initial and recovery
248/// identifiers are. An unset kind reads as Ethereum. A handle signs nothing,
249/// so it and every value `IdentifierKind` does not list are rejected, never
250/// read as Ethereum.
251// implements: IDENT-077
252fn signing_kind(kind: i32) -> Result<IdentifierKind, ConversionError> {
253    match IdentifierKind::try_from(kind) {
254        Ok(IdentifierKind::Unspecified | IdentifierKind::Ethereum) => Ok(IdentifierKind::Ethereum),
255        Ok(IdentifierKind::Passkey) => Ok(IdentifierKind::Passkey),
256        Ok(IdentifierKind::Handle) | Err(_) => Err(ConversionError::NonSigningIdentifierKind(kind)),
257    }
258}
259
260/// Passes `identifier` through unless it is an Ethereum identifier outside the
261/// one form a signer is derived in. The identifier is signed, so it is never
262/// rewritten.
263// implements: IDENT-013
264fn canonical<I: Clone + Into<MemberIdentifier>>(identifier: I) -> Result<I, ConversionError> {
265    match identifier.clone().into() {
266        MemberIdentifier::Ethereum(address) if !address.is_canonical() => {
267            Err(ConversionError::InvalidValue {
268                item: "ethereum identifier",
269                expected: "0x followed by 40 lowercase hexadecimal characters",
270                got: address.0,
271            })
272        }
273        _ => Ok(identifier),
274    }
275}
276
277impl TryFrom<SignatureWrapperProto> for UnverifiedSignature {
278    type Error = ConversionError;
279
280    fn try_from(proto: SignatureWrapperProto) -> Result<Self, Self::Error> {
281        let signature = unwrap_proto_signature(proto)?;
282        let unverified_sig = match signature {
283            SignatureKindProto::Erc191(sig) => UnverifiedSignature::RecoverableEcdsa(
284                UnverifiedRecoverableEcdsaSignature::new(sig.bytes),
285            ),
286            SignatureKindProto::InstallationKey(sig) => {
287                UnverifiedSignature::InstallationKey(UnverifiedInstallationKeySignature::new(
288                    sig.bytes,
289                    sig.public_key.as_slice().try_into()?,
290                ))
291            }
292            SignatureKindProto::Erc6492(sig) => UnverifiedSignature::SmartContractWallet(
293                UnverifiedSmartContractWalletSignature::new(
294                    sig.signature,
295                    sig.account_id.try_into()?,
296                    sig.block_number,
297                ),
298            ),
299            SignatureKindProto::Passkey(sig) => UnverifiedSignature::new_passkey(
300                sig.public_key,
301                sig.signature,
302                sig.authenticator_data,
303                sig.client_data_json,
304            ),
305        };
306
307        Ok(unverified_sig)
308    }
309}
310
311impl TryFrom<Option<SignatureWrapperProto>> for UnverifiedSignature {
312    type Error = ConversionError;
313
314    fn try_from(value: Option<SignatureWrapperProto>) -> Result<Self, Self::Error> {
315        value
316            .ok_or_else(|| ConversionError::Missing {
317                item: "signature",
318                r#type: std::any::type_name::<SignatureWrapperProto>(),
319            })?
320            .try_into()
321    }
322}
323
324fn unwrap_proto_signature(
325    value: SignatureWrapperProto,
326) -> Result<SignatureKindProto, ConversionError> {
327    match value.signature {
328        Some(inner) => Ok(inner),
329        None => Err(ConversionError::Missing {
330            item: "signature",
331            r#type: std::any::type_name::<SignatureKindProto>(),
332        }),
333    }
334}
335
336impl From<UnverifiedIdentityUpdate> for IdentityUpdateProto {
337    fn from(value: UnverifiedIdentityUpdate) -> Self {
338        Self {
339            inbox_id: value.inbox_id,
340            client_timestamp_ns: value.client_timestamp_ns,
341            actions: map_vec(value.actions),
342        }
343    }
344}
345
346impl From<UnverifiedAction> for IdentityActionProto {
347    fn from(value: UnverifiedAction) -> Self {
348        let kind: IdentityActionKindProto = match value {
349            UnverifiedAction::CreateInbox(action) => {
350                let account_identifier = action.unsigned_action.account_identifier;
351                let initial_identifier = format!("{account_identifier}");
352                let relying_party = match &account_identifier {
353                    Identifier::Passkey(pk) => pk.relying_party.clone(),
354                    _ => None,
355                };
356                let initial_identifier_kind: IdentifierKind = account_identifier.into();
357                IdentityActionKindProto::CreateInbox(CreateInboxProto {
358                    nonce: action.unsigned_action.nonce,
359                    initial_identifier,
360                    initial_identifier_kind: initial_identifier_kind as i32,
361                    initial_identifier_signature: Some(action.initial_identifier_signature.into()),
362                    relying_party,
363                })
364            }
365            UnverifiedAction::AddAssociation(action) => {
366                let relying_party = match &action.unsigned_action.new_member_identifier {
367                    MemberIdentifier::Passkey(pk) => pk.relying_party.clone(),
368                    _ => None,
369                };
370                IdentityActionKindProto::Add(AddAssociationProto {
371                    new_member_identifier: Some(
372                        action.unsigned_action.new_member_identifier.into(),
373                    ),
374                    existing_member_signature: Some(action.existing_member_signature.into()),
375                    new_member_signature: Some(action.new_member_signature.into()),
376                    relying_party,
377                })
378            }
379            UnverifiedAction::ChangeRecoveryAddress(action) => {
380                let new_recovery_identifier = action.unsigned_action.new_recovery_identifier;
381                let new_recovery_identifier_string = format!("{new_recovery_identifier}");
382                let relying_party = match &new_recovery_identifier {
383                    Identifier::Passkey(pk) => pk.relying_party.clone(),
384                    _ => None,
385                };
386                let new_recovery_identifier_kind: IdentifierKind = new_recovery_identifier.into();
387                IdentityActionKindProto::ChangeRecoveryAddress(ChangeRecoveryAddressProto {
388                    new_recovery_identifier: new_recovery_identifier_string,
389                    new_recovery_identifier_kind: new_recovery_identifier_kind as i32,
390                    existing_recovery_identifier_signature: Some(
391                        action.recovery_identifier_signature.into(),
392                    ),
393                    relying_party,
394                })
395            }
396            UnverifiedAction::RevokeAssociation(action) => {
397                IdentityActionKindProto::Revoke(RevokeAssociationProto {
398                    recovery_identifier_signature: Some(
399                        action.recovery_identifier_signature.into(),
400                    ),
401                    member_to_revoke: Some(action.unsigned_action.revoked_member.into()),
402                })
403            }
404            UnverifiedAction::ClaimHandle(action) => {
405                IdentityActionKindProto::ClaimHandle(ClaimHandleProto {
406                    handle: action.unsigned_action.handle,
407                    signature: Some(action.signature.into()),
408                })
409            }
410        };
411
412        IdentityActionProto { kind: Some(kind) }
413    }
414}
415
416impl From<&Identifier> for IdentifierKind {
417    fn from(ident: &Identifier) -> Self {
418        match ident {
419            Identifier::Ethereum(_) => IdentifierKind::Ethereum,
420            Identifier::Passkey(_) => IdentifierKind::Passkey,
421        }
422    }
423}
424impl From<Identifier> for IdentifierKind {
425    fn from(ident: Identifier) -> Self {
426        (&ident).into()
427    }
428}
429
430impl From<UnverifiedSignature> for SignatureWrapperProto {
431    fn from(value: UnverifiedSignature) -> Self {
432        let signature = match value {
433            UnverifiedSignature::SmartContractWallet(sig) => {
434                SignatureKindProto::Erc6492(SmartContractWalletSignatureProto {
435                    account_id: sig.account_id.into(),
436                    block_number: sig.block_number,
437                    signature: sig.signature_bytes,
438                })
439            }
440            UnverifiedSignature::InstallationKey(UnverifiedInstallationKeySignature {
441                signature_bytes,
442                verifying_key,
443            }) => SignatureKindProto::InstallationKey(RecoverableEd25519SignatureProto {
444                bytes: signature_bytes,
445                public_key: verifying_key.as_bytes().to_vec(),
446            }),
447            UnverifiedSignature::RecoverableEcdsa(sig) => {
448                SignatureKindProto::Erc191(RecoverableEcdsaSignatureProto {
449                    bytes: sig.signature_bytes,
450                })
451            }
452            UnverifiedSignature::Passkey(sig) => {
453                SignatureKindProto::Passkey(RecoverablePasskeySignatureProto {
454                    public_key: sig.public_key,
455                    signature: sig.signature,
456                    authenticator_data: sig.authenticator_data,
457                    client_data_json: sig.client_data_json,
458                })
459            }
460        };
461
462        Self {
463            signature: Some(signature),
464        }
465    }
466}
467
468impl TryFrom<Vec<u8>> for UnverifiedIdentityUpdate {
469    type Error = ConversionError;
470
471    fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
472        let update_proto: IdentityUpdateProto = IdentityUpdateProto::decode(value.as_slice())?;
473        UnverifiedIdentityUpdate::try_from(update_proto)
474    }
475}
476
477impl From<UnverifiedIdentityUpdate> for Vec<u8> {
478    fn from(value: UnverifiedIdentityUpdate) -> Self {
479        let proto: IdentityUpdateProto = value.into();
480        proto.encode_to_vec()
481    }
482}
483
484impl From<SmartContractWalletValidationResponseProto> for ValidationResponse {
485    fn from(value: SmartContractWalletValidationResponseProto) -> Self {
486        Self {
487            is_valid: value.is_valid,
488            block_number: value.block_number,
489            error: value.error,
490        }
491    }
492}
493
494impl From<MemberIdentifierKindProto> for MemberIdentifier {
495    fn from(proto: MemberIdentifierKindProto) -> Self {
496        match proto {
497            MemberIdentifierKindProto::EthereumAddress(address) => {
498                Self::Ethereum(ident::Ethereum(address))
499            }
500            MemberIdentifierKindProto::InstallationPublicKey(public_key) => {
501                Self::Installation(ident::Installation(public_key))
502            }
503            MemberIdentifierKindProto::Passkey(PasskeyProto { key, relying_party }) => {
504                Self::Passkey(ident::Passkey { key, relying_party })
505            }
506        }
507    }
508}
509
510impl From<Member> for MemberProto {
511    fn from(member: Member) -> MemberProto {
512        MemberProto {
513            identifier: Some(member.identifier.into()),
514            added_by_entity: member.added_by_entity.map(Into::into),
515            client_timestamp_ns: member.client_timestamp_ns,
516            added_on_chain_id: member.added_on_chain_id,
517        }
518    }
519}
520
521impl TryFrom<MemberProto> for Member {
522    type Error = ConversionError;
523
524    fn try_from(proto: MemberProto) -> Result<Self, Self::Error> {
525        Ok(Member {
526            identifier: proto
527                .identifier
528                .ok_or(ConversionError::Missing {
529                    item: "member_identifier",
530                    r#type: std::any::type_name::<MemberIdentifierProto>(),
531                })?
532                .try_into()?,
533            added_by_entity: proto.added_by_entity.map(TryInto::try_into).transpose()?,
534            client_timestamp_ns: proto.client_timestamp_ns,
535            added_on_chain_id: proto.added_on_chain_id,
536        })
537    }
538}
539
540impl From<MemberIdentifier> for MemberIdentifierProto {
541    fn from(member_identifier: MemberIdentifier) -> MemberIdentifierProto {
542        match member_identifier {
543            MemberIdentifier::Ethereum(ident::Ethereum(address)) => MemberIdentifierProto {
544                kind: Some(MemberIdentifierKindProto::EthereumAddress(address)),
545            },
546            MemberIdentifier::Installation(ident::Installation(public_key)) => {
547                MemberIdentifierProto {
548                    kind: Some(MemberIdentifierKindProto::InstallationPublicKey(public_key)),
549                }
550            }
551            MemberIdentifier::Passkey(ident::Passkey { key, relying_party }) => {
552                MemberIdentifierProto {
553                    kind: Some(MemberIdentifierKindProto::Passkey(PasskeyProto {
554                        key,
555                        relying_party,
556                    })),
557                }
558            }
559        }
560    }
561}
562
563impl TryFrom<MemberIdentifierProto> for MemberIdentifier {
564    type Error = ConversionError;
565
566    fn try_from(proto: MemberIdentifierProto) -> Result<Self, Self::Error> {
567        match proto.kind {
568            Some(MemberIdentifierKindProto::EthereumAddress(address)) => {
569                Ok(MemberIdentifier::Ethereum(ident::Ethereum(address)))
570            }
571            Some(MemberIdentifierKindProto::InstallationPublicKey(public_key)) => Ok(
572                MemberIdentifier::Installation(ident::Installation(public_key)),
573            ),
574            Some(MemberIdentifierKindProto::Passkey(PasskeyProto { key, relying_party })) => {
575                Ok(MemberIdentifier::Passkey(ident::Passkey {
576                    key,
577                    relying_party,
578                }))
579            }
580            None => Err(ConversionError::Missing {
581                item: "member_identifier",
582                r#type: std::any::type_name::<MemberIdentifierKindProto>(),
583            }),
584        }
585    }
586}
587
588impl From<AssociationState> for AssociationStateProto {
589    fn from(state: AssociationState) -> AssociationStateProto {
590        let members = state
591            .members
592            .into_iter()
593            .map(|(key, value)| MemberMapProto {
594                key: Some(key.into()),
595                value: Some(value.into()),
596            })
597            .collect();
598
599        let kind: IdentifierKind = (&state.recovery_identifier).into();
600        let relying_party = match &state.recovery_identifier {
601            Identifier::Passkey(ident::Passkey { relying_party, .. }) => relying_party.clone(),
602            _ => None,
603        };
604
605        AssociationStateProto {
606            inbox_id: state.inbox_id,
607            members,
608            recovery_identifier: state.recovery_identifier.to_string(),
609            recovery_identifier_kind: kind as i32,
610            seen_signatures: state.seen_signatures.into_iter().collect(),
611            relying_party,
612            handles: state.handles.into_iter().map(String::from).collect(),
613            handle_claims: state.handle_claims,
614        }
615    }
616}
617
618impl TryFrom<AssociationStateProto> for AssociationState {
619    type Error = ConversionError;
620
621    fn try_from(proto: AssociationStateProto) -> Result<Self, Self::Error> {
622        let kind = match proto.recovery_identifier_kind() {
623            IdentifierKind::Unspecified => IdentifierKind::Ethereum,
624            kind => kind,
625        };
626        let recovery_identifier =
627            Identifier::from_proto(&proto.recovery_identifier, kind, proto.relying_party)?;
628
629        let members = proto
630            .members
631            .into_iter()
632            .map(|kv| {
633                let key = kv
634                    .key
635                    .ok_or(ConversionError::Missing {
636                        item: "member_identifier",
637                        r#type: std::any::type_name::<MemberIdentifierProto>(),
638                    })?
639                    .try_into()?;
640                let value = kv
641                    .value
642                    .ok_or(ConversionError::Missing {
643                        item: "member",
644                        r#type: std::any::type_name::<MemberProto>(),
645                    })?
646                    .try_into()?;
647                Ok((key, value))
648            })
649            .collect::<Result<HashMap<MemberIdentifier, Member>, ConversionError>>()?;
650
651        let handles = proto
652            .handles
653            .iter()
654            .map(|handle| Handle::parse(handle))
655            .collect::<Result<Vec<_>, InvalidHandle>>()
656            .map_err(|_| ConversionError::InvalidValue {
657                item: "association state handle",
658                expected: "a handle under the handle grammar",
659                got: "a string outside the grammar".to_string(),
660            })?;
661
662        Ok(AssociationState {
663            inbox_id: proto.inbox_id,
664            members,
665            recovery_identifier,
666            seen_signatures: HashSet::from_iter(proto.seen_signatures),
667            handles,
668            handle_claims: proto.handle_claims,
669        })
670    }
671}
672
673impl From<AssociationStateDiff> for AssociationStateDiffProto {
674    fn from(diff: AssociationStateDiff) -> AssociationStateDiffProto {
675        AssociationStateDiffProto {
676            new_members: diff.new_members.into_iter().map(Into::into).collect(),
677            removed_members: diff.removed_members.into_iter().map(Into::into).collect(),
678        }
679    }
680}
681
682/// Convert a vector of `A` into a vector of `B` using [`From`]
683pub fn map_vec<A, B: From<A>>(other: Vec<A>) -> Vec<B> {
684    other.into_iter().map(B::from).collect()
685}
686
687/// Convert a vector of `A` into a vector of `B` using [`TryFrom`]
688/// Useful to convert vectors of structs into protos, like `Vec<IdentityUpdate>` to `Vec<IdentityUpdateProto>` or vice-versa.
689pub fn try_map_vec<A, B: TryFrom<A>>(other: Vec<A>) -> Result<Vec<B>, <B as TryFrom<A>>::Error> {
690    other.into_iter().map(B::try_from).collect()
691}
692
693impl TryFrom<String> for AccountId {
694    type Error = ConversionError;
695
696    fn try_from(s: String) -> Result<Self, Self::Error> {
697        let parts: Vec<&str> = s.split(':').collect();
698        if parts.len() != 3 {
699            return Err(ConversionError::InvalidLength {
700                item: "account_id",
701                expected: 3,
702                got: parts.len(),
703            });
704        }
705        let chain_id = format!("{}:{}", parts[0], parts[1]);
706        let chain_id_regex = Regex::new(r"^[-a-z0-9]{3,8}:[-_a-zA-Z0-9]{1,32}$")
707            .expect("Static regex should always compile");
708        let account_address = parts[2];
709        let account_address_regex =
710            Regex::new(r"^[-.%a-zA-Z0-9]{1,128}$").expect("static regex should always compile");
711        if !chain_id_regex.is_match(&chain_id) || !account_address_regex.is_match(account_address) {
712            return Err(ConversionError::InvalidValue {
713                item: "eth account_id",
714                expected: "well-formed chain_id & address",
715                got: s.to_string(),
716            });
717        }
718
719        Ok(AccountId {
720            chain_id: chain_id.to_string(),
721            account_address: account_address.to_string(),
722        })
723    }
724}
725
726impl TryFrom<&str> for AccountId {
727    type Error = ConversionError;
728
729    fn try_from(s: &str) -> Result<Self, Self::Error> {
730        s.to_string().try_into()
731    }
732}
733
734impl From<AccountId> for String {
735    fn from(account_id: AccountId) -> Self {
736        format!("{}:{}", account_id.chain_id, account_id.account_address)
737    }
738}
739
740#[cfg(test)]
741pub(crate) mod tests {
742    use xmtp_common::{rand_u64, rand_vec};
743
744    use super::*;
745
746    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
747    #[cfg_attr(not(target_arch = "wasm32"), test)]
748    fn test_round_trip_unverified() {
749        let account_identifier = Identifier::rand_ethereum();
750        let nonce = rand_u64();
751        let inbox_id = account_identifier.inbox_id(nonce).unwrap();
752        let client_timestamp_ns = rand_u64();
753        let signature_bytes = rand_vec::<32>();
754
755        let identity_update = UnverifiedIdentityUpdate::new(
756            inbox_id,
757            client_timestamp_ns,
758            vec![
759                UnverifiedAction::CreateInbox(UnverifiedCreateInbox {
760                    initial_identifier_signature: UnverifiedSignature::RecoverableEcdsa(
761                        UnverifiedRecoverableEcdsaSignature::new(signature_bytes),
762                    ),
763                    unsigned_action: UnsignedCreateInbox {
764                        nonce,
765                        account_identifier,
766                    },
767                }),
768                UnverifiedAction::AddAssociation(UnverifiedAddAssociation {
769                    new_member_signature: UnverifiedSignature::new_recoverable_ecdsa(vec![1, 2, 3]),
770                    existing_member_signature: UnverifiedSignature::new_recoverable_ecdsa(vec![
771                        4, 5, 6,
772                    ]),
773                    unsigned_action: UnsignedAddAssociation {
774                        new_member_identifier: MemberIdentifier::rand_ethereum(),
775                    },
776                }),
777                UnverifiedAction::ChangeRecoveryAddress(UnverifiedChangeRecoveryAddress {
778                    recovery_identifier_signature: UnverifiedSignature::new_recoverable_ecdsa(
779                        vec![7, 8, 9],
780                    ),
781                    unsigned_action: UnsignedChangeRecoveryAddress {
782                        new_recovery_identifier: Identifier::rand_ethereum(),
783                    },
784                }),
785                UnverifiedAction::RevokeAssociation(UnverifiedRevokeAssociation {
786                    recovery_identifier_signature: UnverifiedSignature::new_recoverable_ecdsa(
787                        vec![10, 11, 12],
788                    ),
789                    unsigned_action: UnsignedRevokeAssociation {
790                        revoked_member: MemberIdentifier::rand_ethereum(),
791                    },
792                }),
793            ],
794        );
795
796        let serialized_update = IdentityUpdateProto::from(identity_update.clone());
797
798        assert_eq!(
799            serialized_update.client_timestamp_ns,
800            identity_update.client_timestamp_ns
801        );
802        assert_eq!(serialized_update.actions.len(), 4);
803
804        let deserialized_update: UnverifiedIdentityUpdate = serialized_update
805            .clone()
806            .try_into()
807            .expect("deserialization error");
808
809        assert_eq!(deserialized_update, identity_update);
810
811        let reserialized = IdentityUpdateProto::from(deserialized_update);
812
813        assert_eq!(serialized_update, reserialized);
814    }
815
816    /// A claim keeps its handle as carried and its signature through the wire
817    /// form, and a state keeps its handles in order and its claim count.
818    // verifies: IDENT-001
819    #[xmtp_common::test(unwrap_try = true)]
820    fn claim_and_state_round_trip() {
821        let account_identifier = Identifier::rand_ethereum();
822        let claim = UnverifiedIdentityUpdate::new(
823            account_identifier.inbox_id(0)?,
824            rand_u64(),
825            vec![UnverifiedAction::ClaimHandle(UnverifiedClaimHandle {
826                unsigned_action: UnsignedClaimHandle {
827                    handle: "nick#2341".into(),
828                },
829                signature: UnverifiedSignature::new_recoverable_ecdsa(rand_vec::<65>()),
830            })],
831        );
832        let proto = IdentityUpdateProto::from(claim.clone());
833        let Some(IdentityActionKindProto::ClaimHandle(wire)) = &proto.actions[0].kind else {
834            panic!("a claim encodes as field 5 of the action: {proto:?}");
835        };
836        assert_eq!(wire.handle, "nick#2341");
837        let decoded = UnverifiedIdentityUpdate::try_from(IdentityUpdateProto::decode(
838            proto.encode_to_vec().as_slice(),
839        )?)?;
840        assert_eq!(decoded, claim);
841
842        let state = AssociationState::new(account_identifier, 0, None)?
843            .claim_handle(Handle::parse("a")?)
844            .claim_handle(Handle::parse("b#0001")?)
845            .claim_handle(Handle::parse("a")?);
846        let proto = AssociationStateProto::from(state.clone());
847        assert_eq!(proto.handles, ["b#0001", "a"]);
848        assert_eq!(proto.handle_claims, 3);
849        let decoded = AssociationState::try_from(AssociationStateProto::decode(
850            proto.encode_to_vec().as_slice(),
851        )?)?;
852        assert_eq!(decoded.handles(), state.handles());
853        assert_eq!(decoded.handle_claims(), 3);
854    }
855
856    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
857    #[cfg_attr(not(target_arch = "wasm32"), test)]
858    fn test_account_id() {
859        // valid evm chain
860        let text = "eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb".to_string();
861        let account_id: AccountId = text.clone().try_into().unwrap();
862        assert_eq!(account_id.chain_id, "eip155:1");
863        assert_eq!(
864            account_id.account_address,
865            "0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb"
866        );
867        assert!(account_id.is_evm_chain());
868        let proto: String = account_id.into();
869        assert_eq!(text, proto);
870
871        // valid Bitcoin mainnet
872        let text = "bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6";
873        let account_id: AccountId = text.try_into().unwrap();
874        assert_eq!(
875            account_id.chain_id,
876            "bip122:000000000019d6689c085ae165831e93"
877        );
878        assert_eq!(
879            account_id.account_address,
880            "128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6"
881        );
882        assert!(!account_id.is_evm_chain());
883        let proto: String = account_id.into();
884        assert_eq!(text, proto);
885
886        // valid Cosmos Hub
887        let text = "cosmos:cosmoshub-3:cosmos1t2uflqwqe0fsj0shcfkrvpukewcw40yjj6hdc0";
888        let account_id: AccountId = text.try_into().unwrap();
889        assert_eq!(account_id.chain_id, "cosmos:cosmoshub-3");
890        assert_eq!(
891            account_id.account_address,
892            "cosmos1t2uflqwqe0fsj0shcfkrvpukewcw40yjj6hdc0"
893        );
894        assert!(!account_id.is_evm_chain());
895        let proto: String = account_id.into();
896        assert_eq!(text, proto);
897
898        // valid Kusama network
899        let text = "polkadot:b0a8d493285c2df73290dfb7e61f870f:5hmuyxw9xdgbpptgypokw4thfyoe3ryenebr381z9iaegmfy";
900        let account_id: AccountId = text.try_into().unwrap();
901        assert_eq!(
902            account_id.chain_id,
903            "polkadot:b0a8d493285c2df73290dfb7e61f870f"
904        );
905        assert_eq!(
906            account_id.account_address,
907            "5hmuyxw9xdgbpptgypokw4thfyoe3ryenebr381z9iaegmfy"
908        );
909        assert!(!account_id.is_evm_chain());
910        let proto: String = account_id.into();
911        assert_eq!(text, proto);
912
913        // valid StarkNet Testnet
914        let text =
915            "starknet:SN_GOERLI:0x02dd1b492765c064eac4039e3841aa5f382773b598097a40073bd8b48170ab57";
916        let account_id: AccountId = text.try_into().unwrap();
917        assert_eq!(account_id.chain_id, "starknet:SN_GOERLI");
918        assert_eq!(
919            account_id.account_address,
920            "0x02dd1b492765c064eac4039e3841aa5f382773b598097a40073bd8b48170ab57"
921        );
922        assert!(!account_id.is_evm_chain());
923        let proto: String = account_id.into();
924        assert_eq!(text, proto);
925
926        // dummy max length (64+1+8+1+32 = 106 chars/bytes)
927        let text = "chainstd:8c3444cf8970a9e41a706fab93e7a6c4:6d9b0b4b9994e8a6afbd3dc3ed983cd51c755afb27cd1dc7825ef59c134a39f7";
928        let account_id: AccountId = text.try_into().unwrap();
929        assert_eq!(
930            account_id.chain_id,
931            "chainstd:8c3444cf8970a9e41a706fab93e7a6c4"
932        );
933        assert_eq!(
934            account_id.account_address,
935            "6d9b0b4b9994e8a6afbd3dc3ed983cd51c755afb27cd1dc7825ef59c134a39f7"
936        );
937        assert!(!account_id.is_evm_chain());
938        let proto: String = account_id.into();
939        assert_eq!(text, proto);
940
941        // Hedera address (with optional checksum suffix per [HIP-15][])
942        let text = "hedera:mainnet:0.0.1234567890-zbhlt";
943        let account_id: AccountId = text.try_into().unwrap();
944        assert_eq!(account_id.chain_id, "hedera:mainnet");
945        assert_eq!(account_id.account_address, "0.0.1234567890-zbhlt");
946        assert!(!account_id.is_evm_chain());
947        let proto: String = account_id.into();
948        assert_eq!(text, proto);
949
950        // invalid
951        let text = "eip/155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcd";
952        let result: Result<AccountId, ConversionError> = text.try_into();
953        tracing::info!("{:?}", result);
954        assert!(matches!(
955            result,
956            Err(ConversionError::InvalidValue {
957                item: "eth account_id",
958                expected: "well-formed chain_id & address",
959                ..
960            })
961        ));
962    }
963
964    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
965    #[cfg_attr(not(target_arch = "wasm32"), test)]
966    fn test_account_id_create() {
967        let address = "0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb".to_string();
968        let chain_id = 12;
969        let account_id = AccountId::new_evm(chain_id, address.clone());
970        assert_eq!(account_id.account_address, address);
971        assert_eq!(account_id.chain_id, "eip155:12");
972    }
973}