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 /// dig-profile BODIES: handing the node the bytes a confirmed on-chain root commits to, and
58 /// reading one back. The chain root itself is never written here -- dig-app signs and pushes
59 /// that (§908); this category moves only the bytes an already-confirmed root commits to.
60 Profile,
61}
62
63/// A dig-node CONTROL method.
64///
65/// `#[non_exhaustive]` so adding a method in a minor release is additive; downstream matches must
66/// carry a `_ => …` arm. Convert to/from the wire name with [`ControlMethod::name`] /
67/// [`ControlMethod::from_name`].
68#[non_exhaustive]
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70pub enum ControlMethod {
71 // ---- Status / config / log (shell-owned) ----
72 /// `control.status` — a rich node status snapshot.
73 Status,
74 /// `control.config.get` — the node's effective configuration.
75 ConfigGet,
76 /// `control.config.setUpstream` — persist an upstream-RPC override (effective on restart).
77 ConfigSetUpstream,
78 /// `control.log.setLevel` — live-swap the running node's tracing level filter.
79 LogSetLevel,
80
81 // ---- Cache (shell-owned) ----
82 /// `control.cache.get` — the on-disk cache view (cap/used/dir/shared).
83 CacheGet,
84 /// `control.cache.setCap` — set the cache size cap (floored at 64 MiB).
85 CacheSetCap,
86 /// `control.cache.clear` — delete all locally cached content.
87 CacheClear,
88
89 // ---- Hosted stores (shell-owned) ----
90 /// `control.hostedStores.list` — every held/pinned store with its cached capsules.
91 HostedStoresList,
92 /// `control.hostedStores.pin` — pin a store (and pre-fetch when a root is given).
93 HostedStoresPin,
94 /// `control.hostedStores.unpin` — unpin a store and evict its cached capsules.
95 HostedStoresUnpin,
96 /// `control.hostedStores.status` — per-store pinned flag + cached capsules.
97 HostedStoresStatus,
98 /// `control.capsule.fetch` — start (or report already-cached) a P2P whole-capsule pull for
99 /// one store+root, over the recursive discover-then-dial path rather than the §21 HTTP sync.
100 CapsuleFetch,
101
102 // ---- §21 sync (shell-owned) ----
103 /// `control.sync.status` — whether authenticated whole-store sync is available + pin coverage.
104 SyncStatus,
105 /// `control.sync.trigger` — trigger a §21 sync for one capsule (storeId + root).
106 SyncTrigger,
107
108 // ---- Updater beacon proxy (shell-owned) ----
109 /// `control.updater.status` — the DIG auto-update beacon's current status.
110 UpdaterStatus,
111 /// `control.updater.setChannel` — set the beacon's update channel.
112 UpdaterSetChannel,
113 /// `control.updater.pause` — suspend auto-updates (optionally until a unix time).
114 UpdaterPause,
115 /// `control.updater.resume` — resume auto-updates.
116 UpdaterResume,
117 /// `control.updater.checkNow` — force an immediate update check.
118 UpdaterCheckNow,
119
120 // ---- Pairing administration (shell-owned, MASTER-token only) ----
121 /// `control.pairing.list` — list pending pairing requests + issued paired tokens.
122 PairingList,
123 /// `control.pairing.approve` — approve a pending pairing, minting a scoped token.
124 PairingApprove,
125 /// `control.pairing.revoke` — revoke an issued paired token.
126 PairingRevoke,
127
128 // ---- Peers (delegated to the engine) ----
129 /// `control.peerStatus` — live peer-pool + relay-reservation snapshot.
130 PeerStatus,
131 /// `control.peerCounts` — how many peers this node holds on EACH network (DIG and Chia).
132 PeerCounts,
133 /// `control.peers.connect` — dial a peer by address / resolve a connected peer_id.
134 PeersConnect,
135 /// `control.peers.disconnect` — drop a pooled peer by peer_id.
136 PeersDisconnect,
137
138 // ---- Trusted CHIA full-node peers (shell-owned) ----
139 //
140 // A DIFFERENT network from `control.peers.*` above, which are DIG gossip peers. These name
141 // Chia full nodes the wallet replica will TRUST, and trust here is a real cost: NC-12 makes
142 // dialled peers untrusted precisely so that agreement across several concurrently-queried
143 // peers is what makes a read safe. A trusted peer is exempted from that agreement, so a wrong
144 // or hostile one is believed on its own. Every surface that offers these MUST say so.
145 //
146 // Trust comes from the operator declaring a node THEIR OWN — that is the whole of the
147 // authorisation, and the wording everywhere in this crate says exactly that. It is not
148 // "a node you vouch for": the unbounded authority a trusted peer holds is justified by the
149 // operator controlling both ends, which is false of a stranger's node however well
150 // recommended. A person can be talked into vouching for an address; they cannot be talked
151 // into believing they run it.
152 /// `control.chiaPeers.add` — trust a Chia full node you RUN, bypassing corroboration for it.
153 ChiaPeersAdd,
154 /// `control.chiaPeers.list` — the trusted Chia full-node peers this node tracks.
155 ChiaPeersList,
156 /// `control.chiaPeers.remove` — stop trusting a Chia full node (optionally banning it).
157 ChiaPeersRemove,
158
159 // ---- Subscriptions (delegated to the engine) ----
160 /// `control.subscribe` — subscribe the node to a store (watch + gap-fill).
161 Subscribe,
162 /// `control.unsubscribe` — stop watching a store.
163 Unsubscribe,
164 /// `control.listSubscriptions` — the node's persisted subscription set.
165 ListSubscriptions,
166
167 // ---- Wallet chain transport (delegated to the engine) ----
168 /// `control.wallet.balance` — read an address's confirmed spendable balance for an asset.
169 WalletBalance,
170 /// `control.wallet.coins` — read an address's spendable coin records for an asset.
171 WalletCoins,
172 /// `control.wallet.coinById` — read ONE coin record by coin id, spent or unspent.
173 WalletCoinById,
174 /// `control.wallet.coinSpend` — read the SPEND that spent a coin (puzzle reveal + solution).
175 WalletCoinSpend,
176 /// `control.wallet.coinsByParent` — read the direct children a coin's spend created (one hop).
177 WalletCoinsByParent,
178 /// `control.wallet.arrivals` — read confirmed INCOMING funds since a cursor position.
179 WalletArrivals,
180 /// `control.wallet.peak` — read the node's current chain peak height.
181 WalletPeak,
182 /// `control.wallet.syncStatus` — read whether the wallet's chain replica is being kept current.
183 WalletSyncStatus,
184 /// `control.wallet.broadcast` — push an ALREADY-SIGNED spend bundle to the network.
185 WalletBroadcast,
186 /// `control.wallet.watch` — enrol PUBLIC keys for the node's chain replica to follow.
187 WalletWatch,
188 /// `control.wallet.unwatch` — deregister enrolled public keys, so the following stops.
189 WalletUnwatch,
190 /// `control.wallet.watched` — list the public keys currently enrolled.
191 WalletWatched,
192
193 // ---- dig-profile bodies (delegated to the engine) ----
194 /// `control.profile.putBody` — hand the node the profile body a CONFIRMED chain root commits to.
195 ProfilePutBody,
196 /// `control.profile.getBody` — read back the profile body this node holds at a given root.
197 ProfileGetBody,
198
199 // ---- Pairing bootstrap (OPEN — no token) ----
200 /// `pairing.request` — request a control-token pairing (returns a code to compare).
201 PairingRequest,
202 /// `pairing.poll` — poll a pairing; once the operator approves, returns the scoped token once.
203 PairingPoll,
204}
205
206impl ControlMethod {
207 /// The stable JSON-RPC wire name. Never derived from anything else — the published contract.
208 pub const fn name(self) -> &'static str {
209 match self {
210 ControlMethod::Status => "control.status",
211 ControlMethod::ConfigGet => "control.config.get",
212 ControlMethod::ConfigSetUpstream => "control.config.setUpstream",
213 ControlMethod::LogSetLevel => "control.log.setLevel",
214 ControlMethod::CacheGet => "control.cache.get",
215 ControlMethod::CacheSetCap => "control.cache.setCap",
216 ControlMethod::CacheClear => "control.cache.clear",
217 ControlMethod::HostedStoresList => "control.hostedStores.list",
218 ControlMethod::HostedStoresPin => "control.hostedStores.pin",
219 ControlMethod::HostedStoresUnpin => "control.hostedStores.unpin",
220 ControlMethod::HostedStoresStatus => "control.hostedStores.status",
221 ControlMethod::CapsuleFetch => "control.capsule.fetch",
222 ControlMethod::SyncStatus => "control.sync.status",
223 ControlMethod::SyncTrigger => "control.sync.trigger",
224 ControlMethod::UpdaterStatus => "control.updater.status",
225 ControlMethod::UpdaterSetChannel => "control.updater.setChannel",
226 ControlMethod::UpdaterPause => "control.updater.pause",
227 ControlMethod::UpdaterResume => "control.updater.resume",
228 ControlMethod::UpdaterCheckNow => "control.updater.checkNow",
229 ControlMethod::PairingList => "control.pairing.list",
230 ControlMethod::PairingApprove => "control.pairing.approve",
231 ControlMethod::PairingRevoke => "control.pairing.revoke",
232 ControlMethod::PeerStatus => "control.peerStatus",
233 ControlMethod::PeerCounts => "control.peerCounts",
234 ControlMethod::PeersConnect => "control.peers.connect",
235 ControlMethod::PeersDisconnect => "control.peers.disconnect",
236 ControlMethod::ChiaPeersAdd => "control.chiaPeers.add",
237 ControlMethod::ChiaPeersList => "control.chiaPeers.list",
238 ControlMethod::ChiaPeersRemove => "control.chiaPeers.remove",
239 ControlMethod::Subscribe => "control.subscribe",
240 ControlMethod::Unsubscribe => "control.unsubscribe",
241 ControlMethod::ListSubscriptions => "control.listSubscriptions",
242 ControlMethod::WalletBalance => "control.wallet.balance",
243 ControlMethod::WalletCoins => "control.wallet.coins",
244 ControlMethod::WalletCoinById => "control.wallet.coinById",
245 ControlMethod::WalletCoinSpend => "control.wallet.coinSpend",
246 ControlMethod::WalletCoinsByParent => "control.wallet.coinsByParent",
247 ControlMethod::WalletArrivals => "control.wallet.arrivals",
248 ControlMethod::WalletPeak => "control.wallet.peak",
249 ControlMethod::WalletSyncStatus => "control.wallet.syncStatus",
250 ControlMethod::WalletBroadcast => "control.wallet.broadcast",
251 ControlMethod::WalletWatch => "control.wallet.watch",
252 ControlMethod::WalletUnwatch => "control.wallet.unwatch",
253 ControlMethod::WalletWatched => "control.wallet.watched",
254 ControlMethod::ProfilePutBody => "control.profile.putBody",
255 ControlMethod::ProfileGetBody => "control.profile.getBody",
256 ControlMethod::PairingRequest => "pairing.request",
257 ControlMethod::PairingPoll => "pairing.poll",
258 }
259 }
260
261 /// Resolve a wire name back to its [`ControlMethod`], or `None` for an unknown name.
262 pub fn from_name(name: &str) -> Option<ControlMethod> {
263 ControlMethod::ALL
264 .iter()
265 .copied()
266 .find(|m| m.name() == name)
267 }
268
269 /// Does calling this method require the local control token?
270 ///
271 /// Three groups are reachable WITHOUT one, and they are open for two different reasons:
272 ///
273 /// - the pairing bootstrap (`pairing.request` / `pairing.poll`), so a token-less client can
274 /// obtain a token at all;
275 /// - the PEER COUNTS (`control.peerCounts`), which disclose three integers about this node's
276 /// own connectivity and no address, endpoint or secret;
277 /// - the wallet CALLER-ADDRESSED CHAIN READS (`control.wallet.balance` / `.coins` /
278 /// `.coinById` / `.coinSpend` / `.coinsByParent`) and the node's own chain POSITION
279 /// (`.peak` / `.syncStatus`), because each needs only PUBLIC chain data the CALLER already
280 /// named — an address, or a coin id; never a seed, a key, or a signature — and dig-node has
281 /// served `control.wallet.balance` open since #1851. A person whose node runs as a service
282 /// with an unreadable token file can still see their own money.
283 ///
284 /// Five wallet methods are deliberately NOT in that second group:
285 ///
286 /// - `control.wallet.broadcast` puts bytes on the network, so the token is what stands between
287 /// a local process and a broadcast — a mutation on the chain state itself;
288 /// - `control.wallet.watch` and `.unwatch` aim what this node follows, so they are mutations
289 /// of this node's own watched-key set;
290 /// - `control.wallet.arrivals` and `.watched` take nothing from the caller and answer back
291 /// with this node's OWN state — watched puzzle hashes and enrolled public keys respectively.
292 ///
293 /// See [`ControlMethod::is_open_read`]. On all five, `UNAUTHORIZED` genuinely means
294 /// *unauthorized*.
295 pub const fn requires_auth(self) -> bool {
296 !self.is_open_read()
297 && !matches!(
298 self,
299 ControlMethod::PairingRequest | ControlMethod::PairingPoll
300 )
301 }
302
303 /// Is this an OPEN READ — served without a control token?
304 ///
305 /// Two kinds of method qualify, and they are open for different reasons:
306 ///
307 /// - the wallet CHAIN READS (`control.wallet.balance` / `.coins` / `.coinById` / `.coinSpend` /
308 /// `.coinsByParent` / `.peak` / `.syncStatus`), which need only PUBLIC chain data — an
309 /// address, or a coin id; never a seed, a key, or a signature. On the first five the CALLER
310 /// supplies the address or coin id, so the node relays a public fact and discloses no
311 /// association with itself; the last two name the node's own chain position and no address
312 /// at all;
313 /// - `control.peerCounts`, which is NOT a chain read: it discloses three integers about this
314 /// node's own connectivity, and no address, endpoint, peer identity or secret. The identity
315 /// and topology half of the same subject stays gated behind `control.peerStatus`.
316 ///
317 /// Naming both reasons matters more than it looks. The test for membership is *does this
318 /// disclose only data that is already public, or a bare count of this node's own state?* — NOT
319 /// *is it a chain read?* A future method judged against the narrower phrasing, and found to
320 /// contradict a member that was already there, invites widening the predicate by analogy rather
321 /// than against the rule.
322 ///
323 /// `control.wallet.arrivals` is the worked example, and it was briefly a member. It passes the
324 /// narrower phrasing — every field it returns is a public chain fact — and fails the rule: the
325 /// caller supplies NOTHING, so the node volunteers its OWN watched puzzle hashes together with
326 /// the full receive history behind them. The individual facts are public; the ASSOCIATION
327 /// between this node and those addresses is not, and that association is the whole answer. A
328 /// token-less caller could then feed those addresses back into the caller-addressed reads.
329 /// Membership turns on *who names the address*, never on whether the bytes are on chain.
330 ///
331 /// Stated on the contract rather than discovered by calling, because the two refusals a client
332 /// can get here demand OPPOSITE remedies. On an open read, `UNAUTHORIZED` can only come from a
333 /// node build that predates the method and gates it generically, so the remedy is an upgrade.
334 /// On a gated method — the push — `UNAUTHORIZED` means exactly what it says, and the remedy is
335 /// the token. A client that maps the two the same way sends somebody to fix the wrong thing.
336 pub const fn is_open_read(self) -> bool {
337 matches!(
338 self,
339 ControlMethod::WalletBalance
340 | ControlMethod::WalletCoins
341 | ControlMethod::WalletCoinById
342 | ControlMethod::WalletCoinSpend
343 | ControlMethod::WalletCoinsByParent
344 | ControlMethod::WalletPeak
345 | ControlMethod::WalletSyncStatus
346 | ControlMethod::PeerCounts
347 )
348 }
349
350 /// Is this a PAIRING-ADMINISTRATION method that requires the MASTER control token specifically?
351 ///
352 /// A paired (scoped) token can drive ordinary `control.*` mutations but MUST NOT mint more
353 /// tokens or revoke itself — so listing/approving/revoking pairings requires the master token
354 /// (a local file read), never a paired token.
355 ///
356 /// This names the pairing LIFECYCLE only. The predicate an auth gate consults is
357 /// [`ControlMethod::requires_master_token`], of which this is a strict subset.
358 pub const fn is_pairing_admin(self) -> bool {
359 matches!(
360 self,
361 ControlMethod::PairingList
362 | ControlMethod::PairingApprove
363 | ControlMethod::PairingRevoke
364 )
365 }
366
367 /// Does this method require the MASTER control token — the local file read — rather than any
368 /// valid token?
369 ///
370 /// **This, not [`ControlMethod::is_pairing_admin`], is the predicate an auth gate consults.**
371 /// The master tier is not "pairing administration"; it is every method whose effect OUTLIVES
372 /// the token that invoked it, and pairing administration is one instance of that shape.
373 ///
374 /// The rule, stated so a later method can be judged against it rather than by analogy: a
375 /// method belongs here when a caller holding a paired token could use it to acquire authority
376 /// it keeps AFTER that token is revoked. `pairing.revoke` is the designated remedy for a
377 /// compromised paired app, so any method that survives it has escaped the remedy.
378 ///
379 /// The two members outside the pairing lifecycle are `control.chiaPeers.add` and
380 /// `control.chiaPeers.remove`, and they are here for exactly that reason. `add` writes a
381 /// standing entry into the peer store the wallet replica reads, and a peer in that set is
382 /// believed WITHOUT corroboration — it can dictate money-bearing chain facts (peak height, and
383 /// therefore confirmation counts). Once written, the caller no longer needs the token at all,
384 /// and revoking the token does not remove the entry. A paired token must therefore not be able
385 /// to write one. `remove` is the only un-trust remedy and is gated with it, so a paired token
386 /// cannot strip the peers an operator deliberately trusts.
387 ///
388 /// `control.chiaPeers.list` deliberately stays on the ordinary token tier: it is a READ, it
389 /// grants nothing that outlives the token, and gating it would leave a paired client unable to
390 /// show the operator the trust state it is subject to. That matches `control.wallet.arrivals`,
391 /// which is gated at the ordinary tier for disclosing an association without conferring
392 /// authority.
393 pub const fn requires_master_token(self) -> bool {
394 self.is_pairing_admin()
395 || matches!(
396 self,
397 ControlMethod::ChiaPeersAdd | ControlMethod::ChiaPeersRemove
398 )
399 }
400
401 /// How the node routes this method (shell-owned, engine-delegated, or open bootstrap).
402 pub const fn routing(self) -> Routing {
403 match self {
404 ControlMethod::PeerStatus
405 | ControlMethod::PeerCounts
406 | ControlMethod::PeersConnect
407 | ControlMethod::PeersDisconnect
408 | ControlMethod::Subscribe
409 | ControlMethod::Unsubscribe
410 | ControlMethod::ListSubscriptions
411 | ControlMethod::WalletBalance
412 | ControlMethod::WalletCoins
413 | ControlMethod::WalletCoinById
414 | ControlMethod::WalletCoinSpend
415 | ControlMethod::WalletCoinsByParent
416 | ControlMethod::WalletArrivals
417 | ControlMethod::WalletPeak
418 | ControlMethod::WalletSyncStatus
419 | ControlMethod::WalletBroadcast
420 | ControlMethod::WalletWatch
421 | ControlMethod::WalletUnwatch
422 | ControlMethod::WalletWatched
423 | ControlMethod::ProfilePutBody
424 | ControlMethod::ProfileGetBody => Routing::Delegated,
425 ControlMethod::PairingRequest | ControlMethod::PairingPoll => Routing::OpenBootstrap,
426 _ => Routing::Owned,
427 }
428 }
429
430 /// The functional area this method belongs to.
431 pub const fn category(self) -> Category {
432 match self {
433 ControlMethod::Status => Category::Status,
434 ControlMethod::ConfigGet | ControlMethod::ConfigSetUpstream => Category::Config,
435 ControlMethod::LogSetLevel => Category::Log,
436 ControlMethod::CacheGet | ControlMethod::CacheSetCap | ControlMethod::CacheClear => {
437 Category::Cache
438 }
439 ControlMethod::HostedStoresList
440 | ControlMethod::HostedStoresPin
441 | ControlMethod::HostedStoresUnpin
442 | ControlMethod::HostedStoresStatus
443 | ControlMethod::CapsuleFetch => Category::HostedStores,
444 ControlMethod::SyncStatus | ControlMethod::SyncTrigger => Category::Sync,
445 ControlMethod::UpdaterStatus
446 | ControlMethod::UpdaterSetChannel
447 | ControlMethod::UpdaterPause
448 | ControlMethod::UpdaterResume
449 | ControlMethod::UpdaterCheckNow => Category::Updater,
450 ControlMethod::PairingList
451 | ControlMethod::PairingApprove
452 | ControlMethod::PairingRevoke
453 | ControlMethod::PairingRequest
454 | ControlMethod::PairingPoll => Category::Pairing,
455 ControlMethod::PeerStatus
456 | ControlMethod::PeerCounts
457 | ControlMethod::PeersConnect
458 | ControlMethod::PeersDisconnect
459 | ControlMethod::ChiaPeersAdd
460 | ControlMethod::ChiaPeersList
461 | ControlMethod::ChiaPeersRemove => Category::Peers,
462 ControlMethod::Subscribe
463 | ControlMethod::Unsubscribe
464 | ControlMethod::ListSubscriptions => Category::Subscriptions,
465 ControlMethod::WalletBalance
466 | ControlMethod::WalletCoins
467 | ControlMethod::WalletCoinById
468 | ControlMethod::WalletCoinSpend
469 | ControlMethod::WalletCoinsByParent
470 | ControlMethod::WalletArrivals
471 | ControlMethod::WalletPeak
472 | ControlMethod::WalletSyncStatus
473 | ControlMethod::WalletBroadcast
474 | ControlMethod::WalletWatch
475 | ControlMethod::WalletUnwatch
476 | ControlMethod::WalletWatched => Category::Wallet,
477 ControlMethod::ProfilePutBody | ControlMethod::ProfileGetBody => Category::Profile,
478 }
479 }
480
481 /// A one-line human/agent description for the discovery catalogue.
482 pub const fn summary(self) -> &'static str {
483 match self {
484 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.",
485 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.",
486 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.",
487 ControlMethod::Status => "A rich node status snapshot (version, uptime, addr, cache, hosted/pinned counts, sync availability).",
488 ControlMethod::ConfigGet => "The node's effective configuration (addr/port, upstream + override, cache dir/shared, config path, sync availability).",
489 ControlMethod::ConfigSetUpstream => "Persist an upstream-RPC override; takes effect on next node start (requires_restart).",
490 ControlMethod::LogSetLevel => "Live-swap the running node's tracing EnvFilter directive (not persisted).",
491 ControlMethod::CacheGet => "The on-disk content-cache view: cap_bytes, used_bytes, dir, shared.",
492 ControlMethod::CacheSetCap => "Set the on-disk cache size cap in bytes (floored at 64 MiB).",
493 ControlMethod::CacheClear => "Delete all locally cached DIG content.",
494 ControlMethod::HostedStoresList => "Every held/pinned store, merged, with each store's cached capsules and a pinned flag.",
495 ControlMethod::HostedStoresPin => "Pin a store (storeId[:rootHash]); pre-fetches the capsule when a root is given and §21 sync is available.",
496 ControlMethod::HostedStoresUnpin => "Unpin a store and evict its cached capsules.",
497 ControlMethod::HostedStoresStatus => "Per-store status: pinned flag, cached capsules, total bytes.",
498 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.",
499 ControlMethod::SyncStatus => "Whether authenticated §21 whole-store sync is available, plus pinned-store cache coverage.",
500 ControlMethod::SyncTrigger => "Trigger a §21 sync for one capsule (storeId + root).",
501 ControlMethod::UpdaterStatus => "The DIG auto-update beacon's current status (proxied from dig-updater).",
502 ControlMethod::UpdaterSetChannel => "Set the beacon's update channel (\"nightly\" | \"stable\").",
503 ControlMethod::UpdaterPause => "Suspend the beacon's auto-updates (optionally until a unix time).",
504 ControlMethod::UpdaterResume => "Resume the beacon's auto-updates.",
505 ControlMethod::UpdaterCheckNow => "Force an immediate beacon update check.",
506 ControlMethod::PairingList => "List pending pairing requests and issued paired tokens (MASTER token only).",
507 ControlMethod::PairingApprove => "Approve a pending pairing, minting a scoped token (MASTER token only).",
508 ControlMethod::PairingRevoke => "Revoke an issued paired token by token_id (MASTER token only).",
509 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.",
510 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.",
511 ControlMethod::PeersConnect => "Dial a peer by address, or resolve an already-connected peer_id, via the live gossip pool.",
512 ControlMethod::PeersDisconnect => "Drop a pooled peer by peer_id, closing its mTLS link (idempotent).",
513 ControlMethod::Subscribe => "Subscribe the node to a store it actively watches and gap-fills.",
514 ControlMethod::Unsubscribe => "Stop watching a store.",
515 ControlMethod::ListSubscriptions => "The node's persisted subscription set + count.",
516 ControlMethod::WalletCoins => "READ-only: the spendable coin records for an address + asset, with the tier that answered and the height they reflect.",
517 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.",
518 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.",
519 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.",
520 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`.",
521 ControlMethod::WalletPeak => "READ-only: the node's current chain peak height, independent of any address.",
522 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).",
523 ControlMethod::WalletBroadcast => "Push an ALREADY-SIGNED spend bundle to the network; the node never signs. TOKEN-GATED.",
524 ControlMethod::WalletBalance => "READ-only: the confirmed spendable balance for an address + asset (plus pending, sync freshness, and the peak height it reflects).",
525 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.",
526 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.",
527 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.",
528 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.",
529 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.",
530 ControlMethod::PairingRequest => "OPEN: request a control-token pairing; returns a pairing_id + pairing_code to compare.",
531 ControlMethod::PairingPoll => "OPEN: poll a pairing by id; once the operator approves, returns the scoped token once.",
532 }
533 }
534
535 /// Every catalogued method, in a stable order — the enumeration a machine reads to discover the
536 /// full control surface, and the anchor the conformance KATs pin against.
537 pub const ALL: &'static [ControlMethod] = &[
538 ControlMethod::Status,
539 ControlMethod::ConfigGet,
540 ControlMethod::ConfigSetUpstream,
541 ControlMethod::LogSetLevel,
542 ControlMethod::CacheGet,
543 ControlMethod::CacheSetCap,
544 ControlMethod::CacheClear,
545 ControlMethod::HostedStoresList,
546 ControlMethod::HostedStoresPin,
547 ControlMethod::HostedStoresUnpin,
548 ControlMethod::HostedStoresStatus,
549 ControlMethod::CapsuleFetch,
550 ControlMethod::SyncStatus,
551 ControlMethod::SyncTrigger,
552 ControlMethod::UpdaterStatus,
553 ControlMethod::UpdaterSetChannel,
554 ControlMethod::UpdaterPause,
555 ControlMethod::UpdaterResume,
556 ControlMethod::UpdaterCheckNow,
557 ControlMethod::PairingList,
558 ControlMethod::PairingApprove,
559 ControlMethod::PairingRevoke,
560 ControlMethod::PeerStatus,
561 ControlMethod::PeerCounts,
562 ControlMethod::PeersConnect,
563 ControlMethod::PeersDisconnect,
564 ControlMethod::ChiaPeersAdd,
565 ControlMethod::ChiaPeersList,
566 ControlMethod::ChiaPeersRemove,
567 ControlMethod::Subscribe,
568 ControlMethod::Unsubscribe,
569 ControlMethod::ListSubscriptions,
570 ControlMethod::WalletBalance,
571 ControlMethod::WalletCoins,
572 ControlMethod::WalletCoinById,
573 ControlMethod::WalletCoinSpend,
574 ControlMethod::WalletCoinsByParent,
575 ControlMethod::WalletArrivals,
576 ControlMethod::WalletPeak,
577 ControlMethod::WalletSyncStatus,
578 ControlMethod::WalletBroadcast,
579 ControlMethod::WalletWatch,
580 ControlMethod::WalletUnwatch,
581 ControlMethod::WalletWatched,
582 ControlMethod::ProfilePutBody,
583 ControlMethod::ProfileGetBody,
584 ControlMethod::PairingRequest,
585 ControlMethod::PairingPoll,
586 ];
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use std::collections::BTreeSet;
593
594 #[test]
595 fn every_method_has_a_unique_wire_name() {
596 let names: BTreeSet<&str> = ControlMethod::ALL.iter().map(|m| m.name()).collect();
597 assert_eq!(
598 names.len(),
599 ControlMethod::ALL.len(),
600 "duplicate or missing wire names in the catalog"
601 );
602 }
603
604 #[test]
605 fn from_name_round_trips_every_method() {
606 for &m in ControlMethod::ALL {
607 assert_eq!(ControlMethod::from_name(m.name()), Some(m));
608 }
609 assert_eq!(ControlMethod::from_name("control.nope"), None);
610 assert_eq!(ControlMethod::from_name(""), None);
611 }
612
613 #[test]
614 fn the_token_less_surface_is_exactly_the_bootstrap_plus_the_chain_reads() {
615 // Written out rather than derived from `is_open_read`, so this pins the SET and not the
616 // implementation's opinion of itself. A method added to the open surface must be added
617 // here deliberately -- which is the review step a broadcast must never slip past.
618 let expected_open: BTreeSet<&str> = [
619 "pairing.request",
620 "pairing.poll",
621 "control.wallet.balance",
622 "control.wallet.coins",
623 "control.wallet.coinById",
624 "control.wallet.coinSpend",
625 "control.wallet.coinsByParent",
626 "control.wallet.peak",
627 "control.wallet.syncStatus",
628 "control.peerCounts",
629 ]
630 .into_iter()
631 .collect();
632 assert_eq!(
633 expected_open.len(),
634 10,
635 "the open surface is ten named methods"
636 );
637 let actual_open: BTreeSet<&str> = ControlMethod::ALL
638 .iter()
639 .filter(|m| !m.requires_auth())
640 .map(|m| m.name())
641 .collect();
642 assert_eq!(actual_open, expected_open);
643 }
644
645 /// **The gated wallet methods are the push, the arrival cursor, and the three enrolment
646 /// methods.** The fixture varies one thing -- which wallet method is asked -- against a category
647 /// whose other members ARE open, so both nearest wrong implementations fail here: one that opens
648 /// the whole category (the state this crate shipped in at `1190a18`) and one that gates it
649 /// wholesale.
650 ///
651 /// Written out in catalog order rather than derived, so a method joining the gated side is a
652 /// deliberate edit here -- the review step a broadcast, or an enrolment, must never slip past.
653 #[test]
654 fn the_gated_wallet_methods_are_the_push_the_cursor_and_enrolment() {
655 let gated: Vec<&str> = ControlMethod::ALL
656 .iter()
657 .filter(|m| m.category() == Category::Wallet && m.requires_auth())
658 .map(|m| m.name())
659 .collect();
660 assert_eq!(
661 gated,
662 vec![
663 "control.wallet.arrivals",
664 "control.wallet.broadcast",
665 "control.wallet.watch",
666 "control.wallet.unwatch",
667 "control.wallet.watched",
668 ]
669 );
670 assert!(!ControlMethod::WalletBroadcast.is_open_read());
671 }
672
673 /// **The arrival cursor is NOT an open read, and the reason is not "is it a chain read?".**
674 ///
675 /// The rule is *who names the address*. `control.wallet.arrivals` takes only a cursor, so the
676 /// node volunteers its OWN watched puzzle hashes and the receive history behind them -- the
677 /// node-to-address association, which is not public, and which a token-less caller could then
678 /// replay into the caller-addressed reads.
679 ///
680 /// The control keeps `control.wallet.coinById` in the same assertion: it is the neighbour the
681 /// analogy was drawn from, it is still open, and it stays open because its CALLER supplies the
682 /// coin id. Without that control this test would also pass on a wholesale gating of the wallet
683 /// category, which is a different (and wrong) implementation.
684 #[test]
685 fn the_arrival_cursor_is_not_an_open_read() {
686 assert!(
687 !ControlMethod::WalletArrivals.is_open_read(),
688 "control.wallet.arrivals discloses this node's OWN watched puzzle hashes to a caller \
689 that supplied nothing, so it MUST NOT be served token-less"
690 );
691 assert!(ControlMethod::WalletArrivals.requires_auth());
692 assert!(
693 ControlMethod::WalletCoinById.is_open_read(),
694 "the caller-addressed reads stay open -- the fix is the membership rule, not gating \
695 the wallet category"
696 );
697 }
698
699 /// **The control plane names every chain primitive `ChainSource` needs.**
700 ///
701 /// The list is written out rather than derived, because the property under test is a claim about
702 /// ANOTHER crate's trait (`dig-chainsource-interface`'s `ChainSource`) that no compiler here can
703 /// check. Five of its seven methods need a control method of their own. The other two need none:
704 /// `parent_spend` is a trait DEFAULT composed from `coin_record` + `coin_spend`, and
705 /// `resolve_singleton_lineage` is composed CLIENT-side from the primitives below rather than
706 /// served as a walk the node performs.
707 ///
708 /// `block_timestamp` is deliberately ABSENT from the control plane. dig-node's light client
709 /// (`chia-peer`'s `ChiaPeerProvider`) does not index block timestamps and answers `Unsupported`,
710 /// so a control method for it could only ever be refused — a surface that looks live and does
711 /// nothing. A consumer mirrors that refusal honestly; if one ever genuinely needs the value, the
712 /// method is an additive minor at that point.
713 ///
714 /// A missing name here is not a cosmetic gap: a client that cannot answer one of these cannot
715 /// implement the trait at all, which is what made a dig-profile mint structurally impossible
716 /// through the node before these two were added (dig_ecosystem#2572).
717 #[test]
718 fn the_catalog_serves_every_chain_source_primitive() {
719 for wire in [
720 "control.wallet.coinById", // coin_record
721 "control.wallet.coins", // coin_records_by_puzzle_hash
722 "control.wallet.peak", // peak_height
723 "control.wallet.coinsByParent", // coin_records_by_parent
724 "control.wallet.coinSpend", // coin_spend
725 ] {
726 assert!(
727 ControlMethod::from_name(wire).is_some(),
728 "{wire} is required to implement ChainSource over the control plane"
729 );
730 }
731 }
732
733 /// **The two chain primitives are `coinById`'s neighbours, not `arrivals`'.**
734 ///
735 /// Each takes a caller-supplied coin id and returns a deterministic public chain fact,
736 /// so the membership rule — *who names the subject* — puts them on the open side. The gated
737 /// control in the same assertion is what makes the test load-bearing: without it, a wholesale
738 /// opening of the wallet category would pass, and that is a different (and wrong) implementation.
739 #[test]
740 fn the_chain_primitives_are_caller_named_open_reads() {
741 for method in [
742 ControlMethod::WalletCoinSpend,
743 ControlMethod::WalletCoinsByParent,
744 ] {
745 assert!(
746 method.is_open_read(),
747 "{} names its subject in the request and discloses no node-to-address \
748 association, exactly like control.wallet.coinById",
749 method.name()
750 );
751 assert!(!method.requires_auth());
752 }
753 assert!(
754 ControlMethod::WalletArrivals.requires_auth(),
755 "the caller-supplies-nothing read stays gated -- the rule is who names the subject, \
756 not whether the bytes are on chain"
757 );
758 assert!(ControlMethod::WalletBroadcast.requires_auth());
759 }
760
761 /// **All three enrolment methods are gated — including the one that only reads.**
762 ///
763 /// `control.wallet.watch` and `.unwatch` aim what the node follows, so they are mutations and the
764 /// question barely arises. `control.wallet.watched` is the one a future reader will be tempted to
765 /// open, because it returns nothing but public keys and every other wallet READ in this catalog is
766 /// open. It stays gated under the SAME rule that gates `control.wallet.arrivals`: the caller
767 /// supplies nothing, so the node volunteers its OWN enrolled keys — the node-to-key association,
768 /// which is not public, and which a token-less caller could replay straight into the
769 /// caller-addressed reads.
770 ///
771 /// The control keeps `control.wallet.coinById` open in the same assertion. Without it this test
772 /// would also pass on a wholesale gating of the wallet category, which is a different (and wrong)
773 /// implementation.
774 #[test]
775 fn the_enrolment_methods_are_gated_including_the_read() {
776 for wire in [
777 "control.wallet.watch",
778 "control.wallet.unwatch",
779 "control.wallet.watched",
780 ] {
781 let method = ControlMethod::from_name(wire)
782 .unwrap_or_else(|| panic!("{wire} must be in the catalog"));
783 assert!(
784 !method.is_open_read(),
785 "{wire} either aims this node's subscriptions or names the keys it already \
786 follows, so it MUST NOT be served token-less"
787 );
788 assert!(method.requires_auth(), "{wire} must require the token");
789 assert_eq!(method.category(), Category::Wallet);
790 assert_eq!(method.routing(), Routing::Delegated);
791 }
792 assert!(
793 ControlMethod::WalletCoinById.is_open_read(),
794 "the caller-addressed reads stay open -- enrolment is gated by the membership rule, \
795 not by gating the wallet category"
796 );
797 }
798
799 #[test]
800 fn only_pairing_bootstrap_is_open_bootstrap_routed() {
801 for &m in ControlMethod::ALL {
802 let open_bootstrap = matches!(
803 m,
804 ControlMethod::PairingRequest | ControlMethod::PairingPoll
805 );
806 assert_eq!(
807 m.routing() == Routing::OpenBootstrap,
808 open_bootstrap,
809 "{} routing mismatch",
810 m.name()
811 );
812 }
813 }
814
815 #[test]
816 fn pairing_admin_methods_are_exactly_three() {
817 let admin: Vec<&str> = ControlMethod::ALL
818 .iter()
819 .filter(|m| m.is_pairing_admin())
820 .map(|m| m.name())
821 .collect();
822 assert_eq!(
823 admin,
824 vec![
825 "control.pairing.list",
826 "control.pairing.approve",
827 "control.pairing.revoke"
828 ]
829 );
830 }
831
832 /// **The master-token tier is the pairing lifecycle PLUS the trusted-peer mutations.**
833 ///
834 /// The set is asserted whole, because the risk is a method quietly joining or leaving it. The
835 /// two non-pairing members are here for a stated reason — `chiaPeers.add` grants authority
836 /// that SURVIVES `pairing.revoke`, so a paired token holding it escapes the very remedy for a
837 /// compromised paired app.
838 #[test]
839 fn the_master_token_tier_is_pairing_admin_plus_the_trusted_peer_mutations() {
840 let master: BTreeSet<&str> = ControlMethod::ALL
841 .iter()
842 .filter(|m| m.requires_master_token())
843 .map(|m| m.name())
844 .collect();
845 let expected: BTreeSet<&str> = [
846 "control.pairing.list",
847 "control.pairing.approve",
848 "control.pairing.revoke",
849 "control.chiaPeers.add",
850 "control.chiaPeers.remove",
851 ]
852 .into_iter()
853 .collect();
854 assert_eq!(master, expected);
855
856 // Pairing administration is a STRICT subset, not a synonym: a gate that consults
857 // `is_pairing_admin` instead of `requires_master_token` lets a paired token add a peer.
858 for &m in ControlMethod::ALL {
859 assert!(
860 !m.is_pairing_admin() || m.requires_master_token(),
861 "{} is pairing-admin but not master-tier",
862 m.name()
863 );
864 }
865 assert!(
866 master.len()
867 > ControlMethod::ALL
868 .iter()
869 .filter(|m| m.is_pairing_admin())
870 .count(),
871 "the two predicates must not be interchangeable"
872 );
873
874 // Master implies the token is required at all.
875 for &m in ControlMethod::ALL {
876 assert!(
877 !m.requires_master_token() || m.requires_auth(),
878 "{}",
879 m.name()
880 );
881 }
882 }
883
884 /// **The trust wording stays inside NC-12's authorisation: a node the operator RUNS.**
885 ///
886 /// NC-12 permits trust only from "the operator declaring it their own node". Widening that to
887 /// vouching moves the case outside the justification for the unbounded authority the entry
888 /// carries, and "a node you vouch for" is a phrase somebody can be talked into applying to a
889 /// stranger's address.
890 #[test]
891 fn the_add_summary_authorises_only_a_node_the_operator_runs() {
892 let summary = ControlMethod::ChiaPeersAdd.summary().to_lowercase();
893 assert!(
894 summary.contains("a node you run"),
895 "add must name the operator-run scope, got: {summary}"
896 );
897 for widened in ["vouch", "otherwise trust", "trust yourself", "recommend"] {
898 assert!(
899 !summary.contains(widened),
900 "add summary widens operator trust past NC-12 with {widened:?}: {summary}"
901 );
902 }
903 }
904
905 #[test]
906 fn delegated_set_matches_the_engine_surface() {
907 let delegated: BTreeSet<&str> = ControlMethod::ALL
908 .iter()
909 .filter(|m| m.routing() == Routing::Delegated)
910 .map(|m| m.name())
911 .collect();
912 let expected: BTreeSet<&str> = [
913 "control.wallet.coins",
914 "control.wallet.coinById",
915 "control.wallet.coinSpend",
916 "control.wallet.coinsByParent",
917 "control.wallet.arrivals",
918 "control.wallet.peak",
919 "control.wallet.syncStatus",
920 "control.wallet.broadcast",
921 "control.wallet.watch",
922 "control.wallet.unwatch",
923 "control.wallet.watched",
924 "control.profile.putBody",
925 "control.profile.getBody",
926 "control.peerStatus",
927 "control.peerCounts",
928 "control.peers.connect",
929 "control.peers.disconnect",
930 "control.subscribe",
931 "control.unsubscribe",
932 "control.listSubscriptions",
933 "control.wallet.balance",
934 ]
935 .into_iter()
936 .collect();
937 assert_eq!(delegated, expected);
938 }
939
940 /// **The trusted-Chia-peer methods are declared, gated, and say what they cost.**
941 ///
942 /// A trusted peer BYPASSES corroboration (NC-12: dialled peers are untrusted and agreement
943 /// across ~5 concurrently-queried peers is what makes a read safe). The catalog is what a
944 /// machine reads before offering the control, so the cost is stated HERE and not only in a
945 /// doc page — a client that surfaces `summary()` surfaces the warning with it.
946 #[test]
947 fn the_trusted_chia_peer_methods_are_gated_and_disclose_the_corroboration_bypass() {
948 let declared: BTreeSet<&str> = ControlMethod::ALL.iter().map(|m| m.name()).collect();
949 for name in [
950 "control.chiaPeers.add",
951 "control.chiaPeers.list",
952 "control.chiaPeers.remove",
953 ] {
954 assert!(declared.contains(name), "{name} is not in the catalog");
955 let m = ControlMethod::from_name(name).expect("from_name round-trips");
956 assert_eq!(m.category(), Category::Peers, "{name} is a peers method");
957 assert_eq!(m.routing(), Routing::Owned, "{name} is served by the shell");
958 assert!(m.requires_auth(), "{name} must require the control token");
959 assert!(!m.is_open_read(), "{name} is not an open read");
960 }
961 // The MUTATIONS need the MASTER token; the READ deliberately does not. `add` writes
962 // standing, corroboration-free authority that outlives the token that wrote it — a paired
963 // token must not be able to install it, and `remove` is the only way back out.
964 assert!(ControlMethod::ChiaPeersAdd.requires_master_token());
965 assert!(ControlMethod::ChiaPeersRemove.requires_master_token());
966 assert!(
967 !ControlMethod::ChiaPeersList.requires_master_token(),
968 "list grants nothing that outlives the token; gating it would blind a paired client \
969 to the trust state it is subject to"
970 );
971 // The COST, not merely the capability: the two methods that change the trusted set must
972 // name the bypass. A summary that only described the action would let a client offer the
973 // control while silently withholding what it gives up.
974 for name in ["control.chiaPeers.add", "control.chiaPeers.remove"] {
975 let summary = ControlMethod::from_name(name).unwrap().summary();
976 assert!(
977 summary.to_lowercase().contains("corroboration"),
978 "{name} summary must name the corroboration bypass, got: {summary}"
979 );
980 }
981 }
982
983 #[test]
984 fn every_method_has_a_nonempty_summary() {
985 for &m in ControlMethod::ALL {
986 assert!(!m.summary().is_empty(), "{} has no summary", m.name());
987 }
988 }
989}