xmtp_id/associations/
unsigned_actions.rs

1use super::{MemberIdentifier, member::Identifier};
2use crate::associations::{MemberKind, member::HasMemberKind};
3use chrono::DateTime;
4
5const HEADER: &str = "XMTP : Authenticate to inbox";
6const FOOTER: &str = "For more info: https://xmtp.org/signatures";
7
8pub trait SignatureTextCreator {
9    fn signature_text(&self) -> String;
10}
11
12#[derive(Clone, Debug, PartialEq)]
13pub struct UnsignedCreateInbox {
14    pub nonce: u64,
15    pub account_identifier: Identifier,
16}
17
18impl SignatureTextCreator for UnsignedCreateInbox {
19    fn signature_text(&self) -> String {
20        format!("- Create inbox\n  (Owner: {})", self.account_identifier)
21    }
22}
23
24#[derive(Clone, Debug, PartialEq)]
25pub struct UnsignedAddAssociation {
26    pub new_member_identifier: MemberIdentifier,
27}
28
29impl SignatureTextCreator for UnsignedAddAssociation {
30    fn signature_text(&self) -> String {
31        let member_kind = self.new_member_identifier.kind();
32        let id_kind = get_identifier_text(&member_kind);
33        let prefix = match member_kind {
34            MemberKind::Installation => "Grant messaging access to app",
35            MemberKind::Ethereum => "Link address to inbox",
36            MemberKind::Passkey => "Link passkey to inbox",
37        };
38        format!("- {prefix}\n  ({id_kind}: {})", self.new_member_identifier)
39    }
40}
41
42#[derive(Clone, Debug, PartialEq)]
43pub struct UnsignedRevokeAssociation {
44    pub revoked_member: MemberIdentifier,
45}
46
47impl SignatureTextCreator for UnsignedRevokeAssociation {
48    fn signature_text(&self) -> String {
49        let member_kind = self.revoked_member.kind();
50        let id_kind = get_identifier_text(&member_kind);
51        let prefix = match self.revoked_member.kind() {
52            MemberKind::Installation => "Revoke messaging access from app",
53            MemberKind::Ethereum => "Unlink address from inbox",
54            MemberKind::Passkey => "Unlink passkey from inbox",
55        };
56        format!("- {prefix}\n  ({id_kind}: {})", self.revoked_member)
57    }
58}
59
60#[derive(Clone, Debug, PartialEq)]
61pub struct UnsignedChangeRecoveryAddress {
62    pub new_recovery_identifier: Identifier,
63}
64
65impl SignatureTextCreator for UnsignedChangeRecoveryAddress {
66    fn signature_text(&self) -> String {
67        format!(
68            "- Change inbox recovery address\n  (Address: {})",
69            self.new_recovery_identifier
70        )
71    }
72}
73
74#[allow(dead_code)]
75#[derive(Clone, Debug, PartialEq)]
76pub enum UnsignedAction {
77    CreateInbox(UnsignedCreateInbox),
78    AddAssociation(UnsignedAddAssociation),
79    RevokeAssociation(UnsignedRevokeAssociation),
80    ChangeRecoveryAddress(UnsignedChangeRecoveryAddress),
81}
82
83impl SignatureTextCreator for UnsignedAction {
84    fn signature_text(&self) -> String {
85        match self {
86            UnsignedAction::CreateInbox(action) => action.signature_text(),
87            UnsignedAction::AddAssociation(action) => action.signature_text(),
88            UnsignedAction::RevokeAssociation(action) => action.signature_text(),
89            UnsignedAction::ChangeRecoveryAddress(action) => action.signature_text(),
90        }
91    }
92}
93
94#[derive(Clone, Debug, PartialEq)]
95pub struct UnsignedIdentityUpdate {
96    pub inbox_id: String,
97    pub client_timestamp_ns: u64,
98    pub actions: Vec<UnsignedAction>,
99}
100
101impl UnsignedIdentityUpdate {
102    pub fn new(actions: Vec<UnsignedAction>, inbox_id: String, client_timestamp_ns: u64) -> Self {
103        UnsignedIdentityUpdate {
104            inbox_id,
105            client_timestamp_ns,
106            actions,
107        }
108    }
109}
110
111impl SignatureTextCreator for UnsignedIdentityUpdate {
112    fn signature_text(&self) -> String {
113        let all_signatures = self
114            .actions
115            .iter()
116            .map(|action| action.signature_text())
117            .collect::<Vec<String>>();
118        format!(
119            "{HEADER}\n\nInbox ID: {}\nCurrent time: {}\n\n{}\n\n{FOOTER}",
120            self.inbox_id,
121            pretty_timestamp(self.client_timestamp_ns),
122            all_signatures.join("\n"),
123        )
124    }
125}
126
127fn get_identifier_text(kind: &MemberKind) -> String {
128    match kind {
129        MemberKind::Ethereum => "Address".to_string(),
130        MemberKind::Installation => "ID".to_string(),
131        MemberKind::Passkey => "Passkey".to_string(),
132    }
133}
134
135fn pretty_timestamp(ns_date: u64) -> String {
136    let date = DateTime::from_timestamp_nanos(ns_date as i64);
137    date.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
138}
139
140#[cfg(test)]
141pub(crate) mod tests {
142    #[cfg(target_arch = "wasm32")]
143    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_dedicated_worker);
144
145    use super::*;
146
147    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
148    #[cfg_attr(not(target_arch = "wasm32"), test)]
149    fn create_signatures() {
150        let account_identifier =
151            Identifier::eth("0x1234567890abcdef1234567890abcdef12345678").unwrap();
152
153        let client_timestamp_ns: u64 = 12;
154        let new_member_address = "0x4567890abcdef1234567890abcdef12345678123".to_string();
155        let new_recovery_identifier =
156            Identifier::eth("0x7890abcdef1234567890abcdef12345678123456").unwrap();
157        let new_installation_id = vec![1, 2, 3];
158        let create_inbox = UnsignedCreateInbox {
159            nonce: 0,
160            account_identifier: account_identifier.clone(),
161        };
162        let inbox_id = account_identifier.inbox_id(create_inbox.nonce).unwrap();
163
164        let add_address = UnsignedAddAssociation {
165            new_member_identifier: MemberIdentifier::eth(&new_member_address).unwrap(),
166        };
167
168        let add_installation = UnsignedAddAssociation {
169            new_member_identifier: MemberIdentifier::installation(new_installation_id.clone()),
170        };
171
172        let revoke_address = UnsignedRevokeAssociation {
173            revoked_member: MemberIdentifier::eth(new_member_address).unwrap(),
174        };
175
176        let revoke_installation = UnsignedRevokeAssociation {
177            revoked_member: MemberIdentifier::installation(new_installation_id.clone()),
178        };
179
180        let change_recovery_address = UnsignedChangeRecoveryAddress {
181            new_recovery_identifier: new_recovery_identifier.clone(),
182        };
183
184        let identity_update = UnsignedIdentityUpdate {
185            inbox_id: inbox_id.clone(),
186            client_timestamp_ns,
187            actions: vec![
188                UnsignedAction::CreateInbox(create_inbox.clone()),
189                UnsignedAction::AddAssociation(add_address.clone()),
190                UnsignedAction::AddAssociation(add_installation.clone()),
191                UnsignedAction::RevokeAssociation(revoke_address.clone()),
192                UnsignedAction::RevokeAssociation(revoke_installation.clone()),
193                UnsignedAction::ChangeRecoveryAddress(change_recovery_address.clone()),
194            ],
195        };
196        let signature_text = identity_update.signature_text();
197        let expected_text = "XMTP : Authenticate to inbox
198
199Inbox ID: fcd18d86276d7a99fe522dba9660c420f03c8648785ada7c5daae232a3df77a9
200Current time: 1970-01-01T00:00:00Z
201
202- Create inbox
203  (Owner: 0x1234567890abcdef1234567890abcdef12345678)
204- Link address to inbox
205  (Address: 0x4567890abcdef1234567890abcdef12345678123)
206- Grant messaging access to app
207  (ID: 010203)
208- Unlink address from inbox
209  (Address: 0x4567890abcdef1234567890abcdef12345678123)
210- Revoke messaging access from app
211  (ID: 010203)
212- Change inbox recovery address
213  (Address: 0x7890abcdef1234567890abcdef12345678123456)
214
215For more info: https://xmtp.org/signatures";
216        assert_eq!(signature_text, expected_text)
217    }
218}