use std::collections::{BTreeMap, HashMap};
use dig_protocol::Bytes32;
use serde::{Deserialize, Serialize};
use crate::bonds::{BondEscrow, BondTag};
use crate::constants::{
APPELLANT_BOND_MOJOS, BPS_DENOMINATOR, MAX_APPEAL_ATTEMPTS_PER_SLASH, MAX_APPEAL_PAYLOAD_BYTES,
MAX_PENDING_SLASHES, MIN_SLASHING_PENALTY_QUOTIENT, PROPORTIONAL_SLASHING_MULTIPLIER,
PROPOSER_REWARD_QUOTIENT, REPORTER_BOND_MOJOS, SLASH_APPEAL_WINDOW_EPOCHS, SLASH_LOCK_EPOCHS,
WHISTLEBLOWER_REWARD_QUOTIENT,
};
use crate::error::SlashingError;
use crate::evidence::envelope::{SlashingEvidence, SlashingEvidencePayload};
use crate::evidence::verify::verify_evidence;
use crate::pending::{PendingSlash, PendingSlashBook, PendingSlashStatus};
use crate::traits::{
CollateralSlasher, EffectiveBalanceView, ProposerView, RewardPayout, ValidatorView,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PerValidatorSlash {
pub validator_index: u32,
pub base_slash_amount: u64,
pub effective_balance_at_slash: u64,
#[serde(default)]
pub collateral_slashed: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct FinalisationResult {
pub evidence_hash: Bytes32,
pub per_validator_correlation_penalty: Vec<(u32, u64)>,
pub reporter_bond_returned: u64,
pub exit_lock_until_epoch: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct SlashingResult {
pub per_validator: Vec<PerValidatorSlash>,
pub whistleblower_reward: u64,
pub proposer_reward: u64,
pub burn_amount: u64,
pub reporter_bond_escrowed: u64,
pub pending_slash_hash: Bytes32,
}
#[derive(Debug, Clone)]
pub struct SlashingManager {
current_epoch: u64,
book: PendingSlashBook,
processed: HashMap<Bytes32, u64>,
slashed_in_window: BTreeMap<(u64, u32), u64>,
}
impl Default for SlashingManager {
fn default() -> Self {
Self::new(0)
}
}
impl SlashingManager {
#[must_use]
pub fn new(current_epoch: u64) -> Self {
Self::with_book_capacity(current_epoch, MAX_PENDING_SLASHES)
}
#[must_use]
pub fn with_book_capacity(current_epoch: u64, book_capacity: usize) -> Self {
Self {
current_epoch,
book: PendingSlashBook::new(book_capacity),
processed: HashMap::new(),
slashed_in_window: BTreeMap::new(),
}
}
#[must_use]
pub fn current_epoch(&self) -> u64 {
self.current_epoch
}
#[must_use]
pub fn book(&self) -> &PendingSlashBook {
&self.book
}
pub fn book_mut(&mut self) -> &mut PendingSlashBook {
&mut self.book
}
#[must_use]
pub fn is_processed(&self, hash: &Bytes32) -> bool {
self.processed.contains_key(hash)
}
#[must_use]
pub fn processed_epoch(&self, hash: &Bytes32) -> Option<u64> {
self.processed.get(hash).copied()
}
#[allow(clippy::too_many_arguments)]
pub fn submit_evidence(
&mut self,
evidence: SlashingEvidence,
validator_set: &mut dyn ValidatorView,
effective_balances: &dyn EffectiveBalanceView,
bond_escrow: &mut dyn BondEscrow,
reward_payout: &mut dyn RewardPayout,
proposer: &dyn ProposerView,
network_id: &Bytes32,
) -> Result<SlashingResult, SlashingError> {
let evidence_hash_pre = evidence.hash();
if self.processed.contains_key(&evidence_hash_pre) {
return Err(SlashingError::AlreadySlashed);
}
let verified = verify_evidence(&evidence, validator_set, network_id, self.current_epoch)?;
if self.book.len() >= self.book.capacity() {
return Err(SlashingError::PendingBookFull);
}
let evidence_hash = evidence_hash_pre;
bond_escrow
.lock(
evidence.reporter_validator_index,
REPORTER_BOND_MOJOS,
BondTag::Reporter(evidence_hash),
)
.map_err(|_| SlashingError::BondLockFailed)?;
let base_bps = u64::from(verified.offense_type.base_penalty_bps());
let mut per_validator: Vec<PerValidatorSlash> = Vec::new();
for &idx in &verified.slashable_validator_indices {
let eff_bal = effective_balances.get(idx);
let should_skip = match validator_set.get(idx) {
Some(entry) => entry.is_slashed(),
None => true,
};
if should_skip {
continue;
}
let bps_term = eff_bal.saturating_mul(base_bps) / BPS_DENOMINATOR;
let floor_term = eff_bal / MIN_SLASHING_PENALTY_QUOTIENT;
let base_slash = bps_term.max(floor_term);
validator_set
.get_mut(idx)
.expect("checked Some above")
.slash_absolute(base_slash, self.current_epoch);
per_validator.push(PerValidatorSlash {
validator_index: idx,
base_slash_amount: base_slash,
effective_balance_at_slash: eff_bal,
collateral_slashed: 0,
});
self.slashed_in_window
.insert((self.current_epoch, idx), eff_bal);
}
let total_eff_bal: u64 = per_validator
.iter()
.map(|p| p.effective_balance_at_slash)
.sum();
let total_base: u64 = per_validator.iter().map(|p| p.base_slash_amount).sum();
let wb_reward = total_eff_bal / WHISTLEBLOWER_REWARD_QUOTIENT;
let prop_reward = wb_reward / PROPOSER_REWARD_QUOTIENT;
let burn_amount = total_base.saturating_sub(wb_reward + prop_reward);
reward_payout.pay(evidence.reporter_puzzle_hash, wb_reward);
let current_slot = proposer.current_slot();
let proposer_idx = proposer
.proposer_at_slot(current_slot)
.ok_or(SlashingError::ProposerUnavailable)?;
let proposer_ph = validator_set
.get(proposer_idx)
.ok_or(SlashingError::ValidatorNotRegistered(proposer_idx))?
.puzzle_hash();
reward_payout.pay(proposer_ph, prop_reward);
let record = PendingSlash {
evidence_hash,
evidence: evidence.clone(),
verified: verified.clone(),
status: PendingSlashStatus::Accepted,
submitted_at_epoch: self.current_epoch,
window_expires_at_epoch: self.current_epoch + SLASH_APPEAL_WINDOW_EPOCHS,
base_slash_per_validator: per_validator.clone(),
reporter_bond_mojos: REPORTER_BOND_MOJOS,
appeal_history: Vec::new(),
};
self.book.insert(record)?;
self.processed.insert(evidence_hash, self.current_epoch);
Ok(SlashingResult {
per_validator,
whistleblower_reward: wb_reward,
proposer_reward: prop_reward,
burn_amount,
reporter_bond_escrowed: REPORTER_BOND_MOJOS,
pending_slash_hash: evidence_hash,
})
}
pub fn finalise_expired_slashes(
&mut self,
validator_set: &mut dyn ValidatorView,
effective_balances: &dyn EffectiveBalanceView,
bond_escrow: &mut dyn BondEscrow,
total_active_balance: u64,
) -> Vec<FinalisationResult> {
let expired = self.book.expired_by(self.current_epoch);
let window_lo = self
.current_epoch
.saturating_sub(u64::from(dig_epoch::CORRELATION_WINDOW_EPOCHS));
let cohort_sum: u64 = self
.slashed_in_window
.range((window_lo, 0)..=(self.current_epoch, u32::MAX))
.map(|(_, eff)| *eff)
.sum();
let mut results = Vec::with_capacity(expired.len());
for hash in expired {
let status_is_terminal = matches!(
self.book.get(&hash).map(|p| p.status),
Some(PendingSlashStatus::Reverted { .. } | PendingSlashStatus::Finalised { .. }),
);
if status_is_terminal {
continue;
}
let (slashable_indices, evidence_hash, reporter_idx) = {
let pending = match self.book.get(&hash) {
Some(p) => p,
None => continue,
};
(
pending
.base_slash_per_validator
.iter()
.map(|p| p.validator_index)
.collect::<Vec<u32>>(),
pending.evidence_hash,
pending.evidence.reporter_validator_index,
)
};
let exit_lock_until_epoch = self.current_epoch + SLASH_LOCK_EPOCHS;
let mut correlation = Vec::with_capacity(slashable_indices.len());
let scaled = cohort_sum.saturating_mul(PROPORTIONAL_SLASHING_MULTIPLIER);
let capped = scaled.min(total_active_balance);
for idx in slashable_indices {
let eff_bal = effective_balances.get(idx);
let penalty = if total_active_balance == 0 {
0
} else {
let product = u128::from(eff_bal) * u128::from(capped);
(product / u128::from(total_active_balance)) as u64
};
if let Some(entry) = validator_set.get_mut(idx) {
entry.slash_absolute(penalty, self.current_epoch);
entry.schedule_exit(exit_lock_until_epoch);
}
correlation.push((idx, penalty));
}
let _ = bond_escrow.release(
reporter_idx,
REPORTER_BOND_MOJOS,
BondTag::Reporter(evidence_hash),
);
if let Some(pending) = self.book.get_mut(&hash) {
pending.status = PendingSlashStatus::Finalised {
finalised_at_epoch: self.current_epoch,
};
}
results.push(FinalisationResult {
evidence_hash,
per_validator_correlation_penalty: correlation,
reporter_bond_returned: REPORTER_BOND_MOJOS,
exit_lock_until_epoch,
});
}
results
}
pub fn submit_appeal(
&mut self,
appeal: &crate::appeal::SlashAppeal,
bond_escrow: &mut dyn BondEscrow,
) -> Result<(), SlashingError> {
let pending = self.book.get(&appeal.evidence_hash).ok_or_else(|| {
SlashingError::UnknownEvidence(hex_encode(appeal.evidence_hash.as_ref()))
})?;
match pending.status {
PendingSlashStatus::Reverted { .. } => {
return Err(SlashingError::SlashAlreadyReverted);
}
PendingSlashStatus::Finalised { .. } => {
return Err(SlashingError::SlashAlreadyFinalised);
}
PendingSlashStatus::Accepted | PendingSlashStatus::ChallengeOpen { .. } => {}
}
if appeal.filed_epoch > pending.window_expires_at_epoch {
return Err(SlashingError::AppealWindowExpired {
submitted_at: pending.submitted_at_epoch,
window: SLASH_APPEAL_WINDOW_EPOCHS,
current: appeal.filed_epoch,
});
}
use crate::appeal::SlashAppealPayload;
let variants_match = matches!(
(&appeal.payload, &pending.evidence.payload),
(
SlashAppealPayload::Proposer(_),
SlashingEvidencePayload::Proposer(_)
) | (
SlashAppealPayload::Attester(_),
SlashingEvidencePayload::Attester(_)
) | (
SlashAppealPayload::InvalidBlock(_),
SlashingEvidencePayload::InvalidBlock(_)
)
);
if !variants_match {
return Err(SlashingError::AppealVariantMismatch);
}
let appeal_hash = appeal.hash();
if pending
.appeal_history
.iter()
.any(|a| a.appeal_hash == appeal_hash)
{
return Err(SlashingError::DuplicateAppeal);
}
if pending.appeal_history.len() >= MAX_APPEAL_ATTEMPTS_PER_SLASH {
return Err(SlashingError::TooManyAttempts {
count: pending.appeal_history.len(),
limit: MAX_APPEAL_ATTEMPTS_PER_SLASH,
});
}
let encoded = bincode::serialize(appeal).expect("SlashAppeal bincode must not fail");
if encoded.len() > MAX_APPEAL_PAYLOAD_BYTES {
return Err(SlashingError::AppealPayloadTooLarge {
actual: encoded.len(),
limit: MAX_APPEAL_PAYLOAD_BYTES,
});
}
bond_escrow
.lock(
appeal.appellant_index,
APPELLANT_BOND_MOJOS,
BondTag::Appellant(appeal_hash),
)
.map_err(|e| SlashingError::AppellantBondLockFailed(e.to_string()))?;
Ok(())
}
pub fn set_epoch(&mut self, epoch: u64) {
self.current_epoch = epoch;
}
pub fn mark_processed(&mut self, hash: Bytes32, epoch: u64) {
self.processed.insert(hash, epoch);
}
pub fn mark_slashed_in_window(&mut self, epoch: u64, idx: u32, effective_balance: u64) {
self.slashed_in_window
.insert((epoch, idx), effective_balance);
}
#[must_use]
pub fn is_slashed_in_window(&self, epoch: u64, idx: u32) -> bool {
self.slashed_in_window.contains_key(&(epoch, idx))
}
#[must_use]
pub fn pending(&self, hash: &Bytes32) -> Option<&PendingSlash> {
self.book.get(hash)
}
pub fn prune(&mut self, before_epoch: u64) -> usize {
self.prune_processed_older_than(before_epoch)
}
#[must_use]
pub fn is_slashed(&self, idx: u32, validator_set: &dyn ValidatorView) -> bool {
validator_set
.get(idx)
.map(|entry| entry.is_slashed())
.unwrap_or(false)
}
pub fn rewind_on_reorg(
&mut self,
new_tip_epoch: u64,
validator_set: &mut dyn ValidatorView,
mut collateral: Option<&mut dyn CollateralSlasher>,
bond_escrow: &mut dyn BondEscrow,
) -> Vec<Bytes32> {
let to_rewind = self.book.submitted_after(new_tip_epoch);
let mut rewound = Vec::with_capacity(to_rewind.len());
for hash in to_rewind {
let Some(pending) = self.book.remove(&hash) else {
continue;
};
for per in &pending.base_slash_per_validator {
if let Some(entry) = validator_set.get_mut(per.validator_index) {
entry.credit_stake(per.base_slash_amount);
entry.restore_status();
}
if let Some(coll) = collateral.as_deref_mut() {
coll.credit(per.validator_index, per.collateral_slashed);
}
self.slashed_in_window
.remove(&(pending.submitted_at_epoch, per.validator_index));
}
let _ = bond_escrow.release(
pending.evidence.reporter_validator_index,
pending.reporter_bond_mojos,
BondTag::Reporter(hash),
);
self.processed.remove(&hash);
rewound.push(hash);
}
rewound
}
pub fn prune_processed_older_than(&mut self, cutoff_epoch: u64) -> usize {
let before = self.processed.len();
self.processed.retain(|_, epoch| *epoch >= cutoff_epoch);
let removed_processed = before - self.processed.len();
let stale_keys: Vec<(u64, u32)> = self
.slashed_in_window
.range(..(cutoff_epoch, 0))
.map(|(k, _)| *k)
.collect();
for k in stale_keys {
self.slashed_in_window.remove(&k);
}
removed_processed
}
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX_CHARS[(b >> 4) as usize] as char);
out.push(HEX_CHARS[(b & 0x0F) as usize] as char);
}
out
}