Skip to main content

nectar_postage/
batch.rs

1//! Postage batch types.
2
3use alloy_primitives::{Address, B256};
4use nectar_primitives::SwarmAddress;
5
6use crate::{StampError, StampIndex, calculate_bucket};
7
8/// A 32-byte batch identifier.
9pub type BatchId = B256;
10
11/// Parameters for creating a new batch.
12#[derive(Debug, Clone, PartialEq, Eq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub struct BatchParams {
15    /// The owner's Ethereum address.
16    pub owner: Address,
17    /// The depth of the batch (total capacity = 2^depth chunks).
18    pub depth: u8,
19    /// The bucket depth for collision bucket uniformity.
20    pub bucket_depth: u8,
21    /// Whether the batch is immutable.
22    ///
23    /// Immutable batches cannot be diluted (depth increased) and chunks cannot
24    /// be overwritten. Mutable batches allow writing new chunks to the same
25    /// bucket index with a later timestamp, replacing the previous chunk.
26    pub immutable: bool,
27    /// Initial amount to fund the batch.
28    pub amount: u128,
29}
30
31impl BatchParams {
32    /// Creates new batch parameters.
33    pub const fn new(owner: Address, depth: u8, bucket_depth: u8, amount: u128) -> Self {
34        Self {
35            owner,
36            depth,
37            bucket_depth,
38            immutable: false,
39            amount,
40        }
41    }
42
43    /// Sets the immutable flag.
44    #[must_use]
45    pub const fn immutable(mut self, immutable: bool) -> Self {
46        self.immutable = immutable;
47        self
48    }
49}
50
51/// A postage batch represents a prepaid storage allocation in the Swarm network.
52///
53/// Batches are created by sending BZZ tokens to the postage stamp contract.
54/// Each batch has a depth that determines the maximum number of chunks it can stamp,
55/// and a bucket depth that controls the uniformity of chunk distribution.
56#[derive(Debug, Clone, PartialEq, Eq)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
58pub struct Batch {
59    /// The unique identifier for this batch.
60    id: BatchId,
61    /// The normalized balance of the batch (value per chunk).
62    value: u128,
63    /// The block number when this batch was created.
64    start: u64,
65    /// The Ethereum address of the batch owner.
66    owner: Address,
67    /// The depth of the batch, determining total capacity (2^depth chunks).
68    depth: u8,
69    /// The bucket depth for collision bucket uniformity.
70    bucket_depth: u8,
71    /// Whether the batch is immutable.
72    ///
73    /// Immutable batches cannot be diluted (depth increased) and chunks cannot
74    /// be overwritten. Mutable batches allow writing new chunks to the same
75    /// bucket index with a later timestamp, replacing the previous chunk.
76    immutable: bool,
77}
78
79impl Batch {
80    /// Creates a new batch with the given parameters.
81    #[inline]
82    pub const fn new(
83        id: BatchId,
84        value: u128,
85        start: u64,
86        owner: Address,
87        depth: u8,
88        bucket_depth: u8,
89        immutable: bool,
90    ) -> Self {
91        Self {
92            id,
93            value,
94            start,
95            owner,
96            depth,
97            bucket_depth,
98            immutable,
99        }
100    }
101
102    /// Returns the batch ID.
103    #[inline]
104    pub const fn id(&self) -> BatchId {
105        self.id
106    }
107
108    /// Returns the normalized value (balance per chunk).
109    #[inline]
110    pub const fn value(&self) -> u128 {
111        self.value
112    }
113
114    /// Returns the block number when this batch was created.
115    #[inline]
116    pub const fn start(&self) -> u64 {
117        self.start
118    }
119
120    /// Returns the owner's Ethereum address.
121    #[inline]
122    pub const fn owner(&self) -> Address {
123        self.owner
124    }
125
126    /// Returns the batch depth.
127    ///
128    /// The total capacity is 2^depth chunks.
129    #[inline]
130    pub const fn depth(&self) -> u8 {
131        self.depth
132    }
133
134    /// Returns the bucket depth.
135    ///
136    /// This controls the uniformity of chunk distribution across collision buckets.
137    #[inline]
138    pub const fn bucket_depth(&self) -> u8 {
139        self.bucket_depth
140    }
141
142    /// Returns whether this batch is immutable.
143    ///
144    /// Immutable batches cannot be diluted (depth increased) and chunks cannot
145    /// be overwritten. Mutable batches allow writing new chunks to the same
146    /// bucket index with a later timestamp, replacing the previous chunk.
147    #[inline]
148    pub const fn immutable(&self) -> bool {
149        self.immutable
150    }
151
152    /// Returns the maximum number of chunks per bucket.
153    ///
154    /// This is equal to 2^(depth - bucket_depth).
155    #[inline]
156    pub const fn bucket_upper_bound(&self) -> u32 {
157        1u32 << (self.depth - self.bucket_depth)
158    }
159
160    /// Returns the number of collision buckets.
161    ///
162    /// This is equal to 2^bucket_depth.
163    #[inline]
164    pub const fn bucket_count(&self) -> u32 {
165        1u32 << self.bucket_depth
166    }
167
168    /// Updates the batch value (for top-up operations).
169    #[inline]
170    pub const fn set_value(&mut self, value: u128) {
171        self.value = value;
172    }
173
174    /// Updates the batch depth (for dilution operations).
175    #[inline]
176    pub const fn set_depth(&mut self, depth: u8) {
177        self.depth = depth;
178    }
179
180    /// Checks if the batch has expired given the current chain state.
181    #[inline]
182    pub const fn is_expired(&self, total_amount: u128) -> bool {
183        self.value <= total_amount
184    }
185
186    /// Checks if the batch is usable (has enough confirmations).
187    #[inline]
188    pub const fn is_usable(&self, current_block: u64, threshold: u64) -> bool {
189        current_block >= self.start.saturating_add(threshold)
190    }
191
192    // =========================================================================
193    // Validation methods
194    // =========================================================================
195
196    /// Validates that an index is within the valid range for this batch.
197    ///
198    /// Checks that:
199    /// - The bucket is within the valid range (< bucket_count)
200    /// - The position within the bucket is within capacity (< bucket_upper_bound)
201    ///
202    /// # Returns
203    ///
204    /// `Ok(())` if the index is valid, or `Err(StampError::InvalidIndex)` otherwise.
205    pub const fn validate_index(&self, index: &StampIndex) -> Result<(), StampError> {
206        // Check bucket is within range
207        if index.bucket() >= self.bucket_count() {
208            return Err(StampError::InvalidIndex);
209        }
210
211        // Check index is within bucket capacity
212        if index.index() >= self.bucket_upper_bound() {
213            return Err(StampError::InvalidIndex);
214        }
215
216        Ok(())
217    }
218
219    /// Calculates which bucket a chunk address belongs to.
220    ///
221    /// The bucket is determined by taking the first `bucket_depth` bits of the
222    /// chunk address, interpreted as a big-endian unsigned integer.
223    #[inline]
224    pub fn bucket_for_address(&self, address: &SwarmAddress) -> u32 {
225        calculate_bucket(address, self.bucket_depth)
226    }
227
228    /// Checks if a chunk address matches the expected bucket for a stamp index.
229    ///
230    /// # Returns
231    ///
232    /// `Ok(())` if the bucket matches, or `Err(StampError::BucketMismatch)` otherwise.
233    pub fn validate_bucket(
234        &self,
235        index: &StampIndex,
236        address: &SwarmAddress,
237    ) -> Result<(), StampError> {
238        let expected_bucket = self.bucket_for_address(address);
239        if index.bucket() != expected_bucket {
240            return Err(StampError::BucketMismatch);
241        }
242        Ok(())
243    }
244}
245
246// Arbitrary implementations for property-based testing
247
248#[cfg(feature = "arbitrary")]
249impl<'a> arbitrary::Arbitrary<'a> for BatchParams {
250    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
251        // Generate valid depth values (bucket_depth must be <= depth)
252        let depth: u8 = u.int_in_range(1..=32)?;
253        let bucket_depth: u8 = u.int_in_range(1..=depth)?;
254
255        Ok(Self {
256            owner: Address::arbitrary(u)?,
257            depth,
258            bucket_depth,
259            immutable: u.arbitrary()?,
260            amount: u.arbitrary()?,
261        })
262    }
263}
264
265#[cfg(feature = "arbitrary")]
266impl<'a> arbitrary::Arbitrary<'a> for Batch {
267    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
268        // Generate valid depth values (bucket_depth must be <= depth)
269        let depth: u8 = u.int_in_range(1..=32)?;
270        let bucket_depth: u8 = u.int_in_range(1..=depth)?;
271
272        Ok(Self::new(
273            B256::arbitrary(u)?,
274            u.arbitrary()?,
275            u.arbitrary()?,
276            Address::arbitrary(u)?,
277            depth,
278            bucket_depth,
279            u.arbitrary()?,
280        ))
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_batch_creation() {
290        let id = B256::ZERO;
291        let batch = Batch::new(id, 1000, 100, Address::ZERO, 18, 16, false);
292
293        assert_eq!(batch.id(), id);
294        assert_eq!(batch.value(), 1000);
295        assert_eq!(batch.start(), 100);
296        assert_eq!(batch.owner(), Address::ZERO);
297        assert_eq!(batch.depth(), 18);
298        assert_eq!(batch.bucket_depth(), 16);
299        assert!(!batch.immutable());
300    }
301
302    #[test]
303    fn test_bucket_calculations() {
304        let batch = Batch::new(B256::ZERO, 0, 0, Address::ZERO, 18, 16, false);
305
306        // 2^(18-16) = 2^2 = 4 chunks per bucket
307        assert_eq!(batch.bucket_upper_bound(), 4);
308        // 2^16 = 65536 buckets
309        assert_eq!(batch.bucket_count(), 65536);
310    }
311
312    #[test]
313    fn test_batch_expiry() {
314        let batch = Batch::new(B256::ZERO, 1000, 0, Address::ZERO, 18, 16, false);
315
316        assert!(!batch.is_expired(999));
317        assert!(batch.is_expired(1000));
318        assert!(batch.is_expired(1001));
319    }
320
321    #[test]
322    fn test_batch_usability() {
323        let batch = Batch::new(B256::ZERO, 1000, 100, Address::ZERO, 18, 16, false);
324
325        assert!(!batch.is_usable(100, 10)); // Same block
326        assert!(!batch.is_usable(109, 10)); // Not enough confirmations
327        assert!(batch.is_usable(110, 10)); // Exactly threshold
328        assert!(batch.is_usable(111, 10)); // Past threshold
329    }
330
331    #[test]
332    fn test_batch_params_builder() {
333        let params = BatchParams::new(Address::ZERO, 20, 16, 1000).immutable(true);
334
335        assert_eq!(params.owner, Address::ZERO);
336        assert_eq!(params.depth, 20);
337        assert_eq!(params.bucket_depth, 16);
338        assert_eq!(params.amount, 1000);
339        assert!(params.immutable);
340    }
341}