dig-rpc-types 0.1.0

JSON-RPC wire types shared by the DIG Network fullnode + validator RPC servers and their clients. Pure types — no I/O, no async, no logic.
Documentation
//! Validator RPC method catalogue.
//!
//! Smaller than the fullnode surface — validators serve operator-facing
//! status / duty / slashing-db inspection and a handful of admin methods.
//!
//! | Class | Methods |
//! |---|---|
//! | Status | `get_status`, `get_duty_history`, `healthz`, `get_version` |
//! | Slashing DB | `get_slashing_db`, `export_slashing_db`, `reset_slashing_db` |
//! | Admin | `stop_node`, `reload_config` |

use std::time::{SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};

use crate::types::{HashHex, PubkeyHex};

// ===========================================================================
// Status
// ===========================================================================

/// `get_status` — current validator status.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetStatusRequest;

impl GetStatusRequest {
    /// The wire method name.
    pub const METHOD: &'static str = "get_status";
}

/// Response for [`GetStatusRequest`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetStatusResponse {
    /// Validator's BLS pubkey.
    pub pubkey: PubkeyHex,
    /// Validator's index in the active-set VMR, if known.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub validator_index: Option<u32>,
    /// Whether the validator is currently allowed to sign duties.
    pub can_participate: bool,
    /// Unix seconds of the most-recent duty (propose / attest / checkpoint).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_duty_at: Option<u64>,
    /// Number of active fullnode client connections.
    pub active_connections: u32,
}

/// `get_duty_history` — recent duty-loop entries.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetDutyHistoryRequest {
    /// Maximum entries. Server caps at 1000.
    pub limit: u32,
    /// Unix seconds lower bound. `None` for "no lower bound".
    #[serde(skip_serializing_if = "Option::is_none")]
    pub since: Option<u64>,
}

impl GetDutyHistoryRequest {
    /// The wire method name.
    pub const METHOD: &'static str = "get_duty_history";
}

/// Response for [`GetDutyHistoryRequest`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetDutyHistoryResponse {
    /// Duty entries, most recent first.
    pub entries: Vec<DutyEntry>,
}

/// A single duty-loop event.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DutyEntry {
    /// Unix seconds when the duty ran.
    pub at: u64,
    /// Duty kind.
    pub kind: DutyKind,
    /// Whether the duty succeeded.
    pub ok: bool,
    /// Optional detail (error message / signed-hash).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
}

/// Duty kinds a validator performs.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DutyKind {
    /// Propose a new L2 block (this validator is elected proposer).
    Propose,
    /// Attest to a recent canonical head.
    Attest,
    /// Sign a per-epoch checkpoint digest.
    SignCheckpoint,
    /// Observe L1 finalisation (passive).
    ObserveL1,
}

/// Convenience helper — compute `now()` in Unix-epoch seconds for callers
/// who want to provide `since`.
pub fn now_unix_seconds() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

// ===========================================================================
// Slashing DB
// ===========================================================================

/// `get_slashing_db` — return the current slashing-protection watermarks.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetSlashingDbRequest;

impl GetSlashingDbRequest {
    /// The wire method name.
    pub const METHOD: &'static str = "get_slashing_db";
}

/// Response for [`GetSlashingDbRequest`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetSlashingDbResponse {
    /// Most-recent proposed slot.
    pub last_proposed_slot: u64,
    /// Most-recent attestation source epoch.
    pub last_attested_source: u64,
    /// Most-recent attestation target epoch.
    pub last_attested_target: u64,
    /// Most-recent attested beacon-block-root.
    pub last_attested_root: HashHex,
}

/// `export_slashing_db` — write the slashing DB to a server-side path
/// (EIP-3076 interchange format).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportSlashingDbRequest {
    /// Absolute path on the server host. Admin-only.
    pub path: String,
}

impl ExportSlashingDbRequest {
    /// The wire method name.
    pub const METHOD: &'static str = "export_slashing_db";
}

/// Response for [`ExportSlashingDbRequest`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportSlashingDbResponse {
    /// Bytes written to disk.
    pub written_bytes: u64,
}

/// `reset_slashing_db` — destructively reset the slashing-protection DB.
///
/// Requires a confirmation token to prevent accidental use; the server
/// rejects unless the token matches its expected value (typically derived
/// from a `dig-validator down` cooldown).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResetSlashingDbRequest {
    /// Single-use confirmation token.
    pub confirm_token: String,
}

impl ResetSlashingDbRequest {
    /// The wire method name.
    pub const METHOD: &'static str = "reset_slashing_db";
}

/// Response for [`ResetSlashingDbRequest`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResetSlashingDbResponse {
    /// Whether the reset actually happened.
    pub reset: bool,
}

// ===========================================================================
// Admin
// ===========================================================================

/// `stop_node` — request validator shutdown. Mirror of the fullnode method
/// but with a different server surface.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StopNodeRequest {
    /// Optional reason (audit log).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
}

