Skip to main content

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}