use xxhash_rust::xxh3::xxh3_64;
use crate::adapter::net::identity::EntityKeypair;
use crate::adapter::net::state::causal::{
CausalChainBuilder, CausalLink, ChainError, CAUSAL_LINK_SIZE,
};
#[derive(Debug, Clone)]
pub struct Discontinuity {
pub origin_hash: u64,
pub last_verified: CausalLink,
pub failed_link: Option<CausalLink>,
pub reason: DiscontinuityReason,
pub detected_at: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiscontinuityReason {
NodeCrash {
last_snapshot_seq: u64,
},
ChainBreak(ChainError),
ConflictingChains {
seq: u64,
hash_a: u64,
hash_b: u64,
},
Corruption,
}
impl std::fmt::Display for DiscontinuityReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NodeCrash { last_snapshot_seq } => {
write!(f, "node crash (last snapshot at seq {})", last_snapshot_seq)
}
Self::ChainBreak(e) => write!(f, "chain break: {}", e),
Self::ConflictingChains {
seq,
hash_a,
hash_b,
} => {
write!(
f,
"conflicting chains at seq {}: {:#x} vs {:#x}",
seq, hash_a, hash_b
)
}
Self::Corruption => write!(f, "data corruption"),
}
}
}
#[derive(Debug, Clone)]
pub struct ForkRecord {
pub original_origin: u64,
pub forked_origin: u64,
pub fork_seq: u64,
pub fork_genesis: CausalLink,
pub from_snapshot_seq: Option<u64>,
}
pub fn fork_sentinel(original_origin: u64, fork_seq: u64) -> u64 {
let mut buf = Vec::with_capacity(8 + 8 + 4);
buf.extend_from_slice(&original_origin.to_le_bytes());
buf.extend_from_slice(&fork_seq.to_le_bytes());
buf.extend_from_slice(b"fork");
xxh3_64(&buf)
}
pub fn fork_entity(
original_origin: u64,
fork_seq: u64,
from_snapshot_seq: Option<u64>,
) -> (EntityKeypair, ForkRecord, CausalChainBuilder) {
let new_keypair = EntityKeypair::generate();
let new_origin = new_keypair.origin_hash();
let sentinel = fork_sentinel(original_origin, fork_seq);
let fork_genesis = CausalLink {
origin_hash: new_origin,
horizon_encoded: 0,
sequence: 0,
parent_hash: sentinel,
};
let record = ForkRecord {
original_origin,
forked_origin: new_origin,
fork_seq,
fork_genesis,
from_snapshot_seq,
};
let builder = CausalChainBuilder::from_head(fork_genesis, bytes::Bytes::new());
(new_keypair, record, builder)
}
impl ForkRecord {
pub fn verify(&self) -> bool {
let expected = fork_sentinel(self.original_origin, self.fork_seq);
self.fork_genesis.parent_hash == expected
&& self.fork_genesis.origin_hash == self.forked_origin
&& self.fork_genesis.sequence == 0
&& self.original_origin != self.forked_origin
}
pub const WIRE_SIZE: usize = 8 + 8 + 8 + CAUSAL_LINK_SIZE + 1 + 8;
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(Self::WIRE_SIZE);
buf.extend_from_slice(&self.original_origin.to_le_bytes());
buf.extend_from_slice(&self.forked_origin.to_le_bytes());
buf.extend_from_slice(&self.fork_seq.to_le_bytes());
buf.extend_from_slice(&self.fork_genesis.to_bytes());
match self.from_snapshot_seq {
Some(seq) => {
buf.push(1);
buf.extend_from_slice(&seq.to_le_bytes());
}
None => {
buf.push(0);
buf.extend_from_slice(&0u64.to_le_bytes());
}
}
buf
}
pub fn from_bytes(data: &[u8]) -> Option<Self> {
let parsed = Self::from_bytes_unchecked(data)?;
if !parsed.verify() {
return None;
}
Some(parsed)
}
#[expect(
clippy::unwrap_used,
reason = "data.len() == WIRE_SIZE checked above; fixed-offset slices convert infallibly to fixed-size arrays"
)]
pub fn from_bytes_unchecked(data: &[u8]) -> Option<Self> {
if data.len() != Self::WIRE_SIZE {
return None;
}
let original_origin = u64::from_le_bytes(data[0..8].try_into().unwrap());
let forked_origin = u64::from_le_bytes(data[8..16].try_into().unwrap());
let fork_seq = u64::from_le_bytes(data[16..24].try_into().unwrap());
let link_end = 24 + CAUSAL_LINK_SIZE;
let fork_genesis = CausalLink::from_bytes(&data[24..link_end])?;
let has_snapshot = data[link_end] != 0;
let snapshot_seq = u64::from_le_bytes(data[link_end + 1..link_end + 9].try_into().unwrap());
let from_snapshot_seq = if has_snapshot {
Some(snapshot_seq)
} else {
None
};
Some(Self {
original_origin,
forked_origin,
fork_seq,
fork_genesis,
from_snapshot_seq,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fork_sentinel_deterministic() {
let s1 = fork_sentinel(0xAAAA, 42);
let s2 = fork_sentinel(0xAAAA, 42);
assert_eq!(s1, s2);
assert_ne!(s1, 0);
}
#[test]
fn test_fork_sentinel_differs() {
let s1 = fork_sentinel(0xAAAA, 42);
let s2 = fork_sentinel(0xBBBB, 42);
let s3 = fork_sentinel(0xAAAA, 43);
assert_ne!(s1, s2);
assert_ne!(s1, s3);
}
#[test]
fn test_fork_entity() {
let (keypair, record, builder) = fork_entity(0xAAAA, 100, Some(90));
assert_eq!(record.original_origin, 0xAAAA);
assert_eq!(record.forked_origin, keypair.origin_hash());
assert_eq!(record.fork_seq, 100);
assert_eq!(record.from_snapshot_seq, Some(90));
assert!(record.verify());
assert_eq!(builder.origin_hash(), keypair.origin_hash());
}
#[test]
fn test_fork_record_roundtrip() {
let (_, record, _) = fork_entity(0xDEAD, 500, None);
let bytes = record.to_bytes();
assert_eq!(bytes.len(), ForkRecord::WIRE_SIZE);
let parsed = ForkRecord::from_bytes(&bytes).unwrap();
assert_eq!(parsed.original_origin, record.original_origin);
assert_eq!(parsed.forked_origin, record.forked_origin);
assert_eq!(parsed.fork_seq, record.fork_seq);
assert_eq!(parsed.fork_genesis, record.fork_genesis);
assert_eq!(parsed.from_snapshot_seq, None);
assert!(parsed.verify());
}
#[test]
fn test_fork_record_with_snapshot_seq() {
let (_, record, _) = fork_entity(0xBEEF, 200, Some(150));
let bytes = record.to_bytes();
let parsed = ForkRecord::from_bytes(&bytes).unwrap();
assert_eq!(parsed.from_snapshot_seq, Some(150));
}
#[test]
fn test_tampered_sentinel_fails_verification() {
let (_, mut record, _) = fork_entity(0xAAAA, 100, None);
record.fork_genesis.parent_hash = 0xBADBADBAD;
assert!(!record.verify());
}
#[test]
fn from_bytes_runs_verify() {
let (_, mut record, _) = fork_entity(0xCAFE, 7, None);
record.fork_genesis.parent_hash = 0xDEAD_BEEF;
let bytes = record.to_bytes();
assert!(
ForkRecord::from_bytes(&bytes).is_none(),
"from_bytes must reject a record whose sentinel \
doesn't verify (#42)"
);
assert!(
ForkRecord::from_bytes_unchecked(&bytes).is_some(),
"from_bytes_unchecked still parses the raw bytes for \
diagnostic use"
);
}
}