Skip to main content

bee/postage/
stamper.rs

1//! Client-side postage stamper.
2//!
3//! Mirrors `pkg/postage/stamper.go` in bee-go and
4//! `src/stamper/stamper.ts` in bee-js. Lets a caller produce a
5//! per-chunk [`Envelope`] without round-tripping the node, which is
6//! the primitive needed for `postEnvelope`-style flows and for
7//! progressive uploads.
8
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use crate::swarm::errors::Error;
12use crate::swarm::keys::PrivateKey;
13use crate::swarm::typed_bytes::{BatchId, EthAddress, Signature};
14
15/// Number of buckets in a postage batch (`2^16`).
16pub const NUM_BUCKETS: usize = 1 << 16;
17
18/// Bucket-depth floor: stamper depth must be **strictly greater than**
19/// this value (matches bee-go and bee-js, which require `depth > 16`).
20pub const MIN_DEPTH: u8 = 16;
21
22/// Wire-format length of a marshaled postage stamp:
23/// `batchID(32) || index(8) || timestamp(8) || signature(65)`.
24pub const MARSHALED_STAMP_LENGTH: usize = 32 + 8 + 8 + 65;
25
26/// Per-chunk postage envelope returned by [`Stamper::stamp`].
27///
28/// Mirrors bee-js `EnvelopeWithBatchId` and bee-go `postage.Envelope`.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct Envelope {
31    /// Batch the chunk is stamped against.
32    pub batch_id: BatchId,
33    /// 8 bytes: `bucket (BE u32) || height (BE u32)`.
34    pub index: [u8; 8],
35    /// Issuer (signer's Ethereum address).
36    pub issuer: EthAddress,
37    /// 65-byte `R || S || V` signature with `V ∈ {27, 28}`.
38    pub signature: Signature,
39    /// 8 bytes: Unix milliseconds (BE u64), matching bee-js `Date.now()`.
40    pub timestamp: [u8; 8],
41}
42
43/// Client-side stamper that tracks per-bucket utilisation and signs
44/// envelopes for individual chunks.
45///
46/// Construct with [`Stamper::from_blank`] for a fresh batch or with
47/// [`Stamper::from_state`] to resume from previously persisted bucket
48/// counters.
49#[derive(Clone, Debug)]
50pub struct Stamper {
51    signer: PrivateKey,
52    batch_id: BatchId,
53    buckets: Vec<u32>,
54    depth: u8,
55    max_slot: u32,
56}
57
58impl Stamper {
59    /// New stamper with empty buckets.
60    pub fn from_blank(signer: PrivateKey, batch_id: BatchId, depth: u8) -> Result<Self, Error> {
61        Self::from_state(signer, batch_id, vec![0u32; NUM_BUCKETS], depth)
62    }
63
64    /// Resume a stamper from previously persisted bucket counters.
65    /// `buckets.len()` must equal [`NUM_BUCKETS`].
66    pub fn from_state(
67        signer: PrivateKey,
68        batch_id: BatchId,
69        buckets: Vec<u32>,
70        depth: u8,
71    ) -> Result<Self, Error> {
72        if depth <= MIN_DEPTH {
73            return Err(Error::argument(format!(
74                "stamper depth must be > {MIN_DEPTH}, got {depth}"
75            )));
76        }
77        if buckets.len() != NUM_BUCKETS {
78            return Err(Error::argument(format!(
79                "buckets length must be {NUM_BUCKETS}, got {}",
80                buckets.len()
81            )));
82        }
83        let max_slot = 1u32 << (depth - MIN_DEPTH);
84        Ok(Self {
85            signer,
86            batch_id,
87            buckets,
88            depth,
89            max_slot,
90        })
91    }
92
93    /// Stamp a chunk address. Increments the per-bucket counter and
94    /// returns a freshly signed [`Envelope`]. Errors with
95    /// [`Error::Argument`] if the bucket is full or the address length
96    /// is wrong.
97    pub fn stamp(&mut self, chunk_addr: &[u8]) -> Result<Envelope, Error> {
98        if chunk_addr.len() != 32 {
99            return Err(Error::argument(format!(
100                "chunk address must be 32 bytes, got {}",
101                chunk_addr.len()
102            )));
103        }
104
105        let bucket = u16::from_be_bytes([chunk_addr[0], chunk_addr[1]]) as usize;
106        let height = self.buckets[bucket];
107        if height >= self.max_slot {
108            return Err(Error::argument(format!(
109                "bucket {bucket} is full (height={height}, max_slot={})",
110                self.max_slot
111            )));
112        }
113        self.buckets[bucket] = height + 1;
114
115        let mut index = [0u8; 8];
116        index[..4].copy_from_slice(&(bucket as u32).to_be_bytes());
117        index[4..].copy_from_slice(&height.to_be_bytes());
118
119        let now_ms = SystemTime::now()
120            .duration_since(UNIX_EPOCH)
121            .map(|d| d.as_millis() as u64)
122            .unwrap_or(0);
123        let timestamp = now_ms.to_be_bytes();
124
125        let mut to_sign = Vec::with_capacity(32 + 32 + 8 + 8);
126        to_sign.extend_from_slice(chunk_addr);
127        to_sign.extend_from_slice(self.batch_id.as_bytes());
128        to_sign.extend_from_slice(&index);
129        to_sign.extend_from_slice(&timestamp);
130
131        let signature = self.signer.sign(&to_sign)?;
132        let issuer = self.signer.public_key()?.address();
133
134        Ok(Envelope {
135            batch_id: self.batch_id,
136            index,
137            issuer,
138            signature,
139            timestamp,
140        })
141    }
142
143    /// Snapshot of the current bucket counters. Useful for persisting
144    /// and resuming via [`Stamper::from_state`].
145    pub fn state(&self) -> &[u32] {
146        &self.buckets
147    }
148
149    /// Configured depth.
150    pub fn depth(&self) -> u8 {
151        self.depth
152    }
153
154    /// Maximum height per bucket (`2^(depth - 16)`).
155    pub fn max_slot(&self) -> u32 {
156        self.max_slot
157    }
158
159    /// Configured batch ID.
160    pub fn batch_id(&self) -> &BatchId {
161        &self.batch_id
162    }
163}
164
165/// Concatenate the components of a postage stamp into the wire format
166/// Bee expects when a stamp travels alongside a chunk:
167/// `batchID(32) || index(8) || timestamp(8) || signature(65)` — 113
168/// bytes total. Mirrors bee-go `postage.MarshalStamp` and bee-js
169/// `marshalStamp`.
170pub fn marshal_stamp(
171    batch_id: &BatchId,
172    index: &[u8],
173    timestamp: &[u8],
174    signature: &Signature,
175) -> Result<[u8; MARSHALED_STAMP_LENGTH], Error> {
176    if index.len() != 8 {
177        return Err(Error::argument(format!(
178            "invalid index length: {}",
179            index.len()
180        )));
181    }
182    if timestamp.len() != 8 {
183        return Err(Error::argument(format!(
184            "invalid timestamp length: {}",
185            timestamp.len()
186        )));
187    }
188    let mut out = [0u8; MARSHALED_STAMP_LENGTH];
189    out[..32].copy_from_slice(batch_id.as_bytes());
190    out[32..40].copy_from_slice(index);
191    out[40..48].copy_from_slice(timestamp);
192    out[48..].copy_from_slice(signature.as_bytes());
193    Ok(out)
194}
195
196/// Marshal an [`Envelope`] into the 113-byte stamp wire format. Thin
197/// wrapper over [`marshal_stamp`] for callers that already hold a
198/// structured envelope (typically from [`Stamper::stamp`]). Mirrors
199/// bee-go `ConvertEnvelopeToMarshaledStamp` / bee-js
200/// `convertEnvelopeToMarshaledStamp`.
201pub fn convert_envelope_to_marshaled_stamp(
202    env: &Envelope,
203) -> Result<[u8; MARSHALED_STAMP_LENGTH], Error> {
204    marshal_stamp(&env.batch_id, &env.index, &env.timestamp, &env.signature)
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    fn signer() -> PrivateKey {
212        PrivateKey::new(&[0x11; 32]).unwrap()
213    }
214
215    fn batch() -> BatchId {
216        BatchId::new(&[0u8; 32]).unwrap()
217    }
218
219    #[test]
220    fn stamp_increments_bucket_and_signs() {
221        let mut stamper = Stamper::from_blank(signer(), batch(), 20).unwrap();
222        let addr = [0u8; 32];
223        let env = stamper.stamp(&addr).unwrap();
224
225        assert_eq!(env.batch_id, batch());
226        assert_eq!(env.signature.as_bytes().len(), 65);
227        assert_eq!(env.index.len(), 8);
228        assert_eq!(env.issuer.as_bytes().len(), 20);
229        assert_eq!(stamper.state()[0], 1);
230
231        // Signature verifies against the issuer.
232        let mut to_sign = Vec::new();
233        to_sign.extend_from_slice(&addr);
234        to_sign.extend_from_slice(batch().as_bytes());
235        to_sign.extend_from_slice(&env.index);
236        to_sign.extend_from_slice(&env.timestamp);
237        assert!(env.signature.is_valid(&to_sign, env.issuer));
238
239        let env2 = stamper.stamp(&addr).unwrap();
240        assert_eq!(stamper.state()[0], 2);
241        // Index height bumped from 0 → 1.
242        assert_eq!(&env2.index[4..], &1u32.to_be_bytes());
243    }
244
245    #[test]
246    fn rejects_depth_at_or_below_floor() {
247        assert!(Stamper::from_blank(signer(), batch(), 16).is_err());
248        assert!(Stamper::from_blank(signer(), batch(), 0).is_err());
249        assert!(Stamper::from_blank(signer(), batch(), 17).is_ok());
250    }
251
252    #[test]
253    fn rejects_bad_chunk_address_length() {
254        let mut stamper = Stamper::from_blank(signer(), batch(), 20).unwrap();
255        assert!(stamper.stamp(&[0u8; 31]).is_err());
256        assert!(stamper.stamp(&[0u8; 33]).is_err());
257    }
258
259    #[test]
260    fn bucket_full_errors() {
261        let mut stamper = Stamper::from_blank(signer(), batch(), 17).unwrap();
262        // depth 17 → max_slot = 2^1 = 2. Two stamps fit, third overflows.
263        let addr = [0u8; 32];
264        stamper.stamp(&addr).unwrap();
265        stamper.stamp(&addr).unwrap();
266        assert!(stamper.stamp(&addr).is_err());
267    }
268
269    #[test]
270    fn from_state_round_trips() {
271        let mut a = Stamper::from_blank(signer(), batch(), 18).unwrap();
272        a.stamp(&[0u8; 32]).unwrap();
273        a.stamp(&[0u8; 32]).unwrap();
274        let snapshot = a.state().to_vec();
275        let b = Stamper::from_state(signer(), batch(), snapshot, 18).unwrap();
276        assert_eq!(b.state()[0], 2);
277    }
278
279    #[test]
280    fn rejects_wrong_state_length() {
281        assert!(Stamper::from_state(signer(), batch(), vec![0u32; 10], 18).is_err());
282    }
283
284    #[test]
285    fn marshal_stamp_round_trip_matches_layout() {
286        let batch_id = BatchId::new(&[0xaa; 32]).unwrap();
287        let mut stamper = Stamper::from_blank(signer(), batch_id, 17).unwrap();
288        let chunk_addr = [0x42u8; 32];
289        let env = stamper.stamp(&chunk_addr).unwrap();
290
291        let bytes = convert_envelope_to_marshaled_stamp(&env).unwrap();
292        assert_eq!(bytes.len(), MARSHALED_STAMP_LENGTH);
293        assert_eq!(&bytes[..32], batch_id.as_bytes());
294        assert_eq!(&bytes[32..40], &env.index);
295        assert_eq!(&bytes[40..48], &env.timestamp);
296        assert_eq!(&bytes[48..], env.signature.as_bytes());
297
298        // Timestamp parses as a sane Unix-ms value (within 24 h of now).
299        let mut ts = [0u8; 8];
300        ts.copy_from_slice(&bytes[40..48]);
301        let ts = u64::from_be_bytes(ts);
302        let now_ms = std::time::SystemTime::now()
303            .duration_since(std::time::UNIX_EPOCH)
304            .unwrap()
305            .as_millis() as u64;
306        assert!(ts <= now_ms);
307        assert!(now_ms - ts < 24 * 60 * 60 * 1000);
308    }
309
310    #[test]
311    fn marshal_stamp_rejects_short_index_or_timestamp() {
312        let batch_id = BatchId::new(&[0u8; 32]).unwrap();
313        let sig = crate::swarm::typed_bytes::Signature::new(&[0xab; 65]).unwrap();
314        assert!(marshal_stamp(&batch_id, &[1, 2, 3], &[0u8; 8], &sig).is_err());
315        assert!(marshal_stamp(&batch_id, &[0u8; 8], &[1, 2], &sig).is_err());
316        assert!(marshal_stamp(&batch_id, &[0u8; 8], &[0u8; 8], &sig).is_ok());
317    }
318
319    #[test]
320    fn bucket_routing_uses_first_two_bytes_be() {
321        let mut stamper = Stamper::from_blank(signer(), batch(), 20).unwrap();
322        let mut addr = [0u8; 32];
323        addr[0] = 0xab;
324        addr[1] = 0xcd;
325        stamper.stamp(&addr).unwrap();
326        assert_eq!(stamper.state()[0xabcd], 1);
327        assert_eq!(stamper.state()[0], 0);
328    }
329}