Skip to main content

dig_slashing/
pending.rs

1//! Pending-slash book + lifecycle-state types.
2//!
3//! Traces to: [SPEC.md §3.8 + §7.1](../docs/resources/SPEC.md),
4//! catalogue rows
5//! [DSL-024](../docs/requirements/domains/lifecycle/specs/DSL-024.md),
6//! [DSL-146](../docs/requirements/domains/lifecycle/specs/DSL-146.md),
7//! [DSL-147](../docs/requirements/domains/lifecycle/specs/DSL-147.md),
8//! [DSL-161](../docs/requirements/domains/).
9//!
10//! # Role
11//!
12//! Optimistic slashing is reversible during the 8-epoch appeal window
13//! (`SLASH_APPEAL_WINDOW_EPOCHS`). The manager holds one
14//! `PendingSlash` per admitted evidence until it transitions to
15//! `Finalised` (DSL-029) or `Reverted` (DSL-070). The
16//! [`PendingSlashBook`] provides the keyed storage + a secondary
17//! by-window-expiry index for efficient `expired_by` scans at
18//! finalisation.
19
20use std::collections::{BTreeMap, HashMap};
21
22use dig_protocol::Bytes32;
23use serde::{Deserialize, Serialize};
24
25use crate::error::SlashingError;
26use crate::evidence::envelope::SlashingEvidence;
27use crate::evidence::verify::VerifiedEvidence;
28use crate::manager::PerValidatorSlash;
29
30/// Lifecycle status of an admitted slash.
31///
32/// Traces to [SPEC §3.8](../../docs/resources/SPEC.md). State machine:
33///
34/// ```text
35///   Accepted ──(first appeal)──►  ChallengeOpen
36///      │                              │
37///      ├──(window expires, no sustained appeal)──► Finalised
38///      │                              │
39///      └────────(sustained appeal)────► Reverted
40/// ```
41///
42/// `Accepted` is the starting state on admission (DSL-024).
43/// `ChallengeOpen` tracks appeal attempts (DSL-072). `Finalised`
44/// locks the slash in (DSL-029..032). `Reverted` undoes it
45/// (DSL-064..067).
46#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
47pub enum PendingSlashStatus {
48    /// No appeals filed yet. Transitions to `ChallengeOpen` on first
49    /// appeal, `Finalised` on window expiry.
50    Accepted,
51    /// At least one appeal has been filed; the window may still be
52    /// open and further appeals may arrive up to
53    /// `MAX_APPEAL_ATTEMPTS_PER_SLASH`.
54    ChallengeOpen {
55        /// Epoch the FIRST appeal was filed.
56        first_appeal_filed_epoch: u64,
57        /// Number of appeals filed so far.
58        appeal_count: u8,
59    },
60    /// Sustained appeal — slash was rolled back via `credit_stake`
61    /// (DSL-064). Terminal.
62    Reverted {
63        /// Hash of the winning appeal.
64        winning_appeal_hash: Bytes32,
65        /// Epoch the reversal was applied.
66        reverted_at_epoch: u64,
67    },
68    /// No sustained appeal within the window. Correlation penalty
69    /// applied, exit lock scheduled. Terminal.
70    Finalised {
71        /// Epoch the finalisation ran.
72        finalised_at_epoch: u64,
73    },
74}
75
76/// Adjudication outcome for an individual appeal attempt.
77///
78/// Traces to [SPEC §3.8](../../docs/resources/SPEC.md).
79#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
80pub enum AppealOutcome {
81    /// Appeal sustained → slash reverted. DSL-064..070.
82    Won,
83    /// Appeal rejected → slash persists. `reason_hash` summarises the
84    /// adjudicator's decision for downstream analytics / audit.
85    Lost {
86        /// Hash of the adjudication reason bytes.
87        reason_hash: Bytes32,
88    },
89    /// Appeal filed but adjudication not yet run (manager sees it in
90    /// this state only within a single run_epoch_boundary transaction).
91    Pending,
92}
93
94/// One appeal attempt attached to a `PendingSlash`.
95///
96/// Traces to [SPEC §3.8](../../docs/resources/SPEC.md).
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98pub struct AppealAttempt {
99    /// Hash of the appeal envelope (SPEC §3.7).
100    pub appeal_hash: Bytes32,
101    /// Validator index of the appellant.
102    pub appellant_index: u32,
103    /// Epoch the appeal was filed.
104    pub filed_epoch: u64,
105    /// Adjudication outcome.
106    pub outcome: AppealOutcome,
107    /// Appellant bond mojos locked for this attempt.
108    pub bond_mojos: u64,
109}
110
111/// A slash record held in the pending book during its appeal window.
112///
113/// Traces to [SPEC §3.8](../../docs/resources/SPEC.md). One
114/// `PendingSlash` per admitted evidence hash; key uniquity enforced
115/// by the manager's `processed` map (DSL-026).
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
117pub struct PendingSlash {
118    /// Content-addressed identity of the evidence envelope —
119    /// matches `evidence.hash()`. Serves as the book's primary key.
120    pub evidence_hash: Bytes32,
121    /// Full evidence envelope. Retained so appeal adjudication
122    /// (DSL-064..067) has the raw bytes without a separate store.
123    pub evidence: SlashingEvidence,
124    /// Verifier output captured at admission. Replaying
125    /// `verify_evidence` is unnecessary.
126    pub verified: VerifiedEvidence,
127    /// Current lifecycle state.
128    pub status: PendingSlashStatus,
129    /// Epoch `submit_evidence` admitted the record — `self.current_epoch`
130    /// at insertion.
131    pub submitted_at_epoch: u64,
132    /// Epoch after which the slash finalises if no sustained appeal
133    /// arrives. Equals `submitted_at_epoch + SLASH_APPEAL_WINDOW_EPOCHS`
134    /// (DSL-024).
135    pub window_expires_at_epoch: u64,
136    /// Per-validator debits applied at admission (DSL-022). Each
137    /// entry is reversible on a sustained appeal (DSL-064 credits
138    /// `base_slash_amount` back).
139    pub base_slash_per_validator: Vec<PerValidatorSlash>,
140    /// Reporter bond escrowed at admission (DSL-023). Returned on
141    /// finalisation (DSL-031) or forfeited on sustained appeal (DSL-068).
142    pub reporter_bond_mojos: u64,
143    /// Appeals filed so far. Empty on admission.
144    pub appeal_history: Vec<AppealAttempt>,
145}
146
147/// Keyed book of admitted pending slashes.
148///
149/// Traces to [SPEC §7.1](../../docs/resources/SPEC.md). Two-layer
150/// index:
151///
152///   - `pending: HashMap<Bytes32, PendingSlash>` — primary keyed by
153///     evidence hash.
154///   - `by_window_expiry: BTreeMap<u64, Vec<Bytes32>>` — secondary,
155///     drives the `expired_by(epoch)` scan at finalisation (DSL-029).
156///
157/// Capacity-bounded at `MAX_PENDING_SLASHES` (4_096); insert at
158/// capacity returns `SlashingError::PendingBookFull` (DSL-027).
159#[derive(Debug, Clone, Default)]
160pub struct PendingSlashBook {
161    pending: HashMap<Bytes32, PendingSlash>,
162    by_window_expiry: BTreeMap<u64, Vec<Bytes32>>,
163    capacity: usize,
164}
165
166impl PendingSlashBook {
167    /// New book with the given capacity.
168    ///
169    /// Traces to [DSL-146](../../docs/requirements/domains/lifecycle/specs/DSL-146.md).
170    /// Capacity of `0` is legal and produces a book that rejects
171    /// every insert — useful for property tests of the full-book
172    /// rejection branch.
173    #[must_use]
174    pub fn new(capacity: usize) -> Self {
175        Self {
176            pending: HashMap::with_capacity(capacity.min(1_024)),
177            by_window_expiry: BTreeMap::new(),
178            capacity,
179        }
180    }
181
182    /// Insert a new pending slash.
183    ///
184    /// Returns `Err(PendingBookFull)` at capacity (DSL-027). Does NOT
185    /// check for duplicate keys — caller (the manager) must consult
186    /// `processed` first (DSL-026).
187    pub fn insert(&mut self, record: PendingSlash) -> Result<(), SlashingError> {
188        if self.pending.len() >= self.capacity {
189            return Err(SlashingError::PendingBookFull);
190        }
191        let hash = record.evidence_hash;
192        let expiry = record.window_expires_at_epoch;
193        self.pending.insert(hash, record);
194        self.by_window_expiry.entry(expiry).or_default().push(hash);
195        Ok(())
196    }
197
198    /// Immutable lookup by evidence hash.
199    #[must_use]
200    pub fn get(&self, hash: &Bytes32) -> Option<&PendingSlash> {
201        self.pending.get(hash)
202    }
203
204    /// Mutable lookup — used by DSL-034..073 appeal code to update
205    /// `status` + `appeal_history` in place.
206    pub fn get_mut(&mut self, hash: &Bytes32) -> Option<&mut PendingSlash> {
207        self.pending.get_mut(hash)
208    }
209
210    /// Remove by evidence hash. Returns the record and cleans up the
211    /// secondary index.
212    pub fn remove(&mut self, hash: &Bytes32) -> Option<PendingSlash> {
213        let record = self.pending.remove(hash)?;
214        if let Some(vec) = self
215            .by_window_expiry
216            .get_mut(&record.window_expires_at_epoch)
217        {
218            vec.retain(|h| h != hash);
219            if vec.is_empty() {
220                self.by_window_expiry
221                    .remove(&record.window_expires_at_epoch);
222            }
223        }
224        Some(record)
225    }
226
227    /// Hashes of every pending slash whose `submitted_at_epoch` is
228    /// STRICTLY greater than `new_tip_epoch`. Used by DSL-129
229    /// `SlashingManager::rewind_on_reorg` to enumerate the slashes
230    /// that need to be rewound when the fork-choice tip moves
231    /// backwards.
232    ///
233    /// Returns a `Vec<Bytes32>` rather than an iterator because
234    /// the caller (`rewind_on_reorg`) then mutates `self.remove`
235    /// on each entry, which would conflict with a live borrow.
236    #[must_use]
237    pub fn submitted_after(&self, new_tip_epoch: u64) -> Vec<Bytes32> {
238        self.pending
239            .values()
240            .filter(|p| p.submitted_at_epoch > new_tip_epoch)
241            .map(|p| p.evidence_hash)
242            .collect()
243    }
244
245    /// Current record count.
246    #[must_use]
247    pub fn len(&self) -> usize {
248        self.pending.len()
249    }
250
251    /// `true` iff no records are held.
252    #[must_use]
253    pub fn is_empty(&self) -> bool {
254        self.pending.is_empty()
255    }
256
257    /// Book capacity.
258    #[must_use]
259    pub fn capacity(&self) -> usize {
260        self.capacity
261    }
262
263    /// Evidence hashes with `window_expires_at_epoch < current_epoch`.
264    ///
265    /// Implements
266    /// [DSL-147](../../docs/requirements/domains/lifecycle/specs/DSL-147.md).
267    /// Drives the finalisation sweep at epoch boundary (DSL-029).
268    /// Order is ascending by window_expiry — earliest-expiring first.
269    #[must_use]
270    pub fn expired_by(&self, current_epoch: u64) -> Vec<Bytes32> {
271        // Range `..current_epoch` is strict less-than → boundary
272        // case (`window_expires == current`) is EXCLUDED per
273        // DSL-147. BTreeMap range is ascending-ordered by key,
274        // giving deterministic output without an explicit sort.
275        //
276        // Filter Accepted + ChallengeOpen only — Reverted /
277        // Finalised terminal statuses stay in the book for
278        // audit trails but must not be re-surfaced to
279        // finalise_expired_slashes (DSL-029).
280        self.by_window_expiry
281            .range(..current_epoch)
282            .flat_map(|(_, hashes)| hashes.iter())
283            .filter(|hash| {
284                matches!(
285                    self.pending.get(hash).map(|p| &p.status),
286                    Some(PendingSlashStatus::Accepted)
287                        | Some(PendingSlashStatus::ChallengeOpen { .. }),
288                )
289            })
290            .copied()
291            .collect()
292    }
293}