Skip to main content

tenzro_storage/da/
mod.rs

1//! Data availability primitives for receipt offload (Spec 7).
2//!
3//! High-volume receipts (inference, agent message, channel updates) ship a
4//! commitment + pointer instead of the full payload — the bulk lives in an
5//! external DA layer (EigenDA / Celestia / Avail). Sensitive low-volume
6//! receipts (kill-switch, governance, escrow) stay inline.
7//!
8//! This module ships only the protocol-side primitives: the `ReceiptEnvelope`
9//! container, the `DaBackend` trait, and an `InlineFallbackBackend` that
10//! refuses to offload (the safe default until external backends are wired).
11//! External backend impls are gated behind feature flags that land alongside
12//! their respective bridge work.
13
14use 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/// Storage mode for a receipt — whether the full payload lives on-chain or in
44/// an external DA layer.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46pub enum ReceiptStorageMode {
47    /// Full payload embedded in the chain envelope. Audit-critical or
48    /// low-volume receipts use this mode.
49    Inline,
50    /// Only commitment + pointer + summary are on-chain; the payload lives in
51    /// the named DA backend.
52    OffloadedDA,
53}
54
55/// Logical kind of receipt — drives the per-kind default storage mode.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57pub enum ReceiptKind {
58    /// Escrow create/release/refund. Default: Inline (audit-critical).
59    SettlementEscrow,
60    /// Off-chain channel update. Default: OffloadedDA (high volume).
61    SettlementChannel,
62    /// Inference request/response pair. Default: OffloadedDA.
63    Inference,
64    /// Agent-to-agent message receipt. Default: OffloadedDA.
65    AgentMessage,
66    /// Pause/Quarantine/Terminate event. Default: Inline (audit-critical).
67    KillSwitch,
68    /// Identity register / agent spawn. Default: Inline.
69    Lifecycle,
70    /// Governance proposal or vote. Default: Inline.
71    Governance,
72}
73
74impl ReceiptKind {
75    /// Default storage mode for this kind. See `da-offload.md` §"Per-receipt-kind defaults".
76    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/// Identifier for a DA backend implementation.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
91pub enum DaBackendId {
92    /// Inline fallback — refuses offload, used when no external backend is
93    /// configured or as a safe default.
94    InlineFallback,
95    /// EigenDA via disperser RPC + attestation service.
96    EigenDA,
97    /// Celestia + Matcha namespace data.
98    Celestia,
99    /// Avail node + KZG opening proofs.
100    Avail,
101    /// iroh-blobs content-addressed transport (Phase A2, #214).
102    /// Locator is the 32-byte BLAKE3 hash; backend commitment_kzg is `None`
103    /// because BLAKE3 is verified end-to-end by iroh-blobs itself.
104    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/// Typed pointer to a payload in an external DA layer.
120///
121/// `commitment_kzg` is backend-attested (KZG for Avail, BN254 for EigenDA's
122/// restaking attestation, namespace-Merkle for Celestia). The chain-of-custody
123/// commitment in `ReceiptEnvelope::commitment` is always SHA-256 over the
124/// canonical payload bytes — verifiers check both.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct DaPointer {
127    pub backend: DaBackendId,
128    /// Backend namespace (Celestia namespace bytes, EigenDA quorum id, …).
129    pub namespace: Vec<u8>,
130    /// Backend-specific locator (batch_id+chunk for Celestia, blob_id for
131    /// Avail, etc.). Opaque to consensus; only the configured backend can
132    /// dereference.
133    pub locator: Vec<u8>,
134    /// Backend-attested commitment. May differ from
135    /// `ReceiptEnvelope::commitment` (KZG vs SHA-256). `None` for backends
136    /// that do not produce one.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub commitment_kzg: Option<Vec<u8>>,
139    /// EigenDA attestation service root (or analogous backend root).
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub attestation_root: Option<Hash>,
142}
143
144/// Minimal triage fields surfaced to indexes regardless of storage mode.
145/// Always present in `ReceiptEnvelope::inline_summary`. Index-style RPCs (list
146/// by controller, summarize controller) work against summaries alone — the
147/// full payload is fetched only when explicitly requested.
148#[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    /// Compact view of the principal chain (Spec 5) when applicable. Full
159    /// chain (delegation_scope_ids etc.) lives in the offloaded payload.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub principal_chain_summary: Option<PrincipalChainSummary>,
162}
163
164/// Reference to a signed off-chain mandate that authorized the receipt.
165///
166/// Settlements that flow from an AP2 cart mandate, an x402 challenge,
167/// an MPP session, or a Stripe SPT carry a `MandateRef` so the on-chain
168/// receipt is cryptographically tied back to the user's signed intent.
169/// The mandate body itself stays off-chain (or in DA); the chain stores
170/// only the hash, the protocol identifier, and the issuer DID.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct MandateRef {
173    /// Wire protocol identifier. Canonical values:
174    /// `"ap2-cart"`, `"ap2-intent"`, `"ap2-payment"`, `"x402"`,
175    /// `"mpp"`, `"stripe-spt"`, `"visa-tap"`, `"mastercard-agent-pay"`,
176    /// `"capital-intent"`, `"workflow-step"`.
177    pub protocol: String,
178    /// `keccak256(canonical_mandate_bytes)` or SHA-256, per the protocol
179    /// — the hash agreed between issuer and verifier.
180    pub mandate_hash: Hash,
181    /// DID of the principal (user / agent / institution) who signed the
182    /// mandate.
183    pub issuer_did: String,
184    /// Optional mandate body URI (e.g. `https://`, `tenzro://`,
185    /// `eigenda://`). When absent the verifier is expected to resolve
186    /// the body out of band.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub mandate_uri: Option<String>,
189    /// Mandate expiry (unix seconds). `0` = no expiry.
190    #[serde(default)]
191    pub expires_at: u64,
192}
193
194impl MandateRef {
195    /// Construct an AP2 cart-mandate reference.
196    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    /// Construct an x402-challenge reference.
207    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/// Receipt as recorded on-chain. Either embeds the full payload (Inline) or
219/// records a commitment + DA pointer (OffloadedDA). The chain's only guarantee
220/// is `commitment = SHA-256(canonical_payload)` — same model L2s use against
221/// EigenDA / Celestia / Avail.
222#[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    /// Present iff `storage_mode == Inline`. Canonical-encoded payload bytes
228    /// (the encoding is the receipt-kind's responsibility — bincode for
229    /// settlement, JSON for inference, etc.).
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub inline_payload: Option<Vec<u8>>,
232    /// Present iff `storage_mode == OffloadedDA`.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub da_pointer: Option<DaPointer>,
235    /// SHA-256 over the canonical payload bytes — the chain-of-custody
236    /// commitment, regardless of storage mode.
237    pub commitment: Hash,
238    /// Optional reference to a signed off-chain mandate authorizing this
239    /// receipt. Present for settlements, inferences, and agent actions
240    /// that flow from a signed user intent.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub mandate_ref: Option<MandateRef>,
243}
244
245impl ReceiptEnvelope {
246    /// Build an inline envelope from a payload. `commitment` is computed as
247    /// `SHA-256(payload)`.
248    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    /// Build an offloaded envelope. `commitment` must be computed by the
262    /// caller over the original payload before submission.
263    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    /// Attach a signed off-chain mandate reference to this envelope. Used
281    /// for settlements that flow from an AP2 cart mandate, x402 challenge,
282    /// MPP session, Stripe SPT, or another signed user intent.
283    pub fn with_mandate(mut self, mandate_ref: MandateRef) -> Self {
284        self.mandate_ref = Some(mandate_ref);
285        self
286    }
287
288    /// Validate the envelope's internal shape — fields match the declared
289    /// storage mode, inline payload (if present) matches commitment.
290    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
329/// Compute the chain-of-custody commitment over a canonical payload.
330/// SHA-256 — matches the rest of Tenzro's hash usage.
331pub 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/// Status snapshot for a configured DA backend, surfaced to operators via
341/// `tenzro_getDaBackends`.
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343pub struct DaBackendStatus {
344    pub backend: DaBackendId,
345    pub healthy: bool,
346    /// Last successful submission, ms-since-epoch. `None` if never submitted.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub last_submission_ms: Option<i64>,
349    /// Last successful fetch, ms-since-epoch. `None` if never fetched.
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub last_fetch_ms: Option<i64>,
352    /// Recent error rate in basis points (0-10000). 0 = fully healthy.
353    pub error_rate_bps: u16,
354}
355
356/// External data availability backend.
357///
358/// `submit` writes a payload to the backend and returns a typed pointer.
359/// `fetch` resolves a pointer back to the payload (commitment verification is
360/// the caller's responsibility — fetched bytes must hash to the envelope's
361/// `commitment` before being trusted). `verify_availability` is a cheap "is
362/// this pointer dereferenceable right now" probe that does not transfer the
363/// payload.
364#[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
377/// Default backend used when no external DA layer (EigenDA / Celestia / Avail)
378/// is configured. Stores submitted payloads in an in-memory `DashMap` keyed by
379/// a synthetic locator `fallback:<sha256_hex>` and round-trips them through
380/// `fetch`. This keeps the receipt-offload path functional pre-mainnet so the
381/// rest of the system (commitments, indices, RPC) can be exercised end-to-end
382/// without standing up a real DA layer.
383///
384/// Optionally takes a [`crate::kv::KvStore`] handle (`with_storage`) so the
385/// fallback DashMap is backed by `CF_METADATA` under the `da_fallback:`
386/// prefix. Without storage the cache is in-process only and submissions do
387/// not survive a restart — fine for tests and unit work, **not fine** for
388/// any persisted receipt that needs to round-trip through `fetch` after a
389/// process restart. Node startup and `RocksDbChannelStorage` wire in the
390/// shared `RocksDbStore` so consensus-relevant offloaded payloads survive
391/// restarts even before EigenDA / Celestia / Avail land.
392///
393/// Operators see `inline_fallback` in `tenzro_getDaBackends` and know external
394/// availability is not guaranteed (no signed attestations, no cross-node
395/// replication). Production deployments swap this out for a real backend
396/// behind the matching feature flag.
397pub struct InlineFallbackBackend {
398    /// Synthetic-locator keyed payload store. Locator is
399    /// `fallback:<sha256_hex>` of the payload, so the same payload submitted
400    /// twice deduplicates to the same entry.
401    store: DashMap<Vec<u8>, Vec<u8>>,
402    /// Optional KvStore for cross-restart durability. When `Some`, every
403    /// `submit_sync` mirror-writes the payload under `da_fallback:<locator>`
404    /// in `CF_METADATA`, and `fetch_sync` falls back to the store on a
405    /// DashMap miss (re-populating the cache).
406    storage: Option<Arc<dyn crate::kv::KvStore>>,
407    /// Last-submission / last-fetch timestamps (ms-since-epoch) for the
408    /// status snapshot.
409    last_submission_ms: Mutex<Option<i64>>,
410    last_fetch_ms: Mutex<Option<i64>>,
411}
412
413impl InlineFallbackBackend {
414    /// Storage prefix used when `with_storage` is wired. Lives in
415    /// `CF_METADATA` to avoid colliding with any column-family-specific
416    /// receipt index.
417    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    /// Wire a durable `KvStore` so submitted payloads survive process
429    /// restarts under `CF_METADATA` / `da_fallback:<locator>`.
430    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    /// Locator format: `fallback:<sha256_hex>` over the payload. Stable across
444    /// duplicate submissions.
445    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    /// Synchronous submit helper for callers in non-async contexts.
455    ///
456    /// Mirrors [`DaBackend::submit`] but without the async wrapper — the
457    /// inline fallback does pure in-memory work, so there is nothing to
458    /// suspend on. Used by sync persistence paths (channel storage, etc.)
459    /// that wrap their payloads in a `ReceiptEnvelope` without taking a
460    /// `&dyn DaBackend` dependency.
461    ///
462    /// When backed by a [`crate::kv::KvStore`] (via `with_storage`) the
463    /// payload is also mirror-written under `CF_METADATA` /
464    /// `da_fallback:<locator>` for cross-restart durability. Storage write
465    /// failures are downgraded to a `tracing::warn` — the in-memory cache
466    /// is still populated, so the same-process round-trip stays
467    /// functional.
468    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    /// Synchronous fetch helper. See [`Self::submit_sync`].
489    ///
490    /// Looks up the in-memory cache first; on miss, falls back to the
491    /// configured `KvStore` (re-populating the cache on hit) so submissions
492    /// from a previous process incarnation are still resolvable.
493    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        // SHA-256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
634        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        // Locator is `fallback:<sha256_hex>` of the payload.
741        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        // Status reflects the submission/fetch.
752        let status = backend.status();
753        assert!(status.last_submission_ms.is_some());
754        assert!(status.last_fetch_ms.is_some());
755
756        // Submitting the same payload again is idempotent — same locator.
757        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}