Skip to main content

xmtp_proto/types/
topic.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use std::{
3    fmt::{Debug, Display},
4    ops::Deref,
5};
6
7use smallvec::SmallVec;
8
9use crate::{ConversionError, types::InstallationId};
10
11/// the max size of an item in a [`TopicKind`] is 32 bytes (installation id).
12/// the 1st byte is interpreted as the prefixed [`TopicKind`] byte.
13type TopicBytes = SmallVec<[u8; 33]>;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16#[repr(u8)]
17#[non_exhaustive]
18pub enum TopicKind {
19    GroupMessagesV1 = 0,
20    WelcomeMessagesV1 = 1,
21    IdentityUpdatesV1 = 2,
22    KeyPackagesV1 = 3,
23    CommitLogEntriesV1 = 4,
24}
25
26impl TryFrom<u8> for TopicKind {
27    type Error = crate::ConversionError;
28
29    fn try_from(value: u8) -> Result<Self, Self::Error> {
30        match value {
31            0 => Ok(TopicKind::GroupMessagesV1),
32            1 => Ok(TopicKind::WelcomeMessagesV1),
33            2 => Ok(TopicKind::IdentityUpdatesV1),
34            3 => Ok(TopicKind::KeyPackagesV1),
35            4 => Ok(TopicKind::CommitLogEntriesV1),
36            i => Err(ConversionError::InvalidValue {
37                item: "u8",
38                expected: "an unsigned integer in the range 0-4",
39                got: i.to_string(),
40            }),
41        }
42    }
43}
44
45impl Display for TopicKind {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        use TopicKind::*;
48        match self {
49            GroupMessagesV1 => write!(f, "group_message_v1"),
50            WelcomeMessagesV1 => write!(f, "welcome_message_v1"),
51            IdentityUpdatesV1 => write!(f, "identity_updates_v1"),
52            KeyPackagesV1 => write!(f, "key_packages_v1"),
53            CommitLogEntriesV1 => write!(f, "commit_log_entries_v1"),
54        }
55    }
56}
57
58impl TopicKind {
59    fn build<B: AsRef<[u8]>>(&self, bytes: B) -> TopicBytes {
60        let bytes = bytes.as_ref();
61        let mut topic = TopicBytes::new();
62        topic.push(*self as u8);
63        topic.extend_from_slice(bytes);
64        topic
65    }
66
67    pub fn create<B: AsRef<[u8]>>(&self, bytes: B) -> Topic {
68        Topic {
69            inner: self.build(bytes),
70        }
71    }
72}
73
74/// A topic where the first byte is the kind
75/// https://github.com/xmtp/XIPs/blob/main/XIPs/xip-49-decentralized-backend.md#332-envelopes
76#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
77#[serde(transparent)]
78pub struct Topic {
79    #[serde(serialize_with = "to_hex", deserialize_with = "from_hex")]
80    inner: TopicBytes,
81}
82
83fn to_hex<S>(bytes: &TopicBytes, serializer: S) -> Result<S::Ok, S::Error>
84where
85    S: Serializer,
86{
87    serializer.serialize_str(&hex::encode(bytes.as_slice()))
88}
89
90fn from_hex<'de, D>(deserializer: D) -> Result<TopicBytes, D::Error>
91where
92    D: Deserializer<'de>,
93{
94    let s: &str = Deserialize::deserialize(deserializer)?;
95    hex::decode(s)
96        .map(SmallVec::from_vec)
97        .map_err(serde::de::Error::custom)
98}
99
100impl Topic {
101    /// Parse a backend topic and check its kind-specific identifier length.
102    ///
103    /// Kinds 0 and 4 take a 32-byte group or commit-log id. A 16-byte legacy
104    /// group id names no topic.
105    pub fn parse(bytes: &[u8]) -> Result<Self, ConversionError> {
106        use xmtp_configuration::{BACKEND_INSTALLATION_ID_BYTES, GROUP_ID_BYTES};
107
108        let Some((&kind, identifier)) = bytes.split_first() else {
109            return Err(ConversionError::InvalidValue {
110                item: "Topic",
111                expected: "a topic kind followed by its identifier",
112                got: "empty".into(),
113            });
114        };
115        let kind = TopicKind::try_from(kind)?;
116        let expected = match kind {
117            TopicKind::GroupMessagesV1 | TopicKind::CommitLogEntriesV1 => GROUP_ID_BYTES,
118            TopicKind::WelcomeMessagesV1
119            | TopicKind::IdentityUpdatesV1
120            | TopicKind::KeyPackagesV1 => BACKEND_INSTALLATION_ID_BYTES,
121        };
122        if identifier.len() != expected {
123            return Err(ConversionError::InvalidLength {
124                item: "Topic identifier",
125                expected,
126                got: identifier.len(),
127            });
128        }
129        Ok(kind.create(identifier))
130    }
131
132    /// Create a commit-log topic from its identifier.
133    pub fn new_commit_log(group_id: impl AsRef<[u8]>) -> Self {
134        TopicKind::CommitLogEntriesV1.create(group_id)
135    }
136
137    pub fn new(kind: TopicKind, bytes: Vec<u8>) -> Self {
138        Self {
139            inner: kind.build(bytes),
140        }
141    }
142
143    /// create a new [`TopicKind::GroupMessagesV1`] topic
144    pub fn new_group_message(group_id: impl AsRef<[u8]>) -> Self {
145        TopicKind::GroupMessagesV1.create(group_id)
146    }
147
148    /// create a new identity update Topic with `inbox_id` bytes
149    /// _NOTE_
150    /// this function expects the decoded hex from an InboxId,
151    /// not the UTF-8 bytes of a InboxId.
152    pub fn new_identity_update(inbox_id: impl AsRef<[u8]>) -> Self {
153        TopicKind::IdentityUpdatesV1.create(inbox_id)
154    }
155
156    /// create a new [`TopicKind::WelcomeMessagesV1`] topic
157    /// from an [`InstallationId`]
158    pub fn new_welcome_message(installation_id: InstallationId) -> Self {
159        TopicKind::WelcomeMessagesV1.create(installation_id)
160    }
161
162    /// create a new [`TopicKind::KeyPackagesV1`] topic
163    /// from an [`InstallationId`]
164    pub fn new_key_package(installation_id: impl AsRef<[u8]>) -> Self {
165        TopicKind::KeyPackagesV1.create(installation_id.as_ref())
166    }
167
168    pub fn kind(&self) -> TopicKind {
169        self.inner[0]
170            .try_into()
171            .expect("A topic must always be built with a valid `TopicKind`")
172    }
173
174    /// Get only the identifying portion of this topic
175    pub fn identifier(&self) -> &[u8] {
176        &self.inner[1..]
177    }
178
179    /// get the full topic bytes as a [`Vec`] by cloning, including the identifying [`TopicKind`]
180    pub fn cloned_vec(&self) -> Vec<u8> {
181        self.inner.clone().to_vec()
182    }
183
184    /// consume this [`Topic`] into its bytes as a Vec
185    pub fn to_bytes(self) -> TopicBytes {
186        self.inner
187    }
188
189    /// treat this topic as a [`TopicKind::IdentityUpdatesV1`],
190    /// otherwise returns [`Option::None`].
191    /// useful for collection `filter_map` operations when a single topic type
192    /// is required
193    pub fn identity_updates(&self) -> Option<&Topic> {
194        if self.kind() == TopicKind::IdentityUpdatesV1 {
195            Some(self)
196        } else {
197            None
198        }
199    }
200
201    /// treat this topic as a [`TopicKind::GroupMessagesV1`],
202    /// otherwise returns [`Option::None`].
203    /// useful for collection `filter_map` operations when a single topic type
204    /// is required
205    pub fn group_message_v1(&self) -> Option<&Topic> {
206        if self.kind() == TopicKind::GroupMessagesV1 {
207            Some(self)
208        } else {
209            None
210        }
211    }
212
213    /// treat this topic as a [`TopicKind::WelcomeMessagesV1`],
214    /// otherwise returns [`Option::None`].
215    /// useful for collection `filter_map` operations when a single topic type
216    /// is required
217    pub fn welcome_message_v1(&self) -> Option<&Topic> {
218        if self.kind() == TopicKind::WelcomeMessagesV1 {
219            Some(self)
220        } else {
221            None
222        }
223    }
224
225    /// treat this topic as a [`TopicKind::KeyPackagesV1`],
226    /// otherwise returns [`Option::None`].
227    /// useful for collection `filter_map` operations when a single topic type
228    /// is required
229    pub fn key_packages_v1(&self) -> Option<&Topic> {
230        if self.kind() == TopicKind::KeyPackagesV1 {
231            Some(self)
232        } else {
233            None
234        }
235    }
236
237    /// create a topic from bytes
238    /// this is test only. using topics with
239    /// invalid byte layout will result in
240    /// undefined behavior.
241    #[cfg(any(feature = "test-utils", test))]
242    pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Self {
243        Self {
244            inner: SmallVec::from_slice(bytes.as_ref()),
245        }
246    }
247}
248
249impl TryFrom<Vec<u8>> for Topic {
250    type Error = ConversionError;
251
252    fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
253        if let Some(byte) = value.first() {
254            let kind = TopicKind::try_from(*byte)?;
255            Ok(Topic::new(kind, value[1..].to_vec()))
256        } else {
257            Err(ConversionError::InvalidValue {
258                item: "Topic",
259                expected: "a byte array where the first byte is a valid TopicKind",
260                got: hex::encode(value),
261            })
262        }
263    }
264}
265
266impl From<Topic> for Vec<u8> {
267    fn from(topic: Topic) -> Vec<u8> {
268        topic.to_bytes().to_vec()
269    }
270}
271
272impl Debug for Topic {
273    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274        f.debug_struct("Topic")
275            .field("kind", &self.kind())
276            .field("bytes", &hex::encode(self.identifier()))
277            .finish()
278    }
279}
280
281impl Display for Topic {
282    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283        write!(f, "[{}/{}]", self.kind(), hex::encode(self.identifier()))
284    }
285}
286
287impl Deref for Topic {
288    type Target = [u8];
289
290    fn deref(&self) -> &Self::Target {
291        self.inner.deref()
292    }
293}
294
295impl<T> AsRef<T> for Topic
296where
297    T: ?Sized,
298    <Topic as Deref>::Target: AsRef<T>,
299{
300    fn as_ref(&self) -> &T {
301        self.deref().as_ref()
302    }
303}
304
305impl AsRef<Topic> for Topic {
306    fn as_ref(&self) -> &Topic {
307        self
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use xmtp_configuration::{
315        BACKEND_INSTALLATION_ID_BYTES, GROUP_ID_BYTES, LEGACY_GROUP_ID_BYTES,
316    };
317
318    fn topic_bytes(kind: TopicKind, length: usize) -> Vec<u8> {
319        let mut bytes = vec![kind as u8];
320        bytes.extend(vec![0; length]);
321        bytes
322    }
323
324    /// P1-VAL-01/04: raw backend topics have one known kind and an exact identifier.
325    #[xmtp_common::test(unwrap_try = true)]
326    fn backend_topic_parser_checks_each_kind_and_identifier_length() {
327        assert!(Topic::parse(&[]).is_err());
328        assert!(Topic::parse(&[u8::MAX]).is_err());
329        for (kind, length) in [
330            (TopicKind::GroupMessagesV1, GROUP_ID_BYTES),
331            (TopicKind::WelcomeMessagesV1, BACKEND_INSTALLATION_ID_BYTES),
332            (TopicKind::IdentityUpdatesV1, BACKEND_INSTALLATION_ID_BYTES),
333            (TopicKind::KeyPackagesV1, BACKEND_INSTALLATION_ID_BYTES),
334            (TopicKind::CommitLogEntriesV1, GROUP_ID_BYTES),
335        ] {
336            let mut bytes = topic_bytes(kind, length);
337            let topic = Topic::parse(&bytes)?;
338            assert_eq!(topic.kind(), kind);
339            assert_eq!(topic.cloned_vec(), bytes);
340            assert!(Topic::parse(&[kind as u8]).is_err());
341            bytes.pop();
342            assert!(Topic::parse(&bytes).is_err());
343            bytes.extend([0, 0]);
344            assert!(Topic::parse(&bytes).is_err());
345        }
346    }
347
348    /// Kinds 0 and 4 take a group or commit-log identifier of 32 bytes and no
349    /// other length: a legacy 16-byte group id names no topic.
350    // verifies: TOPIC-001, TOPIC-002
351    #[xmtp_common::test(unwrap_try = true)]
352    fn group_and_commit_log_topics_accept_only_32_byte_identifiers() {
353        for kind in [TopicKind::GroupMessagesV1, TopicKind::CommitLogEntriesV1] {
354            assert!(
355                Topic::parse(&topic_bytes(kind, LEGACY_GROUP_ID_BYTES)).is_err(),
356                "{kind} with a legacy 16-byte group id"
357            );
358            for length in 0..=40 {
359                let accepted = length == GROUP_ID_BYTES;
360                assert_eq!(
361                    Topic::parse(&topic_bytes(kind, length)).is_ok(),
362                    accepted,
363                    "{kind} with a {length}-byte identifier"
364                );
365            }
366        }
367        for kind in [
368            TopicKind::WelcomeMessagesV1,
369            TopicKind::IdentityUpdatesV1,
370            TopicKind::KeyPackagesV1,
371        ] {
372            assert!(Topic::parse(&topic_bytes(kind, LEGACY_GROUP_ID_BYTES)).is_err());
373        }
374    }
375}