soft3/foculus/src/chain.rs

// ---
// tags: sync, rust
// crystal-type: source
// crystal-domain: cyber
// ---
//! Signal chain: neuron-signed cyberlink batches with equivocation detection.
//!
//! Implements layer 2 ordering from structural-sync: per-neuron hash chain,
//! step counter, and equivocation detection. Lives in the sync repo because
//! ordering is a sync-protocol concern; cybergraph imports these types when
//! validating a submit.

use std::collections::BTreeMap;

use cyber_hemera::hash as hemera_hash;

use bbg::{Particle, NeuronId};

/// A single cyberlink inside a signal.
#[derive(Clone)]
pub struct CyberlinkRecord {
    pub neuron: NeuronId,
    pub from: Particle,
    pub to: Particle,
    pub token: Particle,
    pub amount: u64,
    pub valence: i8,
    pub height: u64,
}

/// The sentinel network meaning "the sender's own private network".
/// A signal carrying this value is routed to `private_network(neuron)` at the
/// cybergraph boundary โ€” every neuron writes to its private network by default.
pub const SELF_NETWORK: Particle = [0u8; 32];

/// A batch of cyberlinks signed by a neuron at a given step.
#[derive(Clone)]
pub struct Signal {
    pub neuron: NeuronId,
    /// Destination network โ€” a card id. `SELF_NETWORK` (the zero particle)
    /// resolves to the sender's private network at the cybergraph boundary.
    pub network: Particle,
    pub links: Vec<CyberlinkRecord>,
    pub delta_pi: Vec<(Particle, u64)>,
    /// Hash of the previous signal in this neuron's chain.
    pub prev: Particle,
    pub step: u64,
    /// Block height (0 until finalized).
    pub height: u64,
    pub proof: Option<zheng::Proof>,
}

impl Signal {
    /// Canonical hash of this signal: hemera over (neuron โ€– step โ€– prev โ€– network).
    /// Binding `network` makes the destination tamper-evident in relay.
    ///
    /// This is the *chain-position* hash โ€” it does NOT cover the signal's payload
    /// (links, delta_pi). Two equivocating signals (same neuron, step, prev,
    /// network but divergent content) share this hash. Use [`Signal::content_id`]
    /// to distinguish them.
    pub fn hash(&self) -> Particle {
        let mut buf = [0u8; 104]; // 32 + 8 + 32 + 32
        buf[..32].copy_from_slice(&self.neuron);
        buf[32..40].copy_from_slice(&self.step.to_le_bytes());
        buf[40..72].copy_from_slice(&self.prev);
        buf[72..104].copy_from_slice(&self.network);
        let h = hemera_hash(&buf);
        *h.as_bytes().first_chunk::<32>().unwrap_or(&[0u8; 32])
    }

    /// Full-content identity: hemera over every semantic field in canonical order.
    ///
    /// Distinct from [`Signal::hash`] (chain position). `content_id` covers the
    /// payload โ€” the links and delta_pi โ€” so two equivocating signals that share
    /// a chain position get *different* content ids. This is the `hash(particle_data)`
    /// that foculus's fork-choice orders by (protocol.md's measure-zero tiebreak,
    /// generalized to the `MinHash` strategy). Canonical: same content on any node
    /// produces the same id, so every node's fork-choice agrees.
    pub fn content_id(&self) -> Particle {
        let mut buf = Vec::with_capacity(
            104 + self.links.len() * 137 + self.delta_pi.len() * 40,
        );
        buf.extend_from_slice(&self.neuron);
        buf.extend_from_slice(&self.network);
        buf.extend_from_slice(&self.step.to_le_bytes());
        buf.extend_from_slice(&self.prev);
        // links in their given order โ€” order is part of the signal's semantics
        for l in &self.links {
            buf.extend_from_slice(&l.neuron);
            buf.extend_from_slice(&l.from);
            buf.extend_from_slice(&l.to);
            buf.extend_from_slice(&l.token);
            buf.extend_from_slice(&l.amount.to_le_bytes());
            buf.push(l.valence as u8);
            buf.extend_from_slice(&l.height.to_le_bytes());
        }
        for (p, v) in &self.delta_pi {
            buf.extend_from_slice(p);
            buf.extend_from_slice(&v.to_le_bytes());
        }
        let h = hemera_hash(&buf);
        *h.as_bytes().first_chunk::<32>().unwrap_or(&[0u8; 32])
    }
}

