xmtp_id/associations/
unsigned_actions.rs1use 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#[derive(Clone, Debug, PartialEq)]
77pub struct UnsignedClaimHandle {
78 pub handle: String,
79}
80
81impl SignatureTextCreator for UnsignedClaimHandle {
82 fn signature_text(&self) -> String {
83 format!("- Claim handle\n (Handle: {})", self.handle)
84 }
85}
86
87#[allow(dead_code)]
88#[derive(Clone, Debug, PartialEq)]
89pub enum UnsignedAction {
90 CreateInbox(UnsignedCreateInbox),
91 AddAssociation(UnsignedAddAssociation),
92 RevokeAssociation(UnsignedRevokeAssociation),
93 ChangeRecoveryAddress(UnsignedChangeRecoveryAddress),
94 ClaimHandle(UnsignedClaimHandle),
95}
96
97impl SignatureTextCreator for UnsignedAction {
98 fn signature_text(&self) -> String {
99 match self {
100 UnsignedAction::CreateInbox(action) => action.signature_text(),
101 UnsignedAction::AddAssociation(action) => action.signature_text(),
102 UnsignedAction::RevokeAssociation(action) => action.signature_text(),
103 UnsignedAction::ChangeRecoveryAddress(action) => action.signature_text(),
104 UnsignedAction::ClaimHandle(action) => action.signature_text(),
105 }
106 }
107}
108
109#[derive(Clone, Debug, PartialEq)]
110pub struct UnsignedIdentityUpdate {
111 pub inbox_id: String,
112 pub client_timestamp_ns: u64,
113 pub actions: Vec<UnsignedAction>,
114}
115
116impl UnsignedIdentityUpdate {
117 pub fn new(actions: Vec<UnsignedAction>, inbox_id: String, client_timestamp_ns: u64) -> Self {
118 UnsignedIdentityUpdate {
119 inbox_id,
120 client_timestamp_ns,
121 actions,
122 }
123 }
124}
125
126impl SignatureTextCreator for UnsignedIdentityUpdate {
127 fn signature_text(&self) -> String {
129 let all_signatures = self
130 .actions
131 .iter()
132 .map(|action| action.signature_text())
133 .collect::<Vec<String>>();
134 format!(
135 "{HEADER}\n\nInbox ID: {}\nCurrent time: {}\n\n{}\n\n{FOOTER}",
136 self.inbox_id,
137 pretty_timestamp(self.client_timestamp_ns),
138 all_signatures.join("\n"),
139 )
140 }
141}
142
143fn get_identifier_text(kind: &MemberKind) -> String {
144 match kind {
145 MemberKind::Ethereum => "Address".to_string(),
146 MemberKind::Installation => "ID".to_string(),
147 MemberKind::Passkey => "Passkey".to_string(),
148 }
149}
150
151fn pretty_timestamp(ns_date: u64) -> String {
152 let date = DateTime::from_timestamp_nanos(ns_date as i64);
153 date.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
154}
155
156#[cfg(test)]
157pub(crate) mod tests {
158 use super::*;
159
160 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
161 #[cfg_attr(not(target_arch = "wasm32"), test)]
162 fn create_signatures() {
164 let account_identifier =
165 Identifier::eth("0x1234567890abcdef1234567890abcdef12345678").unwrap();
166
167 let client_timestamp_ns: u64 = 12;
168 let new_member_address = "0x4567890abcdef1234567890abcdef12345678123".to_string();
169 let new_recovery_identifier =
170 Identifier::eth("0x7890abcdef1234567890abcdef12345678123456").unwrap();
171 let new_installation_id = vec![1, 2, 3];
172 let create_inbox = UnsignedCreateInbox {
173 nonce: 0,
174 account_identifier: account_identifier.clone(),
175 };
176 let inbox_id = account_identifier.inbox_id(create_inbox.nonce).unwrap();
177
178 let add_address = UnsignedAddAssociation {
179 new_member_identifier: MemberIdentifier::eth(&new_member_address).unwrap(),
180 };
181
182 let add_installation = UnsignedAddAssociation {
183 new_member_identifier: MemberIdentifier::installation(new_installation_id.clone()),
184 };
185
186 let revoke_address = UnsignedRevokeAssociation {
187 revoked_member: MemberIdentifier::eth(new_member_address).unwrap(),
188 };
189
190 let revoke_installation = UnsignedRevokeAssociation {
191 revoked_member: MemberIdentifier::installation(new_installation_id.clone()),
192 };
193
194 let change_recovery_address = UnsignedChangeRecoveryAddress {
195 new_recovery_identifier: new_recovery_identifier.clone(),
196 };
197
198 let identity_update = UnsignedIdentityUpdate {
199 inbox_id: inbox_id.clone(),
200 client_timestamp_ns,
201 actions: vec![
202 UnsignedAction::CreateInbox(create_inbox.clone()),
203 UnsignedAction::AddAssociation(add_address.clone()),
204 UnsignedAction::AddAssociation(add_installation.clone()),
205 UnsignedAction::RevokeAssociation(revoke_address.clone()),
206 UnsignedAction::RevokeAssociation(revoke_installation.clone()),
207 UnsignedAction::ChangeRecoveryAddress(change_recovery_address.clone()),
208 ],
209 };
210 let signature_text = identity_update.signature_text();
211 let expected_text = "XMTP : Authenticate to inbox
212
213Inbox ID: fcd18d86276d7a99fe522dba9660c420f03c8648785ada7c5daae232a3df77a9
214Current time: 1970-01-01T00:00:00Z
215
216- Create inbox
217 (Owner: 0x1234567890abcdef1234567890abcdef12345678)
218- Link address to inbox
219 (Address: 0x4567890abcdef1234567890abcdef12345678123)
220- Grant messaging access to app
221 (ID: 010203)
222- Unlink address from inbox
223 (Address: 0x4567890abcdef1234567890abcdef12345678123)
224- Revoke messaging access from app
225 (ID: 010203)
226- Change inbox recovery address
227 (Address: 0x7890abcdef1234567890abcdef12345678123456)
228
229For more info: https://xmtp.org/signatures";
230 assert_eq!(signature_text, expected_text)
231 }
232
233 #[xmtp_common::test(unwrap_try = true)]
237 fn claim_handle_signature_text_is_fixed() {
238 let identity_update = UnsignedIdentityUpdate {
239 inbox_id: "fcd18d86276d7a99fe522dba9660c420f03c8648785ada7c5daae232a3df77a9".into(),
240 client_timestamp_ns: 1_712_750_400_000_000_000,
241 actions: vec![UnsignedAction::ClaimHandle(UnsignedClaimHandle {
242 handle: "nick#2341".into(),
243 })],
244 };
245 let expected_text = "XMTP : Authenticate to inbox
246
247Inbox ID: fcd18d86276d7a99fe522dba9660c420f03c8648785ada7c5daae232a3df77a9
248Current time: 2024-04-10T12:00:00Z
249
250- Claim handle
251 (Handle: nick#2341)
252
253For more info: https://xmtp.org/signatures";
254 assert_eq!(identity_update.signature_text(), expected_text);
255 }
256}