1#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum Routing {
23 Owned,
25 Delegated,
27 OpenBootstrap,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub enum Category {
34 Status,
36 Config,
38 Log,
40 Cache,
42 HostedStores,
44 Sync,
46 Updater,
48 Pairing,
50 Peers,
52 Subscriptions,
54 Wallet,
57}
58
59#[non_exhaustive]
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66pub enum ControlMethod {
67 Status,
70 ConfigGet,
72 ConfigSetUpstream,
74 LogSetLevel,
76
77 CacheGet,
80 CacheSetCap,
82 CacheClear,
84
85 HostedStoresList,
88 HostedStoresPin,
90 HostedStoresUnpin,
92 HostedStoresStatus,
94
95 SyncStatus,
98 SyncTrigger,
100
101 UpdaterStatus,
104 UpdaterSetChannel,
106 UpdaterPause,
108 UpdaterResume,
110 UpdaterCheckNow,
112
113 PairingList,
116 PairingApprove,
118 PairingRevoke,
120
121 PeerStatus,
124 PeerCounts,
126 PeersConnect,
128 PeersDisconnect,
130
131 Subscribe,
134 Unsubscribe,
136 ListSubscriptions,
138
139 WalletBalance,
142 WalletCoins,
144 WalletCoinById,
146 WalletArrivals,
148 WalletPeak,
150 WalletSyncStatus,
152 WalletBroadcast,
154
155 PairingRequest,
158 PairingPoll,
160}
161
162impl ControlMethod {
163 pub const fn name(self) -> &'static str {
165 match self {
166 ControlMethod::Status => "control.status",
167 ControlMethod::ConfigGet => "control.config.get",
168 ControlMethod::ConfigSetUpstream => "control.config.setUpstream",
169 ControlMethod::LogSetLevel => "control.log.setLevel",
170 ControlMethod::CacheGet => "control.cache.get",
171 ControlMethod::CacheSetCap => "control.cache.setCap",
172 ControlMethod::CacheClear => "control.cache.clear",
173 ControlMethod::HostedStoresList => "control.hostedStores.list",
174 ControlMethod::HostedStoresPin => "control.hostedStores.pin",
175 ControlMethod::HostedStoresUnpin => "control.hostedStores.unpin",
176 ControlMethod::HostedStoresStatus => "control.hostedStores.status",
177 ControlMethod::SyncStatus => "control.sync.status",
178 ControlMethod::SyncTrigger => "control.sync.trigger",
179 ControlMethod::UpdaterStatus => "control.updater.status",
180 ControlMethod::UpdaterSetChannel => "control.updater.setChannel",
181 ControlMethod::UpdaterPause => "control.updater.pause",
182 ControlMethod::UpdaterResume => "control.updater.resume",
183 ControlMethod::UpdaterCheckNow => "control.updater.checkNow",
184 ControlMethod::PairingList => "control.pairing.list",
185 ControlMethod::PairingApprove => "control.pairing.approve",
186 ControlMethod::PairingRevoke => "control.pairing.revoke",
187 ControlMethod::PeerStatus => "control.peerStatus",
188 ControlMethod::PeerCounts => "control.peerCounts",
189 ControlMethod::PeersConnect => "control.peers.connect",
190 ControlMethod::PeersDisconnect => "control.peers.disconnect",
191 ControlMethod::Subscribe => "control.subscribe",
192 ControlMethod::Unsubscribe => "control.unsubscribe",
193 ControlMethod::ListSubscriptions => "control.listSubscriptions",
194 ControlMethod::WalletBalance => "control.wallet.balance",
195 ControlMethod::WalletCoins => "control.wallet.coins",
196 ControlMethod::WalletCoinById => "control.wallet.coinById",
197 ControlMethod::WalletArrivals => "control.wallet.arrivals",
198 ControlMethod::WalletPeak => "control.wallet.peak",
199 ControlMethod::WalletSyncStatus => "control.wallet.syncStatus",
200 ControlMethod::WalletBroadcast => "control.wallet.broadcast",
201 ControlMethod::PairingRequest => "pairing.request",
202 ControlMethod::PairingPoll => "pairing.poll",
203 }
204 }
205
206 pub fn from_name(name: &str) -> Option<ControlMethod> {
208 ControlMethod::ALL
209 .iter()
210 .copied()
211 .find(|m| m.name() == name)
212 }
213
214 pub const fn requires_auth(self) -> bool {
235 !self.is_open_read()
236 && !matches!(
237 self,
238 ControlMethod::PairingRequest | ControlMethod::PairingPoll
239 )
240 }
241
242 pub const fn is_open_read(self) -> bool {
275 matches!(
276 self,
277 ControlMethod::WalletBalance
278 | ControlMethod::WalletCoins
279 | ControlMethod::WalletCoinById
280 | ControlMethod::WalletPeak
281 | ControlMethod::WalletSyncStatus
282 | ControlMethod::PeerCounts
283 )
284 }
285
286 pub const fn is_pairing_admin(self) -> bool {
292 matches!(
293 self,
294 ControlMethod::PairingList
295 | ControlMethod::PairingApprove
296 | ControlMethod::PairingRevoke
297 )
298 }
299
300 pub const fn routing(self) -> Routing {
302 match self {
303 ControlMethod::PeerStatus
304 | ControlMethod::PeerCounts
305 | ControlMethod::PeersConnect
306 | ControlMethod::PeersDisconnect
307 | ControlMethod::Subscribe
308 | ControlMethod::Unsubscribe
309 | ControlMethod::ListSubscriptions
310 | ControlMethod::WalletBalance
311 | ControlMethod::WalletCoins
312 | ControlMethod::WalletCoinById
313 | ControlMethod::WalletArrivals
314 | ControlMethod::WalletPeak
315 | ControlMethod::WalletSyncStatus
316 | ControlMethod::WalletBroadcast => Routing::Delegated,
317 ControlMethod::PairingRequest | ControlMethod::PairingPoll => Routing::OpenBootstrap,
318 _ => Routing::Owned,
319 }
320 }
321
322 pub const fn category(self) -> Category {
324 match self {
325 ControlMethod::Status => Category::Status,
326 ControlMethod::ConfigGet | ControlMethod::ConfigSetUpstream => Category::Config,
327 ControlMethod::LogSetLevel => Category::Log,
328 ControlMethod::CacheGet | ControlMethod::CacheSetCap | ControlMethod::CacheClear => {
329 Category::Cache
330 }
331 ControlMethod::HostedStoresList
332 | ControlMethod::HostedStoresPin
333 | ControlMethod::HostedStoresUnpin
334 | ControlMethod::HostedStoresStatus => Category::HostedStores,
335 ControlMethod::SyncStatus | ControlMethod::SyncTrigger => Category::Sync,
336 ControlMethod::UpdaterStatus
337 | ControlMethod::UpdaterSetChannel
338 | ControlMethod::UpdaterPause
339 | ControlMethod::UpdaterResume
340 | ControlMethod::UpdaterCheckNow => Category::Updater,
341 ControlMethod::PairingList
342 | ControlMethod::PairingApprove
343 | ControlMethod::PairingRevoke
344 | ControlMethod::PairingRequest
345 | ControlMethod::PairingPoll => Category::Pairing,
346 ControlMethod::PeerStatus
347 | ControlMethod::PeerCounts
348 | ControlMethod::PeersConnect
349 | ControlMethod::PeersDisconnect => Category::Peers,
350 ControlMethod::Subscribe
351 | ControlMethod::Unsubscribe
352 | ControlMethod::ListSubscriptions => Category::Subscriptions,
353 ControlMethod::WalletBalance
354 | ControlMethod::WalletCoins
355 | ControlMethod::WalletCoinById
356 | ControlMethod::WalletArrivals
357 | ControlMethod::WalletPeak
358 | ControlMethod::WalletSyncStatus
359 | ControlMethod::WalletBroadcast => Category::Wallet,
360 }
361 }
362
363 pub const fn summary(self) -> &'static str {
365 match self {
366 ControlMethod::Status => "A rich node status snapshot (version, uptime, addr, cache, hosted/pinned counts, sync availability).",
367 ControlMethod::ConfigGet => "The node's effective configuration (addr/port, upstream + override, cache dir/shared, config path, sync availability).",
368 ControlMethod::ConfigSetUpstream => "Persist an upstream-RPC override; takes effect on next node start (requires_restart).",
369 ControlMethod::LogSetLevel => "Live-swap the running node's tracing EnvFilter directive (not persisted).",
370 ControlMethod::CacheGet => "The on-disk content-cache view: cap_bytes, used_bytes, dir, shared.",
371 ControlMethod::CacheSetCap => "Set the on-disk cache size cap in bytes (floored at 64 MiB).",
372 ControlMethod::CacheClear => "Delete all locally cached DIG content.",
373 ControlMethod::HostedStoresList => "Every held/pinned store, merged, with each store's cached capsules and a pinned flag.",
374 ControlMethod::HostedStoresPin => "Pin a store (storeId[:rootHash]); pre-fetches the capsule when a root is given and §21 sync is available.",
375 ControlMethod::HostedStoresUnpin => "Unpin a store and evict its cached capsules.",
376 ControlMethod::HostedStoresStatus => "Per-store status: pinned flag, cached capsules, total bytes.",
377 ControlMethod::SyncStatus => "Whether authenticated §21 whole-store sync is available, plus pinned-store cache coverage.",
378 ControlMethod::SyncTrigger => "Trigger a §21 sync for one capsule (storeId + root).",
379 ControlMethod::UpdaterStatus => "The DIG auto-update beacon's current status (proxied from dig-updater).",
380 ControlMethod::UpdaterSetChannel => "Set the beacon's update channel (\"nightly\" | \"stable\").",
381 ControlMethod::UpdaterPause => "Suspend the beacon's auto-updates (optionally until a unix time).",
382 ControlMethod::UpdaterResume => "Resume the beacon's auto-updates.",
383 ControlMethod::UpdaterCheckNow => "Force an immediate beacon update check.",
384 ControlMethod::PairingList => "List pending pairing requests and issued paired tokens (MASTER token only).",
385 ControlMethod::PairingApprove => "Approve a pending pairing, minting a scoped token (MASTER token only).",
386 ControlMethod::PairingRevoke => "Revoke an issued paired token by token_id (MASTER token only).",
387 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.",
388 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.",
389 ControlMethod::PeersConnect => "Dial a peer by address, or resolve an already-connected peer_id, via the live gossip pool.",
390 ControlMethod::PeersDisconnect => "Drop a pooled peer by peer_id, closing its mTLS link (idempotent).",
391 ControlMethod::Subscribe => "Subscribe the node to a store it actively watches and gap-fills.",
392 ControlMethod::Unsubscribe => "Stop watching a store.",
393 ControlMethod::ListSubscriptions => "The node's persisted subscription set + count.",
394 ControlMethod::WalletCoins => "READ-only: the spendable coin records for an address + asset, with the tier that answered and the height they reflect.",
395 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.",
396 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`.",
397 ControlMethod::WalletPeak => "READ-only: the node's current chain peak height, independent of any address.",
398 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).",
399 ControlMethod::WalletBroadcast => "Push an ALREADY-SIGNED spend bundle to the network; the node never signs. TOKEN-GATED.",
400 ControlMethod::WalletBalance => "READ-only: the confirmed spendable balance for an address + asset (plus pending, sync freshness, and the peak height it reflects).",
401 ControlMethod::PairingRequest => "OPEN: request a control-token pairing; returns a pairing_id + pairing_code to compare.",
402 ControlMethod::PairingPoll => "OPEN: poll a pairing by id; once the operator approves, returns the scoped token once.",
403 }
404 }
405
406 pub const ALL: &'static [ControlMethod] = &[
409 ControlMethod::Status,
410 ControlMethod::ConfigGet,
411 ControlMethod::ConfigSetUpstream,
412 ControlMethod::LogSetLevel,
413 ControlMethod::CacheGet,
414 ControlMethod::CacheSetCap,
415 ControlMethod::CacheClear,
416 ControlMethod::HostedStoresList,
417 ControlMethod::HostedStoresPin,
418 ControlMethod::HostedStoresUnpin,
419 ControlMethod::HostedStoresStatus,
420 ControlMethod::SyncStatus,
421 ControlMethod::SyncTrigger,
422 ControlMethod::UpdaterStatus,
423 ControlMethod::UpdaterSetChannel,
424 ControlMethod::UpdaterPause,
425 ControlMethod::UpdaterResume,
426 ControlMethod::UpdaterCheckNow,
427 ControlMethod::PairingList,
428 ControlMethod::PairingApprove,
429 ControlMethod::PairingRevoke,
430 ControlMethod::PeerStatus,
431 ControlMethod::PeerCounts,
432 ControlMethod::PeersConnect,
433 ControlMethod::PeersDisconnect,
434 ControlMethod::Subscribe,
435 ControlMethod::Unsubscribe,
436 ControlMethod::ListSubscriptions,
437 ControlMethod::WalletBalance,
438 ControlMethod::WalletCoins,
439 ControlMethod::WalletCoinById,
440 ControlMethod::WalletArrivals,
441 ControlMethod::WalletPeak,
442 ControlMethod::WalletSyncStatus,
443 ControlMethod::WalletBroadcast,
444 ControlMethod::PairingRequest,
445 ControlMethod::PairingPoll,
446 ];
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use std::collections::BTreeSet;
453
454 #[test]
455 fn every_method_has_a_unique_wire_name() {
456 let names: BTreeSet<&str> = ControlMethod::ALL.iter().map(|m| m.name()).collect();
457 assert_eq!(
458 names.len(),
459 ControlMethod::ALL.len(),
460 "duplicate or missing wire names in the catalog"
461 );
462 }
463
464 #[test]
465 fn from_name_round_trips_every_method() {
466 for &m in ControlMethod::ALL {
467 assert_eq!(ControlMethod::from_name(m.name()), Some(m));
468 }
469 assert_eq!(ControlMethod::from_name("control.nope"), None);
470 assert_eq!(ControlMethod::from_name(""), None);
471 }
472
473 #[test]
474 fn the_token_less_surface_is_exactly_the_bootstrap_plus_the_chain_reads() {
475 let expected_open: BTreeSet<&str> = [
479 "pairing.request",
480 "pairing.poll",
481 "control.wallet.balance",
482 "control.wallet.coins",
483 "control.wallet.coinById",
484 "control.wallet.peak",
485 "control.wallet.syncStatus",
486 "control.peerCounts",
487 ]
488 .into_iter()
489 .collect();
490 assert_eq!(
491 expected_open.len(),
492 8,
493 "the open surface is eight named methods"
494 );
495 let actual_open: BTreeSet<&str> = ControlMethod::ALL
496 .iter()
497 .filter(|m| !m.requires_auth())
498 .map(|m| m.name())
499 .collect();
500 assert_eq!(actual_open, expected_open);
501 }
502
503 #[test]
508 fn the_push_and_the_arrival_cursor_are_the_wallet_methods_behind_the_token() {
509 let gated: Vec<&str> = ControlMethod::ALL
510 .iter()
511 .filter(|m| m.category() == Category::Wallet && m.requires_auth())
512 .map(|m| m.name())
513 .collect();
514 assert_eq!(
515 gated,
516 vec!["control.wallet.arrivals", "control.wallet.broadcast"]
517 );
518 assert!(!ControlMethod::WalletBroadcast.is_open_read());
519 }
520
521 #[test]
533 fn the_arrival_cursor_is_not_an_open_read() {
534 assert!(
535 !ControlMethod::WalletArrivals.is_open_read(),
536 "control.wallet.arrivals discloses this node's OWN watched puzzle hashes to a caller \
537 that supplied nothing, so it MUST NOT be served token-less"
538 );
539 assert!(ControlMethod::WalletArrivals.requires_auth());
540 assert!(
541 ControlMethod::WalletCoinById.is_open_read(),
542 "the caller-addressed reads stay open -- the fix is the membership rule, not gating \
543 the wallet category"
544 );
545 }
546
547 #[test]
548 fn only_pairing_bootstrap_is_open_bootstrap_routed() {
549 for &m in ControlMethod::ALL {
550 let open_bootstrap = matches!(
551 m,
552 ControlMethod::PairingRequest | ControlMethod::PairingPoll
553 );
554 assert_eq!(
555 m.routing() == Routing::OpenBootstrap,
556 open_bootstrap,
557 "{} routing mismatch",
558 m.name()
559 );
560 }
561 }
562
563 #[test]
564 fn pairing_admin_methods_are_exactly_three() {
565 let admin: Vec<&str> = ControlMethod::ALL
566 .iter()
567 .filter(|m| m.is_pairing_admin())
568 .map(|m| m.name())
569 .collect();
570 assert_eq!(
571 admin,
572 vec![
573 "control.pairing.list",
574 "control.pairing.approve",
575 "control.pairing.revoke"
576 ]
577 );
578 }
579
580 #[test]
581 fn delegated_set_matches_the_engine_surface() {
582 let delegated: BTreeSet<&str> = ControlMethod::ALL
583 .iter()
584 .filter(|m| m.routing() == Routing::Delegated)
585 .map(|m| m.name())
586 .collect();
587 let expected: BTreeSet<&str> = [
588 "control.wallet.coins",
589 "control.wallet.coinById",
590 "control.wallet.arrivals",
591 "control.wallet.peak",
592 "control.wallet.syncStatus",
593 "control.wallet.broadcast",
594 "control.peerStatus",
595 "control.peerCounts",
596 "control.peers.connect",
597 "control.peers.disconnect",
598 "control.subscribe",
599 "control.unsubscribe",
600 "control.listSubscriptions",
601 "control.wallet.balance",
602 ]
603 .into_iter()
604 .collect();
605 assert_eq!(delegated, expected);
606 }
607
608 #[test]
609 fn every_method_has_a_nonempty_summary() {
610 for &m in ControlMethod::ALL {
611 assert!(!m.summary().is_empty(), "{} has no summary", m.name());
612 }
613 }
614}