// ---
// 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)));
}
}
// ---
// 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 BTreeMap;
use hash as hemera_hash;
use ;
/// A single cyberlink inside a signal.
/// 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 = ;
/// A batch of cyberlinks signed by a neuron at a given step.
/// Per-neuron ordered sequence of signals with equivocation detection.
/// Errors produced by `SignalChain::append`.