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 // DSL-023: lock reporter bond BEFORE any validator-side mutation.
287 // Failure surfaces as `BondLockFailed` with validator state still
288 // untouched — ordering invariant tested by
289 // `test_dsl_023_lock_failure_no_mutation`. Reuses the hash
290 // computed for the DSL-026 dedup check above.
291 let evidence_hash = evidence_hash_pre;
292 bond_escrow
293 .lock(
294 evidence.reporter_validator_index,
295 REPORTER_BOND_MOJOS,
296 BondTag::Reporter(evidence_hash),
297 )
298 .map_err(|_| SlashingError::BondLockFailed)?;
299
300 let base_bps = u64::from(verified.offense_type.base_penalty_bps());
301 let mut per_validator: Vec<PerValidatorSlash> = Vec::new();
302
303 for &idx in &verified.slashable_validator_indices {
304 // Snapshot effective balance BEFORE any mutation — the
305 // formula must run on the balance at admission time, not
306 // after slash_absolute has debited it.
307 let eff_bal = effective_balances.get(idx);
308
309 // Skip already-slashed (DSL-162) AND indices that drifted
310 // out of the view between verify + submit. Both are silent
311 // skips: per-validator record omitted.
312 let should_skip = match validator_set.get(idx) {
313 Some(entry) => entry.is_slashed(),
314 None => true,
315 };
316 if should_skip {
317 continue;
318 }
319
320 // base_slash = max(eff_bal * base_bps / 10_000, eff_bal / 32).
321 // Order-of-operations: multiply before divide to keep
322 // precision; `eff_bal * base_bps` fits in u64 for any
323 // realistic effective balance (max eff_bal is ~32e9
324 // mojos; times 500 is 1.6e13, far below u64::MAX).
325 let bps_term = eff_bal.saturating_mul(base_bps) / BPS_DENOMINATOR;
326 let floor_term = eff_bal / MIN_SLASHING_PENALTY_QUOTIENT;
327 let base_slash = bps_term.max(floor_term);
328
329 // Debit. `slash_absolute` is saturating (DSL-131), so this
330 // never drives balance negative.
331 validator_set
332 .get_mut(idx)
333 .expect("checked Some above")
334 .slash_absolute(base_slash, self.current_epoch);
335
336 per_validator.push(PerValidatorSlash {
337 validator_index: idx,
338 base_slash_amount: base_slash,
339 effective_balance_at_slash: eff_bal,
340 // DSL-065: collateral wiring lands in a later
341 // orchestration DSL; stake-only path records 0.
342 collateral_slashed: 0,
343 });
344
345 // DSL-030: record the slash in the correlation-window
346 // register. `slashed_in_window` is consumed at
347 // finalisation to compute `cohort_sum`.
348 self.slashed_in_window
349 .insert((self.current_epoch, idx), eff_bal);
350 }
351
352 // DSL-025: reward routing. Two optimistic payouts settled
353 // BEFORE the pending-book insert so the returned
354 // `SlashingResult` carries the paid amounts atomically with
355 // the admission. Rewards are clawed back on sustained appeal
356 // (DSL-067) via a separate `RewardClawback` path — the pay
357 // calls here are idempotent credits, not debits.
358 let total_eff_bal: u64 = per_validator
359 .iter()
360 .map(|p| p.effective_balance_at_slash)
361 .sum();
362 let total_base: u64 = per_validator.iter().map(|p| p.base_slash_amount).sum();
363 let wb_reward = total_eff_bal / WHISTLEBLOWER_REWARD_QUOTIENT;
364 let prop_reward = wb_reward / PROPOSER_REWARD_QUOTIENT;
365 let burn_amount = total_base.saturating_sub(wb_reward + prop_reward);
366
367 // Whistleblower payout — always emits the call (even on zero
368 // reward) so auditors see a deterministic two-call pattern.
369 reward_payout.pay(evidence.reporter_puzzle_hash, wb_reward);
370
371 // Proposer inclusion payout. `proposer_at_slot(current_slot)`
372 // returning `None` is a consensus-layer bug — surface as
373 // `ProposerUnavailable` rather than silently skipping.
374 let current_slot = proposer.current_slot();
375 let proposer_idx = proposer
376 .proposer_at_slot(current_slot)
377 .ok_or(SlashingError::ProposerUnavailable)?;
378 let proposer_ph = validator_set
379 .get(proposer_idx)
380 .ok_or(SlashingError::ValidatorNotRegistered(proposer_idx))?
381 .puzzle_hash();
382 reward_payout.pay(proposer_ph, prop_reward);
383
384 // DSL-024: insert the PendingSlash record + register the hash
385 // in processed. Ordering: book insert first so a capacity
386 // failure bubbles up WITHOUT polluting processed.
387 let record = PendingSlash {
388 evidence_hash,
389 evidence: evidence.clone(),
390 verified: verified.clone(),
391 status: PendingSlashStatus::Accepted,
392 submitted_at_epoch: self.current_epoch,
393 window_expires_at_epoch: self.current_epoch + SLASH_APPEAL_WINDOW_EPOCHS,
394 base_slash_per_validator: per_validator.clone(),
395 reporter_bond_mojos: REPORTER_BOND_MOJOS,
396 appeal_history: Vec::new(),
397 };
398 self.book.insert(record)?;
399 self.processed.insert(evidence_hash, self.current_epoch);
400
401 Ok(SlashingResult {
402 per_validator,
403 whistleblower_reward: wb_reward,
404 proposer_reward: prop_reward,
405 burn_amount,
406 reporter_bond_escrowed: REPORTER_BOND_MOJOS,
407 pending_slash_hash: evidence_hash,
408 })
409 }
410
411 /// Transition every expired pending slash from
412 /// `Accepted`/`ChallengeOpen` to `Finalised { finalised_at_epoch:
413 /// self.current_epoch }` and emit one `FinalisationResult` per
414 /// transition.
415 ///
416 /// Implements [DSL-029](../../docs/requirements/domains/lifecycle/specs/DSL-029.md).
417 /// Traces to SPEC §7.4 steps 1, 6–7.
418 ///
419 /// # Scope (incremental)
420 ///
421 /// This method currently covers the status transition + result
422 /// emission only. Side effects land in subsequent DSLs:
423 ///
424 /// - DSL-030 populates `per_validator_correlation_penalty`.
425 /// - DSL-031 populates `reporter_bond_returned` via
426 /// `bond_escrow.release`.
427 /// - DSL-032 populates `exit_lock_until_epoch` via
428 /// `validator_set.schedule_exit`.
429 ///
430 /// # Behaviour
431 ///
432 /// - Iterates `book.expired_by(self.current_epoch)` in ascending
433 /// window-expiry order (stable across calls).
434 /// - Skips pendings already in `Reverted { .. }` or `Finalised { .. }`
435 /// (DSL-033).
436 /// - Idempotent: calling twice in the same epoch yields an empty
437 /// second result vec.
438 pub fn finalise_expired_slashes(
439 &mut self,
440 validator_set: &mut dyn ValidatorView,
441 effective_balances: &dyn EffectiveBalanceView,
442 bond_escrow: &mut dyn BondEscrow,
443 total_active_balance: u64,
444 ) -> Vec<FinalisationResult> {
445 let expired = self.book.expired_by(self.current_epoch);
446
447 // DSL-030: compute `cohort_sum` ONCE per finalise pass.
448 // `slashed_in_window` is keyed by `(slash_epoch, idx)`; we
449 // sum every eff_bal_at_slash value for epochs in
450 // `[current - CORRELATION_WINDOW_EPOCHS, current]`. Saturating
451 // subtraction keeps the window lower-bound non-negative at
452 // network boot.
453 let window_lo = self
454 .current_epoch
455 .saturating_sub(u64::from(dig_epoch::CORRELATION_WINDOW_EPOCHS));
456 let cohort_sum: u64 = self
457 .slashed_in_window
458 .range((window_lo, 0)..=(self.current_epoch, u32::MAX))
459 .map(|(_, eff)| *eff)
460 .sum();
461
462 let mut results = Vec::with_capacity(expired.len());
463 for hash in expired {
464 // DSL-033: skip terminal statuses. Snapshot status via a
465 // short borrow so we can mutate other fields afterward.
466 let status_is_terminal = matches!(
467 self.book.get(&hash).map(|p| p.status),
468 Some(PendingSlashStatus::Reverted { .. } | PendingSlashStatus::Finalised { .. }),
469 );
470 if status_is_terminal {
471 continue;
472 }
473
474 // Snapshot per-validator entries (clone the small vec)
475 // before the mutable borrow for the validator-set slash
476 // calls. Keeps the borrow graph simple.
477 let (slashable_indices, evidence_hash, reporter_idx) = {
478 let pending = match self.book.get(&hash) {
479 Some(p) => p,
480 None => continue,
481 };
482 (
483 pending
484 .base_slash_per_validator
485 .iter()
486 .map(|p| p.validator_index)
487 .collect::<Vec<u32>>(),
488 pending.evidence_hash,
489 pending.evidence.reporter_validator_index,
490 )
491 };
492
493 // DSL-030: per-validator correlation penalty. Formula:
494 // penalty = eff_bal * min(cohort_sum * 3, total) / total
495 // with total_active_balance==0 yielding 0 (defensive).
496 //
497 // DSL-032: schedule the exit lock alongside the penalty.
498 // `exit_lock_until_epoch = current + SLASH_LOCK_EPOCHS`.
499 let exit_lock_until_epoch = self.current_epoch + SLASH_LOCK_EPOCHS;
500 let mut correlation = Vec::with_capacity(slashable_indices.len());
501 let scaled = cohort_sum.saturating_mul(PROPORTIONAL_SLASHING_MULTIPLIER);
502 let capped = scaled.min(total_active_balance);
503 for idx in slashable_indices {
504 let eff_bal = effective_balances.get(idx);
505 // u128 intermediate prevents overflow on the multiply:
506 // `eff_bal * capped` can exceed u64 when both are near
507 // MIN_EFFECTIVE_BALANCE (e.g. 32e9 * 32e9 = 1.02e21 >
508 // u64::MAX). The final divide shrinks back to u64 range
509 // because `penalty <= eff_bal` (saturation property).
510 let penalty = if total_active_balance == 0 {
511 0
512 } else {
513 let product = u128::from(eff_bal) * u128::from(capped);
514 (product / u128::from(total_active_balance)) as u64
515 };
516 if let Some(entry) = validator_set.get_mut(idx) {
517 entry.slash_absolute(penalty, self.current_epoch);
518 // DSL-032: exit lock scheduled inside same entry
519 // access — one &mut borrow per validator.
520 entry.schedule_exit(exit_lock_until_epoch);
521 }
522 correlation.push((idx, penalty));
523 }
524
525 // DSL-031: release reporter bond in full. Bond was locked
526 // at admission (DSL-023); release is infallible by
527 // construction — any escrow error is a book-keeping bug
528 // that shouldn't block epoch advancement, so log-and-continue
529 // behaviour is acceptable (tests supply accepting escrows).
530 let _ = bond_escrow.release(
531 reporter_idx,
532 REPORTER_BOND_MOJOS,
533 BondTag::Reporter(evidence_hash),
534 );
535
536 // Now flip the pending status. Second borrow on book.
537 if let Some(pending) = self.book.get_mut(&hash) {
538 pending.status = PendingSlashStatus::Finalised {
539 finalised_at_epoch: self.current_epoch,
540 };
541 }
542
543 results.push(FinalisationResult {
544 evidence_hash,
545 per_validator_correlation_penalty: correlation,
546 reporter_bond_returned: REPORTER_BOND_MOJOS,
547 exit_lock_until_epoch,
548 });
549 }
550 results
551 }
552
553 /// Submit an appeal against an existing pending slash.
554 ///
555 /// Implements [DSL-055](../../docs/requirements/domains/appeal/specs/DSL-055.md).
556 /// Traces to SPEC §6.1, §7.2.
557 ///
558 /// # Scope (incremental)
559 ///
560 /// First-cut pipeline stops at the UnknownEvidence precondition:
561 /// if `appeal.evidence_hash` is not present in the pending-slash
562 /// book the method returns `SlashingError::UnknownEvidence(hex)`
563 /// WITHOUT touching the bond escrow. Later DSLs extend the
564 /// pipeline:
565 /// - DSL-056: `WindowExpired`
566 /// - DSL-057: `VariantMismatch`
567 /// - DSL-058: `DuplicateAppeal`
568 /// - DSL-059: `TooManyAttempts`
569 /// - DSL-060/061: `SlashAlreadyReverted` / `SlashAlreadyFinalised`
570 /// - DSL-062: appellant-bond lock (FIRST bond-touching step)
571 /// - DSL-063: `PayloadTooLarge`
572 /// - DSL-064+: dispatch to per-ground verifiers + adjudicate
573 ///
574 /// # Error ordering invariant
575 ///
576 /// `UnknownEvidence` MUST be checked BEFORE any bond operation
577 /// so a caller with a stale / misrouted appeal does not pay
578 /// gas to lock collateral that would immediately need to be
579 /// returned. Preserved by running the book lookup as the first
580 /// statement — see the DSL-055 test suite's
581 /// `test_dsl_055_bond_not_locked` guard.
582 pub fn submit_appeal(
583 &mut self,
584 appeal: &crate::appeal::SlashAppeal,
585 bond_escrow: &mut dyn BondEscrow,
586 ) -> Result<(), SlashingError> {
587 // DSL-055: UnknownEvidence — must run BEFORE any bond
588 // operation or further state inspection.
589 let pending = self.book.get(&appeal.evidence_hash).ok_or_else(|| {
590 SlashingError::UnknownEvidence(hex_encode(appeal.evidence_hash.as_ref()))
591 })?;
592
593 // DSL-060 / DSL-061: terminal-state guards. Reverted and
594 // Finalised pending slashes are non-actionable — no
595 // further appeals are accepted. Checked BEFORE the
596 // window/variant/duplicate logic because terminal state
597 // trumps all other dispositions.
598 match pending.status {
599 PendingSlashStatus::Reverted { .. } => {
600 return Err(SlashingError::SlashAlreadyReverted);
601 }
602 PendingSlashStatus::Finalised { .. } => {
603 return Err(SlashingError::SlashAlreadyFinalised);
604 }
605 PendingSlashStatus::Accepted | PendingSlashStatus::ChallengeOpen { .. } => {}
606 }
607
608 // DSL-056: WindowExpired — reject when the appeal was
609 // filed strictly AFTER the window-close boundary. The
610 // boundary epoch itself (`filed_epoch ==
611 // window_expires_at_epoch`) is still a valid filing; only
612 // `filed_epoch > expires_at` trips. Bond lock happens in
613 // DSL-062 so this check still precedes any collateral
614 // touch.
615 if appeal.filed_epoch > pending.window_expires_at_epoch {
616 return Err(SlashingError::AppealWindowExpired {
617 submitted_at: pending.submitted_at_epoch,
618 window: SLASH_APPEAL_WINDOW_EPOCHS,
619 current: appeal.filed_epoch,
620 });
621 }
622
623 // DSL-057: VariantMismatch — the appeal payload variant
624 // MUST match the evidence payload variant. Structural
625 // check; no state inspection beyond the two enum tags.
626 use crate::appeal::SlashAppealPayload;
627 let variants_match = matches!(
628 (&appeal.payload, &pending.evidence.payload),
629 (
630 SlashAppealPayload::Proposer(_),
631 SlashingEvidencePayload::Proposer(_)
632 ) | (
633 SlashAppealPayload::Attester(_),
634 SlashingEvidencePayload::Attester(_)
635 ) | (
636 SlashAppealPayload::InvalidBlock(_),
637 SlashingEvidencePayload::InvalidBlock(_)
638 )
639 );
640 if !variants_match {
641 return Err(SlashingError::AppealVariantMismatch);
642 }
643
644 // DSL-058: DuplicateAppeal — the appeal's content-addressed
645 // hash (DOMAIN_SLASH_APPEAL || bincode(appeal)) MUST NOT
646 // already appear in `pending.appeal_history`. Near-dupes
647 // (different witness bytes, different ground, etc.)
648 // produce distinct hashes and are accepted.
649 let appeal_hash = appeal.hash();
650 if pending
651 .appeal_history
652 .iter()
653 .any(|a| a.appeal_hash == appeal_hash)
654 {
655 return Err(SlashingError::DuplicateAppeal);
656 }
657
658 // DSL-059: TooManyAttempts — cap adjudication cost at
659 // `MAX_APPEAL_ATTEMPTS_PER_SLASH` (4). Only REJECTED
660 // attempts accumulate here — a sustained appeal drains
661 // the book entry (DSL-070) so this counter never sees
662 // more than the cap in practice.
663 if pending.appeal_history.len() >= MAX_APPEAL_ATTEMPTS_PER_SLASH {
664 return Err(SlashingError::TooManyAttempts {
665 count: pending.appeal_history.len(),
666 limit: MAX_APPEAL_ATTEMPTS_PER_SLASH,
667 });
668 }
669
670 // DSL-063: PayloadTooLarge — cap bincode-serialized
671 // envelope size. Runs BEFORE the bond lock so oversized
672 // appeals never touch collateral. bincode chosen for
673 // parity with `SlashAppeal::hash` (same canonical form).
674 let encoded = bincode::serialize(appeal).expect("SlashAppeal bincode must not fail");
675 if encoded.len() > MAX_APPEAL_PAYLOAD_BYTES {
676 return Err(SlashingError::AppealPayloadTooLarge {
677 actual: encoded.len(),
678 limit: MAX_APPEAL_PAYLOAD_BYTES,
679 });
680 }
681
682 // DSL-062: appellant-bond lock. LAST admission step so
683 // every structural rejection (DSL-055..061, DSL-063)
684 // short-circuits before any collateral is touched. Tag
685 // MUST be `BondTag::Appellant(appeal.hash())` so DSL-068
686 // + DSL-071 can release / forfeit the correct slot.
687 bond_escrow
688 .lock(
689 appeal.appellant_index,
690 APPELLANT_BOND_MOJOS,
691 BondTag::Appellant(appeal_hash),
692 )
693 .map_err(|e| SlashingError::AppellantBondLockFailed(e.to_string()))?;
694
695 // Subsequent DSLs add: dispatch + adjudicate (DSL-064+).
696 Ok(())
697 }
698
699 /// Advance the manager's epoch. Consumers at the consensus layer
700 /// call this at every epoch boundary AFTER running
701 /// `finalise_expired_slashes` — keeps the current epoch in lock
702 /// step with the chain. Test helper.
703 pub fn set_epoch(&mut self, epoch: u64) {
704 self.current_epoch = epoch;
705 }
706
707 /// Record a processed-evidence entry for persistence load
708 /// or test fixtures.
709 ///
710 /// `submit_evidence` does this implicitly on admission;
711 /// this method is the public surface for replaying a
712 /// persisted book or constructing a unit-test fixture
713 /// without going through the full verify + bond-lock
714 /// pipeline.
715 pub fn mark_processed(&mut self, hash: Bytes32, epoch: u64) {
716 self.processed.insert(hash, epoch);
717 }
718
719 /// Record a `(epoch, validator_index) → effective_balance`
720 /// entry in the slashed-in-window cohort map. Companion to
721 /// `mark_processed`; used by persistence load + tests.
722 pub fn mark_slashed_in_window(&mut self, epoch: u64, idx: u32, effective_balance: u64) {
723 self.slashed_in_window
724 .insert((epoch, idx), effective_balance);
725 }
726
727 /// Lookup for `slashed_in_window` — test helper so integration
728 /// tests can verify DSL-129 rewind actually cleared an entry.
729 #[must_use]
730 pub fn is_slashed_in_window(&self, epoch: u64, idx: u32) -> bool {
731 self.slashed_in_window.contains_key(&(epoch, idx))
732 }
733
734 /// Read-side lookup for a pending slash by `evidence_hash`.
735 ///
736 /// Implements [DSL-150](../docs/requirements/domains/lifecycle/specs/DSL-150.md).
737 /// Convenience wrapper over `self.book().get(hash)` so callers
738 /// don't need to chain through `book()`. Returns `None` when
739 /// the slash has been removed via `book.remove` or was never
740 /// admitted.
741 #[must_use]
742 pub fn pending(&self, hash: &Bytes32) -> Option<&PendingSlash> {
743 self.book.get(hash)
744 }
745
746 /// Prune processed + slashed_in_window entries older than
747 /// `before_epoch`. Convenience alias for
748 /// [`prune_processed_older_than`](Self::prune_processed_older_than)
749 /// per DSL-150 naming.
750 ///
751 /// Does NOT touch `book` — pending slashes are removed via
752 /// `book.remove` or `finalise_expired_slashes` which own the
753 /// status-transition lifecycle.
754 ///
755 /// Typical caller: DSL-127 run_epoch_boundary with
756 /// `before_epoch = current.saturating_sub(CORRELATION_WINDOW_EPOCHS)`.
757 pub fn prune(&mut self, before_epoch: u64) -> usize {
758 self.prune_processed_older_than(before_epoch)
759 }
760
761 /// Delegate to `ValidatorView::get(idx)?.is_slashed()`.
762 ///
763 /// Implements [DSL-149](../docs/requirements/domains/lifecycle/specs/DSL-149.md).
764 /// Returns `false` for unknown indices (no panic) — matches
765 /// DSL-136 `ValidatorView::get` out-of-range semantics.
766 /// Read-only: does not mutate `self` or the validator set.
767 #[must_use]
768 pub fn is_slashed(&self, idx: u32, validator_set: &dyn ValidatorView) -> bool {
769 validator_set
770 .get(idx)
771 .map(|entry| entry.is_slashed())
772 .unwrap_or(false)
773 }
774
775 /// Rewind every pending slash whose `submitted_at_epoch` is
776 /// STRICTLY greater than `new_tip_epoch` — the canonical
777 /// fork-choice reorg response.
778 ///
779 /// Implements [DSL-129](../docs/requirements/domains/orchestration/specs/DSL-129.md).
780 /// Traces to SPEC §13.
781 ///
782 /// # Side effects per rewound entry
783 ///
784 /// - `ValidatorEntry::credit_stake(base_slash_amount)` on
785 /// each slashable validator.
786 /// - `ValidatorEntry::restore_status()` on each.
787 /// - `CollateralSlasher::credit(validator_index,
788 /// collateral_slashed)` on each (when `collateral`
789 /// present).
790 /// - `BondEscrow::release` of the reporter bond at
791 /// `BondTag::Reporter(evidence_hash)` — NOT `forfeit`.
792 /// Reorg is not the reporter's fault; the bond returns
793 /// intact.
794 /// - Entry removed from `self.book`, `self.processed`,
795 /// and `self.slashed_in_window`.
796 ///
797 /// # What it does NOT do
798 ///
799 /// - NO reporter penalty. DSL-069 applies a reporter
800 /// penalty on SUSTAINED-APPEAL revert; a reorg is a
801 /// consensus-layer signal that the original evidence was
802 /// never canonical, so the reporter is not at fault.
803 /// - NO appeal-history inspection. Any filed appeals on the
804 /// rewound slash are discarded along with the slash
805 /// itself.
806 ///
807 /// # Returns
808 ///
809 /// List of `evidence_hash` values that were rewound. Empty
810 /// when no pending slashes are past the new tip.
811 pub fn rewind_on_reorg(
812 &mut self,
813 new_tip_epoch: u64,
814 validator_set: &mut dyn ValidatorView,
815 mut collateral: Option<&mut dyn CollateralSlasher>,
816 bond_escrow: &mut dyn BondEscrow,
817 ) -> Vec<Bytes32> {
818 let to_rewind = self.book.submitted_after(new_tip_epoch);
819
820 let mut rewound = Vec::with_capacity(to_rewind.len());
821 for hash in to_rewind {
822 let Some(pending) = self.book.remove(&hash) else {
823 continue;
824 };
825
826 // Credit stake + restore status + collateral per slashable
827 // validator. Snapshot epochs BEFORE the mutable borrows.
828 for per in &pending.base_slash_per_validator {
829 if let Some(entry) = validator_set.get_mut(per.validator_index) {
830 entry.credit_stake(per.base_slash_amount);
831 entry.restore_status();
832 }
833 if let Some(coll) = collateral.as_deref_mut() {
834 coll.credit(per.validator_index, per.collateral_slashed);
835 }
836 // slashed_in_window row keyed by (epoch, idx) —
837 // remove so a later finalise pass does not
838 // double-count this validator in a cohort-sum.
839 self.slashed_in_window
840 .remove(&(pending.submitted_at_epoch, per.validator_index));
841 }
842
843 // Release reporter bond — NOT forfeit. A forfeit here
844 // would punish the reporter for a consensus event they
845 // did not cause. Ignore release errors (TagNotFound)
846 // to keep rewind infallible under partial escrow state
847 // (defensive; should not happen if submit_evidence ran
848 // the lock).
849 let _ = bond_escrow.release(
850 pending.evidence.reporter_validator_index,
851 pending.reporter_bond_mojos,
852 BondTag::Reporter(hash),
853 );
854
855 // Clear dedup map so a re-submission on the new
856 // canonical chain admits cleanly.
857 self.processed.remove(&hash);
858
859 rewound.push(hash);
860 }
861
862 rewound
863 }
864
865 /// Prune processed-evidence map entries whose recorded epoch is
866 /// strictly less than `cutoff_epoch`. Called by the DSL-127
867 /// `run_epoch_boundary` step 8 (last step) to bound memory of
868 /// the AlreadySlashed dedup window.
869 ///
870 /// Returns the number of entries removed. Also drops any
871 /// `slashed_in_window` rows whose epoch is older than the
872 /// cutoff — the cohort-sum window (DSL-030) is
873 /// `CORRELATION_WINDOW_EPOCHS` wide so entries older than
874 /// `current_epoch - CORRELATION_WINDOW_EPOCHS` can never
875 /// contribute to a future finalisation.
876 pub fn prune_processed_older_than(&mut self, cutoff_epoch: u64) -> usize {
877 let before = self.processed.len();
878 self.processed.retain(|_, epoch| *epoch >= cutoff_epoch);
879 let removed_processed = before - self.processed.len();
880
881 // Range-remove over the BTreeMap keyed by (epoch, idx).
882 // Collect the keys first to avoid borrow issues while
883 // mutating.
884 let stale_keys: Vec<(u64, u32)> = self
885 .slashed_in_window
886 .range(..(cutoff_epoch, 0))
887 .map(|(k, _)| *k)
888 .collect();
889 for k in stale_keys {
890 self.slashed_in_window.remove(&k);
891 }
892
893 removed_processed
894 }
895}
896
897/// Fixed-size lowercase hex encoder for diagnostic log strings.
898///
899/// Stays inline to avoid pulling a `hex` crate just for error
900/// messages. DSL-055 uses this to stamp `UnknownEvidence(hex)`
901/// with the 64-char hex representation of the missing evidence
902/// hash.
903fn hex_encode(bytes: &[u8]) -> String {
904 const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
905 let mut out = String::with_capacity(bytes.len() * 2);
906 for &b in bytes {
907 out.push(HEX_CHARS[(b >> 4) as usize] as char);
908 out.push(HEX_CHARS[(b & 0x0F) as usize] as char);
909 }
910 out
911}