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}