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 WalletWatch,
160 WalletUnwatch,
162 WalletWatched,
164
165 PairingRequest,
168 PairingPoll,
170}
171
172impl ControlMethod {
173 pub const fn name(self) -> &'static str {
175 match self {
176 ControlMethod::Status => "control.status",
177 ControlMethod::ConfigGet => "control.config.get",
178 ControlMethod::ConfigSetUpstream => "control.config.setUpstream",
179 ControlMethod::LogSetLevel => "control.log.setLevel",
180 ControlMethod::CacheGet => "control.cache.get",
181 ControlMethod::CacheSetCap => "control.cache.setCap",
182 ControlMethod::CacheClear => "control.cache.clear",
183 ControlMethod::HostedStoresList => "control.hostedStores.list",
184 ControlMethod::HostedStoresPin => "control.hostedStores.pin",
185 ControlMethod::HostedStoresUnpin => "control.hostedStores.unpin",
186 ControlMethod::HostedStoresStatus => "control.hostedStores.status",
187 ControlMethod::SyncStatus => "control.sync.status",
188 ControlMethod::SyncTrigger => "control.sync.trigger",
189 ControlMethod::UpdaterStatus => "control.updater.status",
190 ControlMethod::UpdaterSetChannel => "control.updater.setChannel",
191 ControlMethod::UpdaterPause => "control.updater.pause",
192 ControlMethod::UpdaterResume => "control.updater.resume",
193 ControlMethod::UpdaterCheckNow => "control.updater.checkNow",
194 ControlMethod::PairingList => "control.pairing.list",
195 ControlMethod::PairingApprove => "control.pairing.approve",
196 ControlMethod::PairingRevoke => "control.pairing.revoke",
197 ControlMethod::PeerStatus => "control.peerStatus",
198 ControlMethod::PeerCounts => "control.peerCounts",
199 ControlMethod::PeersConnect => "control.peers.connect",
200 ControlMethod::PeersDisconnect => "control.peers.disconnect",
201 ControlMethod::Subscribe => "control.subscribe",
202 ControlMethod::Unsubscribe => "control.unsubscribe",
203 ControlMethod::ListSubscriptions => "control.listSubscriptions",
204 ControlMethod::WalletBalance => "control.wallet.balance",
205 ControlMethod::WalletCoins => "control.wallet.coins",
206 ControlMethod::WalletCoinById => "control.wallet.coinById",
207 ControlMethod::WalletCoinSpend => "control.wallet.coinSpend",
208 ControlMethod::WalletCoinsByParent => "control.wallet.coinsByParent",
209 ControlMethod::WalletArrivals => "control.wallet.arrivals",
210 ControlMethod::WalletPeak => "control.wallet.peak",
211 ControlMethod::WalletSyncStatus => "control.wallet.syncStatus",
212 ControlMethod::WalletBroadcast => "control.wallet.broadcast",
213 ControlMethod::WalletWatch => "control.wallet.watch",
214 ControlMethod::WalletUnwatch => "control.wallet.unwatch",
215 ControlMethod::WalletWatched => "control.wallet.watched",
216 ControlMethod::PairingRequest => "pairing.request",
217 ControlMethod::PairingPoll => "pairing.poll",
218 }
219 }
220
221 pub fn from_name(name: &str) -> Option<ControlMethod> {
223 ControlMethod::ALL
224 .iter()
225 .copied()
226 .find(|m| m.name() == name)
227 }
228
229 pub const fn requires_auth(self) -> bool {
256 !self.is_open_read()
257 && !matches!(
258 self,
259 ControlMethod::PairingRequest | ControlMethod::PairingPoll
260 )
261 }
262
263 pub const fn is_open_read(self) -> bool {
297 matches!(
298 self,
299 ControlMethod::WalletBalance
300 | ControlMethod::WalletCoins
301 | ControlMethod::WalletCoinById
302 | ControlMethod::WalletCoinSpend
303 | ControlMethod::WalletCoinsByParent
304 | ControlMethod::WalletPeak
305 | ControlMethod::WalletSyncStatus
306 | ControlMethod::PeerCounts
307 )
308 }
309
310 pub const fn is_pairing_admin(self) -> bool {
316 matches!(
317 self,
318 ControlMethod::PairingList
319 | ControlMethod::PairingApprove
320 | ControlMethod::PairingRevoke
321 )
322 }
323
324 pub const fn routing(self) -> Routing {
326 match self {
327 ControlMethod::PeerStatus
328 | ControlMethod::PeerCounts
329 | ControlMethod::PeersConnect
330 | ControlMethod::PeersDisconnect
331 | ControlMethod::Subscribe
332 | ControlMethod::Unsubscribe
333 | ControlMethod::ListSubscriptions
334 | ControlMethod::WalletBalance
335 | ControlMethod::WalletCoins
336 | ControlMethod::WalletCoinById
337 | ControlMethod::WalletCoinSpend
338 | ControlMethod::WalletCoinsByParent
339 | ControlMethod::WalletArrivals
340 | ControlMethod::WalletPeak
341 | ControlMethod::WalletSyncStatus
342 | ControlMethod::WalletBroadcast
343 | ControlMethod::WalletWatch
344 | ControlMethod::WalletUnwatch
345 | ControlMethod::WalletWatched => Routing::Delegated,
346 ControlMethod::PairingRequest | ControlMethod::PairingPoll => Routing::OpenBootstrap,
347 _ => Routing::Owned,
348 }
349 }
350
351 pub const fn category(self) -> Category {
353 match self {
354 ControlMethod::Status => Category::Status,
355 ControlMethod::ConfigGet | ControlMethod::ConfigSetUpstream => Category::Config,
356 ControlMethod::LogSetLevel => Category::Log,
357 ControlMethod::CacheGet | ControlMethod::CacheSetCap | ControlMethod::CacheClear => {
358 Category::Cache
359 }
360 ControlMethod::HostedStoresList
361 | ControlMethod::HostedStoresPin
362 | ControlMethod::HostedStoresUnpin
363 | ControlMethod::HostedStoresStatus => Category::HostedStores,
364 ControlMethod::SyncStatus | ControlMethod::SyncTrigger => Category::Sync,
365 ControlMethod::UpdaterStatus
366 | ControlMethod::UpdaterSetChannel
367 | ControlMethod::UpdaterPause
368 | ControlMethod::UpdaterResume
369 | ControlMethod::UpdaterCheckNow => Category::Updater,
370 ControlMethod::PairingList
371 | ControlMethod::PairingApprove
372 | ControlMethod::PairingRevoke
373 | ControlMethod::PairingRequest
374 | ControlMethod::PairingPoll => Category::Pairing,
375 ControlMethod::PeerStatus
376 | ControlMethod::PeerCounts
377 | ControlMethod::PeersConnect
378 | ControlMethod::PeersDisconnect => Category::Peers,
379 ControlMethod::Subscribe
380 | ControlMethod::Unsubscribe
381 | ControlMethod::ListSubscriptions => Category::Subscriptions,
382 ControlMethod::WalletBalance
383 | ControlMethod::WalletCoins
384 | ControlMethod::WalletCoinById
385 | ControlMethod::WalletCoinSpend
386 | ControlMethod::WalletCoinsByParent
387 | ControlMethod::WalletArrivals
388 | ControlMethod::WalletPeak
389 | ControlMethod::WalletSyncStatus
390 | ControlMethod::WalletBroadcast
391 | ControlMethod::WalletWatch
392 | ControlMethod::WalletUnwatch
393 | ControlMethod::WalletWatched => Category::Wallet,
394 }
395 }
396
397 pub const fn summary(self) -> &'static str {
399 match self {
400 ControlMethod::Status => "A rich node status snapshot (version, uptime, addr, cache, hosted/pinned counts, sync availability).",
401 ControlMethod::ConfigGet => "The node's effective configuration (addr/port, upstream + override, cache dir/shared, config path, sync availability).",
402 ControlMethod::ConfigSetUpstream => "Persist an upstream-RPC override; takes effect on next node start (requires_restart).",
403 ControlMethod::LogSetLevel => "Live-swap the running node's tracing EnvFilter directive (not persisted).",
404 ControlMethod::CacheGet => "The on-disk content-cache view: cap_bytes, used_bytes, dir, shared.",
405 ControlMethod::CacheSetCap => "Set the on-disk cache size cap in bytes (floored at 64 MiB).",
406 ControlMethod::CacheClear => "Delete all locally cached DIG content.",
407 ControlMethod::HostedStoresList => "Every held/pinned store, merged, with each store's cached capsules and a pinned flag.",
408 ControlMethod::HostedStoresPin => "Pin a store (storeId[:rootHash]); pre-fetches the capsule when a root is given and §21 sync is available.",
409 ControlMethod::HostedStoresUnpin => "Unpin a store and evict its cached capsules.",
410 ControlMethod::HostedStoresStatus => "Per-store status: pinned flag, cached capsules, total bytes.",
411 ControlMethod::SyncStatus => "Whether authenticated §21 whole-store sync is available, plus pinned-store cache coverage.",
412 ControlMethod::SyncTrigger => "Trigger a §21 sync for one capsule (storeId + root).",
413 ControlMethod::UpdaterStatus => "The DIG auto-update beacon's current status (proxied from dig-updater).",
414 ControlMethod::UpdaterSetChannel => "Set the beacon's update channel (\"nightly\" | \"stable\").",
415 ControlMethod::UpdaterPause => "Suspend the beacon's auto-updates (optionally until a unix time).",
416 ControlMethod::UpdaterResume => "Resume the beacon's auto-updates.",
417 ControlMethod::UpdaterCheckNow => "Force an immediate beacon update check.",
418 ControlMethod::PairingList => "List pending pairing requests and issued paired tokens (MASTER token only).",
419 ControlMethod::PairingApprove => "Approve a pending pairing, minting a scoped token (MASTER token only).",
420 ControlMethod::PairingRevoke => "Revoke an issued paired token by token_id (MASTER token only).",
421 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.",
422 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.",
423 ControlMethod::PeersConnect => "Dial a peer by address, or resolve an already-connected peer_id, via the live gossip pool.",
424 ControlMethod::PeersDisconnect => "Drop a pooled peer by peer_id, closing its mTLS link (idempotent).",
425 ControlMethod::Subscribe => "Subscribe the node to a store it actively watches and gap-fills.",
426 ControlMethod::Unsubscribe => "Stop watching a store.",
427 ControlMethod::ListSubscriptions => "The node's persisted subscription set + count.",
428 ControlMethod::WalletCoins => "READ-only: the spendable coin records for an address + asset, with the tier that answered and the height they reflect.",
429 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.",
430 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.",
431 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.",
432 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`.",
433 ControlMethod::WalletPeak => "READ-only: the node's current chain peak height, independent of any address.",
434 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).",
435 ControlMethod::WalletBroadcast => "Push an ALREADY-SIGNED spend bundle to the network; the node never signs. TOKEN-GATED.",
436 ControlMethod::WalletBalance => "READ-only: the confirmed spendable balance for an address + asset (plus pending, sync freshness, and the peak height it reflects).",
437 ControlMethod::WalletWatch => "Enrol PUBLIC keys (48-byte G1, lowercase 96-hex) for the node's chain replica to follow, so their addresses are synced and readable. IDEMPOTENT: re-enrolling a key already enrolled succeeds and changes nothing. Keys, never puzzle hashes -- the node derives the addresses itself, so one derivation serves every client. TOKEN-GATED.",
438 ControlMethod::WalletUnwatch => "Deregister enrolled public keys, so the node stops following their addresses. IDEMPOTENT: a key that was never enrolled is not an error. TOKEN-GATED.",
439 ControlMethod::WalletWatched => "READ-only: the public keys currently enrolled, so a client can reconcile what it asked for against what the node holds. TOKEN-GATED although it is a read -- the caller supplies nothing, so the answer is this node's OWN key set.",
440 ControlMethod::PairingRequest => "OPEN: request a control-token pairing; returns a pairing_id + pairing_code to compare.",
441 ControlMethod::PairingPoll => "OPEN: poll a pairing by id; once the operator approves, returns the scoped token once.",
442 }
443 }
444
445 pub const ALL: &'static [ControlMethod] = &[
448 ControlMethod::Status,
449 ControlMethod::ConfigGet,
450 ControlMethod::ConfigSetUpstream,
451 ControlMethod::LogSetLevel,
452 ControlMethod::CacheGet,
453 ControlMethod::CacheSetCap,
454 ControlMethod::CacheClear,
455 ControlMethod::HostedStoresList,
456 ControlMethod::HostedStoresPin,
457 ControlMethod::HostedStoresUnpin,
458 ControlMethod::HostedStoresStatus,
459 ControlMethod::SyncStatus,
460 ControlMethod::SyncTrigger,
461 ControlMethod::UpdaterStatus,
462 ControlMethod::UpdaterSetChannel,
463 ControlMethod::UpdaterPause,
464 ControlMethod::UpdaterResume,
465 ControlMethod::UpdaterCheckNow,
466 ControlMethod::PairingList,
467 ControlMethod::PairingApprove,
468 ControlMethod::PairingRevoke,
469 ControlMethod::PeerStatus,
470 ControlMethod::PeerCounts,
471 ControlMethod::PeersConnect,
472 ControlMethod::PeersDisconnect,
473 ControlMethod::Subscribe,
474 ControlMethod::Unsubscribe,
475 ControlMethod::ListSubscriptions,
476 ControlMethod::WalletBalance,
477 ControlMethod::WalletCoins,
478 ControlMethod::WalletCoinById,
479 ControlMethod::WalletCoinSpend,
480 ControlMethod::WalletCoinsByParent,
481 ControlMethod::WalletArrivals,
482 ControlMethod::WalletPeak,
483 ControlMethod::WalletSyncStatus,
484 ControlMethod::WalletBroadcast,
485 ControlMethod::WalletWatch,
486 ControlMethod::WalletUnwatch,
487 ControlMethod::WalletWatched,
488 ControlMethod::PairingRequest,
489 ControlMethod::PairingPoll,
490 ];
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496 use std::collections::BTreeSet;
497
498 #[test]
499 fn every_method_has_a_unique_wire_name() {
500 let names: BTreeSet<&str> = ControlMethod::ALL.iter().map(|m| m.name()).collect();
501 assert_eq!(
502 names.len(),
503 ControlMethod::ALL.len(),
504 "duplicate or missing wire names in the catalog"
505 );
506 }
507
508 #[test]
509 fn from_name_round_trips_every_method() {
510 for &m in ControlMethod::ALL {
511 assert_eq!(ControlMethod::from_name(m.name()), Some(m));
512 }
513 assert_eq!(ControlMethod::from_name("control.nope"), None);
514 assert_eq!(ControlMethod::from_name(""), None);
515 }
516
517 #[test]
518 fn the_token_less_surface_is_exactly_the_bootstrap_plus_the_chain_reads() {
519 let expected_open: BTreeSet<&str> = [
523 "pairing.request",
524 "pairing.poll",
525 "control.wallet.balance",
526 "control.wallet.coins",
527 "control.wallet.coinById",
528 "control.wallet.coinSpend",
529 "control.wallet.coinsByParent",
530 "control.wallet.peak",
531 "control.wallet.syncStatus",
532 "control.peerCounts",
533 ]
534 .into_iter()
535 .collect();
536 assert_eq!(
537 expected_open.len(),
538 10,
539 "the open surface is ten named methods"
540 );
541 let actual_open: BTreeSet<&str> = ControlMethod::ALL
542 .iter()
543 .filter(|m| !m.requires_auth())
544 .map(|m| m.name())
545 .collect();
546 assert_eq!(actual_open, expected_open);
547 }
548
549 #[test]
558 fn the_gated_wallet_methods_are_the_push_the_cursor_and_enrolment() {
559 let gated: Vec<&str> = ControlMethod::ALL
560 .iter()
561 .filter(|m| m.category() == Category::Wallet && m.requires_auth())
562 .map(|m| m.name())
563 .collect();
564 assert_eq!(
565 gated,
566 vec![
567 "control.wallet.arrivals",
568 "control.wallet.broadcast",
569 "control.wallet.watch",
570 "control.wallet.unwatch",
571 "control.wallet.watched",
572 ]
573 );
574 assert!(!ControlMethod::WalletBroadcast.is_open_read());
575 }
576
577 #[test]
589 fn the_arrival_cursor_is_not_an_open_read() {
590 assert!(
591 !ControlMethod::WalletArrivals.is_open_read(),
592 "control.wallet.arrivals discloses this node's OWN watched puzzle hashes to a caller \
593 that supplied nothing, so it MUST NOT be served token-less"
594 );
595 assert!(ControlMethod::WalletArrivals.requires_auth());
596 assert!(
597 ControlMethod::WalletCoinById.is_open_read(),
598 "the caller-addressed reads stay open -- the fix is the membership rule, not gating \
599 the wallet category"
600 );
601 }
602
603 #[test]
622 fn the_catalog_serves_every_chain_source_primitive() {
623 for wire in [
624 "control.wallet.coinById", "control.wallet.coins", "control.wallet.peak", "control.wallet.coinsByParent", "control.wallet.coinSpend", ] {
630 assert!(
631 ControlMethod::from_name(wire).is_some(),
632 "{wire} is required to implement ChainSource over the control plane"
633 );
634 }
635 }
636
637 #[test]
644 fn the_chain_primitives_are_caller_named_open_reads() {
645 for method in [
646 ControlMethod::WalletCoinSpend,
647 ControlMethod::WalletCoinsByParent,
648 ] {
649 assert!(
650 method.is_open_read(),
651 "{} names its subject in the request and discloses no node-to-address \
652 association, exactly like control.wallet.coinById",
653 method.name()
654 );
655 assert!(!method.requires_auth());
656 }
657 assert!(
658 ControlMethod::WalletArrivals.requires_auth(),
659 "the caller-supplies-nothing read stays gated -- the rule is who names the subject, \
660 not whether the bytes are on chain"
661 );
662 assert!(ControlMethod::WalletBroadcast.requires_auth());
663 }
664
665 #[test]
679 fn the_enrolment_methods_are_gated_including_the_read() {
680 for wire in [
681 "control.wallet.watch",
682 "control.wallet.unwatch",
683 "control.wallet.watched",
684 ] {
685 let method = ControlMethod::from_name(wire)
686 .unwrap_or_else(|| panic!("{wire} must be in the catalog"));
687 assert!(
688 !method.is_open_read(),
689 "{wire} either aims this node's subscriptions or names the keys it already \
690 follows, so it MUST NOT be served token-less"
691 );
692 assert!(method.requires_auth(), "{wire} must require the token");
693 assert_eq!(method.category(), Category::Wallet);
694 assert_eq!(method.routing(), Routing::Delegated);
695 }
696 assert!(
697 ControlMethod::WalletCoinById.is_open_read(),
698 "the caller-addressed reads stay open -- enrolment is gated by the membership rule, \
699 not by gating the wallet category"
700 );
701 }
702
703 #[test]
704 fn only_pairing_bootstrap_is_open_bootstrap_routed() {
705 for &m in ControlMethod::ALL {
706 let open_bootstrap = matches!(
707 m,
708 ControlMethod::PairingRequest | ControlMethod::PairingPoll
709 );
710 assert_eq!(
711 m.routing() == Routing::OpenBootstrap,
712 open_bootstrap,
713 "{} routing mismatch",
714 m.name()
715 );
716 }
717 }
718
719 #[test]
720 fn pairing_admin_methods_are_exactly_three() {
721 let admin: Vec<&str> = ControlMethod::ALL
722 .iter()
723 .filter(|m| m.is_pairing_admin())
724 .map(|m| m.name())
725 .collect();
726 assert_eq!(
727 admin,
728 vec![
729 "control.pairing.list",
730 "control.pairing.approve",
731 "control.pairing.revoke"
732 ]
733 );
734 }
735
736 #[test]
737 fn delegated_set_matches_the_engine_surface() {
738 let delegated: BTreeSet<&str> = ControlMethod::ALL
739 .iter()
740 .filter(|m| m.routing() == Routing::Delegated)
741 .map(|m| m.name())
742 .collect();
743 let expected: BTreeSet<&str> = [
744 "control.wallet.coins",
745 "control.wallet.coinById",
746 "control.wallet.coinSpend",
747 "control.wallet.coinsByParent",
748 "control.wallet.arrivals",
749 "control.wallet.peak",
750 "control.wallet.syncStatus",
751 "control.wallet.broadcast",
752 "control.wallet.watch",
753 "control.wallet.unwatch",
754 "control.wallet.watched",
755 "control.peerStatus",
756 "control.peerCounts",
757 "control.peers.connect",
758 "control.peers.disconnect",
759 "control.subscribe",
760 "control.unsubscribe",
761 "control.listSubscriptions",
762 "control.wallet.balance",
763 ]
764 .into_iter()
765 .collect();
766 assert_eq!(delegated, expected);
767 }
768
769 #[test]
770 fn every_method_has_a_nonempty_summary() {
771 for &m in ControlMethod::ALL {
772 assert!(!m.summary().is_empty(), "{} has no summary", m.name());
773 }
774 }
775}