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