Skip to main content

dig_rpc_types/
types.rs

1//! Shared domain types used across multiple RPC methods.
2//!
3//! This module collects the wire types that more than one method consumes
4//! or produces: hex-string hashes / pubkeys / signatures, XCH amounts,
5//! block summaries, validator records.
6//!
7//! All hex-encoded types follow a single convention:
8//!
9//! - On **serialize**: emit `0x` + lowercase hex.
10//! - On **deserialize**: accept upper / lower / mixed case, optional `0x`
11//!   prefix.
12//!
13//! This matches the Chia wire format (where hashes are `0x`-prefixed) and
14//! the Ethereum RPC convention, giving us maximum cross-ecosystem
15//! compatibility.
16
17use std::fmt;
18use std::str::FromStr;
19
20use serde::{Deserialize, Serialize};
21
22// ===========================================================================
23// Hex-encoded fixed-length byte arrays
24// ===========================================================================
25
26/// A 32-byte hex-encoded value (block hash, coin id, state root, etc).
27///
28/// Wire form is `"0x"` followed by 64 lowercase hex chars. Deserialization
29/// accepts mixed case and optional `0x` prefix.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub struct HashHex(
32    /// Raw 32-byte value.
33    pub [u8; 32],
34);
35
36impl HashHex {
37    /// Construct from a raw 32-byte array.
38    pub const fn new(bytes: [u8; 32]) -> Self {
39        Self(bytes)
40    }
41
42    /// Borrow the underlying 32 bytes.
43    pub fn as_bytes(&self) -> &[u8; 32] {
44        &self.0
45    }
46}
47
48impl fmt::Display for HashHex {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        write!(f, "0x{}", hex::encode(self.0))
51    }
52}
53
54impl FromStr for HashHex {
55    type Err = HexParseError;
56    fn from_str(s: &str) -> Result<Self, Self::Err> {
57        let bytes = parse_hex_fixed::<32>(s)?;
58        Ok(Self(bytes))
59    }
60}
61
62impl Serialize for HashHex {
63    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
64        s.collect_str(self)
65    }
66}
67
68impl<'de> Deserialize<'de> for HashHex {
69    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
70        String::deserialize(d)?
71            .parse()
72            .map_err(serde::de::Error::custom)
73    }
74}
75
76/// A 48-byte BLS12-381 G1 compressed public key.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
78pub struct PubkeyHex(
79    /// Raw 48-byte compressed G1 element.
80    pub [u8; 48],
81);
82
83impl PubkeyHex {
84    /// Construct from a raw 48-byte array.
85    pub const fn new(bytes: [u8; 48]) -> Self {
86        Self(bytes)
87    }
88
89    /// Borrow the underlying 48 bytes.
90    pub fn as_bytes(&self) -> &[u8; 48] {
91        &self.0
92    }
93}
94
95impl fmt::Display for PubkeyHex {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        write!(f, "0x{}", hex::encode(self.0))
98    }
99}
100
101impl FromStr for PubkeyHex {
102    type Err = HexParseError;
103    fn from_str(s: &str) -> Result<Self, Self::Err> {
104        let bytes = parse_hex_fixed::<48>(s)?;
105        Ok(Self(bytes))
106    }
107}
108
109impl Serialize for PubkeyHex {
110    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
111        s.collect_str(self)
112    }
113}
114
115impl<'de> Deserialize<'de> for PubkeyHex {
116    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
117        String::deserialize(d)?
118            .parse()
119            .map_err(serde::de::Error::custom)
120    }
121}
122
123/// A 96-byte BLS12-381 G2 compressed signature.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
125pub struct SignatureHex(
126    /// Raw 96-byte compressed G2 element.
127    pub [u8; 96],
128);
129
130impl SignatureHex {
131    /// Construct from a raw 96-byte array.
132    pub const fn new(bytes: [u8; 96]) -> Self {
133        Self(bytes)
134    }
135
136    /// Borrow the underlying 96 bytes.
137    pub fn as_bytes(&self) -> &[u8; 96] {
138        &self.0
139    }
140}
141
142impl fmt::Display for SignatureHex {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        write!(f, "0x{}", hex::encode(self.0))
145    }
146}
147
148impl FromStr for SignatureHex {
149    type Err = HexParseError;
150    fn from_str(s: &str) -> Result<Self, Self::Err> {
151        let bytes = parse_hex_fixed::<96>(s)?;
152        Ok(Self(bytes))
153    }
154}
155
156impl Serialize for SignatureHex {
157    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
158        s.collect_str(self)
159    }
160}
161
162impl<'de> Deserialize<'de> for SignatureHex {
163    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
164        String::deserialize(d)?
165            .parse()
166            .map_err(serde::de::Error::custom)
167    }
168}
169
170// ===========================================================================
171// Scalars
172// ===========================================================================
173
174/// A Chia amount in mojos (1 XCH = 10^12 mojos).
175///
176/// Encoded as a JSON number — safe for amounts up to 2^53 − 1 mojos
177/// (~9000 XCH) before float round-trip issues. Larger amounts would need
178/// string encoding; not a current concern.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
180#[serde(transparent)]
181pub struct Amount(
182    /// Raw mojo count.
183    pub u64,
184);
185
186impl Amount {
187    /// Zero mojos.
188    pub const ZERO: Self = Self(0);
189}
190
191// ===========================================================================
192// Block / validator summaries
193// ===========================================================================
194
195/// Compact metadata for a single L2 block. Returned by `get_block_records`
196/// and embedded in larger envelopes like `get_blockchain_state`.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct BlockSummary {
199    /// Block height on the L2 chain.
200    pub height: u64,
201    /// Canonical block hash.
202    pub hash: HashHex,
203    /// Parent block hash.
204    pub parent_hash: HashHex,
205    /// Unix timestamp (seconds) from the proposer's signed header.
206    pub timestamp: u64,
207    /// Block proposer's BLS public key.
208    pub proposer: PubkeyHex,
209    /// Number of transactions included.
210    pub tx_count: u32,
211    /// Cumulative attestation weight.
212    pub weight: u64,
213}
214
215/// Validator lifecycle state.
216///
217/// Values match the state machine in
218/// [`docs/superpowers/specs/2026-04-20-validator-lifecycle-checkpoint-gated-design.md`](https://github.com/DIG-Network/dig-network/tree/main/docs)
219/// and pair with the [`ValidatorSummary`] struct below.
220#[non_exhaustive]
221#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
222#[serde(rename_all = "snake_case")]
223pub enum ValidatorStatus {
224    /// Validator has an L1 registration coin but is not yet in the VMR.
225    PendingRegister,
226    /// Validator is in the current checkpoint's VMR and may sign.
227    Active,
228    /// Validator submitted a voluntary-exit signal; waiting for next checkpoint.
229    ExitingVoluntary,
230    /// Validator force-exited by L2 (inactivity / slashing / governance).
231    ExitingForced,
232    /// Validator's exit was committed to an exit-ledger leaf.
233    Exited,
234    /// Registration coin was spent to a withdraw-delay coin; waiting for delay.
235    WithdrawalPending,
236    /// Withdraw-delay coin was released; collateral paid out.
237    Withdrawn,
238}
239
240/// Summary record for a single validator, returned by `get_validator` /
241/// `get_active_validators`.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct ValidatorSummary {
244    /// BLS12-381 G1 public key — the canonical validator identifier.
245    pub pubkey: PubkeyHex,
246    /// Current lifecycle state.
247    pub status: ValidatorStatus,
248    /// Leaf index in the validator-merkle-root, if active. `None` until activation.
249    pub validator_index: Option<u32>,
250    /// Effective balance (Ethereum-parity hysteresis). In mojos.
251    pub effective_balance: Amount,
252    /// Cumulative slashed amount. In mojos.
253    pub slashed_amount: Amount,
254    /// Epoch in which activation committed. `None` if not yet active.
255    pub activation_epoch: Option<u64>,
256    /// Epoch in which exit committed. `None` if not yet exiting.
257    pub exit_epoch: Option<u64>,
258}
259
260// ===========================================================================
261// Hex parsing helper
262// ===========================================================================
263
264/// Errors from parsing a hex string into a fixed-size byte array.
265#[derive(Debug, thiserror::Error)]
266pub enum HexParseError {
267    /// The hex string (after stripping `0x`) had the wrong length.
268    #[error(
269        "wrong hex length: expected {expected} bytes ({expected_hex_chars} hex chars), got {got}"
270    )]
271    WrongLength {
272        /// Required byte length.
273        expected: usize,
274        /// Required hex-char length (`expected * 2`).
275        expected_hex_chars: usize,
276        /// Actual hex-char length observed.
277        got: usize,
278    },
279    /// The hex string contained a non-hex character.
280    #[error("invalid hex: {0}")]
281    InvalidHex(#[from] hex::FromHexError),
282}
283
284fn parse_hex_fixed<const N: usize>(s: &str) -> Result<[u8; N], HexParseError> {
285    let stripped = s
286        .strip_prefix("0x")
287        .or_else(|| s.strip_prefix("0X"))
288        .unwrap_or(s);
289    if stripped.len() != N * 2 {
290        return Err(HexParseError::WrongLength {
291            expected: N,
292            expected_hex_chars: N * 2,
293            got: stripped.len(),
294        });
295    }
296    let mut out = [0u8; N];
297    hex::decode_to_slice(stripped, &mut out)?;
298    Ok(out)
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    /// **Proves:** a 32-byte `HashHex` serializes as `"0x"` + 64 lowercase
306    /// hex chars.
307    ///
308    /// **Why it matters:** External tooling (explorers, dashboards,
309    /// light-clients) identifies coins and blocks by this exact string
310    /// form. Any case change or prefix drop would break equality checks
311    /// across the ecosystem.
312    ///
313    /// **Catches:** a regression where `Display` uses uppercase (`{:X}`),
314    /// or drops the `0x` prefix.
315    #[test]
316    fn hash_hex_display() {
317        let h = HashHex::new([0xAB; 32]);
318        let s = h.to_string();
319        assert_eq!(s.len(), 2 + 64);
320        assert!(s.starts_with("0x"));
321        assert_eq!(s, format!("0x{}", "ab".repeat(32)));
322    }
323
324    /// **Proves:** `HashHex::from_str` accepts `0x`-prefixed lowercase,
325    /// `0x`-prefixed uppercase, bare lowercase, and mixed case.
326    ///
327    /// **Why it matters:** JSON ecosystems are inconsistent about hex case
328    /// — Rust tends to emit lowercase, JavaScript and Java often emit
329    /// mixed. Accepting all forms at deserialize time makes the RPC
330    /// resilient to client-library quirks.
331    ///
332    /// **Catches:** a regression that tightens parsing to one canonical
333    /// form, silently breaking some clients.
334    #[test]
335    fn hash_hex_parse_accepts_case_and_prefix_variations() {
336        let want = HashHex::new([0xAB; 32]);
337
338        let inputs = [
339            format!("0x{}", "ab".repeat(32)),
340            format!("0X{}", "AB".repeat(32)),
341            "ab".repeat(32),
342            "AB".repeat(32),
343        ];
344
345        for s in inputs {
346            let parsed: HashHex = s.parse().expect(&s);
347            assert_eq!(parsed, want, "parse of {s:?} mismatched");
348        }
349    }
350
351    /// **Proves:** parsing a hex string of the wrong length (62 chars
352    /// instead of 64) returns [`HexParseError::WrongLength`].
353    ///
354    /// **Why it matters:** Hex-length checks catch truncated values early.
355    /// Without this, a shortened hash silently rounded up to 32 bytes
356    /// would produce wrong-but-valid-looking coin IDs.
357    ///
358    /// **Catches:** removing the length check; using `decode` (which ignores
359    /// truncation) instead of `decode_to_slice`.
360    #[test]
361    fn hash_hex_parse_rejects_wrong_length() {
362        let short = format!("0x{}", "a".repeat(62));
363        let err = short.parse::<HashHex>().unwrap_err();
364        assert!(matches!(err, HexParseError::WrongLength { .. }));
365    }
366
367    /// **Proves:** `HashHex` and its siblings round-trip through serde JSON
368    /// unchanged.
369    ///
370    /// **Why it matters:** This is the actual wire exercise — serialize to
371    /// JSON and deserialize back, checking bit-exact equality.
372    ///
373    /// **Catches:** a regression where `Serialize`/`Deserialize` impls drift
374    /// out of sync (e.g., serializer uses lowercase but deserializer
375    /// requires `0x` prefix that the serializer emits).
376    #[test]
377    fn fixed_hex_types_roundtrip_via_serde() {
378        let h = HashHex::new([1u8; 32]);
379        let j = serde_json::to_string(&h).unwrap();
380        let back: HashHex = serde_json::from_str(&j).unwrap();
381        assert_eq!(h, back);
382
383        let p = PubkeyHex::new([2u8; 48]);
384        let j = serde_json::to_string(&p).unwrap();
385        let back: PubkeyHex = serde_json::from_str(&j).unwrap();
386        assert_eq!(p, back);
387
388        let s = SignatureHex::new([3u8; 96]);
389        let j = serde_json::to_string(&s).unwrap();
390        let back: SignatureHex = serde_json::from_str(&j).unwrap();
391        assert_eq!(s, back);
392    }
393
394    /// **Proves:** `Amount` serializes as a bare number (transparent
395    /// `u64`), not as an object.
396    ///
397    /// **Why it matters:** An `Amount` of 42 should serialize as `42`, not
398    /// `{"0": 42}`. The `#[serde(transparent)]` attribute pins this.
399    ///
400    /// **Catches:** dropping `#[serde(transparent)]` — which would produce
401    /// object form and break every client that expects a bare number.
402    #[test]
403    fn amount_transparent_serde() {
404        let a = Amount(42);
405        let s = serde_json::to_string(&a).unwrap();
406        assert_eq!(s, "42");
407    }
408
409    /// **Proves:** `ValidatorStatus` serializes in snake_case — e.g.,
410    /// `"pending_register"`, not `"PendingRegister"`.
411    ///
412    /// **Why it matters:** JSON conventions favour snake_case for enum
413    /// variants; the rest of the crate's types already follow this. Clients
414    /// will pattern-match on the exact string.
415    ///
416    /// **Catches:** a regression that drops `#[serde(rename_all = "snake_case")]`.
417    #[test]
418    fn validator_status_serialises_snake_case() {
419        let s = serde_json::to_string(&ValidatorStatus::PendingRegister).unwrap();
420        assert_eq!(s, "\"pending_register\"");
421
422        let s = serde_json::to_string(&ValidatorStatus::WithdrawalPending).unwrap();
423        assert_eq!(s, "\"withdrawal_pending\"");
424    }
425
426    /// **Proves:** `BlockSummary` + `ValidatorSummary` round-trip through
427    /// JSON unchanged when populated with realistic values.
428    ///
429    /// **Why it matters:** These are the two most-referenced domain types.
430    /// If they drift (a field renamed, dropped, or re-typed) every method
431    /// that returns them breaks in lockstep.
432    ///
433    /// **Catches:** accidentally making a field `serde(skip)` that clients
434    /// depend on; reordering tuple-like struct fields (which would break
435    /// wire form even though compilation succeeds).
436    #[test]
437    fn summaries_roundtrip() {
438        let b = BlockSummary {
439            height: 123,
440            hash: HashHex::new([1u8; 32]),
441            parent_hash: HashHex::new([0u8; 32]),
442            timestamp: 1_700_000_000,
443            proposer: PubkeyHex::new([9u8; 48]),
444            tx_count: 7,
445            weight: 10_000,
446        };
447        let j = serde_json::to_string(&b).unwrap();
448        let back: BlockSummary = serde_json::from_str(&j).unwrap();
449        assert_eq!(b.height, back.height);
450        assert_eq!(b.hash, back.hash);
451
452        let v = ValidatorSummary {
453            pubkey: PubkeyHex::new([5u8; 48]),
454            status: ValidatorStatus::Active,
455            validator_index: Some(42),
456            effective_balance: Amount(32_000_000_000_000),
457            slashed_amount: Amount::ZERO,
458            activation_epoch: Some(7),
459            exit_epoch: None,
460        };
461        let j = serde_json::to_string(&v).unwrap();
462        let back: ValidatorSummary = serde_json::from_str(&j).unwrap();
463        assert_eq!(back.pubkey, v.pubkey);
464        assert_eq!(back.status, v.status);
465    }
466}