Skip to main content

commonware_coding/zoda/
mod.rs

1//! This module implements the [ZODA](https://eprint.iacr.org/2025/034) coding scheme.
2//!
3//! At a high level, the scheme works like any other coding scheme: you start with
4//! a piece of data, and split it into shards, and a commitment. Each shard can
5//! be checked to belong to the commitment, and, given enough shards, the data can
6//! be reconstructed.
7//!
8//! What makes ZODA interesting is that upon receiving and checking one shard,
9//! you become convinced that there exists an original piece of data that will
10//! be reconstructable given enough shards. This fails in the case of, e.g.,
11//! plain Reed-Solomon coding. For example, if you give people random shards,
12//! instead of actually encoding data, then when they attempt to reconstruct the
13//! data, they can come to different results depending on which shards they use.
14//!
15//! Ultimately, this stems from the fact that you can't know if your shard comes
16//! from a valid encoding of the data until you have enough shards to reconstruct
17//! the data. With ZODA, you know that the shard comes from a valid encoding as
18//! soon as you've checked it.
19//!
20//! # Variant
21//!
22//! ZODA supports different configurations based on the coding scheme you use
23//! for sharding data, and for checking it.
24//!
25//! We use the Reed-Solomon and Hadamard variant of ZODA: in essence, this means
26//! that the shards are Reed-Solomon encoded, and we include additional checksum
27//! data which does not help reconstruct the data.
28//!
29//! ## Deviations
30//!
31//! In the paper, a sample consists of rows chosen at random from the encoding of
32//! the data. With multiple participants receiving samples, they might receive
33//! overlapping samples, which we don't want. Instead, we shuffle the rows of
34//! the encoded data, and each participant receives a different segment.
35//! From that participant's perspective, they've received a completely random
36//! choice of rows. The other participants' rows are less random, since they're
37//! guaranteed to not overlap. However, no guarantee on the randomness of the other
38//! rows is required: each sample is large enough to guarantee that the data
39//! has been validly encoded.
40//!
41//! We also use a Fiat-Shamir transform to make all randomness sampled
42//! non-interactively, based on the commitment to the encoded data.
43//!
44//! # Protocol
45//!
46//! Let n denote the minimum number of shards needed to recover the data.
47//! Let k denote the number of extra shards to generate.
48//!
49//! We consider the data as being an array of elements in a field F, of 64 bits.
50//!
51//! Given n and k, we have a certain number of required samples R.
52//! We can split these into row samples S, and column samples S',
53//! such that S * S' = R.
54//!
55//! Given a choice of S, our data will need to be arranged into a matrix of size
56//!
57//!   n S x c
58//!
59//! with c being >= 1.
60//!
61//! We choose S as close to R as possible without padding the data. We then
62//! choose S' so that S * S' >= R.
63//!
64//! We also then double S', because the field over which we compute checksums
65//! only has 64 bits. This effectively makes the checksum calculated over the
66//! extension field F^2. Because we don't actually need to multiply elements
67//! in F^2 together, but only ever take linear combinations with elements in F,
68//! we can effectively compute over the larger field simply by using 2 "virtual"
69//! checksum columns per required column.
70//!
71//! For technical reasons, the encoded data will have not have (n + k) S rows,
72//! but pad((n + k) S) rows, where pad returns the next power of two.
73//! This is to our advantage, in that given n shards, we will be able to reconstruct
74//! the data, but these shards consists of rows sampled at random from
75//! pad((n + k) S) rows, thus requiring fewer samples.
76//!
77//! ## Encoding
78//!
79//! 1. The data is arranged as a matrix X of size n S x c.
80//! 2. The data is Reed-Solomon encoded, turning it into a matrix X' of size pad((n + k) S) x c.
81//! 3. The rows of X' are committed to using a vector commitment V (concretely, a Merkle Tree).
82//! 4. V, along with the size of the data, in bytes, are committed to, producing Com.
83//! 5. Com is hashed to create randomness, first to generate a matrix H of size c x S',
84//!    and then to shuffle the rows of X'.
85//! 6. Z := X H, a matrix of size n S x S' is computed.
86//! 7. The ith shard (starting from 0) then consists of:
87//!    - the size of the data, in bytes,
88//!    - the vector commitment, V,
89//!    - the checksum Z,
90//!    - rows i * S..(i + 1) * S of Y, along with a proof of inclusion in V, at the original index.
91//!
92//! ## Weakening
93//!
94//! When transmitting a weak shard to other people, only the following are transmitted:
95//! - rows i * S..(i + 1) * S of Y, along with the inclusion proofs.
96//!
97//! ## Checking
98//!
99//! Let A_{S} denote the matrix formed by taking the rows in a given subset S.
100//!
101//! 1. Check that Com is the hash of V and the size of the data, in bytes.
102//! 2. Use Com to compute H of size c x S', and figure recompute the ith row sample S_i.
103//! 3. Check that Z is of size n S x S'.
104//! 4. Encode Z to get Z', a matrix of size pad((n + k) S) x S'.
105//!
106//! These steps now depend on the particular shard.
107//!
108//! 5. Check that X'_{S_i} (the shard's data) is a matrix of size S x c.
109//! 6. Use the inclusion proofs to check that each row of X'_{S_i} is included in V,
110//!    at the correct index.
111//! 7. Check that X'_{S_i} H = Z'_{S_i}
112//!
113//! ## Decoding
114//!
115//! 1. Given n checked shards, you have n S encoded rows, which can be Reed-Solomon decoded.
116
117use crate::{Config, PhasedScheme, ValidatingScheme};
118use bytes::BufMut;
119use commonware_codec::{Encode, EncodeSize, FixedSize, RangeCfg, Read, ReadExt, Write};
120use commonware_cryptography::{
121    Digest, Hasher,
122    transcript::{Summary, Transcript, Version},
123};
124use commonware_math::{
125    fields::goldilocks::F,
126    ntt::{EvaluationVector, Matrix},
127};
128use commonware_parallel::Strategy;
129use commonware_storage::bmt::{Builder as BmtBuilder, Error as BmtError, Proof};
130use std::{marker::PhantomData, sync::Arc};
131use thiserror::Error;
132
133/// Create an iterator over the data of a buffer, interpreted as little-endian u64s.
134fn iter_u64_le(data: impl bytes::Buf) -> impl Iterator<Item = u64> {
135    struct Iter<B> {
136        remaining_u64s: usize,
137        tail: usize,
138        inner: B,
139    }
140
141    impl<B: bytes::Buf> Iter<B> {
142        fn new(inner: B) -> Self {
143            let remaining_u64s = inner.remaining() / 8;
144            let tail = inner.remaining() % 8;
145            Self {
146                remaining_u64s,
147                tail,
148                inner,
149            }
150        }
151    }
152
153    impl<B: bytes::Buf> Iterator for Iter<B> {
154        type Item = u64;
155
156        fn next(&mut self) -> Option<Self::Item> {
157            if self.remaining_u64s > 0 {
158                self.remaining_u64s -= 1;
159                return Some(self.inner.get_u64_le());
160            }
161            if self.tail > 0 {
162                let mut chunk = [0u8; 8];
163                self.inner.copy_to_slice(&mut chunk[..self.tail]);
164                self.tail = 0;
165                return Some(u64::from_le_bytes(chunk));
166            }
167            None
168        }
169    }
170    Iter::new(data)
171}
172
173fn collect_u64_le(max_length: usize, data: impl Iterator<Item = u64>) -> Vec<u8> {
174    let mut out = Vec::with_capacity(max_length);
175    for d in data {
176        out.extend_from_slice(&d.to_le_bytes());
177    }
178    out.truncate(max_length);
179    out
180}
181
182fn row_digest<H: Hasher>(row: &[F]) -> H::Digest {
183    let mut h = H::default();
184    for x in row {
185        h.update(&x.to_le_bytes());
186    }
187    let (_, digest) = h.finalize();
188    digest
189}
190
191mod topology;
192use topology::Topology;
193
194/// A shard of data produced by the encoding scheme.
195#[derive(Clone, Debug)]
196pub struct StrongShard<D: Digest> {
197    data_bytes: usize,
198    root: D,
199    inclusion_proof: Proof<D>,
200    rows: Matrix<F>,
201    checksum: Arc<Matrix<F>>,
202}
203
204impl<D: Digest> PartialEq for StrongShard<D> {
205    fn eq(&self, other: &Self) -> bool {
206        self.data_bytes == other.data_bytes
207            && self.root == other.root
208            && self.inclusion_proof == other.inclusion_proof
209            && self.rows == other.rows
210            && self.checksum == other.checksum
211    }
212}
213
214impl<D: Digest> Eq for StrongShard<D> {}
215
216impl<D: Digest> EncodeSize for StrongShard<D> {
217    fn encode_size(&self) -> usize {
218        self.data_bytes.encode_size()
219            + self.root.encode_size()
220            + self.inclusion_proof.encode_size()
221            + self.rows.encode_size()
222            + self.checksum.encode_size()
223    }
224}
225
226impl<D: Digest> Write for StrongShard<D> {
227    fn write(&self, buf: &mut impl BufMut) {
228        self.data_bytes.write(buf);
229        self.root.write(buf);
230        self.inclusion_proof.write(buf);
231        self.rows.write(buf);
232        self.checksum.write(buf);
233    }
234}
235
236impl<D: Digest> Read for StrongShard<D> {
237    type Cfg = crate::CodecConfig;
238
239    fn read_cfg(
240        buf: &mut impl bytes::Buf,
241        cfg: &Self::Cfg,
242    ) -> Result<Self, commonware_codec::Error> {
243        let data_bytes = usize::read_cfg(buf, &RangeCfg::from(..=cfg.maximum_shard_size))?;
244        let max_els = cfg.maximum_shard_size / F::SIZE;
245        Ok(Self {
246            data_bytes,
247            root: ReadExt::read(buf)?,
248            inclusion_proof: Read::read_cfg(buf, &max_els)?,
249            rows: Read::read_cfg(buf, &(max_els, ()))?,
250            checksum: Arc::new(Read::read_cfg(buf, &(max_els, ()))?),
251        })
252    }
253}
254
255#[cfg(feature = "arbitrary")]
256impl<D: Digest> arbitrary::Arbitrary<'_> for StrongShard<D>
257where
258    D: for<'a> arbitrary::Arbitrary<'a>,
259{
260    fn arbitrary(u: &mut arbitrary::Unstructured<'_>) -> arbitrary::Result<Self> {
261        Ok(Self {
262            data_bytes: u.arbitrary::<u32>()? as usize,
263            root: u.arbitrary()?,
264            inclusion_proof: u.arbitrary()?,
265            rows: u.arbitrary()?,
266            checksum: Arc::new(u.arbitrary()?),
267        })
268    }
269}
270
271#[derive(Clone, Debug)]
272pub struct WeakShard<D: Digest> {
273    inclusion_proof: Proof<D>,
274    shard: Matrix<F>,
275}
276
277impl<D: Digest> PartialEq for WeakShard<D> {
278    fn eq(&self, other: &Self) -> bool {
279        self.inclusion_proof == other.inclusion_proof && self.shard == other.shard
280    }
281}
282
283impl<D: Digest> Eq for WeakShard<D> {}
284
285impl<D: Digest> EncodeSize for WeakShard<D> {
286    fn encode_size(&self) -> usize {
287        self.inclusion_proof.encode_size() + self.shard.encode_size()
288    }
289}
290
291impl<D: Digest> Write for WeakShard<D> {
292    fn write(&self, buf: &mut impl BufMut) {
293        self.inclusion_proof.write(buf);
294        self.shard.write(buf);
295    }
296}
297
298impl<D: Digest> Read for WeakShard<D> {
299    type Cfg = crate::CodecConfig;
300
301    fn read_cfg(
302        buf: &mut impl bytes::Buf,
303        cfg: &Self::Cfg,
304    ) -> Result<Self, commonware_codec::Error> {
305        let max_data_bits = cfg.maximum_shard_size.saturating_mul(8);
306        let max_data_els = F::bits_to_elements(max_data_bits).max(1);
307        Ok(Self {
308            // Worst case: every row is one data element, and the sample size is all rows.
309            inclusion_proof: Read::read_cfg(buf, &max_data_els)?,
310            shard: Read::read_cfg(buf, &(max_data_els, ()))?,
311        })
312    }
313}
314
315#[cfg(feature = "arbitrary")]
316impl<D: Digest> arbitrary::Arbitrary<'_> for WeakShard<D>
317where
318    D: for<'a> arbitrary::Arbitrary<'a>,
319{
320    fn arbitrary(u: &mut arbitrary::Unstructured<'_>) -> arbitrary::Result<Self> {
321        Ok(Self {
322            inclusion_proof: u.arbitrary()?,
323            shard: u.arbitrary()?,
324        })
325    }
326}
327
328/// A ZODA shard that has been checked for integrity already.
329#[derive(Clone)]
330pub struct CheckedShard {
331    index: usize,
332    shard: Matrix<F>,
333    commitment: Summary,
334}
335
336/// Take indices up to `total`, and shuffle them.
337///
338/// The shuffle depends, deterministically, on the transcript.
339///
340/// # Panics
341///
342/// Panics if `total` exceeds `u32::MAX`.
343fn shuffle_indices(transcript: &Transcript, total: usize) -> Vec<u32> {
344    let total: u32 = total
345        .try_into()
346        .expect("encoded_rows exceeds u32::MAX; data too large for ZODA");
347    let mut out = (0..total).collect::<Vec<_>>();
348    transcript.shuffle(b"shuffle", &mut out);
349    out
350}
351
352/// Create a checking matrix of the right shape.
353///
354/// This matrix is random, using the transcript as a deterministic source of randomness.
355fn checking_matrix(transcript: &Transcript, topology: &Topology) -> Matrix<F> {
356    Matrix::rand(
357        transcript.noise(b"checking matrix"),
358        topology.data_cols,
359        topology.column_samples,
360    )
361}
362
363/// Data used to check [WeakShard]s.
364#[derive(Clone, PartialEq)]
365pub struct CheckingData<D: Digest> {
366    commitment: Summary,
367    topology: Topology,
368    root: D,
369    checking_matrix: Matrix<F>,
370    encoded_checksum: Matrix<F>,
371    shuffled_indices: Vec<u32>,
372}
373
374impl<D: Digest> Eq for CheckingData<D> {}
375
376impl<D: Digest> CheckingData<D> {
377    /// Calculate the values of this struct, based on information received.
378    ///
379    /// We control `config`.
380    ///
381    /// We're provided with `commitment`, which should hash over `root`,
382    /// and `data_bytes`.
383    ///
384    /// We're also give a `checksum` matrix used to check the shards we receive.
385    fn reckon(
386        namespace: &[u8],
387        config: &Config,
388        commitment: &Summary,
389        data_bytes: usize,
390        root: D,
391        checksum: &Matrix<F>,
392    ) -> Result<Self, Error> {
393        let topology = Topology::reckon(config, data_bytes);
394        let mut transcript = Transcript::new(NAMESPACE, Version::V1);
395        transcript.commit(namespace);
396        transcript.commit((topology.data_bytes as u64).encode());
397        transcript.commit(root.encode());
398        let expected_commitment = transcript.summarize();
399        if *commitment != expected_commitment {
400            return Err(Error::InvalidShard);
401        }
402        let mut transcript = Transcript::resume(expected_commitment, Version::V1);
403        let checking_matrix = checking_matrix(&transcript, &topology);
404        if checksum.rows() != topology.data_rows || checksum.cols() != topology.column_samples {
405            return Err(Error::InvalidShard);
406        }
407        // Commit to the checksum before generating the indices to check.
408        //
409        // Nota bene: `checksum.encode()` is *serializing* the checksum, not
410        // Reed-Solomon encoding it.
411        //
412        // cf. the implementation of `Scheme::encode` for ZODA for why it's important
413        // that we do Reed-Solomon encoding of the checksum ourselves.
414        transcript.commit(checksum.encode());
415        let encoded_checksum = checksum
416            .as_polynomials(topology.encoded_rows)
417            .expect("checksum has too many rows")
418            .evaluate()
419            .data();
420        let shuffled_indices = shuffle_indices(&transcript, topology.encoded_rows);
421
422        Ok(Self {
423            commitment: expected_commitment,
424            topology,
425            root,
426            checking_matrix,
427            encoded_checksum,
428            shuffled_indices,
429        })
430    }
431
432    fn check<H: Hasher<Digest = D>>(
433        &self,
434        commitment: &Summary,
435        index: u16,
436        weak_shard: &WeakShard<D>,
437    ) -> Result<CheckedShard, Error> {
438        if self.commitment != *commitment {
439            return Err(Error::InvalidShard);
440        }
441        self.topology.check_index(index)?;
442        if weak_shard.shard.rows() != self.topology.samples
443            || weak_shard.shard.cols() != self.topology.data_cols
444        {
445            return Err(Error::InvalidWeakShard);
446        }
447        let index = index as usize;
448        let these_shuffled_indices = &self.shuffled_indices
449            [index * self.topology.samples..(index + 1) * self.topology.samples];
450
451        // Build elements for BMT multi-proof verification using the deterministically
452        // computed indices for this shard
453        let proof_elements: Vec<(H::Digest, u32)> = these_shuffled_indices
454            .iter()
455            .zip(weak_shard.shard.iter())
456            .map(|(&i, row)| (row_digest::<H>(row), i))
457            .collect();
458
459        // Verify the multi-proof
460        if weak_shard
461            .inclusion_proof
462            .verify_multi_inclusion::<H>(&proof_elements, &self.root)
463            .is_err()
464        {
465            return Err(Error::InvalidWeakShard);
466        }
467
468        let shard_checksum = weak_shard.shard.mul(&self.checking_matrix);
469        // Check that the shard checksum rows match the encoded checksums
470        for (row, &i) in shard_checksum.iter().zip(these_shuffled_indices) {
471            if row != &self.encoded_checksum[i as usize] {
472                return Err(Error::InvalidWeakShard);
473            }
474        }
475        Ok(CheckedShard {
476            index,
477            shard: weak_shard.shard.clone(),
478            commitment: *commitment,
479        })
480    }
481}
482
483#[derive(Debug, Error)]
484pub enum Error {
485    #[error("invalid shard")]
486    InvalidShard,
487    #[error("invalid weak shard")]
488    InvalidWeakShard,
489    #[error("invalid index {0}")]
490    InvalidIndex(u16),
491    #[error("insufficient shards {0} < {1}")]
492    InsufficientShards(usize, usize),
493    #[error("insufficient unique rows {0} < {1}")]
494    InsufficientUniqueRows(usize, usize),
495    #[error("failed to create inclusion proof: {0}")]
496    FailedToCreateInclusionProof(BmtError),
497}
498
499const NAMESPACE: &[u8] = b"_COMMONWARE_CODING_ZODA";
500
501pub struct Zoda<H> {
502    _marker: PhantomData<H>,
503}
504
505impl<H> Clone for Zoda<H> {
506    fn clone(&self) -> Self {
507        *self
508    }
509}
510
511impl<H> Copy for Zoda<H> {}
512
513impl<H> std::fmt::Debug for Zoda<H> {
514    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
515        write!(f, "Zoda")
516    }
517}
518
519impl<H: Hasher> PhasedScheme for Zoda<H> {
520    type Commitment = Summary;
521    type StrongShard = StrongShard<H::Digest>;
522    type WeakShard = WeakShard<H::Digest>;
523    type CheckingData = CheckingData<H::Digest>;
524    type CheckedShard = CheckedShard;
525    type Error = Error;
526
527    fn encode(
528        namespace: &[u8],
529        config: &Config,
530        data: impl bytes::Buf,
531        strategy: &impl Strategy,
532    ) -> Result<(Self::Commitment, Vec<Self::StrongShard>), Self::Error> {
533        // Step 1: arrange the data as a matrix.
534        let data_bytes = data.remaining();
535        let topology = Topology::reckon(config, data_bytes);
536        let data = Matrix::init(
537            topology.data_rows,
538            topology.data_cols,
539            F::stream_from_u64s(iter_u64_le(data)),
540        );
541
542        // Step 2: Encode the data.
543        let encoded_data = data
544            .as_polynomials(topology.encoded_rows)
545            .expect("data has too many rows")
546            .evaluate()
547            .data();
548
549        // Step 3: Commit to the rows of the data using a Binary Merkle Tree.
550        let row_hashes: Vec<H::Digest> = strategy.map_collect_vec(0..encoded_data.rows(), |i| {
551            row_digest::<H>(&encoded_data[i])
552        });
553        let mut bmt_builder = BmtBuilder::<H>::new(row_hashes.len());
554        for hash in &row_hashes {
555            bmt_builder.add(hash);
556        }
557        let bmt = bmt_builder.build();
558        let root = bmt.root();
559
560        // Step 4: Commit to the root, and the size of the data.
561        let mut transcript = Transcript::new(NAMESPACE, Version::V1);
562        transcript.commit(namespace);
563        transcript.commit((topology.data_bytes as u64).encode());
564        transcript.commit(root.encode());
565        let commitment = transcript.summarize();
566
567        // Step 5: Generate a checking matrix and checksum with the commitment.
568        let mut transcript = Transcript::resume(commitment, Version::V1);
569        let checking_matrix = checking_matrix(&transcript, &topology);
570        let checksum = Arc::new(data.mul(&checking_matrix));
571        // Bind index sampling to this checksum to prevent follower-specific malleability.
572        // It's important to commit to the checksum itself, rather than its encoding,
573        // because followers have to encode the checksum itself to prevent the leader from
574        // cheating.
575        transcript.commit(checksum.encode());
576        let shuffled_indices = shuffle_indices(&transcript, encoded_data.rows());
577
578        // Step 6: Produce the shards in parallel.
579        let shards = strategy.try_map_collect_vec(0..topology.total_shards, |shard_idx| {
580            let indices =
581                &shuffled_indices[shard_idx * topology.samples..(shard_idx + 1) * topology.samples];
582            let rows = Matrix::init(
583                indices.len(),
584                topology.data_cols,
585                indices
586                    .iter()
587                    .flat_map(|&i| encoded_data[i as usize].iter().copied()),
588            );
589            let inclusion_proof = bmt
590                .multi_proof(indices)
591                .map_err(Error::FailedToCreateInclusionProof)?;
592            Ok(StrongShard {
593                data_bytes,
594                root,
595                inclusion_proof,
596                rows,
597                checksum: checksum.clone(),
598            })
599        })?;
600        Ok((commitment, shards))
601    }
602
603    fn weaken(
604        namespace: &[u8],
605        config: &Config,
606        commitment: &Self::Commitment,
607        index: u16,
608        shard: Self::StrongShard,
609    ) -> Result<(Self::CheckingData, Self::CheckedShard, Self::WeakShard), Self::Error> {
610        let weak_shard = WeakShard {
611            inclusion_proof: shard.inclusion_proof,
612            shard: shard.rows,
613        };
614        let checking_data = CheckingData::reckon(
615            namespace,
616            config,
617            commitment,
618            shard.data_bytes,
619            shard.root,
620            shard.checksum.as_ref(),
621        )?;
622        let checked_shard = checking_data.check::<H>(commitment, index, &weak_shard)?;
623        Ok((checking_data, checked_shard, weak_shard))
624    }
625
626    fn check(
627        _config: &Config,
628        commitment: &Self::Commitment,
629        checking_data: &Self::CheckingData,
630        index: u16,
631        weak_shard: Self::WeakShard,
632    ) -> Result<Self::CheckedShard, Self::Error> {
633        checking_data.check::<H>(commitment, index, &weak_shard)
634    }
635
636    fn decode<'a>(
637        _config: &Config,
638        commitment: &Self::Commitment,
639        checking_data: Self::CheckingData,
640        shards: impl Iterator<Item = &'a Self::CheckedShard>,
641        _strategy: &impl Strategy,
642    ) -> Result<Vec<u8>, Self::Error> {
643        if checking_data.commitment != *commitment {
644            return Err(Error::InvalidShard);
645        }
646
647        let Topology {
648            encoded_rows,
649            data_cols,
650            samples,
651            data_rows,
652            data_bytes,
653            min_shards,
654            ..
655        } = checking_data.topology;
656        let mut evaluation = EvaluationVector::<F>::empty(encoded_rows.ilog2() as usize, data_cols);
657        let mut shard_count = 0usize;
658        for shard in shards {
659            shard_count += 1;
660            if shard.commitment != *commitment {
661                return Err(Error::InvalidShard);
662            }
663            let indices =
664                &checking_data.shuffled_indices[shard.index * samples..(shard.index + 1) * samples];
665            for (&i, row) in indices.iter().zip(shard.shard.iter()) {
666                evaluation.fill_row(u64::from(i) as usize, row);
667            }
668        }
669        if shard_count < min_shards {
670            return Err(Error::InsufficientShards(shard_count, min_shards));
671        }
672        // This should never happen, because we check each shard, and the shards
673        // should have distinct rows. But, as a sanity check, this doesn't hurt.
674        let filled_rows = evaluation.filled_rows();
675        if filled_rows < data_rows {
676            return Err(Error::InsufficientUniqueRows(filled_rows, data_rows));
677        }
678        Ok(collect_u64_le(
679            data_bytes,
680            F::stream_to_u64s(
681                evaluation
682                    .recover()
683                    .coefficients_up_to(data_rows)
684                    .flatten()
685                    .copied(),
686            ),
687        ))
688    }
689}
690
691impl<H: Hasher> ValidatingScheme for Zoda<H> {}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696    use crate::{Config, PhasedScheme};
697    use commonware_cryptography::Sha256;
698    use commonware_math::{
699        algebra::{FieldNTT as _, Ring as _},
700        ntt::PolynomialVector,
701    };
702    use commonware_parallel::Sequential;
703    use commonware_utils::NZU16;
704
705    const STRATEGY: Sequential = Sequential;
706
707    #[test]
708    fn decode_rejects_duplicate_indices() {
709        let config = Config {
710            minimum_shards: NZU16!(2),
711            extra_shards: NZU16!(1),
712        };
713        let data = b"duplicate shard coverage";
714        let (commitment, shards) =
715            Zoda::<Sha256>::encode(b"", &config, &data[..], &STRATEGY).unwrap();
716        let shard0 = shards[0].clone();
717        let (checking_data, checked_shard0, _weak_shard0) =
718            Zoda::<Sha256>::weaken(b"", &config, &commitment, 0, shard0).unwrap();
719        let duplicate = CheckedShard {
720            index: checked_shard0.index,
721            shard: checked_shard0.shard.clone(),
722            commitment: checked_shard0.commitment,
723        };
724        let shards = [checked_shard0, duplicate];
725        let result = Zoda::<Sha256>::decode(
726            &config,
727            &commitment,
728            checking_data,
729            shards.iter(),
730            &STRATEGY,
731        );
732        match result {
733            Err(Error::InsufficientUniqueRows(actual, expected)) => {
734                assert!(actual < expected);
735            }
736            other => panic!("expected insufficient unique rows error, got {other:?}"),
737        }
738    }
739
740    #[test]
741    fn checksum_malleability() {
742        /// Construct the vanishing polynomial over specific indices.
743        ///
744        /// When encoded, this will be 0 at those indices, and non-zero elsewhere.
745        fn vanishing(lg_domain: u8, vanish_indices: &[u32]) -> PolynomialVector<F> {
746            let w = F::root_of_unity(lg_domain).expect("domain too large for Goldilocks");
747            let mut domain = Vec::with_capacity(1usize << lg_domain);
748            let mut x = F::one();
749            for _ in 0..(1usize << lg_domain) {
750                domain.push(x);
751                x *= &w;
752            }
753            let roots: Vec<F> = vanish_indices.iter().map(|&i| domain[i as usize]).collect();
754            let mut out = EvaluationVector::empty(lg_domain as usize, 1);
755            domain.into_iter().enumerate().for_each(|(i, x)| {
756                let mut acc = F::one();
757                for root in &roots {
758                    acc *= &(x - root);
759                }
760                out.fill_row(i, &[acc]);
761            });
762            out.recover()
763        }
764
765        let config = Config {
766            minimum_shards: NZU16!(2),
767            extra_shards: NZU16!(1),
768        };
769        let data = vec![0x5Au8; 256 * 1024];
770        let (commitment, mut shards) =
771            Zoda::<Sha256>::encode(b"", &config, &data[..], &STRATEGY).unwrap();
772
773        let leader_i = 0usize;
774        let a_i = 1usize;
775        let b_i = 2usize;
776
777        // Apply a shift to the checksums
778        {
779            let (checking_data, _, _) = Zoda::<Sha256>::weaken(
780                b"",
781                &config,
782                &commitment,
783                leader_i as u16,
784                shards[leader_i].clone(),
785            )
786            .unwrap();
787
788            let samples = checking_data.topology.samples;
789            let a_indices =
790                checking_data.shuffled_indices[a_i * samples..(a_i + 1) * samples].to_vec();
791            let lg_rows = checking_data.topology.encoded_rows.ilog2() as usize;
792            let shift = vanishing(lg_rows as u8, &a_indices);
793            let mut checksum = (*shards[1].checksum).clone();
794            for (i, shift_i) in shift.coefficients_up_to(checksum.rows()).enumerate() {
795                for j in 0..checksum.cols() {
796                    checksum[(i, j)] += &shift_i[0];
797                }
798            }
799            shards[1].checksum = Arc::new(checksum);
800            shards[2].checksum = shards[1].checksum.clone();
801        }
802
803        assert!(matches!(
804            Zoda::<Sha256>::weaken(b"", &config, &commitment, b_i as u16, shards[b_i].clone()),
805            Err(Error::InvalidWeakShard)
806        ));
807
808        // Without robust Fiat-Shamir, this will succeed.
809        // This should be rejected once follower-specific challenge binding is fixed.
810        assert!(matches!(
811            Zoda::<Sha256>::weaken(b"", &config, &commitment, a_i as u16, shards[a_i].clone()),
812            Err(Error::InvalidWeakShard)
813        ));
814    }
815
816    #[cfg(feature = "arbitrary")]
817    mod conformance {
818        use super::*;
819        use commonware_codec::conformance::CodecConformance;
820        use commonware_conformance::Conformance;
821        use commonware_cryptography::sha256::Digest as Sha256Digest;
822
823        struct EncodeCheck;
824
825        impl Conformance for EncodeCheck {
826            async fn commit(seed: u64) -> Vec<u8> {
827                let config = Config {
828                    minimum_shards: NZU16!(2),
829                    extra_shards: NZU16!(1),
830                };
831                let data: Vec<_> = (0..seed as usize % 768)
832                    .map(|i| (seed as u8).wrapping_add(i as u8))
833                    .collect();
834
835                let (commitment, shards) =
836                    Zoda::<Sha256>::encode(b"conformance", &config, &data[..], &STRATEGY).unwrap();
837
838                let mut log = commitment.encode().to_vec();
839                for (i, shard) in shards.into_iter().enumerate() {
840                    let index: u16 = i.try_into().unwrap();
841                    let (checking_data, _, weak_shard) =
842                        Zoda::<Sha256>::weaken(b"conformance", &config, &commitment, index, shard)
843                            .unwrap();
844                    let checked_shard = Zoda::<Sha256>::check(
845                        &config,
846                        &commitment,
847                        &checking_data,
848                        index,
849                        weak_shard.clone(),
850                    )
851                    .unwrap();
852
853                    log.extend(index.encode());
854                    log.extend(weak_shard.encode());
855                    log.extend(checked_shard.shard.encode());
856                    log.extend(checked_shard.commitment.encode());
857                }
858                log
859            }
860        }
861
862        commonware_conformance::conformance_tests! {
863            EncodeCheck => 256,
864            CodecConformance<StrongShard<Sha256Digest>>,
865            CodecConformance<WeakShard<Sha256Digest>>,
866        }
867    }
868}