dig_slashing/error.rs
1//! Error types for the slashing crate.
2//!
3//! Traces to: [SPEC.md §17.1](../docs/resources/SPEC.md) (SlashingError).
4//!
5//! # Design
6//!
7//! A single `SlashingError` enum covers every verifier and state-machine
8//! failure mode. Variants align 1:1 with the rows in SPEC §17.1 so
9//! downstream callers (and adjudicators) can pattern-match without
10//! stringly-typed discrimination.
11//!
12//! New variants land as their DSL-NNN requirements are implemented. Each
13//! variant's docstring points at the requirement that introduced it.
14
15use dig_protocol::Bytes32;
16use thiserror::Error;
17
18/// Every failure mode `dig-slashing`'s verifiers, manager, and adjudicator
19/// can return.
20///
21/// Per SPEC §17.1. Variants carry the minimum context needed to diagnose
22/// the failure without leaking internal state.
23#[derive(Debug, Clone, PartialEq, Eq, Error, serde::Serialize, serde::Deserialize)]
24pub enum SlashingError {
25 /// `IndexedAttestation` failed its cheap structural check
26 /// (DSL-005): empty indices, non-ascending/duplicate indices,
27 /// over-cap length, or wrong-width signature.
28 ///
29 /// Consumed by `verify_attester_slashing` (DSL-014/DSL-015) before
30 /// any BLS work. Reason string describes the specific violation.
31 #[error("invalid indexed attestation: {0}")]
32 InvalidIndexedAttestation(String),
33
34 /// Aggregate BLS verify returned `false` OR the signature bytes /
35 /// pubkey set could not be decoded at all.
36 ///
37 /// Raised by `IndexedAttestation::verify_signature` (DSL-006) and
38 /// by `verify_proposer_slashing` / `verify_invalid_block` (DSL-013 /
39 /// DSL-018). Intentionally coarse: the security model does not
40 /// distinguish "bad pubkey width", "missing validator index", or
41 /// "cryptographic mismatch" — all three are equally invalid
42 /// evidence and callers MUST reject the envelope uniformly.
43 #[error("BLS signature verification failed")]
44 BlsVerifyFailed,
45
46 /// `AttesterSlashing` payload failed a structural / BLS
47 /// precondition in DSL-014..016: byte-identical attestations,
48 /// structural violation bubbled up from DSL-005, or BLS verify
49 /// failure on one of the two aggregates.
50 ///
51 /// Reason string names the specific violation. Predicate-failure
52 /// paths use the dedicated [`SlashingError::AttesterSlashingNotSlashable`]
53 /// and [`SlashingError::EmptySlashableIntersection`] variants so
54 /// appeals (DSL-042, DSL-043) can distinguish without string
55 /// matching.
56 #[error("invalid attester slashing: {0}")]
57 InvalidAttesterSlashing(String),
58
59 /// Neither the double-vote (DSL-014) nor the surround-vote (DSL-015)
60 /// predicate holds for the two `AttestationData`s.
61 ///
62 /// Raised by DSL-017. Mirrored at the appeal layer by
63 /// `AttesterAppealGround::NotSlashableByPredicate` (DSL-042).
64 #[error("attestations do not prove a slashable offense")]
65 AttesterSlashingNotSlashable,
66
67 /// The intersection of `attestation_a.attesting_indices` and
68 /// `attestation_b.attesting_indices` is empty — no validator
69 /// participated in both, so there is nobody to slash.
70 ///
71 /// Raised by DSL-016 after the slashable-predicate check succeeds
72 /// but the intersection yields zero indices. Mirrored at the appeal
73 /// layer by `AttesterAppealGround::EmptyIntersection` (DSL-043).
74 #[error("attester slashing intersecting indices empty")]
75 EmptySlashableIntersection,
76
77 /// `InvalidBlockProof` payload failed one of the preconditions in
78 /// DSL-018..020: BLS verify failure over `block_signing_message`,
79 /// `header.epoch != evidence.epoch`, out-of-range
80 /// `failure_witness`, or the optional `InvalidBlockOracle`
81 /// rejected the re-execution.
82 ///
83 /// Reason string names the specific violation. Appeals
84 /// (DSL-049..054) distinguish the categories at their own layer.
85 #[error("invalid block evidence: {0}")]
86 InvalidSlashingEvidence(String),
87
88 /// `ProposerSlashing` payload failed one of the preconditions in
89 /// DSL-013: slot mismatch, proposer mismatch, identical headers,
90 /// bad signature bytes, inactive validator, or BLS verify failure
91 /// on one of the two signatures.
92 ///
93 /// Reason string names the specific violation for diagnostics
94 /// (appeals in DSL-034..040 distinguish the same categories by
95 /// structured variants; this coarse string is only the verifier's
96 /// rejection channel).
97 #[error("invalid proposer slashing: {0}")]
98 InvalidProposerSlashing(String),
99
100 /// A validator index named in the evidence is not registered in
101 /// the validator view.
102 ///
103 /// Raised by DSL-013 (accused proposer) and DSL-018 (invalid-block
104 /// proposer). Carries the offending index.
105 #[error("validator not registered: {0}")]
106 ValidatorNotRegistered(u32),
107
108 /// Duplicate `submit_evidence` for an `evidence.hash()` already in
109 /// the manager's `processed` map.
110 ///
111 /// Raised by DSL-026 as the FIRST pipeline check — before verify,
112 /// capacity check, bond lock, or any state mutation. Persists
113 /// across pending statuses (`Accepted`, `ChallengeOpen`,
114 /// `Reverted`, `Finalised`) until a reorg rewind (DSL-129) or
115 /// prune clears the entry.
116 #[error("evidence already slashed")]
117 AlreadySlashed,
118
119 /// `ProposerView::proposer_at_slot(current_slot)` returned `None`.
120 ///
121 /// Raised by DSL-025 reward routing. A `None` here is a
122 /// consensus-layer bug — the proposer at the current slot must
123 /// always exist at admission time. Surfaces as a hard error
124 /// rather than silently dropping the proposer reward.
125 #[error("proposer unavailable at current slot")]
126 ProposerUnavailable,
127
128 /// `PendingSlashBook` at capacity; new slashes cannot be admitted
129 /// until existing ones finalise or revert.
130 ///
131 /// Raised by DSL-027. `MAX_PENDING_SLASHES = 4_096` caps memory +
132 /// pruning cost. Admission attempt at capacity performs no bond
133 /// lock or validator mutation.
134 #[error("pending slash book full")]
135 PendingBookFull,
136
137 /// Reporter bond lock failed — principal lacks collateral or the
138 /// escrow rejected the tag.
139 ///
140 /// Raised by DSL-023 in `SlashingManager::submit_evidence` when
141 /// `BondEscrow::lock(reporter_idx, REPORTER_BOND_MOJOS, Reporter(hash))`
142 /// returns `Err(_)`. No state mutation occurs — the manager has
143 /// not yet touched `ValidatorEntry::slash_absolute`.
144 #[error("bond lock failed")]
145 BondLockFailed,
146
147 /// The evidence reporter named themselves among the slashable
148 /// validators (self-accuse).
149 ///
150 /// Raised by `verify_evidence` (DSL-012) when
151 /// `evidence.reporter_validator_index ∈ evidence.slashable_validators()`.
152 /// Blocks a validator from self-slashing to collect the
153 /// whistleblower reward (DSL-025 reward routing). Payload is the
154 /// offending validator index so the adjudicator can log without
155 /// re-deriving it.
156 #[error("reporter cannot accuse self (index {0})")]
157 ReporterIsAccused(u32),
158
159 /// Serialized `SlashAppeal` exceeds `MAX_APPEAL_PAYLOAD_BYTES`.
160 ///
161 /// Raised by DSL-063. Caps memory + DoS cost for invalid-block
162 /// witness storage. Runs BEFORE the DSL-062 bond lock so an
163 /// oversized appeal never reaches collateral.
164 #[error("appeal payload too large: actual={actual}, limit={limit}")]
165 AppealPayloadTooLarge {
166 /// Actual bincode-encoded length in bytes.
167 actual: usize,
168 /// `MAX_APPEAL_PAYLOAD_BYTES` at the time of check.
169 limit: usize,
170 },
171
172 /// Appellant-bond lock failed — principal lacks collateral or
173 /// the escrow rejected the tag.
174 ///
175 /// Raised by DSL-062 in `SlashingManager::submit_appeal` when
176 /// `BondEscrow::lock(appellant_idx, APPELLANT_BOND_MOJOS,
177 /// Appellant(appeal_hash))` returns `Err(_)`. Runs as the
178 /// LAST step of the admission pipeline so all structural
179 /// rejections (DSL-055..061, DSL-063) short-circuit first.
180 /// The carried string is the underlying `BondError` rendered
181 /// via `Display`.
182 #[error("appellant bond lock failed: {0}")]
183 AppellantBondLockFailed(String),
184
185 /// Pending slash is already in the `Reverted` terminal state —
186 /// no further appeals are accepted.
187 ///
188 /// Raised by DSL-060. A sustained appeal (DSL-064..070)
189 /// transitions the book entry to `Reverted{..}`. Additional
190 /// appeals against a reverted slash would have nothing to
191 /// revert; the check short-circuits cheaply before bond lock.
192 #[error("slash already reverted")]
193 SlashAlreadyReverted,
194
195 /// Pending slash is already in the `Finalised` terminal state —
196 /// no further appeals are accepted.
197 ///
198 /// Raised by DSL-061. Window closed, correlation penalty
199 /// applied, exit lock scheduled. Terminal; non-actionable.
200 #[error("slash already finalised")]
201 SlashAlreadyFinalised,
202
203 /// Appellant ran out of distinct attempts against this pending
204 /// slash.
205 ///
206 /// Raised by DSL-059. Caps adjudication cost at
207 /// `MAX_APPEAL_ATTEMPTS_PER_SLASH` (4). Only REJECTED attempts
208 /// accumulate — a sustained appeal transitions the slash to
209 /// `Reverted` and drains the book entry, so the counter can
210 /// never exceed the cap in practice.
211 #[error("too many appeal attempts: count={count}, limit={limit}")]
212 TooManyAttempts {
213 /// Attempts already recorded in `appeal_history`.
214 count: usize,
215 /// `MAX_APPEAL_ATTEMPTS_PER_SLASH` at the time of check.
216 limit: usize,
217 },
218
219 /// Byte-equal appeal already present in
220 /// `PendingSlash::appeal_history`.
221 ///
222 /// Raised by DSL-058. Prevents an appellant from spamming the
223 /// adjudicator with identical rejected appeals. Near-duplicates
224 /// (different witness bytes or different ground) are accepted;
225 /// only byte-equal envelopes trip this check. Runs AFTER
226 /// `AppealVariantMismatch` (DSL-057) and BEFORE bond lock
227 /// (DSL-062).
228 #[error("duplicate appeal: byte-equal to prior attempt")]
229 DuplicateAppeal,
230
231 /// Appeal's payload variant does not match the evidence's
232 /// payload variant (e.g., `ProposerSlashingAppeal` filed
233 /// against `AttesterSlashing` evidence).
234 ///
235 /// Raised by DSL-057. Cheap structural check — no state
236 /// inspection beyond the two enum tags. Runs AFTER DSL-055
237 /// (UnknownEvidence) + DSL-056 (WindowExpired) and BEFORE any
238 /// bond operation.
239 #[error("appeal payload variant does not match evidence variant")]
240 AppealVariantMismatch,
241
242 /// Appeal filed after the slash's appeal window closed.
243 ///
244 /// Raised by DSL-056. The window is `[submitted_at_epoch,
245 /// submitted_at_epoch + SLASH_APPEAL_WINDOW_EPOCHS]` — inclusive
246 /// on BOTH ends (the boundary epoch itself is still a valid
247 /// filing). Bond is NOT locked on this path; precondition order
248 /// guarantees this.
249 #[error(
250 "appeal window expired: submitted_at={submitted_at}, window={window}, current={current}"
251 )]
252 AppealWindowExpired {
253 /// Epoch the slash was admitted at.
254 submitted_at: u64,
255 /// `SLASH_APPEAL_WINDOW_EPOCHS` at the time of admission.
256 window: u64,
257 /// `appeal.filed_epoch` — the epoch the appeal claims it
258 /// was filed at.
259 current: u64,
260 },
261
262 /// Appeal's `evidence_hash` does not match any entry in the
263 /// `PendingSlashBook`.
264 ///
265 /// Raised by DSL-055 as the FIRST precondition in
266 /// `SlashingManager::submit_appeal` — checked BEFORE any bond
267 /// lock so callers can retry cheaply. The carried string is the
268 /// hex encoding of the 32-byte evidence hash for diagnostic
269 /// logging (the raw bytes remain available at the call site).
270 #[error("unknown evidence: {0}")]
271 UnknownEvidence(String),
272
273 /// Serialized evidence payload exceeds
274 /// `MAX_SLASH_PROPOSAL_PAYLOAD_BYTES`.
275 ///
276 /// Raised by DSL-109 `enforce_slashing_evidence_payload_cap`.
277 /// Caps memory + DoS cost for invalid-block witness storage
278 /// inside a single REMARK. Mirrors the appeal-side
279 /// [`SlashingError::AppealPayloadTooLarge`] (DSL-063); the two
280 /// variants are kept distinct because callers upstream route
281 /// them through different admission pipelines.
282 #[error("evidence payload too large: actual={actual}, limit={limit}")]
283 EvidencePayloadTooLarge {
284 /// Actual JSON-encoded length in bytes.
285 actual: usize,
286 /// `MAX_SLASH_PROPOSAL_PAYLOAD_BYTES` at the time of check.
287 limit: usize,
288 },
289
290 /// Block-level evidence cap exceeded
291 /// (`evidence_count > MAX_SLASH_PROPOSALS_PER_BLOCK`) or
292 /// appeal cap exceeded
293 /// (`appeal_count > MAX_APPEALS_PER_BLOCK`).
294 ///
295 /// Raised by DSL-108 `enforce_block_level_slashing_caps` and
296 /// DSL-119 `enforce_block_level_appeal_caps`. Caps bound
297 /// per-block admission cost — each evidence triggers DSL-103
298 /// puzzle-hash derivation + BLS verification downstream, so
299 /// a hard cap keeps block-validation time predictable.
300 /// Carries both the `actual` count and the `limit` so
301 /// operators can tell whether they are hitting the proposal
302 /// or the appeal ceiling without re-deriving constants.
303 #[error("block cap exceeded: actual={actual}, limit={limit}")]
304 BlockCapExceeded {
305 /// Number of REMARK items observed in the block.
306 actual: usize,
307 /// `MAX_SLASH_PROPOSALS_PER_BLOCK` or
308 /// `MAX_APPEALS_PER_BLOCK` at the time of check.
309 limit: usize,
310 },
311
312 /// Mempool policy caught a byte-identical evidence between
313 /// `pending_evidence` and `incoming_evidence`, or a duplicate
314 /// within `incoming_evidence` itself.
315 ///
316 /// Raised by DSL-107
317 /// `enforce_slashing_evidence_mempool_dedup_policy`.
318 /// Fingerprint is the JSON wire bytes (`serde_json::to_vec`).
319 /// Separate from the manager-level dedup
320 /// [`SlashingError::AlreadySlashed`] (DSL-026) which operates
321 /// on the `evidence.hash()` digest and runs inside the
322 /// slashing manager; this variant runs earlier in the
323 /// mempool upstream of any manager state.
324 #[error("duplicate evidence in mempool policy")]
325 DuplicateEvidence,
326
327 /// Reorg depth exceeds the retention window the trackers
328 /// can reconstruct.
329 ///
330 /// Raised by DSL-130 `rewind_all_on_reorg` when
331 /// `current_epoch - new_tip_epoch > CORRELATION_WINDOW_EPOCHS`.
332 /// The correlation window is the deepest per-validator state
333 /// we retain; anything older cannot be rewound correctly
334 /// because the `slashed_in_window` rows have been pruned
335 /// (DSL-127 step 8) and the participation / inactivity
336 /// trackers do not keep per-epoch snapshots.
337 ///
338 /// An embedder receiving this error must fall back to a
339 /// longer-range reconciliation path (full resync or
340 /// checkpoint restore); the slashing crate cannot handle
341 /// the rewind locally.
342 #[error("reorg depth {depth} exceeds retention limit {limit}")]
343 ReorgTooDeep {
344 /// `current_epoch - new_tip_epoch` — how far back the
345 /// reorg wants to move.
346 depth: u64,
347 /// `CORRELATION_WINDOW_EPOCHS` at the time of check.
348 limit: u64,
349 },
350
351 /// REMARK admission found an evidence whose derived
352 /// `slashing_evidence_remark_puzzle_hash_v1` does NOT match
353 /// the spent coin's `puzzle_hash`.
354 ///
355 /// Raised by DSL-104/105 enforcement. The payload on-chain
356 /// (the REMARK bytes) binds to a puzzle hash at coin creation
357 /// time; if the admitted spend references a coin whose
358 /// `puzzle_hash` does not equal the recomputed hash, an
359 /// attacker is attempting to launder a payload through a coin
360 /// that never committed to it. Carries both hashes for
361 /// diagnostic logging; the check runs BEFORE any state
362 /// mutation.
363 #[error("REMARK admission puzzle_hash mismatch: expected=0x{expected}, got=0x{got}")]
364 AdmissionPuzzleHashMismatch {
365 /// Puzzle hash derived from the parsed evidence via
366 /// `slashing_evidence_remark_puzzle_hash_v1`.
367 expected: Bytes32,
368 /// `coin.puzzle_hash` on the admitted spend.
369 got: Bytes32,
370 },
371
372 /// Offense epoch is older than `SLASH_LOOKBACK_EPOCHS` relative to
373 /// the current epoch.
374 ///
375 /// Raised by `verify_evidence` (DSL-011) as the very first check —
376 /// cheap filter BEFORE any BLS or validator-view work. The check
377 /// is `evidence.epoch + SLASH_LOOKBACK_EPOCHS < current_epoch`,
378 /// phrased with addition on the LHS to avoid underflow when
379 /// `current_epoch < SLASH_LOOKBACK_EPOCHS` (e.g., at network boot).
380 /// Carries both epochs so adjudicators can diagnose the exact
381 /// delta without re-deriving it.
382 #[error("offense too old: offense_epoch={offense_epoch}, current_epoch={current_epoch}")]
383 OffenseTooOld {
384 /// Epoch the evidence claims the offense occurred at.
385 offense_epoch: u64,
386 /// Current epoch as seen by the verifier.
387 current_epoch: u64,
388 },
389}