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}
58
59/// A dig-node CONTROL method.
60///
61/// `#[non_exhaustive]` so adding a method in a minor release is additive; downstream matches must
62/// carry a `_ => …` arm. Convert to/from the wire name with [`ControlMethod::name`] /
63/// [`ControlMethod::from_name`].
64#[non_exhaustive]
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66pub enum ControlMethod {
67    // ---- Status / config / log (shell-owned) ----
68    /// `control.status` — a rich node status snapshot.
69    Status,
70    /// `control.config.get` — the node's effective configuration.
71    ConfigGet,
72    /// `control.config.setUpstream` — persist an upstream-RPC override (effective on restart).
73    ConfigSetUpstream,
74    /// `control.log.setLevel` — live-swap the running node's tracing level filter.
75    LogSetLevel,
76
77    // ---- Cache (shell-owned) ----
78    /// `control.cache.get` — the on-disk cache view (cap/used/dir/shared).
79    CacheGet,
80    /// `control.cache.setCap` — set the cache size cap (floored at 64 MiB).
81    CacheSetCap,
82    /// `control.cache.clear` — delete all locally cached content.
83    CacheClear,
84
85    // ---- Hosted stores (shell-owned) ----
86    /// `control.hostedStores.list` — every held/pinned store with its cached capsules.
87    HostedStoresList,
88    /// `control.hostedStores.pin` — pin a store (and pre-fetch when a root is given).
89    HostedStoresPin,
90    /// `control.hostedStores.unpin` — unpin a store and evict its cached capsules.
91    HostedStoresUnpin,
92    /// `control.hostedStores.status` — per-store pinned flag + cached capsules.
93    HostedStoresStatus,
94
95    // ---- §21 sync (shell-owned) ----
96    /// `control.sync.status` — whether authenticated whole-store sync is available + pin coverage.
97    SyncStatus,
98    /// `control.sync.trigger` — trigger a §21 sync for one capsule (storeId + root).
99    SyncTrigger,
100
101    // ---- Updater beacon proxy (shell-owned) ----
102    /// `control.updater.status` — the DIG auto-update beacon's current status.
103    UpdaterStatus,
104    /// `control.updater.setChannel` — set the beacon's update channel.
105    UpdaterSetChannel,
106    /// `control.updater.pause` — suspend auto-updates (optionally until a unix time).
107    UpdaterPause,
108    /// `control.updater.resume` — resume auto-updates.
109    UpdaterResume,
110    /// `control.updater.checkNow` — force an immediate update check.
111    UpdaterCheckNow,
112
113    // ---- Pairing administration (shell-owned, MASTER-token only) ----
114    /// `control.pairing.list` — list pending pairing requests + issued paired tokens.
115    PairingList,
116    /// `control.pairing.approve` — approve a pending pairing, minting a scoped token.
117    PairingApprove,
118    /// `control.pairing.revoke` — revoke an issued paired token.
119    PairingRevoke,
120
121    // ---- Peers (delegated to the engine) ----
122    /// `control.peerStatus` — live peer-pool + relay-reservation snapshot.
123    PeerStatus,
124    /// `control.peerCounts` — how many peers this node holds on EACH network (DIG and Chia).
125    PeerCounts,
126    /// `control.peers.connect` — dial a peer by address / resolve a connected peer_id.
127    PeersConnect,
128    /// `control.peers.disconnect` — drop a pooled peer by peer_id.
129    PeersDisconnect,
130
131    // ---- Subscriptions (delegated to the engine) ----
132    /// `control.subscribe` — subscribe the node to a store (watch + gap-fill).
133    Subscribe,
134    /// `control.unsubscribe` — stop watching a store.
135    Unsubscribe,
136    /// `control.listSubscriptions` — the node's persisted subscription set.
137    ListSubscriptions,
138
139    // ---- Wallet chain transport (delegated to the engine) ----
140    /// `control.wallet.balance` — read an address's confirmed spendable balance for an asset.
141    WalletBalance,
142    /// `control.wallet.coins` — read an address's spendable coin records for an asset.
143    WalletCoins,
144    /// `control.wallet.coinById` — read ONE coin record by coin id, spent or unspent.
145    WalletCoinById,
146    /// `control.wallet.arrivals` — read confirmed INCOMING funds since a cursor position.
147    WalletArrivals,
148    /// `control.wallet.peak` — read the node's current chain peak height.
149    WalletPeak,
150    /// `control.wallet.syncStatus` — read whether the wallet's chain replica is being kept current.
151    WalletSyncStatus,
152    /// `control.wallet.broadcast` — push an ALREADY-SIGNED spend bundle to the network.
153    WalletBroadcast,
154
155    // ---- Pairing bootstrap (OPEN — no token) ----
156    /// `pairing.request` — request a control-token pairing (returns a code to compare).
157    PairingRequest,
158    /// `pairing.poll` — poll a pairing; once the operator approves, returns the scoped token once.
159    PairingPoll,
160}
161
162impl ControlMethod {
163    /// The stable JSON-RPC wire name. Never derived from anything else — the published contract.
164    pub const fn name(self) -> &'static str {
165        match self {
166            ControlMethod::Status => "control.status",
167            ControlMethod::ConfigGet => "control.config.get",
168            ControlMethod::ConfigSetUpstream => "control.config.setUpstream",
169            ControlMethod::LogSetLevel => "control.log.setLevel",
170            ControlMethod::CacheGet => "control.cache.get",
171            ControlMethod::CacheSetCap => "control.cache.setCap",
172            ControlMethod::CacheClear => "control.cache.clear",
173            ControlMethod::HostedStoresList => "control.hostedStores.list",
174            ControlMethod::HostedStoresPin => "control.hostedStores.pin",
175            ControlMethod::HostedStoresUnpin => "control.hostedStores.unpin",
176            ControlMethod::HostedStoresStatus => "control.hostedStores.status",
177            ControlMethod::SyncStatus => "control.sync.status",
178            ControlMethod::SyncTrigger => "control.sync.trigger",
179            ControlMethod::UpdaterStatus => "control.updater.status",
180            ControlMethod::UpdaterSetChannel => "control.updater.setChannel",
181            ControlMethod::UpdaterPause => "control.updater.pause",
182            ControlMethod::UpdaterResume => "control.updater.resume",
183            ControlMethod::UpdaterCheckNow => "control.updater.checkNow",
184            ControlMethod::PairingList => "control.pairing.list",
185            ControlMethod::PairingApprove => "control.pairing.approve",
186            ControlMethod::PairingRevoke => "control.pairing.revoke",
187            ControlMethod::PeerStatus => "control.peerStatus",
188            ControlMethod::PeerCounts => "control.peerCounts",
189            ControlMethod::PeersConnect => "control.peers.connect",
190            ControlMethod::PeersDisconnect => "control.peers.disconnect",
191            ControlMethod::Subscribe => "control.subscribe",
192            ControlMethod::Unsubscribe => "control.unsubscribe",
193            ControlMethod::ListSubscriptions => "control.listSubscriptions",
194            ControlMethod::WalletBalance => "control.wallet.balance",
195            ControlMethod::WalletCoins => "control.wallet.coins",
196            ControlMethod::WalletCoinById => "control.wallet.coinById",
197            ControlMethod::WalletArrivals => "control.wallet.arrivals",
198            ControlMethod::WalletPeak => "control.wallet.peak",
199            ControlMethod::WalletSyncStatus => "control.wallet.syncStatus",
200            ControlMethod::WalletBroadcast => "control.wallet.broadcast",
201            ControlMethod::PairingRequest => "pairing.request",
202            ControlMethod::PairingPoll => "pairing.poll",
203        }
204    }
205
206    /// Resolve a wire name back to its [`ControlMethod`], or `None` for an unknown name.
207    pub fn from_name(name: &str) -> Option<ControlMethod> {
208        ControlMethod::ALL
209            .iter()
210            .copied()
211            .find(|m| m.name() == name)
212    }
213
214    /// Does calling this method require the local control token?
215    ///
216    /// Three groups are reachable WITHOUT one, and they are open for two different reasons:
217    ///
218    /// - the pairing bootstrap (`pairing.request` / `pairing.poll`), so a token-less client can
219    ///   obtain a token at all;
220    /// - the PEER COUNTS (`control.peerCounts`), which disclose two integers about this node's own
221    ///   connectivity and no address, endpoint or secret;
222    /// - the wallet CALLER-ADDRESSED CHAIN READS (`control.wallet.balance` / `.coins` /
223    ///   `.coinById`) and the node's own chain POSITION (`.peak` / `.syncStatus`), because each
224    ///   needs only PUBLIC chain data the CALLER already named — an address, or a coin id; never a
225    ///   seed, a key, or a signature — and dig-node has served
226    ///   `control.wallet.balance` open since #1851. A person whose node runs as a service with an
227    ///   unreadable token file can still see their own money.
228    ///
229    /// Two wallet methods are deliberately NOT in that second group.
230    /// `control.wallet.broadcast` puts bytes on the network, so the token is what stands between a
231    /// local process and a broadcast. `control.wallet.arrivals` names the wallet's OWN watched
232    /// puzzle hashes back to a caller that supplied nothing — see
233    /// [`ControlMethod::is_open_read`]. On both, `UNAUTHORIZED` genuinely means *unauthorized*.
234    pub const fn requires_auth(self) -> bool {
235        !self.is_open_read()
236            && !matches!(
237                self,
238                ControlMethod::PairingRequest | ControlMethod::PairingPoll
239            )
240    }
241
242    /// Is this an OPEN READ — served without a control token?
243    ///
244    /// Two kinds of method qualify, and they are open for different reasons:
245    ///
246    /// - the wallet CHAIN READS (`control.wallet.balance` / `.coins` / `.coinById` / `.peak` /
247    ///   `.syncStatus`), which need only PUBLIC chain data — an address, or a coin id; never a seed,
248    ///   a key, or a signature. On the first three the CALLER supplies the address or coin id, so
249    ///   the node relays a public fact and discloses no association with itself; the last two name
250    ///   the node's own chain position and no address at all;
251    /// - `control.peerCounts`, which is NOT a chain read: it discloses two integers about this
252    ///   node's own connectivity, and no address, endpoint, peer identity or secret. The identity
253    ///   and topology half of the same subject stays gated behind `control.peerStatus`.
254    ///
255    /// Naming both reasons matters more than it looks. The test for membership is *does this
256    /// disclose only data that is already public, or a bare count of this node's own state?* — NOT
257    /// *is it a chain read?* A future method judged against the narrower phrasing, and found to
258    /// contradict a member that was already there, invites widening the predicate by analogy rather
259    /// than against the rule.
260    ///
261    /// `control.wallet.arrivals` is the worked example, and it was briefly a member. It passes the
262    /// narrower phrasing — every field it returns is a public chain fact — and fails the rule: the
263    /// caller supplies NOTHING, so the node volunteers its OWN watched puzzle hashes together with
264    /// the full receive history behind them. The individual facts are public; the ASSOCIATION
265    /// between this node and those addresses is not, and that association is the whole answer. A
266    /// token-less caller could then feed those addresses back into the caller-addressed reads.
267    /// Membership turns on *who names the address*, never on whether the bytes are on chain.
268    ///
269    /// Stated on the contract rather than discovered by calling, because the two refusals a client
270    /// can get here demand OPPOSITE remedies. On an open read, `UNAUTHORIZED` can only come from a
271    /// node build that predates the method and gates it generically, so the remedy is an upgrade.
272    /// On a gated method — the push — `UNAUTHORIZED` means exactly what it says, and the remedy is
273    /// the token. A client that maps the two the same way sends somebody to fix the wrong thing.
274    pub const fn is_open_read(self) -> bool {
275        matches!(
276            self,
277            ControlMethod::WalletBalance
278                | ControlMethod::WalletCoins
279                | ControlMethod::WalletCoinById
280                | ControlMethod::WalletPeak
281                | ControlMethod::WalletSyncStatus
282                | ControlMethod::PeerCounts
283        )
284    }
285
286    /// Is this a PAIRING-ADMINISTRATION method that requires the MASTER control token specifically?
287    ///
288    /// A paired (scoped) token can drive ordinary `control.*` mutations but MUST NOT mint more
289    /// tokens or revoke itself — so listing/approving/revoking pairings requires the master token
290    /// (a local file read), never a paired token.
291    pub const fn is_pairing_admin(self) -> bool {
292        matches!(
293            self,
294            ControlMethod::PairingList
295                | ControlMethod::PairingApprove
296                | ControlMethod::PairingRevoke
297        )
298    }
299
300    /// How the node routes this method (shell-owned, engine-delegated, or open bootstrap).
301    pub const fn routing(self) -> Routing {
302        match self {
303            ControlMethod::PeerStatus
304            | ControlMethod::PeerCounts
305            | ControlMethod::PeersConnect
306            | ControlMethod::PeersDisconnect
307            | ControlMethod::Subscribe
308            | ControlMethod::Unsubscribe
309            | ControlMethod::ListSubscriptions
310            | ControlMethod::WalletBalance
311            | ControlMethod::WalletCoins
312            | ControlMethod::WalletCoinById
313            | ControlMethod::WalletArrivals
314            | ControlMethod::WalletPeak
315            | ControlMethod::WalletSyncStatus
316            | ControlMethod::WalletBroadcast => Routing::Delegated,
317            ControlMethod::PairingRequest | ControlMethod::PairingPoll => Routing::OpenBootstrap,
318            _ => Routing::Owned,
319        }
320    }
321
322    /// The functional area this method belongs to.
323    pub const fn category(self) -> Category {
324        match self {
325            ControlMethod::Status => Category::Status,
326            ControlMethod::ConfigGet | ControlMethod::ConfigSetUpstream => Category::Config,
327            ControlMethod::LogSetLevel => Category::Log,
328            ControlMethod::CacheGet | ControlMethod::CacheSetCap | ControlMethod::CacheClear => {
329                Category::Cache
330            }
331            ControlMethod::HostedStoresList
332            | ControlMethod::HostedStoresPin
333            | ControlMethod::HostedStoresUnpin
334            | ControlMethod::HostedStoresStatus => Category::HostedStores,
335            ControlMethod::SyncStatus | ControlMethod::SyncTrigger => Category::Sync,
336            ControlMethod::UpdaterStatus
337            | ControlMethod::UpdaterSetChannel
338            | ControlMethod::UpdaterPause
339            | ControlMethod::UpdaterResume
340            | ControlMethod::UpdaterCheckNow => Category::Updater,
341            ControlMethod::PairingList
342            | ControlMethod::PairingApprove
343            | ControlMethod::PairingRevoke
344            | ControlMethod::PairingRequest
345            | ControlMethod::PairingPoll => Category::Pairing,
346            ControlMethod::PeerStatus
347            | ControlMethod::PeerCounts
348            | ControlMethod::PeersConnect
349            | ControlMethod::PeersDisconnect => Category::Peers,
350            ControlMethod::Subscribe
351            | ControlMethod::Unsubscribe
352            | ControlMethod::ListSubscriptions => Category::Subscriptions,
353            ControlMethod::WalletBalance
354            | ControlMethod::WalletCoins
355            | ControlMethod::WalletCoinById
356            | ControlMethod::WalletArrivals
357            | ControlMethod::WalletPeak
358            | ControlMethod::WalletSyncStatus
359            | ControlMethod::WalletBroadcast => Category::Wallet,
360        }
361    }
362
363    /// A one-line human/agent description for the discovery catalogue.
364    pub const fn summary(self) -> &'static str {
365        match self {
366            ControlMethod::Status => "A rich node status snapshot (version, uptime, addr, cache, hosted/pinned counts, sync availability).",
367            ControlMethod::ConfigGet => "The node's effective configuration (addr/port, upstream + override, cache dir/shared, config path, sync availability).",
368            ControlMethod::ConfigSetUpstream => "Persist an upstream-RPC override; takes effect on next node start (requires_restart).",
369            ControlMethod::LogSetLevel => "Live-swap the running node's tracing EnvFilter directive (not persisted).",
370            ControlMethod::CacheGet => "The on-disk content-cache view: cap_bytes, used_bytes, dir, shared.",
371            ControlMethod::CacheSetCap => "Set the on-disk cache size cap in bytes (floored at 64 MiB).",
372            ControlMethod::CacheClear => "Delete all locally cached DIG content.",
373            ControlMethod::HostedStoresList => "Every held/pinned store, merged, with each store's cached capsules and a pinned flag.",
374            ControlMethod::HostedStoresPin => "Pin a store (storeId[:rootHash]); pre-fetches the capsule when a root is given and §21 sync is available.",
375            ControlMethod::HostedStoresUnpin => "Unpin a store and evict its cached capsules.",
376            ControlMethod::HostedStoresStatus => "Per-store status: pinned flag, cached capsules, total bytes.",
377            ControlMethod::SyncStatus => "Whether authenticated §21 whole-store sync is available, plus pinned-store cache coverage.",
378            ControlMethod::SyncTrigger => "Trigger a §21 sync for one capsule (storeId + root).",
379            ControlMethod::UpdaterStatus => "The DIG auto-update beacon's current status (proxied from dig-updater).",
380            ControlMethod::UpdaterSetChannel => "Set the beacon's update channel (\"nightly\" | \"stable\").",
381            ControlMethod::UpdaterPause => "Suspend the beacon's auto-updates (optionally until a unix time).",
382            ControlMethod::UpdaterResume => "Resume the beacon's auto-updates.",
383            ControlMethod::UpdaterCheckNow => "Force an immediate beacon update check.",
384            ControlMethod::PairingList => "List pending pairing requests and issued paired tokens (MASTER token only).",
385            ControlMethod::PairingApprove => "Approve a pending pairing, minting a scoped token (MASTER token only).",
386            ControlMethod::PairingRevoke => "Revoke an issued paired token by token_id (MASTER token only).",
387            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.",
388            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.",
389            ControlMethod::PeersConnect => "Dial a peer by address, or resolve an already-connected peer_id, via the live gossip pool.",
390            ControlMethod::PeersDisconnect => "Drop a pooled peer by peer_id, closing its mTLS link (idempotent).",
391            ControlMethod::Subscribe => "Subscribe the node to a store it actively watches and gap-fills.",
392            ControlMethod::Unsubscribe => "Stop watching a store.",
393            ControlMethod::ListSubscriptions => "The node's persisted subscription set + count.",
394            ControlMethod::WalletCoins => "READ-only: the spendable coin records for an address + asset, with the tier that answered and the height they reflect.",
395            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.",
396            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`.",
397            ControlMethod::WalletPeak => "READ-only: the node's current chain peak height, independent of any address.",
398            ControlMethod::WalletSyncStatus => "READ-only: whether the wallet's CHAIN replica is being kept current (not_started/syncing/synced), 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).",
399            ControlMethod::WalletBroadcast => "Push an ALREADY-SIGNED spend bundle to the network; the node never signs. TOKEN-GATED.",
400            ControlMethod::WalletBalance => "READ-only: the confirmed spendable balance for an address + asset (plus pending, sync freshness, and the peak height it reflects).",
401            ControlMethod::PairingRequest => "OPEN: request a control-token pairing; returns a pairing_id + pairing_code to compare.",
402            ControlMethod::PairingPoll => "OPEN: poll a pairing by id; once the operator approves, returns the scoped token once.",
403        }
404    }
405
406    /// Every catalogued method, in a stable order — the enumeration a machine reads to discover the
407    /// full control surface, and the anchor the conformance KATs pin against.
408    pub const ALL: &'static [ControlMethod] = &[
409        ControlMethod::Status,
410        ControlMethod::ConfigGet,
411        ControlMethod::ConfigSetUpstream,
412        ControlMethod::LogSetLevel,
413        ControlMethod::CacheGet,
414        ControlMethod::CacheSetCap,
415        ControlMethod::CacheClear,
416        ControlMethod::HostedStoresList,
417        ControlMethod::HostedStoresPin,
418        ControlMethod::HostedStoresUnpin,
419        ControlMethod::HostedStoresStatus,
420        ControlMethod::SyncStatus,
421        ControlMethod::SyncTrigger,
422        ControlMethod::UpdaterStatus,
423        ControlMethod::UpdaterSetChannel,
424        ControlMethod::UpdaterPause,
425        ControlMethod::UpdaterResume,
426        ControlMethod::UpdaterCheckNow,
427        ControlMethod::PairingList,
428        ControlMethod::PairingApprove,
429        ControlMethod::PairingRevoke,
430        ControlMethod::PeerStatus,
431        ControlMethod::PeerCounts,
432        ControlMethod::PeersConnect,
433        ControlMethod::PeersDisconnect,
434        ControlMethod::Subscribe,
435        ControlMethod::Unsubscribe,
436        ControlMethod::ListSubscriptions,
437        ControlMethod::WalletBalance,
438        ControlMethod::WalletCoins,
439        ControlMethod::WalletCoinById,
440        ControlMethod::WalletArrivals,
441        ControlMethod::WalletPeak,
442        ControlMethod::WalletSyncStatus,
443        ControlMethod::WalletBroadcast,
444        ControlMethod::PairingRequest,
445        ControlMethod::PairingPoll,
446    ];
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use std::collections::BTreeSet;
453
454    #[test]
455    fn every_method_has_a_unique_wire_name() {
456        let names: BTreeSet<&str> = ControlMethod::ALL.iter().map(|m| m.name()).collect();
457        assert_eq!(
458            names.len(),
459            ControlMethod::ALL.len(),
460            "duplicate or missing wire names in the catalog"
461        );
462    }
463
464    #[test]
465    fn from_name_round_trips_every_method() {
466        for &m in ControlMethod::ALL {
467            assert_eq!(ControlMethod::from_name(m.name()), Some(m));
468        }
469        assert_eq!(ControlMethod::from_name("control.nope"), None);
470        assert_eq!(ControlMethod::from_name(""), None);
471    }
472
473    #[test]
474    fn the_token_less_surface_is_exactly_the_bootstrap_plus_the_chain_reads() {
475        // Written out rather than derived from `is_open_read`, so this pins the SET and not the
476        // implementation's opinion of itself. A method added to the open surface must be added
477        // here deliberately -- which is the review step a broadcast must never slip past.
478        let expected_open: BTreeSet<&str> = [
479            "pairing.request",
480            "pairing.poll",
481            "control.wallet.balance",
482            "control.wallet.coins",
483            "control.wallet.coinById",
484            "control.wallet.peak",
485            "control.wallet.syncStatus",
486            "control.peerCounts",
487        ]
488        .into_iter()
489        .collect();
490        assert_eq!(
491            expected_open.len(),
492            8,
493            "the open surface is eight named methods"
494        );
495        let actual_open: BTreeSet<&str> = ControlMethod::ALL
496            .iter()
497            .filter(|m| !m.requires_auth())
498            .map(|m| m.name())
499            .collect();
500        assert_eq!(actual_open, expected_open);
501    }
502
503    /// **The push and the arrival cursor are the two token-gated wallet methods.** The fixture
504    /// varies one thing -- which wallet method is asked -- against a category whose other members
505    /// ARE open, so both nearest wrong implementations fail here: one that opens the whole category
506    /// (the state this crate shipped in at `1190a18`) and one that gates it wholesale.
507    #[test]
508    fn the_push_and_the_arrival_cursor_are_the_wallet_methods_behind_the_token() {
509        let gated: Vec<&str> = ControlMethod::ALL
510            .iter()
511            .filter(|m| m.category() == Category::Wallet && m.requires_auth())
512            .map(|m| m.name())
513            .collect();
514        assert_eq!(
515            gated,
516            vec!["control.wallet.arrivals", "control.wallet.broadcast"]
517        );
518        assert!(!ControlMethod::WalletBroadcast.is_open_read());
519    }
520
521    /// **The arrival cursor is NOT an open read, and the reason is not "is it a chain read?".**
522    ///
523    /// The rule is *who names the address*. `control.wallet.arrivals` takes only a cursor, so the
524    /// node volunteers its OWN watched puzzle hashes and the receive history behind them -- the
525    /// node-to-address association, which is not public, and which a token-less caller could then
526    /// replay into the caller-addressed reads.
527    ///
528    /// The control keeps `control.wallet.coinById` in the same assertion: it is the neighbour the
529    /// analogy was drawn from, it is still open, and it stays open because its CALLER supplies the
530    /// coin id. Without that control this test would also pass on a wholesale gating of the wallet
531    /// category, which is a different (and wrong) implementation.
532    #[test]
533    fn the_arrival_cursor_is_not_an_open_read() {
534        assert!(
535            !ControlMethod::WalletArrivals.is_open_read(),
536            "control.wallet.arrivals discloses this node's OWN watched puzzle hashes to a caller \
537             that supplied nothing, so it MUST NOT be served token-less"
538        );
539        assert!(ControlMethod::WalletArrivals.requires_auth());
540        assert!(
541            ControlMethod::WalletCoinById.is_open_read(),
542            "the caller-addressed reads stay open -- the fix is the membership rule, not gating \
543             the wallet category"
544        );
545    }
546
547    #[test]
548    fn only_pairing_bootstrap_is_open_bootstrap_routed() {
549        for &m in ControlMethod::ALL {
550            let open_bootstrap = matches!(
551                m,
552                ControlMethod::PairingRequest | ControlMethod::PairingPoll
553            );
554            assert_eq!(
555                m.routing() == Routing::OpenBootstrap,
556                open_bootstrap,
557                "{} routing mismatch",
558                m.name()
559            );
560        }
561    }
562
563    #[test]
564    fn pairing_admin_methods_are_exactly_three() {
565        let admin: Vec<&str> = ControlMethod::ALL
566            .iter()
567            .filter(|m| m.is_pairing_admin())
568            .map(|m| m.name())
569            .collect();
570        assert_eq!(
571            admin,
572            vec![
573                "control.pairing.list",
574                "control.pairing.approve",
575                "control.pairing.revoke"
576            ]
577        );
578    }
579
580    #[test]
581    fn delegated_set_matches_the_engine_surface() {
582        let delegated: BTreeSet<&str> = ControlMethod::ALL
583            .iter()
584            .filter(|m| m.routing() == Routing::Delegated)
585            .map(|m| m.name())
586            .collect();
587        let expected: BTreeSet<&str> = [
588            "control.wallet.coins",
589            "control.wallet.coinById",
590            "control.wallet.arrivals",
591            "control.wallet.peak",
592            "control.wallet.syncStatus",
593            "control.wallet.broadcast",
594            "control.peerStatus",
595            "control.peerCounts",
596            "control.peers.connect",
597            "control.peers.disconnect",
598            "control.subscribe",
599            "control.unsubscribe",
600            "control.listSubscriptions",
601            "control.wallet.balance",
602        ]
603        .into_iter()
604        .collect();
605        assert_eq!(delegated, expected);
606    }
607
608    #[test]
609    fn every_method_has_a_nonempty_summary() {
610        for &m in ControlMethod::ALL {
611            assert!(!m.summary().is_empty(), "{} has no summary", m.name());
612        }
613    }
614}