Skip to main content

dig_slashing/
manager.rs

1//! Slashing-lifecycle manager: optimistic admission, appeal windows,
2//! and finalisation.
3//!
4//! Traces to: [SPEC.md §7](../docs/resources/SPEC.md), catalogue rows
5//! [DSL-022..033](../docs/requirements/domains/lifecycle/specs/) plus
6//! the Phase-10 gap fills (DSL-146..152).
7//!
8//! # Surface
9//!
10//! `SlashingManager` implements the full optimistic-slashing lifecycle:
11//!
12//!   - base-slash formula per slashable validator (DSL-022),
13//!   - reporter-bond escrow (DSL-023),
14//!   - the `PendingSlash` book + status transitions (DSL-024, DSL-146..150),
15//!   - reward routing + correlation penalty (DSL-025, DSL-030),
16//!   - duplicate-rejection + capacity checks (DSL-026..028),
17//!   - finalisation of expired pending slashes (DSL-029..033),
18//!   - appeal admission (`submit_appeal`, DSL-055..063),
19//!   - reorg rewind (DSL-129) + processed/window pruning.
20
21use std::collections::{BTreeMap, HashMap};
22
23use dig_peer_protocol::Bytes32;
24use serde::{Deserialize, Serialize};
25
26use crate::bonds::{BondEscrow, BondTag};
27use crate::constants::{
28    APPELLANT_BOND_MOJOS, BPS_DENOMINATOR, MAX_APPEAL_ATTEMPTS_PER_SLASH, MAX_APPEAL_PAYLOAD_BYTES,
29    MAX_PENDING_SLASHES, MIN_SLASHING_PENALTY_QUOTIENT, PROPORTIONAL_SLASHING_MULTIPLIER,
30    PROPOSER_REWARD_QUOTIENT, REPORTER_BOND_MOJOS, SLASH_APPEAL_WINDOW_EPOCHS, SLASH_LOCK_EPOCHS,
31    WHISTLEBLOWER_REWARD_QUOTIENT,
32};
33use crate::error::SlashingError;
34use crate::evidence::envelope::{SlashingEvidence, SlashingEvidencePayload};
35use crate::evidence::verify::verify_evidence;
36use crate::pending::{PendingSlash, PendingSlashBook, PendingSlashStatus};
37use crate::traits::{
38    CollateralSlasher, EffectiveBalanceView, ProposerView, RewardPayout, ValidatorView,
39};
40
41/// Per-validator record produced by `submit_evidence`.
42///
43/// Traces to [SPEC §3.9](../../docs/resources/SPEC.md). Reversible on
44/// sustained appeal (DSL-064 credits `base_slash_amount` back); joined
45/// by a correlation penalty on finalisation (DSL-030).
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct PerValidatorSlash {
48    /// Index of the slashed validator.
49    pub validator_index: u32,
50    /// Base slash amount in mojos debited via
51    /// `ValidatorEntry::slash_absolute`. Equals
52    /// `max(eff_bal * base_bps / 10_000, eff_bal / 32)`.
53    pub base_slash_amount: u64,
54    /// `EffectiveBalanceView::get(idx)` captured at submission. Stored
55    /// so the adjudicator / correlation-penalty math can reproduce the
56    /// formula without re-reading state (which may drift after further
57    /// epochs).
58    pub effective_balance_at_slash: u64,
59    /// Collateral mojos slashed alongside the stake debit. Populated
60    /// by the consensus-layer collateral slasher wiring (landing in
61    /// a later orchestration DSL); default `0` so records produced
62    /// before that wiring still serialize + roundtrip.
63    ///
64    /// Consumed by DSL-065 on sustained appeal: the adjudicator
65    /// calls `CollateralSlasher::credit(validator_index, collateral_slashed)`
66    /// per reverted validator when a collateral slasher is supplied.
67    #[serde(default)]
68    pub collateral_slashed: u64,
69}
70
71/// Aggregate result of a `finalise_expired_slashes` pass — one
72/// record per pending slash that transitioned from
73/// `Accepted`/`ChallengeOpen` to `Finalised` during the call.
74///
75/// Traces to [SPEC §3.9](../../docs/resources/SPEC.md). Populated by
76/// `finalise_expired_slashes`:
77///
78///   - `per_validator_correlation_penalty` — DSL-030.
79///   - `reporter_bond_returned` — DSL-031.
80///   - `exit_lock_until_epoch` — DSL-032.
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
82pub struct FinalisationResult {
83    /// Evidence hash of the finalised pending slash.
84    pub evidence_hash: Bytes32,
85    /// Per-validator correlation penalty applied at finalisation
86    /// (DSL-030). `(validator_index, penalty_mojos)`.
87    pub per_validator_correlation_penalty: Vec<(u32, u64)>,
88    /// Reporter bond returned in full at finalisation (DSL-031).
89    pub reporter_bond_returned: u64,
90    /// Epoch the validator's exit lock runs until (DSL-032).
91    pub exit_lock_until_epoch: u64,
92}
93
94/// Aggregate result of a `submit_evidence` call.
95///
96/// Traces to [SPEC §3.9](../../docs/resources/SPEC.md). Every field is
97/// populated on a successful admission; the owning DSL is noted per
98/// field below.
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
100pub struct SlashingResult {
101    /// One entry per accused index actually debited. Already-slashed
102    /// validators (DSL-162) are silently skipped and do NOT appear.
103    pub per_validator: Vec<PerValidatorSlash>,
104    /// Whistleblower reward in mojos. Populated by DSL-025.
105    pub whistleblower_reward: u64,
106    /// Proposer inclusion reward in mojos. Populated by DSL-025.
107    pub proposer_reward: u64,
108    /// Burn amount in mojos. Populated by DSL-025.
109    pub burn_amount: u64,
110    /// Reporter bond escrowed in mojos. Populated by DSL-023.
111    pub reporter_bond_escrowed: u64,
112    /// Hash of the stored `PendingSlash` record. Populated by DSL-024.
113    pub pending_slash_hash: Bytes32,
114}
115
116/// Top-level slashing lifecycle manager.
117///
118/// Traces to [SPEC §7](../docs/resources/SPEC.md). Owns the
119/// `current_epoch`, the processed-hash dedup map, the pending-slash
120/// book, and the correlation-window counters — all consumed by the
121/// submit / finalise / appeal / rewind flows below.
122#[derive(Debug, Clone)]
123pub struct SlashingManager {
124    /// Current epoch the manager is running in. Used as the `epoch`
125    /// argument to `slash_absolute` so debits are timestamped with the
126    /// admission epoch, not the offense epoch (the two may differ by
127    /// up to `SLASH_LOOKBACK_EPOCHS`).
128    current_epoch: u64,
129    /// Pending-slash book (SPEC §7.1). Keyed by evidence hash;
130    /// populated on admission (DSL-024), drained on finalisation
131    /// (DSL-029) or reversal (DSL-070).
132    book: PendingSlashBook,
133    /// Processed-evidence dedup map. Value = admission epoch; used
134    /// by DSL-026 (`AlreadySlashed` short-circuit) and by pruning
135    /// (SPEC §8, `prune(lower_bound_epoch)`).
136    processed: HashMap<Bytes32, u64>,
137    /// Per-epoch register of effective balances slashed inside the
138    /// correlation window. Keyed by `(slash_epoch, validator_index)`
139    /// so `expired_by`-style range scans can be done cheaply at
140    /// finalisation. Populated by `submit_evidence` (DSL-022) with
141    /// one entry per per-validator debit; consumed by DSL-030's
142    /// `cohort_sum` computation.
143    slashed_in_window: BTreeMap<(u64, u32), u64>,
144}
145
146impl Default for SlashingManager {
147    fn default() -> Self {
148        Self::new(0)
149    }
150}
151
152impl SlashingManager {
153    /// New manager at `current_epoch` with the default
154    /// `MAX_PENDING_SLASHES` book capacity. Further fields (pending
155    /// book, processed map) start empty.
156    #[must_use]
157    pub fn new(current_epoch: u64) -> Self {
158        Self::with_book_capacity(current_epoch, MAX_PENDING_SLASHES)
159    }
160
161    /// New manager with a caller-specified book capacity. Used by
162    /// DSL-027 tests to exercise the `PendingBookFull` rejection.
163    #[must_use]
164    pub fn with_book_capacity(current_epoch: u64, book_capacity: usize) -> Self {
165        Self {
166            current_epoch,
167            book: PendingSlashBook::new(book_capacity),
168            processed: HashMap::new(),
169            slashed_in_window: BTreeMap::new(),
170        }
171    }
172
173    /// Current epoch accessor.
174    #[must_use]
175    pub fn current_epoch(&self) -> u64 {
176        self.current_epoch
177    }
178
179    /// Immutable view of the pending-slash book.
180    #[must_use]
181    pub fn book(&self) -> &PendingSlashBook {
182        &self.book
183    }
184
185    /// Mutable access to the pending-slash book.
186    ///
187    /// Exposed for adjudication code (DSL-064..070) that needs to
188    /// transition pending statuses to `Reverted`/`ChallengeOpen`
189    /// outside the manager's own `submit_evidence` +
190    /// `finalise_expired_slashes` flow. Test suites also use this to
191    /// inject pre-`Reverted` state for DSL-033 skip-path coverage.
192    pub fn book_mut(&mut self) -> &mut PendingSlashBook {
193        &mut self.book
194    }
195
196    /// `true` iff the evidence hash has been admitted. Used by
197    /// DSL-026 (`AlreadySlashed` short-circuit) + tests.
198    #[must_use]
199    pub fn is_processed(&self, hash: &Bytes32) -> bool {
200        self.processed.contains_key(hash)
201    }
202
203    /// Admission epoch recorded for a processed hash. `None` when the
204    /// hash is not in the map.
205    #[must_use]
206    pub fn processed_epoch(&self, hash: &Bytes32) -> Option<u64> {
207        self.processed.get(hash).copied()
208    }
209
210    /// Optimistic-admission entry point for validator slashing evidence.
211    ///
212    /// Implements the base-slash branch of
213    /// [DSL-022](../../docs/requirements/domains/lifecycle/specs/DSL-022.md).
214    /// Traces to SPEC §7.3 step 5, §4.
215    ///
216    /// # Pipeline
217    ///
218    /// 1. DSL-026 duplicate-evidence dedup: reject with
219    ///    `AlreadySlashed` if the evidence hash is already processed.
220    /// 2. `verify_evidence(...)` → `VerifiedEvidence` (DSL-011..020).
221    ///    Failure propagates as `SlashingError`; no state mutation.
222    /// 3. DSL-027 capacity check: reject with `PendingBookFull` when
223    ///    the book is at capacity.
224    /// 4. Resolve the proposer reward target as a read-only
225    ///    precondition (`ProposerUnavailable` / `ValidatorNotRegistered`)
226    ///    BEFORE any mutation (dig_ecosystem #346 finding-B).
227    /// 5. `bond_escrow.lock(reporter_idx, REPORTER_BOND_MOJOS,
228    ///    BondTag::Reporter(evidence.hash()))` (DSL-023). Lock
229    ///    failure collapses to `SlashingError::BondLockFailed` with
230    ///    no validator-side mutation — hence the ordering (bond
231    ///    BEFORE any `slash_absolute`).
232    /// 6. For each slashable index:
233    ///    - `eff_bal = effective_balances.get(idx)`
234    ///    - `bps_term = eff_bal * base_bps / BPS_DENOMINATOR`
235    ///    - `floor_term = eff_bal / MIN_SLASHING_PENALTY_QUOTIENT`
236    ///    - `base_slash = max(bps_term, floor_term)`
237    ///    - Skip iff `validator_set.get(idx).is_slashed()` OR index
238    ///      absent from the view (defensive tolerance per SPEC §7.3).
239    ///    - Otherwise `validator_set.get_mut(idx).slash_absolute(
240    ///      base_slash, self.current_epoch)`, record a
241    ///      `PerValidatorSlash`, and register the debit in
242    ///      `slashed_in_window` (DSL-030).
243    /// 7. DSL-025 reward routing: pay the whistleblower + proposer
244    ///    rewards and compute the burn amount.
245    /// 8. DSL-024: insert the `PendingSlash` record + mark the hash
246    ///    processed, then return a fully-populated `SlashingResult`.
247    ///
248    /// # Collateral
249    ///
250    /// The stake-only path records `collateral_slashed: 0`; the
251    /// `CollateralSlasher` wiring is driven by the consensus-layer
252    /// orchestration, not this entry point.
253    #[allow(clippy::too_many_arguments)]
254    pub fn submit_evidence(
255        &mut self,
256        evidence: SlashingEvidence,
257        validator_set: &mut dyn ValidatorView,
258        effective_balances: &dyn EffectiveBalanceView,
259        bond_escrow: &mut dyn BondEscrow,
260        reward_payout: &mut dyn RewardPayout,
261        proposer: &dyn ProposerView,
262        network_id: &Bytes32,
263    ) -> Result<SlashingResult, SlashingError> {
264        // DSL-026: duplicate evidence dedup. Runs BEFORE verify /
265        // capacity / bond / slash — cheapest rejection path. Uses
266        // evidence.hash() (DSL-002) as the dedup key. Persists across
267        // pending statuses until reorg rewind (DSL-129) or prune
268        // clears the entry.
269        let evidence_hash_pre = evidence.hash();
270        if self.processed.contains_key(&evidence_hash_pre) {
271            return Err(SlashingError::AlreadySlashed);
272        }
273
274        // Verify first — no state mutation on rejection.
275        let verified = verify_evidence(&evidence, validator_set, network_id, self.current_epoch)?;
276
277        // DSL-027: capacity check BEFORE bond lock or any validator
278        // mutation. Placed after verify so only valid, non-duplicate
279        // evidence can trigger capacity exhaustion. Strict `>=` — the
280        // book never holds more than `capacity` records.
281        if self.book.len() >= self.book.capacity() {
282            return Err(SlashingError::PendingBookFull);
283        }
284
285        // Resolve the proposer reward target BEFORE any mutation. This is
286        // a READ-ONLY precondition: `proposer_at_slot` returning `None` is
287        // a consensus-layer bug (→ `ProposerUnavailable`), and a resolved
288        // index absent from the validator set (→ `ValidatorNotRegistered`)
289        // must both be surfaced WITHOUT slashing anyone or locking the
290        // reporter bond (dig_ecosystem #346 finding-B). Resolving up front
291        // preserves the DSL-023 atomicity invariant ("lock BEFORE any
292        // validator-side mutation; failure → no validator-side mutation")
293        // while adding a fail-fast read-only guard ahead of it. The
294        // puzzle-hash read is stable across the slash loop — `slash_absolute`
295        // changes balance, not identity — so the success path is
296        // behaviour-preserving; only the fallible resolution moves up, the
297        // actual `reward_payout.pay(proposer_ph, …)` stays after
298        // `prop_reward` is computed post-loop.
299        let current_slot = proposer.current_slot();
300        let proposer_idx = proposer
301            .proposer_at_slot(current_slot)
302            .ok_or(SlashingError::ProposerUnavailable)?;
303        let proposer_ph = validator_set
304            .get(proposer_idx)
305            .ok_or(SlashingError::ValidatorNotRegistered(proposer_idx))?
306            .puzzle_hash();
307
308        // DSL-023: lock reporter bond BEFORE any validator-side mutation.
309        // Failure surfaces as `BondLockFailed` with validator state still
310        // untouched — ordering invariant tested by
311        // `test_dsl_023_lock_failure_no_mutation`. Reuses the hash
312        // computed for the DSL-026 dedup check above.
313        let evidence_hash = evidence_hash_pre;
314        bond_escrow
315            .lock(
316                evidence.reporter_validator_index,
317                REPORTER_BOND_MOJOS,
318                BondTag::Reporter(evidence_hash),
319            )
320            .map_err(|_| SlashingError::BondLockFailed)?;
321
322        let base_bps = u64::from(verified.offense_type.base_penalty_bps());
323        let mut per_validator: Vec<PerValidatorSlash> = Vec::new();
324
325        for &idx in &verified.slashable_validator_indices {
326            // Snapshot effective balance BEFORE any mutation — the
327            // formula must run on the balance at admission time, not
328            // after slash_absolute has debited it.
329            let eff_bal = effective_balances.get(idx);
330
331            // Skip already-slashed (DSL-162) AND indices that drifted
332            // out of the view between verify + submit. Both are silent
333            // skips: per-validator record omitted.
334            let should_skip = match validator_set.get(idx) {
335                Some(entry) => entry.is_slashed(),
336                None => true,
337            };
338            if should_skip {
339                continue;
340            }
341
342            // base_slash = max(eff_bal * base_bps / 10_000, eff_bal / 32).
343            // Order-of-operations: multiply before divide to keep
344            // precision; `eff_bal * base_bps` fits in u64 for any
345            // realistic effective balance (max eff_bal is ~32e9
346            // mojos; times 500 is 1.6e13, far below u64::MAX).
347            let bps_term = eff_bal.saturating_mul(base_bps) / BPS_DENOMINATOR;
348            let floor_term = eff_bal / MIN_SLASHING_PENALTY_QUOTIENT;
349            let base_slash = bps_term.max(floor_term);
350
351            // Debit. `slash_absolute` is saturating (DSL-131), so this
352            // never drives balance negative.
353            validator_set
354                .get_mut(idx)
355                .expect("checked Some above")
356                .slash_absolute(base_slash, self.current_epoch);
357
358            per_validator.push(PerValidatorSlash {
359                validator_index: idx,
360                base_slash_amount: base_slash,
361                effective_balance_at_slash: eff_bal,
362                // DSL-065: collateral wiring lands in a later
363                // orchestration DSL; stake-only path records 0.
364                collateral_slashed: 0,
365            });
366
367            // DSL-030: record the slash in the correlation-window
368            // register. `slashed_in_window` is consumed at
369            // finalisation to compute `cohort_sum`.
370            self.slashed_in_window
371                .insert((self.current_epoch, idx), eff_bal);
372        }
373
374        // DSL-025: reward routing. Two optimistic payouts settled
375        // BEFORE the pending-book insert so the returned
376        // `SlashingResult` carries the paid amounts atomically with
377        // the admission. Rewards are clawed back on sustained appeal
378        // (DSL-067) via a separate `RewardClawback` path — the pay
379        // calls here are idempotent credits, not debits.
380        let total_eff_bal: u64 = per_validator
381            .iter()
382            .map(|p| p.effective_balance_at_slash)
383            .sum();
384        let total_base: u64 = per_validator.iter().map(|p| p.base_slash_amount).sum();
385        let wb_reward = total_eff_bal / WHISTLEBLOWER_REWARD_QUOTIENT;
386        let prop_reward = wb_reward / PROPOSER_REWARD_QUOTIENT;
387        let burn_amount = total_base.saturating_sub(wb_reward + prop_reward);
388
389        // Whistleblower payout — always emits the call (even on zero
390        // reward) so auditors see a deterministic two-call pattern.
391        reward_payout.pay(evidence.reporter_puzzle_hash, wb_reward);
392
393        // Proposer inclusion payout. The reward target (`proposer_ph`) was
394        // resolved up front as a read-only precondition, so a missing
395        // proposer never reaches this mutating section.
396        reward_payout.pay(proposer_ph, prop_reward);
397
398        // DSL-024: insert the PendingSlash record + register the hash
399        // in processed. Ordering: book insert first so a capacity
400        // failure bubbles up WITHOUT polluting processed.
401        let record = PendingSlash {
402            evidence_hash,
403            evidence: evidence.clone(),
404            verified: verified.clone(),
405            status: PendingSlashStatus::Accepted,
406            submitted_at_epoch: self.current_epoch,
407            window_expires_at_epoch: self.current_epoch + SLASH_APPEAL_WINDOW_EPOCHS,
408            base_slash_per_validator: per_validator.clone(),
409            reporter_bond_mojos: REPORTER_BOND_MOJOS,
410            appeal_history: Vec::new(),
411        };
412        // The `?` below is de-facto unreachable: the DSL-027 capacity
413        // pre-check above (`self.book.len() >= self.book.capacity()`)
414        // guarantees headroom before any mutation runs, so the book
415        // cannot be full here. This assert ties the two together so a
416        // future refactor that separates the pre-check from the insert
417        // can't silently reopen a partial-mutation window (rewards paid
418        // + validators slashed, but the record rejected). Compiles out
419        // in release — behaviour is unchanged.
420        debug_assert!(
421            self.book.len() < self.book.capacity(),
422            "book must have capacity headroom here — guaranteed by the \
423             DSL-027 pre-check; a full book at insert time means the \
424             pre-check and the insert have drifted apart",
425        );
426        self.book.insert(record)?;
427        self.processed.insert(evidence_hash, self.current_epoch);
428
429        Ok(SlashingResult {
430            per_validator,
431            whistleblower_reward: wb_reward,
432            proposer_reward: prop_reward,
433            burn_amount,
434            reporter_bond_escrowed: REPORTER_BOND_MOJOS,
435            pending_slash_hash: evidence_hash,
436        })
437    }
438
439    /// Transition every expired pending slash from
440    /// `Accepted`/`ChallengeOpen` to `Finalised { finalised_at_epoch:
441    /// self.current_epoch }` and emit one `FinalisationResult` per
442    /// transition.
443    ///
444    /// Implements [DSL-029](../../docs/requirements/domains/lifecycle/specs/DSL-029.md).
445    /// Traces to SPEC §7.4 steps 1, 6–7.
446    ///
447    /// # Side effects per finalised slash
448    ///
449    ///   - DSL-030: applies + reports `per_validator_correlation_penalty`.
450    ///   - DSL-031: releases the reporter bond (`bond_escrow.release`)
451    ///     and reports `reporter_bond_returned`.
452    ///   - DSL-032: schedules the validator exit lock
453    ///     (`validator_set.schedule_exit`) and reports
454    ///     `exit_lock_until_epoch`.
455    ///
456    /// # Behaviour
457    ///
458    /// - Iterates `book.expired_by(self.current_epoch)` in ascending
459    ///   window-expiry order (stable across calls).
460    /// - Skips pendings already in `Reverted { .. }` or `Finalised { .. }`
461    ///   (DSL-033).
462    /// - Idempotent: calling twice in the same epoch yields an empty
463    ///   second result vec.
464    pub fn finalise_expired_slashes(
465        &mut self,
466        validator_set: &mut dyn ValidatorView,
467        effective_balances: &dyn EffectiveBalanceView,
468        bond_escrow: &mut dyn BondEscrow,
469        total_active_balance: u64,
470    ) -> Vec<FinalisationResult> {
471        let expired = self.book.expired_by(self.current_epoch);
472
473        // DSL-030: compute `cohort_sum` ONCE per finalise pass.
474        // `slashed_in_window` is keyed by `(slash_epoch, idx)`; we
475        // sum every eff_bal_at_slash value for epochs in
476        // `[current - CORRELATION_WINDOW_EPOCHS, current]`. Saturating
477        // subtraction keeps the window lower-bound non-negative at
478        // network boot.
479        let window_lo = self
480            .current_epoch
481            .saturating_sub(u64::from(dig_epoch::CORRELATION_WINDOW_EPOCHS));
482        let cohort_sum: u64 = self
483            .slashed_in_window
484            .range((window_lo, 0)..=(self.current_epoch, u32::MAX))
485            .map(|(_, eff)| *eff)
486            .sum();
487
488        let mut results = Vec::with_capacity(expired.len());
489        for hash in expired {
490            // DSL-033: skip terminal statuses. Snapshot status via a
491            // short borrow so we can mutate other fields afterward.
492            let status_is_terminal = matches!(
493                self.book.get(&hash).map(|p| p.status),
494                Some(PendingSlashStatus::Reverted { .. } | PendingSlashStatus::Finalised { .. }),
495            );
496            if status_is_terminal {
497                continue;
498            }
499
500            // Snapshot per-validator entries (clone the small vec)
501            // before the mutable borrow for the validator-set slash
502            // calls. Keeps the borrow graph simple.
503            let (slashable_indices, evidence_hash, reporter_idx) = {
504                let pending = match self.book.get(&hash) {
505                    Some(p) => p,
506                    None => continue,
507                };
508                (
509                    pending
510                        .base_slash_per_validator
511                        .iter()
512                        .map(|p| p.validator_index)
513                        .collect::<Vec<u32>>(),
514                    pending.evidence_hash,
515                    pending.evidence.reporter_validator_index,
516                )
517            };
518
519            // DSL-030: per-validator correlation penalty. Formula:
520            //   penalty = eff_bal * min(cohort_sum * 3, total) / total
521            // with total_active_balance==0 yielding 0 (defensive).
522            //
523            // DSL-032: schedule the exit lock alongside the penalty.
524            // `exit_lock_until_epoch = current + SLASH_LOCK_EPOCHS`.
525            let exit_lock_until_epoch = self.current_epoch + SLASH_LOCK_EPOCHS;
526            let mut correlation = Vec::with_capacity(slashable_indices.len());
527            let scaled = cohort_sum.saturating_mul(PROPORTIONAL_SLASHING_MULTIPLIER);
528            let capped = scaled.min(total_active_balance);
529            for idx in slashable_indices {
530                let eff_bal = effective_balances.get(idx);
531                // u128 intermediate prevents overflow on the multiply:
532                // `eff_bal * capped` can exceed u64 when both are near
533                // MIN_EFFECTIVE_BALANCE (e.g. 32e9 * 32e9 = 1.02e21 >
534                // u64::MAX). The final divide shrinks back to u64 range
535                // because `penalty <= eff_bal` (saturation property).
536                let penalty = if total_active_balance == 0 {
537                    0
538                } else {
539                    let product = u128::from(eff_bal) * u128::from(capped);
540                    (product / u128::from(total_active_balance)) as u64
541                };
542                if let Some(entry) = validator_set.get_mut(idx) {
543                    entry.slash_absolute(penalty, self.current_epoch);
544                    // DSL-032: exit lock scheduled inside same entry
545                    // access — one &mut borrow per validator.
546                    entry.schedule_exit(exit_lock_until_epoch);
547                }
548                correlation.push((idx, penalty));
549            }
550
551            // DSL-031: release reporter bond in full. Bond was locked
552            // at admission (DSL-023); release is infallible by
553            // construction — any escrow error is a book-keeping bug
554            // that shouldn't block epoch advancement, so log-and-continue
555            // behaviour is acceptable (tests supply accepting escrows).
556            let _ = bond_escrow.release(
557                reporter_idx,
558                REPORTER_BOND_MOJOS,
559                BondTag::Reporter(evidence_hash),
560            );
561
562            // Now flip the pending status. Second borrow on book.
563            if let Some(pending) = self.book.get_mut(&hash) {
564                pending.status = PendingSlashStatus::Finalised {
565                    finalised_at_epoch: self.current_epoch,
566                };
567            }
568
569            results.push(FinalisationResult {
570                evidence_hash,
571                per_validator_correlation_penalty: correlation,
572                reporter_bond_returned: REPORTER_BOND_MOJOS,
573                exit_lock_until_epoch,
574            });
575        }
576        results
577    }
578
579    /// Submit an appeal against an existing pending slash.
580    ///
581    /// Implements [DSL-055](../../docs/requirements/domains/appeal/specs/DSL-055.md).
582    /// Traces to SPEC §6.1, §7.2.
583    ///
584    /// # Pipeline
585    ///
586    /// Runs the full appeal-admission pipeline, in order, so every
587    /// structural rejection short-circuits before any collateral is
588    /// touched:
589    ///   - DSL-055: `UnknownEvidence` (book lookup — FIRST, pre-bond)
590    ///   - DSL-060/061: `SlashAlreadyReverted` / `SlashAlreadyFinalised`
591    ///   - DSL-056: `WindowExpired`
592    ///   - DSL-057: `VariantMismatch`
593    ///   - DSL-058: `DuplicateAppeal`
594    ///   - DSL-059: `TooManyAttempts`
595    ///   - DSL-063: `PayloadTooLarge`
596    ///   - DSL-062: appellant-bond lock (LAST step, first bond touch)
597    ///
598    /// Verdict dispatch + economic adjudication (DSL-064+) are NOT
599    /// part of admission — they run via
600    /// [`adjudicate_appeal`](crate::appeal::adjudicate_appeal).
601    ///
602    /// # Error ordering invariant
603    ///
604    /// `UnknownEvidence` MUST be checked BEFORE any bond operation
605    /// so a caller with a stale / misrouted appeal does not pay
606    /// gas to lock collateral that would immediately need to be
607    /// returned. Preserved by running the book lookup as the first
608    /// statement — see the DSL-055 test suite's
609    /// `test_dsl_055_bond_not_locked` guard.
610    pub fn submit_appeal(
611        &mut self,
612        appeal: &crate::appeal::SlashAppeal,
613        bond_escrow: &mut dyn BondEscrow,
614    ) -> Result<(), SlashingError> {
615        // DSL-055: UnknownEvidence — must run BEFORE any bond
616        // operation or further state inspection.
617        let pending = self.book.get(&appeal.evidence_hash).ok_or_else(|| {
618            SlashingError::UnknownEvidence(hex_encode(appeal.evidence_hash.as_ref()))
619        })?;
620
621        // DSL-060 / DSL-061: terminal-state guards. Reverted and
622        // Finalised pending slashes are non-actionable — no
623        // further appeals are accepted. Checked BEFORE the
624        // window/variant/duplicate logic because terminal state
625        // trumps all other dispositions.
626        match pending.status {
627            PendingSlashStatus::Reverted { .. } => {
628                return Err(SlashingError::SlashAlreadyReverted);
629            }
630            PendingSlashStatus::Finalised { .. } => {
631                return Err(SlashingError::SlashAlreadyFinalised);
632            }
633            PendingSlashStatus::Accepted | PendingSlashStatus::ChallengeOpen { .. } => {}
634        }
635
636        // DSL-056: WindowExpired — reject when the appeal was
637        // filed strictly AFTER the window-close boundary. The
638        // boundary epoch itself (`filed_epoch ==
639        // window_expires_at_epoch`) is still a valid filing; only
640        // `filed_epoch > expires_at` trips. Bond lock happens in
641        // DSL-062 so this check still precedes any collateral
642        // touch.
643        if appeal.filed_epoch > pending.window_expires_at_epoch {
644            return Err(SlashingError::AppealWindowExpired {
645                submitted_at: pending.submitted_at_epoch,
646                window: SLASH_APPEAL_WINDOW_EPOCHS,
647                current: appeal.filed_epoch,
648            });
649        }
650
651        // DSL-057: VariantMismatch — the appeal payload variant
652        // MUST match the evidence payload variant. Structural
653        // check; no state inspection beyond the two enum tags.
654        use crate::appeal::SlashAppealPayload;
655        let variants_match = matches!(
656            (&appeal.payload, &pending.evidence.payload),
657            (
658                SlashAppealPayload::Proposer(_),
659                SlashingEvidencePayload::Proposer(_)
660            ) | (
661                SlashAppealPayload::Attester(_),
662                SlashingEvidencePayload::Attester(_)
663            ) | (
664                SlashAppealPayload::InvalidBlock(_),
665                SlashingEvidencePayload::InvalidBlock(_)
666            )
667        );
668        if !variants_match {
669            return Err(SlashingError::AppealVariantMismatch);
670        }
671
672        // DSL-058: DuplicateAppeal — the appeal's content-addressed
673        // hash (DOMAIN_SLASH_APPEAL || bincode(appeal)) MUST NOT
674        // already appear in `pending.appeal_history`. Near-dupes
675        // (different witness bytes, different ground, etc.)
676        // produce distinct hashes and are accepted.
677        let appeal_hash = appeal.hash();
678        if pending
679            .appeal_history
680            .iter()
681            .any(|a| a.appeal_hash == appeal_hash)
682        {
683            return Err(SlashingError::DuplicateAppeal);
684        }
685
686        // DSL-059: TooManyAttempts — cap adjudication cost at
687        // `MAX_APPEAL_ATTEMPTS_PER_SLASH` (4). Only REJECTED
688        // attempts accumulate here — a sustained appeal drains
689        // the book entry (DSL-070) so this counter never sees
690        // more than the cap in practice.
691        if pending.appeal_history.len() >= MAX_APPEAL_ATTEMPTS_PER_SLASH {
692            return Err(SlashingError::TooManyAttempts {
693                count: pending.appeal_history.len(),
694                limit: MAX_APPEAL_ATTEMPTS_PER_SLASH,
695            });
696        }
697
698        // DSL-063: PayloadTooLarge — cap bincode-serialized
699        // envelope size. Runs BEFORE the bond lock so oversized
700        // appeals never touch collateral. bincode chosen for
701        // parity with `SlashAppeal::hash` (same canonical form).
702        let encoded = bincode::serialize(appeal).expect("SlashAppeal bincode must not fail");
703        if encoded.len() > MAX_APPEAL_PAYLOAD_BYTES {
704            return Err(SlashingError::AppealPayloadTooLarge {
705                actual: encoded.len(),
706                limit: MAX_APPEAL_PAYLOAD_BYTES,
707            });
708        }
709
710        // DSL-062: appellant-bond lock. LAST admission step so
711        // every structural rejection (DSL-055..061, DSL-063)
712        // short-circuits before any collateral is touched. Tag
713        // MUST be `BondTag::Appellant(appeal.hash())` so DSL-068
714        // + DSL-071 can release / forfeit the correct slot.
715        bond_escrow
716            .lock(
717                appeal.appellant_index,
718                APPELLANT_BOND_MOJOS,
719                BondTag::Appellant(appeal_hash),
720            )
721            .map_err(|e| SlashingError::AppellantBondLockFailed(e.to_string()))?;
722
723        // Admission complete. Verdict dispatch + adjudication
724        // (DSL-064+) run separately via `adjudicate_appeal`.
725        Ok(())
726    }
727
728    /// Advance the manager's epoch. Consumers at the consensus layer
729    /// call this at every epoch boundary AFTER running
730    /// `finalise_expired_slashes` — keeps the current epoch in lock
731    /// step with the chain. Test helper.
732    pub fn set_epoch(&mut self, epoch: u64) {
733        self.current_epoch = epoch;
734    }
735
736    /// Record a processed-evidence entry for persistence load
737    /// or test fixtures.
738    ///
739    /// `submit_evidence` does this implicitly on admission;
740    /// this method is the public surface for replaying a
741    /// persisted book or constructing a unit-test fixture
742    /// without going through the full verify + bond-lock
743    /// pipeline.
744    pub fn mark_processed(&mut self, hash: Bytes32, epoch: u64) {
745        self.processed.insert(hash, epoch);
746    }
747
748    /// Record a `(epoch, validator_index) → effective_balance`
749    /// entry in the slashed-in-window cohort map. Companion to
750    /// `mark_processed`; used by persistence load + tests.
751    pub fn mark_slashed_in_window(&mut self, epoch: u64, idx: u32, effective_balance: u64) {
752        self.slashed_in_window
753            .insert((epoch, idx), effective_balance);
754    }
755
756    /// Lookup for `slashed_in_window` — test helper so integration
757    /// tests can verify DSL-129 rewind actually cleared an entry.
758    #[must_use]
759    pub fn is_slashed_in_window(&self, epoch: u64, idx: u32) -> bool {
760        self.slashed_in_window.contains_key(&(epoch, idx))
761    }
762
763    /// Read-side lookup for a pending slash by `evidence_hash`.
764    ///
765    /// Implements [DSL-150](../docs/requirements/domains/lifecycle/specs/DSL-150.md).
766    /// Convenience wrapper over `self.book().get(hash)` so callers
767    /// don't need to chain through `book()`. Returns `None` when
768    /// the slash has been removed via `book.remove` or was never
769    /// admitted.
770    #[must_use]
771    pub fn pending(&self, hash: &Bytes32) -> Option<&PendingSlash> {
772        self.book.get(hash)
773    }
774
775    /// Prune processed + slashed_in_window entries older than
776    /// `before_epoch`. Convenience alias for
777    /// [`prune_processed_older_than`](Self::prune_processed_older_than)
778    /// per DSL-150 naming.
779    ///
780    /// Does NOT touch `book` — pending slashes are removed via
781    /// `book.remove` or `finalise_expired_slashes` which own the
782    /// status-transition lifecycle.
783    ///
784    /// Typical caller: DSL-127 run_epoch_boundary with
785    /// `before_epoch = current.saturating_sub(CORRELATION_WINDOW_EPOCHS)`.
786    pub fn prune(&mut self, before_epoch: u64) -> usize {
787        self.prune_processed_older_than(before_epoch)
788    }
789
790    /// Delegate to `ValidatorView::get(idx)?.is_slashed()`.
791    ///
792    /// Implements [DSL-149](../docs/requirements/domains/lifecycle/specs/DSL-149.md).
793    /// Returns `false` for unknown indices (no panic) — matches
794    /// DSL-136 `ValidatorView::get` out-of-range semantics.
795    /// Read-only: does not mutate `self` or the validator set.
796    #[must_use]
797    pub fn is_slashed(&self, idx: u32, validator_set: &dyn ValidatorView) -> bool {
798        validator_set
799            .get(idx)
800            .map(|entry| entry.is_slashed())
801            .unwrap_or(false)
802    }
803
804    /// Rewind every pending slash whose `submitted_at_epoch` is
805    /// STRICTLY greater than `new_tip_epoch` — the canonical
806    /// fork-choice reorg response.
807    ///
808    /// Implements [DSL-129](../docs/requirements/domains/orchestration/specs/DSL-129.md).
809    /// Traces to SPEC §13.
810    ///
811    /// # Side effects per rewound entry
812    ///
813    ///   - `ValidatorEntry::credit_stake(base_slash_amount)` on
814    ///     each slashable validator.
815    ///   - `ValidatorEntry::restore_status()` on each.
816    ///   - `CollateralSlasher::credit(validator_index,
817    ///     collateral_slashed)` on each (when `collateral`
818    ///     present).
819    ///   - `BondEscrow::release` of the reporter bond at
820    ///     `BondTag::Reporter(evidence_hash)` — NOT `forfeit`.
821    ///     Reorg is not the reporter's fault; the bond returns
822    ///     intact.
823    ///   - Entry removed from `self.book`, `self.processed`,
824    ///     and `self.slashed_in_window`.
825    ///
826    /// # What it does NOT do
827    ///
828    /// - NO reporter penalty. DSL-069 applies a reporter
829    ///   penalty on SUSTAINED-APPEAL revert; a reorg is a
830    ///   consensus-layer signal that the original evidence was
831    ///   never canonical, so the reporter is not at fault.
832    /// - NO appeal-history inspection. Any filed appeals on the
833    ///   rewound slash are discarded along with the slash
834    ///   itself.
835    ///
836    /// # Returns
837    ///
838    /// List of `evidence_hash` values that were rewound. Empty
839    /// when no pending slashes are past the new tip.
840    pub fn rewind_on_reorg(
841        &mut self,
842        new_tip_epoch: u64,
843        validator_set: &mut dyn ValidatorView,
844        mut collateral: Option<&mut dyn CollateralSlasher>,
845        bond_escrow: &mut dyn BondEscrow,
846    ) -> Vec<Bytes32> {
847        let to_rewind = self.book.submitted_after(new_tip_epoch);
848
849        let mut rewound = Vec::with_capacity(to_rewind.len());
850        for hash in to_rewind {
851            let Some(pending) = self.book.remove(&hash) else {
852                continue;
853            };
854
855            // Credit stake + restore status + collateral per slashable
856            // validator. Snapshot epochs BEFORE the mutable borrows.
857            for per in &pending.base_slash_per_validator {
858                if let Some(entry) = validator_set.get_mut(per.validator_index) {
859                    entry.credit_stake(per.base_slash_amount);
860                    entry.restore_status();
861                }
862                if let Some(coll) = collateral.as_deref_mut() {
863                    coll.credit(per.validator_index, per.collateral_slashed);
864                }
865                // slashed_in_window row keyed by (epoch, idx) —
866                // remove so a later finalise pass does not
867                // double-count this validator in a cohort-sum.
868                self.slashed_in_window
869                    .remove(&(pending.submitted_at_epoch, per.validator_index));
870            }
871
872            // Release reporter bond — NOT forfeit. A forfeit here
873            // would punish the reporter for a consensus event they
874            // did not cause. Ignore release errors (TagNotFound)
875            // to keep rewind infallible under partial escrow state
876            // (defensive; should not happen if submit_evidence ran
877            // the lock).
878            let _ = bond_escrow.release(
879                pending.evidence.reporter_validator_index,
880                pending.reporter_bond_mojos,
881                BondTag::Reporter(hash),
882            );
883
884            // Clear dedup map so a re-submission on the new
885            // canonical chain admits cleanly.
886            self.processed.remove(&hash);
887
888            rewound.push(hash);
889        }
890
891        rewound
892    }
893
894    /// Prune processed-evidence map entries whose recorded epoch is
895    /// strictly less than `cutoff_epoch`. Called by the DSL-127
896    /// `run_epoch_boundary` step 8 (last step) to bound memory of
897    /// the AlreadySlashed dedup window.
898    ///
899    /// Returns the number of entries removed. Also drops any
900    /// `slashed_in_window` rows whose epoch is older than the
901    /// cutoff — the cohort-sum window (DSL-030) is
902    /// `CORRELATION_WINDOW_EPOCHS` wide so entries older than
903    /// `current_epoch - CORRELATION_WINDOW_EPOCHS` can never
904    /// contribute to a future finalisation.
905    pub fn prune_processed_older_than(&mut self, cutoff_epoch: u64) -> usize {
906        let before = self.processed.len();
907        self.processed.retain(|_, epoch| *epoch >= cutoff_epoch);
908        let removed_processed = before - self.processed.len();
909
910        // Range-remove over the BTreeMap keyed by (epoch, idx).
911        // Collect the keys first to avoid borrow issues while
912        // mutating.
913        let stale_keys: Vec<(u64, u32)> = self
914            .slashed_in_window
915            .range(..(cutoff_epoch, 0))
916            .map(|(k, _)| *k)
917            .collect();
918        for k in stale_keys {
919            self.slashed_in_window.remove(&k);
920        }
921
922        removed_processed
923    }
924}
925
926/// Fixed-size lowercase hex encoder for diagnostic log strings.
927///
928/// Stays inline to avoid pulling a `hex` crate just for error
929/// messages. DSL-055 uses this to stamp `UnknownEvidence(hex)`
930/// with the 64-char hex representation of the missing evidence
931/// hash.
932fn hex_encode(bytes: &[u8]) -> String {
933    const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
934    let mut out = String::with_capacity(bytes.len() * 2);
935    for &b in bytes {
936        out.push(HEX_CHARS[(b >> 4) as usize] as char);
937        out.push(HEX_CHARS[(b & 0x0F) as usize] as char);
938    }
939    out
940}