Skip to main content

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