diff --git a/src/slashing/evidence_verifier.rs b/src/slashing/evidence_verifier.rs new file mode 100644 index 0000000..f180502 --- /dev/null +++ b/src/slashing/evidence_verifier.rs @@ -0,0 +1,82 @@ +// Evidence verifier utilities for slashing expiry checks + +pub type Slot = u64; +pub type Epoch = u64; + +pub const SLOTS_PER_EPOCH: Slot = 32; +pub const MAX_SLASHING_WINDOW: Slot = 8192; // in slots (~36 hours) + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SlashingEvidence { + /// For single-slot infractions (double-vote) this contains the offending slot. + pub slot: Option, + /// For surround-vote evidence include source and target epochs. + pub source_epoch: Option, + pub target_epoch: Option, +} + +impl SlashingEvidence { + pub fn new(slot: Option, source_epoch: Option, target_epoch: Option) -> Self { + Self { slot, source_epoch, target_epoch } + } +} + +/// Returns inclusive slot range (start, end) that covers the infraction represented by `ev`. +/// For epoch-based timestamps, we use the epoch's slot range: [epoch * SLOTS_PER_EPOCH, epoch * SLOTS_PER_EPOCH + (SLOTS_PER_EPOCH - 1)]. +/// If multiple timestamps present, the returned range spans the earliest start to the latest end. +pub fn evidence_infraction_slot_range(ev: &SlashingEvidence) -> (Slot, Slot) { + // Start with large bounds depending on what is present + let mut starts: Vec = Vec::new(); + let mut ends: Vec = Vec::new(); + + if let Some(s) = ev.slot { + starts.push(s); + ends.push(s); + } + if let Some(se) = ev.source_epoch { + let start = se.saturating_mul(SLOTS_PER_EPOCH); + let end = start + (SLOTS_PER_EPOCH - 1); + starts.push(start); + ends.push(end); + } + if let Some(te) = ev.target_epoch { + let start = te.saturating_mul(SLOTS_PER_EPOCH); + let end = start + (SLOTS_PER_EPOCH - 1); + starts.push(start); + ends.push(end); + } + + // If nothing present, treat as slot 0 + if starts.is_empty() { + return (0, 0); + } + + let start = *starts.iter().min().unwrap(); + let end = *ends.iter().max().unwrap(); + (start, end) +} + +/// Verify whether the evidence is still within the slashing window relative to `current_slot`. +/// Returns true if the evidence is considered expired (outside the max slashing window). +/// The rule: evidence is valid if earliest_infraction_start + MAX_SLASHING_WINDOW >= current_slot. +/// Expired when earliest_start + MAX_SLASHING_WINDOW < current_slot. +pub fn verify_evidence_expiry(ev: &SlashingEvidence, current_slot: Slot) -> bool { + let (earliest_start, _latest_end) = evidence_infraction_slot_range(ev); + // Accept evidence at the boundary (inclusive). Expire if strictly past the window. + let valid_until = earliest_start.saturating_add(MAX_SLASHING_WINDOW); + // expired = current_slot > valid_until + current_slot > valid_until +} + +/// Verify surround vote semantics: evidence must include both source and target epochs and +/// source_epoch < target_epoch. This function returns true if the surround evidence is valid +/// and within the slashing window (not expired). +pub fn verify_surround_vote(ev: &SlashingEvidence, current_slot: Slot) -> Result { + match (ev.source_epoch, ev.target_epoch) { + (Some(s), Some(t)) => { + if s >= t { return Err("invalid_surround_vote_epochs"); } + Ok(!verify_evidence_expiry(ev, current_slot)) + } + _ => Err("missing_surround_vote_epochs"), + } +} diff --git a/src/slashing/mod.rs b/src/slashing/mod.rs index 0b967ca..c4e60d5 100644 --- a/src/slashing/mod.rs +++ b/src/slashing/mod.rs @@ -1,3 +1,4 @@ pub mod cross_chain_relay; pub mod mempool; pub mod types; +pub mod evidence_verifier; diff --git a/tests/slashing/evidence_expiry_test.rs b/tests/slashing/evidence_expiry_test.rs new file mode 100644 index 0000000..9169eed --- /dev/null +++ b/tests/slashing/evidence_expiry_test.rs @@ -0,0 +1,40 @@ +use soroban_sdk::Env; // bring in crate for test context if needed + +use sorosusu_contracts::slashing::evidence_verifier::*; + +#[test] +fn surround_vote_at_window_boundary_is_valid_and_one_past_is_expired() { + // earliest infraction at slot 1000 (from source_epoch) + let source_epoch = 1000 / SLOTS_PER_EPOCH; + let target_epoch = source_epoch + 1; // a surround that spans into next epoch + let ev = SlashingEvidence::new(None, Some(source_epoch), Some(target_epoch)); + + let earliest_start = evidence_infraction_slot_range(&ev).0; + let boundary_slot = earliest_start + MAX_SLASHING_WINDOW; // inclusive boundary + + // At boundary: still valid + let expired_at_boundary = verify_evidence_expiry(&ev, boundary_slot); + assert_eq!(expired_at_boundary, false, "evidence at boundary should be valid"); + + // One slot past boundary: expired + let expired_one_past = verify_evidence_expiry(&ev, boundary_slot + 1); + assert_eq!(expired_one_past, true, "evidence one past boundary should be expired"); +} + +use proptest::prelude::*; + +proptest! { + #[test] + fn prop_evidence_window_symmetry(slot in 0u64..1_000_000u64, src_epoch in 0u64..1000u64, tgt_epoch in 0u64..1000u64, current_slot in 0u64..2_000_000u64) { + // Build evidence with random fields + let ev = SlashingEvidence::new(Some(slot), Some(src_epoch), Some(tgt_epoch)); + let (start, _end) = evidence_infraction_slot_range(&ev); + let valid_until = start.saturating_add(MAX_SLASHING_WINDOW); + + // Manual determination: not expired if current_slot <= valid_until + let manual_not_expired = current_slot <= valid_until; + let func_not_expired = !verify_evidence_expiry(&ev, current_slot); + + prop_assert_eq!(manual_not_expired, func_not_expired); + } +}