Skip to main content

coven_protocol/provider/
probe.rs

1use super::*;
2pub(super) use crate::store_commit::domain_json;
3
4#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
5pub struct ProviderProbeId([u8; 32]);
6
7impl ProviderProbeId {
8    pub fn from_bytes(bytes: [u8; 32]) -> Self {
9        Self(bytes)
10    }
11
12    pub fn as_bytes(&self) -> &[u8; 32] {
13        &self.0
14    }
15}
16
17impl fmt::Debug for ProviderProbeId {
18    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
19        formatter.write_str(&hex::encode(self.0))
20    }
21}
22
23impl Serialize for ProviderProbeId {
24    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
25    where
26        S: Serializer,
27    {
28        serializer.serialize_str(&hex::encode(self.0))
29    }
30}
31
32impl<'de> Deserialize<'de> for ProviderProbeId {
33    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
34    where
35        D: Deserializer<'de>,
36    {
37        let value = String::deserialize(deserializer)?;
38        if value.len() != 64
39            || value
40                .bytes()
41                .any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(&byte))
42        {
43            return Err(serde::de::Error::custom(
44                "provider probe id must be 64 lowercase hexadecimal characters",
45            ));
46        }
47        let bytes: [u8; 32] = hex::decode(value)
48            .map_err(serde::de::Error::custom)?
49            .try_into()
50            .map_err(|_| serde::de::Error::custom("provider probe id has the wrong length"))?;
51        Ok(Self(bytes))
52    }
53}
54
55#[derive(Clone, Copy)]
56pub enum ProbePayloadLabel {
57    ExactCreateFirst,
58    ExactCreateSecond,
59    ConditionalInitial,
60    ConditionalFirst,
61    ConditionalSecond,
62    LostResponse,
63    CrossAdministrator,
64    CrossPeer,
65}
66
67impl ProbePayloadLabel {
68    fn bytes(self) -> &'static [u8] {
69        match self {
70            Self::ExactCreateFirst => b"exact-create-first",
71            Self::ExactCreateSecond => b"exact-create-second",
72            Self::ConditionalInitial => b"conditional-initial",
73            Self::ConditionalFirst => b"conditional-first",
74            Self::ConditionalSecond => b"conditional-second",
75            Self::LostResponse => b"lost-response",
76            Self::CrossAdministrator => b"cross-administrator",
77            Self::CrossPeer => b"cross-peer",
78        }
79    }
80}
81
82pub fn probe_payload(probe_id: &ProviderProbeId, label: ProbePayloadLabel) -> Vec<u8> {
83    let mut output = Vec::with_capacity(PROBE_PAYLOAD_LEN);
84    let mut counter = 0u32;
85    while output.len() < PROBE_PAYLOAD_LEN {
86        let mut digest = Sha256::new();
87        digest.update(PAYLOAD_DOMAIN);
88        digest.update(probe_id.as_bytes());
89        digest.update(label.bytes());
90        digest.update(counter.to_be_bytes());
91        output.extend_from_slice(&digest.finalize());
92        counter += 1;
93    }
94    output.truncate(PROBE_PAYLOAD_LEN);
95    output
96}
97
98#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct ExactSlotProbeReceipt {
101    pub transcript: ExactSlotProbeTranscript,
102    pub transcript_hash: ObjectHash,
103}
104
105impl ExactSlotProbeReceipt {
106    pub fn from_transcript(
107        transcript: ExactSlotProbeTranscript,
108        store: &StoreProviderBinding,
109        device: &ProviderDeviceBinding,
110    ) -> Self {
111        let transcript_hash = exact_transcript_hash(store, device, &transcript);
112        Self {
113            transcript,
114            transcript_hash,
115        }
116    }
117
118    pub fn verify(
119        &self,
120        store: &StoreProviderBinding,
121        device: &ProviderDeviceBinding,
122    ) -> Result<(), ProviderProbeError> {
123        store.validate().map_err(ProviderProbeError::Storage)?;
124        device
125            .validate_for(store)
126            .map_err(ProviderProbeError::Storage)?;
127        let t = &self.transcript;
128        if self.transcript_hash != exact_transcript_hash(store, device, t) {
129            return invalid("exact-slot transcript hash does not match its context");
130        }
131        if t.logical_key != t.slot.logical_key() || t.accepted.slot() != &t.slot {
132            return invalid("exact-slot transcript disagrees with its allocated slot");
133        }
134        let payloads = [
135            probe_payload(&t.probe_id, ProbePayloadLabel::ExactCreateFirst),
136            probe_payload(&t.probe_id, ProbePayloadLabel::ExactCreateSecond),
137        ];
138        let expected_hashes = [
139            ObjectHash::digest(&payloads[0]),
140            ObjectHash::digest(&payloads[1]),
141        ];
142        if t.contenders[0].payload_hash != expected_hashes[0]
143            || t.contenders[1].payload_hash != expected_hashes[1]
144        {
145            return invalid("exact-slot contender payload hashes are not deterministic");
146        }
147        let winners: Vec<_> = t
148            .contenders
149            .iter()
150            .enumerate()
151            .filter_map(|(index, attempt)| {
152                (attempt.outcome == ProbeCreateOutcome::Created).then_some(index)
153            })
154            .collect();
155        let rejected = t
156            .contenders
157            .iter()
158            .filter(|attempt| attempt.outcome == ProbeCreateOutcome::RejectedOccupied)
159            .count();
160        if winners.len() != 1 || rejected != 1 {
161            return invalid("exact-slot race must contain one create and one occupied rejection");
162        }
163        let winner = &payloads[winners[0]];
164        if t.accepted.stored_size() != winner.len() as u64
165            || t.accepted.stored_hash() != ObjectHash::digest(winner)
166            || t.full_read_hash != ObjectHash::digest(winner)
167            || t.range.start != PROBE_RANGE_START
168            || t.range.end != PROBE_RANGE_END
169            || t.range.bytes_hash
170                != ObjectHash::digest(&winner[PROBE_RANGE_START as usize..PROBE_RANGE_END as usize])
171        {
172            return invalid("exact-slot read, range, reference, or deletion evidence is invalid");
173        }
174        t.conditional.verify(&t.probe_id)?;
175        let lost = probe_payload(&t.probe_id, ProbePayloadLabel::LostResponse);
176        let lost_hash = ObjectHash::digest(&lost);
177        if t.lost_response.logical_key != t.lost_response.slot.logical_key()
178            || t.lost_response.settled.slot() != &t.lost_response.slot
179            || t.lost_response.payload_hash != lost_hash
180            || t.lost_response.settled.stored_size() != lost.len() as u64
181            || t.lost_response.settled.stored_hash() != lost_hash
182            || t.lost_response.readback_hash != lost_hash
183        {
184            return invalid("lost-response exact-slot evidence is invalid");
185        }
186        Ok(())
187    }
188}
189
190#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(deny_unknown_fields)]
192pub struct ExactSlotProbeTranscript {
193    pub probe_id: ProviderProbeId,
194    pub logical_key: String,
195    pub slot: ObjectSlot,
196    pub contenders: [ProbeCreateAttempt; 2],
197    pub accepted: ExactObjectRef,
198    pub full_read_hash: ObjectHash,
199    pub range: ProbeRangeReceipt,
200    pub conditional: ConditionalUpdateProbeReceipt,
201    pub lost_response: LostResponseProbeReceipt,
202}
203
204#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(deny_unknown_fields)]
206pub struct ConditionalUpdateProbeReceipt {
207    pub logical_key: String,
208    pub slot: ObjectSlot,
209    pub starting_payload_hash: ObjectHash,
210    pub contenders: [ProbeConditionalAttempt; 2],
211    pub accepted_payload_hash: ObjectHash,
212}
213
214impl ConditionalUpdateProbeReceipt {
215    fn verify(&self, probe_id: &ProviderProbeId) -> Result<(), ProviderProbeError> {
216        if self.logical_key != self.slot.logical_key() {
217            return invalid("conditional-update transcript disagrees with its allocated slot");
218        }
219        let initial = probe_payload(probe_id, ProbePayloadLabel::ConditionalInitial);
220        let payloads = [
221            probe_payload(probe_id, ProbePayloadLabel::ConditionalFirst),
222            probe_payload(probe_id, ProbePayloadLabel::ConditionalSecond),
223        ];
224        let allowed_starting_hashes = [
225            ObjectHash::digest(&initial),
226            ObjectHash::digest(&payloads[0]),
227            ObjectHash::digest(&payloads[1]),
228        ];
229        if !allowed_starting_hashes.contains(&self.starting_payload_hash)
230            || self.contenders[0].payload_hash != ObjectHash::digest(&payloads[0])
231            || self.contenders[1].payload_hash != ObjectHash::digest(&payloads[1])
232        {
233            return invalid("conditional-update payload hashes are not deterministic");
234        }
235        let winners = self
236            .contenders
237            .iter()
238            .enumerate()
239            .filter_map(|(index, attempt)| {
240                (attempt.outcome == ProbeConditionalOutcome::Replaced).then_some(index)
241            })
242            .collect::<Vec<_>>();
243        let rejected = self
244            .contenders
245            .iter()
246            .filter(|attempt| attempt.outcome == ProbeConditionalOutcome::RejectedRevision)
247            .count();
248        if winners.len() != 1
249            || rejected != 1
250            || self.accepted_payload_hash != ObjectHash::digest(&payloads[winners[0]])
251        {
252            return invalid(
253                "conditional-update race must contain one replacement and one revision rejection",
254            );
255        }
256        Ok(())
257    }
258}
259
260#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(deny_unknown_fields)]
262pub struct ProbeConditionalAttempt {
263    pub payload_hash: ObjectHash,
264    pub outcome: ProbeConditionalOutcome,
265}
266
267#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "snake_case")]
269pub enum ProbeConditionalOutcome {
270    Replaced,
271    RejectedRevision,
272}
273
274#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(deny_unknown_fields)]
276pub struct ProbeCreateAttempt {
277    pub payload_hash: ObjectHash,
278    pub outcome: ProbeCreateOutcome,
279}
280
281#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
282#[serde(rename_all = "snake_case")]
283pub enum ProbeCreateOutcome {
284    Created,
285    RejectedOccupied,
286}
287
288#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(deny_unknown_fields)]
290pub struct LostResponseProbeReceipt {
291    pub logical_key: String,
292    pub slot: ObjectSlot,
293    pub payload_hash: ObjectHash,
294    pub settled: ExactObjectRef,
295    pub readback_hash: ObjectHash,
296}
297
298#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(deny_unknown_fields)]
300pub struct ProbeRangeReceipt {
301    pub start: u64,
302    pub end: u64,
303    pub bytes_hash: ObjectHash,
304}
305
306#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(deny_unknown_fields)]
308pub struct ProbeExactObjectReceipt {
309    pub slot: ObjectSlot,
310    pub payload_hash: ObjectHash,
311    pub object: ExactObjectRef,
312}
313
314#[derive(Debug, thiserror::Error)]
315pub enum ProviderProbeError {
316    #[error(transparent)]
317    Storage(#[from] StorageError),
318    #[error("provider capability receipt Store protocol: {0}")]
319    Protocol(#[from] crate::store_commit::StoreProtocolError),
320    #[error("provider capability receipt is invalid: {0}")]
321    InvalidReceipt(String),
322}
323
324#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(rename_all = "snake_case", deny_unknown_fields)]
326pub enum ProviderProbeJournalRecord {
327    Exact(ExactProbeJournal),
328    CrossPrincipal(CrossPrincipalCompletionJournal),
329}
330
331impl ProviderProbeJournalRecord {
332    pub fn probe_id(&self) -> ProviderProbeId {
333        match self {
334            Self::Exact(record) => record.probe_id,
335            Self::CrossPrincipal(record) => record.probe_id,
336        }
337    }
338
339    pub fn validate_begin(&self) -> Result<(), ProviderProbeJournalError> {
340        let prepared = match self {
341            Self::Exact(record) => matches!(record.progress, ExactProbeProgress::Prepared),
342            Self::CrossPrincipal(record) => {
343                matches!(record.progress, CrossPrincipalCompletionProgress::Prepared)
344            }
345        };
346        if !prepared {
347            return Err(ProviderProbeJournalError::BeginNotPrepared);
348        }
349        Ok(())
350    }
351
352    pub fn validate_transition(&self, next: &Self) -> Result<(), ProviderProbeJournalError> {
353        match (self, next) {
354            (Self::Exact(previous), Self::Exact(next)) => {
355                if previous.probe_id != next.probe_id
356                    || previous.binding != next.binding
357                    || previous.slot != next.slot
358                    || previous.conditional_slot != next.conditional_slot
359                    || previous.lost_response_slot != next.lost_response_slot
360                {
361                    return Err(ProviderProbeJournalError::ImmutableFactsChanged);
362                }
363                validate_exact_progress_transition(&previous.progress, &next.progress)
364            }
365            (Self::CrossPrincipal(previous), Self::CrossPrincipal(next)) => {
366                if previous.probe_id != next.probe_id
367                    || previous.store != next.store
368                    || previous.context != next.context
369                    || previous.challenge != next.challenge
370                    || previous.response != next.response
371                {
372                    return Err(ProviderProbeJournalError::ImmutableFactsChanged);
373                }
374                let expected_read_hash = ObjectHash::digest(&probe_payload(
375                    &previous.probe_id,
376                    ProbePayloadLabel::CrossPeer,
377                ));
378                if cross_progress_evidence_hash(&next.progress)
379                    .is_some_and(|hash| hash != expected_read_hash)
380                {
381                    return Err(ProviderProbeJournalError::EvidenceChanged);
382                }
383                validate_cross_progress_transition(&previous.progress, &next.progress)
384            }
385            _ => Err(ProviderProbeJournalError::ProbeKindChanged),
386        }
387    }
388}
389
390#[derive(Debug, thiserror::Error, PartialEq, Eq)]
391pub enum ProviderProbeJournalError {
392    #[error("provider probe journal must begin at prepared")]
393    BeginNotPrepared,
394    #[error("provider probe journal advance changes immutable facts")]
395    ImmutableFactsChanged,
396    #[error("provider probe journal advance changes the probe kind")]
397    ProbeKindChanged,
398    #[error("provider probe journal advance skips or reverses progress")]
399    NonAdjacentProgress,
400    #[error("provider probe journal advance changes established evidence")]
401    EvidenceChanged,
402}
403
404#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
405#[serde(deny_unknown_fields)]
406pub struct ExactProbeJournal {
407    pub probe_id: ProviderProbeId,
408    pub binding: crate::objects::ResolvedProviderBinding,
409    pub slot: ObjectSlot,
410    pub conditional_slot: ObjectSlot,
411    pub lost_response_slot: ObjectSlot,
412    pub progress: ExactProbeProgress,
413}
414
415#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
416#[serde(rename_all = "snake_case", deny_unknown_fields)]
417pub enum ExactProbeProgress {
418    Prepared,
419    Created {
420        outcomes: [ProbeCreateOutcome; 2],
421    },
422    ReadsVerified {
423        outcomes: [ProbeCreateOutcome; 2],
424    },
425    ConditionalVerified {
426        outcomes: [ProbeCreateOutcome; 2],
427        conditional: ConditionalUpdateProbeReceipt,
428    },
429    PrimaryAbsent {
430        outcomes: [ProbeCreateOutcome; 2],
431        conditional: ConditionalUpdateProbeReceipt,
432    },
433    LostResponseCreated {
434        outcomes: [ProbeCreateOutcome; 2],
435        conditional: ConditionalUpdateProbeReceipt,
436    },
437    LostResponseReadVerified {
438        outcomes: [ProbeCreateOutcome; 2],
439        conditional: ConditionalUpdateProbeReceipt,
440    },
441    Absent {
442        outcomes: [ProbeCreateOutcome; 2],
443        conditional: ConditionalUpdateProbeReceipt,
444    },
445    ReceiptReady {
446        receipt: ExactSlotProbeReceipt,
447    },
448}
449
450#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
451#[serde(deny_unknown_fields)]
452pub struct CrossPrincipalCompletionJournal {
453    pub probe_id: ProviderProbeId,
454    pub store: StoreProviderBinding,
455    pub context: CrossPrincipalResponseContext,
456    pub challenge: CrossPrincipalProbeChallenge,
457    pub response: CrossPrincipalProbeResponse,
458    pub progress: CrossPrincipalCompletionProgress,
459}
460
461#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
462#[serde(rename_all = "snake_case", deny_unknown_fields)]
463pub enum CrossPrincipalCompletionProgress {
464    Prepared,
465    ReadsVerified {
466        administrator_read_peer_hash: ObjectHash,
467    },
468    PeerAbsent {
469        administrator_read_peer_hash: ObjectHash,
470    },
471    Absent {
472        administrator_read_peer_hash: ObjectHash,
473    },
474    ReceiptReady {
475        receipt: CrossPrincipalProbeReceipt,
476    },
477}
478
479pub(super) fn validate_exact_progress_transition(
480    previous: &ExactProbeProgress,
481    next: &ExactProbeProgress,
482) -> Result<(), ProviderProbeJournalError> {
483    let evidence_matches = match (previous, next) {
484        (ExactProbeProgress::Prepared, ExactProbeProgress::Created { .. }) => true,
485        (
486            ExactProbeProgress::Created { outcomes: previous },
487            ExactProbeProgress::ReadsVerified { outcomes: next },
488        ) => previous == next,
489        (
490            ExactProbeProgress::ReadsVerified { outcomes: previous },
491            ExactProbeProgress::ConditionalVerified { outcomes: next, .. },
492        ) => previous == next,
493        (
494            ExactProbeProgress::ConditionalVerified {
495                outcomes: previous_outcomes,
496                conditional: previous_conditional,
497            },
498            ExactProbeProgress::PrimaryAbsent {
499                outcomes: next_outcomes,
500                conditional: next_conditional,
501            },
502        )
503        | (
504            ExactProbeProgress::PrimaryAbsent {
505                outcomes: previous_outcomes,
506                conditional: previous_conditional,
507            },
508            ExactProbeProgress::LostResponseCreated {
509                outcomes: next_outcomes,
510                conditional: next_conditional,
511            },
512        )
513        | (
514            ExactProbeProgress::LostResponseCreated {
515                outcomes: previous_outcomes,
516                conditional: previous_conditional,
517            },
518            ExactProbeProgress::LostResponseReadVerified {
519                outcomes: next_outcomes,
520                conditional: next_conditional,
521            },
522        )
523        | (
524            ExactProbeProgress::LostResponseReadVerified {
525                outcomes: previous_outcomes,
526                conditional: previous_conditional,
527            },
528            ExactProbeProgress::Absent {
529                outcomes: next_outcomes,
530                conditional: next_conditional,
531            },
532        ) => previous_outcomes == next_outcomes && previous_conditional == next_conditional,
533        (
534            ExactProbeProgress::Absent {
535                outcomes,
536                conditional,
537            },
538            ExactProbeProgress::ReceiptReady { receipt },
539        ) => {
540            receipt
541                .transcript
542                .contenders
543                .iter()
544                .map(|attempt| attempt.outcome)
545                .eq(outcomes.iter().copied())
546                && receipt.transcript.conditional == *conditional
547        }
548        _ => return Err(ProviderProbeJournalError::NonAdjacentProgress),
549    };
550    if !evidence_matches {
551        return Err(ProviderProbeJournalError::EvidenceChanged);
552    }
553    Ok(())
554}
555
556pub(super) fn validate_cross_progress_transition(
557    previous: &CrossPrincipalCompletionProgress,
558    next: &CrossPrincipalCompletionProgress,
559) -> Result<(), ProviderProbeJournalError> {
560    let evidence_matches = match (previous, next) {
561        (
562            CrossPrincipalCompletionProgress::Prepared,
563            CrossPrincipalCompletionProgress::ReadsVerified { .. },
564        ) => true,
565        (
566            CrossPrincipalCompletionProgress::ReadsVerified {
567                administrator_read_peer_hash: previous,
568            },
569            CrossPrincipalCompletionProgress::PeerAbsent {
570                administrator_read_peer_hash: next,
571            },
572        )
573        | (
574            CrossPrincipalCompletionProgress::PeerAbsent {
575                administrator_read_peer_hash: previous,
576            },
577            CrossPrincipalCompletionProgress::Absent {
578                administrator_read_peer_hash: next,
579            },
580        ) => previous == next,
581        (
582            CrossPrincipalCompletionProgress::Absent {
583                administrator_read_peer_hash,
584            },
585            CrossPrincipalCompletionProgress::ReceiptReady { receipt },
586        ) => receipt.transcript.administrator_read_peer_hash == *administrator_read_peer_hash,
587        _ => return Err(ProviderProbeJournalError::NonAdjacentProgress),
588    };
589    if !evidence_matches {
590        return Err(ProviderProbeJournalError::EvidenceChanged);
591    }
592    Ok(())
593}
594
595pub(super) fn cross_progress_evidence_hash(
596    progress: &CrossPrincipalCompletionProgress,
597) -> Option<ObjectHash> {
598    match progress {
599        CrossPrincipalCompletionProgress::Prepared => None,
600        CrossPrincipalCompletionProgress::ReadsVerified {
601            administrator_read_peer_hash,
602        }
603        | CrossPrincipalCompletionProgress::PeerAbsent {
604            administrator_read_peer_hash,
605        }
606        | CrossPrincipalCompletionProgress::Absent {
607            administrator_read_peer_hash,
608        } => Some(*administrator_read_peer_hash),
609        CrossPrincipalCompletionProgress::ReceiptReady { receipt } => {
610            Some(receipt.transcript.administrator_read_peer_hash)
611        }
612    }
613}
614
615#[async_trait]
616pub trait ProviderProbeJournal: Send + Sync {
617    async fn load(
618        &self,
619        probe_id: ProviderProbeId,
620    ) -> Result<Option<ProviderProbeJournalRecord>, StorageError>;
621
622    /// Atomically inserts `prepared` when absent or returns the exact existing
623    /// record for this probe id. A different record under the id is corruption.
624    async fn begin(
625        &self,
626        prepared: ProviderProbeJournalRecord,
627    ) -> Result<ProviderProbeJournalRecord, StorageError>;
628
629    /// Atomically replaces the exact current record. Implementations reject a
630    /// stale predecessor instead of merging progress.
631    async fn advance(
632        &self,
633        previous: &ProviderProbeJournalRecord,
634        next: ProviderProbeJournalRecord,
635    ) -> Result<(), StorageError>;
636}
637
638pub(super) fn validate_probe_exact_object(
639    receipt: &ProbeExactObjectReceipt,
640    expected_logical_key: &str,
641    payload: &[u8],
642    label: &str,
643) -> Result<(), ProviderProbeError> {
644    let payload_hash = ObjectHash::digest(payload);
645    if receipt.slot.logical_key() != expected_logical_key
646        || receipt.slot != *receipt.object.slot()
647        || receipt.payload_hash != payload_hash
648        || receipt.object.stored_size() != payload.len() as u64
649        || receipt.object.stored_hash() != payload_hash
650    {
651        return invalid(&format!(
652            "{label} object reference or payload hash is invalid"
653        ));
654    }
655    Ok(())
656}
657
658pub fn invalid<T>(reason: &str) -> Result<T, ProviderProbeError> {
659    Err(ProviderProbeError::InvalidReceipt(reason.to_string()))
660}
661
662pub(super) fn exact_transcript_hash(
663    store: &StoreProviderBinding,
664    device: &ProviderDeviceBinding,
665    transcript: &ExactSlotProbeTranscript,
666) -> ObjectHash {
667    ObjectHash::digest(&domain_json(
668        EXACT_TRANSCRIPT_DOMAIN,
669        &(store, device, transcript),
670    ))
671}