/// Per-neuron ordered sequence of signals with equivocation detection.
pub struct SignalChain {
    pub entries: BTreeMap<u64, Signal>,
}

/// Errors produced by `SignalChain::append`.
#[derive(Debug)]
pub enum ChainError {
    StepNotSequential,
    PrevMismatch,
    Equivocation,
}

impl SignalChain {
    pub fn new() -> Self {
        Self { entries: BTreeMap::new() }
    }

    /// Append a signal. Returns `Err` on equivocation, wrong step, or prev mismatch.
    pub fn append(&mut self, signal: Signal) -> Result<(), ChainError> {
        if self.check_equivocation(&signal).is_some() {
            return Err(ChainError::Equivocation);
        }

        let expected_step = self.entries.len() as u64;
        if signal.step != expected_step {
            return Err(ChainError::StepNotSequential);
        }

        let expected_prev: Particle = if expected_step == 0 {
            [0u8; 32]
        } else {
            let last = self.entries.get(&(expected_step - 1)).ok_or(ChainError::PrevMismatch)?;
            last.hash()
        };

        if signal.prev != expected_prev {
            return Err(ChainError::PrevMismatch);
        }

        self.entries.insert(signal.step, signal);
        Ok(())
    }

    /// Return the existing signal if `s` would equivocate (same neuron, step, prev).
    pub fn check_equivocation(&self, s: &Signal) -> Option<&Signal> {
        self.entries.get(&s.step).and_then(|existing| {
            if existing.neuron == s.neuron && existing.prev == s.prev {
                Some(existing)
            } else {
                None
            }
        })
    }
}

impl Default for SignalChain {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn neuron(seed: u8) -> NeuronId {
        [seed; 32]
    }

    fn particle(seed: u8) -> Particle {
        [seed; 32]
    }

    fn signal(neuron: NeuronId, step: u64, prev: Particle) -> Signal {
        Signal {
            neuron,
            network: SELF_NETWORK,
            links: vec![],
            delta_pi: vec![],
            prev,
            step,
            height: 0,
            proof: None,
        }
    }

    #[test]
    fn first_signal_has_zero_prev() {
        let mut chain = SignalChain::new();
        let s = signal(neuron(1), 0, [0u8; 32]);
        chain.append(s).unwrap();
        assert_eq!(chain.entries.len(), 1);
    }

    #[test]
    fn sequential_chain_links_correctly() {
        let mut chain = SignalChain::new();
        let n = neuron(1);
        let s0 = signal(n, 0, [0u8; 32]);
        let prev1 = s0.hash();
        chain.append(s0).unwrap();

        let s1 = signal(n, 1, prev1);
        let prev2 = s1.hash();
        chain.append(s1).unwrap();

        let s2 = signal(n, 2, prev2);
        chain.append(s2).unwrap();

        assert_eq!(chain.entries.len(), 3);
    }

    #[test]
    fn wrong_step_is_rejected() {
        let mut chain = SignalChain::new();
        let s = signal(neuron(1), 5, [0u8; 32]);
        assert!(matches!(chain.append(s), Err(ChainError::StepNotSequential)));
    }

    #[test]
    fn wrong_prev_is_rejected() {
        let mut chain = SignalChain::new();
        chain.append(signal(neuron(1), 0, [0u8; 32])).unwrap();
        let s1 = signal(neuron(1), 1, particle(99));
        assert!(matches!(chain.append(s1), Err(ChainError::PrevMismatch)));
    }

    #[test]
    fn equivocation_is_detected() {
        let mut chain = SignalChain::new();
        let n = neuron(1);
        chain.append(signal(n, 0, [0u8; 32])).unwrap();
        let dupe = signal(n, 0, [0u8; 32]);
        assert!(matches!(chain.append(dupe), Err(ChainError::Equivocation)));
    }
}

Graph