Skip to main content

dig_rpc_types/
validator.rs

1//! Validator RPC method catalogue.
2//!
3//! Smaller than the fullnode surface — validators serve operator-facing
4//! status / duty / slashing-db inspection and a handful of admin methods.
5//!
6//! | Class | Methods |
7//! |---|---|
8//! | Status | `get_status`, `get_duty_history`, `healthz`, `get_version` |
9//! | Slashing DB | `get_slashing_db`, `export_slashing_db`, `reset_slashing_db` |
10//! | Admin | `stop_node`, `reload_config` |
11
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use serde::{Deserialize, Serialize};
15
16use crate::types::{HashHex, PubkeyHex};
17
18// ===========================================================================
19// Status
20// ===========================================================================
21
22/// `get_status` — current validator status.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct GetStatusRequest;
25
26impl GetStatusRequest {
27    /// The wire method name.
28    pub const METHOD: &'static str = "get_status";
29}
30
31/// Response for [`GetStatusRequest`].
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct GetStatusResponse {
34    /// Validator's BLS pubkey.
35    pub pubkey: PubkeyHex,
36    /// Validator's index in the active-set VMR, if known.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub validator_index: Option<u32>,
39    /// Whether the validator is currently allowed to sign duties.
40    pub can_participate: bool,
41    /// Unix seconds of the most-recent duty (propose / attest / checkpoint).
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub last_duty_at: Option<u64>,
44    /// Number of active fullnode client connections.
45    pub active_connections: u32,
46}
47
48/// `get_duty_history` — recent duty-loop entries.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct GetDutyHistoryRequest {
51    /// Maximum entries. Server caps at 1000.
52    pub limit: u32,
53    /// Unix seconds lower bound. `None` for "no lower bound".
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub since: Option<u64>,
56}
57
58impl GetDutyHistoryRequest {
59    /// The wire method name.
60    pub const METHOD: &'static str = "get_duty_history";
61}
62
63/// Response for [`GetDutyHistoryRequest`].
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct GetDutyHistoryResponse {
66    /// Duty entries, most recent first.
67    pub entries: Vec<DutyEntry>,
68}
69
70/// A single duty-loop event.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct DutyEntry {
73    /// Unix seconds when the duty ran.
74    pub at: u64,
75    /// Duty kind.
76    pub kind: DutyKind,
77    /// Whether the duty succeeded.
78    pub ok: bool,
79    /// Optional detail (error message / signed-hash).
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub detail: Option<String>,
82}
83
84/// Duty kinds a validator performs.
85#[non_exhaustive]
86#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
87#[serde(rename_all = "snake_case")]
88pub enum DutyKind {
89    /// Propose a new L2 block (this validator is elected proposer).
90    Propose,
91    /// Attest to a recent canonical head.
92    Attest,
93    /// Sign a per-epoch checkpoint digest.
94    SignCheckpoint,
95    /// Observe L1 finalisation (passive).
96    ObserveL1,
97}
98
99/// Convenience helper — compute `now()` in Unix-epoch seconds for callers
100/// who want to provide `since`.
101pub fn now_unix_seconds() -> u64 {
102    SystemTime::now()
103        .duration_since(UNIX_EPOCH)
104        .map(|d| d.as_secs())
105        .unwrap_or(0)
106}
107
108// ===========================================================================
109// Slashing DB
110// ===========================================================================
111
112/// `get_slashing_db` — return the current slashing-protection watermarks.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct GetSlashingDbRequest;
115
116impl GetSlashingDbRequest {
117    /// The wire method name.
118    pub const METHOD: &'static str = "get_slashing_db";
119}
120
121/// Response for [`GetSlashingDbRequest`].
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct GetSlashingDbResponse {
124    /// Most-recent proposed slot.
125    pub last_proposed_slot: u64,
126    /// Most-recent attestation source epoch.
127    pub last_attested_source: u64,
128    /// Most-recent attestation target epoch.
129    pub last_attested_target: u64,
130    /// Most-recent attested beacon-block-root.
131    pub last_attested_root: HashHex,
132}
133
134/// `export_slashing_db` — write the slashing DB to a server-side path
135/// (EIP-3076 interchange format).
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct ExportSlashingDbRequest {
138    /// Absolute path on the server host. Admin-only.
139    pub path: String,
140}
141
142impl ExportSlashingDbRequest {
143    /// The wire method name.
144    pub const METHOD: &'static str = "export_slashing_db";
145}
146
147/// Response for [`ExportSlashingDbRequest`].
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct ExportSlashingDbResponse {
150    /// Bytes written to disk.
151    pub written_bytes: u64,
152}
153
154/// `reset_slashing_db` — destructively reset the slashing-protection DB.
155///
156/// Requires a confirmation token to prevent accidental use; the server
157/// rejects unless the token matches its expected value (typically derived
158/// from a `dig-validator down` cooldown).
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct ResetSlashingDbRequest {
161    /// Single-use confirmation token.
162    pub confirm_token: String,
163}
164
165impl ResetSlashingDbRequest {
166    /// The wire method name.
167    pub const METHOD: &'static str = "reset_slashing_db";
168}
169
170/// Response for [`ResetSlashingDbRequest`].
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct ResetSlashingDbResponse {
173    /// Whether the reset actually happened.
174    pub reset: bool,
175}
176
177// ===========================================================================
178// Admin
179// ===========================================================================
180
181/// `stop_node` — request validator shutdown. Mirror of the fullnode method
182/// but with a different server surface.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct StopNodeRequest {
185    /// Optional reason (audit log).
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub reason: Option<String>,
188}
189
190impl StopNodeRequest {
191    /// The wire method name.
192    pub const METHOD: &'static str = "stop_node";
193}
194
195/// Response for [`StopNodeRequest`].
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct StopNodeResponse {
198    /// Whether shutdown was initiated.
199    pub accepted: bool,
200}
201
202/// `reload_config` — re-read config.toml and apply non-destructive changes.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct ReloadConfigRequest;
205
206impl ReloadConfigRequest {
207    /// The wire method name.
208    pub const METHOD: &'static str = "reload_config";
209}
210
211/// Response for [`ReloadConfigRequest`].
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct ReloadConfigResponse {
214    /// Whether the reload was applied.
215    pub accepted: bool,
216    /// Changes that took effect (human-readable strings).
217    pub applied_changes: Vec<String>,
218}
219
220/// `healthz` — liveness probe. Identical shape to the fullnode method.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct HealthzRequest;
223
224impl HealthzRequest {
225    /// The wire method name.
226    pub const METHOD: &'static str = "healthz";
227}
228
229/// Response for [`HealthzRequest`].
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct HealthzResponse {
232    /// Whether the validator reports itself healthy.
233    pub ok: bool,
234}
235
236/// `get_version` — binary identification.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct GetVersionRequest;
239
240impl GetVersionRequest {
241    /// The wire method name.
242    pub const METHOD: &'static str = "get_version";
243}
244
245/// Response for [`GetVersionRequest`].
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct GetVersionResponse {
248    /// Human-readable version string.
249    pub version: String,
250    /// Build commit SHA.
251    pub build_commit: String,
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    /// **Proves:** every validator request's `METHOD` constant is
259    /// lower-snake-case.
260    ///
261    /// **Why it matters:** Same rationale as the fullnode method-names
262    /// test. Catches PascalCase leaks early.
263    ///
264    /// **Catches:** typo regressions in the one place these strings are
265    /// declared.
266    #[test]
267    fn method_names_are_snake_case() {
268        let names = [
269            GetStatusRequest::METHOD,
270            GetDutyHistoryRequest::METHOD,
271            GetSlashingDbRequest::METHOD,
272            ExportSlashingDbRequest::METHOD,
273            ResetSlashingDbRequest::METHOD,
274            StopNodeRequest::METHOD,
275            ReloadConfigRequest::METHOD,
276            HealthzRequest::METHOD,
277            GetVersionRequest::METHOD,
278        ];
279        for m in names {
280            assert!(
281                m.chars()
282                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
283                "not snake_case: {m:?}",
284            );
285        }
286    }
287
288    /// **Proves:** `DutyKind` serialises in snake_case across all variants.
289    ///
290    /// **Why it matters:** Clients (ops dashboards, alert rules) match on
291    /// these strings in `get_duty_history` responses. Any case change would
292    /// break every deployed rule.
293    ///
294    /// **Catches:** dropping `#[serde(rename_all = "snake_case")]`.
295    #[test]
296    fn duty_kind_snake_case() {
297        assert_eq!(
298            serde_json::to_string(&DutyKind::Propose).unwrap(),
299            "\"propose\""
300        );
301        assert_eq!(
302            serde_json::to_string(&DutyKind::SignCheckpoint).unwrap(),
303            "\"sign_checkpoint\""
304        );
305        assert_eq!(
306            serde_json::to_string(&DutyKind::ObserveL1).unwrap(),
307            "\"observe_l1\""
308        );
309    }
310
311    /// **Proves:** `get_status` response round-trips through JSON.
312    ///
313    /// **Why it matters:** Most validator RPC traffic will call
314    /// `get_status` — smoke test the full envelope decodes.
315    ///
316    /// **Catches:** a regression that re-types `validator_index` from
317    /// `Option<u32>` to `u32` (which would make `None` → `0` on the wire,
318    /// ambiguous with "validator at index 0").
319    #[test]
320    fn get_status_roundtrip() {
321        let r = GetStatusResponse {
322            pubkey: PubkeyHex::new([7u8; 48]),
323            validator_index: Some(3),
324            can_participate: true,
325            last_duty_at: Some(now_unix_seconds()),
326            active_connections: 2,
327        };
328        let j = serde_json::to_string(&r).unwrap();
329        let back: GetStatusResponse = serde_json::from_str(&j).unwrap();
330        assert_eq!(back.pubkey, r.pubkey);
331        assert_eq!(back.validator_index, r.validator_index);
332        assert_eq!(back.can_participate, r.can_participate);
333    }
334
335    /// **Proves:** `now_unix_seconds` returns a non-zero value after the
336    /// Unix epoch.
337    ///
338    /// **Why it matters:** This helper exists so the `since` field in
339    /// `GetDutyHistoryRequest` can be populated by a one-liner. If the
340    /// helper returned a constant (say, `0`), any CLI tool using it would
341    /// silently fetch the entire history every time.
342    ///
343    /// **Catches:** a regression where the helper is stubbed during
344    /// refactoring.
345    #[test]
346    fn now_unix_seconds_is_sane() {
347        let t = now_unix_seconds();
348        // Well past the Unix epoch, well before the year 2100.
349        assert!(t > 1_600_000_000);
350        assert!(t < 4_102_444_800);
351    }
352}