tenzro-storage 0.1.0

State storage layer for Tenzro Network — Merkle trees, RocksDB, block storage, snapshots
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
//! Data availability primitives for receipt offload (Spec 7).
//!
//! High-volume receipts (inference, agent message, channel updates) ship a
//! commitment + pointer instead of the full payload — the bulk lives in an
//! external DA layer (EigenDA / Celestia / Avail). Sensitive low-volume
//! receipts (kill-switch, governance, escrow) stay inline.
//!
//! This module ships only the protocol-side primitives: the `ReceiptEnvelope`
//! container, the `DaBackend` trait, and an `InlineFallbackBackend` that
//! refuses to offload (the safe default until external backends are wired).
//! External backend impls are gated behind feature flags that land alongside
//! their respective bridge work.

use async_trait::async_trait;
use dashmap::DashMap;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::sync::Arc;
use tenzro_types::primitives::{Hash, Timestamp};
use tenzro_types::principal_chain::PrincipalChainSummary;

use crate::error::{Result, StorageError};

#[cfg(feature = "celestia")]
pub mod celestia;

#[cfg(feature = "celestia")]
pub use celestia::CelestiaBackend;

#[cfg(feature = "eigenda")]
pub mod eigenda;

#[cfg(feature = "eigenda")]
pub use eigenda::EigenDaBackend;

#[cfg(feature = "avail")]
pub mod avail;

#[cfg(feature = "avail")]
pub use avail::AvailBackend;

/// Storage mode for a receipt — whether the full payload lives on-chain or in
/// an external DA layer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReceiptStorageMode {
    /// Full payload embedded in the chain envelope. Audit-critical or
    /// low-volume receipts use this mode.
    Inline,
    /// Only commitment + pointer + summary are on-chain; the payload lives in
    /// the named DA backend.
    OffloadedDA,
}

/// Logical kind of receipt — drives the per-kind default storage mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReceiptKind {
    /// Escrow create/release/refund. Default: Inline (audit-critical).
    SettlementEscrow,
    /// Off-chain channel update. Default: OffloadedDA (high volume).
    SettlementChannel,
    /// Inference request/response pair. Default: OffloadedDA.
    Inference,
    /// Agent-to-agent message receipt. Default: OffloadedDA.
    AgentMessage,
    /// Pause/Quarantine/Terminate event. Default: Inline (audit-critical).
    KillSwitch,
    /// Identity register / agent spawn. Default: Inline.
    Lifecycle,
    /// Governance proposal or vote. Default: Inline.
    Governance,
}

impl ReceiptKind {
    /// Default storage mode for this kind. See `da-offload.md` §"Per-receipt-kind defaults".
    pub fn default_mode(&self) -> ReceiptStorageMode {
        match self {
            ReceiptKind::SettlementChannel
            | ReceiptKind::Inference
            | ReceiptKind::AgentMessage => ReceiptStorageMode::OffloadedDA,
            ReceiptKind::SettlementEscrow
            | ReceiptKind::KillSwitch
            | ReceiptKind::Lifecycle
            | ReceiptKind::Governance => ReceiptStorageMode::Inline,
        }
    }
}

/// Identifier for a DA backend implementation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DaBackendId {
    /// Inline fallback — refuses offload, used when no external backend is
    /// configured or as a safe default.
    InlineFallback,
    /// EigenDA via disperser RPC + attestation service.
    EigenDA,
    /// Celestia + Matcha namespace data.
    Celestia,
    /// Avail node + KZG opening proofs.
    Avail,
    /// iroh-blobs content-addressed transport (Phase A2, #214).
    /// Locator is the 32-byte BLAKE3 hash; backend commitment_kzg is `None`
    /// because BLAKE3 is verified end-to-end by iroh-blobs itself.
    IrohBlobs,
}

impl DaBackendId {
    pub fn as_str(&self) -> &'static str {
        match self {
            DaBackendId::InlineFallback => "inline_fallback",
            DaBackendId::EigenDA => "eigenda",
            DaBackendId::Celestia => "celestia",
            DaBackendId::Avail => "avail",
            DaBackendId::IrohBlobs => "iroh_blobs",
        }
    }
}

