Skip to main content

dig_rpc_types/
fullnode.rs

1//! Fullnode RPC method catalogue.
2//!
3//! One `{Method}Request` + `{Method}Response` struct pair per method. The
4//! catalogue follows the table in
5//! [`dig-network/docs/resources/02-subsystems/08-binaries/supplement/02-rpc-method-matrix.md`](https://github.com/DIG-Network/dig-network).
6//!
7//! Method classification:
8//!
9//! | Class | Examples | Role requirement |
10//! |---|---|---|
11//! | Blockchain state | `get_blockchain_state`, `get_network_info`, `healthz` | Explorer+ |
12//! | Blocks | `get_block`, `get_block_by_height`, `get_block_records` | Explorer+ |
13//! | Coins | `get_coin_record`, `get_coin_records_by_hint`, `get_coin_records_by_puzzle_hash` | Explorer+ |
14//! | Mempool | `get_mempool`, `push_tx` | Explorer+ (read), Admin (write) |
15//! | Peers | `get_connections`, `ban_peer` | Admin |
16//! | Checkpoint | `submit_partial_checkpoint_signature`, `get_checkpoint_pool` | Validator |
17//! | Validator set | `get_validator`, `get_active_validators`, `get_current_proposer` | Explorer+ |
18//! | Admin | `stop_node`, `get_recovery_status`, `get_version` | Admin |
19//!
20//! # Method name convention
21//!
22//! Method names on the JSON-RPC wire are snake_case strings, declared via
23//! the associated `METHOD` constant on each request struct. Request / response
24//! struct names follow PascalCase of the method with `Request` / `Response`
25//! suffixes — `GetBlockchainStateRequest` etc.
26
27use serde::{Deserialize, Serialize};
28
29use crate::types::{Amount, BlockSummary, HashHex, PubkeyHex, SignatureHex, ValidatorSummary};
30
31// ===========================================================================
32// Blockchain state
33// ===========================================================================
34
35/// `get_blockchain_state` — overall chain state.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct GetBlockchainStateRequest;
38
39impl GetBlockchainStateRequest {
40    /// The wire method name.
41    pub const METHOD: &'static str = "get_blockchain_state";
42}
43
44/// Response for [`GetBlockchainStateRequest`].
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct GetBlockchainStateResponse {
47    /// Current canonical tip height.
48    pub height: u64,
49    /// Current canonical tip block hash.
50    pub tip_hash: HashHex,
51    /// Whether the node is synced to the chain tip.
52    pub synced: bool,
53    /// Progress 0.0..=1.0 while syncing.
54    pub sync_progress: f32,
55    /// The most recent finalised (L3) epoch.
56    pub finalized_epoch: u64,
57    /// Height floor below which no reorg may cross.
58    pub sealed_height: u64,
59}
60
61/// `get_network_info` — genesis / network identity.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct GetNetworkInfoRequest;
64
65impl GetNetworkInfoRequest {
66    /// The wire method name.
67    pub const METHOD: &'static str = "get_network_info";
68}
69
70/// Response for [`GetNetworkInfoRequest`].
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct GetNetworkInfoResponse {
73    /// Network identifier (e.g., `"mainnet"`, `"testnet11"`).
74    pub network_id: String,
75    /// 32-byte genesis challenge.
76    pub genesis_challenge: HashHex,
77    /// Seconds since genesis.
78    pub chain_age_seconds: u64,
79}
80
81/// `healthz` — liveness probe.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct HealthzRequest;
84
85impl HealthzRequest {
86    /// The wire method name.
87    pub const METHOD: &'static str = "healthz";
88}
89
90/// Response for [`HealthzRequest`].
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct HealthzResponse {
93    /// Whether the node reports itself healthy.
94    pub ok: bool,
95}
96
97// ===========================================================================
98// Blocks
99// ===========================================================================
100
101/// `get_block` — fetch a full block by hash.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct GetBlockRequest {
104    /// Block hash.
105    pub hash: HashHex,
106}
107
108impl GetBlockRequest {
109    /// The wire method name.
110    pub const METHOD: &'static str = "get_block";
111}
112
113/// Response for [`GetBlockRequest`]. `None` means not found.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct GetBlockResponse {
116    /// The block, or `None` if not present.
117    pub block: Option<BlockFull>,
118}
119
120/// `get_block_by_height` — fetch a full canonical block by height.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct GetBlockByHeightRequest {
123    /// Canonical block height.
124    pub height: u64,
125}
126
127impl GetBlockByHeightRequest {
128    /// The wire method name.
129    pub const METHOD: &'static str = "get_block_by_height";
130}
131
132/// Response for [`GetBlockByHeightRequest`]. `None` means out-of-range.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct GetBlockByHeightResponse {
135    /// The block, or `None` if out-of-range / not canonical.
136    pub block: Option<BlockFull>,
137}
138
139/// `get_block_records` — compact summaries of a contiguous height range.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct GetBlockRecordsRequest {
142    /// First height (inclusive).
143    pub start_height: u64,
144    /// Number of records to return. Server caps at 1000.
145    pub count: u32,
146}
147
148impl GetBlockRecordsRequest {
149    /// The wire method name.
150    pub const METHOD: &'static str = "get_block_records";
151}
152
153/// Response for [`GetBlockRecordsRequest`]. `records.len()` may be less
154/// than `count` at the chain tip.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct GetBlockRecordsResponse {
157    /// Block summaries in ascending height order.
158    pub records: Vec<BlockSummary>,
159}
160
161/// Full block envelope used by `get_block*` responses.
162///
163/// The header is JSON-native; the body is hex-encoded Chia-streamable
164/// bytes. Consumers that need to decode the body must route through
165/// `dig-block`'s streamable decoder.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct BlockFull {
168    /// Parsed block header (JSON-native).
169    pub header: BlockHeaderWire,
170    /// Hex-encoded chia-streamable body bytes.
171    pub body_hex: String,
172    /// Whether this block is on the canonical chain.
173    pub canonical: bool,
174}
175
176/// Block header in wire form.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct BlockHeaderWire {
179    /// L2 block height.
180    pub height: u64,
181    /// Canonical hash of this header.
182    pub hash: HashHex,
183    /// Parent block hash.
184    pub parent_hash: HashHex,
185    /// Unix timestamp (seconds).
186    pub timestamp: u64,
187    /// Proposer's BLS public key.
188    pub proposer: PubkeyHex,
189    /// Coin-state SMT root at the end of this block.
190    pub state_root: HashHex,
191    /// Transaction-receipts root.
192    pub receipts_root: HashHex,
193    /// Cumulative attestation weight.
194    pub weight: u64,
195    /// Chia-style cumulative iteration count (128 bits).
196    pub total_iters: u128,
197    /// Proposer's signature over the header's canonical preimage.
198    pub signature: SignatureHex,
199}
200
201// ===========================================================================
202// Coins
203// ===========================================================================
204
205/// `get_coin_record` — fetch a single coin record by coin id.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct GetCoinRecordRequest {
208    /// Coin id (32-byte hash of `parent_coin || puzzle_hash || amount`).
209    pub coin_id: HashHex,
210}
211
212impl GetCoinRecordRequest {
213    /// The wire method name.
214    pub const METHOD: &'static str = "get_coin_record";
215}
216
217/// Response for [`GetCoinRecordRequest`]. `None` means not found.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct GetCoinRecordResponse {
220    /// The coin record, or `None` if not present.
221    pub record: Option<CoinRecordWire>,
222}
223
224/// `get_coin_records_by_hint` — look up coins by CLVM hint.
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct GetCoinRecordsByHintRequest {
227    /// 32-byte hint value.
228    pub hint: HashHex,
229    /// Include already-spent coins?
230    pub include_spent_coins: bool,
231    /// Confirmed-height lower bound.
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub start_height: Option<u64>,
234    /// Confirmed-height upper bound.
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub end_height: Option<u64>,
237}
238
239impl GetCoinRecordsByHintRequest {
240    /// The wire method name.
241    pub const METHOD: &'static str = "get_coin_records_by_hint";
242}
243
244/// Response for [`GetCoinRecordsByHintRequest`].
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct GetCoinRecordsByHintResponse {
247    /// Coin records matching the filter.
248    pub records: Vec<CoinRecordWire>,
249}
250
251/// `get_coin_records_by_puzzle_hash` — look up coins owned by a puzzle.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct GetCoinRecordsByPuzzleHashRequest {
254    /// 32-byte puzzle hash (address target).
255    pub puzzle_hash: HashHex,
256    /// Include already-spent coins?
257    pub include_spent_coins: bool,
258    /// Confirmed-height lower bound.
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub start_height: Option<u64>,
261    /// Confirmed-height upper bound.
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub end_height: Option<u64>,
264}
265
266impl GetCoinRecordsByPuzzleHashRequest {
267    /// The wire method name.
268    pub const METHOD: &'static str = "get_coin_records_by_puzzle_hash";
269}
270
271/// Response for [`GetCoinRecordsByPuzzleHashRequest`].
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct GetCoinRecordsByPuzzleHashResponse {
274    /// Coin records matching the filter.
275    pub records: Vec<CoinRecordWire>,
276}
277
278/// Coin record in wire form.
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct CoinRecordWire {
281    /// Coin id (derived hash).
282    pub coin_id: HashHex,
283    /// Parent coin id.
284    pub parent_coin_info: HashHex,
285    /// Puzzle hash (address target).
286    pub puzzle_hash: HashHex,
287    /// Amount in mojos.
288    pub amount: Amount,
289    /// Height at which this coin was created.
290    pub confirmed_block_height: u64,
291    /// Height at which this coin was spent; `0` if unspent.
292    pub spent_block_height: u64,
293    /// True iff this is a coinbase (reward) coin.
294    pub coinbase: bool,
295    /// Timestamp from the confirming block.
296    pub timestamp: u64,
297}
298
299// ===========================================================================
300// Mempool
301// ===========================================================================
302
303/// `get_mempool` — summary of pending transactions.
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct GetMempoolRequest;
306
307impl GetMempoolRequest {
308    /// The wire method name.
309    pub const METHOD: &'static str = "get_mempool";
310}
311
312/// Response for [`GetMempoolRequest`].
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct GetMempoolResponse {
315    /// Sum of CLVM cost across all items.
316    pub total_cost: u64,
317    /// Sum of fees across all items.
318    pub total_fees: Amount,
319    /// Summary entries, in eviction-priority order.
320    pub items: Vec<MempoolItem>,
321}
322
323/// One entry in the mempool summary.
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct MempoolItem {
326    /// Spend-bundle identifier (hash).
327    pub spend_bundle_name: HashHex,
328    /// CLVM cost for this bundle.
329    pub cost: u64,
330    /// Fee paid by this bundle, in mojos.
331    pub fee: Amount,
332    /// Whether this bundle is a CPFP ancestor / descendant.
333    pub is_cpfp: bool,
334}
335
336/// `push_tx` — submit a spend bundle to the mempool.
337#[derive(Debug, Clone, Serialize, Deserialize)]
338pub struct PushTxRequest {
339    /// Hex-encoded chia-streamable spend bundle.
340    pub spend_bundle_hex: String,
341}
342
343impl PushTxRequest {
344    /// The wire method name.
345    pub const METHOD: &'static str = "push_tx";
346}
347
348/// Response for [`PushTxRequest`].
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct PushTxResponse {
351    /// Admission outcome.
352    pub status: PushTxStatus,
353    /// Optional details (reason code / error message).
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub details: Option<String>,
356}
357
358/// Admission outcome of [`PushTxRequest`].
359#[non_exhaustive]
360#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
361#[serde(rename_all = "snake_case")]
362pub enum PushTxStatus {
363    /// Admitted to the mempool.
364    Success,
365    /// Rejected; see `details`.
366    Rejected,
367    /// Already present in the mempool.
368    AlreadyExists,
369}
370
371// ===========================================================================
372// Peers
373// ===========================================================================
374
375/// `get_connections` — enumerate connected peers.
376#[derive(Debug, Clone, Serialize, Deserialize)]
377pub struct GetConnectionsRequest;
378
379impl GetConnectionsRequest {
380    /// The wire method name.
381    pub const METHOD: &'static str = "get_connections";
382}
383
384/// Response for [`GetConnectionsRequest`].
385#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct GetConnectionsResponse {
387    /// Connected peers.
388    pub peers: Vec<PeerInfoWire>,
389}
390
391/// Peer info in wire form.
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct PeerInfoWire {
394    /// Peer id — SHA256 of remote cert pubkey.
395    pub peer_id: HashHex,
396    /// Peer IP address + port.
397    pub remote_addr: String,
398    /// Peer node type (e.g., `"full_node"`, `"validator"`).
399    pub node_type: String,
400    /// Unix epoch seconds when the connection was established.
401    pub connected_since: u64,
402    /// Accumulated penalty points for abuse.
403    pub penalty: u32,
404}
405
406/// `ban_peer` — evict and ban a peer.
407#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct BanPeerRequest {
409    /// Peer id to ban.
410    pub peer_id: HashHex,
411    /// Reason string (stored in the audit log).
412    pub reason: String,
413    /// Ban duration in seconds; `0` = indefinite.
414    pub duration_secs: u64,
415}
416
417impl BanPeerRequest {
418    /// The wire method name.
419    pub const METHOD: &'static str = "ban_peer";
420}
421
422/// Response for [`BanPeerRequest`].
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct BanPeerResponse {
425    /// Whether the peer is now banned.
426    pub banned: bool,
427}
428
429// ===========================================================================
430// Checkpoint
431// ===========================================================================
432
433/// `submit_partial_checkpoint_signature` — validator submits a partial sig
434/// for the current epoch.
435#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct SubmitPartialCheckpointSignatureRequest {
437    /// Epoch the signature is for.
438    pub epoch: u64,
439    /// Signer's validator index in the VMR.
440    pub validator_index: u32,
441    /// BLS partial signature over the checkpoint digest.
442    pub partial_sig: SignatureHex,
443}
444
445impl SubmitPartialCheckpointSignatureRequest {
446    /// The wire method name.
447    pub const METHOD: &'static str = "submit_partial_checkpoint_signature";
448}
449
450/// Response for [`SubmitPartialCheckpointSignatureRequest`].
451#[derive(Debug, Clone, Serialize, Deserialize)]
452pub struct SubmitPartialCheckpointSignatureResponse {
453    /// Whether the signature was admitted to the pool.
454    pub accepted: bool,
455    /// Optional rejection reason.
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub reason: Option<String>,
458}
459
460/// `get_checkpoint_pool` — status of the checkpoint aggregation pool.
461#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct GetCheckpointPoolRequest {
463    /// Specific epoch to inspect. `None` returns all open epochs.
464    #[serde(skip_serializing_if = "Option::is_none")]
465    pub epoch: Option<u64>,
466}
467
468impl GetCheckpointPoolRequest {
469    /// The wire method name.
470    pub const METHOD: &'static str = "get_checkpoint_pool";
471}
472
473/// Response for [`GetCheckpointPoolRequest`].
474#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct GetCheckpointPoolResponse {
476    /// Per-epoch status entries.
477    pub epochs: Vec<CheckpointEpochStatus>,
478}
479
480/// Per-epoch aggregation status.
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct CheckpointEpochStatus {
483    /// Epoch number.
484    pub epoch: u64,
485    /// Number of partials received.
486    pub partials_count: u32,
487    /// Threshold required (`2k + 1` where `n = validator_count`).
488    pub threshold: u32,
489    /// Whether the aggregation succeeded.
490    pub aggregated: bool,
491}
492
493// ===========================================================================
494// Validator set
495// ===========================================================================
496
497/// `get_validator` — look up a validator by pubkey.
498#[derive(Debug, Clone, Serialize, Deserialize)]
499pub struct GetValidatorRequest {
500    /// Validator's BLS pubkey.
501    pub pubkey: PubkeyHex,
502}
503
504impl GetValidatorRequest {
505    /// The wire method name.
506    pub const METHOD: &'static str = "get_validator";
507}
508
509/// Response for [`GetValidatorRequest`]. `None` if no such validator.
510#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct GetValidatorResponse {
512    /// Validator summary, or `None` if not found.
513    pub validator: Option<ValidatorSummary>,
514}
515
516/// `get_active_validators` — page through the current active set.
517#[derive(Debug, Clone, Serialize, Deserialize)]
518pub struct GetActiveValidatorsRequest {
519    /// Maximum results. Server caps at 1000.
520    pub limit: u32,
521    /// Results to skip.
522    pub offset: u32,
523}
524
525impl GetActiveValidatorsRequest {
526    /// The wire method name.
527    pub const METHOD: &'static str = "get_active_validators";
528}
529
530/// Response for [`GetActiveValidatorsRequest`].
531#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct GetActiveValidatorsResponse {
533    /// Active validators in this page.
534    pub validators: Vec<ValidatorSummary>,
535    /// Total active validators across the full set.
536    pub total: u32,
537}
538
539/// `get_current_proposer` — who is elected proposer at a height.
540#[derive(Debug, Clone, Serialize, Deserialize)]
541pub struct GetCurrentProposerRequest {
542    /// The L2 height to query.
543    pub height: u64,
544}
545
546impl GetCurrentProposerRequest {
547    /// The wire method name.
548    pub const METHOD: &'static str = "get_current_proposer";
549}
550
551/// Response for [`GetCurrentProposerRequest`].
552#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct GetCurrentProposerResponse {
554    /// Elected proposer, or `None` if the height is out of range.
555    pub proposer: Option<ValidatorSummary>,
556}
557
558// ===========================================================================
559// Admin
560// ===========================================================================
561
562/// `stop_node` — request a graceful shutdown.
563#[derive(Debug, Clone, Serialize, Deserialize)]
564pub struct StopNodeRequest {
565    /// Optional reason (written to audit log).
566    #[serde(skip_serializing_if = "Option::is_none")]
567    pub reason: Option<String>,
568}
569
570impl StopNodeRequest {
571    /// The wire method name.
572    pub const METHOD: &'static str = "stop_node";
573}
574
575/// Response for [`StopNodeRequest`].
576#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct StopNodeResponse {
578    /// Whether shutdown has been initiated.
579    pub accepted: bool,
580}
581
582/// `get_recovery_status` — fullnode recovery-journal state.
583#[derive(Debug, Clone, Serialize, Deserialize)]
584pub struct GetRecoveryStatusRequest;
585
586impl GetRecoveryStatusRequest {
587    /// The wire method name.
588    pub const METHOD: &'static str = "get_recovery_status";
589}
590
591/// Response for [`GetRecoveryStatusRequest`].
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct GetRecoveryStatusResponse {
594    /// Current recovery mode.
595    pub mode: RecoveryMode,
596    /// Last anomaly observed, if any.
597    #[serde(skip_serializing_if = "Option::is_none")]
598    pub last_anomaly: Option<String>,
599    /// Number of recovery attempts in the current epoch.
600    pub attempts: u32,
601}
602
603/// Recovery state machine values.
604#[non_exhaustive]
605#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
606#[serde(rename_all = "snake_case")]
607pub enum RecoveryMode {
608    /// Normal operation.
609    Running,
610    /// Rolling back after an anomaly.
611    Recovering,
612    /// Exceeded the max recovery attempts per epoch.
613    LoopBreakerOpen,
614}
615
616/// `get_version` — binary identification.
617#[derive(Debug, Clone, Serialize, Deserialize)]
618pub struct GetVersionRequest;
619
620impl GetVersionRequest {
621    /// The wire method name.
622    pub const METHOD: &'static str = "get_version";
623}
624
625/// Response for [`GetVersionRequest`].
626#[derive(Debug, Clone, Serialize, Deserialize)]
627pub struct GetVersionResponse {
628    /// Human-readable version string (`"1.2.3-pre"`).
629    pub version: String,
630    /// Build commit SHA.
631    pub build_commit: String,
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637
638    /// **Proves:** every request struct's `METHOD` constant is
639    /// lower-snake-case — no hyphens, no camelCase leaks.
640    ///
641    /// **Why it matters:** JSON-RPC method names are case-sensitive. A
642    /// regression that introduced PascalCase or kebab-case for one method
643    /// would break that single method on every deployed client.
644    ///
645    /// **Catches:** an accidental `pub const METHOD: &str = "GetBlock";`
646    /// that slipped through review.
647    #[test]
648    fn method_names_are_snake_case() {
649        let method_names = [
650            GetBlockchainStateRequest::METHOD,
651            GetNetworkInfoRequest::METHOD,
652            HealthzRequest::METHOD,
653            GetBlockRequest::METHOD,
654            GetBlockByHeightRequest::METHOD,
655            GetBlockRecordsRequest::METHOD,
656            GetCoinRecordRequest::METHOD,
657            GetCoinRecordsByHintRequest::METHOD,
658            GetCoinRecordsByPuzzleHashRequest::METHOD,
659            GetMempoolRequest::METHOD,
660            PushTxRequest::METHOD,
661            GetConnectionsRequest::METHOD,
662            BanPeerRequest::METHOD,
663            SubmitPartialCheckpointSignatureRequest::METHOD,
664            GetCheckpointPoolRequest::METHOD,
665            GetValidatorRequest::METHOD,
666            GetActiveValidatorsRequest::METHOD,
667            GetCurrentProposerRequest::METHOD,
668            StopNodeRequest::METHOD,
669            GetRecoveryStatusRequest::METHOD,
670            GetVersionRequest::METHOD,
671        ];
672
673        for m in method_names {
674            assert!(
675                m.chars()
676                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
677                "method name {m:?} is not snake_case",
678            );
679            assert!(!m.is_empty(), "empty method name");
680            assert!(!m.starts_with('_'), "method {m:?} starts with underscore");
681        }
682    }
683
684    /// **Proves:** all method names are pairwise distinct.
685    ///
686    /// **Why it matters:** Two different request types cannot share a
687    /// method name — the server's dispatcher would have no way to tell
688    /// them apart. Catching collisions at test time prevents a whole class
689    /// of integration bugs.
690    ///
691    /// **Catches:** copy-paste of a method name without updating it;
692    /// shadowing a fullnode method with a same-named validator method
693    /// (they're namespaced in code but collide on the wire).
694    #[test]
695    fn method_names_are_unique() {
696        let method_names = [
697            GetBlockchainStateRequest::METHOD,
698            GetNetworkInfoRequest::METHOD,
699            HealthzRequest::METHOD,
700            GetBlockRequest::METHOD,
701            GetBlockByHeightRequest::METHOD,
702            GetBlockRecordsRequest::METHOD,
703            GetCoinRecordRequest::METHOD,
704            GetCoinRecordsByHintRequest::METHOD,
705            GetCoinRecordsByPuzzleHashRequest::METHOD,
706            GetMempoolRequest::METHOD,
707            PushTxRequest::METHOD,
708            GetConnectionsRequest::METHOD,
709            BanPeerRequest::METHOD,
710            SubmitPartialCheckpointSignatureRequest::METHOD,
711            GetCheckpointPoolRequest::METHOD,
712            GetValidatorRequest::METHOD,
713            GetActiveValidatorsRequest::METHOD,
714            GetCurrentProposerRequest::METHOD,
715            StopNodeRequest::METHOD,
716            GetRecoveryStatusRequest::METHOD,
717            GetVersionRequest::METHOD,
718        ];
719
720        let mut seen = std::collections::HashSet::new();
721        for m in method_names {
722            assert!(seen.insert(m), "duplicate method name {m:?}");
723        }
724    }
725
726    /// **Proves:** `GetBlockchainStateResponse` round-trips through JSON
727    /// unchanged.
728    ///
729    /// **Why it matters:** This is the most-called RPC method — smoke test
730    /// that the full struct decodes cleanly.
731    ///
732    /// **Catches:** dropping a field; re-typing `sync_progress` from `f32`
733    /// to `f64` (JSON numbers are all f64 in JS; this is fine but the test
734    /// pins the Rust-side type).
735    #[test]
736    fn get_blockchain_state_roundtrip() {
737        let r = GetBlockchainStateResponse {
738            height: 100,
739            tip_hash: HashHex::new([1u8; 32]),
740            synced: true,
741            sync_progress: 1.0,
742            finalized_epoch: 3,
743            sealed_height: 96,
744        };
745        let j = serde_json::to_string(&r).unwrap();
746        let back: GetBlockchainStateResponse = serde_json::from_str(&j).unwrap();
747        assert_eq!(back.height, r.height);
748        assert_eq!(back.tip_hash, r.tip_hash);
749        assert_eq!(back.synced, r.synced);
750    }
751
752    /// **Proves:** `PushTxStatus` serializes in snake_case
753    /// (`"success"`, `"rejected"`, `"already_exists"`).
754    ///
755    /// **Why it matters:** Clients pattern-match on these strings. The
756    /// already-declared `#[serde(rename_all = "snake_case")]` is what makes
757    /// them stable.
758    ///
759    /// **Catches:** a regression that drops the rename attribute, or a
760    /// typo in a variant name (`AlreadyExist` vs `AlreadyExists`).
761    #[test]
762    fn push_tx_status_snake_case() {
763        let s = serde_json::to_string(&PushTxStatus::Success).unwrap();
764        assert_eq!(s, "\"success\"");
765
766        let s = serde_json::to_string(&PushTxStatus::AlreadyExists).unwrap();
767        assert_eq!(s, "\"already_exists\"");
768    }
769
770    /// **Proves:** a request with optional fields omitted encodes without
771    /// the `null` sentinel (because of `skip_serializing_if`).
772    ///
773    /// **Why it matters:** JSON-RPC clients vary in how they interpret
774    /// `null` vs absent fields. We consistently omit absent optionals to
775    /// match Chia's own RPC behaviour.
776    ///
777    /// **Catches:** dropping `skip_serializing_if = "Option::is_none"`
778    /// from an optional field — the wire form would gain `"field":null`
779    /// and break deduplication of requests that rely on canonical form.
780    #[test]
781    fn optional_fields_omitted_when_none() {
782        let req = GetCoinRecordsByHintRequest {
783            hint: HashHex::new([0u8; 32]),
784            include_spent_coins: false,
785            start_height: None,
786            end_height: None,
787        };
788        let s = serde_json::to_string(&req).unwrap();
789        assert!(
790            !s.contains("start_height"),
791            "start_height should be omitted, got: {s}"
792        );
793        assert!(
794            !s.contains("end_height"),
795            "end_height should be omitted, got: {s}"
796        );
797    }
798}