Skip to main content

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/// A `ClaimHandle` before it is signed. `handle` is kept exactly as carried,
75/// because the signature text holds it.
76#[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    // implements: IDENT-020
128    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    // verifies: IDENT-010, IDENT-020
163    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    /// A claim contributes its own two lines, with the handle as carried, so
234    /// a signer sees exactly the handle a validator applies.
235    // verifies: IDENT-020
236    #[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}