/// Typed pointer to a payload in an external DA layer.
///
/// `commitment_kzg` is backend-attested (KZG for Avail, BN254 for EigenDA's
/// restaking attestation, namespace-Merkle for Celestia). The chain-of-custody
/// commitment in `ReceiptEnvelope::commitment` is always SHA-256 over the
/// canonical payload bytes — verifiers check both.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DaPointer {
    pub backend: DaBackendId,
    /// Backend namespace (Celestia namespace bytes, EigenDA quorum id, …).
    pub namespace: Vec<u8>,
    /// Backend-specific locator (batch_id+chunk for Celestia, blob_id for
    /// Avail, etc.). Opaque to consensus; only the configured backend can
    /// dereference.
    pub locator: Vec<u8>,
    /// Backend-attested commitment. May differ from
    /// `ReceiptEnvelope::commitment` (KZG vs SHA-256). `None` for backends
    /// that do not produce one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commitment_kzg: Option<Vec<u8>>,
    /// EigenDA attestation service root (or analogous backend root).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub attestation_root: Option<Hash>,
}

/// Minimal triage fields surfaced to indexes regardless of storage mode.
/// Always present in `ReceiptEnvelope::inline_summary`. Index-style RPCs (list
/// by controller, summarize controller) work against summaries alone — the
/// full payload is fetched only when explicitly requested.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReceiptSummary {
    pub receipt_id: Hash,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub payer: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub payee: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub amount_wei: Option<u128>,
    pub timestamp: Timestamp,
    /// Compact view of the principal chain (Spec 5) when applicable. Full
    /// chain (delegation_scope_ids etc.) lives in the offloaded payload.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub principal_chain_summary: Option<PrincipalChainSummary>,
}

/// Reference to a signed off-chain mandate that authorized the receipt.
///
/// Settlements that flow from an AP2 cart mandate, an x402 challenge,
/// an MPP session, or a Stripe SPT carry a `MandateRef` so the on-chain
/// receipt is cryptographically tied back to the user's signed intent.
/// The mandate body itself stays off-chain (or in DA); the chain stores
/// only the hash, the protocol identifier, and the issuer DID.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MandateRef {
    /// Wire protocol identifier. Canonical values:
    /// `"ap2-cart"`, `"ap2-intent"`, `"ap2-payment"`, `"x402"`,
    /// `"mpp"`, `"stripe-spt"`, `"visa-tap"`, `"mastercard-agent-pay"`,
    /// `"capital-intent"`, `"workflow-step"`.
    pub protocol: String,
    /// `keccak256(canonical_mandate_bytes)` or SHA-256, per the protocol
    /// — the hash agreed between issuer and verifier.
    pub mandate_hash: Hash,
    /// DID of the principal (user / agent / institution) who signed the
    /// mandate.
    pub issuer_did: String,
    /// Optional mandate body URI (e.g. `https://`, `tenzro://`,
    /// `eigenda://`). When absent the verifier is expected to resolve
    /// the body out of band.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mandate_uri: Option<String>,
    /// Mandate expiry (unix seconds). `0` = no expiry.
    #[serde(default)]
    pub expires_at: u64,
}

impl MandateRef {
    /// Construct an AP2 cart-mandate reference.
    pub fn ap2_cart(mandate_hash: Hash, issuer_did: impl Into<String>) -> Self {
        Self {
            protocol: "ap2-cart".into(),
            mandate_hash,
            issuer_did: issuer_did.into(),
            mandate_uri: None,
            expires_at: 0,
        }
    }

    /// Construct an x402-challenge reference.
    pub fn x402(mandate_hash: Hash, issuer_did: impl Into<String>) -> Self {
        Self {
            protocol: "x402".into(),
            mandate_hash,
            issuer_did: issuer_did.into(),
            mandate_uri: None,
            expires_at: 0,
        }
    }
}

