Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/slashing/evidence_verifier.rs
Original file line number Diff line number Diff line change
@@ -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<Slot>,
/// For surround-vote evidence include source and target epochs.
pub source_epoch: Option<Epoch>,
pub target_epoch: Option<Epoch>,
}

impl SlashingEvidence {
pub fn new(slot: Option<Slot>, source_epoch: Option<Epoch>, target_epoch: Option<Epoch>) -> 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<Slot> = Vec::new();
let mut ends: Vec<Slot> = 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<bool, &'static str> {
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"),
}
}
1 change: 1 addition & 0 deletions src/slashing/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod cross_chain_relay;
pub mod mempool;
pub mod types;
pub mod evidence_verifier;
40 changes: 40 additions & 0 deletions tests/slashing/evidence_expiry_test.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading