Skip to main content

dig_node_control_interface/
method.rs

1//! The canonical control-method catalog.
2//!
3//! [`ControlMethod`] enumerates every method a client can send to a running dig-node's CONTROL
4//! plane, its stable wire name, whether it requires the local control token, whether it is a
5//! pairing-administration method (which requires the MASTER token specifically), and how the node
6//! routes it (owned by the service shell, delegated to the embedded node engine, or an open
7//! pairing-bootstrap method reachable without a token).
8//!
9//! This is the SINGLE source of truth for "what can be controlled". The node dispatchers, the
10//! client SDKs (CLI `dign`, the extension, dig-app, hub), the OpenRPC/discovery surface, and the
11//! conformance KATs all read this one table, so the method set can never drift between them.
12//!
13//! Mirrors the live dig-node surface: the shell-owned methods in
14//! `dig-node-service/src/control.rs` (`CONTROL_METHODS`) plus the peer/subscription methods
15//! delegated to `dig-node-core` (`control.peerStatus` / `control.peers.*` / `control.subscribe`
16//! / `control.unsubscribe` / `control.listSubscriptions`), and the two OPEN pairing-bootstrap
17//! methods (`pairing.request` / `pairing.poll`) a token-less MV3 extension uses to obtain a
18//! scoped token after local operator approval.
19
20/// How the node resolves a control method — the routing source of truth.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum Routing {
23    /// Answered by the dig-node service shell itself (config/status/cache/pins/sync/updater/pairing-admin).
24    Owned,
25    /// Delegated to the embedded dig-node engine's own control surface (peers + subscriptions).
26    Delegated,
27    /// An OPEN bootstrap method reachable WITHOUT the control token (pairing handshake).
28    OpenBootstrap,
29}
30
31/// The functional area a control method belongs to — for grouping in UIs and docs.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub enum Category {
34    /// Node status snapshot.
35    Status,
36    /// Node configuration (upstream override).
37    Config,
38    /// Live log-level control.
39    Log,
40    /// On-disk content cache.
41    Cache,
42    /// Hosted/pinned stores.
43    HostedStores,
44    /// §21 authenticated whole-store sync.
45    Sync,
46    /// The DIG auto-update beacon proxy.
47    Updater,
48    /// Control-token pairing lifecycle.
49    Pairing,
50    /// The L7 peer network: the live pool snapshot, the per-network peer counts, and dial/drop.
51    Peers,
52    /// The node's subscribed-store set.
53    Subscriptions,
54    /// Wallet chain transport: the read-only chain views (balance, coins, one coin by id, peak,
55    /// sync status) plus the push of an already-signed spend bundle.
56    Wallet,
57}
58
59/// A dig-node CONTROL method.
60///
61/// `#[non_exhaustive]` so adding a method in a minor release is additive; downstream matches must
62/// carry a `_ => …` arm. Convert to/from the wire name with [`ControlMethod::name`] /
63/// [`ControlMethod::from_name`].
64#[non_exhaustive]
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66pub enum ControlMethod {
67    // ---- Status / config / log (shell-owned) ----
68    /// `control.status` — a rich node status snapshot.
69    Status,
70    /// `control.config.get` — the node's effective configuration.
71    ConfigGet,
72    /// `control.config.setUpstream` — persist an upstream-RPC override (effective on restart).
73    ConfigSetUpstream,
74    /// `control.log.setLevel` — live-swap the running node's tracing level filter.
75    LogSetLevel,
76
77    // ---- Cache (shell-owned) ----
78    /// `control.cache.get` — the on-disk cache view (cap/used/dir/shared).
79    CacheGet,
80    /// `control.cache.setCap` — set the cache size cap (floored at 64 MiB).
81    CacheSetCap,
82    /// `control.cache.clear` — delete all locally cached content.
83    CacheClear,
84
85    // ---- Hosted stores (shell-owned) ----
86    /// `control.hostedStores.list` — every held/pinned store with its cached capsules.
87    HostedStoresList,
88    /// `control.hostedStores.pin` — pin a store (and pre-fetch when a root is given).
89    HostedStoresPin,
90    /// `control.hostedStores.unpin` — unpin a store and evict its cached capsules.
91    HostedStoresUnpin,
92    /// `control.hostedStores.status` — per-store pinned flag + cached capsules.
93    HostedStoresStatus,
94
95    // ---- §21 sync (shell-owned) ----
96    /// `control.sync.status` — whether authenticated whole-store sync is available + pin coverage.
97    SyncStatus,
98    /// `control.sync.trigger` — trigger a §21 sync for one capsule (storeId + root).
99    SyncTrigger,
100
101    // ---- Updater beacon proxy (shell-owned) ----
102    /// `control.updater.status` — the DIG auto-update beacon's current status.
103    UpdaterStatus,
104    /// `control.updater.setChannel` — set the beacon's update channel.
105    UpdaterSetChannel,
106    /// `control.updater.pause` — suspend auto-updates (optionally until a unix time).
107    UpdaterPause,
108    /// `control.updater.resume` — resume auto-updates.
109    UpdaterResume,
110    /// `control.updater.checkNow` — force an immediate update check.
111    UpdaterCheckNow,
112
113    // ---- Pairing administration (shell-owned, MASTER-token only) ----
114    /// `control.pairing.list` — list pending pairing requests + issued paired tokens.
115    PairingList,
116    /// `control.pairing.approve` — approve a pending pairing, minting a scoped token.
117    PairingApprove,
118    /// `control.pairing.revoke` — revoke an issued paired token.
119    PairingRevoke,
120
121    // ---- Peers (delegated to the engine) ----
122    /// `control.peerStatus` — live peer-pool + relay-reservation snapshot.
123    PeerStatus,
124    /// `control.peerCounts` — how many peers this node holds on EACH network (DIG and Chia).
125    PeerCounts,
126    /// `control.peers.connect` — dial a peer by address / resolve a connected peer_id.
127    PeersConnect,
128    /// `control.peers.disconnect` — drop a pooled peer by peer_id.
129    PeersDisconnect,
130
131    // ---- Subscriptions (delegated to the engine) ----
132    /// `control.subscribe` — subscribe the node to a store (watch + gap-fill).
133    Subscribe,
134    /// `control.unsubscribe` — stop watching a store.
135    Unsubscribe,
136    /// `control.listSubscriptions` — the node's persisted subscription set.
137    ListSubscriptions,
138
139    // ---- Wallet chain transport (delegated to the engine) ----
140    /// `control.wallet.balance` — read an address's confirmed spendable balance for an asset.
141    WalletBalance,
142    /// `control.wallet.coins` — read an address's spendable coin records for an asset.
143    WalletCoins,
144    /// `control.wallet.coinById` — read ONE coin record by coin id, spent or unspent.
145    WalletCoinById,
146    /// `control.wallet.coinSpend` — read the SPEND that spent a coin (puzzle reveal + solution).
147    WalletCoinSpend,
148    /// `control.wallet.coinsByParent` — read the direct children a coin's spend created (one hop).
149    WalletCoinsByParent,
150    /// `control.wallet.arrivals` — read confirmed INCOMING funds since a cursor position.
151    WalletArrivals,
152    /// `control.wallet.peak` — read the node's current chain peak height.
153    WalletPeak,
154    /// `control.wallet.syncStatus` — read whether the wallet's chain replica is being kept current.
155    WalletSyncStatus,
156    /// `control.wallet.broadcast` — push an ALREADY-SIGNED spend bundle to the network.
157    WalletBroadcast,
158
159    // ---- Pairing bootstrap (OPEN — no token) ----
160    /// `pairing.request` — request a control-token pairing (returns a code to compare).
161    PairingRequest,
162    /// `pairing.poll` — poll a pairing; once the operator approves, returns the scoped token once.
163    PairingPoll,
164}
165
166impl ControlMethod {
167    /// The stable JSON-RPC wire name. Never derived from anything else — the published contract.
168    pub const fn name(self) -> &'static str {
169        match self {
170            ControlMethod::Status => "control.status",
171            ControlMethod::ConfigGet => "control.config.get",
172            ControlMethod::ConfigSetUpstream => "control.config.setUpstream",
173            ControlMethod::LogSetLevel => "control.log.setLevel",
174            ControlMethod::CacheGet => "control.cache.get",
175            ControlMethod::CacheSetCap => "control.cache.setCap",
176            ControlMethod::CacheClear => "control.cache.clear",
177            ControlMethod::HostedStoresList => "control.hostedStores.list",
178            ControlMethod::HostedStoresPin => "control.hostedStores.pin",
179            ControlMethod::HostedStoresUnpin => "control.hostedStores.unpin",
180            ControlMethod::HostedStoresStatus => "control.hostedStores.status",
181            ControlMethod::SyncStatus => "control.sync.status",
182            ControlMethod::SyncTrigger => "control.sync.trigger",
183            ControlMethod::UpdaterStatus => "control.updater.status",
184            ControlMethod::UpdaterSetChannel => "control.updater.setChannel",
185            ControlMethod::UpdaterPause => "control.updater.pause",
186            ControlMethod::UpdaterResume => "control.updater.resume",
187            ControlMethod::UpdaterCheckNow => "control.updater.checkNow",
188            ControlMethod::PairingList => "control.pairing.list",
189            ControlMethod::PairingApprove => "control.pairing.approve",
190            ControlMethod::PairingRevoke => "control.pairing.revoke",
191            ControlMethod::PeerStatus => "control.peerStatus",
192            ControlMethod::PeerCounts => "control.peerCounts",
193            ControlMethod::PeersConnect => "control.peers.connect",
194            ControlMethod::PeersDisconnect => "control.peers.disconnect",
195            ControlMethod::Subscribe => "control.subscribe",
196            ControlMethod::Unsubscribe => "control.unsubscribe",
197            ControlMethod::ListSubscriptions => "control.listSubscriptions",
198            ControlMethod::WalletBalance => "control.wallet.balance",
199            ControlMethod::WalletCoins => "control.wallet.coins",
200            ControlMethod::WalletCoinById => "control.wallet.coinById",
201            ControlMethod::WalletCoinSpend => "control.wallet.coinSpend",
202            ControlMethod::WalletCoinsByParent => "control.wallet.coinsByParent",
203            ControlMethod::WalletArrivals => "control.wallet.arrivals",
204            ControlMethod::WalletPeak => "control.wallet.peak",
205            ControlMethod::WalletSyncStatus => "control.wallet.syncStatus",
206            ControlMethod::WalletBroadcast => "control.wallet.broadcast",
207            ControlMethod::PairingRequest => "pairing.request",
208            ControlMethod::PairingPoll => "pairing.poll",
209        }
210    }
211
212    /// Resolve a wire name back to its [`ControlMethod`], or `None` for an unknown name.
213    pub fn from_name(name: &str) -> Option<ControlMethod> {
214        ControlMethod::ALL
215            .iter()
216            .copied()
217            .find(|m| m.name() == name)
218    }
219
220    /// Does calling this method require the local control token?
221    ///
222    /// Three groups are reachable WITHOUT one, and they are open for two different reasons:
223    ///
224    /// - the pairing bootstrap (`pairing.request` / `pairing.poll`), so a token-less client can
225    ///   obtain a token at all;
226    /// - the PEER COUNTS (`control.peerCounts`), which disclose two integers about this node's own
227    ///   connectivity and no address, endpoint or secret;
228    /// - the wallet CALLER-ADDRESSED CHAIN READS (`control.wallet.balance` / `.coins` /
229    ///   `.coinById` / `.coinSpend` / `.coinsByParent`) and the node's own chain POSITION
230    ///   (`.peak` / `.syncStatus`), because each needs only PUBLIC chain data the CALLER already
231    ///   named — an address, or a coin id; never a seed, a key, or a signature — and dig-node has
232    ///   served `control.wallet.balance` open since #1851. A person whose node runs as a service
233    ///   with an unreadable token file can still see their own money.
234    ///
235    /// Two wallet methods are deliberately NOT in that second group.
236    /// `control.wallet.broadcast` puts bytes on the network, so the token is what stands between a
237    /// local process and a broadcast. `control.wallet.arrivals` names the wallet's OWN watched
238    /// puzzle hashes back to a caller that supplied nothing — see
239    /// [`ControlMethod::is_open_read`]. On both, `UNAUTHORIZED` genuinely means *unauthorized*.
240    pub const fn requires_auth(self) -> bool {
241        !self.is_open_read()
242            && !matches!(
243                self,
244                ControlMethod::PairingRequest | ControlMethod::PairingPoll
245            )
246    }
247
248    /// Is this an OPEN READ — served without a control token?
249    ///
250    /// Two kinds of method qualify, and they are open for different reasons:
251    ///
252    /// - the wallet CHAIN READS (`control.wallet.balance` / `.coins` / `.coinById` / `.coinSpend` /
253    ///   `.coinsByParent` / `.peak` / `.syncStatus`), which need only PUBLIC chain data — an
254    ///   address, or a coin id; never a seed, a key, or a signature. On the first five the CALLER
255    ///   supplies the address or coin id, so the node relays a public fact and discloses no
256    ///   association with itself; the last two name the node's own chain position and no address
257    ///   at all;
258    /// - `control.peerCounts`, which is NOT a chain read: it discloses two integers about this
259    ///   node's own connectivity, and no address, endpoint, peer identity or secret. The identity
260    ///   and topology half of the same subject stays gated behind `control.peerStatus`.
261    ///
262    /// Naming both reasons matters more than it looks. The test for membership is *does this
263    /// disclose only data that is already public, or a bare count of this node's own state?* — NOT
264    /// *is it a chain read?* A future method judged against the narrower phrasing, and found to
265    /// contradict a member that was already there, invites widening the predicate by analogy rather
266    /// than against the rule.
267    ///
268    /// `control.wallet.arrivals` is the worked example, and it was briefly a member. It passes the
269    /// narrower phrasing — every field it returns is a public chain fact — and fails the rule: the
270    /// caller supplies NOTHING, so the node volunteers its OWN watched puzzle hashes together with
271    /// the full receive history behind them. The individual facts are public; the ASSOCIATION
272    /// between this node and those addresses is not, and that association is the whole answer. A
273    /// token-less caller could then feed those addresses back into the caller-addressed reads.
274    /// Membership turns on *who names the address*, never on whether the bytes are on chain.
275    ///
276    /// Stated on the contract rather than discovered by calling, because the two refusals a client
277    /// can get here demand OPPOSITE remedies. On an open read, `UNAUTHORIZED` can only come from a
278    /// node build that predates the method and gates it generically, so the remedy is an upgrade.
279    /// On a gated method — the push — `UNAUTHORIZED` means exactly what it says, and the remedy is
280    /// the token. A client that maps the two the same way sends somebody to fix the wrong thing.
281    pub const fn is_open_read(self) -> bool {
282        matches!(
283            self,
284            ControlMethod::WalletBalance
285                | ControlMethod::WalletCoins
286                | ControlMethod::WalletCoinById
287                | ControlMethod::WalletCoinSpend
288                | ControlMethod::WalletCoinsByParent
289                | ControlMethod::WalletPeak
290                | ControlMethod::WalletSyncStatus
291                | ControlMethod::PeerCounts
292        )
293    }
294
295    /// Is this a PAIRING-ADMINISTRATION method that requires the MASTER control token specifically?
296    ///
297    /// A paired (scoped) token can drive ordinary `control.*` mutations but MUST NOT mint more
298    /// tokens or revoke itself — so listing/approving/revoking pairings requires the master token
299    /// (a local file read), never a paired token.
300    pub const fn is_pairing_admin(self) -> bool {
301        matches!(
302            self,
303            ControlMethod::PairingList
304                | ControlMethod::PairingApprove
305                | ControlMethod::PairingRevoke
306        )
307    }
308
309    /// How the node routes this method (shell-owned, engine-delegated, or open bootstrap).
310    pub const fn routing(self) -> Routing {
311        match self {
312            ControlMethod::PeerStatus
313            | ControlMethod::PeerCounts
314            | ControlMethod::PeersConnect
315            | ControlMethod::PeersDisconnect
316            | ControlMethod::Subscribe
317            | ControlMethod::Unsubscribe
318            | ControlMethod::ListSubscriptions
319            | ControlMethod::WalletBalance
320            | ControlMethod::WalletCoins
321            | ControlMethod::WalletCoinById
322            | ControlMethod::WalletCoinSpend
323            | ControlMethod::WalletCoinsByParent
324            | ControlMethod::WalletArrivals
325            | ControlMethod::WalletPeak
326            | ControlMethod::WalletSyncStatus
327            | ControlMethod::WalletBroadcast => Routing::Delegated,
328            ControlMethod::PairingRequest | ControlMethod::PairingPoll => Routing::OpenBootstrap,
329            _ => Routing::Owned,
330        }
331    }
332
333    /// The functional area this method belongs to.
334    pub const fn category(self) -> Category {
335        match self {
336            ControlMethod::Status => Category::Status,
337            ControlMethod::ConfigGet | ControlMethod::ConfigSetUpstream => Category::Config,
338            ControlMethod::LogSetLevel => Category::Log,
339            ControlMethod::CacheGet | ControlMethod::CacheSetCap | ControlMethod::CacheClear => {
340                Category::Cache
341            }
342            ControlMethod::HostedStoresList
343            | ControlMethod::HostedStoresPin
344            | ControlMethod::HostedStoresUnpin
345            | ControlMethod::HostedStoresStatus => Category::HostedStores,
346            ControlMethod::SyncStatus | ControlMethod::SyncTrigger => Category::Sync,
347            ControlMethod::UpdaterStatus
348            | ControlMethod::UpdaterSetChannel
349            | ControlMethod::UpdaterPause
350            | ControlMethod::UpdaterResume
351            | ControlMethod::UpdaterCheckNow => Category::Updater,
352            ControlMethod::PairingList
353            | ControlMethod::PairingApprove
354            | ControlMethod::PairingRevoke
355            | ControlMethod::PairingRequest
356            | ControlMethod::PairingPoll => Category::Pairing,
357            ControlMethod::PeerStatus
358            | ControlMethod::PeerCounts
359            | ControlMethod::PeersConnect
360            | ControlMethod::PeersDisconnect => Category::Peers,
361            ControlMethod::Subscribe
362            | ControlMethod::Unsubscribe
363            | ControlMethod::ListSubscriptions => Category::Subscriptions,
364            ControlMethod::WalletBalance
365            | ControlMethod::WalletCoins
366            | ControlMethod::WalletCoinById
367            | ControlMethod::WalletCoinSpend
368            | ControlMethod::WalletCoinsByParent
369            | ControlMethod::WalletArrivals
370            | ControlMethod::WalletPeak
371            | ControlMethod::WalletSyncStatus
372            | ControlMethod::WalletBroadcast => Category::Wallet,
373        }
374    }
375
376    /// A one-line human/agent description for the discovery catalogue.
377    pub const fn summary(self) -> &'static str {
378        match self {
379            ControlMethod::Status => "A rich node status snapshot (version, uptime, addr, cache, hosted/pinned counts, sync availability).",
380            ControlMethod::ConfigGet => "The node's effective configuration (addr/port, upstream + override, cache dir/shared, config path, sync availability).",
381            ControlMethod::ConfigSetUpstream => "Persist an upstream-RPC override; takes effect on next node start (requires_restart).",
382            ControlMethod::LogSetLevel => "Live-swap the running node's tracing EnvFilter directive (not persisted).",
383            ControlMethod::CacheGet => "The on-disk content-cache view: cap_bytes, used_bytes, dir, shared.",
384            ControlMethod::CacheSetCap => "Set the on-disk cache size cap in bytes (floored at 64 MiB).",
385            ControlMethod::CacheClear => "Delete all locally cached DIG content.",
386            ControlMethod::HostedStoresList => "Every held/pinned store, merged, with each store's cached capsules and a pinned flag.",
387            ControlMethod::HostedStoresPin => "Pin a store (storeId[:rootHash]); pre-fetches the capsule when a root is given and §21 sync is available.",
388            ControlMethod::HostedStoresUnpin => "Unpin a store and evict its cached capsules.",
389            ControlMethod::HostedStoresStatus => "Per-store status: pinned flag, cached capsules, total bytes.",
390            ControlMethod::SyncStatus => "Whether authenticated §21 whole-store sync is available, plus pinned-store cache coverage.",
391            ControlMethod::SyncTrigger => "Trigger a §21 sync for one capsule (storeId + root).",
392            ControlMethod::UpdaterStatus => "The DIG auto-update beacon's current status (proxied from dig-updater).",
393            ControlMethod::UpdaterSetChannel => "Set the beacon's update channel (\"nightly\" | \"stable\").",
394            ControlMethod::UpdaterPause => "Suspend the beacon's auto-updates (optionally until a unix time).",
395            ControlMethod::UpdaterResume => "Resume the beacon's auto-updates.",
396            ControlMethod::UpdaterCheckNow => "Force an immediate beacon update check.",
397            ControlMethod::PairingList => "List pending pairing requests and issued paired tokens (MASTER token only).",
398            ControlMethod::PairingApprove => "Approve a pending pairing, minting a scoped token (MASTER token only).",
399            ControlMethod::PairingRevoke => "Revoke an issued paired token by token_id (MASTER token only).",
400            ControlMethod::PeerStatus => "Live peer-pool + relay-reservation snapshot, including the per-peer connected array; each entry carries an always-present `software` field (the peer's advertised build). Its `relay.peer_count` counts peers connected to THE RELAY, not to this node, and is never the answer to \"how many peers does this node have\" -- that is control.peerCounts.",
401            ControlMethod::PeerCounts => "READ-only: how many peers this node holds on EACH network -- dig_peer_count (DIG content/gossip, port 9445) and chia_peer_count (Chia full nodes serving the wallet chain sync). Two unrelated numbers, each named for its network.",
402            ControlMethod::PeersConnect => "Dial a peer by address, or resolve an already-connected peer_id, via the live gossip pool.",
403            ControlMethod::PeersDisconnect => "Drop a pooled peer by peer_id, closing its mTLS link (idempotent).",
404            ControlMethod::Subscribe => "Subscribe the node to a store it actively watches and gap-fills.",
405            ControlMethod::Unsubscribe => "Stop watching a store.",
406            ControlMethod::ListSubscriptions => "The node's persisted subscription set + count.",
407            ControlMethod::WalletCoins => "READ-only: the spendable coin records for an address + asset, with the tier that answered and the height they reflect.",
408            ControlMethod::WalletCoinById => "READ-only: ONE coin record by coin id, spent or unspent, with no address and no asset scope; `coin: null` means the chain holds no such coin.",
409            ControlMethod::WalletCoinSpend => "READ-only: the SPEND that spent a coin -- its puzzle reveal, its solution and the coin itself -- named by the coin's own id. `spend: null` means the consulted chain shows that coin as unspent or unknown; it NEVER means the chain could not be reached, which is an error.",
410            ControlMethod::WalletCoinsByParent => "READ-only: the DIRECT children created by spending one coin, named by that parent's coin id. ONE hop, never a recursive walk: an empty list means the parent created no known children, and a caller wanting a lineage composes hops itself.",
411            ControlMethod::WalletArrivals => "READ-only: confirmed INCOMING funds recorded since a cursor position, oldest first -- the answer to `was I just paid?`, which no balance or coin list can give. Each row is a coin the node determined ARRIVED: confirmed on chain, above the wallet's arrival baseline, not previously reported, and not the wallet's own change. Resume from `cursor` (the last row you were handed), never from `latest`.",
412            ControlMethod::WalletPeak => "READ-only: the node's current chain peak height, independent of any address.",
413            ControlMethod::WalletSyncStatus => "READ-only: whether the wallet's CHAIN replica is being kept current (not_started/syncing/synced), the replica's own height, and its CHIA full-node peer count -- unrelated to control.sync.status (DIG stores) and to control.peerStatus (DIG peers).",
414            ControlMethod::WalletBroadcast => "Push an ALREADY-SIGNED spend bundle to the network; the node never signs. TOKEN-GATED.",
415            ControlMethod::WalletBalance => "READ-only: the confirmed spendable balance for an address + asset (plus pending, sync freshness, and the peak height it reflects).",
416            ControlMethod::PairingRequest => "OPEN: request a control-token pairing; returns a pairing_id + pairing_code to compare.",
417            ControlMethod::PairingPoll => "OPEN: poll a pairing by id; once the operator approves, returns the scoped token once.",
418        }
419    }
420
421    /// Every catalogued method, in a stable order — the enumeration a machine reads to discover the
422    /// full control surface, and the anchor the conformance KATs pin against.
423    pub const ALL: &'static [ControlMethod] = &[
424        ControlMethod::Status,
425        ControlMethod::ConfigGet,
426        ControlMethod::ConfigSetUpstream,
427        ControlMethod::LogSetLevel,
428        ControlMethod::CacheGet,
429        ControlMethod::CacheSetCap,
430        ControlMethod::CacheClear,
431        ControlMethod::HostedStoresList,
432        ControlMethod::HostedStoresPin,
433        ControlMethod::HostedStoresUnpin,
434        ControlMethod::HostedStoresStatus,
435        ControlMethod::SyncStatus,
436        ControlMethod::SyncTrigger,
437        ControlMethod::UpdaterStatus,
438        ControlMethod::UpdaterSetChannel,
439        ControlMethod::UpdaterPause,
440        ControlMethod::UpdaterResume,
441        ControlMethod::UpdaterCheckNow,
442        ControlMethod::PairingList,
443        ControlMethod::PairingApprove,
444        ControlMethod::PairingRevoke,
445        ControlMethod::PeerStatus,
446        ControlMethod::PeerCounts,
447        ControlMethod::PeersConnect,
448        ControlMethod::PeersDisconnect,
449        ControlMethod::Subscribe,
450        ControlMethod::Unsubscribe,
451        ControlMethod::ListSubscriptions,
452        ControlMethod::WalletBalance,
453        ControlMethod::WalletCoins,
454        ControlMethod::WalletCoinById,
455        ControlMethod::WalletCoinSpend,
456        ControlMethod::WalletCoinsByParent,
457        ControlMethod::WalletArrivals,
458        ControlMethod::WalletPeak,
459        ControlMethod::WalletSyncStatus,
460        ControlMethod::WalletBroadcast,
461        ControlMethod::PairingRequest,
462        ControlMethod::PairingPoll,
463    ];
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use std::collections::BTreeSet;
470
471    #[test]
472    fn every_method_has_a_unique_wire_name() {
473        let names: BTreeSet<&str> = ControlMethod::ALL.iter().map(|m| m.name()).collect();
474        assert_eq!(
475            names.len(),
476            ControlMethod::ALL.len(),
477            "duplicate or missing wire names in the catalog"
478        );
479    }
480
481    #[test]
482    fn from_name_round_trips_every_method() {
483        for &m in ControlMethod::ALL {
484            assert_eq!(ControlMethod::from_name(m.name()), Some(m));
485        }
486        assert_eq!(ControlMethod::from_name("control.nope"), None);
487        assert_eq!(ControlMethod::from_name(""), None);
488    }
489
490    #[test]
491    fn the_token_less_surface_is_exactly_the_bootstrap_plus_the_chain_reads() {
492        // Written out rather than derived from `is_open_read`, so this pins the SET and not the
493        // implementation's opinion of itself. A method added to the open surface must be added
494        // here deliberately -- which is the review step a broadcast must never slip past.
495        let expected_open: BTreeSet<&str> = [
496            "pairing.request",
497            "pairing.poll",
498            "control.wallet.balance",
499            "control.wallet.coins",
500            "control.wallet.coinById",
501            "control.wallet.coinSpend",
502            "control.wallet.coinsByParent",
503            "control.wallet.peak",
504            "control.wallet.syncStatus",
505            "control.peerCounts",
506        ]
507        .into_iter()
508        .collect();
509        assert_eq!(
510            expected_open.len(),
511            10,
512            "the open surface is ten named methods"
513        );
514        let actual_open: BTreeSet<&str> = ControlMethod::ALL
515            .iter()
516            .filter(|m| !m.requires_auth())
517            .map(|m| m.name())
518            .collect();
519        assert_eq!(actual_open, expected_open);
520    }
521
522    /// **The push and the arrival cursor are the two token-gated wallet methods.** The fixture
523    /// varies one thing -- which wallet method is asked -- against a category whose other members
524    /// ARE open, so both nearest wrong implementations fail here: one that opens the whole category
525    /// (the state this crate shipped in at `1190a18`) and one that gates it wholesale.
526    #[test]
527    fn the_push_and_the_arrival_cursor_are_the_wallet_methods_behind_the_token() {
528        let gated: Vec<&str> = ControlMethod::ALL
529            .iter()
530            .filter(|m| m.category() == Category::Wallet && m.requires_auth())
531            .map(|m| m.name())
532            .collect();
533        assert_eq!(
534            gated,
535            vec!["control.wallet.arrivals", "control.wallet.broadcast"]
536        );
537        assert!(!ControlMethod::WalletBroadcast.is_open_read());
538    }
539
540    /// **The arrival cursor is NOT an open read, and the reason is not "is it a chain read?".**
541    ///
542    /// The rule is *who names the address*. `control.wallet.arrivals` takes only a cursor, so the
543    /// node volunteers its OWN watched puzzle hashes and the receive history behind them -- the
544    /// node-to-address association, which is not public, and which a token-less caller could then
545    /// replay into the caller-addressed reads.
546    ///
547    /// The control keeps `control.wallet.coinById` in the same assertion: it is the neighbour the
548    /// analogy was drawn from, it is still open, and it stays open because its CALLER supplies the
549    /// coin id. Without that control this test would also pass on a wholesale gating of the wallet
550    /// category, which is a different (and wrong) implementation.
551    #[test]
552    fn the_arrival_cursor_is_not_an_open_read() {
553        assert!(
554            !ControlMethod::WalletArrivals.is_open_read(),
555            "control.wallet.arrivals discloses this node's OWN watched puzzle hashes to a caller \
556             that supplied nothing, so it MUST NOT be served token-less"
557        );
558        assert!(ControlMethod::WalletArrivals.requires_auth());
559        assert!(
560            ControlMethod::WalletCoinById.is_open_read(),
561            "the caller-addressed reads stay open -- the fix is the membership rule, not gating \
562             the wallet category"
563        );
564    }
565
566    /// **The control plane names every chain primitive `ChainSource` needs.**
567    ///
568    /// The list is written out rather than derived, because the property under test is a claim about
569    /// ANOTHER crate's trait (`dig-chainsource-interface`'s `ChainSource`) that no compiler here can
570    /// check. Five of its seven methods need a control method of their own. The other two need none:
571    /// `parent_spend` is a trait DEFAULT composed from `coin_record` + `coin_spend`, and
572    /// `resolve_singleton_lineage` is composed CLIENT-side from the primitives below rather than
573    /// served as a walk the node performs.
574    ///
575    /// `block_timestamp` is deliberately ABSENT from the control plane. dig-node's light client
576    /// (`chia-peer`'s `ChiaPeerProvider`) does not index block timestamps and answers `Unsupported`,
577    /// so a control method for it could only ever be refused — a surface that looks live and does
578    /// nothing. A consumer mirrors that refusal honestly; if one ever genuinely needs the value, the
579    /// method is an additive minor at that point.
580    ///
581    /// A missing name here is not a cosmetic gap: a client that cannot answer one of these cannot
582    /// implement the trait at all, which is what made a dig-profile mint structurally impossible
583    /// through the node before these two were added (dig_ecosystem#2572).
584    #[test]
585    fn the_catalog_serves_every_chain_source_primitive() {
586        for wire in [
587            "control.wallet.coinById",      // coin_record
588            "control.wallet.coins",         // coin_records_by_puzzle_hash
589            "control.wallet.peak",          // peak_height
590            "control.wallet.coinsByParent", // coin_records_by_parent
591            "control.wallet.coinSpend",     // coin_spend
592        ] {
593            assert!(
594                ControlMethod::from_name(wire).is_some(),
595                "{wire} is required to implement ChainSource over the control plane"
596            );
597        }
598    }
599
600    /// **The two chain primitives are `coinById`'s neighbours, not `arrivals`'.**
601    ///
602    /// Each takes a caller-supplied coin id and returns a deterministic public chain fact,
603    /// so the membership rule — *who names the subject* — puts them on the open side. The gated
604    /// control in the same assertion is what makes the test load-bearing: without it, a wholesale
605    /// opening of the wallet category would pass, and that is a different (and wrong) implementation.
606    #[test]
607    fn the_chain_primitives_are_caller_named_open_reads() {
608        for method in [
609            ControlMethod::WalletCoinSpend,
610            ControlMethod::WalletCoinsByParent,
611        ] {
612            assert!(
613                method.is_open_read(),
614                "{} names its subject in the request and discloses no node-to-address \
615                 association, exactly like control.wallet.coinById",
616                method.name()
617            );
618            assert!(!method.requires_auth());
619        }
620        assert!(
621            ControlMethod::WalletArrivals.requires_auth(),
622            "the caller-supplies-nothing read stays gated -- the rule is who names the subject, \
623             not whether the bytes are on chain"
624        );
625        assert!(ControlMethod::WalletBroadcast.requires_auth());
626    }
627
628    #[test]
629    fn only_pairing_bootstrap_is_open_bootstrap_routed() {
630        for &m in ControlMethod::ALL {
631            let open_bootstrap = matches!(
632                m,
633                ControlMethod::PairingRequest | ControlMethod::PairingPoll
634            );
635            assert_eq!(
636                m.routing() == Routing::OpenBootstrap,
637                open_bootstrap,
638                "{} routing mismatch",
639                m.name()
640            );
641        }
642    }
643
644    #[test]
645    fn pairing_admin_methods_are_exactly_three() {
646        let admin: Vec<&str> = ControlMethod::ALL
647            .iter()
648            .filter(|m| m.is_pairing_admin())
649            .map(|m| m.name())
650            .collect();
651        assert_eq!(
652            admin,
653            vec![
654                "control.pairing.list",
655                "control.pairing.approve",
656                "control.pairing.revoke"
657            ]
658        );
659    }
660
661    #[test]
662    fn delegated_set_matches_the_engine_surface() {
663        let delegated: BTreeSet<&str> = ControlMethod::ALL
664            .iter()
665            .filter(|m| m.routing() == Routing::Delegated)
666            .map(|m| m.name())
667            .collect();
668        let expected: BTreeSet<&str> = [
669            "control.wallet.coins",
670            "control.wallet.coinById",
671            "control.wallet.coinSpend",
672            "control.wallet.coinsByParent",
673            "control.wallet.arrivals",
674            "control.wallet.peak",
675            "control.wallet.syncStatus",
676            "control.wallet.broadcast",
677            "control.peerStatus",
678            "control.peerCounts",
679            "control.peers.connect",
680            "control.peers.disconnect",
681            "control.subscribe",
682            "control.unsubscribe",
683            "control.listSubscriptions",
684            "control.wallet.balance",
685        ]
686        .into_iter()
687        .collect();
688        assert_eq!(delegated, expected);
689    }
690
691    #[test]
692    fn every_method_has_a_nonempty_summary() {
693        for &m in ControlMethod::ALL {
694            assert!(!m.summary().is_empty(), "{} has no summary", m.name());
695        }
696    }
697}