/// Receipt as recorded on-chain. Either embeds the full payload (Inline) or
/// records a commitment + DA pointer (OffloadedDA). The chain's only guarantee
/// is `commitment = SHA-256(canonical_payload)` — same model L2s use against
/// EigenDA / Celestia / Avail.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReceiptEnvelope {
    pub kind: ReceiptKind,
    pub storage_mode: ReceiptStorageMode,
    pub inline_summary: ReceiptSummary,
    /// Present iff `storage_mode == Inline`. Canonical-encoded payload bytes
    /// (the encoding is the receipt-kind's responsibility — bincode for
    /// settlement, JSON for inference, etc.).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub inline_payload: Option<Vec<u8>>,
    /// Present iff `storage_mode == OffloadedDA`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub da_pointer: Option<DaPointer>,
    /// SHA-256 over the canonical payload bytes — the chain-of-custody
    /// commitment, regardless of storage mode.
    pub commitment: Hash,
    /// Optional reference to a signed off-chain mandate authorizing this
    /// receipt. Present for settlements, inferences, and agent actions
    /// that flow from a signed user intent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mandate_ref: Option<MandateRef>,
}

impl ReceiptEnvelope {
    /// Build an inline envelope from a payload. `commitment` is computed as
    /// `SHA-256(payload)`.
    pub fn inline(kind: ReceiptKind, summary: ReceiptSummary, payload: Vec<u8>) -> Self {
        let commitment = compute_commitment(&payload);
        Self {
            kind,
            storage_mode: ReceiptStorageMode::Inline,
            inline_summary: summary,
            inline_payload: Some(payload),
            da_pointer: None,
            commitment,
            mandate_ref: None,
        }
    }

    /// Build an offloaded envelope. `commitment` must be computed by the
    /// caller over the original payload before submission.
    pub fn offloaded(
        kind: ReceiptKind,
        summary: ReceiptSummary,
        pointer: DaPointer,
        commitment: Hash,
    ) -> Self {
        Self {
            kind,
            storage_mode: ReceiptStorageMode::OffloadedDA,
            inline_summary: summary,
            inline_payload: None,
            da_pointer: Some(pointer),
            commitment,
            mandate_ref: None,
        }
    }

    /// Attach a signed off-chain mandate reference to this envelope. Used
    /// for settlements that flow from an AP2 cart mandate, x402 challenge,
    /// MPP session, Stripe SPT, or another signed user intent.
    pub fn with_mandate(mut self, mandate_ref: MandateRef) -> Self {
        self.mandate_ref = Some(mandate_ref);
        self
    }

    /// Validate the envelope's internal shape — fields match the declared
    /// storage mode, inline payload (if present) matches commitment.
    pub fn validate(&self) -> Result<()> {
        match self.storage_mode {
            ReceiptStorageMode::Inline => {
                let payload = self.inline_payload.as_ref().ok_or_else(|| {
                    StorageError::InvalidValue(
                        "Inline receipt envelope missing inline_payload".into(),
                    )
                })?;
                if self.da_pointer.is_some() {
                    return Err(StorageError::InvalidValue(
                        "Inline receipt envelope must not carry da_pointer".into(),
                    ));
                }
                let actual = compute_commitment(payload);
                if actual != self.commitment {
                    return Err(StorageError::InvalidValue(format!(
                        "Receipt commitment mismatch: declared {}, computed {}",
                        self.commitment, actual,
                    )));
                }
                Ok(())
            }
            ReceiptStorageMode::OffloadedDA => {
                if self.da_pointer.is_none() {
                    return Err(StorageError::InvalidValue(
                        "Offloaded receipt envelope missing da_pointer".into(),
                    ));
                }
                if self.inline_payload.is_some() {
                    return Err(StorageError::InvalidValue(
                        "Offloaded receipt envelope must not carry inline_payload".into(),
                    ));
                }
                Ok(())
            }
        }
    }
}

/// Compute the chain-of-custody commitment over a canonical payload.
/// SHA-256 — matches the rest of Tenzro's hash usage.
pub fn compute_commitment(payload: &[u8]) -> Hash {
    let mut hasher = Sha256::new();
    hasher.update(payload);
    let result = hasher.finalize();
    let mut bytes = [0u8; 32];
    bytes.copy_from_slice(&result);
    Hash::new(bytes)
}

/// Status snapshot for a configured DA backend, surfaced to operators via
/// `tenzro_getDaBackends`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DaBackendStatus {
    pub backend: DaBackendId,
    pub healthy: bool,
    /// Last successful submission, ms-since-epoch. `None` if never submitted.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_submission_ms: Option<i64>,
    /// Last successful fetch, ms-since-epoch. `None` if never fetched.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_fetch_ms: Option<i64>,
    /// Recent error rate in basis points (0-10000). 0 = fully healthy.
    pub error_rate_bps: u16,
}

