xmtp_id/associations/
mod.rs

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