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.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
372            // preference set in 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
383                        // next preferred commitment
384                        // kind.
385                        uncovered_query_idx.sent = sent_uncovered;
386
387                        let (recv_hashes, recv_uncovered) = uncovered_query_idx.recv.cover_by(
388                            self.hash_secrets.iter().filter(|hash| {
389                                hash.direction == Direction::Received && &hash.alg == alg
390                            }),
391                            |hash| &hash.idx,
392                        );
393                        uncovered_query_idx.recv = recv_uncovered;
394
395                        transcript_proof.hash_secrets.extend(
396                            sent_hashes
397                                .into_iter()
398                                .map(|s| PlaintextHashSecret::clone(s)),
399                        );
400                        transcript_proof.hash_secrets.extend(
401                            recv_hashes
402                                .into_iter()
403                                .map(|s| PlaintextHashSecret::clone(s)),
404                        );
405                    }
406                    #[allow(unreachable_patterns)]
407                    kind => {
408                        return Err(TranscriptProofBuilderError::new(
409                            BuilderErrorKind::NotSupported,
410                            format!("opening {kind} transcript commitments is not yet supported"),
411                        ));
412                    }
413                }
414            } else {
415                // Stops the set cover check if there are no more commitment
416                // kinds left.
417                break;
418            }
419        }
420
421        // If there are still uncovered ranges, it means that query ranges
422        // cannot be covered by committed ranges of any kind.
423        if !uncovered_query_idx.is_empty() {
424            return Err(TranscriptProofBuilderError::cover(
425                uncovered_query_idx,
426                &self.commitment_kinds,
427            ));
428        }
429
430        Ok(transcript_proof)
431    }
432}
433
434/// Error for [`TranscriptProofBuilder`].
435#[derive(Debug, thiserror::Error)]
436pub struct TranscriptProofBuilderError {
437    kind: BuilderErrorKind,
438    source: Option<Box<dyn std::error::Error + Send + Sync>>,
439}
440
441impl TranscriptProofBuilderError {
442    fn new<E>(kind: BuilderErrorKind, source: E) -> Self
443    where
444        E: Into<Box<dyn std::error::Error + Send + Sync>>,
445    {
446        Self {
447            kind,
448            source: Some(source.into()),
449        }
450    }
451
452    fn cover(uncovered: QueryIdx, kinds: &[TranscriptCommitmentKind]) -> Self {
453        Self {
454            kind: BuilderErrorKind::Cover {
455                uncovered,
456                kinds: kinds.to_vec(),
457            },
458            source: None,
459        }
460    }
461}
462
463#[derive(Debug, PartialEq)]
464enum BuilderErrorKind {
465    Index,
466    MissingCommitment,
467    Cover {
468        uncovered: QueryIdx,
469        kinds: Vec<TranscriptCommitmentKind>,
470    },
471    NotSupported,
472}
473
474impl fmt::Display for TranscriptProofBuilderError {
475    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476        f.write_str("transcript proof builder error: ")?;
477
478        match &self.kind {
479            BuilderErrorKind::Index => f.write_str("index error")?,
480            BuilderErrorKind::MissingCommitment => f.write_str("commitment error")?,
481            BuilderErrorKind::Cover { uncovered, kinds } => f.write_str(&format!(
482                "unable to cover the following ranges in transcript using available {kinds:?} commitments: {uncovered}"
483            ))?,
484            BuilderErrorKind::NotSupported => f.write_str("not supported")?,
485        }
486
487        if let Some(source) = &self.source {
488            write!(f, " caused by: {source}")?;
489        }
490
491        Ok(())
492    }
493}
494
495#[allow(clippy::single_range_in_vec_init)]
496#[cfg(test)]
497mod tests {
498    use rand::{Rng, SeedableRng};
499    use rangeset::prelude::*;
500    use rstest::rstest;
501    use tlsn_data_fixtures::http::{request::GET_WITH_HEADER, response::OK_JSON};
502
503    use crate::hash::{Blinder, HashAlgId};
504
505    use super::*;
506
507    #[rstest]
508    fn test_reveal_range_out_of_bounds() {
509        let transcript = Transcript::new(
510            [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
511            [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
512        );
513        let mut builder = TranscriptProofBuilder::new(&transcript, &[]);
514
515        let err = builder.reveal(&(10..15), Direction::Sent).unwrap_err();
516        assert!(matches!(err.kind, BuilderErrorKind::Index));
517
518        let err = builder
519            .reveal(&(10..15), Direction::Received)
520            .err()
521            .unwrap();
522        assert!(matches!(err.kind, BuilderErrorKind::Index));
523    }
524
525    #[rstest]
526    fn test_reveal_missing_commitment() {
527        let transcript = Transcript::new(
528            [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
529            [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
530        );
531        let mut builder = TranscriptProofBuilder::new(&transcript, &[]);
532
533        let err = builder.reveal_recv(&(9..11)).unwrap_err();
534        assert!(matches!(err.kind, BuilderErrorKind::MissingCommitment));
535    }
536
537    #[rstest]
538    #[case::sha256(HashAlgId::SHA256)]
539    #[case::blake3(HashAlgId::BLAKE3)]
540    #[case::keccak256(HashAlgId::KECCAK256)]
541    fn test_reveal_with_hash_commitment(#[case] alg: HashAlgId) {
542        let mut rng = rand::rngs::StdRng::seed_from_u64(0);
543        let provider = HashProvider::default();
544        let transcript = Transcript::new(GET_WITH_HEADER, OK_JSON);
545
546        let direction = Direction::Sent;
547        let idx = RangeSet::from(0..10);
548        let blinder: Blinder = rng.random();
549        let hasher = provider.get(&alg).unwrap();
550
551        let commitment = PlaintextHash {
552            direction,
553            idx: idx.clone(),
554            hash: hash_plaintext(hasher, &transcript.sent()[0..10], &blinder),
555        };
556
557        let secret = PlaintextHashSecret {
558            direction,
559            idx: idx.clone(),
560            alg,
561            blinder,
562        };
563
564        let secrets = vec![TranscriptSecret::Hash(secret)];
565        let mut builder = TranscriptProofBuilder::new(&transcript, &secrets);
566
567        builder.reveal_sent(&(0..10)).unwrap();
568
569        let transcript_proof = builder.build().unwrap();
570
571        let partial_transcript = transcript_proof
572            .verify_with_provider(
573                &provider,
574                &transcript.length(),
575                &[TranscriptCommitment::Hash(commitment)],
576            )
577            .unwrap();
578
579        assert_eq!(
580            partial_transcript.sent_unsafe()[0..10],
581            transcript.sent()[0..10]
582        );
583    }
584
585    #[rstest]
586    #[case::sha256(HashAlgId::SHA256)]
587    #[case::blake3(HashAlgId::BLAKE3)]
588    #[case::keccak256(HashAlgId::KECCAK256)]
589    fn test_reveal_with_inconsistent_hash_commitment(#[case] alg: HashAlgId) {
590        let mut rng = rand::rngs::StdRng::seed_from_u64(0);
591        let provider = HashProvider::default();
592        let transcript = Transcript::new(GET_WITH_HEADER, OK_JSON);
593
594        let direction = Direction::Sent;
595        let idx = RangeSet::from(0..10);
596        let blinder: Blinder = rng.random();
597        let hasher = provider.get(&alg).unwrap();
598
599        let commitment = PlaintextHash {
600            direction,
601            idx: idx.clone(),
602            hash: hash_plaintext(hasher, &transcript.sent()[0..10], &blinder),
603        };
604
605        let secret = PlaintextHashSecret {
606            direction,
607            idx: idx.clone(),
608            alg,
609            // Use a different blinder to create an inconsistent commitment
610            blinder: rng.random(),
611        };
612
613        let secrets = vec![TranscriptSecret::Hash(secret)];
614        let mut builder = TranscriptProofBuilder::new(&transcript, &secrets);
615
616        builder.reveal_sent(&(0..10)).unwrap();
617
618        let transcript_proof = builder.build().unwrap();
619
620        let err = transcript_proof
621            .verify_with_provider(
622                &provider,
623                &transcript.length(),
624                &[TranscriptCommitment::Hash(commitment)],
625            )
626            .unwrap_err();
627
628        assert!(matches!(err.kind, ErrorKind::Hash));
629    }
630
631    #[rstest]
632    fn test_set_commitment_kinds_with_duplicates() {
633        let transcript = Transcript::new(GET_WITH_HEADER, OK_JSON);
634        let mut builder = TranscriptProofBuilder::new(&transcript, &[]);
635        builder.commitment_kinds(&[
636            TranscriptCommitmentKind::Hash {
637                alg: HashAlgId::SHA256,
638            },
639            TranscriptCommitmentKind::Hash {
640                alg: HashAlgId::SHA256,
641            },
642            TranscriptCommitmentKind::Hash {
643                alg: HashAlgId::SHA256,
644            },
645        ]);
646
647        assert_eq!(
648            builder.commitment_kinds,
649            vec![TranscriptCommitmentKind::Hash {
650                alg: HashAlgId::SHA256
651            },]
652        );
653    }
654
655    #[rstest]
656    #[case::reveal_all_rangesets_with_exact_set(
657        vec![RangeSet::from([0..10]), RangeSet::from([12..30]), RangeSet::from([0..5, 15..30]), RangeSet::from([70..75, 85..100])],
658        RangeSet::from([0..10, 12..30]),
659        true,
660    )]
661    #[case::reveal_all_rangesets_with_single_superset_range(
662        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])],
663        RangeSet::from([0..4, 6..9]),
664        true,
665    )]
666    #[case::reveal_all_rangesets_with_superset_range(
667        vec![RangeSet::from([0..1, 2..4]), RangeSet::from([1..3]), RangeSet::from([1..9]), RangeSet::from([2..3])],
668        RangeSet::from([0..4]),
669        true,
670    )]
671    #[case::failed_to_reveal_with_superset_range_missing_within(
672        vec![RangeSet::from([0..20, 45..56]), RangeSet::from([80..120]), RangeSet::from([50..53])],
673        RangeSet::from([0..120]),
674        false,
675    )]
676    #[case::failed_to_reveal_with_superset_range_missing_outside(
677        vec![RangeSet::from([2..20, 45..116]), RangeSet::from([20..45]), RangeSet::from([50..53])],
678        RangeSet::from([0..120]),
679        false,
680    )]
681    #[case::failed_to_reveal_with_superset_ranges_missing_outside(
682        vec![RangeSet::from([1..10]), RangeSet::from([1..20]),  RangeSet::from([15..20, 75..110])],
683        RangeSet::from([0..41, 74..100]),
684        false,
685    )]
686    #[case::failed_to_reveal_as_no_subset_range(
687        vec![RangeSet::from([2..4]), RangeSet::from([1..2]), RangeSet::from([1..9]), RangeSet::from([2..3])],
688        RangeSet::from([0..1]),
689        false,
690    )]
691    #[allow(clippy::single_range_in_vec_init)]
692    fn test_reveal_multiple_rangesets_with_one_rangeset(
693        #[case] commit_recv_rangesets: Vec<RangeSet<usize>>,
694        #[case] reveal_recv_rangeset: RangeSet<usize>,
695        #[case] success: bool,
696    ) {
697        use rand::{Rng, SeedableRng};
698
699        let mut rng = rand::rngs::StdRng::seed_from_u64(0);
700        let transcript = Transcript::new(GET_WITH_HEADER, OK_JSON);
701
702        // Create hash commitments for each rangeset
703        let mut secrets = Vec::new();
704        for rangeset in commit_recv_rangesets.iter() {
705            let blinder: crate::hash::Blinder = rng.random();
706
707            let secret = PlaintextHashSecret {
708                direction: Direction::Received,
709                idx: rangeset.clone(),
710                alg: HashAlgId::BLAKE3,
711                blinder,
712            };
713            secrets.push(TranscriptSecret::Hash(secret));
714        }
715
716        let mut builder = TranscriptProofBuilder::new(&transcript, &secrets);
717
718        if success {
719            assert!(builder.reveal_recv(&reveal_recv_rangeset).is_ok());
720        } else {
721            let err = builder.reveal_recv(&reveal_recv_rangeset).unwrap_err();
722            assert!(matches!(err.kind, BuilderErrorKind::MissingCommitment));
723        }
724    }
725
726    #[rstest]
727    #[case::cover(
728        vec![RangeSet::from([1..5, 6..10])],
729        vec![RangeSet::from([2..4, 8..10])],
730        RangeSet::from([1..5, 6..10]),
731        RangeSet::from([2..4, 8..10]),
732        RangeSet::default(),
733        RangeSet::default(),
734    )]
735    #[case::failed_to_cover_sent(
736        vec![RangeSet::from([1..5, 6..10])],
737        vec![RangeSet::from([2..4, 8..10])],
738        RangeSet::from([1..5]),
739        RangeSet::from([2..4, 8..10]),
740        RangeSet::from([1..5]),
741        RangeSet::default(),
742    )]
743    #[case::failed_to_cover_recv(
744        vec![RangeSet::from([1..5, 6..10])],
745        vec![RangeSet::from([2..4, 8..10])],
746        RangeSet::from([1..5, 6..10]),
747        RangeSet::from([2..4]),
748        RangeSet::default(),
749        RangeSet::from([2..4]),
750    )]
751    #[case::failed_to_cover_both(
752        vec![RangeSet::from([1..5, 6..10])],
753        vec![RangeSet::from([2..4, 8..10])],
754        RangeSet::from([1..5]),
755        RangeSet::from([2..4]),
756        RangeSet::from([1..5]),
757        RangeSet::from([2..4]),
758    )]
759    #[allow(clippy::single_range_in_vec_init)]
760    fn test_transcript_proof_builder(
761        #[case] commit_sent_rangesets: Vec<RangeSet<usize>>,
762        #[case] commit_recv_rangesets: Vec<RangeSet<usize>>,
763        #[case] reveal_sent_rangeset: RangeSet<usize>,
764        #[case] reveal_recv_rangeset: RangeSet<usize>,
765        #[case] uncovered_sent_rangeset: RangeSet<usize>,
766        #[case] uncovered_recv_rangeset: RangeSet<usize>,
767    ) {
768        use rand::{Rng, SeedableRng};
769
770        let mut rng = rand::rngs::StdRng::seed_from_u64(0);
771        let transcript = Transcript::new(GET_WITH_HEADER, OK_JSON);
772
773        // Create hash commitments for each rangeset
774        let mut secrets = Vec::new();
775        for rangeset in commit_sent_rangesets.iter() {
776            let blinder: crate::hash::Blinder = rng.random();
777            let secret = PlaintextHashSecret {
778                direction: Direction::Sent,
779                idx: rangeset.clone(),
780                alg: HashAlgId::BLAKE3,
781                blinder,
782            };
783            secrets.push(TranscriptSecret::Hash(secret));
784        }
785        for rangeset in commit_recv_rangesets.iter() {
786            let blinder: crate::hash::Blinder = rng.random();
787            let secret = PlaintextHashSecret {
788                direction: Direction::Received,
789                idx: rangeset.clone(),
790                alg: HashAlgId::BLAKE3,
791                blinder,
792            };
793            secrets.push(TranscriptSecret::Hash(secret));
794        }
795
796        let mut builder = TranscriptProofBuilder::new(&transcript, &secrets);
797        builder.reveal_sent(&reveal_sent_rangeset).unwrap();
798        builder.reveal_recv(&reveal_recv_rangeset).unwrap();
799
800        if uncovered_sent_rangeset.is_empty() && uncovered_recv_rangeset.is_empty() {
801            assert!(builder.build().is_ok());
802        } else {
803            let TranscriptProofBuilderError { kind, .. } = builder.build().unwrap_err();
804            match kind {
805                BuilderErrorKind::Cover { uncovered, .. } => {
806                    if !uncovered_sent_rangeset.is_empty() {
807                        assert_eq!(uncovered.sent, uncovered_sent_rangeset);
808                    }
809                    if !uncovered_recv_rangeset.is_empty() {
810                        assert_eq!(uncovered.recv, uncovered_recv_rangeset);
811                    }
812                }
813                _ => panic!("unexpected error kind: {kind:?}"),
814            }
815        }
816    }
817}