/// External data availability backend.
///
/// `submit` writes a payload to the backend and returns a typed pointer.
/// `fetch` resolves a pointer back to the payload (commitment verification is
/// the caller's responsibility — fetched bytes must hash to the envelope's
/// `commitment` before being trusted). `verify_availability` is a cheap "is
/// this pointer dereferenceable right now" probe that does not transfer the
/// payload.
#[async_trait]
pub trait DaBackend: Send + Sync {
    fn id(&self) -> DaBackendId;

    fn status(&self) -> DaBackendStatus;

    async fn submit(&self, namespace: &[u8], payload: &[u8]) -> Result<DaPointer>;

    async fn fetch(&self, pointer: &DaPointer) -> Result<Vec<u8>>;

    async fn verify_availability(&self, pointer: &DaPointer) -> Result<()>;
}

/// Default backend used when no external DA layer (EigenDA / Celestia / Avail)
/// is configured. Stores submitted payloads in an in-memory `DashMap` keyed by
/// a synthetic locator `fallback:<sha256_hex>` and round-trips them through
/// `fetch`. This keeps the receipt-offload path functional pre-mainnet so the
/// rest of the system (commitments, indices, RPC) can be exercised end-to-end
/// without standing up a real DA layer.
///
/// Optionally takes a [`crate::kv::KvStore`] handle (`with_storage`) so the
/// fallback DashMap is backed by `CF_METADATA` under the `da_fallback:`
/// prefix. Without storage the cache is in-process only and submissions do
/// not survive a restart — fine for tests and unit work, **not fine** for
/// any persisted receipt that needs to round-trip through `fetch` after a
/// process restart. Node startup and `RocksDbChannelStorage` wire in the
/// shared `RocksDbStore` so consensus-relevant offloaded payloads survive
/// restarts even before EigenDA / Celestia / Avail land.
///
/// Operators see `inline_fallback` in `tenzro_getDaBackends` and know external
/// availability is not guaranteed (no signed attestations, no cross-node
/// replication). Production deployments swap this out for a real backend
/// behind the matching feature flag.
pub struct InlineFallbackBackend {
    /// Synthetic-locator keyed payload store. Locator is
    /// `fallback:<sha256_hex>` of the payload, so the same payload submitted
    /// twice deduplicates to the same entry.
    store: DashMap<Vec<u8>, Vec<u8>>,
    /// Optional KvStore for cross-restart durability. When `Some`, every
    /// `submit_sync` mirror-writes the payload under `da_fallback:<locator>`
    /// in `CF_METADATA`, and `fetch_sync` falls back to the store on a
    /// DashMap miss (re-populating the cache).
    storage: Option<Arc<dyn crate::kv::KvStore>>,
    /// Last-submission / last-fetch timestamps (ms-since-epoch) for the
    /// status snapshot.
    last_submission_ms: Mutex<Option<i64>>,
    last_fetch_ms: Mutex<Option<i64>>,
}

impl InlineFallbackBackend {
    /// Storage prefix used when `with_storage` is wired. Lives in
    /// `CF_METADATA` to avoid colliding with any column-family-specific
    /// receipt index.
    const STORAGE_PREFIX: &'static [u8] = b"da_fallback:";

    pub fn new() -> Self {
        Self {
            store: DashMap::new(),
            storage: None,
            last_submission_ms: Mutex::new(None),
            last_fetch_ms: Mutex::new(None),
        }
    }

    /// Wire a durable `KvStore` so submitted payloads survive process
    /// restarts under `CF_METADATA` / `da_fallback:<locator>`.
    pub fn with_storage(mut self, storage: Arc<dyn crate::kv::KvStore>) -> Self {
        self.storage = Some(storage);
        self
    }

    pub fn arc() -> Arc<dyn DaBackend> {
        Arc::new(Self::new())
    }

    fn storage_key(locator: &[u8]) -> Vec<u8> {
        [Self::STORAGE_PREFIX, locator].concat()
    }

