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