tlsn_core/transcript/
proof.rs

1//! Transcript proofs.
2
3use rangeset::{
4    iter::{FromRangeIterator, IntoRangeIterator, RangeIterator},
5    ops::{Cover, Set},
6};
7use serde::{Deserialize, Serialize};
8use std::{collections::HashSet, fmt};
9
10use crate::{
11    connection::TranscriptLength,
12    display::FmtRangeSet,
13    hash::{HashAlgId, HashProvider},
14    transcript::{
15        commit::{TranscriptCommitment, TranscriptCommitmentKind},
16        hash::{hash_plaintext, PlaintextHash, PlaintextHashSecret},
17        Direction, PartialTranscript, RangeSet, Transcript, TranscriptSecret,
18    },
19};
20
21/// Default commitment kinds in order of preference for building transcript
22/// proofs.
23const DEFAULT_COMMITMENT_KINDS: &[TranscriptCommitmentKind] = &[
24    TranscriptCommitmentKind::Hash {
25        alg: HashAlgId::SHA256,
26    },
27    TranscriptCommitmentKind::Hash {
28        alg: HashAlgId::BLAKE3,
29    },
30    TranscriptCommitmentKind::Hash {
31        alg: HashAlgId::KECCAK256,
32    },
33];
34
35/// Proof of the contents of a transcript.
36#[derive(Clone, Serialize, Deserialize)]
37pub struct TranscriptProof {
38    transcript: PartialTranscript,
39    hash_secrets: Vec<PlaintextHashSecret>,
40}
41
42opaque_debug::implement!(TranscriptProof);
43
44impl TranscriptProof {
45    /// Verifies the proof.
46    ///
47    /// Returns a partial transcript of authenticated data.
48    ///
49    /// # Arguments
50    ///
51    /// * `provider` - The hash provider to use for verification.
52    /// * `length` - The transcript length.
53    /// * `commitments` - The commitments to verify against.
54    pub fn verify_with_provider<'a>(
55        self,
56        provider: &HashProvider,
57        length: &TranscriptLength,
58        commitments: impl IntoIterator<Item = &'a TranscriptCommitment>,
59    ) -> Result<PartialTranscript, TranscriptProofError> {
60        let mut hash_commitments = HashSet::new();
61        // Index commitments.
62        for commitment in commitments {
63            match commitment {
64                TranscriptCommitment::Hash(plaintext_hash) => {
65                    hash_commitments.insert(plaintext_hash);
66                }
67            }
68        }
69
70        if self.transcript.sent_unsafe().len() != length.sent as usize
71            || self.transcript.received_unsafe().len() != length.received as usize
72        {
73            return Err(TranscriptProofError::new(
74                ErrorKind::Proof,
75                "transcript has incorrect length",
76            ));
77        }
78
79        let mut total_auth_sent = RangeSet::default();
80        let mut total_auth_recv = RangeSet::default();
81
82        let mut buffer = Vec::new();
83        for PlaintextHashSecret {
84            direction,
85            idx,
86            alg,
87            blinder,
88        } in self.hash_secrets
89        {
90            let hasher = provider.get(&alg).map_err(|_| {
91                TranscriptProofError::new(
92                    ErrorKind::Hash,
93                    format!("hash opening has unknown algorithm: {alg}"),
94                )
95            })?;
96
97            let (plaintext, auth) = match direction {
98                Direction::Sent => (self.transcript.sent_unsafe(), &mut total_auth_sent),
99                Direction::Received => (self.transcript.received_unsafe(), &mut total_auth_recv),
100            };
101
102            if idx.end().unwrap_or(0) > plaintext.len() {
103                return Err(TranscriptProofError::new(
104                    ErrorKind::Hash,
105                    "hash opening index is out of bounds",
106                ));
107            }
108
109            buffer.clear();
110            for range in idx.iter() {
111                buffer.extend_from_slice(&plaintext[range]);
112            }
113
114            let expected = PlaintextHash {
115                direction,
116                idx,
117                hash: hash_plaintext(hasher, &buffer, &blinder),
118            };
119
120            if !hash_commitments.contains(&expected) {
121                return Err(TranscriptProofError::new(
122                    ErrorKind::Hash,
123                    "hash opening does not match any commitment",
124                ));
125            }
126
127            auth.union_mut(&expected.idx);
128        }
129
130        // Assert that all the authenticated data are covered by the proof.
131        if &total_auth_sent != self.transcript.sent_authed()
132            || &total_auth_recv != self.transcript.received_authed()
133        {
134            return Err(TranscriptProofError::new(
135                ErrorKind::Proof,
136                "transcript proof contains unauthenticated data",
137            ));
138        }
139
140        Ok(self.transcript)
141    }
142}
143
144/// Error for [`TranscriptProof`].
145#[derive(Debug, thiserror::Error)]
146pub struct TranscriptProofError {
147    kind: ErrorKind,
148    source: Option<Box<dyn std::error::Error + Send + Sync>>,
149}
150
151impl TranscriptProofError {
152    fn new<E>(kind: ErrorKind, source: E) -> Self
153    where
154        E: Into<Box<dyn std::error::Error + Send + Sync>>,
155    {
156        Self {
157            kind,
158            source: Some(source.into()),
159        }
160    }
161}
162
163#[derive(Debug)]
164enum ErrorKind {
165    Hash,
166    Proof,
167}
168
169impl fmt::Display for TranscriptProofError {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        f.write_str("transcript proof error: ")?;
172
173        match self.kind {
174            ErrorKind::Hash => f.write_str("hash error")?,
175            ErrorKind::Proof => f.write_str("proof error")?,
176        }
177
178        if let Some(source) = &self.source {
179            write!(f, " caused by: {source}")?;
180        }
181
182        Ok(())
183    }
184}
185
186/// Union of ranges to reveal.
187#[derive(Clone, Debug, PartialEq)]
188struct QueryIdx {
189    sent: RangeSet<usize>,
190    recv: RangeSet<usize>,
191}
192
193impl QueryIdx {
194    fn new() -> Self {
195        Self {
196            sent: RangeSet::default(),
197            recv: RangeSet::default(),
198        }
199    }
200
201    fn is_empty(&self) -> bool {
202        self.sent.is_empty() && self.recv.is_empty()
203    }
204
205    fn union(&mut self, direction: &Direction, other: &RangeSet<usize>) {
206        match direction {
207            Direction::Sent => self.sent.union_mut(other),
208            Direction::Received => self.recv.union_mut(other),
209        }
210    }
211}
212
213impl std::fmt::Display for QueryIdx {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        write!(
216            f,
217            "sent: {}, received: {}",
218            FmtRangeSet(&self.sent),
219            FmtRangeSet(&self.recv)
220        )
221    }
222}
223
224/// Builder for [`TranscriptProof`].
225#[derive(Debug)]
226pub struct TranscriptProofBuilder<'a> {
227    /// Commitment kinds in order of preference for building transcript proofs.
228    commitment_kinds: Vec<TranscriptCommitmentKind>,
229    transcript: &'a Transcript,
230    hash_secrets: Vec<&'a PlaintextHashSecret>,
231    committed_sent: RangeSet<usize>,
232    committed_recv: RangeSet<usize>,
233    query_idx: QueryIdx,
234}
235
236impl<'a> TranscriptProofBuilder<'a> {
237    /// Creates a new proof builder.
238    pub fn new(
239        transcript: &'a Transcript,
240        secrets: impl IntoIterator<Item = &'a TranscriptSecret>,
241    ) -> Self {
242        let mut committed_sent = RangeSet::default();
243        let mut committed_recv = RangeSet::default();
244
245        let mut hash_secrets = Vec::new();
246        for secret in secrets {
247            match secret {
248                TranscriptSecret::Hash(hash) => {
249                    match hash.direction {
250                        Direction::Sent => committed_sent.union_mut(&hash.idx),
251                        Direction::Received => committed_recv.union_mut(&hash.idx),
252                    }
253                    hash_secrets.push(hash);
254                }
255            }
256        }
257
258        Self {
259            commitment_kinds: DEFAULT_COMMITMENT_KINDS.to_vec(),
260            transcript,
261            hash_secrets,
262            committed_sent,
263            committed_recv,
264            query_idx: QueryIdx::new(),
265        }
266    }
267
268    /// Sets the commitment kinds in order of preference for building transcript
269    /// proofs, i.e. the first one is the most preferred.
270    pub fn commitment_kinds(&mut self, kinds: &[TranscriptCommitmentKind]) -> &mut Self {
271        if !kinds.is_empty() {
272            // Removes duplicates from `kinds` while preserving its order.
273            let mut seen = HashSet::new();
274            self.commitment_kinds = kinds
275                .iter()
276                .filter(|&kind| seen.insert(kind))
277                .cloned()
278                .collect();
279        }
280        self
281    }
282
283    /// Reveals the given ranges in the transcript.
284    ///
285    /// # Arguments
286    ///
287    /// * `ranges` - The ranges to reveal.
288    /// * `direction` - The direction of the transcript.
289    pub fn reveal(
290        &mut self,
291        ranges: impl IntoRangeIterator<usize>,
292        direction: Direction,
293    ) -> Result<&mut Self, TranscriptProofBuilderError> {
294        self.reveal_inner(RangeSet::from_range_iter(ranges), direction)
295    }
296
297    fn reveal_inner(
298        &mut self,
299        idx: RangeSet<usize>,
300        direction: Direction,
301    ) -> Result<&mut Self, TranscriptProofBuilderError> {
302        if idx.end().unwrap_or(0) > self.transcript.len_of_direction(direction) {
303            return Err(TranscriptProofBuilderError::new(
304                BuilderErrorKind::Index,
305                format!(
306                    "range is out of bounds of the transcript ({}): {} > {}",
307                    direction,
308                    idx.end().unwrap_or(0),
309                    self.transcript.len_of_direction(direction)
310                ),
311            ));
312        }
313
314        let committed = match direction {
315            Direction::Sent => &self.committed_sent,
316            Direction::Received => &self.committed_recv,
317        };
318
319        if idx.is_subset(committed) {
320            self.query_idx.union(&direction, &idx);
321        } else {
322            let missing = idx.difference(committed).into_set();
323            return Err(TranscriptProofBuilderError::new(
324                BuilderErrorKind::MissingCommitment,
325                format!(
326                    "commitment is missing for ranges in {direction} transcript: {}",
327                    FmtRangeSet(&missing)
328                ),
329            ));
330        }
331        Ok(self)
332    }
333
334    /// Reveals the given ranges in the sent transcript.
335    ///
336    /// # Arguments
337    ///
338    /// * `ranges` - The ranges to reveal.
339    pub fn reveal_sent(
340        &mut self,
341        ranges: impl IntoRangeIterator<usize>,
342    ) -> Result<&mut Self, TranscriptProofBuilderError> {
343        self.reveal_inner(RangeSet::from_range_iter(ranges), Direction::Sent)
344    }
345
346    /// Reveals the given ranges in the received transcript.
347    ///
348    /// # Arguments
349    ///
350    /// * `ranges` - The ranges to reveal.
351    pub fn reveal_recv(
352        &mut self,
353        ranges: impl IntoRangeIterator<usize>,
354    ) -> Result<&mut Self, TranscriptProofBuilderError> {
355        self.reveal_inner(RangeSet::from_range_iter(ranges), Direction::Received)
356    }
357
358    /// Builds the transcript proof.
359    pub fn build(self) -> Result<TranscriptProof, TranscriptProofBuilderError> {
360        let mut transcript_proof = TranscriptProof {
361            transcript: self
362                .transcript
363                .to_partial(self.query_idx.sent.clone(), self.query_idx.recv.clone()),
364            hash_secrets: Vec::new(),
365        };
366        let mut uncovered_query_idx = self.query_idx.clone();
367        let mut commitment_kinds_iter = self.commitment_kinds.iter();
368
369        // Tries to cover the query ranges with committed ranges.
370        while !uncovered_query_idx.is_empty() {
371            // Committed ranges of different kinds are checked in order of preference set in
372            // self.commitment_kinds.
373            if let Some(kind) = commitment_kinds_iter.next() {
374                match kind {
375                    TranscriptCommitmentKind::Hash { alg } => {
376                        let (sent_hashes, sent_uncovered) = uncovered_query_idx.sent.cover_by(
377                            self.hash_secrets.iter().filter(|hash| {
378                                hash.direction == Direction::Sent && &hash.alg == alg
379                            }),
380                            |hash| &hash.idx,
381                        );
382                        // Uncovered ranges will be checked with ranges of the next
383                        // preferred commitment kind.
384                        uncovered_query_idx.sent = sent_uncovered;
385
386                        let (recv_hashes, recv_uncovered) = uncovered_query_idx.recv.cover_by(
387                            self.hash_secrets.iter().filter(|hash| {
388                                hash.direction == Direction::Received && &hash.alg == alg
389                            }),
390                            |hash| &hash.idx,
391                        );
392                        uncovered_query_idx.recv = recv_uncovered;
393
394                        transcript_proof.hash_secrets.extend(
395                            sent_hashes
396                                .into_iter()
397                                .map(|s| PlaintextHashSecret::clone(s)),
398                        );
399                        transcript_proof.hash_secrets.extend(
400                            recv_hashes
401                                .into_iter()
402                                .map(|s| PlaintextHashSecret::clone(s)),
403                        );
404                    }
405                    #[allow(unreachable_patterns)]
406                    kind => {
407                        return Err(TranscriptProofBuilderError::new(
408                            BuilderErrorKind::NotSupported,
409                            format!("opening {kind} transcript commitments is not yet supported"),
410                        ));
411                    }
412                }
413            } else {
414                // Stops the set cover check if there are no more commitment kinds left.
415                break;
416            }
417        }
418
419        // If there are still uncovered ranges, it means that query ranges cannot be
420        // covered by committed ranges of any kind.
421        if !uncovered_query_idx.is_empty() {
422            return Err(TranscriptProofBuilderError::cover(
423                uncovered_query_idx,
424                &self.commitment_kinds,
425            ));
426        }
427
428        Ok(transcript_proof)
429    }
430}
431
432/// Error for [`TranscriptProofBuilder`].
433#[derive(Debug, thiserror::Error)]
434pub struct TranscriptProofBuilderError {
435    kind: BuilderErrorKind,
436    source: Option<Box<dyn std::error::Error + Send + Sync>>,
437}
438
439impl TranscriptProofBuilderError {
440    fn new<E>(kind: BuilderErrorKind, source: E) -> Self
441    where
442        E: Into<Box<dyn std::error::Error + Send + Sync>>,
443    {
444        Self {
445            kind,
446            source: Some(source.into()),
447        }
448    }
449
450    fn cover(uncovered: QueryIdx, kinds: &[TranscriptCommitmentKind]) -> Self {
451        Self {
452            kind: BuilderErrorKind::Cover {
453                uncovered,
454                kinds: kinds.to_vec(),
455            },
456            source: None,
457        }
458    }
459}
460
461#[derive(Debug, PartialEq)]
462enum BuilderErrorKind {
463    Index,
464    MissingCommitment,
465    Cover {
466        uncovered: QueryIdx,
467        kinds: Vec<TranscriptCommitmentKind>,
468    },
469    NotSupported,
470}
471
472impl fmt::Display for TranscriptProofBuilderError {
473    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
474        f.write_str("transcript proof builder error: ")?;
475
476        match &self.kind {
477            BuilderErrorKind::Index => f.write_str("index error")?,
478            BuilderErrorKind::MissingCommitment => f.write_str("commitment error")?,
479            BuilderErrorKind::Cover { uncovered, kinds } => f.write_str(&format!(
480                "unable to cover the following ranges in transcript using available {kinds:?} commitments: {uncovered}"
481            ))?,
482            BuilderErrorKind::NotSupported => f.write_str("not supported")?,
483        }
484
485        if let Some(source) = &self.source {
486            write!(f, " caused by: {source}")?;
487        }
488
489        Ok(())
490    }
491}
492
493#[allow(clippy::single_range_in_vec_init)]
494#[cfg(test)]
495mod tests {
496    use rand::{Rng, SeedableRng};
497    use rangeset::prelude::*;
498    use rstest::rstest;
499    use tlsn_data_fixtures::http::{request::GET_WITH_HEADER, response::OK_JSON};
500
501    use crate::hash::{Blinder, HashAlgId};
502
503    use super::*;
504
505    #[rstest]
506    fn test_reveal_range_out_of_bounds() {
507        let transcript = Transcript::new(
508            [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
509            [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
510        );
511        let mut builder = TranscriptProofBuilder::new(&transcript, &[]);
512
513        let err = builder.reveal(&(10..15), Direction::Sent).unwrap_err();
514        assert!(matches!(err.kind, BuilderErrorKind::Index));
515
516        let err = builder
517            .reveal(&(10..15), Direction::Received)
518            .err()
519            .unwrap();
520        assert!(matches!(err.kind, BuilderErrorKind::Index));
521    }
522
523    #[rstest]
524    fn test_reveal_missing_commitment() {
525        let transcript = Transcript::new(
526            [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
527            [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
528        );
529        let mut builder = TranscriptProofBuilder::new(&transcript, &[]);
530
531        let err = builder.reveal_recv(&(9..11)).unwrap_err();
532        assert!(matches!(err.kind, BuilderErrorKind::MissingCommitment));
533    }
534
535    #[rstest]
536    #[case::sha256(HashAlgId::SHA256)]
537    #[case::blake3(HashAlgId::BLAKE3)]
538    #[case::keccak256(HashAlgId::KECCAK256)]
539    fn test_reveal_with_hash_commitment(#[case] alg: HashAlgId) {
540        let mut rng = rand::rngs::StdRng::seed_from_u64(0);
541        let provider = HashProvider::default();
542        let transcript = Transcript::new(GET_WITH_HEADER, OK_JSON);
543
544        let direction = Direction::Sent;
545        let idx = RangeSet::from(0..10);
546        let blinder: Blinder = rng.random();
547        let hasher = provider.get(&alg).unwrap();
548
549        let commitment = PlaintextHash {
550            direction,
551            idx: idx.clone(),
552            hash: hash_plaintext(hasher, &transcript.sent()[0..10], &blinder),
553        };
554
555        let secret = PlaintextHashSecret {
556            direction,
557            idx: idx.clone(),
558            alg,
559            blinder,
560        };
561
562        let secrets = vec![TranscriptSecret::Hash(secret)];
563        let mut builder = TranscriptProofBuilder::new(&transcript, &secrets);
564
565        builder.reveal_sent(&(0..10)).unwrap();
566
567        let transcript_proof = builder.build().unwrap();
568
569        let partial_transcript = transcript_proof
570            .verify_with_provider(
571                &provider,
572                &transcript.length(),
573                &[TranscriptCommitment::Hash(commitment)],
574            )
575            .unwrap();
576
577        assert_eq!(
578            partial_transcript.sent_unsafe()[0..10],
579            transcript.sent()[0..10]
580        );
581    }
582
583    #[rstest]
584    #[case::sha256(HashAlgId::SHA256)]
585    #[case::blake3(HashAlgId::BLAKE3)]
586    #[case::keccak256(HashAlgId::KECCAK256)]
587    fn test_reveal_with_inconsistent_hash_commitment(#[case] alg: HashAlgId) {
588        let mut rng = rand::rngs::StdRng::seed_from_u64(0);
589        let provider = HashProvider::default();
590        let transcript = Transcript::new(GET_WITH_HEADER, OK_JSON);
591
592        let direction = Direction::Sent;
593        let idx = RangeSet::from(0..10);
594        let blinder: Blinder = rng.random();
595        let hasher = provider.get(&alg).unwrap();
596
597        let commitment = PlaintextHash {
598            direction,
599            idx: idx.clone(),
600            hash: hash_plaintext(hasher, &transcript.sent()[0..10], &blinder),
601        };
602
603        let secret = PlaintextHashSecret {
604            direction,
605            idx: idx.clone(),
606            alg,
607            // Use a different blinder to create an inconsistent commitment
608            blinder: rng.random(),
609        };
610
611        let secrets = vec![TranscriptSecret::Hash(secret)];
612        let mut builder = TranscriptProofBuilder::new(&transcript, &secrets);
613
614        builder.reveal_sent(&(0..10)).unwrap();
615
616        let transcript_proof = builder.build().unwrap();
617
618        let err = transcript_proof
619            .verify_with_provider(
620                &provider,
621                &transcript.length(),
622                &[TranscriptCommitment::Hash(commitment)],
623            )
624            .unwrap_err();
625
626        assert!(matches!(err.kind, ErrorKind::Hash));
627    }
628
629    #[rstest]
630    fn test_set_commitment_kinds_with_duplicates() {
631        let transcript = Transcript::new(GET_WITH_HEADER, OK_JSON);
632        let mut builder = TranscriptProofBuilder::new(&transcript, &[]);
633        builder.commitment_kinds(&[
634            TranscriptCommitmentKind::Hash {
635                alg: HashAlgId::SHA256,
636            },
637            TranscriptCommitmentKind::Hash {
638                alg: HashAlgId::SHA256,
639            },
640            TranscriptCommitmentKind::Hash {
641                alg: HashAlgId::SHA256,
642            },
643        ]);
644
645        assert_eq!(
646            builder.commitment_kinds,
647            vec![TranscriptCommitmentKind::Hash {
648                alg: HashAlgId::SHA256
649            },]
650        );
651    }
652
653    #[rstest]
654    #[case::reveal_all_rangesets_with_exact_set(
655        vec![RangeSet::from([0..10]), RangeSet::from([12..30]), RangeSet::from([0..5, 15..30]), RangeSet::from([70..75, 85..100])],
656        RangeSet::from([0..10, 12..30]),
657        true,
658    )]
659    #[case::reveal_all_rangesets_with_single_superset_range(
660        vec![RangeSet::from([0..1]), RangeSet::from([1..2, 8..9]), RangeSet::from([2..4, 6..8]), RangeSet::from([2..3, 6..7]), RangeSet::from([9..12])],
661        RangeSet::from([0..4, 6..9]),
662        true,
663    )]
664    #[case::reveal_all_rangesets_with_superset_range(
665        vec![RangeSet::from([0..1, 2..4]), RangeSet::from([1..3]), RangeSet::from([1..9]), RangeSet::from([2..3])],
666        RangeSet::from([0..4]),
667        true,
668    )]
669    #[case::failed_to_reveal_with_superset_range_missing_within(
670        vec![RangeSet::from([0..20, 45..56]), RangeSet::from([80..120]), RangeSet::from([50..53])],
671        RangeSet::from([0..120]),
672        false,
673    )]
674    #[case::failed_to_reveal_with_superset_range_missing_outside(
675        vec![RangeSet::from([2..20, 45..116]), RangeSet::from([20..45]), RangeSet::from([50..53])],
676        RangeSet::from([0..120]),
677        false,
678    )]
679    #[case::failed_to_reveal_with_superset_ranges_missing_outside(
680        vec![RangeSet::from([1..10]), RangeSet::from([1..20]),  RangeSet::from([15..20, 75..110])],
681        RangeSet::from([0..41, 74..100]),
682        false,
683    )]
684    #[case::failed_to_reveal_as_no_subset_range(
685        vec![RangeSet::from([2..4]), RangeSet::from([1..2]), RangeSet::from([1..9]), RangeSet::from([2..3])],
686        RangeSet::from([0..1]),
687        false,
688    )]
689    #[allow(clippy::single_range_in_vec_init)]
690    fn test_reveal_multiple_rangesets_with_one_rangeset(
691        #[case] commit_recv_rangesets: Vec<RangeSet<usize>>,
692        #[case] reveal_recv_rangeset: RangeSet<usize>,
693        #[case] success: bool,
694    ) {
695        use rand::{Rng, SeedableRng};
696
697        let mut rng = rand::rngs::StdRng::seed_from_u64(0);
698        let transcript = Transcript::new(GET_WITH_HEADER, OK_JSON);
699
700        // Create hash commitments for each rangeset
701        let mut secrets = Vec::new();
702        for rangeset in commit_recv_rangesets.iter() {
703            let blinder: crate::hash::Blinder = rng.random();
704
705            let secret = PlaintextHashSecret {
706                direction: Direction::Received,
707                idx: rangeset.clone(),
708                alg: HashAlgId::BLAKE3,
709                blinder,
710            };
711            secrets.push(TranscriptSecret::Hash(secret));
712        }
713
714        let mut builder = TranscriptProofBuilder::new(&transcript, &secrets);
715
716        if success {
717            assert!(builder.reveal_recv(&reveal_recv_rangeset).is_ok());
718        } else {
719            let err = builder.reveal_recv(&reveal_recv_rangeset).unwrap_err();
720            assert!(matches!(err.kind, BuilderErrorKind::MissingCommitment));
721        }
722    }
723
724    #[rstest]
725    #[case::cover(
726        vec![RangeSet::from([1..5, 6..10])],
727        vec![RangeSet::from([2..4, 8..10])],
728        RangeSet::from([1..5, 6..10]),
729        RangeSet::from([2..4, 8..10]),
730        RangeSet::default(),
731        RangeSet::default(),
732    )]
733    #[case::failed_to_cover_sent(
734        vec![RangeSet::from([1..5, 6..10])],
735        vec![RangeSet::from([2..4, 8..10])],
736        RangeSet::from([1..5]),
737        RangeSet::from([2..4, 8..10]),
738        RangeSet::from([1..5]),
739        RangeSet::default(),
740    )]
741    #[case::failed_to_cover_recv(
742        vec![RangeSet::from([1..5, 6..10])],
743        vec![RangeSet::from([2..4, 8..10])],
744        RangeSet::from([1..5, 6..10]),
745        RangeSet::from([2..4]),
746        RangeSet::default(),
747        RangeSet::from([2..4]),
748    )]
749    #[case::failed_to_cover_both(
750        vec![RangeSet::from([1..5, 6..10])],
751        vec![RangeSet::from([2..4, 8..10])],
752        RangeSet::from([1..5]),
753        RangeSet::from([2..4]),
754        RangeSet::from([1..5]),
755        RangeSet::from([2..4]),
756    )]
757    #[allow(clippy::single_range_in_vec_init)]
758    fn test_transcript_proof_builder(
759        #[case] commit_sent_rangesets: Vec<RangeSet<usize>>,
760        #[case] commit_recv_rangesets: Vec<RangeSet<usize>>,
761        #[case] reveal_sent_rangeset: RangeSet<usize>,
762        #[case] reveal_recv_rangeset: RangeSet<usize>,
763        #[case] uncovered_sent_rangeset: RangeSet<usize>,
764        #[case] uncovered_recv_rangeset: RangeSet<usize>,
765    ) {
766        use rand::{Rng, SeedableRng};
767
768        let mut rng = rand::rngs::StdRng::seed_from_u64(0);
769        let transcript = Transcript::new(GET_WITH_HEADER, OK_JSON);
770
771        // Create hash commitments for each rangeset
772        let mut secrets = Vec::new();
773        for rangeset in commit_sent_rangesets.iter() {
774            let blinder: crate::hash::Blinder = rng.random();
775            let secret = PlaintextHashSecret {
776                direction: Direction::Sent,
777                idx: rangeset.clone(),
778                alg: HashAlgId::BLAKE3,
779                blinder,
780            };
781            secrets.push(TranscriptSecret::Hash(secret));
782        }
783        for rangeset in commit_recv_rangesets.iter() {
784            let blinder: crate::hash::Blinder = rng.random();
785            let secret = PlaintextHashSecret {
786                direction: Direction::Received,
787                idx: rangeset.clone(),
788                alg: HashAlgId::BLAKE3,
789                blinder,
790            };
791            secrets.push(TranscriptSecret::Hash(secret));
792        }
793
794        let mut builder = TranscriptProofBuilder::new(&transcript, &secrets);
795        builder.reveal_sent(&reveal_sent_rangeset).unwrap();
796        builder.reveal_recv(&reveal_recv_rangeset).unwrap();
797
798        if uncovered_sent_rangeset.is_empty() && uncovered_recv_rangeset.is_empty() {
799            assert!(builder.build().is_ok());
800        } else {
801            let TranscriptProofBuilderError { kind, .. } = builder.build().unwrap_err();
802            match kind {
803                BuilderErrorKind::Cover { uncovered, .. } => {
804                    if !uncovered_sent_rangeset.is_empty() {
805                        assert_eq!(uncovered.sent, uncovered_sent_rangeset);
806                    }
807                    if !uncovered_recv_rangeset.is_empty() {
808                        assert_eq!(uncovered.recv, uncovered_recv_rangeset);
809                    }
810                }
811                _ => panic!("unexpected error kind: {kind:?}"),
812            }
813        }
814    }
815}