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}