Skip to main content

xmtp_id/associations/
mod.rs

1#[cfg(test)]
2mod handle_claims;
3#[cfg(test)]
4mod identity_invalid_mutations;
5mod verify_updates;
6pub use verify_updates::verify_updates;
7mod association_log;
8pub mod builder;
9pub mod handle;
10pub mod ident;
11pub(super) mod member;
12pub(super) mod serialization;
13pub mod signature;
14pub(super) mod state;
15#[cfg(any(test, feature = "test-utils"))]
16pub mod test_utils;
17pub mod unsigned_actions;
18pub mod unverified;
19pub mod verified_signature;
20
21pub use self::association_log::*;
22pub use self::handle::{Handle, InvalidHandle, MAX_HANDLE_CLAIMS, MAX_HANDLE_NAME_LEN};
23pub use self::member::{HasMemberKind, Identifier, Member, MemberIdentifier, MemberKind};
24pub use self::serialization::{DeserializationError, map_vec, try_map_vec};
25pub use self::signature::*;
26pub use self::state::{AssociationState, AssociationStateDiff};
27
28/// Apply a single [`IdentityUpdate`] to an existing [`AssociationState`] and return a new [`AssociationState`]
29pub fn apply_update(
30    initial_state: AssociationState,
31    update: IdentityUpdate,
32) -> Result<AssociationState, AssociationError> {
33    update.update_state(Some(initial_state), update.client_timestamp_ns)
34}
35
36/// Get the current state from an array of `IdentityUpdate`s. Entire operation fails if any operation fails
37pub fn get_state<Updates: AsRef<[IdentityUpdate]>>(
38    updates: Updates,
39) -> Result<AssociationState, AssociationError> {
40    let mut state = None;
41    for update in updates.as_ref().iter() {
42        let res = update.update_state(state, update.client_timestamp_ns);
43        state = Some(res?);
44    }
45
46    state.ok_or(AssociationError::NotCreated)
47}
48
49#[cfg(any(test, feature = "test-utils"))]
50pub mod test_defaults {
51    use self::{
52        unverified::{UnverifiedAction, UnverifiedIdentityUpdate},
53        verified_signature::VerifiedSignature,
54    };
55    use super::{member::Identifier, *};
56    use xmtp_common::{rand_u64, rand_vec};
57
58    impl IdentityUpdate {
59        pub fn new_test(actions: Vec<Action>, inbox_id: String) -> Self {
60            Self::new(actions, inbox_id, rand_u64())
61        }
62    }
63
64    impl UnverifiedIdentityUpdate {
65        pub fn new_test(actions: Vec<UnverifiedAction>, inbox_id: String) -> Self {
66            Self::new(inbox_id, rand_u64(), actions)
67        }
68    }
69
70    impl Default for AddAssociation {
71        fn default() -> Self {
72            let existing_member = Identifier::rand_ethereum();
73            let new_member = MemberIdentifier::rand_installation();
74            Self {
75                existing_member_signature: VerifiedSignature::new(
76                    existing_member.into(),
77                    SignatureKind::Erc191,
78                    rand_vec::<32>(),
79                    None,
80                ),
81                new_member_signature: VerifiedSignature::new(
82                    new_member.clone(),
83                    SignatureKind::InstallationKey,
84                    rand_vec::<32>(),
85                    None,
86                ),
87                new_member_identifier: new_member,
88            }
89        }
90    }
91
92    // Default will create an inbox with a ERC-191 signature
93    impl Default for CreateInbox {
94        fn default() -> Self {
95            let signer = Identifier::rand_ethereum();
96            Self {
97                nonce: rand_u64(),
98                account_identifier: signer.clone(),
99                initial_identifier_signature: VerifiedSignature::new(
100                    signer.into(),
101                    SignatureKind::Erc191,
102                    rand_vec::<32>(),
103                    None,
104                ),
105            }
106        }
107    }
108
109    impl Default for RevokeAssociation {
110        fn default() -> Self {
111            let signer = MemberIdentifier::rand_ethereum();
112            Self {
113                recovery_identifier_signature: VerifiedSignature::new(
114                    signer,
115                    SignatureKind::Erc191,
116                    rand_vec::<32>(),
117                    None,
118                ),
119                revoked_member: MemberIdentifier::rand_ethereum(),
120            }
121        }
122    }
123}
124
125#[cfg(test)]
126pub(crate) mod tests {
127    use wasm_bindgen_test::wasm_bindgen_test;
128
129    use super::*;
130    use crate::associations::{member::Identifier, verified_signature::VerifiedSignature};
131    use xmtp_common::{rand_hexstring, rand_vec};
132
133    pub fn new_test_inbox() -> AssociationState {
134        let create_request = CreateInbox::default();
135        let inbox_id = create_request
136            .account_identifier
137            .inbox_id(create_request.nonce)
138            .unwrap();
139
140        let identity_update =
141            IdentityUpdate::new_test(vec![Action::CreateInbox(create_request)], inbox_id);
142
143        get_state(vec![identity_update]).unwrap()
144    }
145
146    pub fn new_test_inbox_with_installation() -> AssociationState {
147        let initial_state = new_test_inbox();
148        let inbox_id = initial_state.inbox_id().to_string();
149        let initial_wallet_address = initial_state.recovery_identifier.clone();
150
151        let update = Action::AddAssociation(AddAssociation {
152            existing_member_signature: VerifiedSignature::new(
153                initial_wallet_address.clone().into(),
154                SignatureKind::Erc191,
155                rand_vec::<32>(),
156                None,
157            ),
158            ..Default::default()
159        });
160
161        apply_update(
162            initial_state,
163            IdentityUpdate::new_test(vec![update], inbox_id.to_string()),
164        )
165        .unwrap()
166    }
167
168    #[wasm_bindgen_test(unsupported = test)]
169    // verifies: IDENT-012
170    fn test_create_inbox() {
171        let create_request = CreateInbox::default();
172        let inbox_id = create_request
173            .account_identifier
174            .inbox_id(create_request.nonce)
175            .unwrap();
176
177        let account_address = create_request.account_identifier.clone();
178        let identity_update =
179            IdentityUpdate::new_test(vec![Action::CreateInbox(create_request)], inbox_id.clone());
180        let state = get_state(vec![identity_update]).unwrap();
181        assert_eq!(state.members().len(), 1);
182
183        let existing_entity = state.get(&account_address.clone().into()).unwrap();
184        assert_eq!(existing_entity.identifier, account_address);
185    }
186
187    #[wasm_bindgen_test(unsupported = test)]
188    // verifies: IDENT-042
189    fn create_and_add_separately() {
190        let initial_state = new_test_inbox();
191        let inbox_id = initial_state.inbox_id().to_string();
192        let new_installation_identifier = MemberIdentifier::rand_installation();
193        let first_member = initial_state.recovery_identifier.clone();
194
195        let update = Action::AddAssociation(AddAssociation {
196            new_member_identifier: new_installation_identifier.clone(),
197            new_member_signature: VerifiedSignature::new(
198                new_installation_identifier.clone(),
199                SignatureKind::InstallationKey,
200                rand_vec::<32>(),
201                None,
202            ),
203            existing_member_signature: VerifiedSignature::new(
204                first_member.clone().into(),
205                SignatureKind::Erc191,
206                rand_vec::<32>(),
207                None,
208            ),
209        });
210
211        let new_state = apply_update(
212            initial_state,
213            IdentityUpdate::new_test(vec![update], inbox_id.to_string()),
214        )
215        .unwrap();
216        assert_eq!(new_state.members().len(), 2);
217
218        let new_member = new_state.get(&new_installation_identifier).unwrap();
219        assert_eq!(new_member.added_by_entity, Some(first_member.into()));
220    }
221
222    #[wasm_bindgen_test(unsupported = test)]
223    // verifies: IDENT-004
224    fn create_and_add_together() {
225        let create_action = CreateInbox::default();
226        let account_address = create_action.account_identifier.clone();
227        let inbox_id = account_address.inbox_id(create_action.nonce).unwrap();
228        let new_member_identifier = MemberIdentifier::rand_installation();
229        let add_action = AddAssociation {
230            existing_member_signature: VerifiedSignature::new(
231                account_address.clone().into(),
232                SignatureKind::Erc191,
233                rand_vec::<32>(),
234                None,
235            ),
236            // Add an installation ID
237            new_member_signature: VerifiedSignature::new(
238                new_member_identifier.clone(),
239                SignatureKind::InstallationKey,
240                rand_vec::<32>(),
241                None,
242            ),
243            new_member_identifier: new_member_identifier.clone(),
244        };
245        let identity_update = IdentityUpdate::new_test(
246            vec![
247                Action::CreateInbox(create_action),
248                Action::AddAssociation(add_action),
249            ],
250            inbox_id.clone(),
251        );
252        let state = get_state(vec![identity_update]).unwrap();
253        assert_eq!(state.members().len(), 2);
254        assert_eq!(
255            state.get(&new_member_identifier).unwrap().added_by_entity,
256            Some(account_address.into())
257        );
258    }
259
260    #[wasm_bindgen_test(unsupported = test)]
261    fn add_wallet_from_installation_key() {
262        let initial_state = new_test_inbox_with_installation();
263        let inbox_id = initial_state.inbox_id().to_string();
264        let installation_id = initial_state
265            .members_by_kind(MemberKind::Installation)
266            .first()
267            .cloned()
268            .unwrap()
269            .identifier;
270
271        let new_wallet_address = MemberIdentifier::rand_ethereum();
272        let add_association = Action::AddAssociation(AddAssociation {
273            new_member_identifier: new_wallet_address.clone(),
274            new_member_signature: VerifiedSignature::new(
275                new_wallet_address.clone(),
276                SignatureKind::Erc191,
277                rand_vec::<32>(),
278                None,
279            ),
280            existing_member_signature: VerifiedSignature::new(
281                installation_id.clone(),
282                SignatureKind::InstallationKey,
283                rand_vec::<32>(),
284                None,
285            ),
286        });
287
288        let new_state = apply_update(
289            initial_state,
290            IdentityUpdate::new_test(vec![add_association], inbox_id.to_string()),
291        )
292        .expect("expected update to succeed");
293        assert_eq!(new_state.members().len(), 3);
294    }
295
296    // verifies: IDENT-012
297    #[wasm_bindgen_test(unsupported = test)]
298    fn reject_invalid_signature_on_create() {
299        // Creates a signature with the wrong signer
300        let bad_signature = VerifiedSignature::new(
301            MemberIdentifier::rand_ethereum(),
302            SignatureKind::Erc191,
303            rand_vec::<32>(),
304            None,
305        );
306        let action = CreateInbox {
307            initial_identifier_signature: bad_signature,
308            ..Default::default()
309        };
310
311        let state_result = get_state(vec![IdentityUpdate::new_test(
312            vec![Action::CreateInbox(action)],
313            rand_hexstring(),
314        )]);
315
316        assert!(state_result.is_err());
317        assert!(matches!(
318            state_result,
319            Err(AssociationError::MissingExistingMember)
320        ));
321    }
322
323    #[wasm_bindgen_test(unsupported = test)]
324    // verifies: IDENT-040
325    fn reject_invalid_signature_on_update() {
326        let initial_state = new_test_inbox();
327        let inbox_id = initial_state.inbox_id().to_string();
328        // Signature is from a random address
329        let bad_signature = VerifiedSignature::new(
330            MemberIdentifier::rand_ethereum(),
331            SignatureKind::Erc191,
332            rand_vec::<32>(),
333            None,
334        );
335
336        let update_with_bad_existing_member = Action::AddAssociation(AddAssociation {
337            existing_member_signature: bad_signature.clone(),
338            ..Default::default()
339        });
340
341        let update_result = apply_update(
342            initial_state.clone(),
343            IdentityUpdate::new_test(vec![update_with_bad_existing_member], inbox_id.to_string()),
344        );
345
346        assert!(matches!(
347            update_result,
348            Err(AssociationError::MissingExistingMember)
349        ));
350
351        let update_with_bad_new_member = Action::AddAssociation(AddAssociation {
352            new_member_signature: bad_signature.clone(),
353            existing_member_signature: VerifiedSignature::new(
354                initial_state.recovery_identifier().clone().into(),
355                SignatureKind::Erc191,
356                rand_vec::<32>(),
357                None,
358            ),
359            ..Default::default()
360        });
361
362        let update_result_2 = apply_update(
363            initial_state,
364            IdentityUpdate::new_test(vec![update_with_bad_new_member], inbox_id.to_string()),
365        );
366        assert!(matches!(
367            update_result_2,
368            Err(AssociationError::NewMemberIdSignatureMismatch)
369        ));
370    }
371
372    #[wasm_bindgen_test(unsupported = test)]
373    fn reject_if_signer_not_existing_member() {
374        let create_inbox = CreateInbox::default();
375        let inbox_id = create_inbox
376            .account_identifier
377            .inbox_id(create_inbox.nonce)
378            .unwrap();
379
380        let create_request = Action::CreateInbox(create_inbox);
381        // The default here will create an AddAssociation from a random wallet
382        let update = Action::AddAssociation(AddAssociation {
383            // Existing member signature is coming from a random wallet
384            existing_member_signature: VerifiedSignature::new(
385                MemberIdentifier::rand_ethereum(),
386                SignatureKind::Erc191,
387                rand_vec::<32>(),
388                None,
389            ),
390            ..Default::default()
391        });
392
393        let state_result = get_state(vec![IdentityUpdate::new_test(
394            vec![create_request, update],
395            inbox_id.clone(),
396        )]);
397        assert!(matches!(
398            state_result,
399            Err(AssociationError::MissingExistingMember)
400        ));
401    }
402
403    // verifies: IDENT-041
404    #[wasm_bindgen_test(unsupported = test)]
405    fn reject_if_installation_adding_installation() {
406        let existing_state = new_test_inbox_with_installation();
407        let inbox_id = existing_state.inbox_id().to_string();
408        let existing_installations = existing_state.members_by_kind(MemberKind::Installation);
409        let existing_installation = existing_installations.first().unwrap();
410        let new_installation_id = MemberIdentifier::rand_installation();
411
412        let update = Action::AddAssociation(AddAssociation {
413            existing_member_signature: VerifiedSignature::new(
414                existing_installation.identifier.clone(),
415                SignatureKind::InstallationKey,
416                rand_vec::<32>(),
417                None,
418            ),
419            new_member_identifier: new_installation_id.clone(),
420            new_member_signature: VerifiedSignature::new(
421                new_installation_id.clone(),
422                SignatureKind::InstallationKey,
423                rand_vec::<32>(),
424                None,
425            ),
426        });
427
428        let update_result = apply_update(
429            existing_state,
430            IdentityUpdate::new_test(vec![update], inbox_id.to_string()),
431        );
432        assert!(matches!(
433            update_result,
434            Err(AssociationError::MemberNotAllowed(
435                MemberKind::Installation,
436                MemberKind::Installation
437            ))
438        ));
439    }
440
441    #[wasm_bindgen_test(unsupported = test)]
442    fn revoke() {
443        let initial_state = new_test_inbox_with_installation();
444        let inbox_id = initial_state.inbox_id().to_string();
445        let installation_id = initial_state
446            .members_by_kind(MemberKind::Installation)
447            .first()
448            .cloned()
449            .unwrap()
450            .identifier;
451        let update = Action::RevokeAssociation(RevokeAssociation {
452            recovery_identifier_signature: VerifiedSignature::new(
453                initial_state.recovery_identifier.clone().into(),
454                SignatureKind::Erc191,
455                rand_vec::<32>(),
456                None,
457            ),
458            revoked_member: installation_id.clone(),
459        });
460
461        let new_state = apply_update(
462            initial_state,
463            IdentityUpdate::new_test(vec![update], inbox_id.to_string()),
464        )
465        .expect("expected update to succeed");
466        assert!(new_state.get(&installation_id).is_none());
467    }
468
469    // verifies: IDENT-044
470    #[wasm_bindgen_test(unsupported = test)]
471    fn revoke_children() {
472        let initial_state = new_test_inbox_with_installation();
473        let inbox_id = initial_state.inbox_id().to_string();
474        let wallet_address = initial_state
475            .members_by_kind(MemberKind::Ethereum)
476            .first()
477            .cloned()
478            .unwrap()
479            .identifier;
480
481        let add_second_installation = Action::AddAssociation(AddAssociation {
482            existing_member_signature: VerifiedSignature::new(
483                wallet_address.clone(),
484                SignatureKind::Erc191,
485                rand_vec::<32>(),
486                None,
487            ),
488            ..Default::default()
489        });
490
491        let new_state = apply_update(
492            initial_state,
493            IdentityUpdate::new_test(vec![add_second_installation], inbox_id.to_string()),
494        )
495        .expect("expected update to succeed");
496        assert_eq!(new_state.members().len(), 3);
497
498        let revocation = Action::RevokeAssociation(RevokeAssociation {
499            recovery_identifier_signature: VerifiedSignature::new(
500                wallet_address.clone(),
501                SignatureKind::Erc191,
502                rand_vec::<32>(),
503                None,
504            ),
505            revoked_member: wallet_address.clone(),
506        });
507
508        // With this revocation the original wallet + both installations should be gone
509        let new_state = apply_update(
510            new_state,
511            IdentityUpdate::new_test(vec![revocation], inbox_id.to_string()),
512        )
513        .expect("expected update to succeed");
514        assert_eq!(new_state.members().len(), 0);
515    }
516
517    #[wasm_bindgen_test(unsupported = test)]
518    fn revoke_and_re_add() {
519        let initial_state = new_test_inbox();
520        let wallet_address = initial_state
521            .members_by_kind(MemberKind::Ethereum)
522            .first()
523            .cloned()
524            .unwrap()
525            .identifier;
526
527        let inbox_id = initial_state.inbox_id().to_string();
528
529        let second_wallet_address = MemberIdentifier::rand_ethereum();
530        let add_second_wallet = Action::AddAssociation(AddAssociation {
531            new_member_identifier: second_wallet_address.clone(),
532            new_member_signature: VerifiedSignature::new(
533                second_wallet_address.clone(),
534                SignatureKind::Erc191,
535                rand_vec::<32>(),
536                None,
537            ),
538            existing_member_signature: VerifiedSignature::new(
539                wallet_address.clone(),
540                SignatureKind::Erc191,
541                rand_vec::<32>(),
542                None,
543            ),
544        });
545
546        let revoke_second_wallet = Action::RevokeAssociation(RevokeAssociation {
547            recovery_identifier_signature: VerifiedSignature::new(
548                wallet_address.clone(),
549                SignatureKind::Erc191,
550                rand_vec::<32>(),
551                None,
552            ),
553            revoked_member: second_wallet_address.clone(),
554        });
555
556        let state_after_remove = apply_update(
557            initial_state,
558            IdentityUpdate::new_test(
559                vec![add_second_wallet, revoke_second_wallet],
560                inbox_id.to_string(),
561            ),
562        )
563        .expect("expected update to succeed");
564        assert_eq!(state_after_remove.members().len(), 1);
565
566        let add_second_wallet_again = Action::AddAssociation(AddAssociation {
567            new_member_identifier: second_wallet_address.clone(),
568            new_member_signature: VerifiedSignature::new(
569                second_wallet_address.clone(),
570                SignatureKind::Erc191,
571                rand_vec::<32>(),
572                None,
573            ),
574            existing_member_signature: VerifiedSignature::new(
575                wallet_address,
576                SignatureKind::Erc191,
577                rand_vec::<32>(),
578                None,
579            ),
580        });
581
582        let state_after_re_add = apply_update(
583            state_after_remove,
584            IdentityUpdate::new_test(vec![add_second_wallet_again], inbox_id.to_string()),
585        )
586        .expect("expected update to succeed");
587        assert_eq!(state_after_re_add.members().len(), 2);
588    }
589
590    #[wasm_bindgen_test(unsupported = test)]
591    // verifies: IDENT-043, IDENT-045
592    fn change_recovery_address() {
593        let initial_state = new_test_inbox_with_installation();
594        let inbox_id = initial_state.inbox_id().to_string();
595        let initial_recovery_address = initial_state.recovery_identifier().clone();
596        let new_recovery_identifier = Identifier::rand_ethereum();
597        let update_recovery = Action::ChangeRecoveryIdentity(ChangeRecoveryIdentity {
598            new_recovery_identifier: new_recovery_identifier.clone(),
599            recovery_identifier_signature: VerifiedSignature::new(
600                initial_state.recovery_identifier().clone().into(),
601                SignatureKind::Erc191,
602                rand_vec::<32>(),
603                None,
604            ),
605        });
606
607        let new_state = apply_update(
608            initial_state,
609            IdentityUpdate::new_test(vec![update_recovery], inbox_id.to_string()),
610        )
611        .expect("expected update to succeed");
612        assert_eq!(new_state.recovery_identifier(), &new_recovery_identifier);
613
614        let attempted_revoke = Action::RevokeAssociation(RevokeAssociation {
615            recovery_identifier_signature: VerifiedSignature::new(
616                initial_recovery_address.clone().into(),
617                SignatureKind::Erc191,
618                rand_vec::<32>(),
619                None,
620            ),
621            revoked_member: initial_recovery_address.clone().into(),
622        });
623
624        let revoke_result = apply_update(
625            new_state,
626            IdentityUpdate::new_test(vec![attempted_revoke], inbox_id.to_string()),
627        );
628        assert!(revoke_result.is_err());
629        assert!(matches!(
630            revoke_result,
631            Err(AssociationError::MissingExistingMember)
632        ));
633    }
634
635    #[wasm_bindgen_test(unsupported = test)]
636    // verifies: IDENT-046
637    fn scw_signature_binding() {
638        let initial_chain_id: u64 = 1;
639        let signer = Identifier::rand_ethereum();
640        let initial_identifier_signature = VerifiedSignature::new(
641            signer.clone().into(),
642            SignatureKind::Erc1271,
643            rand_vec::<32>(),
644            Some(initial_chain_id),
645        );
646        let action = CreateInbox {
647            initial_identifier_signature,
648            nonce: 0,
649            account_identifier: signer.clone(),
650        };
651
652        let inbox_id = signer.inbox_id(0).unwrap();
653        let initial_state = get_state(vec![IdentityUpdate::new_test(
654            vec![Action::CreateInbox(action)],
655            inbox_id,
656        )])
657        .expect("initial state should be OK");
658
659        let inbox_id = initial_state.inbox_id();
660
661        let new_chain_id: u64 = 2;
662        let new_member = MemberIdentifier::rand_installation();
663
664        // A signature from the same account address but on a different chain ID
665        let existing_member_sig = VerifiedSignature::new(
666            signer.clone().into(),
667            SignatureKind::Erc1271,
668            rand_vec::<32>(),
669            Some(new_chain_id),
670        );
671
672        let actions: Vec<Action> = vec![
673            Action::AddAssociation(AddAssociation {
674                existing_member_signature: existing_member_sig.clone(),
675                new_member_signature: VerifiedSignature::new(
676                    new_member.clone(),
677                    SignatureKind::InstallationKey,
678                    rand_vec::<32>(),
679                    None,
680                ),
681                new_member_identifier: new_member.clone(),
682            }),
683            Action::RevokeAssociation(RevokeAssociation {
684                recovery_identifier_signature: existing_member_sig.clone(),
685                revoked_member: signer.clone().into(),
686            }),
687            Action::ChangeRecoveryIdentity(ChangeRecoveryIdentity {
688                recovery_identifier_signature: existing_member_sig.clone(),
689                new_recovery_identifier: Identifier::rand_ethereum(),
690            }),
691        ];
692
693        // Test all possible actions and ensure the chain id mismatch error is thrown
694        for action in actions {
695            let apply_result = apply_update(
696                initial_state.clone(),
697                IdentityUpdate::new_test(vec![action], inbox_id.to_string()),
698            );
699
700            assert!(matches!(
701                apply_result,
702                Err(AssociationError::ChainIdMismatch(_, _))
703            ));
704        }
705    }
706}