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