Skip to main content

vid/
avidm_gf2.rs

1//! This module implements the AVID-M scheme over GF2
2
3use std::{borrow::Cow, ops::Range, vec};
4
5use anyhow::anyhow;
6use ark_serialize::{CanonicalDeserialize, CanonicalSerialize};
7use jf_merkle_tree::{MerkleTreeScheme, append_only::MerkleTree as JfMerkleTree};
8use p3_maybe_rayon::prelude::*;
9use reed_solomon_simd::ReedSolomonEncoder;
10use serde::{Deserialize, Serialize};
11use tagged_base64::tagged;
12
13use crate::{
14    VidError, VidResult, VidScheme,
15    utils::blake3::{Blake3DigestAlgorithm, Blake3Node},
16};
17
18/// Namespaced AvidmGf2 scheme
19pub mod namespaced;
20/// Namespace proofs for AvidmGf2 scheme
21pub mod proofs;
22
23/// Merkle tree scheme used in the VID. Uses BLAKE3 directly via
24/// [`Blake3DigestAlgorithm`] rather than going through the
25/// `jf_merkle_tree::hasher::HasherDigest` blanket impl, which would pin
26/// `blake3` to a `digest 0.10`-compatible release line.
27pub(crate) type MerkleTree = JfMerkleTree<Blake3Node, Blake3DigestAlgorithm, u64, 4, Blake3Node>;
28/// Membership proof of the VID Merkle tree, one per shard of a share.
29pub type MerkleProof = <MerkleTree as MerkleTreeScheme>::MembershipProof;
30type MerkleCommit = <MerkleTree as MerkleTreeScheme>::Commitment;
31
32/// Dummy struct for AVID-M scheme over GF2
33pub struct AvidmGf2Scheme;
34
35// reed_solomon_simd's shard limit. Node weights total at most
36// `VID_TARGET_TOTAL_STAKE` (1000) plus one per validator.
37const MAX_TOTAL_WEIGHTS: usize = 1 << 16;
38
39// Nodes expand 3x, so 4 is headroom.
40const MAX_EXPANSION: usize = 4;
41
42/// VID Parameters
43///
44/// Valid when `0 < recovery_threshold <= total_weights <= 2^16` and
45/// `total_weights <= 4 * recovery_threshold`. The fields are public and
46/// deserialized unchecked, so [`Self::new`] and every [`AvidmGf2Scheme`]
47/// entry point return [`VidError::InvalidParam`] for an invalid param.
48#[derive(Clone, Debug, Hash, Serialize, Deserialize, PartialEq, Eq)]
49pub struct AvidmGf2Param {
50    /// Total weights of all storage nodes
51    pub total_weights: usize,
52    /// Minimum collective weights required to recover the original payload.
53    pub recovery_threshold: usize,
54}
55
56impl AvidmGf2Param {
57    /// Construct a new [`AvidmGf2Param`].
58    pub fn new(recovery_threshold: usize, total_weights: usize) -> VidResult<Self> {
59        let param = Self {
60            total_weights,
61            recovery_threshold,
62        };
63        param.validate()?;
64        Ok(param)
65    }
66
67    fn validate(&self) -> VidResult<()> {
68        if self.recovery_threshold == 0
69            || self.total_weights < self.recovery_threshold
70            || self.total_weights > MAX_TOTAL_WEIGHTS
71            || self.total_weights > self.recovery_threshold * MAX_EXPANSION
72        {
73            return Err(VidError::InvalidParam);
74        }
75        Ok(())
76    }
77}
78
79/// VID Share type to be distributed among the parties.
80#[derive(Clone, Debug, Hash, Serialize, Deserialize, PartialEq, Eq)]
81pub struct AvidmGf2Share {
82    /// Range of this share in the encoded payload.
83    range: Range<usize>,
84    /// Actual share content.
85    #[serde(with = "nested_bytes")]
86    payload: Vec<Vec<u8>>,
87    /// Merkle proof of the content.
88    mt_proofs: Vec<MerkleProof>,
89}
90
91/// Optimised serialisation of a sequence of `Vec<u8>`s using `serde_bytes`.
92mod nested_bytes {
93    use serde::{Deserialize, Deserializer, Serializer, ser::SerializeSeq};
94    use serde_bytes::{ByteBuf, Bytes};
95
96    pub fn serialize<S>(v: &[Vec<u8>], s: S) -> Result<S::Ok, S::Error>
97    where
98        S: Serializer,
99    {
100        let mut seq = s.serialize_seq(Some(v.len()))?;
101        for inner in v {
102            seq.serialize_element(Bytes::new(inner))?;
103        }
104        seq.end()
105    }
106
107    pub fn deserialize<'de, D>(d: D) -> Result<Vec<Vec<u8>>, D::Error>
108    where
109        D: Deserializer<'de>,
110    {
111        let v: Vec<ByteBuf> = Deserialize::deserialize(d)?;
112        Ok(v.into_iter().map(ByteBuf::into_vec).collect())
113    }
114}
115
116impl AvidmGf2Share {
117    /// Get the weight of this share
118    pub fn weight(&self) -> usize {
119        self.range.len()
120    }
121
122    /// Range of this share in the encoded payload.
123    pub fn range(&self) -> &Range<usize> {
124        &self.range
125    }
126
127    /// One shard of raw bytes per index in [`Self::range`].
128    pub fn payload(&self) -> &[Vec<u8>] {
129        &self.payload
130    }
131
132    /// One Merkle proof per shard of [`Self::payload`].
133    pub fn mt_proofs(&self) -> &[MerkleProof] {
134        &self.mt_proofs
135    }
136
137    /// Validate the share structure.
138    pub fn validate(&self) -> bool {
139        self.payload.len() == self.range.len() && self.mt_proofs.len() == self.range.len()
140    }
141
142    /// The size every shard of this share has, or `None` if the share is
143    /// empty or its shards disagree. A dispersal gives all its shards the
144    /// size [`AvidmGf2Scheme::shard_bytes`] computes for its payload.
145    pub fn shard_bytes(&self) -> Option<usize> {
146        let first = self.payload.first()?.len();
147        self.payload
148            .iter()
149            .all(|shard| shard.len() == first)
150            .then_some(first)
151    }
152}
153
154/// VID Commitment type
155#[derive(
156    Clone,
157    Copy,
158    Debug,
159    Default,
160    Hash,
161    CanonicalSerialize,
162    CanonicalDeserialize,
163    Eq,
164    PartialEq,
165    Ord,
166    PartialOrd,
167)]
168#[tagged("AvidmGf2Commit")]
169#[repr(C)]
170pub struct AvidmGf2Commit {
171    /// VID commitment is the Merkle tree root
172    pub commit: MerkleCommit,
173}
174
175impl AsRef<[u8]> for AvidmGf2Commit {
176    fn as_ref(&self) -> &[u8] {
177        self.commit.as_ref()
178    }
179}
180
181impl AsRef<[u8; 32]> for AvidmGf2Commit {
182    fn as_ref(&self) -> &[u8; 32] {
183        <Self as AsRef<[u8]>>::as_ref(self)
184            .try_into()
185            .expect("AvidmGf2Commit is always 32 bytes")
186    }
187}
188
189impl AvidmGf2Scheme {
190    /// Setup an instance for AVID-M scheme
191    pub fn setup(recovery_threshold: usize, total_weights: usize) -> VidResult<AvidmGf2Param> {
192        AvidmGf2Param::new(recovery_threshold, total_weights)
193    }
194
195    /// Size in bytes of every shard of a `payload_byte_len`-byte payload
196    /// dispersed into `original_count` original shards: the fewest bytes that
197    /// hold the payload plus its pad byte, rounded up to the even size
198    /// `reed_solomon_simd` requires.
199    ///
200    /// `None` if `original_count` is zero or the size overflows, so a verifier
201    /// can evaluate it on an untrusted length without panicking.
202    pub fn shard_bytes(payload_byte_len: usize, original_count: usize) -> Option<usize> {
203        if original_count == 0 {
204            return None;
205        }
206        payload_byte_len
207            .checked_add(1)?
208            .div_ceil(original_count)
209            .checked_next_multiple_of(2)
210    }
211
212    fn payload_shard_bytes(param: &AvidmGf2Param, payload_len: usize) -> VidResult<usize> {
213        Self::shard_bytes(payload_len, param.recovery_threshold).ok_or_else(|| {
214            VidError::Argument("Payload length is too large to disperse".to_string())
215        })
216    }
217
218    /// Build the `original_count` original shards from `payload`, applying
219    /// the AvidM-GF2 bit padding (one `0x01` byte at `payload.len()` followed
220    /// by zeros to fill the final shard).
221    ///
222    /// Full shards borrow from `payload`; only shards touching the padding are allocated.
223    fn chunk_and_pad(
224        payload: &[u8],
225        shard_bytes: usize,
226        original_count: usize,
227    ) -> VidResult<Vec<Cow<'_, [u8]>>> {
228        let padded_len = shard_bytes * original_count;
229        if padded_len < payload.len() + 1 {
230            return Err(VidError::Argument(
231                "Payload length is too large to fit in the given payload length".to_string(),
232            ));
233        }
234        let mut original: Vec<Cow<[u8]>> = Vec::with_capacity(original_count);
235        for i in 0..original_count {
236            let start = i * shard_bytes;
237            let end = start + shard_bytes;
238            if end <= payload.len() {
239                original.push(Cow::Borrowed(&payload[start..end]));
240                continue;
241            }
242            let mut chunk = vec![0u8; shard_bytes];
243            if start < payload.len() {
244                let take = payload.len() - start;
245                chunk[..take].copy_from_slice(&payload[start..]);
246                // Pad byte falls inside this chunk.
247                chunk[take] = 1u8;
248            } else if start == payload.len() {
249                // Payload ended exactly on a chunk boundary, pad byte is the
250                // first byte of this all-zero chunk.
251                chunk[0] = 1u8;
252            }
253            original.push(Cow::Owned(chunk));
254        }
255        Ok(original)
256    }
257
258    /// Erasure-code the padded `original` shards, returning only the recovery shards.
259    fn encode_recovery(
260        param: &AvidmGf2Param,
261        original: &[Cow<'_, [u8]>],
262    ) -> VidResult<Vec<Vec<u8>>> {
263        let recovery_count = param.total_weights - param.recovery_threshold;
264        if recovery_count == 0 {
265            return Ok(vec![]);
266        }
267        Ok(reed_solomon_simd::encode(
268            param.recovery_threshold,
269            recovery_count,
270            original,
271        )?)
272    }
273
274    fn merkle_tree<T: AsRef<[u8]> + Sync>(shards: &[T]) -> VidResult<MerkleTree> {
275        let share_digests: Vec<Blake3Node> = shards
276            .par_iter()
277            .map(|share| Blake3Node::from(blake3::hash(share.as_ref())))
278            .collect();
279        Ok(MerkleTree::from_elems(None, &share_digests)?)
280    }
281
282    /// Merkle tree over all shards of `payload`, hashing recovery shards in place in the encoder.
283    fn raw_commit(param: &AvidmGf2Param, payload: &[u8]) -> VidResult<MerkleTree> {
284        let original_count = param.recovery_threshold;
285        let recovery_count = param.total_weights - original_count;
286        let shard_bytes = Self::payload_shard_bytes(param, payload.len())?;
287        let original = Self::chunk_and_pad(payload, shard_bytes, original_count)?;
288        if recovery_count == 0 {
289            return Self::merkle_tree(&original);
290        }
291        let mut encoder = ReedSolomonEncoder::new(original_count, recovery_count, shard_bytes)?;
292        for shard in &original {
293            encoder.add_original_shard(shard)?;
294        }
295        let result = encoder.encode()?;
296        let shards: Vec<&[u8]> = original
297            .iter()
298            .map(AsRef::as_ref)
299            .chain(result.recovery_iter())
300            .collect();
301        Self::merkle_tree(&shards)
302    }
303
304    fn raw_disperse(
305        param: &AvidmGf2Param,
306        payload: &[u8],
307    ) -> VidResult<(MerkleTree, Vec<Vec<u8>>)> {
308        let shard_bytes = Self::payload_shard_bytes(param, payload.len())?;
309        let original = Self::chunk_and_pad(payload, shard_bytes, param.recovery_threshold)?;
310        let recovery = Self::encode_recovery(param, &original)?;
311
312        let mut shares: Vec<Vec<u8>> = original.into_iter().map(Cow::into_owned).collect();
313        shares.extend(recovery);
314        let mt = Self::merkle_tree(&shares)?;
315        Ok((mt, shares))
316    }
317
318    /// Test-only: disperse `payload` but corrupt the erasure-coded (recovery)
319    /// shards, so the committed shard set is **not** a valid codeword. Every
320    /// returned share still verifies against the returned commitment — its
321    /// merkle proofs are genuine — yet recovering from any threshold-covering
322    /// subset and re-committing yields a *different* commitment. This models a
323    /// Byzantine disperser that commits to a non-codeword, exercising the
324    /// unrecoverable reconstruction path.
325    ///
326    /// Requires `recovery_threshold < total_weights` so recovery shards exist
327    /// to corrupt.
328    #[cfg(any(test, feature = "testing"))]
329    pub fn disperse_non_codeword(
330        param: &AvidmGf2Param,
331        distribution: &[u32],
332        payload: &[u8],
333    ) -> VidResult<(AvidmGf2Commit, Vec<AvidmGf2Share>)> {
334        param.validate()?;
335        let total_weights = distribution.iter().map(|&w| w as usize).sum::<usize>();
336        if total_weights != param.total_weights {
337            return Err(VidError::Argument(
338                "Weight distribution is inconsistent with the given param".to_string(),
339            ));
340        }
341        if distribution.contains(&0u32) {
342            return Err(VidError::Argument("Weight cannot be zero".to_string()));
343        }
344        let original_count = param.recovery_threshold;
345        let (_, mut shards) = Self::raw_disperse(param, payload)?;
346        if shards.len() <= original_count {
347            return Err(VidError::Argument(
348                "Payload has no recovery shards to corrupt".to_string(),
349            ));
350        }
351        // Flip a byte in every recovery shard. The original shards are
352        // untouched, so each shard still verifies against the rebuilt tree, but
353        // the recovery shards no longer match the Reed-Solomon encoding of the
354        // originals: the committed set is not a codeword and cannot re-commit.
355        for shard in &mut shards[original_count..] {
356            shard[0] ^= 0xff;
357        }
358        let share_digests: Vec<Blake3Node> = shards
359            .iter()
360            .map(|shard| Blake3Node::from(blake3::hash(shard)))
361            .collect();
362        let mt = MerkleTree::from_elems(None, &share_digests)?;
363        let commit = AvidmGf2Commit {
364            commit: mt.commitment(),
365        };
366        let ranges: Vec<_> = distribution
367            .iter()
368            .scan(0usize, |sum, w| {
369                let prefix_sum = *sum;
370                *sum += *w as usize;
371                Some(prefix_sum..*sum)
372            })
373            .collect();
374        let mut shards_iter = shards.into_iter();
375        let payloads: Vec<Vec<Vec<u8>>> = ranges
376            .iter()
377            .map(|range| shards_iter.by_ref().take(range.len()).collect())
378            .collect();
379        let mut proofs_iter = mt
380            .collect_leaves_with_proofs()
381            .into_iter()
382            .map(|(_, _, proof)| proof);
383        let proof_groups: Vec<Vec<MerkleProof>> = ranges
384            .iter()
385            .map(|range| proofs_iter.by_ref().take(range.len()).collect())
386            .collect();
387        let shares: Vec<_> = ranges
388            .into_iter()
389            .zip(payloads)
390            .zip(proof_groups)
391            .map(|((range, payload), mt_proofs)| AvidmGf2Share {
392                range,
393                payload,
394                mt_proofs,
395            })
396            .collect();
397        Ok((commit, shares))
398    }
399}
400
401impl VidScheme for AvidmGf2Scheme {
402    type Param = AvidmGf2Param;
403    type Share = AvidmGf2Share;
404    type Commit = AvidmGf2Commit;
405
406    fn commit(param: &Self::Param, payload: &[u8]) -> VidResult<Self::Commit> {
407        param.validate()?;
408        let mt = Self::raw_commit(param, payload)?;
409        Ok(Self::Commit {
410            commit: mt.commitment(),
411        })
412    }
413
414    fn disperse(
415        param: &Self::Param,
416        distribution: &[u32],
417        payload: &[u8],
418    ) -> VidResult<(Self::Commit, Vec<Self::Share>)> {
419        param.validate()?;
420        let total_weights = distribution.iter().map(|&w| w as usize).sum::<usize>();
421        if total_weights != param.total_weights {
422            return Err(VidError::Argument(
423                "Weight distribution is inconsistent with the given param".to_string(),
424            ));
425        }
426        if distribution.contains(&0u32) {
427            return Err(VidError::Argument("Weight cannot be zero".to_string()));
428        }
429        let (mt, shares) = Self::raw_disperse(param, payload)?;
430        let commit = AvidmGf2Commit {
431            commit: mt.commitment(),
432        };
433
434        let ranges: Vec<_> = distribution
435            .iter()
436            .scan(0usize, |sum, w| {
437                let prefix_sum = *sum;
438                *sum += *w as usize;
439                Some(prefix_sum..*sum)
440            })
441            .collect();
442        // Ranges partition `shares` and `proofs` in order. Consume both via
443        // owning iterators instead of `shares[range].to_vec()` /
444        // `proofs[range].to_vec()`, which would heap-clone every Vec<u8>
445        // payload and every per-leaf proof at high num_ns × total_weights.
446        //
447        // `mt.collect_leaves_with_proofs()` returns leaves in ascending
448        // position order (DFS over children 0..ARITY), so we can drain the
449        // iterator directly without an indexed placeholder Vec.
450        let mut shares_iter = shares.into_iter();
451        let payloads: Vec<Vec<Vec<u8>>> = ranges
452            .iter()
453            .map(|range| shares_iter.by_ref().take(range.len()).collect())
454            .collect();
455        let mut proofs_iter = mt
456            .collect_leaves_with_proofs()
457            .into_iter()
458            .map(|(_, _, proof)| proof);
459        let proof_groups: Vec<Vec<MerkleProof>> = ranges
460            .iter()
461            .map(|range| proofs_iter.by_ref().take(range.len()).collect())
462            .collect();
463        // The map body is just a struct construction over already-prepared
464        // owned components — sub-µs per item, smaller than rayon's
465        // per-item scheduling overhead. Stay sequential.
466        let shares: Vec<_> = ranges
467            .into_iter()
468            .zip(payloads)
469            .zip(proof_groups)
470            .map(|((range, payload), mt_proofs)| AvidmGf2Share {
471                range,
472                payload,
473                mt_proofs,
474            })
475            .collect();
476        Ok((commit, shares))
477    }
478
479    fn verify_share(
480        param: &Self::Param,
481        commit: &Self::Commit,
482        share: &Self::Share,
483    ) -> VidResult<crate::VerificationResult> {
484        param.validate()?;
485        if !share.validate() || share.range.is_empty() || share.range.end > param.total_weights {
486            return Err(VidError::InvalidShare);
487        }
488        // Each (i, leaf, proof) triple is independent. `find_any` short-
489        // circuits on the first failing position and avoids allocating any
490        // intermediate collection.
491        let start = share.range.start;
492        let len = share.range.end - start;
493        match (0..len)
494            .into_par_iter()
495            .map(|i| -> VidResult<crate::VerificationResult> {
496                let payload_digest = Blake3Node::from(blake3::hash(&share.payload[i]));
497                MerkleTree::verify(
498                    commit.commit,
499                    (start + i) as u64,
500                    payload_digest,
501                    &share.mt_proofs[i],
502                )
503                .map_err(VidError::from)
504            })
505            .find_any(|r| !matches!(r, Ok(Ok(()))))
506        {
507            None => Ok(Ok(())),
508            Some(Ok(v)) => Ok(v),
509            Some(Err(e)) => Err(e),
510        }
511    }
512
513    fn recover(
514        param: &Self::Param,
515        _commit: &Self::Commit,
516        shares: &[Self::Share],
517    ) -> VidResult<Vec<u8>> {
518        param.validate()?;
519        let original_count = param.recovery_threshold;
520        let recovery_count = param.total_weights - param.recovery_threshold;
521        // Find the first non-empty share
522        let Some(first_share) = shares.iter().find(|s| !s.payload.is_empty()) else {
523            return Err(VidError::InsufficientShares);
524        };
525        let shard_bytes = first_share.payload[0].len();
526        // The buffers below are sized from `shard_bytes` and the threshold, so
527        // the shares must agree on the former and carry enough shards to cover
528        // the latter before anything is allocated.
529        if shares
530            .iter()
531            .any(|share| !share.validate() || share.payload.iter().any(|p| p.len() != shard_bytes))
532        {
533            return Err(VidError::InvalidShare);
534        }
535        if shares
536            .iter()
537            .map(|share| share.payload.len())
538            .sum::<usize>()
539            < original_count
540        {
541            return Err(VidError::InsufficientShares);
542        }
543
544        // Track references to input original shards; avoids the per-shard
545        // `.clone()` the previous version did to populate a
546        // `Vec<Option<Vec<u8>>>`. Reconstructed shards come from the decoder
547        // and are copied directly into the output buffer below.
548        let mut input_orig: Vec<Option<&[u8]>> = vec![None; original_count];
549
550        let mut recovered: Vec<u8> = Vec::with_capacity(original_count * shard_bytes);
551        if recovery_count == 0 {
552            // Edge case where there are no recovery shares: every original must
553            // be supplied as input.
554            for share in shares {
555                for (i, index) in share.range.clone().enumerate() {
556                    if index < original_count {
557                        input_orig[index] = Some(&share.payload[i]);
558                    }
559                }
560            }
561            for slot in &input_orig {
562                let shard = slot
563                    .ok_or_else(|| VidError::Internal(anyhow!("Failed to recover the payload.")))?;
564                recovered.extend_from_slice(shard);
565            }
566        } else {
567            let mut decoder = reed_solomon_simd::ReedSolomonDecoder::new(
568                original_count,
569                recovery_count,
570                shard_bytes,
571            )?;
572            for share in shares {
573                for (i, index) in share.range.clone().enumerate() {
574                    let shard = &share.payload[i];
575                    if index < original_count {
576                        input_orig[index] = Some(shard);
577                        decoder.add_original_shard(index, shard)?;
578                    } else {
579                        decoder.add_recovery_shard(index - original_count, shard)?;
580                    }
581                }
582            }
583
584            let result = decoder.decode()?;
585            for (i, shard) in input_orig.iter().enumerate().take(original_count) {
586                let shard: &[u8] = match shard {
587                    Some(data) => data,
588                    None => result.restored_original(i).ok_or_else(|| {
589                        VidError::Internal(anyhow!("Failed to recover the payload."))
590                    })?,
591                };
592                recovered.extend_from_slice(shard);
593            }
594        }
595        match recovered.iter().rposition(|&b| b != 0) {
596            Some(pad_index) if recovered[pad_index] == 1u8 => {
597                recovered.truncate(pad_index);
598                Ok(recovered)
599            },
600            _ => Err(VidError::Argument(
601                "Malformed payload, cannot find the padding position".to_string(),
602            )),
603        }
604    }
605}
606
607/// Unit tests
608#[cfg(test)]
609pub mod tests {
610    use rand::{RngCore, seq::SliceRandom};
611
612    use super::{AvidmGf2Param, AvidmGf2Scheme};
613    use crate::{VidError, VidScheme};
614
615    /// Digest over the commitment and every share's range and shards.
616    fn output_digest(rt: usize, tw: usize, len: usize) -> String {
617        let payload: Vec<u8> = (0..len).map(|i| (i * 31 + 7) as u8).collect();
618        let param = AvidmGf2Scheme::setup(rt, tw).unwrap();
619        let commit = AvidmGf2Scheme::commit(&param, &payload).unwrap();
620        let distribution: Vec<u32> = (0..tw).map(|_| 1).collect();
621        let (dcommit, shares) = AvidmGf2Scheme::disperse(&param, &distribution, &payload).unwrap();
622        assert_eq!(commit, dcommit);
623        let mut hasher = blake3::Hasher::new();
624        hasher.update(commit.as_ref());
625        for share in &shares {
626            hasher.update(&share.range().start.to_le_bytes());
627            hasher.update(&share.range().end.to_le_bytes());
628            for shard in share.payload() {
629                hasher.update(&(shard.len() as u64).to_le_bytes());
630                hasher.update(shard);
631            }
632        }
633        hasher.finalize().to_hex().to_string()
634    }
635
636    #[test]
637    fn outputs_are_pinned() {
638        let mut out = String::new();
639        for (rt, tw) in [(1, 1), (1, 3), (3, 10), (4, 12), (5, 5), (7, 20)] {
640            for len in [0, 1, 31, 32, 33, 63, 64, 65, 1000, 4097, 100_000] {
641                out += &format!("{rt} {tw} {len} {}\n", output_digest(rt, tw, len));
642            }
643        }
644        let got = blake3::hash(out.as_bytes()).to_hex().to_string();
645        assert_eq!(
646            got, "3c96b583adfdf90d15d80c117f9ab76625995e851743204c4e9c5725b5b7950a",
647            "{out}"
648        );
649    }
650
651    #[test]
652    fn round_trip() {
653        // play with these items
654        let num_storage_nodes_list = [4, 9, 16];
655        let payload_byte_lens = [1, 31, 32, 500];
656
657        // more items as a function of the above
658
659        let mut rng = jf_utils::test_rng();
660
661        for num_storage_nodes in num_storage_nodes_list {
662            let weights: Vec<u32> = (0..num_storage_nodes)
663                .map(|_| rng.next_u32() % 5 + 1)
664                .collect();
665            let total_weights: u32 = weights.iter().sum();
666            let recovery_threshold = total_weights.div_ceil(3) as usize;
667            let params = AvidmGf2Scheme::setup(recovery_threshold, total_weights as usize).unwrap();
668
669            for payload_byte_len in payload_byte_lens {
670                let payload = {
671                    let mut bytes_random = vec![0u8; payload_byte_len];
672                    rng.fill_bytes(&mut bytes_random);
673                    bytes_random
674                };
675
676                let (commit, mut shares) =
677                    AvidmGf2Scheme::disperse(&params, &weights, &payload).unwrap();
678
679                assert_eq!(shares.len(), num_storage_nodes);
680
681                // verify shares
682                shares.iter().for_each(|share| {
683                    assert!(
684                        AvidmGf2Scheme::verify_share(&params, &commit, share)
685                            .is_ok_and(|r| r.is_ok())
686                    )
687                });
688
689                // test payload recovery on a random subset of shares
690                shares.shuffle(&mut rng);
691                let mut cumulated_weights = 0;
692                let mut cut_index = 0;
693                while cumulated_weights < recovery_threshold {
694                    cumulated_weights += shares[cut_index].weight();
695                    cut_index += 1;
696                }
697                let payload_recovered =
698                    AvidmGf2Scheme::recover(&params, &commit, &shares[..cut_index]).unwrap();
699                assert_eq!(payload_recovered, payload);
700            }
701        }
702    }
703
704    #[test]
705    fn round_trip_edge_case() {
706        // play with these items
707        let num_storage_nodes_list = [4, 9, 16];
708        let payload_byte_lens = [1, 31, 32, 500];
709
710        // more items as a function of the above
711
712        let mut rng = jf_utils::test_rng();
713
714        for num_storage_nodes in num_storage_nodes_list {
715            let weights: Vec<u32> = (0..num_storage_nodes)
716                .map(|_| rng.next_u32() % 5 + 1)
717                .collect();
718            let total_weights: u32 = weights.iter().sum();
719            let recovery_threshold = total_weights as usize;
720            let params = AvidmGf2Scheme::setup(recovery_threshold, total_weights as usize).unwrap();
721
722            for payload_byte_len in payload_byte_lens {
723                let payload = {
724                    let mut bytes_random = vec![0u8; payload_byte_len];
725                    rng.fill_bytes(&mut bytes_random);
726                    bytes_random
727                };
728
729                let (commit, mut shares) =
730                    AvidmGf2Scheme::disperse(&params, &weights, &payload).unwrap();
731
732                assert_eq!(shares.len(), num_storage_nodes);
733
734                // verify shares
735                shares.iter().for_each(|share| {
736                    assert!(
737                        AvidmGf2Scheme::verify_share(&params, &commit, share)
738                            .is_ok_and(|r| r.is_ok())
739                    )
740                });
741
742                // test payload recovery on a random subset of shares
743                shares.shuffle(&mut rng);
744                let payload_recovered =
745                    AvidmGf2Scheme::recover(&params, &commit, &shares[..]).unwrap();
746                assert_eq!(payload_recovered, payload);
747            }
748        }
749    }
750
751    /// `shard_bytes` is what dispersal actually produces, for every shard of
752    /// every share, across payload lengths on both sides of a shard boundary
753    /// and on both sides of the even rounding.
754    #[test]
755    fn shard_bytes_matches_dispersal() {
756        let total_weights = 10usize;
757        let recovery_threshold = 4;
758        let params = AvidmGf2Scheme::setup(recovery_threshold, total_weights).unwrap();
759        let weights = vec![2u32; 5];
760
761        for payload_byte_len in [0usize, 1, 3, 4, 7, 8, 31, 32, 100, 101] {
762            let payload = vec![1u8; payload_byte_len];
763            let expected =
764                AvidmGf2Scheme::shard_bytes(payload_byte_len, recovery_threshold).unwrap();
765            assert_eq!(expected % 2, 0, "{payload_byte_len}");
766            assert!(
767                expected * recovery_threshold > payload_byte_len,
768                "{payload_byte_len}: shards must hold the payload and its pad byte"
769            );
770            assert!(
771                (expected - 2) * recovery_threshold <= payload_byte_len,
772                "{payload_byte_len}: shards must not be larger than needed"
773            );
774            let (_, shares) = AvidmGf2Scheme::disperse(&params, &weights, &payload).unwrap();
775            for share in &shares {
776                assert_eq!(share.shard_bytes(), Some(expected), "{payload_byte_len}");
777            }
778        }
779
780        // Total on inputs dispersal never sees.
781        assert_eq!(AvidmGf2Scheme::shard_bytes(100, 0), None);
782        assert_eq!(AvidmGf2Scheme::shard_bytes(usize::MAX, 1), None);
783        assert_eq!(AvidmGf2Scheme::shard_bytes(usize::MAX - 1, 1), None);
784    }
785
786    #[test]
787    fn disperse_rejects_inconsistent_distribution() {
788        let total_weights = 10usize;
789        let recovery_threshold = 4;
790        let params = AvidmGf2Scheme::setup(recovery_threshold, total_weights).unwrap();
791        let payload = vec![1u8; 100];
792
793        // distribution sums to 12, but param says total_weights=10
794        let bad_weights = vec![3u32; 4];
795        assert!(
796            AvidmGf2Scheme::disperse(&params, &bad_weights, &payload).is_err(),
797            "disperse should reject distribution that doesn't sum to total_weights"
798        );
799
800        // distribution contains a zero weight
801        let zero_weight = vec![0u32, 5, 5];
802        assert!(
803            AvidmGf2Scheme::disperse(&params, &zero_weight, &payload).is_err(),
804            "disperse should reject zero-weight entries"
805        );
806
807        // correct distribution should succeed
808        let good_weights = vec![2u32; 5];
809        assert!(AvidmGf2Scheme::disperse(&params, &good_weights, &payload).is_ok());
810    }
811
812    #[test]
813    fn verify_share_rejects_out_of_range() {
814        let total_weights = 10usize;
815        let recovery_threshold = 4;
816        let params = AvidmGf2Scheme::setup(recovery_threshold, total_weights).unwrap();
817        let payload = vec![1u8; 100];
818        let weights = vec![2u32; 5];
819
820        let (commit, shares) = AvidmGf2Scheme::disperse(&params, &weights, &payload).unwrap();
821
822        // valid shares pass
823        for share in &shares {
824            assert!(AvidmGf2Scheme::verify_share(&params, &commit, share).is_ok_and(|r| r.is_ok()));
825        }
826
827        // a share verified against a smaller param should be rejected
828        let smaller_params = AvidmGf2Scheme::setup(2, 5).unwrap();
829        let last_share = shares.last().unwrap();
830        assert!(
831            AvidmGf2Scheme::verify_share(&smaller_params, &commit, last_share).is_err(),
832            "verify_share should reject share with range.end > param.total_weights"
833        );
834    }
835
836    #[test]
837    fn param_bounds_are_inclusive() {
838        for (recovery_threshold, total_weights) in [(1, 1), (1 << 14, 1 << 16), (2, 8)] {
839            assert!(
840                AvidmGf2Param::new(recovery_threshold, total_weights).is_ok(),
841                "({recovery_threshold}, {total_weights})"
842            );
843        }
844        for (recovery_threshold, total_weights) in
845            [(0, 4), (3, 2), ((1 << 14) + 1, (1 << 16) + 1), (2, 9)]
846        {
847            assert!(
848                matches!(
849                    AvidmGf2Param::new(recovery_threshold, total_weights),
850                    Err(VidError::InvalidParam)
851                ),
852                "({recovery_threshold}, {total_weights})"
853            );
854        }
855    }
856
857    #[test]
858    fn invalid_param_is_rejected() {
859        let param = AvidmGf2Scheme::setup(3, 9).unwrap();
860        let payload = [7u8; 100];
861        let (commit, shares) = AvidmGf2Scheme::disperse(&param, &[3, 3, 3], &payload).unwrap();
862
863        for (recovery_threshold, total_weights) in
864            [(0, 4), (3, 2), (1 << 15, 1 << 17), (1, 1 << 16)]
865        {
866            let forged = AvidmGf2Param {
867                total_weights,
868                recovery_threshold,
869            };
870            assert!(
871                matches!(
872                    AvidmGf2Scheme::recover(&forged, &commit, &shares),
873                    Err(VidError::InvalidParam)
874                ),
875                "recover {forged:?}"
876            );
877            assert!(
878                matches!(
879                    AvidmGf2Scheme::commit(&forged, &payload),
880                    Err(VidError::InvalidParam)
881                ),
882                "commit {forged:?}"
883            );
884            assert!(
885                matches!(
886                    AvidmGf2Scheme::disperse(&forged, &[1], &payload),
887                    Err(VidError::InvalidParam)
888                ),
889                "disperse {forged:?}"
890            );
891            assert!(
892                matches!(
893                    AvidmGf2Scheme::verify_share(&forged, &commit, &shares[0]),
894                    Err(VidError::InvalidParam)
895                ),
896                "verify_share {forged:?}"
897            );
898        }
899    }
900
901    #[test]
902    fn recover_rejects_too_few_shards_for_a_large_threshold() {
903        let param = AvidmGf2Scheme::setup(3, 9).unwrap();
904        let (commit, shares) = AvidmGf2Scheme::disperse(&param, &[3, 3, 3], &[7u8; 100]).unwrap();
905        let forged = AvidmGf2Param::new(1 << 14, 1 << 16).unwrap();
906
907        assert!(matches!(
908            AvidmGf2Scheme::recover(&forged, &commit, &shares[..1]),
909            Err(VidError::InsufficientShares)
910        ));
911    }
912}