Skip to main content

dig_rpc_protocol/
method.rs

1//! The canonical DIG-node RPC method catalogue.
2//!
3//! [`Method`] enumerates every JSON-RPC method the DIG node surface exposes, its
4//! stable wire name, its primary [`Tier`], and whether it is reachable over the
5//! mTLS peer surface. This is the single method table the OpenRPC generator,
6//! both node dispatchers, and the server's allow-list all read — so the name set,
7//! the tier map, and the peer allowlist can never drift from one another.
8//!
9//! Frame families that are shape-dispatched rather than carried in a JSON-RPC
10//! `method` field — the DHT (`find_node` / `find_providers` / `add_provider` /
11//! `ping`) and PEX (`pex_handshake` / `pex_snapshot` / `pex_delta` /
12//! `pex_error`) wires — are documented in [`crate::frames`] and are not
13//! `Method` variants.
14
15use crate::tier::Tier;
16
17/// Generates [`Method`] and [`Method::ALL`] from ONE variant table.
18///
19/// This exists so a new method cannot enter the enum without also entering
20/// `ALL`: the two used to be two hand-written lists (the enum body and a
21/// literal `&[Method::...]` array), and a variant added to one while
22/// forgotten in the other left it invisible to every `ALL`-driven guard in
23/// this module -- including the one enforcing dig_ecosystem#3261 ("no
24/// `*Reward*` method may ever be peer-reachable or non-`Control`"). With one
25/// source table, that omission is unwritable rather than merely testable
26/// (dig_ecosystem#3317).
27///
28/// `name()`, `tier()`, `is_peer_reachable()` and `is_reward()` stay
29/// hand-written exhaustive matches below -- they are already compiler-forced
30/// (adding a variant is `E0004` until every one of them is updated), and
31/// generating them too would hide the method table behind macro grammar for
32/// no completeness gain.
33macro_rules! method_catalogue {
34    ($( $(#[$doc:meta])* $variant:ident ),+ $(,)?) => {
35        /// A DIG-node RPC method.
36        ///
37        /// `#[non_exhaustive]` so adding a method in a minor release is additive.
38        /// Convert to/from the wire name with [`Method::name`] / [`Method::from_name`].
39        #[non_exhaustive]
40        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41        pub enum Method {
42            $( $(#[$doc])* $variant ),+
43        }
44
45        impl Method {
46            /// Every method, in catalogue order. Generated by
47            /// `method_catalogue!` from the same table that defines the enum, so
48            /// a variant cannot exist without also being listed here.
49            pub const ALL: &'static [Method] = &[ $( Method::$variant ),+ ];
50        }
51    };
52}
53
54method_catalogue! {
55    // ---- PublicRead ----
56    /// `dig.getContent` — a verified resource-window read.
57    GetContent,
58    /// `dig.getCapsule` — the whole `.dig` module for `(store, root)`.
59    GetCapsule,
60    /// `dig.getModule` — alias of [`GetCapsule`](Method::GetCapsule).
61    GetModule,
62    /// `dig.getManifest` — the public discovery manifest resource.
63    GetManifest,
64    /// `dig.getMetadata` — the plaintext metadata manifest (never encrypted).
65    GetMetadata,
66    /// `dig.listCapsules` — the confirmed capsule list (discovery metadata).
67    ListCapsules,
68    /// `dig.getProof` — the real inclusion proof + execution-proof status.
69    GetProof,
70    /// `dig.getProofStatus` — poll a real execution-proof job by id.
71    GetProofStatus,
72    /// `dig.getAnchoredRoot` — resolve a store's chain-anchored tip root.
73    /// (Also peer-reachable.)
74    GetAnchoredRoot,
75    /// `dig.getCollection` — collection-level facts for a set of NFT launchers.
76    /// (Also peer-reachable.)
77    GetCollection,
78    /// `dig.listCollectionItems` — a page of resolved collection items.
79    /// (Also peer-reachable.)
80    ListCollectionItems,
81    /// `dig.health` — liveness + capability summary.
82    Health,
83    /// `dig.methods` — the implemented method names (agent self-describe).
84    Methods,
85
86    // ---- Peer ----
87    /// `dig.getNetworkInfo` — this node's peer-network posture.
88    GetNetworkInfo,
89    /// `dig.getPeers` — the peers this node knows (peer exchange).
90    GetPeers,
91    /// `dig.announce` — accept a peer announcement.
92    Announce,
93    /// `dig.getAvailability` — batch presence check across stores/roots/capsules.
94    GetAvailability,
95    /// `dig.listInventory` — enumerate what this node serves.
96    ListInventory,
97    /// `dig.fetchRange` — a single verified range frame of a resource.
98    FetchRange,
99    /// `dig.getModuleInfo` — the whole-`.dig`-module transfer descriptor
100    /// (total size + module hash + per-chunk hashes) for `(store, root)`, the
101    /// handshake a peer reads before range-pulling the module blob.
102    GetModuleInfo,
103    /// `dig.fetchModuleRange` — a single range frame of the whole `.dig` module
104    /// blob for `(store, root)` (reuses [`RangeFrame`](crate::types::RangeFrame)).
105    FetchModuleRange,
106
107    // ---- Control (loopback / in-process only) ----
108    /// `dig.stage` — compile a local folder into a capsule `.dig` in-process.
109    Stage,
110    /// `cache.getConfig` — the local-cache config.
111    CacheGetConfig,
112    /// `cache.setCapBytes` — set the cache size cap.
113    CacheSetCapBytes,
114    /// `cache.clear` — clear the cache.
115    CacheClear,
116    /// `cache.listCached` — the durable module inventory.
117    CacheListCached,
118    /// `cache.removeCached` — remove one cached capsule.
119    CacheRemoveCached,
120    /// `cache.fetchAndCache` — fetch + cache one capsule.
121    CacheFetchAndCache,
122    /// `cache.stats` — cache telemetry: reserved cap + live usage, cached-capsule
123    /// count + total on-disk bytes, session eviction + content-cache hit/miss.
124    CacheStats,
125    /// `control.peerStatus` — the peer-network status snapshot.
126    ControlPeerStatus,
127    /// `control.subscribe` — subscribe the node to a store (persisted): it
128    /// actively watches + gap-fills that store.
129    ControlSubscribe,
130    /// `control.unsubscribe` — remove a store from the node's subscription set.
131    ControlUnsubscribe,
132    /// `control.listSubscriptions` — the node's persisted store subscriptions.
133    ControlListSubscriptions,
134    /// `control.peers.connect` — dial a peer (turn a discovered peer into a
135    /// counted, RPC-reachable connected peer).
136    ControlPeersConnect,
137    /// `control.peers.disconnect` — drop a pooled peer by `peer_id`, closing its
138    /// mTLS link (the inverse of `control.peers.connect`).
139    ControlPeersDisconnect,
140    /// `dig.listRewardDistributors` — the reward distributors this node funds,
141    /// and the ones it has a claim to as a mirror (dig-rewards-coin SPEC §2.6).
142    ListRewardDistributors,
143    /// `dig.getRewardProverStatus` — the always-on reward prover loop's status
144    /// for one or every distributor this node runs (dig-rewards-coin SPEC §2.3).
145    GetRewardProverStatus,
146    /// `dig.getRewardDistributor` — one reward distributor's chain-derived
147    /// state (dig-rewards-coin SPEC §2.6).
148    GetRewardDistributor,
149    /// `dig.listRewardDistributorCommitments` — one distributor's per-epoch
150    /// clawback commitment slots, with the recoverable amount computed per
151    /// slot (dig-rewards-coin SPEC §7.4 clause 5, §7.5).
152    ListRewardDistributorCommitments,
153    /// `dig.getPayeeRewardClaimStatus` — this node's own claim-side posture as a
154    /// **payee** (dig-rewards-coin SPEC §12.5 clause 6, §2.4).
155    GetPayeeRewardClaimStatus,
156    /// `rpc.discover` — the OpenRPC self-describe document.
157    RpcDiscover,
158}
159
160impl Method {
161    // ---- hand-written exhaustive classifiers (compiler-forced, not generated) ----
162
163    /// The stable JSON-RPC wire name.
164    pub const fn name(self) -> &'static str {
165        match self {
166            Method::GetContent => "dig.getContent",
167            Method::GetCapsule => "dig.getCapsule",
168            Method::GetModule => "dig.getModule",
169            Method::GetManifest => "dig.getManifest",
170            Method::GetMetadata => "dig.getMetadata",
171            Method::ListCapsules => "dig.listCapsules",
172            Method::GetProof => "dig.getProof",
173            Method::GetProofStatus => "dig.getProofStatus",
174            Method::GetAnchoredRoot => "dig.getAnchoredRoot",
175            Method::GetCollection => "dig.getCollection",
176            Method::ListCollectionItems => "dig.listCollectionItems",
177            Method::Health => "dig.health",
178            Method::Methods => "dig.methods",
179            Method::GetNetworkInfo => "dig.getNetworkInfo",
180            Method::GetPeers => "dig.getPeers",
181            Method::Announce => "dig.announce",
182            Method::GetAvailability => "dig.getAvailability",
183            Method::ListInventory => "dig.listInventory",
184            Method::FetchRange => "dig.fetchRange",
185            Method::GetModuleInfo => "dig.getModuleInfo",
186            Method::FetchModuleRange => "dig.fetchModuleRange",
187            Method::Stage => "dig.stage",
188            Method::CacheGetConfig => "cache.getConfig",
189            Method::CacheSetCapBytes => "cache.setCapBytes",
190            Method::CacheClear => "cache.clear",
191            Method::CacheListCached => "cache.listCached",
192            Method::CacheRemoveCached => "cache.removeCached",
193            Method::CacheFetchAndCache => "cache.fetchAndCache",
194            Method::CacheStats => "cache.stats",
195            Method::ControlPeerStatus => "control.peerStatus",
196            Method::ControlSubscribe => "control.subscribe",
197            Method::ControlUnsubscribe => "control.unsubscribe",
198            Method::ControlListSubscriptions => "control.listSubscriptions",
199            Method::ControlPeersConnect => "control.peers.connect",
200            Method::ControlPeersDisconnect => "control.peers.disconnect",
201            Method::ListRewardDistributors => "dig.listRewardDistributors",
202            Method::GetRewardProverStatus => "dig.getRewardProverStatus",
203            Method::GetRewardDistributor => "dig.getRewardDistributor",
204            Method::ListRewardDistributorCommitments => "dig.listRewardDistributorCommitments",
205            Method::GetPayeeRewardClaimStatus => "dig.getPayeeRewardClaimStatus",
206            Method::RpcDiscover => "rpc.discover",
207        }
208    }
209
210    /// Parse a wire name into a [`Method`], or `None` for an unknown method
211    /// (the caller answers `-32601`).
212    pub fn from_name(name: &str) -> Option<Method> {
213        Method::ALL.iter().copied().find(|m| m.name() == name)
214    }
215
216    /// The method's PRIMARY access [`Tier`].
217    ///
218    /// Note: a `PublicRead` method may still be peer-reachable — see
219    /// [`is_peer_reachable`](Method::is_peer_reachable). The tier is the
220    /// method's canonical home, the allowlist is the security boundary.
221    pub const fn tier(self) -> Tier {
222        match self {
223            Method::GetContent
224            | Method::GetCapsule
225            | Method::GetModule
226            | Method::GetManifest
227            | Method::GetMetadata
228            | Method::ListCapsules
229            | Method::GetProof
230            | Method::GetProofStatus
231            | Method::GetAnchoredRoot
232            | Method::GetCollection
233            | Method::ListCollectionItems
234            | Method::Health
235            | Method::Methods => Tier::PublicRead,
236
237            Method::GetNetworkInfo
238            | Method::GetPeers
239            | Method::Announce
240            | Method::GetAvailability
241            | Method::ListInventory
242            | Method::FetchRange
243            | Method::GetModuleInfo
244            | Method::FetchModuleRange => Tier::Peer,
245
246            Method::Stage
247            | Method::CacheGetConfig
248            | Method::CacheSetCapBytes
249            | Method::CacheClear
250            | Method::CacheListCached
251            | Method::CacheRemoveCached
252            | Method::CacheFetchAndCache
253            | Method::CacheStats
254            | Method::ControlPeerStatus
255            | Method::ControlSubscribe
256            | Method::ControlUnsubscribe
257            | Method::ControlListSubscriptions
258            | Method::ControlPeersConnect
259            | Method::ControlPeersDisconnect
260            | Method::ListRewardDistributors
261            | Method::GetRewardProverStatus
262            | Method::GetRewardDistributor
263            | Method::ListRewardDistributorCommitments
264            | Method::GetPayeeRewardClaimStatus
265            | Method::RpcDiscover => Tier::Control,
266        }
267    }
268
269    /// Whether this method is reachable over the mTLS **peer** surface.
270    ///
271    /// This mirrors the canonical node's `is_peer_reachable_method` byte-for-byte
272    /// (digstore `dig-node` `peer.rs`). It is an ALLOWLIST: adding a method here
273    /// is a deliberate security decision — it exposes the method to any remote
274    /// peer (the peer verifier authenticates a `peer_id`, it does NOT authorize).
275    /// Management/mutation methods (`cache.*`, `control.*`, `dig.stage`) are
276    /// NEVER peer-reachable.
277    pub const fn is_peer_reachable(self) -> bool {
278        matches!(
279            self,
280            Method::GetContent
281                | Method::GetNetworkInfo
282                | Method::GetPeers
283                | Method::Announce
284                | Method::GetAvailability
285                | Method::ListInventory
286                | Method::FetchRange
287                | Method::GetModuleInfo
288                | Method::FetchModuleRange
289                | Method::GetAnchoredRoot
290                | Method::GetCollection
291                | Method::ListCollectionItems
292        )
293    }
294
295    /// Whether this method answers on the **reward** surface — distributor,
296    /// prover-loop, or payee claim-side state (dig-rewards-coin SPEC §2.3, §2.6,
297    /// §7.4, §7.5, §12.5).
298    ///
299    /// Every method for which this is `true` MUST be `Tier::Control` and MUST
300    /// NOT be peer-reachable; the guard that enforces that drives itself from
301    /// here.
302    ///
303    /// # Why an exhaustive match and not a name filter
304    ///
305    /// The guard previously selected its subjects with
306    /// `name().contains("Reward")`, which is convention-bound: a reward method
307    /// named without that substring escapes the tier assertion silently, and
308    /// nothing tells the author of that method that it has. This match is
309    /// compiler-bound instead — a new variant fails to compile until someone
310    /// classifies it, and the answer they must give is the one the guard needs.
311    /// There is deliberately no `_` arm, for exactly that reason.
312    pub const fn is_reward(self) -> bool {
313        match self {
314            Method::ListRewardDistributors
315            | Method::GetRewardProverStatus
316            | Method::GetRewardDistributor
317            | Method::ListRewardDistributorCommitments
318            | Method::GetPayeeRewardClaimStatus => true,
319            Method::GetContent
320            | Method::GetCapsule
321            | Method::GetModule
322            | Method::GetManifest
323            | Method::GetMetadata
324            | Method::ListCapsules
325            | Method::GetProof
326            | Method::GetProofStatus
327            | Method::GetAnchoredRoot
328            | Method::GetCollection
329            | Method::ListCollectionItems
330            | Method::Health
331            | Method::Methods
332            | Method::GetNetworkInfo
333            | Method::GetPeers
334            | Method::Announce
335            | Method::GetAvailability
336            | Method::ListInventory
337            | Method::FetchRange
338            | Method::GetModuleInfo
339            | Method::FetchModuleRange
340            | Method::Stage
341            | Method::CacheGetConfig
342            | Method::CacheSetCapBytes
343            | Method::CacheClear
344            | Method::CacheListCached
345            | Method::CacheRemoveCached
346            | Method::CacheFetchAndCache
347            | Method::CacheStats
348            | Method::ControlPeerStatus
349            | Method::ControlSubscribe
350            | Method::ControlUnsubscribe
351            | Method::ControlListSubscriptions
352            | Method::ControlPeersConnect
353            | Method::ControlPeersDisconnect
354            | Method::RpcDiscover => false,
355        }
356    }
357
358    /// A one-line human summary (drives the OpenRPC method `summary`).
359    pub const fn summary(self) -> &'static str {
360        match self {
361            Method::GetContent => "Read a verified window of a resource's ciphertext.",
362            Method::GetCapsule => "Fetch the whole .dig module for (store, root).",
363            Method::GetModule => "Alias of dig.getCapsule.",
364            Method::GetManifest => "Fetch the public discovery manifest resource.",
365            Method::GetMetadata => "Fetch the plaintext metadata manifest.",
366            Method::ListCapsules => "List a store's confirmed capsules.",
367            Method::GetProof => "Get the real inclusion proof + execution-proof status.",
368            Method::GetProofStatus => "Poll a real execution-proof job by id.",
369            Method::GetAnchoredRoot => "Resolve a store's chain-anchored tip root.",
370            Method::GetCollection => "Get collection-level facts for NFT launcher ids.",
371            Method::ListCollectionItems => "List resolved NFT collection items (paginated).",
372            Method::Health => "Liveness and capability summary.",
373            Method::Methods => "List the method names this node implements.",
374            Method::GetNetworkInfo => "This node's peer-network posture.",
375            Method::GetPeers => "The peers this node currently knows.",
376            Method::Announce => "Accept a peer announcement (peer_id + addresses).",
377            Method::GetAvailability => "Batch-check whether this node holds items.",
378            Method::ListInventory => "Enumerate the stores / roots this node serves.",
379            Method::FetchRange => "Fetch a single verified range frame of a resource.",
380            Method::GetModuleInfo => {
381                "Get the whole-.dig-module transfer descriptor (size + hashes) for (store, root)."
382            }
383            Method::FetchModuleRange => "Fetch a single range frame of the whole .dig module blob.",
384            Method::Stage => "Compile a local folder into a capsule .dig in-process.",
385            Method::CacheGetConfig => "Get the local-cache configuration.",
386            Method::CacheSetCapBytes => "Set the local-cache size cap.",
387            Method::CacheClear => "Clear the local cache.",
388            Method::CacheListCached => "List the durable cached modules.",
389            Method::CacheRemoveCached => "Remove one cached capsule.",
390            Method::CacheFetchAndCache => "Fetch and cache one capsule.",
391            Method::CacheStats => {
392                "Cache telemetry: cap + usage, entry count + bytes, eviction + hit/miss counters."
393            }
394            Method::ControlPeerStatus => "Snapshot the node's peer network.",
395            Method::ControlSubscribe => {
396                "Subscribe the node to a store (persisted watch + gap-fill)."
397            }
398            Method::ControlUnsubscribe => "Unsubscribe the node from a store.",
399            Method::ControlListSubscriptions => "List the node's persisted store subscriptions.",
400            Method::ControlPeersConnect => "Dial a peer into the connected pool.",
401            Method::ControlPeersDisconnect => "Drop a pooled peer by peer_id.",
402            Method::ListRewardDistributors => {
403                "List the reward distributors this node funds or has a mirror claim to."
404            }
405            Method::GetRewardProverStatus => {
406                "Get the reward prover loop's status for one or every distributor."
407            }
408            Method::GetRewardDistributor => "Get one reward distributor's chain-derived state.",
409            Method::ListRewardDistributorCommitments => {
410                "List one reward distributor's per-epoch clawback commitment slots."
411            }
412            Method::GetPayeeRewardClaimStatus => {
413                "Report this node's own payee-side reward claim posture."
414            }
415            Method::RpcDiscover => "Return the OpenRPC self-describe document.",
416        }
417    }
418
419    /// The method names reachable over the peer surface, in catalogue order —
420    /// the exact allowlist a server hands its peer responder.
421    pub fn peer_reachable_names() -> Vec<&'static str> {
422        Method::ALL
423            .iter()
424            .copied()
425            .filter(|m| m.is_peer_reachable())
426            .map(Method::name)
427            .collect()
428    }
429}
430
431impl std::fmt::Display for Method {
432    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433        f.write_str(self.name())
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use std::collections::HashSet;
441
442    /// **Proves:** every method name round-trips through `from_name`.
443    /// **Catches:** a name typo or a variant left out of `ALL`.
444    #[test]
445    fn names_round_trip() {
446        for m in Method::ALL {
447            assert_eq!(Method::from_name(m.name()), Some(*m), "{}", m.name());
448        }
449        assert!(Method::from_name("dig.nope").is_none());
450    }
451
452    /// **Proves:** all wire names are unique.
453    /// **Catches:** two variants accidentally sharing a name.
454    #[test]
455    fn names_unique() {
456        let names: HashSet<&str> = Method::ALL.iter().map(|m| m.name()).collect();
457        assert_eq!(names.len(), Method::ALL.len());
458    }
459
460    /// **Proves:** the peer allowlist is EXACTLY the twelve methods the canonical
461    /// node exposes over mTLS — and no `cache.*`/`control.*`/`dig.stage` leaks
462    /// onto the peer surface.
463    /// **Catches:** a drift from digstore `is_peer_reachable_method`, or a
464    /// management method accidentally allowlisted (the audit #179 auth-bypass).
465    #[test]
466    fn peer_allowlist_matches_canonical() {
467        let mut got = Method::peer_reachable_names();
468        got.sort_unstable();
469        let mut want = vec![
470            "dig.getContent",
471            "dig.getNetworkInfo",
472            "dig.getPeers",
473            "dig.announce",
474            "dig.getAvailability",
475            "dig.listInventory",
476            "dig.fetchRange",
477            "dig.getModuleInfo",
478            "dig.fetchModuleRange",
479            "dig.getAnchoredRoot",
480            "dig.getCollection",
481            "dig.listCollectionItems",
482        ];
483        want.sort_unstable();
484        assert_eq!(got, want);
485
486        // No management/mutation method is peer-reachable.
487        for m in [
488            Method::Stage,
489            Method::CacheGetConfig,
490            Method::CacheSetCapBytes,
491            Method::CacheClear,
492            Method::CacheListCached,
493            Method::CacheRemoveCached,
494            Method::CacheFetchAndCache,
495            Method::CacheStats,
496            Method::ControlPeerStatus,
497            Method::ControlSubscribe,
498            Method::ControlUnsubscribe,
499            Method::ControlListSubscriptions,
500            Method::ControlPeersConnect,
501            Method::ControlPeersDisconnect,
502            Method::RpcDiscover,
503        ] {
504            assert!(
505                !m.is_peer_reachable(),
506                "{} must NOT be peer-reachable",
507                m.name()
508            );
509        }
510    }
511
512    /// **Proves:** the chain-anchored public reads are PublicRead yet ALSO
513    /// peer-reachable (the design-brief tier decision §5).
514    #[test]
515    fn anchored_reads_public_but_peer_reachable() {
516        for m in [
517            Method::GetAnchoredRoot,
518            Method::GetCollection,
519            Method::ListCollectionItems,
520        ] {
521            assert_eq!(m.tier(), Tier::PublicRead);
522            assert!(m.is_peer_reachable());
523        }
524    }
525
526    /// **Proves:** the six live-node methods the crate previously lacked are
527    /// present, Control-tiered, and NEVER peer-reachable (the #1075 completeness
528    /// gap: the crate must catalogue every method the dig-node dispatch serves).
529    /// **Catches:** a variant dropped from the catalogue, mis-tiered, or leaked
530    /// onto the peer surface.
531    #[test]
532    fn completed_control_methods_present_and_gated() {
533        let expected = [
534            (Method::CacheStats, "cache.stats"),
535            (Method::ControlSubscribe, "control.subscribe"),
536            (Method::ControlUnsubscribe, "control.unsubscribe"),
537            (
538                Method::ControlListSubscriptions,
539                "control.listSubscriptions",
540            ),
541            (Method::ControlPeersConnect, "control.peers.connect"),
542            (Method::ControlPeersDisconnect, "control.peers.disconnect"),
543        ];
544        for (m, name) in expected {
545            assert_eq!(m.name(), name);
546            assert_eq!(Method::from_name(name), Some(m));
547            assert!(Method::ALL.contains(&m), "{name} missing from ALL");
548            assert_eq!(m.tier(), Tier::Control, "{name} must be Control");
549            assert!(!m.is_peer_reachable(), "{name} must NOT be peer-reachable");
550        }
551    }
552
553    /// **Proves:** the whole-module peer-pull methods (#1576) are present,
554    /// Peer-tiered, and peer-reachable — the wire surface a peer uses to pull a
555    /// complete `.dig` module and reshare it.
556    /// **Catches:** either method dropped from the catalogue, mis-tiered, or left
557    /// off the peer allowlist (which would make peer reshare unreachable).
558    #[test]
559    fn module_pull_methods_present_and_peer_reachable() {
560        for (m, name) in [
561            (Method::GetModuleInfo, "dig.getModuleInfo"),
562            (Method::FetchModuleRange, "dig.fetchModuleRange"),
563        ] {
564            assert_eq!(m.name(), name);
565            assert_eq!(Method::from_name(name), Some(m));
566            assert!(Method::ALL.contains(&m), "{name} missing from ALL");
567            assert_eq!(m.tier(), Tier::Peer, "{name} must be Peer");
568            assert!(m.is_peer_reachable(), "{name} must be peer-reachable");
569        }
570    }
571
572    /// **Proves:** `dig.getPayeeRewardClaimStatus` is in the catalogue under its
573    /// exact wire name (dig_ecosystem#3269). The tier / peer-reachability half of
574    /// its contract is proven by the catalogue-wide scan below, not restated here.
575    #[test]
576    fn payee_reward_claim_status_present_under_its_wire_name() {
577        assert_eq!(
578            Method::GetPayeeRewardClaimStatus.name(),
579            "dig.getPayeeRewardClaimStatus"
580        );
581        assert_eq!(
582            Method::from_name("dig.getPayeeRewardClaimStatus"),
583            Some(Method::GetPayeeRewardClaimStatus)
584        );
585        assert!(Method::ALL.contains(&Method::GetPayeeRewardClaimStatus));
586    }
587
588    /// **Proves:** EVERY reward method in the catalogue is Control-tiered, not
589    /// peer-reachable, and absent from the peer allowlist — dig-rewards-coin
590    /// SPEC §2.3/§2.6/§7.4/§7.5, §12.5.
591    ///
592    /// **Catches:** a mis-tier that would expose distributor / prover-loop /
593    /// claim-side internals to any remote peer, *including on a reward method
594    /// added after this test was written*. An earlier form named its four
595    /// members explicitly, so a fifth reward method passed it by simply not
596    /// being on the list (#3261: a test over an enumeration can only check the
597    /// enumeration it was given); the form after that filtered on
598    /// `name().contains("Reward")`, which a reward method named without that
599    /// substring escapes silently.
600    ///
601    /// [`Method::is_reward`] is an exhaustive match, so the selection is
602    /// compiler-bound: a new variant fails to compile until it is classified,
603    /// and classifying it as a reward method puts it under every assertion
604    /// below. There is deliberately no expected count here — a count is a second
605    /// thing to forget, and nothing tells it to move.
606    #[test]
607    fn every_reward_method_is_control_and_not_peer_reachable() {
608        let reward_methods: Vec<Method> = Method::ALL
609            .iter()
610            .copied()
611            .filter(|m| m.is_reward())
612            .collect();
613
614        // Non-vacuous: a catalogue that lost the reward methods, or an
615        // `is_reward` that stopped claiming any, must fail here rather than
616        // empty the set and pass silently.
617        assert!(
618            !reward_methods.is_empty(),
619            "no method reported is_reward() -- either the catalogue lost them or \
620             the classification was inverted"
621        );
622
623        let allowlist = Method::peer_reachable_names();
624        for m in reward_methods {
625            let name = m.name();
626            assert_eq!(
627                Method::from_name(name),
628                Some(m),
629                "{name} does not round-trip"
630            );
631            assert_eq!(m.tier(), Tier::Control, "{name} must be Control");
632            assert!(!m.is_peer_reachable(), "{name} must NOT be peer-reachable");
633            assert!(
634                !allowlist.contains(&name),
635                "{name} must not appear in the peer allowlist"
636            );
637        }
638    }
639
640    /// **Proves:** every method whose wire name says "Reward" is classified as
641    /// one.
642    /// **Catches:** the one wrong answer a machine can check. The exhaustive
643    /// match in [`Method::is_reward`] forces an author to answer for a new
644    /// variant, but it cannot tell a wrong answer from a right one; a reward
645    /// method classified `false` would drop straight out of the tier guard, and
646    /// its own name is the evidence against it.
647    #[test]
648    fn reward_named_methods_are_classified_as_reward() {
649        for m in Method::ALL {
650            if m.name().contains("Reward") {
651                assert!(
652                    m.is_reward(),
653                    "{} is named a reward method but is_reward() says otherwise",
654                    m.name()
655                );
656            }
657        }
658    }
659
660    /// **Proves:** every Control method is NOT peer-reachable (the boundary
661    /// invariant).
662    #[test]
663    fn control_tier_never_peer_reachable() {
664        for m in Method::ALL {
665            if m.tier() == Tier::Control {
666                assert!(
667                    !m.is_peer_reachable(),
668                    "{} is Control but peer-reachable",
669                    m.name()
670                );
671            }
672        }
673    }
674
675    /// **Documents:** every [`Method`] variant is named by this exhaustive
676    /// match, same as the test-module's other classifiers. This is no longer
677    /// the completeness guard for `Method::ALL` — `method_catalogue!`
678    /// generates `ALL` directly from the variant table, so a variant missing
679    /// from `ALL` is unwritable, not merely red (dig_ecosystem#3317). This
680    /// match is kept because it is cheap and documents that the test module
681    /// sees the same catalogue as the rest of the crate; deliberately no `_`
682    /// arm, so adding a variant is still a compile error here until an author
683    /// names it.
684    #[test]
685    fn every_variant_is_named_by_an_exhaustive_match() {
686        // The identity mapping is the point: `needless_match` would have us
687        // collapse this into `m`, which erases the exhaustiveness check this
688        // test exists to force. Keep every arm explicit.
689        #[allow(clippy::needless_match)]
690        fn name_it(m: Method) -> Method {
691            match m {
692                Method::GetContent => Method::GetContent,
693                Method::GetCapsule => Method::GetCapsule,
694                Method::GetModule => Method::GetModule,
695                Method::GetManifest => Method::GetManifest,
696                Method::GetMetadata => Method::GetMetadata,
697                Method::ListCapsules => Method::ListCapsules,
698                Method::GetProof => Method::GetProof,
699                Method::GetProofStatus => Method::GetProofStatus,
700                Method::GetAnchoredRoot => Method::GetAnchoredRoot,
701                Method::GetCollection => Method::GetCollection,
702                Method::ListCollectionItems => Method::ListCollectionItems,
703                Method::Health => Method::Health,
704                Method::Methods => Method::Methods,
705                Method::GetNetworkInfo => Method::GetNetworkInfo,
706                Method::GetPeers => Method::GetPeers,
707                Method::Announce => Method::Announce,
708                Method::GetAvailability => Method::GetAvailability,
709                Method::ListInventory => Method::ListInventory,
710                Method::FetchRange => Method::FetchRange,
711                Method::GetModuleInfo => Method::GetModuleInfo,
712                Method::FetchModuleRange => Method::FetchModuleRange,
713                Method::Stage => Method::Stage,
714                Method::CacheGetConfig => Method::CacheGetConfig,
715                Method::CacheSetCapBytes => Method::CacheSetCapBytes,
716                Method::CacheClear => Method::CacheClear,
717                Method::CacheListCached => Method::CacheListCached,
718                Method::CacheRemoveCached => Method::CacheRemoveCached,
719                Method::CacheFetchAndCache => Method::CacheFetchAndCache,
720                Method::CacheStats => Method::CacheStats,
721                Method::ControlPeerStatus => Method::ControlPeerStatus,
722                Method::ControlSubscribe => Method::ControlSubscribe,
723                Method::ControlUnsubscribe => Method::ControlUnsubscribe,
724                Method::ControlListSubscriptions => Method::ControlListSubscriptions,
725                Method::ControlPeersConnect => Method::ControlPeersConnect,
726                Method::ControlPeersDisconnect => Method::ControlPeersDisconnect,
727                Method::ListRewardDistributors => Method::ListRewardDistributors,
728                Method::GetRewardProverStatus => Method::GetRewardProverStatus,
729                Method::GetRewardDistributor => Method::GetRewardDistributor,
730                Method::ListRewardDistributorCommitments => {
731                    Method::ListRewardDistributorCommitments
732                }
733                Method::GetPayeeRewardClaimStatus => Method::GetPayeeRewardClaimStatus,
734                Method::RpcDiscover => Method::RpcDiscover,
735            }
736        }
737
738        for m in Method::ALL {
739            assert_eq!(name_it(*m), *m);
740        }
741    }
742
743    /// The number of methods `method_catalogue!` should have generated into
744    /// `Method::ALL`. Bump this when you add a method -- the macro already put
745    /// it in `ALL`; this is the independent oracle that would catch a broken
746    /// macro body (an entry silently dropped from the table), since
747    /// completeness of `ALL` itself is now structural, not this test's job.
748    const CATALOGUE_LEN: usize = 41;
749
750    /// **Proves:** `Method::ALL` has exactly [`CATALOGUE_LEN`] entries.
751    /// **Catches:** a broken `method_catalogue!` body that silently drops an
752    /// entry from the generated `ALL` (the compiler can't catch that -- the
753    /// macro would still expand to a valid, just shorter, array). This is
754    /// NOT the completeness guard for #3317: `Method::ALL` can no longer omit
755    /// a variant that exists in the enum, because `method_catalogue!`
756    /// generates both from the same table -- there is no second list left to
757    /// forget. This test only defends against the table itself losing an
758    /// entry, and forces a visible, reviewable bump on every addition.
759    #[test]
760    fn all_len_is_the_catalogue_count() {
761        assert_eq!(
762            Method::ALL.len(),
763            CATALOGUE_LEN,
764            "Method::ALL has {} entries, expected {CATALOGUE_LEN} -- update CATALOGUE_LEN if a \
765             method was deliberately added or removed, otherwise the macro table lost an entry",
766            Method::ALL.len()
767        );
768
769        // No duplicates in ALL (unchanged from the prior form of this guard).
770        let all_set: HashSet<Method> = Method::ALL.iter().copied().collect();
771        assert_eq!(
772            all_set.len(),
773            Method::ALL.len(),
774            "Method::ALL contains a duplicate variant"
775        );
776    }
777}