    /// Locator format: `fallback:<sha256_hex>` over the payload. Stable across
    /// duplicate submissions.
    fn make_locator(payload: &[u8]) -> Vec<u8> {
        let commitment = compute_commitment(payload);
        format!("fallback:{}", hex::encode(commitment.as_bytes())).into_bytes()
    }

    fn now_ms() -> i64 {
        Timestamp::now().0
    }

    /// Synchronous submit helper for callers in non-async contexts.
    ///
    /// Mirrors [`DaBackend::submit`] but without the async wrapper — the
    /// inline fallback does pure in-memory work, so there is nothing to
    /// suspend on. Used by sync persistence paths (channel storage, etc.)
    /// that wrap their payloads in a `ReceiptEnvelope` without taking a
    /// `&dyn DaBackend` dependency.
    ///
    /// When backed by a [`crate::kv::KvStore`] (via `with_storage`) the
    /// payload is also mirror-written under `CF_METADATA` /
    /// `da_fallback:<locator>` for cross-restart durability. Storage write
    /// failures are downgraded to a `tracing::warn` — the in-memory cache
    /// is still populated, so the same-process round-trip stays
    /// functional.
    pub fn submit_sync(&self, namespace: &[u8], payload: &[u8]) -> DaPointer {
        let locator = Self::make_locator(payload);
        self.store.insert(locator.clone(), payload.to_vec());
        if let Some(storage) = &self.storage
            && let Err(e) = storage.put(crate::kv::CF_METADATA, &Self::storage_key(&locator), payload) {
                tracing::warn!(
                    "InlineFallbackBackend: failed to mirror-write payload to storage: {}",
                    e
                );
            }
        *self.last_submission_ms.lock() = Some(Self::now_ms());
        DaPointer {
            backend: DaBackendId::InlineFallback,
            namespace: namespace.to_vec(),
            locator,
            commitment_kzg: None,
            attestation_root: None,
        }
    }

    /// Synchronous fetch helper. See [`Self::submit_sync`].
    ///
    /// Looks up the in-memory cache first; on miss, falls back to the
    /// configured `KvStore` (re-populating the cache on hit) so submissions
    /// from a previous process incarnation are still resolvable.
    pub fn fetch_sync(&self, pointer: &DaPointer) -> Result<Vec<u8>> {
        if pointer.backend != DaBackendId::InlineFallback {
            return Err(StorageError::Generic(format!(
                "InlineFallbackBackend cannot fetch pointer with backend {:?}",
                pointer.backend
            )));
        }
        if let Some(entry) = self.store.get(&pointer.locator) {
            *self.last_fetch_ms.lock() = Some(Self::now_ms());
            return Ok(entry.value().clone());
        }
        if let Some(storage) = &self.storage
            && let Some(bytes) = storage
                .get(crate::kv::CF_METADATA, &Self::storage_key(&pointer.locator))
                .map_err(|e| StorageError::Generic(format!("storage read: {}", e)))?
            {
                self.store.insert(pointer.locator.clone(), bytes.clone());
                *self.last_fetch_ms.lock() = Some(Self::now_ms());
                return Ok(bytes);
            }
        Err(StorageError::KeyNotFound(format!(
            "InlineFallbackBackend has no payload for locator {}",
            String::from_utf8_lossy(&pointer.locator)
        )))
    }
}

impl std::fmt::Debug for InlineFallbackBackend {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("InlineFallbackBackend")
            .field("entries", &self.store.len())
            .finish()
    }
}

impl Default for InlineFallbackBackend {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl DaBackend for InlineFallbackBackend {
    fn id(&self) -> DaBackendId {
        DaBackendId::InlineFallback
    }

    fn status(&self) -> DaBackendStatus {
        DaBackendStatus {
            backend: DaBackendId::InlineFallback,
            healthy: true,
            last_submission_ms: *self.last_submission_ms.lock(),
            last_fetch_ms: *self.last_fetch_ms.lock(),
            error_rate_bps: 0,
        }
    }

    async fn submit(&self, namespace: &[u8], payload: &[u8]) -> Result<DaPointer> {
        Ok(self.submit_sync(namespace, payload))
    }

    async fn fetch(&self, pointer: &DaPointer) -> Result<Vec<u8>> {
        self.fetch_sync(pointer)
    }

