use crate::hash::{HASH_LEN, Hash};
use commonware_cryptography::{Blake3, Hasher as CHasher};
use commonware_storage::merkle::Bagging;
use commonware_storage::merkle::mmb::{
Location as MmbLocation, Proof as MmbProof, StandardHasher, mem::Mmb as MemMmb,
};
pub(crate) mod ancestry;
pub use ancestry::{
AncestryDescriptor, AncestrySnapshot, TrustedAncestryDescriptor, verify_ancestry,
};
const HISTORY_BAGGING: Bagging = Bagging::ForwardFold;
fn history_hasher() -> StandardHasher<Blake3> {
StandardHasher::new(HISTORY_BAGGING)
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Position(pub u64);
impl Position {
#[must_use]
pub const fn as_u64(self) -> u64 {
self.0
}
}
pub type InclusionProof = MmbProof<<Blake3 as CHasher>::Digest>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum HistoryError {
#[error("history ref: {0}")]
Ref(#[from] crate::refs::RefError),
#[error("history object: {0}")]
Store(#[from] crate::store::StoreError),
#[error("mmb error: {0}")]
Mmb(String),
#[error("invalid branch name for ancestry: {0:?}")]
InvalidBranch(String),
#[error("history snapshot is corrupt: {0}")]
Corrupted(String),
#[error("history directory I/O: {0}")]
Io(#[from] std::io::Error),
}
pub struct CommitHistory {
mmb: MemMmb<<Blake3 as CHasher>::Digest>,
hasher: StandardHasher<Blake3>,
}
impl std::fmt::Debug for CommitHistory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CommitHistory")
.field("leaves", &self.len())
.finish_non_exhaustive()
}
}
impl Default for CommitHistory {
fn default() -> Self {
Self::open()
}
}
impl CommitHistory {
#[must_use]
pub fn open() -> Self {
Self {
mmb: MemMmb::new(),
hasher: history_hasher(),
}
}
pub fn append(&mut self, commit_hash: &Hash) -> Result<Position, HistoryError> {
let leaf_loc = self.mmb.leaves();
self.extend(std::iter::once(commit_hash))?;
Ok(Position(u64::from(leaf_loc)))
}
pub fn extend<'a>(
&mut self,
commit_hashes: impl IntoIterator<Item = &'a Hash>,
) -> Result<(), HistoryError> {
let mut batch = self.mmb.new_batch();
for h in commit_hashes {
let leaf = digest_from_hash(h);
batch = batch.add(&self.hasher, &leaf);
}
let batch = batch.merkleize(&self.mmb, &self.hasher);
self.mmb
.apply_batch(&batch)
.map_err(|e| HistoryError::Mmb(e.to_string()))?;
Ok(())
}
#[must_use]
pub fn root(&self) -> Hash {
let digest = self
.mmb
.root(&self.hasher, 0)
.expect("zero inactive peaks is valid");
let mut out = [0; HASH_LEN];
out.copy_from_slice(digest.as_ref());
out
}
#[must_use]
pub fn len(&self) -> u64 {
u64::from(self.mmb.leaves())
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn prove(&self, position: Position) -> Result<InclusionProof, HistoryError> {
self.mmb
.proof(&self.hasher, MmbLocation::new(position.0), 0)
.map_err(|e| HistoryError::Mmb(e.to_string()))
}
}
#[must_use]
pub fn verify_inclusion(
commit_hash: &Hash,
position: Position,
proof: &InclusionProof,
root: &Hash,
) -> bool {
let leaf = digest_from_hash(commit_hash);
let root_digest = digest_from_hash(root);
let loc = MmbLocation::new(position.0);
let hasher = history_hasher();
proof.verify_element_inclusion(&hasher, leaf.as_ref(), loc, &root_digest)
}
fn digest_from_hash(h: &Hash) -> <Blake3 as CHasher>::Digest {
<<Blake3 as CHasher>::Digest as From<[u8; HASH_LEN]>>::from(*h)
}
#[cfg(test)]
mod tests {
use super::*;
fn synth(i: u64) -> Hash {
crate::hash::hash(&i.to_be_bytes())
}
#[test]
fn mem_empty_history_root_is_well_defined() {
let h1 = CommitHistory::open();
let h2 = CommitHistory::open();
assert_eq!(h1.root(), h2.root(), "empty root must be deterministic");
assert!(h1.is_empty());
assert_eq!(h1.len(), 0);
}
#[test]
fn mem_append_returns_dense_positions() {
let mut h = CommitHistory::open();
for i in 0..16u64 {
let pos = h.append(&synth(i)).unwrap();
assert_eq!(pos, Position(i), "positions must be dense and 0-based");
}
assert_eq!(h.len(), 16);
}
#[test]
fn mem_extend_matches_sequential_append_from_empty() {
for count in [0u64, 1, 2, 3, 16, 17, 100] {
let hashes: Vec<Hash> = (0..count).map(synth).collect();
let mut sequential = CommitHistory::open();
for h in &hashes {
sequential.append(h).unwrap();
}
let mut batched = CommitHistory::open();
batched.extend(&hashes).unwrap();
assert_eq!(
sequential.root(),
batched.root(),
"extend({count}) root must match {count} sequential appends"
);
assert_eq!(sequential.len(), batched.len());
assert_eq!(sequential.len(), count);
}
}
#[test]
fn mem_extend_matches_sequential_append_onto_existing_history() {
let prefix: Vec<Hash> = (0..12u64).map(synth).collect();
let suffix: Vec<Hash> = (12..12 + 9u64).map(synth).collect();
let mut sequential = CommitHistory::open();
for h in prefix.iter().chain(suffix.iter()) {
sequential.append(h).unwrap();
}
let mut batched = CommitHistory::open();
for h in &prefix {
batched.append(h).unwrap();
}
batched.extend(&suffix).unwrap();
assert_eq!(sequential.root(), batched.root());
assert_eq!(sequential.len(), batched.len());
}
#[test]
fn mem_prove_and_verify_position_712_of_1000() {
let mut h = CommitHistory::open();
let commits: Vec<Hash> = (0..1000u64).map(synth).collect();
for c in &commits {
h.append(c).unwrap();
}
assert_eq!(h.len(), 1000);
let target = Position(712);
let proof = h.prove(target).unwrap();
let root = h.root();
assert!(
verify_inclusion(&commits[712], target, &proof, &root),
"honest proof must verify"
);
}
#[test]
fn mem_tampered_proof_fails_verification() {
let mut h = CommitHistory::open();
for i in 0..256u64 {
h.append(&synth(i)).unwrap();
}
let target = Position(42);
let mut proof = h.prove(target).unwrap();
let root = h.root();
let commit = synth(42);
assert!(verify_inclusion(&commit, target, &proof, &root));
assert!(
!proof.digests.is_empty(),
"non-trivial proof must carry at least one sibling"
);
let mut bytes: [u8; HASH_LEN] = [0u8; HASH_LEN];
bytes.copy_from_slice(proof.digests[0].as_ref());
bytes[0] ^= 0x01;
proof.digests[0] = <<Blake3 as CHasher>::Digest as From<[u8; HASH_LEN]>>::from(bytes);
assert!(
!verify_inclusion(&commit, target, &proof, &root),
"tampered proof must fail"
);
}
#[test]
fn verify_inclusion_rejects_wrong_position() {
let mut h = CommitHistory::open();
let commits: Vec<Hash> = (0..64u64).map(synth).collect();
for c in &commits {
h.append(c).unwrap();
}
let target = Position(42);
let proof = h.prove(target).unwrap();
let root = h.root();
assert!(verify_inclusion(&commits[42], target, &proof, &root));
assert!(!verify_inclusion(&commits[42], Position(41), &proof, &root));
assert!(!verify_inclusion(&commits[42], Position(0), &proof, &root));
}
#[test]
fn verify_inclusion_rejects_mismatched_leaf_count() {
let mut h = CommitHistory::open();
let commits: Vec<Hash> = (0..64u64).map(synth).collect();
for c in &commits {
h.append(c).unwrap();
}
let target = Position(42);
let mut proof = h.prove(target).unwrap();
let root = h.root();
assert!(verify_inclusion(&commits[42], target, &proof, &root));
proof.leaves = MmbLocation::new(63);
assert!(!verify_inclusion(&commits[42], target, &proof, &root));
}
#[test]
fn verify_inclusion_rejects_truncated_or_over_long_digests() {
let mut h = CommitHistory::open();
let commits: Vec<Hash> = (0..64u64).map(synth).collect();
for c in &commits {
h.append(c).unwrap();
}
let target = Position(42);
let proof = h.prove(target).unwrap();
let root = h.root();
assert!(verify_inclusion(&commits[42], target, &proof, &root));
assert!(
!proof.digests.is_empty(),
"non-trivial proof must carry at least one digest"
);
let mut truncated = proof.clone();
truncated.digests.pop();
assert!(!verify_inclusion(&commits[42], target, &truncated, &root));
let mut over_long = proof;
over_long.digests.push(over_long.digests[0]);
assert!(!verify_inclusion(&commits[42], target, &over_long, &root));
}
}