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}