dig_slashing/protection.rs
1//! Self-slashing protection for a single validator.
2//!
3//! Traces to: [SPEC §14](../docs/resources/SPEC.md), catalogue
4//! rows
5//! [DSL-094..101](../docs/requirements/domains/protection/specs/).
6//!
7//! # Role
8//!
9//! `SlashingProtection` is a per-validator local-state struct
10//! that prevents a running validator from signing two
11//! messages that would slash itself on restart / fork-choice
12//! change. Lives on the validator's machine, not the chain —
13//! purely advisory at the network level, but load-bearing at
14//! the single-validator level.
15//!
16//! # Surface
17//!
18//! The full self-slashing-protection state machine is implemented:
19//!
20//! - DSL-094: proposal-slot monotonic check
21//! - DSL-095: attestation same-(src,tgt) different-hash check
22//! - DSL-096: would-surround self-check
23//! - DSL-097: `record_proposal` + `record_attestation` persistence
24//! - DSL-098: `rewind_attestation_to_epoch`
25//! - DSL-099/100/101: reorg, bootstrap, persistence (`save`/`load`)
26
27use std::path::PathBuf;
28
29use dig_peer_protocol::Bytes32;
30use serde::{Deserialize, Serialize};
31
32/// Per-validator local slashing-protection state.
33///
34/// Implements [DSL-094..101](../docs/requirements/domains/protection/specs/).
35/// Traces to SPEC §14.
36///
37/// # Fields
38///
39/// - `last_proposed_slot` — largest slot the validator has
40/// proposed at. `check_proposal_slot` requires a strictly
41/// greater slot before signing a new proposal.
42/// - `last_attested_source_epoch` / `last_attested_target_epoch`
43/// — the source/target epochs of the last attestation; guard
44/// double-vote (DSL-095) + surround-vote (DSL-096).
45/// - `last_attested_block_hash` — block root of the last
46/// attestation; distinguishes a re-attestation from a
47/// conflicting one.
48///
49/// # Default
50///
51/// `Default::default()` → `last_proposed_slot = 0`. Any slot
52/// `> 0` passes `check_proposal_slot` on a fresh instance.
53#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
54pub struct SlashingProtection {
55 /// Largest slot the validator has proposed at. Guards
56 /// proposer-equivocation (DSL-094).
57 last_proposed_slot: u64,
58 /// `source.epoch` of the validator's last successful
59 /// attestation. `0` on a fresh instance. Guards attester
60 /// double-vote (DSL-095) + surround-vote (DSL-096).
61 last_attested_source_epoch: u64,
62 /// `target.epoch` of the validator's last successful
63 /// attestation. `0` on a fresh instance.
64 last_attested_target_epoch: u64,
65 /// Block-root hex (`0x...` lowercase) of the validator's
66 /// last successful attestation. `None` when no attestation
67 /// has been recorded. Stored as a `String` so persistence
68 /// (DSL-101) can round-trip via JSON without binary-blob
69 /// plumbing.
70 ///
71 /// `#[serde(default)]` pins
72 /// [DSL-100](../docs/requirements/domains/protection/specs/DSL-100.md)
73 /// backwards compatibility: legacy on-disk JSON from the pre-
74 /// hash schema deserialises with `last_attested_block_hash =
75 /// None` instead of a hard "missing field" error, avoiding a
76 /// "delete your state" migration step that would strip
77 /// monotonic slashing-protection guarantees.
78 #[serde(default)]
79 last_attested_block_hash: Option<String>,
80}
81
82impl SlashingProtection {
83 /// Construct with `last_proposed_slot = 0`.
84 #[must_use]
85 pub fn new() -> Self {
86 Self::default()
87 }
88
89 /// Last slot at which the validator proposed. Used for
90 /// introspection + persistence round-trips.
91 #[must_use]
92 pub fn last_proposed_slot(&self) -> u64 {
93 self.last_proposed_slot
94 }
95
96 /// `true` iff the caller MAY sign a new proposal at `slot`.
97 ///
98 /// Implements [DSL-094](../docs/requirements/domains/protection/specs/DSL-094.md).
99 ///
100 /// # Predicate
101 ///
102 /// `slot > self.last_proposed_slot` — strict greater-than
103 /// so the same slot cannot be signed twice (that would be
104 /// the canonical proposer-equivocation offense).
105 ///
106 /// Fresh validators have `last_proposed_slot = 0`, so any
107 /// slot `> 0` is safe to sign.
108 #[must_use]
109 pub fn check_proposal_slot(&self, slot: u64) -> bool {
110 slot > self.last_proposed_slot
111 }
112
113 /// Record a successful proposal at `slot`. Subsequent
114 /// `check_proposal_slot(s)` calls with `s <= slot` will
115 /// return `false`.
116 ///
117 /// Implements [DSL-094](../docs/requirements/domains/protection/specs/DSL-094.md).
118 /// Persistence semantics land in DSL-097.
119 pub fn record_proposal(&mut self, slot: u64) {
120 self.last_proposed_slot = slot;
121 }
122
123 /// `source.epoch` of the last recorded attestation.
124 #[must_use]
125 pub fn last_attested_source_epoch(&self) -> u64 {
126 self.last_attested_source_epoch
127 }
128
129 /// `target.epoch` of the last recorded attestation.
130 #[must_use]
131 pub fn last_attested_target_epoch(&self) -> u64 {
132 self.last_attested_target_epoch
133 }
134
135 /// Lowercase `0x`-prefixed hex of the last recorded
136 /// attestation's block hash. `None` when no attestation
137 /// has been recorded.
138 #[must_use]
139 pub fn last_attested_block_hash(&self) -> Option<&str> {
140 self.last_attested_block_hash.as_deref()
141 }
142
143 /// `true` iff the caller MAY sign an attestation at
144 /// `(source_epoch, target_epoch, block_hash)`.
145 ///
146 /// Implements DSL-095 (same-(src, tgt) different-hash
147 /// self-check). DSL-096 (surround-vote self-check) extends
148 /// this method in a later commit.
149 ///
150 /// # DSL-095 rule
151 ///
152 /// When the candidate FFG coordinates match the stored
153 /// last-attested pair EXACTLY, the attestation is allowed
154 /// only if the candidate block hash matches the stored
155 /// hash (case-insensitive hex compare). This is the
156 /// "re-sign the same vote" carve-out — a validator
157 /// restarting mid-epoch may re-emit its own attestation,
158 /// but may NOT switch to a different block at the same
159 /// source/target pair (that would be an
160 /// `AttesterDoubleVote`, DSL-014).
161 ///
162 /// If no prior attestation is stored (`last_attested_*
163 /// = 0, None`), the check falls through to the surround
164 /// guard (DSL-096 — currently a no-op stub) and returns
165 /// `true`.
166 #[must_use]
167 pub fn check_attestation(
168 &self,
169 source_epoch: u64,
170 target_epoch: u64,
171 block_hash: &Bytes32,
172 ) -> bool {
173 // DSL-096: surround-vote self-check. Runs BEFORE the
174 // DSL-095 same-coord check — a surround is slashable
175 // regardless of the stored hash, so we short-circuit
176 // cheaply. Mirrors the DSL-015 verify-side predicate.
177 if self.would_surround(source_epoch, target_epoch) {
178 return false;
179 }
180
181 // DSL-095: exact (source, target) coordinate collision.
182 // The stored hash must be present AND match the
183 // candidate case-insensitively; anything else is a
184 // potential double-vote.
185 if source_epoch == self.last_attested_source_epoch
186 && target_epoch == self.last_attested_target_epoch
187 {
188 let candidate = to_hex_lower(block_hash.as_ref());
189 match self.last_attested_block_hash.as_deref() {
190 Some(stored) if stored.eq_ignore_ascii_case(&candidate) => {
191 // Re-sign the SAME vote is allowed.
192 }
193 _ => return false,
194 }
195 }
196 true
197 }
198
199 /// Would the candidate attestation surround the stored
200 /// one?
201 ///
202 /// Implements [DSL-096](../docs/requirements/domains/protection/specs/DSL-096.md).
203 /// Traces to SPEC §14.2.
204 ///
205 /// # Predicate
206 ///
207 /// ```text
208 /// candidate_source < self.last_attested_source_epoch
209 /// AND
210 /// candidate_target > self.last_attested_target_epoch
211 /// ```
212 ///
213 /// Both strict — a candidate matching either epoch exactly
214 /// is NOT a surround (it is either a same-coord case
215 /// (DSL-095) or a non-surround flank).
216 #[must_use]
217 fn would_surround(&self, candidate_source: u64, candidate_target: u64) -> bool {
218 candidate_source < self.last_attested_source_epoch
219 && candidate_target > self.last_attested_target_epoch
220 }
221
222 /// Record a successful attestation. Updates
223 /// `last_attested_source_epoch`, `last_attested_target_epoch`
224 /// and `last_attested_block_hash`.
225 ///
226 /// Implements the DSL-095/096 persistence primitive. DSL-097
227 /// pins the full contract (including the proposer-side
228 /// `record_proposal` companion).
229 pub fn record_attestation(
230 &mut self,
231 source_epoch: u64,
232 target_epoch: u64,
233 block_hash: &Bytes32,
234 ) {
235 self.last_attested_source_epoch = source_epoch;
236 self.last_attested_target_epoch = target_epoch;
237 self.last_attested_block_hash = Some(to_hex_lower(block_hash.as_ref()));
238 }
239
240 /// Rewind the proposal watermark on fork-choice reorg.
241 ///
242 /// Previews [DSL-156](../docs/requirements/domains/protection/specs/DSL-156.md)
243 /// — DSL-099 composes this fn alongside [`rewind_attestation_to_epoch`]
244 /// (DSL-098) inside [`reconcile_with_chain_tip`]. The DSL-156
245 /// dedicated test file lands in Phase 10.
246 ///
247 /// # Semantics
248 ///
249 /// Caps `last_proposed_slot` at `new_tip_slot` using strict `>`
250 /// so the boundary (stored == tip) is a no-op and already-lower
251 /// slots remain untouched. Reconcile must never RAISE a
252 /// watermark — doing so would weaken slashing protection.
253 ///
254 /// No hash-equivalent to clear on the proposal side: DSL-094
255 /// only tracks the slot, not a block binding.
256 pub fn rewind_proposal_to_slot(&mut self, new_tip_slot: u64) {
257 if self.last_proposed_slot > new_tip_slot {
258 self.last_proposed_slot = new_tip_slot;
259 }
260 }
261
262 /// Persist slashing-protection state to disk as pretty-printed
263 /// JSON.
264 ///
265 /// Implements [DSL-101](../docs/requirements/domains/protection/specs/DSL-101.md).
266 /// Traces to SPEC §14.4.
267 ///
268 /// # On-disk format
269 ///
270 /// JSON, pretty-printed for operator debuggability. All fields
271 /// are primitive (`u64`) except `last_attested_block_hash`,
272 /// which is stored as the canonical `0x<lowercase-hex>` form
273 /// produced by [`record_attestation`]. External tooling that
274 /// rewrites the hash to uppercase is tolerated on reload via
275 /// [`check_attestation`]'s case-insensitive compare.
276 ///
277 /// # Errors
278 ///
279 /// Returns any I/O error raised by the underlying `std::fs`
280 /// write. Serialization is infallible (every field is
281 /// serde-safe by construction).
282 ///
283 /// # Companion [`load`](Self::load)
284 ///
285 /// The inverse of this call. `load` handles the missing-file
286 /// case by returning `Self::default()` so a first-boot
287 /// validator does not need a bootstrap branch.
288 pub fn save(&self, path: &PathBuf) -> std::io::Result<()> {
289 let json = serde_json::to_vec_pretty(self)
290 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
291 std::fs::write(path, json)
292 }
293
294 /// Load slashing-protection state from disk.
295 ///
296 /// Implements [DSL-101](../docs/requirements/domains/protection/specs/DSL-101.md).
297 ///
298 /// # Behaviour
299 ///
300 /// - Path exists: decode the file via `serde_json::from_slice`.
301 /// Uses the same schema as `save`; DSL-100 handles legacy
302 /// JSON lacking `last_attested_block_hash` via
303 /// `#[serde(default)]` on that field.
304 /// - Path does NOT exist: return `Self::default()`. This is
305 /// the intentional first-boot path — a validator with no
306 /// prior state calls `load` and gets a clean instance,
307 /// avoiding an explicit bootstrap branch at every call site.
308 ///
309 /// # Errors
310 ///
311 /// - `std::fs::read` errors (permission, I/O) propagate.
312 /// - Deserialization errors surface as `InvalidData` via
313 /// `serde_json`. NOTE: a legitimate legacy file triggers
314 /// `#[serde(default)]`, not an error.
315 pub fn load(path: &PathBuf) -> std::io::Result<Self> {
316 if !path.exists() {
317 return Ok(Self::default());
318 }
319 let bytes = std::fs::read(path)?;
320 serde_json::from_slice(&bytes)
321 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
322 }
323
324 /// Reconcile local slashing-protection state with the canonical
325 /// chain tip on validator startup or after a reorg.
326 ///
327 /// Implements [DSL-099](../docs/requirements/domains/protection/specs/DSL-099.md).
328 /// Traces to SPEC §14.3.
329 ///
330 /// # Semantics
331 ///
332 /// Composes [`rewind_proposal_to_slot`] (DSL-156) with
333 /// [`rewind_attestation_to_epoch`] (DSL-098) under a single
334 /// entry point. Net effect:
335 ///
336 /// - `last_proposed_slot` capped at `tip_slot` (never raised).
337 /// - `last_attested_source_epoch` / `last_attested_target_epoch`
338 /// capped at `tip_epoch` (never raised).
339 /// - `last_attested_block_hash` cleared unconditionally —
340 /// the hash binds to a specific block that the reorg
341 /// invalidates.
342 ///
343 /// Idempotent by construction: both legs are caps, and a second
344 /// call with the same `(tip_slot, tip_epoch)` finds the state
345 /// already satisfying both caps.
346 ///
347 /// Called by:
348 ///
349 /// - validator boot sequence (rejoin canonical chain after
350 /// downtime),
351 /// - [DSL-130](../../docs/requirements/domains/orchestration/specs/DSL-130.md)
352 /// global-reorg orchestration.
353 pub fn reconcile_with_chain_tip(&mut self, tip_slot: u64, tip_epoch: u64) {
354 self.rewind_proposal_to_slot(tip_slot);
355 self.rewind_attestation_to_epoch(tip_epoch);
356 }
357
358 /// Rewind attestation state on fork-choice reorg or chain-tip
359 /// refresh.
360 ///
361 /// Implements [DSL-098](../docs/requirements/domains/protection/specs/DSL-098.md).
362 /// Traces to SPEC §14.3.
363 ///
364 /// # Semantics
365 ///
366 /// The stored (source, target, hash) triple is the validator's
367 /// local memory of "what I already signed." When a reorg drops
368 /// the chain back below the attested epochs, that memory is
369 /// a ghost watermark — the block the hash points to no longer
370 /// exists on the canonical chain. Keeping it would block honest
371 /// re-attestation through DSL-095/096.
372 ///
373 /// Two legs:
374 ///
375 /// 1. Cap `last_attested_source_epoch` and
376 /// `last_attested_target_epoch` at `new_tip_epoch`. Use
377 /// strict `>` so the boundary case (stored == tip) is a
378 /// no-op — the cap must never RAISE a watermark, only
379 /// lower it.
380 /// 2. Clear `last_attested_block_hash` unconditionally. The
381 /// hash binds to a specific block; a reorg invalidates
382 /// that binding regardless of epoch ordering.
383 ///
384 /// After rewind, a re-attestation on the new canonical tip
385 /// passes [`check_attestation`].
386 ///
387 /// Companion DSL-099 (`reconcile_with_chain_tip`) calls this
388 /// alongside the proposal-rewind DSL-156; DSL-130 triggers the
389 /// whole bundle on global reorg.
390 pub fn rewind_attestation_to_epoch(&mut self, new_tip_epoch: u64) {
391 if self.last_attested_source_epoch > new_tip_epoch {
392 self.last_attested_source_epoch = new_tip_epoch;
393 }
394 if self.last_attested_target_epoch > new_tip_epoch {
395 self.last_attested_target_epoch = new_tip_epoch;
396 }
397 self.last_attested_block_hash = None;
398 }
399}
400
401/// Fixed-size lowercase hex encoder with `0x` prefix. Matches
402/// Ethereum JSON convention used by validator-key management
403/// tooling — keeps the on-disk format portable across clients.
404fn to_hex_lower(bytes: &[u8]) -> String {
405 const HEX: &[u8; 16] = b"0123456789abcdef";
406 let mut out = String::with_capacity(2 + bytes.len() * 2);
407 out.push_str("0x");
408 for &b in bytes {
409 out.push(HEX[(b >> 4) as usize] as char);
410 out.push(HEX[(b & 0x0F) as usize] as char);
411 }
412 out
413}