    async fn verify_availability(&self, pointer: &DaPointer) -> Result<()> {
        if pointer.backend != DaBackendId::InlineFallback {
            return Err(StorageError::Generic(format!(
                "InlineFallbackBackend cannot verify pointer with backend {:?}",
                pointer.backend
            )));
        }
        if self.store.contains_key(&pointer.locator) {
            return Ok(());
        }
        if let Some(storage) = &self.storage
            && storage
                .get(crate::kv::CF_METADATA, &Self::storage_key(&pointer.locator))
                .map_err(|e| StorageError::Generic(format!("storage read: {}", e)))?
                .is_some()
            {
                return Ok(());
            }
        Err(StorageError::KeyNotFound(format!(
            "InlineFallbackBackend has no payload for locator {}",
            String::from_utf8_lossy(&pointer.locator)
        )))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample_summary() -> ReceiptSummary {
        ReceiptSummary {
            receipt_id: Hash::new([7u8; 32]),
            payer: Some("did:tenzro:human:alice".into()),
            payee: Some("did:tenzro:machine:bob".into()),
            amount_wei: Some(123_456_789),
            timestamp: Timestamp::new(1_700_000_000_000),
            principal_chain_summary: None,
        }
    }

    #[test]
    fn default_mode_per_kind_matches_spec() {
        assert_eq!(
            ReceiptKind::SettlementEscrow.default_mode(),
            ReceiptStorageMode::Inline
        );
        assert_eq!(
            ReceiptKind::SettlementChannel.default_mode(),
            ReceiptStorageMode::OffloadedDA
        );
        assert_eq!(
            ReceiptKind::Inference.default_mode(),
            ReceiptStorageMode::OffloadedDA
        );
        assert_eq!(
            ReceiptKind::AgentMessage.default_mode(),
            ReceiptStorageMode::OffloadedDA
        );
        assert_eq!(
            ReceiptKind::KillSwitch.default_mode(),
            ReceiptStorageMode::Inline
        );
        assert_eq!(
            ReceiptKind::Lifecycle.default_mode(),
            ReceiptStorageMode::Inline
        );
        assert_eq!(
            ReceiptKind::Governance.default_mode(),
            ReceiptStorageMode::Inline
        );
    }

    #[test]
    fn compute_commitment_is_sha256() {
        let h = compute_commitment(b"hello");
        // SHA-256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
        assert_eq!(
            hex::encode(h.as_bytes()),
            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
        );
    }

    #[test]
    fn inline_envelope_validates() {
        let env = ReceiptEnvelope::inline(
            ReceiptKind::SettlementEscrow,
            sample_summary(),
            b"escrow-payload".to_vec(),
        );
        env.validate().unwrap();
        assert_eq!(env.storage_mode, ReceiptStorageMode::Inline);
        assert!(env.da_pointer.is_none());
        assert!(env.inline_payload.is_some());
    }

    #[test]
    fn inline_envelope_with_tampered_commitment_rejected() {
        let mut env = ReceiptEnvelope::inline(
            ReceiptKind::Inference,
            sample_summary(),
            b"payload".to_vec(),
        );
        env.commitment = Hash::new([0u8; 32]);
        let err = env.validate().unwrap_err();
        match err {
            StorageError::InvalidValue(msg) => assert!(msg.contains("commitment mismatch")),
            other => panic!("unexpected error: {:?}", other),
        }
    }

    #[test]
    fn offloaded_envelope_validates() {
        let payload = b"large-inference-blob".to_vec();
        let commitment = compute_commitment(&payload);
        let pointer = DaPointer {
            backend: DaBackendId::EigenDA,
            namespace: b"tenzro/inference".to_vec(),
            locator: b"blob-42".to_vec(),
            commitment_kzg: Some(vec![0xab; 48]),
            attestation_root: Some(Hash::new([3u8; 32])),
        };
        let env =
            ReceiptEnvelope::offloaded(ReceiptKind::Inference, sample_summary(), pointer, commitment);
        env.validate().unwrap();
        assert_eq!(env.storage_mode, ReceiptStorageMode::OffloadedDA);
        assert!(env.inline_payload.is_none());
        assert!(env.da_pointer.is_some());
    }

    #[test]
    fn offloaded_envelope_without_pointer_rejected() {
        let env = ReceiptEnvelope {
            kind: ReceiptKind::Inference,
            storage_mode: ReceiptStorageMode::OffloadedDA,
            inline_summary: sample_summary(),
            inline_payload: None,
            da_pointer: None,
            commitment: Hash::new([1u8; 32]),
            mandate_ref: None,
        };
        let err = env.validate().unwrap_err();
        match err {
            StorageError::InvalidValue(msg) => assert!(msg.contains("missing da_pointer")),
            other => panic!("unexpected error: {:?}", other),
        }
    }

    #[test]
    fn inline_envelope_with_pointer_rejected() {
        let payload = b"x".to_vec();
        let env = ReceiptEnvelope {
            kind: ReceiptKind::SettlementEscrow,
            storage_mode: ReceiptStorageMode::Inline,
            inline_summary: sample_summary(),
            inline_payload: Some(payload.clone()),
            da_pointer: Some(DaPointer {
                backend: DaBackendId::EigenDA,
                namespace: vec![],
                locator: vec![],
                commitment_kzg: None,
                attestation_root: None,
            }),
            commitment: compute_commitment(&payload),
            mandate_ref: None,
        };
        let err = env.validate().unwrap_err();
        match err {
            StorageError::InvalidValue(msg) => assert!(msg.contains("must not carry da_pointer")),
            other => panic!("unexpected error: {:?}", other),
        }
    }

    #[tokio::test]
    async fn inline_fallback_round_trips_payload() {
        let backend = InlineFallbackBackend::new();
        assert_eq!(backend.id(), DaBackendId::InlineFallback);
        assert!(backend.status().healthy);

        let payload = b"large-inference-blob".to_vec();
        let pointer = backend.submit(b"tenzro/inference", &payload).await.unwrap();
        assert_eq!(pointer.backend, DaBackendId::InlineFallback);
        assert_eq!(pointer.namespace, b"tenzro/inference".to_vec());
        // Locator is `fallback:<sha256_hex>` of the payload.
        let expected_locator = format!(
            "fallback:{}",
            hex::encode(compute_commitment(&payload).as_bytes())
        );
        assert_eq!(pointer.locator, expected_locator.as_bytes());

        backend.verify_availability(&pointer).await.unwrap();
        let fetched = backend.fetch(&pointer).await.unwrap();
        assert_eq!(fetched, payload);

        // Status reflects the submission/fetch.
        let status = backend.status();
        assert!(status.last_submission_ms.is_some());
        assert!(status.last_fetch_ms.is_some());

        // Submitting the same payload again is idempotent — same locator.
        let pointer2 = backend.submit(b"tenzro/inference", &payload).await.unwrap();
        assert_eq!(pointer2.locator, pointer.locator);
    }

    #[tokio::test]
    async fn inline_fallback_rejects_foreign_pointer() {
        let backend = InlineFallbackBackend::new();
        let foreign = DaPointer {
            backend: DaBackendId::EigenDA,
            namespace: vec![],
            locator: b"blob-1".to_vec(),
            commitment_kzg: None,
            attestation_root: None,
        };
        assert!(backend.fetch(&foreign).await.is_err());
        assert!(backend.verify_availability(&foreign).await.is_err());
    }

    #[tokio::test]
    async fn inline_fallback_missing_locator_is_key_not_found() {
        let backend = InlineFallbackBackend::new();
        let pointer = DaPointer {
            backend: DaBackendId::InlineFallback,
            namespace: vec![],
            locator: b"fallback:deadbeef".to_vec(),
            commitment_kzg: None,
            attestation_root: None,
        };
        let err = backend.fetch(&pointer).await.unwrap_err();
        assert!(matches!(err, StorageError::KeyNotFound(_)));
    }

    #[test]
    fn da_backend_id_str() {
        assert_eq!(DaBackendId::InlineFallback.as_str(), "inline_fallback");
        assert_eq!(DaBackendId::EigenDA.as_str(), "eigenda");
        assert_eq!(DaBackendId::Celestia.as_str(), "celestia");
        assert_eq!(DaBackendId::Avail.as_str(), "avail");
        assert_eq!(DaBackendId::IrohBlobs.as_str(), "iroh_blobs");
    }
}