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    pub fn parse(bytes: &[u8]) -> Result<Self, ConversionError> {
103        use xmtp_configuration::{BACKEND_GROUP_ID_BYTES, BACKEND_INSTALLATION_ID_BYTES};
104
105        let Some((&kind, identifier)) = bytes.split_first() else {
106            return Err(ConversionError::InvalidValue {
107                item: "Topic",
108                expected: "a topic kind followed by its identifier",
109                got: "empty".into(),
110            });
111        };
112        let kind = TopicKind::try_from(kind)?;
113        let expected = match kind {
114            TopicKind::GroupMessagesV1 | TopicKind::CommitLogEntriesV1 => BACKEND_GROUP_ID_BYTES,
115            TopicKind::WelcomeMessagesV1
116            | TopicKind::IdentityUpdatesV1
117            | TopicKind::KeyPackagesV1 => BACKEND_INSTALLATION_ID_BYTES,
118        };
119        if identifier.len() != expected {
120            return Err(ConversionError::InvalidLength {
121                item: "Topic identifier",
122                expected,
123                got: identifier.len(),
124            });
125        }
126        Ok(kind.create(identifier))
127    }
128
129    /// Create a commit-log topic from its group identifier.
130    pub fn new_commit_log(group_id: impl AsRef<[u8]>) -> Self {
131        TopicKind::CommitLogEntriesV1.create(group_id)
132    }
133
134    pub fn new(kind: TopicKind, bytes: Vec<u8>) -> Self {
135        Self {
136            inner: kind.build(bytes),
137        }
138    }
139
140    /// create a new [`TopicKind::GroupMessagesV1`] topic
141    pub fn new_group_message(group_id: impl AsRef<[u8]>) -> Self {
142        TopicKind::GroupMessagesV1.create(group_id)
143    }
144
145    /// create a new identity update Topic with `inbox_id` bytes
146    /// _NOTE_
147    /// this function expects the decoded hex from an InboxId,
148    /// not the UTF-8 bytes of a InboxId.
149    pub fn new_identity_update(inbox_id: impl AsRef<[u8]>) -> Self {
150        TopicKind::IdentityUpdatesV1.create(inbox_id)
151    }
152
153    /// create a new [`TopicKind::WelcomeMessagesV1`] topic
154    /// from an [`InstallationId`]
155    pub fn new_welcome_message(installation_id: InstallationId) -> Self {
156        TopicKind::WelcomeMessagesV1.create(installation_id)
157    }
158
159    /// create a new [`TopicKind::KeyPackagesV1`] topic
160    /// from an [`InstallationId`]
161    pub fn new_key_package(installation_id: impl AsRef<[u8]>) -> Self {
162        TopicKind::KeyPackagesV1.create(installation_id.as_ref())
163    }
164
165    pub fn kind(&self) -> TopicKind {
166        self.inner[0]
167            .try_into()
168            .expect("A topic must always be built with a valid `TopicKind`")
169    }
170
171    /// Get only the identifying portion of this topic
172    pub fn identifier(&self) -> &[u8] {
173        &self.inner[1..]
174    }
175
176    /// get the full topic bytes as a [`Vec`] by cloning, including the identifying [`TopicKind`]
177    pub fn cloned_vec(&self) -> Vec<u8> {
178        self.inner.clone().to_vec()
179    }
180
181    /// consume this [`Topic`] into its bytes as a Vec
182    pub fn to_bytes(self) -> TopicBytes {
183        self.inner
184    }
185
186    /// treat this topic as a [`TopicKind::IdentityUpdatesV1`],
187    /// otherwise returns [`Option::None`].
188    /// useful for collection `filter_map` operations when a single topic type
189    /// is required
190    pub fn identity_updates(&self) -> Option<&Topic> {
191        if self.kind() == TopicKind::IdentityUpdatesV1 {
192            Some(self)
193        } else {
194            None
195        }
196    }
197
198    /// treat this topic as a [`TopicKind::GroupMessagesV1`],
199    /// otherwise returns [`Option::None`].
200    /// useful for collection `filter_map` operations when a single topic type
201    /// is required
202    pub fn group_message_v1(&self) -> Option<&Topic> {
203        if self.kind() == TopicKind::GroupMessagesV1 {
204            Some(self)
205        } else {
206            None
207        }
208    }
209
210    /// treat this topic as a [`TopicKind::WelcomeMessagesV1`],
211    /// otherwise returns [`Option::None`].
212    /// useful for collection `filter_map` operations when a single topic type
213    /// is required
214    pub fn welcome_message_v1(&self) -> Option<&Topic> {
215        if self.kind() == TopicKind::WelcomeMessagesV1 {
216            Some(self)
217        } else {
218            None
219        }
220    }
221
222    /// treat this topic as a [`TopicKind::KeyPackagesV1`],
223    /// otherwise returns [`Option::None`].
224    /// useful for collection `filter_map` operations when a single topic type
225    /// is required
226    pub fn key_packages_v1(&self) -> Option<&Topic> {
227        if self.kind() == TopicKind::KeyPackagesV1 {
228            Some(self)
229        } else {
230            None
231        }
232    }
233
234    /// create a topic from bytes
235    /// this is test only. using topics with
236    /// invalid byte layout will result in
237    /// undefined behavior.
238    #[cfg(any(feature = "test-utils", test))]
239    pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Self {
240        Self {
241            inner: SmallVec::from_slice(bytes.as_ref()),
242        }
243    }
244}
245
246impl TryFrom<Vec<u8>> for Topic {
247    type Error = ConversionError;
248
249    fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
250        if let Some(byte) = value.first() {
251            let kind = TopicKind::try_from(*byte)?;
252            Ok(Topic::new(kind, value[1..].to_vec()))
253        } else {
254            Err(ConversionError::InvalidValue {
255                item: "Topic",
256                expected: "a byte array where the first byte is a valid TopicKind",
257                got: hex::encode(value),
258            })
259        }
260    }
261}
262
263impl From<Topic> for Vec<u8> {
264    fn from(topic: Topic) -> Vec<u8> {
265        topic.to_bytes().to_vec()
266    }
267}
268
269impl Debug for Topic {
270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271        f.debug_struct("Topic")
272            .field("kind", &self.kind())
273            .field("bytes", &hex::encode(self.identifier()))
274            .finish()
275    }
276}
277
278impl Display for Topic {
279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        write!(f, "[{}/{}]", self.kind(), hex::encode(self.identifier()))
281    }
282}
283
284impl Deref for Topic {
285    type Target = [u8];
286
287    fn deref(&self) -> &Self::Target {
288        self.inner.deref()
289    }
290}
291
292impl<T> AsRef<T> for Topic
293where
294    T: ?Sized,
295    <Topic as Deref>::Target: AsRef<T>,
296{
297    fn as_ref(&self) -> &T {
298        self.deref().as_ref()
299    }
300}
301
302impl AsRef<Topic> for Topic {
303    fn as_ref(&self) -> &Topic {
304        self
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use xmtp_configuration::{BACKEND_GROUP_ID_BYTES, BACKEND_INSTALLATION_ID_BYTES};
312
313    /// P1-VAL-01/04: raw backend topics have one known kind and an exact identifier.
314    #[xmtp_common::test(unwrap_try = true)]
315    fn backend_topic_parser_checks_each_kind_and_identifier_length() {
316        assert!(Topic::parse(&[]).is_err());
317        assert!(Topic::parse(&[u8::MAX]).is_err());
318        for (kind, length) in [
319            (TopicKind::GroupMessagesV1, BACKEND_GROUP_ID_BYTES),
320            (TopicKind::WelcomeMessagesV1, BACKEND_INSTALLATION_ID_BYTES),
321            (TopicKind::IdentityUpdatesV1, BACKEND_INSTALLATION_ID_BYTES),
322            (TopicKind::KeyPackagesV1, BACKEND_INSTALLATION_ID_BYTES),
323            (TopicKind::CommitLogEntriesV1, BACKEND_GROUP_ID_BYTES),
324        ] {
325            let mut bytes = vec![kind as u8];
326            bytes.extend(vec![0; length]);
327            let topic = Topic::parse(&bytes)?;
328            assert_eq!(topic.kind(), kind);
329            assert_eq!(topic.cloned_vec(), bytes);
330            assert!(Topic::parse(&[kind as u8]).is_err());
331            bytes.pop();
332            assert!(Topic::parse(&bytes).is_err());
333            bytes.extend([0, 0]);
334            assert!(Topic::parse(&bytes).is_err());
335        }
336    }
337}