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