1use async_trait::async_trait;
15use dashmap::DashMap;
16use parking_lot::Mutex;
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19use std::sync::Arc;
20use tenzro_types::primitives::{Hash, Timestamp};
21use tenzro_types::principal_chain::PrincipalChainSummary;
22
23use crate::error::{Result, StorageError};
24
25#[cfg(feature = "celestia")]
26pub mod celestia;
27
28#[cfg(feature = "celestia")]
29pub use celestia::CelestiaBackend;
30
31#[cfg(feature = "eigenda")]
32pub mod eigenda;
33
34#[cfg(feature = "eigenda")]
35pub use eigenda::EigenDaBackend;
36
37#[cfg(feature = "avail")]
38pub mod avail;
39
40#[cfg(feature = "avail")]
41pub use avail::AvailBackend;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46pub enum ReceiptStorageMode {
47 Inline,
50 OffloadedDA,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57pub enum ReceiptKind {
58 SettlementEscrow,
60 SettlementChannel,
62 Inference,
64 AgentMessage,
66 KillSwitch,
68 Lifecycle,
70 Governance,
72}
73
74impl ReceiptKind {
75 pub fn default_mode(&self) -> ReceiptStorageMode {
77 match self {
78 ReceiptKind::SettlementChannel
79 | ReceiptKind::Inference
80 | ReceiptKind::AgentMessage => ReceiptStorageMode::OffloadedDA,
81 ReceiptKind::SettlementEscrow
82 | ReceiptKind::KillSwitch
83 | ReceiptKind::Lifecycle
84 | ReceiptKind::Governance => ReceiptStorageMode::Inline,
85 }
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
91pub enum DaBackendId {
92 InlineFallback,
95 EigenDA,
97 Celestia,
99 Avail,
101 IrohBlobs,
105}
106
107impl DaBackendId {
108 pub fn as_str(&self) -> &'static str {
109 match self {
110 DaBackendId::InlineFallback => "inline_fallback",
111 DaBackendId::EigenDA => "eigenda",
112 DaBackendId::Celestia => "celestia",
113 DaBackendId::Avail => "avail",
114 DaBackendId::IrohBlobs => "iroh_blobs",
115 }
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct DaPointer {
127 pub backend: DaBackendId,
128 pub namespace: Vec<u8>,
130 pub locator: Vec<u8>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub commitment_kzg: Option<Vec<u8>>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub attestation_root: Option<Hash>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct ReceiptSummary {
150 pub receipt_id: Hash,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub payer: Option<String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub payee: Option<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub amount_wei: Option<u128>,
157 pub timestamp: Timestamp,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub principal_chain_summary: Option<PrincipalChainSummary>,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct MandateRef {
173 pub protocol: String,
178 pub mandate_hash: Hash,
181 pub issuer_did: String,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub mandate_uri: Option<String>,
189 #[serde(default)]
191 pub expires_at: u64,
192}
193
194impl MandateRef {
195 pub fn ap2_cart(mandate_hash: Hash, issuer_did: impl Into<String>) -> Self {
197 Self {
198 protocol: "ap2-cart".into(),
199 mandate_hash,
200 issuer_did: issuer_did.into(),
201 mandate_uri: None,
202 expires_at: 0,
203 }
204 }
205
206 pub fn x402(mandate_hash: Hash, issuer_did: impl Into<String>) -> Self {
208 Self {
209 protocol: "x402".into(),
210 mandate_hash,
211 issuer_did: issuer_did.into(),
212 mandate_uri: None,
213 expires_at: 0,
214 }
215 }
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223pub struct ReceiptEnvelope {
224 pub kind: ReceiptKind,
225 pub storage_mode: ReceiptStorageMode,
226 pub inline_summary: ReceiptSummary,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub inline_payload: Option<Vec<u8>>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub da_pointer: Option<DaPointer>,
235 pub commitment: Hash,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub mandate_ref: Option<MandateRef>,
243}
244
245impl ReceiptEnvelope {
246 pub fn inline(kind: ReceiptKind, summary: ReceiptSummary, payload: Vec<u8>) -> Self {
249 let commitment = compute_commitment(&payload);
250 Self {
251 kind,
252 storage_mode: ReceiptStorageMode::Inline,
253 inline_summary: summary,
254 inline_payload: Some(payload),
255 da_pointer: None,
256 commitment,
257 mandate_ref: None,
258 }
259 }
260
261 pub fn offloaded(
264 kind: ReceiptKind,
265 summary: ReceiptSummary,
266 pointer: DaPointer,
267 commitment: Hash,
268 ) -> Self {
269 Self {
270 kind,
271 storage_mode: ReceiptStorageMode::OffloadedDA,
272 inline_summary: summary,
273 inline_payload: None,
274 da_pointer: Some(pointer),
275 commitment,
276 mandate_ref: None,
277 }
278 }
279
280 pub fn with_mandate(mut self, mandate_ref: MandateRef) -> Self {
284 self.mandate_ref = Some(mandate_ref);
285 self
286 }
287
288 pub fn validate(&self) -> Result<()> {
291 match self.storage_mode {
292 ReceiptStorageMode::Inline => {
293 let payload = self.inline_payload.as_ref().ok_or_else(|| {
294 StorageError::InvalidValue(
295 "Inline receipt envelope missing inline_payload".into(),
296 )
297 })?;
298 if self.da_pointer.is_some() {
299 return Err(StorageError::InvalidValue(
300 "Inline receipt envelope must not carry da_pointer".into(),
301 ));
302 }
303 let actual = compute_commitment(payload);
304 if actual != self.commitment {
305 return Err(StorageError::InvalidValue(format!(
306 "Receipt commitment mismatch: declared {}, computed {}",
307 self.commitment, actual,
308 )));
309 }
310 Ok(())
311 }
312 ReceiptStorageMode::OffloadedDA => {
313 if self.da_pointer.is_none() {
314 return Err(StorageError::InvalidValue(
315 "Offloaded receipt envelope missing da_pointer".into(),
316 ));
317 }
318 if self.inline_payload.is_some() {
319 return Err(StorageError::InvalidValue(
320 "Offloaded receipt envelope must not carry inline_payload".into(),
321 ));
322 }
323 Ok(())
324 }
325 }
326 }
327}
328
329pub fn compute_commitment(payload: &[u8]) -> Hash {
332 let mut hasher = Sha256::new();
333 hasher.update(payload);
334 let result = hasher.finalize();
335 let mut bytes = [0u8; 32];
336 bytes.copy_from_slice(&result);
337 Hash::new(bytes)
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343pub struct DaBackendStatus {
344 pub backend: DaBackendId,
345 pub healthy: bool,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub last_submission_ms: Option<i64>,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub last_fetch_ms: Option<i64>,
352 pub error_rate_bps: u16,
354}
355
356#[async_trait]
365pub trait DaBackend: Send + Sync {
366 fn id(&self) -> DaBackendId;
367
368 fn status(&self) -> DaBackendStatus;
369
370 async fn submit(&self, namespace: &[u8], payload: &[u8]) -> Result<DaPointer>;
371
372 async fn fetch(&self, pointer: &DaPointer) -> Result<Vec<u8>>;
373
374 async fn verify_availability(&self, pointer: &DaPointer) -> Result<()>;
375}
376
377pub struct InlineFallbackBackend {
398 store: DashMap<Vec<u8>, Vec<u8>>,
402 storage: Option<Arc<dyn crate::kv::KvStore>>,
407 last_submission_ms: Mutex<Option<i64>>,
410 last_fetch_ms: Mutex<Option<i64>>,
411}
412
413impl InlineFallbackBackend {
414 const STORAGE_PREFIX: &'static [u8] = b"da_fallback:";
418
419 pub fn new() -> Self {
420 Self {
421 store: DashMap::new(),
422 storage: None,
423 last_submission_ms: Mutex::new(None),
424 last_fetch_ms: Mutex::new(None),
425 }
426 }
427
428 pub fn with_storage(mut self, storage: Arc<dyn crate::kv::KvStore>) -> Self {
431 self.storage = Some(storage);
432 self
433 }
434
435 pub fn arc() -> Arc<dyn DaBackend> {
436 Arc::new(Self::new())
437 }
438
439 fn storage_key(locator: &[u8]) -> Vec<u8> {
440 [Self::STORAGE_PREFIX, locator].concat()
441 }
442
443 fn make_locator(payload: &[u8]) -> Vec<u8> {
446 let commitment = compute_commitment(payload);
447 format!("fallback:{}", hex::encode(commitment.as_bytes())).into_bytes()
448 }
449
450 fn now_ms() -> i64 {
451 Timestamp::now().0
452 }
453
454 pub fn submit_sync(&self, namespace: &[u8], payload: &[u8]) -> DaPointer {
469 let locator = Self::make_locator(payload);
470 self.store.insert(locator.clone(), payload.to_vec());
471 if let Some(storage) = &self.storage
472 && let Err(e) = storage.put(crate::kv::CF_METADATA, &Self::storage_key(&locator), payload) {
473 tracing::warn!(
474 "InlineFallbackBackend: failed to mirror-write payload to storage: {}",
475 e
476 );
477 }
478 *self.last_submission_ms.lock() = Some(Self::now_ms());
479 DaPointer {
480 backend: DaBackendId::InlineFallback,
481 namespace: namespace.to_vec(),
482 locator,
483 commitment_kzg: None,
484 attestation_root: None,
485 }
486 }
487
488 pub fn fetch_sync(&self, pointer: &DaPointer) -> Result<Vec<u8>> {
494 if pointer.backend != DaBackendId::InlineFallback {
495 return Err(StorageError::Generic(format!(
496 "InlineFallbackBackend cannot fetch pointer with backend {:?}",
497 pointer.backend
498 )));
499 }
500 if let Some(entry) = self.store.get(&pointer.locator) {
501 *self.last_fetch_ms.lock() = Some(Self::now_ms());
502 return Ok(entry.value().clone());
503 }
504 if let Some(storage) = &self.storage
505 && let Some(bytes) = storage
506 .get(crate::kv::CF_METADATA, &Self::storage_key(&pointer.locator))
507 .map_err(|e| StorageError::Generic(format!("storage read: {}", e)))?
508 {
509 self.store.insert(pointer.locator.clone(), bytes.clone());
510 *self.last_fetch_ms.lock() = Some(Self::now_ms());
511 return Ok(bytes);
512 }
513 Err(StorageError::KeyNotFound(format!(
514 "InlineFallbackBackend has no payload for locator {}",
515 String::from_utf8_lossy(&pointer.locator)
516 )))
517 }
518}
519
520impl std::fmt::Debug for InlineFallbackBackend {
521 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
522 f.debug_struct("InlineFallbackBackend")
523 .field("entries", &self.store.len())
524 .finish()
525 }
526}
527
528impl Default for InlineFallbackBackend {
529 fn default() -> Self {
530 Self::new()
531 }
532}
533
534#[async_trait]
535impl DaBackend for InlineFallbackBackend {
536 fn id(&self) -> DaBackendId {
537 DaBackendId::InlineFallback
538 }
539
540 fn status(&self) -> DaBackendStatus {
541 DaBackendStatus {
542 backend: DaBackendId::InlineFallback,
543 healthy: true,
544 last_submission_ms: *self.last_submission_ms.lock(),
545 last_fetch_ms: *self.last_fetch_ms.lock(),
546 error_rate_bps: 0,
547 }
548 }
549
550 async fn submit(&self, namespace: &[u8], payload: &[u8]) -> Result<DaPointer> {
551 Ok(self.submit_sync(namespace, payload))
552 }
553
554 async fn fetch(&self, pointer: &DaPointer) -> Result<Vec<u8>> {
555 self.fetch_sync(pointer)
556 }
557
558 async fn verify_availability(&self, pointer: &DaPointer) -> Result<()> {
559 if pointer.backend != DaBackendId::InlineFallback {
560 return Err(StorageError::Generic(format!(
561 "InlineFallbackBackend cannot verify pointer with backend {:?}",
562 pointer.backend
563 )));
564 }
565 if self.store.contains_key(&pointer.locator) {
566 return Ok(());
567 }
568 if let Some(storage) = &self.storage
569 && storage
570 .get(crate::kv::CF_METADATA, &Self::storage_key(&pointer.locator))
571 .map_err(|e| StorageError::Generic(format!("storage read: {}", e)))?
572 .is_some()
573 {
574 return Ok(());
575 }
576 Err(StorageError::KeyNotFound(format!(
577 "InlineFallbackBackend has no payload for locator {}",
578 String::from_utf8_lossy(&pointer.locator)
579 )))
580 }
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 fn sample_summary() -> ReceiptSummary {
588 ReceiptSummary {
589 receipt_id: Hash::new([7u8; 32]),
590 payer: Some("did:tenzro:human:alice".into()),
591 payee: Some("did:tenzro:machine:bob".into()),
592 amount_wei: Some(123_456_789),
593 timestamp: Timestamp::new(1_700_000_000_000),
594 principal_chain_summary: None,
595 }
596 }
597
598 #[test]
599 fn default_mode_per_kind_matches_spec() {
600 assert_eq!(
601 ReceiptKind::SettlementEscrow.default_mode(),
602 ReceiptStorageMode::Inline
603 );
604 assert_eq!(
605 ReceiptKind::SettlementChannel.default_mode(),
606 ReceiptStorageMode::OffloadedDA
607 );
608 assert_eq!(
609 ReceiptKind::Inference.default_mode(),
610 ReceiptStorageMode::OffloadedDA
611 );
612 assert_eq!(
613 ReceiptKind::AgentMessage.default_mode(),
614 ReceiptStorageMode::OffloadedDA
615 );
616 assert_eq!(
617 ReceiptKind::KillSwitch.default_mode(),
618 ReceiptStorageMode::Inline
619 );
620 assert_eq!(
621 ReceiptKind::Lifecycle.default_mode(),
622 ReceiptStorageMode::Inline
623 );
624 assert_eq!(
625 ReceiptKind::Governance.default_mode(),
626 ReceiptStorageMode::Inline
627 );
628 }
629
630 #[test]
631 fn compute_commitment_is_sha256() {
632 let h = compute_commitment(b"hello");
633 assert_eq!(
635 hex::encode(h.as_bytes()),
636 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
637 );
638 }
639
640 #[test]
641 fn inline_envelope_validates() {
642 let env = ReceiptEnvelope::inline(
643 ReceiptKind::SettlementEscrow,
644 sample_summary(),
645 b"escrow-payload".to_vec(),
646 );
647 env.validate().unwrap();
648 assert_eq!(env.storage_mode, ReceiptStorageMode::Inline);
649 assert!(env.da_pointer.is_none());
650 assert!(env.inline_payload.is_some());
651 }
652
653 #[test]
654 fn inline_envelope_with_tampered_commitment_rejected() {
655 let mut env = ReceiptEnvelope::inline(
656 ReceiptKind::Inference,
657 sample_summary(),
658 b"payload".to_vec(),
659 );
660 env.commitment = Hash::new([0u8; 32]);
661 let err = env.validate().unwrap_err();
662 match err {
663 StorageError::InvalidValue(msg) => assert!(msg.contains("commitment mismatch")),
664 other => panic!("unexpected error: {:?}", other),
665 }
666 }
667
668 #[test]
669 fn offloaded_envelope_validates() {
670 let payload = b"large-inference-blob".to_vec();
671 let commitment = compute_commitment(&payload);
672 let pointer = DaPointer {
673 backend: DaBackendId::EigenDA,
674 namespace: b"tenzro/inference".to_vec(),
675 locator: b"blob-42".to_vec(),
676 commitment_kzg: Some(vec![0xab; 48]),
677 attestation_root: Some(Hash::new([3u8; 32])),
678 };
679 let env =
680 ReceiptEnvelope::offloaded(ReceiptKind::Inference, sample_summary(), pointer, commitment);
681 env.validate().unwrap();
682 assert_eq!(env.storage_mode, ReceiptStorageMode::OffloadedDA);
683 assert!(env.inline_payload.is_none());
684 assert!(env.da_pointer.is_some());
685 }
686
687 #[test]
688 fn offloaded_envelope_without_pointer_rejected() {
689 let env = ReceiptEnvelope {
690 kind: ReceiptKind::Inference,
691 storage_mode: ReceiptStorageMode::OffloadedDA,
692 inline_summary: sample_summary(),
693 inline_payload: None,
694 da_pointer: None,
695 commitment: Hash::new([1u8; 32]),
696 mandate_ref: None,
697 };
698 let err = env.validate().unwrap_err();
699 match err {
700 StorageError::InvalidValue(msg) => assert!(msg.contains("missing da_pointer")),
701 other => panic!("unexpected error: {:?}", other),
702 }
703 }
704
705 #[test]
706 fn inline_envelope_with_pointer_rejected() {
707 let payload = b"x".to_vec();
708 let env = ReceiptEnvelope {
709 kind: ReceiptKind::SettlementEscrow,
710 storage_mode: ReceiptStorageMode::Inline,
711 inline_summary: sample_summary(),
712 inline_payload: Some(payload.clone()),
713 da_pointer: Some(DaPointer {
714 backend: DaBackendId::EigenDA,
715 namespace: vec![],
716 locator: vec![],
717 commitment_kzg: None,
718 attestation_root: None,
719 }),
720 commitment: compute_commitment(&payload),
721 mandate_ref: None,
722 };
723 let err = env.validate().unwrap_err();
724 match err {
725 StorageError::InvalidValue(msg) => assert!(msg.contains("must not carry da_pointer")),
726 other => panic!("unexpected error: {:?}", other),
727 }
728 }
729
730 #[tokio::test]
731 async fn inline_fallback_round_trips_payload() {
732 let backend = InlineFallbackBackend::new();
733 assert_eq!(backend.id(), DaBackendId::InlineFallback);
734 assert!(backend.status().healthy);
735
736 let payload = b"large-inference-blob".to_vec();
737 let pointer = backend.submit(b"tenzro/inference", &payload).await.unwrap();
738 assert_eq!(pointer.backend, DaBackendId::InlineFallback);
739 assert_eq!(pointer.namespace, b"tenzro/inference".to_vec());
740 let expected_locator = format!(
742 "fallback:{}",
743 hex::encode(compute_commitment(&payload).as_bytes())
744 );
745 assert_eq!(pointer.locator, expected_locator.as_bytes());
746
747 backend.verify_availability(&pointer).await.unwrap();
748 let fetched = backend.fetch(&pointer).await.unwrap();
749 assert_eq!(fetched, payload);
750
751 let status = backend.status();
753 assert!(status.last_submission_ms.is_some());
754 assert!(status.last_fetch_ms.is_some());
755
756 let pointer2 = backend.submit(b"tenzro/inference", &payload).await.unwrap();
758 assert_eq!(pointer2.locator, pointer.locator);
759 }
760
761 #[tokio::test]
762 async fn inline_fallback_rejects_foreign_pointer() {
763 let backend = InlineFallbackBackend::new();
764 let foreign = DaPointer {
765 backend: DaBackendId::EigenDA,
766 namespace: vec![],
767 locator: b"blob-1".to_vec(),
768 commitment_kzg: None,
769 attestation_root: None,
770 };
771 assert!(backend.fetch(&foreign).await.is_err());
772 assert!(backend.verify_availability(&foreign).await.is_err());
773 }
774
775 #[tokio::test]
776 async fn inline_fallback_missing_locator_is_key_not_found() {
777 let backend = InlineFallbackBackend::new();
778 let pointer = DaPointer {
779 backend: DaBackendId::InlineFallback,
780 namespace: vec![],
781 locator: b"fallback:deadbeef".to_vec(),
782 commitment_kzg: None,
783 attestation_root: None,
784 };
785 let err = backend.fetch(&pointer).await.unwrap_err();
786 assert!(matches!(err, StorageError::KeyNotFound(_)));
787 }
788
789 #[test]
790 fn da_backend_id_str() {
791 assert_eq!(DaBackendId::InlineFallback.as_str(), "inline_fallback");
792 assert_eq!(DaBackendId::EigenDA.as_str(), "eigenda");
793 assert_eq!(DaBackendId::Celestia.as_str(), "celestia");
794 assert_eq!(DaBackendId::Avail.as_str(), "avail");
795 assert_eq!(DaBackendId::IrohBlobs.as_str(), "iroh_blobs");
796 }
797}