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//! # 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}