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 Spends,
60 Profile,
64}
65
66#[non_exhaustive]
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
73pub enum ControlMethod {
74 Status,
77 ConfigGet,
79 ConfigSetUpstream,
81 LogSetLevel,
83
84 CacheGet,
87 CacheSetCap,
89 CacheClear,
91
92 HostedStoresList,
95 HostedStoresPin,
97 HostedStoresUnpin,
99 HostedStoresStatus,
101 CapsuleFetch,
104
105 SyncStatus,
108 SyncTrigger,
110
111 UpdaterStatus,
114 UpdaterSetChannel,
116 UpdaterPause,
118 UpdaterResume,
120 UpdaterCheckNow,
122
123 PairingList,
126 PairingApprove,
128 PairingRevoke,
130
131 PeerStatus,
134 PeerCounts,
136 PeersConnect,
138 PeersDisconnect,
140
141 ChiaPeersAdd,
157 ChiaPeersList,
159 ChiaPeersRemove,
161
162 Subscribe,
165 Unsubscribe,
167 ListSubscriptions,
169
170 WalletBalance,
173 WalletCoins,
175 WalletCoinById,
177 WalletCoinSpend,
179 WalletCoinsByParent,
181 WalletArrivals,
183 WalletPeak,
185 WalletSyncStatus,
187 WalletBroadcast,
189 WalletWatch,
191 WalletUnwatch,
193 WalletWatched,
195 WalletReservationsHeld,
197 WalletReservationsReserve,
199 WalletReservationsRelease,
201
202 SpendsList,
205
206 ProfilePutBody,
209 ProfileGetBody,
211
212 PairingRequest,
215 PairingPoll,
217}
218
219impl ControlMethod {
220 pub const fn name(self) -> &'static str {
222 match self {
223 ControlMethod::Status => "control.status",
224 ControlMethod::ConfigGet => "control.config.get",
225 ControlMethod::ConfigSetUpstream => "control.config.setUpstream",
226 ControlMethod::LogSetLevel => "control.log.setLevel",
227 ControlMethod::CacheGet => "control.cache.get",
228 ControlMethod::CacheSetCap => "control.cache.setCap",
229 ControlMethod::CacheClear => "control.cache.clear",
230 ControlMethod::HostedStoresList => "control.hostedStores.list",
231 ControlMethod::HostedStoresPin => "control.hostedStores.pin",
232 ControlMethod::HostedStoresUnpin => "control.hostedStores.unpin",
233 ControlMethod::HostedStoresStatus => "control.hostedStores.status",
234 ControlMethod::CapsuleFetch => "control.capsule.fetch",
235 ControlMethod::SyncStatus => "control.sync.status",
236 ControlMethod::SyncTrigger => "control.sync.trigger",
237 ControlMethod::UpdaterStatus => "control.updater.status",
238 ControlMethod::UpdaterSetChannel => "control.updater.setChannel",
239 ControlMethod::UpdaterPause => "control.updater.pause",
240 ControlMethod::UpdaterResume => "control.updater.resume",
241 ControlMethod::UpdaterCheckNow => "control.updater.checkNow",
242 ControlMethod::PairingList => "control.pairing.list",
243 ControlMethod::PairingApprove => "control.pairing.approve",
244 ControlMethod::PairingRevoke => "control.pairing.revoke",
245 ControlMethod::PeerStatus => "control.peerStatus",
246 ControlMethod::PeerCounts => "control.peerCounts",
247 ControlMethod::PeersConnect => "control.peers.connect",
248 ControlMethod::PeersDisconnect => "control.peers.disconnect",
249 ControlMethod::ChiaPeersAdd => "control.chiaPeers.add",
250 ControlMethod::ChiaPeersList => "control.chiaPeers.list",
251 ControlMethod::ChiaPeersRemove => "control.chiaPeers.remove",
252 ControlMethod::Subscribe => "control.subscribe",
253 ControlMethod::Unsubscribe => "control.unsubscribe",
254 ControlMethod::ListSubscriptions => "control.listSubscriptions",
255 ControlMethod::WalletBalance => "control.wallet.balance",
256 ControlMethod::WalletCoins => "control.wallet.coins",
257 ControlMethod::WalletCoinById => "control.wallet.coinById",
258 ControlMethod::WalletCoinSpend => "control.wallet.coinSpend",
259 ControlMethod::WalletCoinsByParent => "control.wallet.coinsByParent",
260 ControlMethod::WalletArrivals => "control.wallet.arrivals",
261 ControlMethod::WalletPeak => "control.wallet.peak",
262 ControlMethod::WalletSyncStatus => "control.wallet.syncStatus",
263 ControlMethod::WalletBroadcast => "control.wallet.broadcast",
264 ControlMethod::WalletWatch => "control.wallet.watch",
265 ControlMethod::WalletUnwatch => "control.wallet.unwatch",
266 ControlMethod::WalletWatched => "control.wallet.watched",
267 ControlMethod::WalletReservationsHeld => "control.wallet.reservations.held",
268 ControlMethod::WalletReservationsReserve => "control.wallet.reservations.reserve",
269 ControlMethod::WalletReservationsRelease => "control.wallet.reservations.release",
270 ControlMethod::SpendsList => "control.spends.list",
271 ControlMethod::ProfilePutBody => "control.profile.putBody",
272 ControlMethod::ProfileGetBody => "control.profile.getBody",
273 ControlMethod::PairingRequest => "pairing.request",
274 ControlMethod::PairingPoll => "pairing.poll",
275 }
276 }
277
278 pub fn from_name(name: &str) -> Option<ControlMethod> {
280 ControlMethod::ALL
281 .iter()
282 .copied()
283 .find(|m| m.name() == name)
284 }
285
286 pub const fn requires_auth(self) -> bool {
313 !self.is_open_read()
314 && !matches!(
315 self,
316 ControlMethod::PairingRequest | ControlMethod::PairingPoll
317 )
318 }
319
320 pub const fn is_open_read(self) -> bool {
354 matches!(
355 self,
356 ControlMethod::WalletBalance
357 | ControlMethod::WalletCoins
358 | ControlMethod::WalletCoinById
359 | ControlMethod::WalletCoinSpend
360 | ControlMethod::WalletCoinsByParent
361 | ControlMethod::WalletPeak
362 | ControlMethod::WalletSyncStatus
363 | ControlMethod::PeerCounts
364 )
365 }
366
367 pub const fn is_pairing_admin(self) -> bool {
376 matches!(
377 self,
378 ControlMethod::PairingList
379 | ControlMethod::PairingApprove
380 | ControlMethod::PairingRevoke
381 )
382 }
383
384 pub const fn requires_master_token(self) -> bool {
411 self.is_pairing_admin()
412 || matches!(
413 self,
414 ControlMethod::ChiaPeersAdd | ControlMethod::ChiaPeersRemove
415 )
416 }
417
418 pub const fn routing(self) -> Routing {
420 match self {
421 ControlMethod::PeerStatus
422 | ControlMethod::PeerCounts
423 | ControlMethod::PeersConnect
424 | ControlMethod::PeersDisconnect
425 | ControlMethod::Subscribe
426 | ControlMethod::Unsubscribe
427 | ControlMethod::ListSubscriptions
428 | ControlMethod::WalletBalance
429 | ControlMethod::WalletCoins
430 | ControlMethod::WalletCoinById
431 | ControlMethod::WalletCoinSpend
432 | ControlMethod::WalletCoinsByParent
433 | ControlMethod::WalletArrivals
434 | ControlMethod::WalletPeak
435 | ControlMethod::WalletSyncStatus
436 | ControlMethod::WalletBroadcast
437 | ControlMethod::WalletWatch
438 | ControlMethod::WalletUnwatch
439 | ControlMethod::WalletWatched
440 | ControlMethod::WalletReservationsHeld
441 | ControlMethod::WalletReservationsReserve
442 | ControlMethod::WalletReservationsRelease
443 | ControlMethod::ProfilePutBody
444 | ControlMethod::ProfileGetBody => Routing::Delegated,
445 ControlMethod::PairingRequest | ControlMethod::PairingPoll => Routing::OpenBootstrap,
446 _ => Routing::Owned,
447 }
448 }
449
450 pub const fn category(self) -> Category {
452 match self {
453 ControlMethod::Status => Category::Status,
454 ControlMethod::ConfigGet | ControlMethod::ConfigSetUpstream => Category::Config,
455 ControlMethod::LogSetLevel => Category::Log,
456 ControlMethod::CacheGet | ControlMethod::CacheSetCap | ControlMethod::CacheClear => {
457 Category::Cache
458 }
459 ControlMethod::HostedStoresList
460 | ControlMethod::HostedStoresPin
461 | ControlMethod::HostedStoresUnpin
462 | ControlMethod::HostedStoresStatus
463 | ControlMethod::CapsuleFetch => Category::HostedStores,
464 ControlMethod::SyncStatus | ControlMethod::SyncTrigger => Category::Sync,
465 ControlMethod::UpdaterStatus
466 | ControlMethod::UpdaterSetChannel
467 | ControlMethod::UpdaterPause
468 | ControlMethod::UpdaterResume
469 | ControlMethod::UpdaterCheckNow => Category::Updater,
470 ControlMethod::PairingList
471 | ControlMethod::PairingApprove
472 | ControlMethod::PairingRevoke
473 | ControlMethod::PairingRequest
474 | ControlMethod::PairingPoll => Category::Pairing,
475 ControlMethod::PeerStatus
476 | ControlMethod::PeerCounts
477 | ControlMethod::PeersConnect
478 | ControlMethod::PeersDisconnect
479 | ControlMethod::ChiaPeersAdd
480 | ControlMethod::ChiaPeersList
481 | ControlMethod::ChiaPeersRemove => Category::Peers,
482 ControlMethod::Subscribe
483 | ControlMethod::Unsubscribe
484 | ControlMethod::ListSubscriptions => Category::Subscriptions,
485 ControlMethod::WalletBalance
486 | ControlMethod::WalletCoins
487 | ControlMethod::WalletCoinById
488 | ControlMethod::WalletCoinSpend
489 | ControlMethod::WalletCoinsByParent
490 | ControlMethod::WalletArrivals
491 | ControlMethod::WalletPeak
492 | ControlMethod::WalletSyncStatus
493 | ControlMethod::WalletBroadcast
494 | ControlMethod::WalletWatch
495 | ControlMethod::WalletUnwatch
496 | ControlMethod::WalletWatched
497 | ControlMethod::WalletReservationsHeld
498 | ControlMethod::WalletReservationsReserve
499 | ControlMethod::WalletReservationsRelease => Category::Wallet,
500 ControlMethod::SpendsList => Category::Spends,
501 ControlMethod::ProfilePutBody | ControlMethod::ProfileGetBody => Category::Profile,
502 }
503 }
504
505 pub const fn summary(self) -> &'static str {
507 match self {
508 ControlMethod::ChiaPeersAdd => "Trust a Chia full node by IP. A trusted peer BYPASSES CORROBORATION: this node normally believes a chain answer only when several independently-dialled peers agree, and a trusted peer is believed on its own -- so a wrong or hostile one can feed this node a false view of the chain. Add only a node you run yourself.",
509 ControlMethod::ChiaPeersList => "The Chia full-node peers this node tracks, each flagged user_managed: true where a person added it by hand and it is therefore trusted without corroboration.",
510 ControlMethod::ChiaPeersRemove => "Stop trusting a Chia full node, optionally banning it. Removing restores corroboration for that peer: chain answers must once again be agreed by independently-dialled peers.",
511 ControlMethod::Status => "A rich node status snapshot (version, uptime, addr, cache, hosted/pinned counts, sync availability).",
512 ControlMethod::ConfigGet => "The node's effective configuration (addr/port, upstream + override, cache dir/shared, config path, sync availability).",
513 ControlMethod::ConfigSetUpstream => "Persist an upstream-RPC override; takes effect on next node start (requires_restart).",
514 ControlMethod::LogSetLevel => "Live-swap the running node's tracing EnvFilter directive (not persisted).",
515 ControlMethod::CacheGet => "The on-disk content-cache view: cap_bytes, used_bytes, dir, shared.",
516 ControlMethod::CacheSetCap => "Set the on-disk cache size cap in bytes (floored at 64 MiB).",
517 ControlMethod::CacheClear => "Delete all locally cached DIG content.",
518 ControlMethod::HostedStoresList => "Every held/pinned store, merged, with each store's cached capsules and a pinned flag.",
519 ControlMethod::HostedStoresPin => "Pin a store (storeId[:rootHash]); pre-fetches the capsule when a root is given and §21 sync is available.",
520 ControlMethod::HostedStoresUnpin => "Unpin a store and evict its cached capsules.",
521 ControlMethod::HostedStoresStatus => "Per-store status: pinned flag, cached capsules, total bytes.",
522 ControlMethod::CapsuleFetch => "Start a P2P whole-capsule pull for one store+root over the recursive discover-then-dial path (distinct from the §21 HTTP sync `control.sync.trigger` uses). Answers `already_cached` without dialling out when the capsule is already on disk.",
523 ControlMethod::SyncStatus => "Whether authenticated §21 whole-store sync is available, plus pinned-store cache coverage.",
524 ControlMethod::SyncTrigger => "Trigger a §21 sync for one capsule (storeId + root).",
525 ControlMethod::UpdaterStatus => "The DIG auto-update beacon's current status (proxied from dig-updater).",
526 ControlMethod::UpdaterSetChannel => "Set the beacon's update channel (\"nightly\" | \"stable\").",
527 ControlMethod::UpdaterPause => "Suspend the beacon's auto-updates (optionally until a unix time).",
528 ControlMethod::UpdaterResume => "Resume the beacon's auto-updates.",
529 ControlMethod::UpdaterCheckNow => "Force an immediate beacon update check.",
530 ControlMethod::PairingList => "List pending pairing requests and issued paired tokens (MASTER token only).",
531 ControlMethod::PairingApprove => "Approve a pending pairing, minting a scoped token (MASTER token only).",
532 ControlMethod::PairingRevoke => "Revoke an issued paired token by token_id (MASTER token only).",
533 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.",
534 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.",
535 ControlMethod::PeersConnect => "Dial a peer by address, or resolve an already-connected peer_id, via the live gossip pool.",
536 ControlMethod::PeersDisconnect => "Drop a pooled peer by peer_id, closing its mTLS link (idempotent).",
537 ControlMethod::Subscribe => "Subscribe the node to a store it actively watches and gap-fills.",
538 ControlMethod::Unsubscribe => "Stop watching a store.",
539 ControlMethod::ListSubscriptions => "The node's persisted subscription set + count.",
540 ControlMethod::WalletCoins => "READ-only: the spendable coin records for an address + asset, with the tier that answered and the height they reflect.",
541 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.",
542 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.",
543 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.",
544 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`.",
545 ControlMethod::WalletPeak => "READ-only: the node's current chain peak height, independent of any address.",
546 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).",
547 ControlMethod::WalletBroadcast => "Push an ALREADY-SIGNED spend bundle to the network; the node never signs. TOKEN-GATED.",
548 ControlMethod::WalletBalance => "READ-only: the confirmed spendable balance for an address + asset (plus pending, sync freshness, and the peak height it reflects).",
549 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.",
550 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.",
551 ControlMethod::SpendsList => "READ-only: the record of spends this node made WITHOUT per-transaction approval -- what moved, when, on whose standing authority, and whether the chain confirmed it. It NEVER initiates, signs, cancels or alters a spend, and there is no verb here that edits an entry. A failed spend is reported WITH the stage it died at, because only a signing failure means the money definitely did not move; a broadcast or confirmation failure is an UNKNOWN outcome, as is `unresolved`. A page is bounded and says so via `complete`; `unreadable_lines` reports entries the node could not parse, so an audit trail that lost rows can never read as a tidy shorter one. TOKEN-GATED although it is a read: the caller supplies no identifier, so the answer is this node's OWN state.",
552 ControlMethod::ProfilePutBody => "Hand the node the dig-profile BODY that a chain root commits to. The node INDEPENDENTLY resolves that root on chain and REFUSES any body whose recomputed root is not the confirmed one -- the caller's `root` is a claim to be checked, never a fact to be trusted, and dig-app is a caller like any other. Bodies are capped at MAX_BODY_BYTES (4 MiB). TOKEN-GATED.",
553 ControlMethod::ProfileGetBody => "READ-only: the dig-profile body this node holds at a given store id + root, or `body: null` when it holds none. `null` NEVER means the body could not be read, which is an error. TOKEN-GATED.",
554 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.",
555 ControlMethod::WalletReservationsHeld => "READ-only: every coin currently committed to an in-flight spend, each with the reservation holding it and the unix second that hold lapses, plus the node's own clock. `reserved: []` means NOTHING is held; a set that cannot be read is an error, never an empty list. Narrows what a caller may SELECT; never subtract these from a balance -- the coins are still the user's money. TOKEN-GATED although it is a read: the caller supplies nothing, so the answer is this node's OWN state.",
556 ControlMethod::WalletReservationsReserve => "Atomically hold coins against further selection: EVERY named coin or none. A coin already held refuses the whole call and reserves nothing, as WALLET_COINS_RESERVED -- a WAIT, never a shortfall. Reserving an empty list succeeds with a handle that releases nothing. The requested ttl_secs is clamped by the node, which returns the lifetime it actually applied. Bookkeeping only: it holds no key and authorizes nothing (§908). TOKEN-GATED.",
557 ControlMethod::WalletReservationsRelease => "Free a hold now rather than waiting out its TTL -- call it the moment a spend is known settled or known dead. A handle that names no live reservation is a SUCCESS with released: false, because a caller releasing on confirmation cannot know whether the TTL got there first. Every hold also lapses on its own, so an abandoned reservation is recoverable and never a permanent funds lockout. TOKEN-GATED.",
558 ControlMethod::PairingRequest => "OPEN: request a control-token pairing; returns a pairing_id + pairing_code to compare.",
559 ControlMethod::PairingPoll => "OPEN: poll a pairing by id; once the operator approves, returns the scoped token once.",
560 }
561 }
562
563 pub const ALL: &'static [ControlMethod] = &[
566 ControlMethod::Status,
567 ControlMethod::ConfigGet,
568 ControlMethod::ConfigSetUpstream,
569 ControlMethod::LogSetLevel,
570 ControlMethod::CacheGet,
571 ControlMethod::CacheSetCap,
572 ControlMethod::CacheClear,
573 ControlMethod::HostedStoresList,
574 ControlMethod::HostedStoresPin,
575 ControlMethod::HostedStoresUnpin,
576 ControlMethod::HostedStoresStatus,
577 ControlMethod::CapsuleFetch,
578 ControlMethod::SyncStatus,
579 ControlMethod::SyncTrigger,
580 ControlMethod::UpdaterStatus,
581 ControlMethod::UpdaterSetChannel,
582 ControlMethod::UpdaterPause,
583 ControlMethod::UpdaterResume,
584 ControlMethod::UpdaterCheckNow,
585 ControlMethod::PairingList,
586 ControlMethod::PairingApprove,
587 ControlMethod::PairingRevoke,
588 ControlMethod::PeerStatus,
589 ControlMethod::PeerCounts,
590 ControlMethod::PeersConnect,
591 ControlMethod::PeersDisconnect,
592 ControlMethod::ChiaPeersAdd,
593 ControlMethod::ChiaPeersList,
594 ControlMethod::ChiaPeersRemove,
595 ControlMethod::Subscribe,
596 ControlMethod::Unsubscribe,
597 ControlMethod::ListSubscriptions,
598 ControlMethod::WalletBalance,
599 ControlMethod::WalletCoins,
600 ControlMethod::WalletCoinById,
601 ControlMethod::WalletCoinSpend,
602 ControlMethod::WalletCoinsByParent,
603 ControlMethod::WalletArrivals,
604 ControlMethod::WalletPeak,
605 ControlMethod::WalletSyncStatus,
606 ControlMethod::WalletBroadcast,
607 ControlMethod::WalletWatch,
608 ControlMethod::WalletUnwatch,
609 ControlMethod::WalletWatched,
610 ControlMethod::WalletReservationsHeld,
611 ControlMethod::WalletReservationsReserve,
612 ControlMethod::WalletReservationsRelease,
613 ControlMethod::SpendsList,
614 ControlMethod::ProfilePutBody,
615 ControlMethod::ProfileGetBody,
616 ControlMethod::PairingRequest,
617 ControlMethod::PairingPoll,
618 ];
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624 use std::collections::BTreeSet;
625
626 #[test]
627 fn every_method_has_a_unique_wire_name() {
628 let names: BTreeSet<&str> = ControlMethod::ALL.iter().map(|m| m.name()).collect();
629 assert_eq!(
630 names.len(),
631 ControlMethod::ALL.len(),
632 "duplicate or missing wire names in the catalog"
633 );
634 }
635
636 #[test]
637 fn from_name_round_trips_every_method() {
638 for &m in ControlMethod::ALL {
639 assert_eq!(ControlMethod::from_name(m.name()), Some(m));
640 }
641 assert_eq!(ControlMethod::from_name("control.nope"), None);
642 assert_eq!(ControlMethod::from_name(""), None);
643 }
644
645 #[test]
646 fn the_token_less_surface_is_exactly_the_bootstrap_plus_the_chain_reads() {
647 let expected_open: BTreeSet<&str> = [
651 "pairing.request",
652 "pairing.poll",
653 "control.wallet.balance",
654 "control.wallet.coins",
655 "control.wallet.coinById",
656 "control.wallet.coinSpend",
657 "control.wallet.coinsByParent",
658 "control.wallet.peak",
659 "control.wallet.syncStatus",
660 "control.peerCounts",
661 ]
662 .into_iter()
663 .collect();
664 assert_eq!(
665 expected_open.len(),
666 10,
667 "the open surface is ten named methods"
668 );
669 let actual_open: BTreeSet<&str> = ControlMethod::ALL
670 .iter()
671 .filter(|m| !m.requires_auth())
672 .map(|m| m.name())
673 .collect();
674 assert_eq!(actual_open, expected_open);
675 }
676
677 #[test]
686 fn the_gated_wallet_methods_are_the_push_the_cursor_and_enrolment() {
687 let gated: Vec<&str> = ControlMethod::ALL
688 .iter()
689 .filter(|m| m.category() == Category::Wallet && m.requires_auth())
690 .map(|m| m.name())
691 .collect();
692 assert_eq!(
693 gated,
694 vec![
695 "control.wallet.arrivals",
696 "control.wallet.broadcast",
697 "control.wallet.watch",
698 "control.wallet.unwatch",
699 "control.wallet.watched",
700 "control.wallet.reservations.held",
701 "control.wallet.reservations.reserve",
702 "control.wallet.reservations.release",
703 ]
704 );
705 assert!(!ControlMethod::WalletBroadcast.is_open_read());
706 }
707
708 #[test]
720 fn the_arrival_cursor_is_not_an_open_read() {
721 assert!(
722 !ControlMethod::WalletArrivals.is_open_read(),
723 "control.wallet.arrivals discloses this node's OWN watched puzzle hashes to a caller \
724 that supplied nothing, so it MUST NOT be served token-less"
725 );
726 assert!(ControlMethod::WalletArrivals.requires_auth());
727 assert!(
728 ControlMethod::WalletCoinById.is_open_read(),
729 "the caller-addressed reads stay open -- the fix is the membership rule, not gating \
730 the wallet category"
731 );
732 }
733
734 #[test]
753 fn the_catalog_serves_every_chain_source_primitive() {
754 for wire in [
755 "control.wallet.coinById", "control.wallet.coins", "control.wallet.peak", "control.wallet.coinsByParent", "control.wallet.coinSpend", ] {
761 assert!(
762 ControlMethod::from_name(wire).is_some(),
763 "{wire} is required to implement ChainSource over the control plane"
764 );
765 }
766 }
767
768 #[test]
775 fn the_chain_primitives_are_caller_named_open_reads() {
776 for method in [
777 ControlMethod::WalletCoinSpend,
778 ControlMethod::WalletCoinsByParent,
779 ] {
780 assert!(
781 method.is_open_read(),
782 "{} names its subject in the request and discloses no node-to-address \
783 association, exactly like control.wallet.coinById",
784 method.name()
785 );
786 assert!(!method.requires_auth());
787 }
788 assert!(
789 ControlMethod::WalletArrivals.requires_auth(),
790 "the caller-supplies-nothing read stays gated -- the rule is who names the subject, \
791 not whether the bytes are on chain"
792 );
793 assert!(ControlMethod::WalletBroadcast.requires_auth());
794 }
795
796 #[test]
810 fn the_enrolment_methods_are_gated_including_the_read() {
811 for wire in [
812 "control.wallet.watch",
813 "control.wallet.unwatch",
814 "control.wallet.watched",
815 ] {
816 let method = ControlMethod::from_name(wire)
817 .unwrap_or_else(|| panic!("{wire} must be in the catalog"));
818 assert!(
819 !method.is_open_read(),
820 "{wire} either aims this node's subscriptions or names the keys it already \
821 follows, so it MUST NOT be served token-less"
822 );
823 assert!(method.requires_auth(), "{wire} must require the token");
824 assert_eq!(method.category(), Category::Wallet);
825 assert_eq!(method.routing(), Routing::Delegated);
826 }
827 assert!(
828 ControlMethod::WalletCoinById.is_open_read(),
829 "the caller-addressed reads stay open -- enrolment is gated by the membership rule, \
830 not by gating the wallet category"
831 );
832 }
833
834 #[test]
835 fn only_pairing_bootstrap_is_open_bootstrap_routed() {
836 for &m in ControlMethod::ALL {
837 let open_bootstrap = matches!(
838 m,
839 ControlMethod::PairingRequest | ControlMethod::PairingPoll
840 );
841 assert_eq!(
842 m.routing() == Routing::OpenBootstrap,
843 open_bootstrap,
844 "{} routing mismatch",
845 m.name()
846 );
847 }
848 }
849
850 #[test]
851 fn pairing_admin_methods_are_exactly_three() {
852 let admin: Vec<&str> = ControlMethod::ALL
853 .iter()
854 .filter(|m| m.is_pairing_admin())
855 .map(|m| m.name())
856 .collect();
857 assert_eq!(
858 admin,
859 vec![
860 "control.pairing.list",
861 "control.pairing.approve",
862 "control.pairing.revoke"
863 ]
864 );
865 }
866
867 #[test]
874 fn the_master_token_tier_is_pairing_admin_plus_the_trusted_peer_mutations() {
875 let master: BTreeSet<&str> = ControlMethod::ALL
876 .iter()
877 .filter(|m| m.requires_master_token())
878 .map(|m| m.name())
879 .collect();
880 let expected: BTreeSet<&str> = [
881 "control.pairing.list",
882 "control.pairing.approve",
883 "control.pairing.revoke",
884 "control.chiaPeers.add",
885 "control.chiaPeers.remove",
886 ]
887 .into_iter()
888 .collect();
889 assert_eq!(master, expected);
890
891 for &m in ControlMethod::ALL {
894 assert!(
895 !m.is_pairing_admin() || m.requires_master_token(),
896 "{} is pairing-admin but not master-tier",
897 m.name()
898 );
899 }
900 assert!(
901 master.len()
902 > ControlMethod::ALL
903 .iter()
904 .filter(|m| m.is_pairing_admin())
905 .count(),
906 "the two predicates must not be interchangeable"
907 );
908
909 for &m in ControlMethod::ALL {
911 assert!(
912 !m.requires_master_token() || m.requires_auth(),
913 "{}",
914 m.name()
915 );
916 }
917 }
918
919 #[test]
926 fn the_add_summary_authorises_only_a_node_the_operator_runs() {
927 let summary = ControlMethod::ChiaPeersAdd.summary().to_lowercase();
928 assert!(
929 summary.contains("a node you run"),
930 "add must name the operator-run scope, got: {summary}"
931 );
932 for widened in ["vouch", "otherwise trust", "trust yourself", "recommend"] {
933 assert!(
934 !summary.contains(widened),
935 "add summary widens operator trust past NC-12 with {widened:?}: {summary}"
936 );
937 }
938 }
939
940 #[test]
941 fn delegated_set_matches_the_engine_surface() {
942 let delegated: BTreeSet<&str> = ControlMethod::ALL
943 .iter()
944 .filter(|m| m.routing() == Routing::Delegated)
945 .map(|m| m.name())
946 .collect();
947 let expected: BTreeSet<&str> = [
948 "control.wallet.coins",
949 "control.wallet.coinById",
950 "control.wallet.coinSpend",
951 "control.wallet.coinsByParent",
952 "control.wallet.arrivals",
953 "control.wallet.peak",
954 "control.wallet.syncStatus",
955 "control.wallet.broadcast",
956 "control.wallet.watch",
957 "control.wallet.unwatch",
958 "control.wallet.watched",
959 "control.wallet.reservations.held",
960 "control.wallet.reservations.reserve",
961 "control.wallet.reservations.release",
962 "control.profile.putBody",
963 "control.profile.getBody",
964 "control.peerStatus",
965 "control.peerCounts",
966 "control.peers.connect",
967 "control.peers.disconnect",
968 "control.subscribe",
969 "control.unsubscribe",
970 "control.listSubscriptions",
971 "control.wallet.balance",
972 ]
973 .into_iter()
974 .collect();
975 assert_eq!(delegated, expected);
976 }
977
978 #[test]
985 fn the_trusted_chia_peer_methods_are_gated_and_disclose_the_corroboration_bypass() {
986 let declared: BTreeSet<&str> = ControlMethod::ALL.iter().map(|m| m.name()).collect();
987 for name in [
988 "control.chiaPeers.add",
989 "control.chiaPeers.list",
990 "control.chiaPeers.remove",
991 ] {
992 assert!(declared.contains(name), "{name} is not in the catalog");
993 let m = ControlMethod::from_name(name).expect("from_name round-trips");
994 assert_eq!(m.category(), Category::Peers, "{name} is a peers method");
995 assert_eq!(m.routing(), Routing::Owned, "{name} is served by the shell");
996 assert!(m.requires_auth(), "{name} must require the control token");
997 assert!(!m.is_open_read(), "{name} is not an open read");
998 }
999 assert!(ControlMethod::ChiaPeersAdd.requires_master_token());
1003 assert!(ControlMethod::ChiaPeersRemove.requires_master_token());
1004 assert!(
1005 !ControlMethod::ChiaPeersList.requires_master_token(),
1006 "list grants nothing that outlives the token; gating it would blind a paired client \
1007 to the trust state it is subject to"
1008 );
1009 for name in ["control.chiaPeers.add", "control.chiaPeers.remove"] {
1013 let summary = ControlMethod::from_name(name).unwrap().summary();
1014 assert!(
1015 summary.to_lowercase().contains("corroboration"),
1016 "{name} summary must name the corroboration bypass, got: {summary}"
1017 );
1018 }
1019 }
1020
1021 #[test]
1022 fn every_method_has_a_nonempty_summary() {
1023 for &m in ControlMethod::ALL {
1024 assert!(!m.summary().is_empty(), "{} has no summary", m.name());
1025 }
1026 }
1027}