Skip to main content

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