Skip to main content

nectar_postage/
stamped.rs

1//! A chunk paired with the postage stamp that authorises its storage.
2//!
3//! [`StampedChunk`] is the pairing of an [`AnyChunk`] (a `nectar-primitives`
4//! type) with a [`Stamp`] (a `nectar-postage` type). A retrieval, a pushsync
5//! delivery, and an upload all move a *chunk plus its proof of payment* as one
6//! unit, so this pairing is the cohesive value that flows across those
7//! boundaries instead of two loose fields.
8//!
9//! Both halves are nectar types, so the pairing and its serialisation belong
10//! here. The wire codec is pure serialisation layered on top of the
11//! type-tagged [`AnyChunk`] codec and the fixed-size [`Stamp`] codec.
12
13use alloc::vec::Vec;
14
15use nectar_primitives::{AnyChunk, ChunkAddress, DEFAULT_BODY_SIZE, bytes::Bytes};
16
17use crate::{BatchId, STAMP_SIZE, Stamp, StampError};
18
19/// A chunk together with its postage stamp.
20///
21/// [`AnyChunk`] holds the chunk bytes but carries no stamp, so this pairing is
22/// the always-stamped currency on the network paths (pushsync, upload, the
23/// stamped reserve). The address is the chunk's own address;
24/// [`address`](Self::address) delegates to it.
25///
26/// # Equality
27///
28/// `PartialEq`/`Eq` are derived. The chunk component therefore inherits
29/// [`AnyChunk`]'s own equality, which compares chunks *by address only* (a
30/// chunk's address is the cryptographic commitment to its bytes, so equal
31/// addresses mean equal chunks), and the stamp component compares all stamp
32/// fields. Two stamped chunks are equal when their chunks share an address and
33/// their stamps are field-for-field identical. This matches the semantics of
34/// the original vertex `StampedChunk`, which also derived equality over the
35/// same two fields.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct StampedChunk<const BODY_SIZE: usize = DEFAULT_BODY_SIZE> {
38    chunk: AnyChunk<BODY_SIZE>,
39    stamp: Stamp,
40}
41
42impl<const BODY_SIZE: usize> StampedChunk<BODY_SIZE> {
43    /// Pair a chunk with its stamp.
44    #[inline]
45    #[must_use]
46    pub const fn new(chunk: AnyChunk<BODY_SIZE>, stamp: Stamp) -> Self {
47        Self { chunk, stamp }
48    }
49
50    /// The chunk.
51    #[inline]
52    #[must_use]
53    pub const fn chunk(&self) -> &AnyChunk<BODY_SIZE> {
54        &self.chunk
55    }
56
57    /// The postage stamp.
58    #[inline]
59    #[must_use]
60    pub const fn stamp(&self) -> &Stamp {
61        &self.stamp
62    }
63
64    /// The chunk's address (delegates to the chunk).
65    #[inline]
66    #[must_use]
67    pub fn address(&self) -> &ChunkAddress {
68        self.chunk.address()
69    }
70
71    /// Split into the chunk and its stamp.
72    #[inline]
73    #[must_use]
74    pub fn into_parts(self) -> (AnyChunk<BODY_SIZE>, Stamp) {
75        (self.chunk, self.stamp)
76    }
77
78    /// Encode to a self-describing byte string: the stamp followed by the
79    /// type-tagged chunk.
80    ///
81    /// The layout is `[stamp: STAMP_SIZE][type_id: 1][chunk wire bytes]`, the
82    /// stamp's fixed-size encoding ([`Stamp::to_bytes`], `STAMP_SIZE = 113`
83    /// bytes) followed by the chunk's type-tagged encoding
84    /// ([`AnyChunk::to_typed_bytes`]). Decode with
85    /// [`from_typed_bytes`](Self::from_typed_bytes).
86    #[must_use]
87    pub fn to_typed_bytes(&self) -> Vec<u8> {
88        let stamp = self.stamp.to_bytes();
89        let chunk = self.chunk.to_typed_bytes();
90        let mut out = Vec::with_capacity(stamp.len() + chunk.len());
91        out.extend_from_slice(&stamp);
92        out.extend_from_slice(&chunk);
93        out
94    }
95
96    /// Decode a stamped chunk produced by [`to_typed_bytes`](Self::to_typed_bytes).
97    ///
98    /// The first `STAMP_SIZE` bytes are the stamp ([`Stamp::from_bytes`]); the
99    /// remainder is the type-tagged chunk ([`AnyChunk::from_typed_bytes`]),
100    /// verified against `address`.
101    ///
102    /// # Errors
103    ///
104    /// Returns an error (and never panics) when the input is shorter than a
105    /// stamp, the stamp bytes are invalid, the chunk payload cannot be decoded,
106    /// or the chunk's computed address does not match `address`.
107    pub fn from_typed_bytes(address: &ChunkAddress, bytes: &[u8]) -> Result<Self, StampError> {
108        if bytes.len() < STAMP_SIZE {
109            return Err(StampError::InvalidData(
110                "stamped chunk shorter than a stamp",
111            ));
112        }
113        let (stamp_bytes, chunk_bytes) = bytes.split_at(STAMP_SIZE);
114        let stamp = Stamp::try_from_slice(stamp_bytes)?;
115        let chunk = AnyChunk::<BODY_SIZE>::from_typed_bytes(address, chunk_bytes)
116            .map_err(|_| StampError::Chunk("failed to decode typed chunk"))?;
117        Ok(Self::new(chunk, stamp))
118    }
119
120    /// Rebuild a stamped chunk from the bare chunk wire bytes, its expected
121    /// address, and a separately-carried stamp.
122    ///
123    /// `data` is the bare chunk wire bytes (no type tag), as carried in a
124    /// `Delivery { data, stamp }` wire message; the address disambiguates the
125    /// chunk variant (see [`AnyChunk::from_wire_bytes`]).
126    ///
127    /// # Errors
128    ///
129    /// Returns an error (and never panics) when `data` does not hash to
130    /// `expected` as either a content or a single-owner chunk.
131    pub fn reconstruct(
132        expected: ChunkAddress,
133        data: Bytes,
134        stamp: Stamp,
135    ) -> Result<Self, StampError> {
136        let chunk = AnyChunk::<BODY_SIZE>::from_wire_bytes(&expected, data)
137            .map_err(|_| StampError::Chunk("chunk bytes do not match expected address"))?;
138        Ok(Self::new(chunk, stamp))
139    }
140
141    /// Read the batch id from a [`to_typed_bytes`](Self::to_typed_bytes) value
142    /// without a full decode.
143    ///
144    /// The stamp leads the encoding and [`Stamp::to_bytes`] places the batch id
145    /// in its first 32 bytes, so the batch id is `typed_bytes[0..32]`. This lets
146    /// a store index a stamped chunk by batch without decoding the chunk.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error (and never panics) when `typed_bytes` is shorter than 32
151    /// bytes.
152    pub fn batch_id(typed_bytes: &[u8]) -> Result<BatchId, StampError> {
153        let id = typed_bytes.get(..32).ok_or(StampError::InvalidData(
154            "typed bytes shorter than a batch id",
155        ))?;
156        Ok(BatchId::from_slice(id))
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use alloy_primitives::{B256, Signature};
163    use alloy_signer_local::PrivateKeySigner;
164    use nectar_primitives::{Chunk, ContentChunk, SingleOwnerChunk, bytes::Bytes};
165
166    use super::*;
167
168    type DefaultStampedChunk = StampedChunk<DEFAULT_BODY_SIZE>;
169
170    fn test_stamp() -> Stamp {
171        let sig = Signature::from_raw(&[1u8; 65]).expect("valid signature");
172        Stamp::new(B256::repeat_byte(0xaa), 3, 7, 42, sig)
173    }
174
175    fn content_chunk() -> ContentChunk<DEFAULT_BODY_SIZE> {
176        ContentChunk::new(&b"hello swarm"[..]).expect("valid content chunk")
177    }
178
179    fn single_owner_chunk() -> SingleOwnerChunk<DEFAULT_BODY_SIZE> {
180        let signer = PrivateKeySigner::from_bytes(&B256::repeat_byte(0x11)).expect("valid signer");
181        SingleOwnerChunk::new(B256::repeat_byte(0x22), &b"soc payload"[..], &signer)
182            .expect("valid soc")
183    }
184
185    #[test]
186    fn into_parts_round_trips_the_fields() {
187        let chunk: AnyChunk = content_chunk().into();
188        let stamp = test_stamp();
189        let stamped = DefaultStampedChunk::new(chunk.clone(), stamp.clone());
190        assert_eq!(stamped.address(), chunk.address());
191        let (got_chunk, got_stamp) = stamped.into_parts();
192        assert_eq!(got_chunk, chunk);
193        assert_eq!(got_stamp, stamp);
194    }
195
196    #[test]
197    fn typed_content_round_trip() {
198        let chunk = content_chunk();
199        let address = *chunk.address();
200        let stamp = test_stamp();
201        let stamped = DefaultStampedChunk::new(chunk.into(), stamp.clone());
202
203        let bytes = stamped.to_typed_bytes();
204        let decoded = DefaultStampedChunk::from_typed_bytes(&address, &bytes).expect("decode");
205
206        assert!(decoded.chunk().is_content());
207        assert_eq!(*decoded.address(), address);
208        assert_eq!(decoded.stamp().batch(), stamp.batch());
209        assert_eq!(decoded.stamp().timestamp(), stamp.timestamp());
210        assert_eq!(decoded, stamped);
211    }
212
213    #[test]
214    fn typed_single_owner_round_trip() {
215        let chunk = single_owner_chunk();
216        let address = *chunk.address();
217        let stamp = test_stamp();
218        let stamped = DefaultStampedChunk::new(chunk.into(), stamp.clone());
219
220        let bytes = stamped.to_typed_bytes();
221        let decoded = DefaultStampedChunk::from_typed_bytes(&address, &bytes).expect("decode");
222
223        assert!(decoded.chunk().is_single_owner());
224        assert_eq!(*decoded.address(), address);
225        assert_eq!(decoded.stamp().batch(), stamp.batch());
226        assert_eq!(decoded.stamp().timestamp(), stamp.timestamp());
227        assert_eq!(decoded, stamped);
228    }
229
230    #[test]
231    fn reconstruct_round_trips_from_wire() {
232        let chunk = content_chunk();
233        let address = *chunk.address();
234        let data = Bytes::from(chunk);
235        let stamp = test_stamp();
236
237        let rebuilt = DefaultStampedChunk::reconstruct(address, data.clone(), stamp.clone())
238            .expect("rebuild");
239        assert!(rebuilt.chunk().is_content());
240        assert_eq!(*rebuilt.address(), address);
241        assert_eq!(rebuilt.stamp(), &stamp);
242        assert_eq!(rebuilt.into_parts().0.into_bytes(), data);
243    }
244
245    #[test]
246    fn reconstruct_single_owner_from_wire() {
247        let chunk = single_owner_chunk();
248        let address = *chunk.address();
249        let data = Bytes::from(chunk);
250        let stamp = test_stamp();
251
252        let rebuilt =
253            DefaultStampedChunk::reconstruct(address, data, stamp.clone()).expect("rebuild");
254        assert!(rebuilt.chunk().is_single_owner());
255        assert_eq!(*rebuilt.address(), address);
256        assert_eq!(rebuilt.stamp(), &stamp);
257    }
258
259    #[test]
260    fn batch_id_matches_stamp_and_leading_bytes() {
261        let chunk = content_chunk();
262        let stamp = test_stamp();
263        let stamped = DefaultStampedChunk::new(chunk.into(), stamp.clone());
264        let bytes = stamped.to_typed_bytes();
265
266        let id = DefaultStampedChunk::batch_id(&bytes).expect("batch id");
267        assert_eq!(id, stamp.batch());
268        assert_eq!(id.as_slice(), &bytes[0..32]);
269    }
270
271    #[test]
272    fn from_typed_bytes_empty_errors() {
273        let address: ChunkAddress = [0u8; 32].into();
274        assert!(DefaultStampedChunk::from_typed_bytes(&address, &[]).is_err());
275    }
276
277    #[test]
278    fn from_typed_bytes_shorter_than_stamp_errors() {
279        let address: ChunkAddress = [0u8; 32].into();
280        let short = [0u8; STAMP_SIZE - 1];
281        let err = DefaultStampedChunk::from_typed_bytes(&address, &short)
282            .expect_err("short input must error");
283        assert!(matches!(err, StampError::InvalidData(_)));
284    }
285
286    #[test]
287    fn from_typed_bytes_address_mismatch_errors() {
288        let chunk = content_chunk();
289        let stamped = DefaultStampedChunk::new(chunk.into(), test_stamp());
290        let bytes = stamped.to_typed_bytes();
291
292        let wrong: ChunkAddress = [0xFFu8; 32].into();
293        let err = DefaultStampedChunk::from_typed_bytes(&wrong, &bytes)
294            .expect_err("address mismatch must error");
295        assert!(matches!(err, StampError::Chunk(_)));
296    }
297
298    #[test]
299    fn reconstruct_rejects_wrong_address() {
300        let chunk = content_chunk();
301        let data = Bytes::from(chunk);
302        let wrong: ChunkAddress = [0xFFu8; 32].into();
303        let err = DefaultStampedChunk::reconstruct(wrong, data, test_stamp())
304            .expect_err("wrong address must error");
305        assert!(matches!(err, StampError::Chunk(_)));
306    }
307
308    #[test]
309    fn batch_id_short_errors() {
310        let short = [0u8; 31];
311        assert!(DefaultStampedChunk::batch_id(&short).is_err());
312    }
313}