impl StopNodeRequest {
    /// The wire method name.
    pub const METHOD: &'static str = "stop_node";
}

/// Response for [`StopNodeRequest`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StopNodeResponse {
    /// Whether shutdown was initiated.
    pub accepted: bool,
}

/// `reload_config` — re-read config.toml and apply non-destructive changes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReloadConfigRequest;

impl ReloadConfigRequest {
    /// The wire method name.
    pub const METHOD: &'static str = "reload_config";
}

/// Response for [`ReloadConfigRequest`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReloadConfigResponse {
    /// Whether the reload was applied.
    pub accepted: bool,
    /// Changes that took effect (human-readable strings).
    pub applied_changes: Vec<String>,
}

/// `healthz` — liveness probe. Identical shape to the fullnode method.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthzRequest;

impl HealthzRequest {
    /// The wire method name.
    pub const METHOD: &'static str = "healthz";
}

/// Response for [`HealthzRequest`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthzResponse {
    /// Whether the validator reports itself healthy.
    pub ok: bool,
}

/// `get_version` — binary identification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetVersionRequest;

impl GetVersionRequest {
    /// The wire method name.
    pub const METHOD: &'static str = "get_version";
}

/// Response for [`GetVersionRequest`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetVersionResponse {
    /// Human-readable version string.
    pub version: String,
    /// Build commit SHA.
    pub build_commit: String,
}

#[cfg(test)]
mod tests {
    use super::*;

    /// **Proves:** every validator request's `METHOD` constant is
    /// lower-snake-case.
    ///
    /// **Why it matters:** Same rationale as the fullnode method-names
    /// test. Catches PascalCase leaks early.
    ///
    /// **Catches:** typo regressions in the one place these strings are
    /// declared.
    #[test]
    fn method_names_are_snake_case() {
        let names = [
            GetStatusRequest::METHOD,
            GetDutyHistoryRequest::METHOD,
            GetSlashingDbRequest::METHOD,
            ExportSlashingDbRequest::METHOD,
            ResetSlashingDbRequest::METHOD,
            StopNodeRequest::METHOD,
            ReloadConfigRequest::METHOD,
            HealthzRequest::METHOD,
            GetVersionRequest::METHOD,
        ];
        for m in names {
            assert!(
                m.chars()
                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
                "not snake_case: {m:?}",
            );
        }
    }

    /// **Proves:** `DutyKind` serialises in snake_case across all variants.
    ///
    /// **Why it matters:** Clients (ops dashboards, alert rules) match on
    /// these strings in `get_duty_history` responses. Any case change would
    /// break every deployed rule.
    ///
    /// **Catches:** dropping `#[serde(rename_all = "snake_case")]`.
    #[test]
    fn duty_kind_snake_case() {
        assert_eq!(
            serde_json::to_string(&DutyKind::Propose).unwrap(),
            "\"propose\""
        );
        assert_eq!(
            serde_json::to_string(&DutyKind::SignCheckpoint).unwrap(),
            "\"sign_checkpoint\""
        );
        assert_eq!(
            serde_json::to_string(&DutyKind::ObserveL1).unwrap(),
            "\"observe_l1\""
        );
    }

    /// **Proves:** `get_status` response round-trips through JSON.
    ///
    /// **Why it matters:** Most validator RPC traffic will call
    /// `get_status` — smoke test the full envelope decodes.
    ///
    /// **Catches:** a regression that re-types `validator_index` from
    /// `Option<u32>` to `u32` (which would make `None` → `0` on the wire,
    /// ambiguous with "validator at index 0").
    #[test]
    fn get_status_roundtrip() {
        let r = GetStatusResponse {
            pubkey: PubkeyHex::new([7u8; 48]),
            validator_index: Some(3),
            can_participate: true,
            last_duty_at: Some(now_unix_seconds()),
            active_connections: 2,
        };
        let j = serde_json::to_string(&r).unwrap();
        let back: GetStatusResponse = serde_json::from_str(&j).unwrap();
        assert_eq!(back.pubkey, r.pubkey);
        assert_eq!(back.validator_index, r.validator_index);
        assert_eq!(back.can_participate, r.can_participate);
    }

    /// **Proves:** `now_unix_seconds` returns a non-zero value after the
    /// Unix epoch.
    ///
    /// **Why it matters:** This helper exists so the `since` field in
    /// `GetDutyHistoryRequest` can be populated by a one-liner. If the
    /// helper returned a constant (say, `0`), any CLI tool using it would
    /// silently fetch the entire history every time.
    ///
    /// **Catches:** a regression where the helper is stubbed during
    /// refactoring.
    #[test]
    fn now_unix_seconds_is_sane() {
        let t = now_unix_seconds();
        // Well past the Unix epoch, well before the year 2100.
        assert!(t > 1_600_000_000);
        assert!(t < 4_102_444_800);
    }
}