Skip to main content

mkit_core/
history.rs

1//! In-memory Merkle Mountain Belt (MMB) for canonical first-parent ancestry.
2//! Durable snapshots and their trusted context live in [`AncestrySnapshot`].
3//!
4//! An MMB is chosen over the older Merkle Mountain Range (MMR) for the same
5//! reason `commonware-storage` added it (<https://arxiv.org/abs/2511.13582>):
6//! appending a leaf to an MMR can create up to `O(log N)` new internal nodes
7//! (whenever a run of same-height peaks all merge at once), while an MMB
8//! creates at most one, every time — a real property for `mkit commit`'s
9//! one-append-per-publish pattern, since it bounds the worst case instead of
10//! only the amortized average. The two share the same generic node-storage,
11//! pruning, root, and proof machinery in `commonware-storage`'s `merkle`
12//! module (only the peak/position topology differs), so this is the same
13//! `Bagging::ForwardFold`-bagged, BLAKE3-digest tree SPEC-HISTORY-PROOF
14//! already specified, over a different (more efficient) forest shape.
15use crate::hash::{HASH_LEN, Hash};
16use commonware_cryptography::{Blake3, Hasher as CHasher};
17use commonware_storage::merkle::Bagging;
18use commonware_storage::merkle::mmb::{
19    Location as MmbLocation, Proof as MmbProof, StandardHasher, mem::Mmb as MemMmb,
20};
21pub(crate) mod ancestry;
22pub use ancestry::{
23    AncestryDescriptor, AncestrySnapshot, TrustedAncestryDescriptor, verify_ancestry,
24};
25
26// A cryptographic format parameter shared by producers and verifiers.
27const HISTORY_BAGGING: Bagging = Bagging::ForwardFold;
28fn history_hasher() -> StandardHasher<Blake3> {
29    StandardHasher::new(HISTORY_BAGGING)
30}
31
32/// Zero-based leaf index within one ancestry generation.
33#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
34pub struct Position(pub u64);
35impl Position {
36    #[must_use]
37    pub const fn as_u64(self) -> u64 {
38        self.0
39    }
40}
41/// Inclusion proof with the wire shape specified by SPEC-HISTORY-PROOF.
42pub type InclusionProof = MmbProof<<Blake3 as CHasher>::Digest>;
43
44/// `#[non_exhaustive]`: which `commonware-storage` structure backs the
45/// proof (MMR, MMB, or a future replacement — see the module doc) is an
46/// internal implementation detail, not part of the contract this error
47/// type documents; the `Mmb` variant's name is itself already the result
48/// of one such internal swap (from `Mmr`) and had no other reason to be
49/// a breaking rename. Same rationale [`crate::ops::gc::GcRootsError`]
50/// documents for its own `#[non_exhaustive]`.
51#[derive(Debug, thiserror::Error)]
52#[non_exhaustive]
53pub enum HistoryError {
54    #[error("history ref: {0}")]
55    Ref(#[from] crate::refs::RefError),
56    #[error("history object: {0}")]
57    Store(#[from] crate::store::StoreError),
58    #[error("mmb error: {0}")]
59    Mmb(String),
60    #[error("invalid branch name for ancestry: {0:?}")]
61    InvalidBranch(String),
62    #[error("history snapshot is corrupt: {0}")]
63    Corrupted(String),
64    #[error("history directory I/O: {0}")]
65    Io(#[from] std::io::Error),
66}
67
68/// In-memory accumulator over a verified first-parent chain.
69pub struct CommitHistory {
70    mmb: MemMmb<<Blake3 as CHasher>::Digest>,
71    hasher: StandardHasher<Blake3>,
72}
73impl std::fmt::Debug for CommitHistory {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.debug_struct("CommitHistory")
76            .field("leaves", &self.len())
77            .finish_non_exhaustive()
78    }
79}
80impl Default for CommitHistory {
81    fn default() -> Self {
82        Self::open()
83    }
84}
85impl CommitHistory {
86    #[must_use]
87    pub fn open() -> Self {
88        Self {
89            mmb: MemMmb::new(),
90            hasher: history_hasher(),
91        }
92    }
93    /// Append one hash and return its leaf position.
94    pub fn append(&mut self, commit_hash: &Hash) -> Result<Position, HistoryError> {
95        let leaf_loc = self.mmb.leaves();
96        self.extend(std::iter::once(commit_hash))?;
97        Ok(Position(u64::from(leaf_loc)))
98    }
99
100    /// Append many hashes in one batch: a single `new_batch`/`merkleize`/
101    /// `apply_batch` cycle instead of one per hash. Leaf positions and the
102    /// resulting root are identical to calling [`Self::append`] once per
103    /// hash in the same order — the underlying MMB batch API is explicitly
104    /// designed to merkleize many leaves at once, so batching only changes
105    /// how many allocations it performs, not the tree it builds. Used by
106    /// [`AncestrySnapshot::build`](crate::history::ancestry::AncestrySnapshot)
107    /// to build a chain's MMB without one allocation-heavy batch cycle per
108    /// commit.
109    pub fn extend<'a>(
110        &mut self,
111        commit_hashes: impl IntoIterator<Item = &'a Hash>,
112    ) -> Result<(), HistoryError> {
113        let mut batch = self.mmb.new_batch();
114        for h in commit_hashes {
115            let leaf = digest_from_hash(h);
116            batch = batch.add(&self.hasher, &leaf);
117        }
118        let batch = batch.merkleize(&self.mmb, &self.hasher);
119        self.mmb
120            .apply_batch(&batch)
121            .map_err(|e| HistoryError::Mmb(e.to_string()))?;
122        Ok(())
123    }
124    /// Return the root over every current leaf.
125    ///
126    /// # Panics
127    /// Panics only if commonware rejects zero inactive peaks, which its contract permits.
128    #[must_use]
129    pub fn root(&self) -> Hash {
130        let digest = self
131            .mmb
132            .root(&self.hasher, 0)
133            .expect("zero inactive peaks is valid");
134        let mut out = [0; HASH_LEN];
135        out.copy_from_slice(digest.as_ref());
136        out
137    }
138    #[must_use]
139    pub fn len(&self) -> u64 {
140        u64::from(self.mmb.leaves())
141    }
142    #[must_use]
143    pub fn is_empty(&self) -> bool {
144        self.len() == 0
145    }
146    pub fn prove(&self, position: Position) -> Result<InclusionProof, HistoryError> {
147        self.mmb
148            .proof(&self.hasher, MmbLocation::new(position.0), 0)
149            .map_err(|e| HistoryError::Mmb(e.to_string()))
150    }
151}
152/// Verify raw MMB inclusion. Use [`verify_ancestry`] for branch-context trust.
153#[must_use]
154pub fn verify_inclusion(
155    commit_hash: &Hash,
156    position: Position,
157    proof: &InclusionProof,
158    root: &Hash,
159) -> bool {
160    let leaf = digest_from_hash(commit_hash);
161    let root_digest = digest_from_hash(root);
162    let loc = MmbLocation::new(position.0);
163
164    // Same bagging policy as the producer — see [`HISTORY_BAGGING`].
165    let hasher = history_hasher();
166    proof.verify_element_inclusion(&hasher, leaf.as_ref(), loc, &root_digest)
167}
168
169fn digest_from_hash(h: &Hash) -> <Blake3 as CHasher>::Digest {
170    <<Blake3 as CHasher>::Digest as From<[u8; HASH_LEN]>>::from(*h)
171}
172#[cfg(test)]
173mod tests {
174    use super::*;
175    fn synth(i: u64) -> Hash {
176        crate::hash::hash(&i.to_be_bytes())
177    }
178    #[test]
179    fn mem_empty_history_root_is_well_defined() {
180        let h1 = CommitHistory::open();
181        let h2 = CommitHistory::open();
182        assert_eq!(h1.root(), h2.root(), "empty root must be deterministic");
183        assert!(h1.is_empty());
184        assert_eq!(h1.len(), 0);
185    }
186
187    #[test]
188    fn mem_append_returns_dense_positions() {
189        let mut h = CommitHistory::open();
190        for i in 0..16u64 {
191            let pos = h.append(&synth(i)).unwrap();
192            assert_eq!(pos, Position(i), "positions must be dense and 0-based");
193        }
194        assert_eq!(h.len(), 16);
195    }
196
197    /// `extend`'s single-batch construction must be bit-identical — same
198    /// root, same leaf count — to the same hashes fed through `append` one
199    /// at a time. Covers both a from-empty build (`AncestrySnapshot::build`'s
200    /// use) and extending a non-empty history, and a few sizes that land on
201    /// either side of MMB peak-merge boundaries (1, 2, 3, 16, 17, 100).
202    #[test]
203    fn mem_extend_matches_sequential_append_from_empty() {
204        for count in [0u64, 1, 2, 3, 16, 17, 100] {
205            let hashes: Vec<Hash> = (0..count).map(synth).collect();
206
207            let mut sequential = CommitHistory::open();
208            for h in &hashes {
209                sequential.append(h).unwrap();
210            }
211
212            let mut batched = CommitHistory::open();
213            batched.extend(&hashes).unwrap();
214
215            assert_eq!(
216                sequential.root(),
217                batched.root(),
218                "extend({count}) root must match {count} sequential appends"
219            );
220            assert_eq!(sequential.len(), batched.len());
221            assert_eq!(sequential.len(), count);
222        }
223    }
224
225    #[test]
226    fn mem_extend_matches_sequential_append_onto_existing_history() {
227        let prefix: Vec<Hash> = (0..12u64).map(synth).collect();
228        let suffix: Vec<Hash> = (12..12 + 9u64).map(synth).collect();
229
230        let mut sequential = CommitHistory::open();
231        for h in prefix.iter().chain(suffix.iter()) {
232            sequential.append(h).unwrap();
233        }
234
235        let mut batched = CommitHistory::open();
236        for h in &prefix {
237            batched.append(h).unwrap();
238        }
239        batched.extend(&suffix).unwrap();
240
241        assert_eq!(sequential.root(), batched.root());
242        assert_eq!(sequential.len(), batched.len());
243    }
244
245    #[test]
246    fn mem_prove_and_verify_position_712_of_1000() {
247        let mut h = CommitHistory::open();
248        let commits: Vec<Hash> = (0..1000u64).map(synth).collect();
249        for c in &commits {
250            h.append(c).unwrap();
251        }
252        assert_eq!(h.len(), 1000);
253
254        let target = Position(712);
255        let proof = h.prove(target).unwrap();
256        let root = h.root();
257
258        assert!(
259            verify_inclusion(&commits[712], target, &proof, &root),
260            "honest proof must verify"
261        );
262    }
263
264    #[test]
265    fn mem_tampered_proof_fails_verification() {
266        let mut h = CommitHistory::open();
267        for i in 0..256u64 {
268            h.append(&synth(i)).unwrap();
269        }
270        let target = Position(42);
271        let mut proof = h.prove(target).unwrap();
272        let root = h.root();
273        let commit = synth(42);
274
275        assert!(verify_inclusion(&commit, target, &proof, &root));
276
277        assert!(
278            !proof.digests.is_empty(),
279            "non-trivial proof must carry at least one sibling"
280        );
281        let mut bytes: [u8; HASH_LEN] = [0u8; HASH_LEN];
282        bytes.copy_from_slice(proof.digests[0].as_ref());
283        bytes[0] ^= 0x01;
284        proof.digests[0] = <<Blake3 as CHasher>::Digest as From<[u8; HASH_LEN]>>::from(bytes);
285
286        assert!(
287            !verify_inclusion(&commit, target, &proof, &root),
288            "tampered proof must fail"
289        );
290    }
291
292    /// SPEC-HISTORY-PROOF §3 enumerates six failure modes
293    /// `verify_inclusion` MUST reject without panicking. Three are
294    /// already covered above (tampered digest, wrong commit, wrong
295    /// root); the remaining three — wrong position, mismatched leaf
296    /// count, and a truncated/over-long `digests` vector — were only
297    /// exercised indirectly, by trusting commonware's own test suite at
298    /// the pinned version. Pin them directly here.
299    #[test]
300    fn verify_inclusion_rejects_wrong_position() {
301        let mut h = CommitHistory::open();
302        let commits: Vec<Hash> = (0..64u64).map(synth).collect();
303        for c in &commits {
304            h.append(c).unwrap();
305        }
306        let target = Position(42);
307        let proof = h.prove(target).unwrap();
308        let root = h.root();
309
310        assert!(verify_inclusion(&commits[42], target, &proof, &root));
311        // Same commit_hash, proof, and root — only the claimed position
312        // differs. The proof was built for position 42; claiming it
313        // proves position 41 (or any other) instead must fail.
314        assert!(!verify_inclusion(&commits[42], Position(41), &proof, &root));
315        assert!(!verify_inclusion(&commits[42], Position(0), &proof, &root));
316    }
317
318    #[test]
319    fn verify_inclusion_rejects_mismatched_leaf_count() {
320        let mut h = CommitHistory::open();
321        let commits: Vec<Hash> = (0..64u64).map(synth).collect();
322        for c in &commits {
323            h.append(c).unwrap();
324        }
325        let target = Position(42);
326        let mut proof = h.prove(target).unwrap();
327        let root = h.root();
328        assert!(verify_inclusion(&commits[42], target, &proof, &root));
329
330        // `proof.leaves` claims how many leaves the MMB had when the
331        // proof was built. Disagreeing with the actual count (64) must
332        // fail — this is the prover asserting a different-length
333        // history than the root it's paired with actually commits to.
334        proof.leaves = MmbLocation::new(63);
335        assert!(!verify_inclusion(&commits[42], target, &proof, &root));
336    }
337
338    #[test]
339    fn verify_inclusion_rejects_truncated_or_over_long_digests() {
340        let mut h = CommitHistory::open();
341        let commits: Vec<Hash> = (0..64u64).map(synth).collect();
342        for c in &commits {
343            h.append(c).unwrap();
344        }
345        let target = Position(42);
346        let proof = h.prove(target).unwrap();
347        let root = h.root();
348        assert!(verify_inclusion(&commits[42], target, &proof, &root));
349        assert!(
350            !proof.digests.is_empty(),
351            "non-trivial proof must carry at least one digest"
352        );
353
354        // Truncated: drop the last digest the fold-consumer expects.
355        let mut truncated = proof.clone();
356        truncated.digests.pop();
357        assert!(!verify_inclusion(&commits[42], target, &truncated, &root));
358
359        // Over-long: append a bogus extra digest past what the
360        // consumer-pointer walk expects to find.
361        let mut over_long = proof;
362        over_long.digests.push(over_long.digests[0]);
363        assert!(!verify_inclusion(&commits[42], target, &over_long, &root));
364    }
365}