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 WalletCoinSpend,
148 WalletCoinsByParent,
150 WalletArrivals,
152 WalletPeak,
154 WalletSyncStatus,
156 WalletBroadcast,
158
159 PairingRequest,
162 PairingPoll,
164}
165
166impl ControlMethod {
167 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 pub fn from_name(name: &str) -> Option<ControlMethod> {
214 ControlMethod::ALL
215 .iter()
216 .copied()
217 .find(|m| m.name() == name)
218 }
219
220 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 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 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 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 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 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/no_wallet_enrolled/wallet_not_unlocked), 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 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 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 #[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 #[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 #[test]
585 fn the_catalog_serves_every_chain_source_primitive() {
586 for wire in [
587 "control.wallet.coinById", "control.wallet.coins", "control.wallet.peak", "control.wallet.coinsByParent", "control.wallet.coinSpend", ] {
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 #[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}