Skip to main content

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}