Skip to main content

mnemo_deal/
ledger.rs

1//! Append-only deal ledger (v0.4.0 P1-5).
2
3use std::sync::Mutex;
4
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8use crate::envelope::DealEnvelope;
9
10/// Index into the ledger. Stable across replays — appending an
11/// envelope at offset N does not reshuffle earlier offsets.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
13pub struct LedgerOffset(pub u64);
14
15#[derive(Debug, Error, PartialEq)]
16pub enum LedgerError {
17    #[error("envelope's prev_hash {observed:?} does not match ledger head {expected:?}")]
18    BrokenChain {
19        expected: [u8; 32],
20        observed: [u8; 32],
21    },
22    #[error("ledger lock poisoned — internal invariant violated")]
23    LockPoisoned,
24}
25
26pub trait DealLedger: Send + Sync {
27    fn append(&self, e: DealEnvelope) -> Result<LedgerOffset, LedgerError>;
28    fn replay(&self, range: std::ops::Range<u64>) -> Vec<DealEnvelope>;
29    fn head_hash(&self) -> [u8; 32];
30    fn len(&self) -> u64;
31    fn is_empty(&self) -> bool {
32        self.len() == 0
33    }
34}
35
36/// In-memory implementation. Production deployments swap in a
37/// disk-backed ledger (sled, redb, or DuckDB). Same trait so the
38/// host code is implementation-agnostic.
39#[derive(Default)]
40pub struct InMemoryDealLedger {
41    rows: Mutex<Vec<DealEnvelope>>,
42    head: Mutex<[u8; 32]>,
43}
44
45impl InMemoryDealLedger {
46    pub fn new() -> Self {
47        Self::default()
48    }
49}
50
51impl DealLedger for InMemoryDealLedger {
52    fn append(&self, e: DealEnvelope) -> Result<LedgerOffset, LedgerError> {
53        let mut head = self.head.lock().map_err(|_| LedgerError::LockPoisoned)?;
54        if e.prev_hash != *head {
55            return Err(LedgerError::BrokenChain {
56                expected: *head,
57                observed: e.prev_hash,
58            });
59        }
60        let next_head = e.next_prev_hash();
61        let mut rows = self.rows.lock().map_err(|_| LedgerError::LockPoisoned)?;
62        let offset = LedgerOffset(rows.len() as u64);
63        rows.push(e);
64        *head = next_head;
65        Ok(offset)
66    }
67
68    fn replay(&self, range: std::ops::Range<u64>) -> Vec<DealEnvelope> {
69        let rows = match self.rows.lock() {
70            Ok(g) => g,
71            Err(_) => return Vec::new(),
72        };
73        let lo = range.start as usize;
74        let hi = (range.end as usize).min(rows.len());
75        if lo >= hi {
76            return Vec::new();
77        }
78        rows[lo..hi].to_vec()
79    }
80
81    fn head_hash(&self) -> [u8; 32] {
82        self.head.lock().map(|g| *g).unwrap_or([0u8; 32])
83    }
84
85    fn len(&self) -> u64 {
86        self.rows.lock().map(|g| g.len() as u64).unwrap_or(0)
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use std::time::{Duration, SystemTime};
93
94    use super::*;
95
96    fn append_one(
97        ledger: &InMemoryDealLedger,
98        key: &[u8; 32],
99        prev: [u8; 32],
100        i: u64,
101    ) -> DealEnvelope {
102        let e = DealEnvelope::sign(
103            format!("buyer-{i}"),
104            format!("seller-{i}"),
105            serde_json::json!({"i": i}),
106            prev,
107            SystemTime::UNIX_EPOCH + Duration::from_secs(i),
108            key,
109        )
110        .unwrap();
111        ledger.append(e.clone()).unwrap();
112        e
113    }
114
115    #[test]
116    fn append_advances_offset_and_head() {
117        let ledger = InMemoryDealLedger::new();
118        let key = [7u8; 32];
119        let mut prev = [0u8; 32];
120        for i in 0..3 {
121            let e = append_one(&ledger, &key, prev, i);
122            prev = e.next_prev_hash();
123        }
124        assert_eq!(ledger.len(), 3);
125        assert_eq!(ledger.head_hash(), prev);
126    }
127
128    #[test]
129    fn broken_chain_is_rejected() {
130        let ledger = InMemoryDealLedger::new();
131        let key = [7u8; 32];
132        let _e0 = append_one(&ledger, &key, [0u8; 32], 0);
133        // Build a second envelope that doesn't reference the head;
134        // appending must fail.
135        let bad = DealEnvelope::sign(
136            "x",
137            "y",
138            serde_json::json!({}),
139            [0u8; 32], // wrong prev — head moved
140            SystemTime::UNIX_EPOCH,
141            &key,
142        )
143        .unwrap();
144        let err = ledger.append(bad).unwrap_err();
145        assert!(matches!(err, LedgerError::BrokenChain { .. }));
146    }
147
148    #[test]
149    fn replay_range_returns_subset() {
150        let ledger = InMemoryDealLedger::new();
151        let key = [7u8; 32];
152        let mut prev = [0u8; 32];
153        for i in 0..5 {
154            let e = append_one(&ledger, &key, prev, i);
155            prev = e.next_prev_hash();
156        }
157        let mid = ledger.replay(1..4);
158        assert_eq!(mid.len(), 3);
159        assert_eq!(mid[0].buyer, "buyer-1");
160        assert_eq!(mid[2].buyer, "buyer-3");
161    }
162}