use std::collections::HashMap;
use crate::types::IdentityDID;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KelEventRef<'a> {
pub prefix: &'a str,
pub seq: u64,
pub said: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type")]
pub enum DuplicityReport {
Clean,
Diverging {
shared_kel_prefix: IdentityDID,
seq: u64,
event_saids: Vec<String>,
},
}
impl DuplicityReport {
pub fn is_diverging(&self) -> bool {
matches!(self, DuplicityReport::Diverging { .. })
}
}
pub fn detect_duplicity(events: &[KelEventRef<'_>]) -> DuplicityReport {
let mut seen: HashMap<(String, u64), Vec<String>> = HashMap::new();
let mut first_conflict: Option<(String, u64)> = None;
for ev in events {
let key = (ev.prefix.to_string(), ev.seq);
let saids = seen.entry(key.clone()).or_default();
if !saids.iter().any(|s| s == ev.said) {
saids.push(ev.said.to_string());
if saids.len() >= 2 && first_conflict.is_none() {
first_conflict = Some(key);
}
}
}
if let Some((prefix, seq)) = first_conflict {
let saids = seen.remove(&(prefix.clone(), seq)).unwrap_or_default();
let shared_kel_prefix = {
#[allow(clippy::disallowed_methods)]
IdentityDID::new_unchecked(prefix)
};
return DuplicityReport::Diverging {
shared_kel_prefix,
seq,
event_saids: saids,
};
}
DuplicityReport::Clean
}
pub fn seal_rejects_extension(
report: &DuplicityReport,
candidate_seq: u64,
candidate_prev_said: &str,
) -> bool {
match report {
DuplicityReport::Diverging {
seq, event_saids, ..
} => candidate_seq > *seq && event_saids.iter().any(|s| s == candidate_prev_said),
DuplicityReport::Clean => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ev<'a>(prefix: &'a str, seq: u64, said: &'a str) -> KelEventRef<'a> {
KelEventRef { prefix, seq, said }
}
#[test]
fn empty_is_clean() {
assert_eq!(detect_duplicity(&[]), DuplicityReport::Clean);
}
#[test]
fn single_event_is_clean() {
let events = vec![ev("did:keri:E1", 0, "EaaaA")];
assert_eq!(detect_duplicity(&events), DuplicityReport::Clean);
}
#[test]
fn sequential_rots_are_clean() {
let events = vec![
ev("did:keri:E1", 0, "EincpA"),
ev("did:keri:E1", 1, "ErotA"),
ev("did:keri:E1", 2, "ErotB"),
];
assert_eq!(detect_duplicity(&events), DuplicityReport::Clean);
}
#[test]
fn identical_said_at_same_seq_is_clean() {
let events = vec![ev("did:keri:E1", 2, "Erot"), ev("did:keri:E1", 2, "Erot")];
assert_eq!(detect_duplicity(&events), DuplicityReport::Clean);
}
#[test]
fn two_different_saids_at_same_seq_is_diverging() {
let events = vec![
ev("did:keri:E1", 0, "Eincp"),
ev("did:keri:E1", 2, "ErotA"),
ev("did:keri:E1", 2, "ErotB"),
];
match detect_duplicity(&events) {
DuplicityReport::Diverging {
shared_kel_prefix,
seq,
event_saids,
} => {
assert_eq!(shared_kel_prefix.as_str(), "did:keri:E1");
assert_eq!(seq, 2);
assert_eq!(event_saids, vec!["ErotA".to_string(), "ErotB".to_string()]);
}
DuplicityReport::Clean => panic!("expected Diverging"),
}
}
#[test]
fn three_way_fork_reports_all_saids() {
let events = vec![
ev("did:keri:E1", 2, "ErotA"),
ev("did:keri:E1", 2, "ErotB"),
ev("did:keri:E1", 2, "ErotC"),
];
match detect_duplicity(&events) {
DuplicityReport::Diverging { event_saids, .. } => {
assert_eq!(event_saids.len(), 3);
}
_ => panic!("expected three-way divergence"),
}
}
#[test]
fn icp_only_stream_is_clean() {
let events = vec![ev("did:keri:E1", 0, "Eincp")];
assert_eq!(detect_duplicity(&events), DuplicityReport::Clean);
}
#[test]
fn concurrent_rotation_is_detected_or_prevented() {
let events = vec![
ev("did:keri:EShared", 0, "Eicp"),
ev("did:keri:EShared", 1, "Erot1"),
ev("did:keri:EShared", 2, "ErotControllerA"),
ev("did:keri:EShared", 2, "ErotControllerB"),
];
match detect_duplicity(&events) {
DuplicityReport::Diverging {
shared_kel_prefix,
seq,
event_saids,
} => {
assert_eq!(shared_kel_prefix.as_str(), "did:keri:EShared");
assert_eq!(seq, 2, "divergence is at the concurrent rotation");
assert_eq!(event_saids.len(), 2);
}
DuplicityReport::Clean => {
panic!("concurrent rotations at the same sequence must be flagged, not accepted")
}
}
}
#[test]
fn diverging_report_is_diverging() {
let report = DuplicityReport::Diverging {
#[allow(clippy::disallowed_methods)]
shared_kel_prefix: IdentityDID::new_unchecked("did:keri:E1".to_string()),
seq: 2,
event_saids: vec!["Ea".into(), "Eb".into()],
};
assert!(report.is_diverging());
assert!(!DuplicityReport::Clean.is_diverging());
}
fn diverging_at_seq2() -> DuplicityReport {
DuplicityReport::Diverging {
#[allow(clippy::disallowed_methods)]
shared_kel_prefix: IdentityDID::new_unchecked("did:keri:EShared".to_string()),
seq: 2,
event_saids: vec!["ErotA".into(), "ErotB".into()],
}
}
#[test]
fn seal_rejects_extension_built_on_a_disputed_head() {
let report = diverging_at_seq2();
assert!(seal_rejects_extension(&report, 3, "ErotA"));
assert!(seal_rejects_extension(&report, 3, "ErotB"));
}
#[test]
fn seal_allows_extension_not_descending_from_the_dispute() {
let report = diverging_at_seq2();
assert!(!seal_rejects_extension(&report, 3, "EunrelatedHead"));
assert!(!seal_rejects_extension(&report, 2, "ErotA"));
assert!(!seal_rejects_extension(&report, 1, "ErotA"));
}
#[test]
fn seal_rejects_nothing_when_clean() {
assert!(!seal_rejects_extension(&DuplicityReport::Clean, 3, "ErotA"));
}
}