Skip to main content

hashiverse_lib/protocol/posting/
encoded_post_feedback.rs

1//! # `EncodedPostFeedbackV1` — a single feedback vote
2//!
3//! Compact 35-byte entry representing one vote on one post: `post_id` (32) +
4//! `feedback_type` (1) + `salt` (1) + `pow` (1). Entries are packed by the hundreds
5//! into an [`crate::protocol::posting::encoded_post_bundle_feedback`] bundle; the tiny
6//! fixed size is what makes that packing cheap to verify.
7//!
8//! The PoW is computed over `(post_id, feedback_type)`. Clients must search for a
9//! `salt` that produces at least [`crate::tools::config::CLIENT_FEEDBACK_POW_NUMERAIRE`]
10//! bits — this is what makes feedback Sybil-resistant: a thousand fake votes costs a
11//! thousand PoWs, not one.
12//!
13//! [`EncodedPostFeedbackViewV1`] is a zero-copy view over the on-wire bytes for
14//! iteration during bundle verification, avoiding the need to allocate one
15//! `EncodedPostFeedbackV1` per entry just to read it.
16
17use bytes::{Buf, BufMut, BytesMut};
18use crate::{anyhow_assert_eq, anyhow_assert_ge};
19use crate::tools::config::CLIENT_FEEDBACK_POW_NUMERAIRE;
20use crate::tools::parallel_pow_generator::ParallelPowGenerator;
21use crate::tools::pow;
22use crate::tools::types::{Hash, Id, Pow, Salt, ID_BYTES, SALT_BYTES};
23
24// Offsets within a single serialised entry
25const OFF_POST_ID: usize = 1;
26const OFF_FEEDBACK_TYPE: usize = OFF_POST_ID + ID_BYTES;
27const OFF_SALT: usize = OFF_FEEDBACK_TYPE + 1;
28const OFF_POW: usize = OFF_SALT + SALT_BYTES;
29pub const ENTRY_SIZE: usize = OFF_POW + 1; // 1 + ID_BYTES + 1 + SALT_BYTES + 1
30
31/// A zero-copy view into one serialised `EncodedPostFeedbackV1` entry.
32/// Construction validates the slice length and version byte; all accessors
33/// are then infallible and return references into the original buffer.
34pub struct EncodedPostFeedbackViewV1<'a>(&'a [u8]);
35
36impl<'a> EncodedPostFeedbackViewV1<'a> {
37    pub fn from_slice(bytes: &'a [u8]) -> anyhow::Result<Self> {
38        anyhow_assert_eq!(bytes.len(), ENTRY_SIZE, "wrong entry size for EncodedPostFeedbackViewV1");
39        anyhow_assert_eq!(bytes[0], 1u8, "unsupported version in EncodedPostFeedbackViewV1");
40        Ok(Self(bytes))
41    }
42
43    pub fn post_id_bytes(&self) -> &'a [u8] {
44        &self.0[OFF_POST_ID..OFF_FEEDBACK_TYPE]
45    }
46
47    pub fn feedback_type(&self) -> u8 {
48        self.0[OFF_FEEDBACK_TYPE]
49    }
50
51    pub fn salt_bytes(&self) -> &'a [u8] {
52        &self.0[OFF_SALT..OFF_POW]
53    }
54
55    pub fn pow(&self) -> Pow {
56        Pow(self.0[OFF_POW])
57    }
58
59    /// Iterate views over a concatenated feedback byte slice.  Entries that
60    /// fail validation are surfaced as `Err`; iteration always advances by
61    /// `ENTRY_SIZE` so a bad entry does not stall the rest.
62    pub fn iter(bytes: &'a [u8]) -> impl Iterator<Item = anyhow::Result<Self>> + 'a {
63        bytes.chunks_exact(ENTRY_SIZE).map(Self::from_slice)
64    }
65}
66
67#[derive(Debug, PartialEq, Clone)]
68pub struct EncodedPostFeedbackV1 {
69    pub post_id: Id,
70    pub feedback_type: u8,
71    pub salt: Salt,
72    pub pow: Pow,
73}
74impl EncodedPostFeedbackV1 {
75    pub fn new(post_id: Id, feedback_type: u8, salt: Salt, pow: Pow) -> Self {
76        Self {
77            post_id,
78            feedback_type,
79            salt,
80            pow,
81        }
82    }
83
84    pub async fn pow_generate(post_id: &Id, feedback_type: u8, pow_generator: &dyn ParallelPowGenerator) -> anyhow::Result<(Salt, Pow, Hash)> {
85        let data_hash = pow::pow_compute_data_hash(&[post_id.as_bytes(), &[feedback_type]]);
86        pow_generator.generate_best_effort("feedback", CLIENT_FEEDBACK_POW_NUMERAIRE, Pow(255), data_hash).await
87    }
88
89    pub fn pow_verify(&self) -> anyhow::Result<()> {
90        let (pow, _hash) = pow::pow_measure(&[self.post_id.as_bytes(), &[self.feedback_type]], &self.salt)?;
91        anyhow_assert_eq!(pow, self.pow);
92        Ok(())
93    }
94
95    pub async fn encode_to_bytes(&mut self) -> anyhow::Result<Vec<u8>> {
96        let mut bytes = BytesMut::new();
97        bytes.put_u8(1); // Version
98        bytes.put_slice(self.post_id.as_ref());
99        bytes.put_u8(self.feedback_type);
100        bytes.put_slice(self.salt.as_ref());
101        bytes.put_u8(self.pow.0);
102        let bytes = bytes.to_vec();
103        Ok(bytes)
104    }
105
106    pub fn append_encode_direct_to_bytes<B: BufMut>(bytes: &mut B, post_id: &[u8], feedback_type: u8, salt: &[u8], pow: Pow) -> anyhow::Result<()> {
107        anyhow_assert_eq!(post_id.len(), ID_BYTES);
108        anyhow_assert_eq!(salt.len(), SALT_BYTES);
109
110        bytes.put_u8(1); // Version
111        bytes.put_slice(post_id.as_ref());
112        bytes.put_u8(feedback_type);
113        bytes.put_slice(salt.as_ref());
114        bytes.put_u8(pow.0);
115
116        Ok(())
117    }
118
119    pub fn append_encode_to_bytes<B: BufMut>(&self, bytes: &mut B) -> anyhow::Result<()> {
120        Self::append_encode_direct_to_bytes(bytes, self.post_id.as_ref(), self.feedback_type, self.salt.as_ref(), self.pow)
121    }
122
123    pub fn decode_from_bytes(mut bytes: impl Buf) -> anyhow::Result<Self> {
124        anyhow_assert_ge!(bytes.remaining(), 1, "Missing version");
125        let version = bytes.get_u8();
126        anyhow_assert_eq!(1, version);
127
128        let post_id = Id::from_buf(&mut bytes, "post_id")?;
129
130        anyhow_assert_ge!(bytes.remaining(), 1, "Missing feedback_type");
131        let feedback_type = bytes.get_u8();
132
133        let salt = Salt::from_buf(&mut bytes, "salt")?;
134
135        anyhow_assert_ge!(bytes.remaining(), 1, "Missing pow");
136        let pow = Pow(bytes.get_u8());
137
138        // We don't check that the remeinig bytes are zero, as this method it used to decode a sequence of Self in a buffer...
139
140        Ok(Self {
141            post_id,
142            feedback_type,
143            salt,
144            pow,
145        })
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use bytes::Bytes;
152    use super::*;
153    use crate::tools::types::{Id, Salt};
154
155    const ENTRY_SIZE: usize = 1 + ID_BYTES + 1 + SALT_BYTES + 1;
156
157    // ── encode_to_bytes / decode_from_bytes ──────────────────────────────────
158
159    #[tokio::test]
160    async fn roundtrip_encode_decode() -> anyhow::Result<()> {
161        let original = EncodedPostFeedbackV1::new(Id::random(), 3, Salt::random(), Pow(42));
162
163        let bytes = original.clone().encode_to_bytes().await?;
164        assert_eq!(bytes.len(), ENTRY_SIZE);
165
166        let decoded = EncodedPostFeedbackV1::decode_from_bytes(&mut Bytes::from(bytes.clone()))?;
167        assert_eq!(original, decoded);
168
169        // Re-encoding the decoded value must produce identical bytes (stability)
170        let bytes2 = decoded.clone().encode_to_bytes().await?;
171        assert_eq!(bytes, bytes2);
172
173        Ok(())
174    }
175
176    #[tokio::test]
177    async fn roundtrip_all_feedback_types() -> anyhow::Result<()> {
178        for feedback_type in [0u8, 1, 2, 3, 127, 255] {
179            let original = EncodedPostFeedbackV1::new(Id::random(), feedback_type, Salt::random(), Pow(0));
180            let bytes = original.clone().encode_to_bytes().await?;
181            let decoded = EncodedPostFeedbackV1::decode_from_bytes(&mut Bytes::from(bytes))?;
182            assert_eq!(original, decoded, "failed for feedback_type={feedback_type}");
183        }
184        Ok(())
185    }
186
187    #[tokio::test]
188    async fn roundtrip_extreme_pow_values() -> anyhow::Result<()> {
189        for pow in [0u8, 1, 127, 255] {
190            let original = EncodedPostFeedbackV1::new(Id::random(), 1, Salt::random(), Pow(pow));
191            let bytes = original.clone().encode_to_bytes().await?;
192            let decoded = EncodedPostFeedbackV1::decode_from_bytes(&mut Bytes::from(bytes))?;
193            assert_eq!(original, decoded, "failed for pow={pow}");
194        }
195        Ok(())
196    }
197
198    // ── append_encode_to_bytes ───────────────────────────────────────────────
199
200    #[test]
201    fn append_single_roundtrip() -> anyhow::Result<()> {
202        let original = EncodedPostFeedbackV1::new(Id::random(), 5, Salt::random(), Pow(99));
203
204        let mut buf = Vec::new();
205        EncodedPostFeedbackV1::append_encode_direct_to_bytes(&mut buf, original.post_id.as_ref(), original.feedback_type, original.salt.as_ref(), original.pow)?;
206
207        assert_eq!(buf.len(), ENTRY_SIZE);
208        let decoded = EncodedPostFeedbackV1::decode_from_bytes(&mut Bytes::from(buf))?;
209        assert_eq!(original, decoded);
210        Ok(())
211    }
212
213    #[test]
214    fn append_multiple_roundtrip() -> anyhow::Result<()> {
215        let originals = [
216            EncodedPostFeedbackV1::new(Id::random(), 1, Salt::random(), Pow(10)),
217            EncodedPostFeedbackV1::new(Id::random(), 2, Salt::random(), Pow(20)),
218            EncodedPostFeedbackV1::new(Id::random(), 255, Salt::zero(), Pow(0)),
219        ];
220
221        let mut buf = Vec::new();
222        for f in &originals {
223            EncodedPostFeedbackV1::append_encode_direct_to_bytes(&mut buf, f.post_id.as_ref(), f.feedback_type, f.salt.as_ref(), f.pow)?;
224        }
225
226        assert_eq!(buf.len(), originals.len() * ENTRY_SIZE);
227
228        for (i, expected) in originals.iter().enumerate() {
229            let start = i * ENTRY_SIZE;
230            let mut chunk = Bytes::copy_from_slice(&buf[start..start + ENTRY_SIZE]);
231            let decoded = EncodedPostFeedbackV1::decode_from_bytes(&mut chunk)?;
232            assert_eq!(*expected, decoded, "mismatch at entry {i}");
233        }
234
235        Ok(())
236    }
237
238    #[test]
239    fn append_rejects_wrong_post_id_length() {
240        let mut buf = Vec::new();
241        let short_id = vec![0u8; ID_BYTES - 1];
242        let salt = Salt::zero();
243        assert!(EncodedPostFeedbackV1::append_encode_direct_to_bytes(&mut buf, &short_id, 1, salt.as_ref(), Pow(0)).is_err());
244    }
245
246    #[test]
247    fn append_rejects_wrong_salt_length() {
248        let mut buf = Vec::new();
249        let id = Id::random();
250        let short_salt = vec![0u8; SALT_BYTES - 1];
251        assert!(EncodedPostFeedbackV1::append_encode_direct_to_bytes(&mut buf, id.as_ref(), 1, &short_salt, Pow(0)).is_err());
252    }
253
254    // ── decode_from_bytes error cases ────────────────────────────────────────
255
256    #[test]
257    fn decode_rejects_empty_input() {
258        assert!(EncodedPostFeedbackV1::decode_from_bytes(&mut Bytes::new()).is_err());
259    }
260
261    #[test]
262    fn decode_rejects_wrong_version() {
263        let mut buf = vec![0u8; ENTRY_SIZE];
264        buf[0] = 99; // bad version
265        assert!(EncodedPostFeedbackV1::decode_from_bytes(&mut Bytes::from(buf)).is_err());
266    }
267
268    #[test]
269    fn decode_rejects_truncated_at_each_boundary() {
270        // Build a valid buffer, then truncate at every byte before the last
271        let valid = {
272            let mut b = BytesMut::new();
273            b.put_u8(1);
274            b.put_slice(Id::random().as_ref());
275            b.put_u8(3);
276            b.put_slice(Salt::random().as_ref());
277            b.put_u8(7);
278            b.freeze()
279        };
280        assert_eq!(valid.len(), ENTRY_SIZE);
281
282        for truncate_at in 0..ENTRY_SIZE {
283            let mut truncated = valid.slice(0..truncate_at);
284            assert!(
285                EncodedPostFeedbackV1::decode_from_bytes(&mut truncated).is_err(),
286                "expected error when truncated to {truncate_at} bytes"
287            );
288        }
289    }
290
291    // ── EncodedPostFeedbackViewV1 ────────────────────────────────────────────
292
293    /// Encode a struct, then confirm the view's accessors agree with both
294    /// the original field values (encoder) and a full decode (decoder).
295    #[tokio::test]
296    async fn view_matches_encoder_and_decoder() -> anyhow::Result<()> {
297        let original = EncodedPostFeedbackV1::new(Id::random(), 7, Salt::random(), Pow(42));
298        let bytes_for_view = original.clone().encode_to_bytes().await?;
299        let bytes_for_decode = bytes_for_view.clone();
300
301        let view = EncodedPostFeedbackViewV1::from_slice(&bytes_for_view)?;
302        assert_eq!(view.post_id_bytes(), original.post_id.as_ref());
303        assert_eq!(view.feedback_type(), original.feedback_type);
304        assert_eq!(view.salt_bytes(), original.salt.as_ref());
305        assert_eq!(view.pow(), original.pow);
306
307        // Decoder must read back the same values the view exposes
308        let decoded = EncodedPostFeedbackV1::decode_from_bytes(&mut Bytes::from(bytes_for_decode))?;
309        assert_eq!(view.post_id_bytes(), decoded.post_id.as_ref());
310        assert_eq!(view.feedback_type(), decoded.feedback_type);
311        assert_eq!(view.salt_bytes(), decoded.salt.as_ref());
312        assert_eq!(view.pow(), decoded.pow);
313
314        Ok(())
315    }
316
317    /// Iter over a concatenated buffer — each view must agree with the
318    /// original struct that produced its bytes.
319    #[test]
320    fn view_iter_matches_originals() -> anyhow::Result<()> {
321        let originals = [
322            EncodedPostFeedbackV1::new(Id::random(), 1, Salt::random(), Pow(10)),
323            EncodedPostFeedbackV1::new(Id::random(), 2, Salt::random(), Pow(20)),
324            EncodedPostFeedbackV1::new(Id::random(), 255, Salt::zero(), Pow(0)),
325        ];
326
327        let mut buf = Vec::new();
328        for f in &originals {
329            f.append_encode_to_bytes(&mut buf)?;
330        }
331
332        let views: Vec<_> = EncodedPostFeedbackViewV1::iter(&buf)
333            .collect::<anyhow::Result<_>>()?;
334
335        assert_eq!(views.len(), originals.len());
336        for (view, original) in views.iter().zip(originals.iter()) {
337            assert_eq!(view.post_id_bytes(), original.post_id.as_ref());
338            assert_eq!(view.feedback_type(), original.feedback_type);
339            assert_eq!(view.salt_bytes(), original.salt.as_ref());
340            assert_eq!(view.pow(), original.pow);
341        }
342
343        Ok(())
344    }
345
346    /// A partial trailing entry must be silently ignored by iter
347    /// (chunks_exact semantics), not cause a panic or error.
348    #[test]
349    fn view_iter_ignores_partial_tail() -> anyhow::Result<()> {
350        let f = EncodedPostFeedbackV1::new(Id::random(), 3, Salt::random(), Pow(5));
351        let mut buf = Vec::new();
352        f.append_encode_to_bytes(&mut buf)?;
353        buf.push(0xFF); // one extra byte — partial second entry
354
355        let views: Vec<_> = EncodedPostFeedbackViewV1::iter(&buf)
356            .collect::<anyhow::Result<_>>()?;
357        assert_eq!(views.len(), 1);
358
359        Ok(())
360    }
361
362    #[test]
363    fn view_rejects_wrong_length() {
364        assert!(EncodedPostFeedbackViewV1::from_slice(&[]).is_err());
365        assert!(EncodedPostFeedbackViewV1::from_slice(&vec![1u8; ENTRY_SIZE - 1]).is_err());
366        assert!(EncodedPostFeedbackViewV1::from_slice(&vec![1u8; ENTRY_SIZE + 1]).is_err());
367    }
368
369    #[test]
370    fn view_rejects_wrong_version() {
371        let mut buf = vec![1u8; ENTRY_SIZE];
372        buf[0] = 99;
373        assert!(EncodedPostFeedbackViewV1::from_slice(&buf).is_err());
374    }
375
376    #[cfg(not(target_arch = "wasm32"))]
377    mod bolero_fuzz {
378        use bytes::Bytes;
379        use crate::protocol::posting::encoded_post_feedback::EncodedPostFeedbackV1;
380
381        #[test]
382        fn fuzz_decode_from_bytes() {
383            bolero::check!().for_each(|data: &[u8]| {
384                let mut bytes = Bytes::copy_from_slice(data);
385                let _ = EncodedPostFeedbackV1::decode_from_bytes(&mut bytes);
386            });
387        }
388    }
389}