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.coinSpend` — read the SPEND that spent a coin (puzzle reveal + solution).
147    WalletCoinSpend,
148    /// `control.wallet.coinsByParent` — read the direct children a coin's spend created (one hop).
149    WalletCoinsByParent,
150    /// `control.wallet.arrivals` — read confirmed INCOMING funds since a cursor position.
151    WalletArrivals,
152    /// `control.wallet.peak` — read the node's current chain peak height.
153    WalletPeak,
154    /// `control.wallet.syncStatus` — read whether the wallet's chain replica is being kept current.
155    WalletSyncStatus,
156    /// `control.wallet.broadcast` — push an ALREADY-SIGNED spend bundle to the network.
157    WalletBroadcast,
158    /// `control.wallet.watch` — enrol PUBLIC keys for the node's chain replica to follow.
159    WalletWatch,
160    /// `control.wallet.unwatch` — deregister enrolled public keys, so the following stops.
161    WalletUnwatch,
162    /// `control.wallet.watched` — list the public keys currently enrolled.
163    WalletWatched,
164
165    // ---- Pairing bootstrap (OPEN — no token) ----
166    /// `pairing.request` — request a control-token pairing (returns a code to compare).
167    PairingRequest,
168    /// `pairing.poll` — poll a pairing; once the operator approves, returns the scoped token once.
169    PairingPoll,
170}
171
172impl ControlMethod {
173    /// The stable JSON-RPC wire name. Never derived from anything else — the published contract.
174    pub const fn name(self) -> &'static str {
175        match self {
176            ControlMethod::Status => "control.status",
177            ControlMethod::ConfigGet => "control.config.get",
178            ControlMethod::ConfigSetUpstream => "control.config.setUpstream",
179            ControlMethod::LogSetLevel => "control.log.setLevel",
180            ControlMethod::CacheGet => "control.cache.get",
181            ControlMethod::CacheSetCap => "control.cache.setCap",
182            ControlMethod::CacheClear => "control.cache.clear",
183            ControlMethod::HostedStoresList => "control.hostedStores.list",
184            ControlMethod::HostedStoresPin => "control.hostedStores.pin",
185            ControlMethod::HostedStoresUnpin => "control.hostedStores.unpin",
186            ControlMethod::HostedStoresStatus => "control.hostedStores.status",
187            ControlMethod::SyncStatus => "control.sync.status",
188            ControlMethod::SyncTrigger => "control.sync.trigger",
189            ControlMethod::UpdaterStatus => "control.updater.status",
190            ControlMethod::UpdaterSetChannel => "control.updater.setChannel",
191            ControlMethod::UpdaterPause => "control.updater.pause",
192            ControlMethod::UpdaterResume => "control.updater.resume",
193            ControlMethod::UpdaterCheckNow => "control.updater.checkNow",
194            ControlMethod::PairingList => "control.pairing.list",
195            ControlMethod::PairingApprove => "control.pairing.approve",
196            ControlMethod::PairingRevoke => "control.pairing.revoke",
197            ControlMethod::PeerStatus => "control.peerStatus",
198            ControlMethod::PeerCounts => "control.peerCounts",
199            ControlMethod::PeersConnect => "control.peers.connect",
200            ControlMethod::PeersDisconnect => "control.peers.disconnect",
201            ControlMethod::Subscribe => "control.subscribe",
202            ControlMethod::Unsubscribe => "control.unsubscribe",
203            ControlMethod::ListSubscriptions => "control.listSubscriptions",
204            ControlMethod::WalletBalance => "control.wallet.balance",
205            ControlMethod::WalletCoins => "control.wallet.coins",
206            ControlMethod::WalletCoinById => "control.wallet.coinById",
207            ControlMethod::WalletCoinSpend => "control.wallet.coinSpend",
208            ControlMethod::WalletCoinsByParent => "control.wallet.coinsByParent",
209            ControlMethod::WalletArrivals => "control.wallet.arrivals",
210            ControlMethod::WalletPeak => "control.wallet.peak",
211            ControlMethod::WalletSyncStatus => "control.wallet.syncStatus",
212            ControlMethod::WalletBroadcast => "control.wallet.broadcast",
213            ControlMethod::WalletWatch => "control.wallet.watch",
214            ControlMethod::WalletUnwatch => "control.wallet.unwatch",
215            ControlMethod::WalletWatched => "control.wallet.watched",
216            ControlMethod::PairingRequest => "pairing.request",
217            ControlMethod::PairingPoll => "pairing.poll",
218        }
219    }
220
221    /// Resolve a wire name back to its [`ControlMethod`], or `None` for an unknown name.
222    pub fn from_name(name: &str) -> Option<ControlMethod> {
223        ControlMethod::ALL
224            .iter()
225            .copied()
226            .find(|m| m.name() == name)
227    }
228
229    /// Does calling this method require the local control token?
230    ///
231    /// Three groups are reachable WITHOUT one, and they are open for two different reasons:
232    ///
233    /// - the pairing bootstrap (`pairing.request` / `pairing.poll`), so a token-less client can
234    ///   obtain a token at all;
235    /// - the PEER COUNTS (`control.peerCounts`), which disclose three integers about this node's
236    ///   own connectivity and no address, endpoint or secret;
237    /// - the wallet CALLER-ADDRESSED CHAIN READS (`control.wallet.balance` / `.coins` /
238    ///   `.coinById` / `.coinSpend` / `.coinsByParent`) and the node's own chain POSITION
239    ///   (`.peak` / `.syncStatus`), because each needs only PUBLIC chain data the CALLER already
240    ///   named — an address, or a coin id; never a seed, a key, or a signature — and dig-node has
241    ///   served `control.wallet.balance` open since #1851. A person whose node runs as a service
242    ///   with an unreadable token file can still see their own money.
243    ///
244    /// Five wallet methods are deliberately NOT in that second group:
245    ///
246    /// - `control.wallet.broadcast` puts bytes on the network, so the token is what stands between
247    ///   a local process and a broadcast — a mutation on the chain state itself;
248    /// - `control.wallet.watch` and `.unwatch` aim what this node follows, so they are mutations
249    ///   of this node's own watched-key set;
250    /// - `control.wallet.arrivals` and `.watched` take nothing from the caller and answer back
251    ///   with this node's OWN state — watched puzzle hashes and enrolled public keys respectively.
252    ///
253    /// See [`ControlMethod::is_open_read`]. On all five, `UNAUTHORIZED` genuinely means
254    /// *unauthorized*.
255    pub const fn requires_auth(self) -> bool {
256        !self.is_open_read()
257            && !matches!(
258                self,
259                ControlMethod::PairingRequest | ControlMethod::PairingPoll
260            )
261    }
262
263    /// Is this an OPEN READ — served without a control token?
264    ///
265    /// Two kinds of method qualify, and they are open for different reasons:
266    ///
267    /// - the wallet CHAIN READS (`control.wallet.balance` / `.coins` / `.coinById` / `.coinSpend` /
268    ///   `.coinsByParent` / `.peak` / `.syncStatus`), which need only PUBLIC chain data — an
269    ///   address, or a coin id; never a seed, a key, or a signature. On the first five the CALLER
270    ///   supplies the address or coin id, so the node relays a public fact and discloses no
271    ///   association with itself; the last two name the node's own chain position and no address
272    ///   at all;
273    /// - `control.peerCounts`, which is NOT a chain read: it discloses three integers about this
274    ///   node's own connectivity, and no address, endpoint, peer identity or secret. The identity
275    ///   and topology half of the same subject stays gated behind `control.peerStatus`.
276    ///
277    /// Naming both reasons matters more than it looks. The test for membership is *does this
278    /// disclose only data that is already public, or a bare count of this node's own state?* — NOT
279    /// *is it a chain read?* A future method judged against the narrower phrasing, and found to
280    /// contradict a member that was already there, invites widening the predicate by analogy rather
281    /// than against the rule.
282    ///
283    /// `control.wallet.arrivals` is the worked example, and it was briefly a member. It passes the
284    /// narrower phrasing — every field it returns is a public chain fact — and fails the rule: the
285    /// caller supplies NOTHING, so the node volunteers its OWN watched puzzle hashes together with
286    /// the full receive history behind them. The individual facts are public; the ASSOCIATION
287    /// between this node and those addresses is not, and that association is the whole answer. A
288    /// token-less caller could then feed those addresses back into the caller-addressed reads.
289    /// Membership turns on *who names the address*, never on whether the bytes are on chain.
290    ///
291    /// Stated on the contract rather than discovered by calling, because the two refusals a client
292    /// can get here demand OPPOSITE remedies. On an open read, `UNAUTHORIZED` can only come from a
293    /// node build that predates the method and gates it generically, so the remedy is an upgrade.
294    /// On a gated method — the push — `UNAUTHORIZED` means exactly what it says, and the remedy is
295    /// the token. A client that maps the two the same way sends somebody to fix the wrong thing.
296    pub const fn is_open_read(self) -> bool {
297        matches!(
298            self,
299            ControlMethod::WalletBalance
300                | ControlMethod::WalletCoins
301                | ControlMethod::WalletCoinById
302                | ControlMethod::WalletCoinSpend
303                | ControlMethod::WalletCoinsByParent
304                | ControlMethod::WalletPeak
305                | ControlMethod::WalletSyncStatus
306                | ControlMethod::PeerCounts
307        )
308    }
309
310    /// Is this a PAIRING-ADMINISTRATION method that requires the MASTER control token specifically?
311    ///
312    /// A paired (scoped) token can drive ordinary `control.*` mutations but MUST NOT mint more
313    /// tokens or revoke itself — so listing/approving/revoking pairings requires the master token
314    /// (a local file read), never a paired token.
315    pub const fn is_pairing_admin(self) -> bool {
316        matches!(
317            self,
318            ControlMethod::PairingList
319                | ControlMethod::PairingApprove
320                | ControlMethod::PairingRevoke
321        )
322    }
323
324    /// How the node routes this method (shell-owned, engine-delegated, or open bootstrap).
325    pub const fn routing(self) -> Routing {
326        match self {
327            ControlMethod::PeerStatus
328            | ControlMethod::PeerCounts
329            | ControlMethod::PeersConnect
330            | ControlMethod::PeersDisconnect
331            | ControlMethod::Subscribe
332            | ControlMethod::Unsubscribe
333            | ControlMethod::ListSubscriptions
334            | ControlMethod::WalletBalance
335            | ControlMethod::WalletCoins
336            | ControlMethod::WalletCoinById
337            | ControlMethod::WalletCoinSpend
338            | ControlMethod::WalletCoinsByParent
339            | ControlMethod::WalletArrivals
340            | ControlMethod::WalletPeak
341            | ControlMethod::WalletSyncStatus
342            | ControlMethod::WalletBroadcast
343            | ControlMethod::WalletWatch
344            | ControlMethod::WalletUnwatch
345            | ControlMethod::WalletWatched => Routing::Delegated,
346            ControlMethod::PairingRequest | ControlMethod::PairingPoll => Routing::OpenBootstrap,
347            _ => Routing::Owned,
348        }
349    }
350
351    /// The functional area this method belongs to.
352    pub const fn category(self) -> Category {
353        match self {
354            ControlMethod::Status => Category::Status,
355            ControlMethod::ConfigGet | ControlMethod::ConfigSetUpstream => Category::Config,
356            ControlMethod::LogSetLevel => Category::Log,
357            ControlMethod::CacheGet | ControlMethod::CacheSetCap | ControlMethod::CacheClear => {
358                Category::Cache
359            }
360            ControlMethod::HostedStoresList
361            | ControlMethod::HostedStoresPin
362            | ControlMethod::HostedStoresUnpin
363            | ControlMethod::HostedStoresStatus => Category::HostedStores,
364            ControlMethod::SyncStatus | ControlMethod::SyncTrigger => Category::Sync,
365            ControlMethod::UpdaterStatus
366            | ControlMethod::UpdaterSetChannel
367            | ControlMethod::UpdaterPause
368            | ControlMethod::UpdaterResume
369            | ControlMethod::UpdaterCheckNow => Category::Updater,
370            ControlMethod::PairingList
371            | ControlMethod::PairingApprove
372            | ControlMethod::PairingRevoke
373            | ControlMethod::PairingRequest
374            | ControlMethod::PairingPoll => Category::Pairing,
375            ControlMethod::PeerStatus
376            | ControlMethod::PeerCounts
377            | ControlMethod::PeersConnect
378            | ControlMethod::PeersDisconnect => Category::Peers,
379            ControlMethod::Subscribe
380            | ControlMethod::Unsubscribe
381            | ControlMethod::ListSubscriptions => Category::Subscriptions,
382            ControlMethod::WalletBalance
383            | ControlMethod::WalletCoins
384            | ControlMethod::WalletCoinById
385            | ControlMethod::WalletCoinSpend
386            | ControlMethod::WalletCoinsByParent
387            | ControlMethod::WalletArrivals
388            | ControlMethod::WalletPeak
389            | ControlMethod::WalletSyncStatus
390            | ControlMethod::WalletBroadcast
391            | ControlMethod::WalletWatch
392            | ControlMethod::WalletUnwatch
393            | ControlMethod::WalletWatched => Category::Wallet,
394        }
395    }
396
397    /// A one-line human/agent description for the discovery catalogue.
398    pub const fn summary(self) -> &'static str {
399        match self {
400            ControlMethod::Status => "A rich node status snapshot (version, uptime, addr, cache, hosted/pinned counts, sync availability).",
401            ControlMethod::ConfigGet => "The node's effective configuration (addr/port, upstream + override, cache dir/shared, config path, sync availability).",
402            ControlMethod::ConfigSetUpstream => "Persist an upstream-RPC override; takes effect on next node start (requires_restart).",
403            ControlMethod::LogSetLevel => "Live-swap the running node's tracing EnvFilter directive (not persisted).",
404            ControlMethod::CacheGet => "The on-disk content-cache view: cap_bytes, used_bytes, dir, shared.",
405            ControlMethod::CacheSetCap => "Set the on-disk cache size cap in bytes (floored at 64 MiB).",
406            ControlMethod::CacheClear => "Delete all locally cached DIG content.",
407            ControlMethod::HostedStoresList => "Every held/pinned store, merged, with each store's cached capsules and a pinned flag.",
408            ControlMethod::HostedStoresPin => "Pin a store (storeId[:rootHash]); pre-fetches the capsule when a root is given and §21 sync is available.",
409            ControlMethod::HostedStoresUnpin => "Unpin a store and evict its cached capsules.",
410            ControlMethod::HostedStoresStatus => "Per-store status: pinned flag, cached capsules, total bytes.",
411            ControlMethod::SyncStatus => "Whether authenticated §21 whole-store sync is available, plus pinned-store cache coverage.",
412            ControlMethod::SyncTrigger => "Trigger a §21 sync for one capsule (storeId + root).",
413            ControlMethod::UpdaterStatus => "The DIG auto-update beacon's current status (proxied from dig-updater).",
414            ControlMethod::UpdaterSetChannel => "Set the beacon's update channel (\"nightly\" | \"stable\").",
415            ControlMethod::UpdaterPause => "Suspend the beacon's auto-updates (optionally until a unix time).",
416            ControlMethod::UpdaterResume => "Resume the beacon's auto-updates.",
417            ControlMethod::UpdaterCheckNow => "Force an immediate beacon update check.",
418            ControlMethod::PairingList => "List pending pairing requests and issued paired tokens (MASTER token only).",
419            ControlMethod::PairingApprove => "Approve a pending pairing, minting a scoped token (MASTER token only).",
420            ControlMethod::PairingRevoke => "Revoke an issued paired token by token_id (MASTER token only).",
421            ControlMethod::PeerStatus => "Live peer-pool + relay-reservation snapshot, including the per-peer connected array; each entry carries an always-present `software` field (the peer's advertised build). Its `relay.peer_count` counts peers connected to THE RELAY, not to this node, and is never the answer to \"how many peers does this node have\" -- that is control.peerCounts.",
422            ControlMethod::PeerCounts => "READ-only: how many peers this node holds on EACH network -- dig_peer_count (DIG content/gossip, port 9445) and chia_peer_count (Chia full nodes serving the wallet chain sync). Two unrelated numbers, each named for its network.",
423            ControlMethod::PeersConnect => "Dial a peer by address, or resolve an already-connected peer_id, via the live gossip pool.",
424            ControlMethod::PeersDisconnect => "Drop a pooled peer by peer_id, closing its mTLS link (idempotent).",
425            ControlMethod::Subscribe => "Subscribe the node to a store it actively watches and gap-fills.",
426            ControlMethod::Unsubscribe => "Stop watching a store.",
427            ControlMethod::ListSubscriptions => "The node's persisted subscription set + count.",
428            ControlMethod::WalletCoins => "READ-only: the spendable coin records for an address + asset, with the tier that answered and the height they reflect.",
429            ControlMethod::WalletCoinById => "READ-only: ONE coin record by coin id, spent or unspent, with no address and no asset scope; `coin: null` means the chain holds no such coin.",
430            ControlMethod::WalletCoinSpend => "READ-only: the SPEND that spent a coin -- its puzzle reveal, its solution and the coin itself -- named by the coin's own id. `spend: null` means the consulted chain shows that coin as unspent or unknown; it NEVER means the chain could not be reached, which is an error.",
431            ControlMethod::WalletCoinsByParent => "READ-only: the DIRECT children created by spending one coin, named by that parent's coin id. ONE hop, never a recursive walk: an empty list means the parent created no known children, and a caller wanting a lineage composes hops itself.",
432            ControlMethod::WalletArrivals => "READ-only: confirmed INCOMING funds recorded since a cursor position, oldest first -- the answer to `was I just paid?`, which no balance or coin list can give. Each row is a coin the node determined ARRIVED: confirmed on chain, above the wallet's arrival baseline, not previously reported, and not the wallet's own change. Resume from `cursor` (the last row you were handed), never from `latest`.",
433            ControlMethod::WalletPeak => "READ-only: the node's current chain peak height, independent of any address.",
434            ControlMethod::WalletSyncStatus => "READ-only: whether the wallet's CHAIN replica is being kept current (not_started/syncing/synced/no_wallet_enrolled/wallet_not_unlocked), the replica's own height, and its CHIA full-node peer count -- unrelated to control.sync.status (DIG stores) and to control.peerStatus (DIG peers).",
435            ControlMethod::WalletBroadcast => "Push an ALREADY-SIGNED spend bundle to the network; the node never signs. TOKEN-GATED.",
436            ControlMethod::WalletBalance => "READ-only: the confirmed spendable balance for an address + asset (plus pending, sync freshness, and the peak height it reflects).",
437            ControlMethod::WalletWatch => "Enrol PUBLIC keys (48-byte G1, lowercase 96-hex) for the node's chain replica to follow, so their addresses are synced and readable. IDEMPOTENT: re-enrolling a key already enrolled succeeds and changes nothing. Keys, never puzzle hashes -- the node derives the addresses itself, so one derivation serves every client. TOKEN-GATED.",
438            ControlMethod::WalletUnwatch => "Deregister enrolled public keys, so the node stops following their addresses. IDEMPOTENT: a key that was never enrolled is not an error. TOKEN-GATED.",
439            ControlMethod::WalletWatched => "READ-only: the public keys currently enrolled, so a client can reconcile what it asked for against what the node holds. TOKEN-GATED although it is a read -- the caller supplies nothing, so the answer is this node's OWN key set.",
440            ControlMethod::PairingRequest => "OPEN: request a control-token pairing; returns a pairing_id + pairing_code to compare.",
441            ControlMethod::PairingPoll => "OPEN: poll a pairing by id; once the operator approves, returns the scoped token once.",
442        }
443    }
444
445    /// Every catalogued method, in a stable order — the enumeration a machine reads to discover the
446    /// full control surface, and the anchor the conformance KATs pin against.
447    pub const ALL: &'static [ControlMethod] = &[
448        ControlMethod::Status,
449        ControlMethod::ConfigGet,
450        ControlMethod::ConfigSetUpstream,
451        ControlMethod::LogSetLevel,
452        ControlMethod::CacheGet,
453        ControlMethod::CacheSetCap,
454        ControlMethod::CacheClear,
455        ControlMethod::HostedStoresList,
456        ControlMethod::HostedStoresPin,
457        ControlMethod::HostedStoresUnpin,
458        ControlMethod::HostedStoresStatus,
459        ControlMethod::SyncStatus,
460        ControlMethod::SyncTrigger,
461        ControlMethod::UpdaterStatus,
462        ControlMethod::UpdaterSetChannel,
463        ControlMethod::UpdaterPause,
464        ControlMethod::UpdaterResume,
465        ControlMethod::UpdaterCheckNow,
466        ControlMethod::PairingList,
467        ControlMethod::PairingApprove,
468        ControlMethod::PairingRevoke,
469        ControlMethod::PeerStatus,
470        ControlMethod::PeerCounts,
471        ControlMethod::PeersConnect,
472        ControlMethod::PeersDisconnect,
473        ControlMethod::Subscribe,
474        ControlMethod::Unsubscribe,
475        ControlMethod::ListSubscriptions,
476        ControlMethod::WalletBalance,
477        ControlMethod::WalletCoins,
478        ControlMethod::WalletCoinById,
479        ControlMethod::WalletCoinSpend,
480        ControlMethod::WalletCoinsByParent,
481        ControlMethod::WalletArrivals,
482        ControlMethod::WalletPeak,
483        ControlMethod::WalletSyncStatus,
484        ControlMethod::WalletBroadcast,
485        ControlMethod::WalletWatch,
486        ControlMethod::WalletUnwatch,
487        ControlMethod::WalletWatched,
488        ControlMethod::PairingRequest,
489        ControlMethod::PairingPoll,
490    ];
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use std::collections::BTreeSet;
497
498    #[test]
499    fn every_method_has_a_unique_wire_name() {
500        let names: BTreeSet<&str> = ControlMethod::ALL.iter().map(|m| m.name()).collect();
501        assert_eq!(
502            names.len(),
503            ControlMethod::ALL.len(),
504            "duplicate or missing wire names in the catalog"
505        );
506    }
507
508    #[test]
509    fn from_name_round_trips_every_method() {
510        for &m in ControlMethod::ALL {
511            assert_eq!(ControlMethod::from_name(m.name()), Some(m));
512        }
513        assert_eq!(ControlMethod::from_name("control.nope"), None);
514        assert_eq!(ControlMethod::from_name(""), None);
515    }
516
517    #[test]
518    fn the_token_less_surface_is_exactly_the_bootstrap_plus_the_chain_reads() {
519        // Written out rather than derived from `is_open_read`, so this pins the SET and not the
520        // implementation's opinion of itself. A method added to the open surface must be added
521        // here deliberately -- which is the review step a broadcast must never slip past.
522        let expected_open: BTreeSet<&str> = [
523            "pairing.request",
524            "pairing.poll",
525            "control.wallet.balance",
526            "control.wallet.coins",
527            "control.wallet.coinById",
528            "control.wallet.coinSpend",
529            "control.wallet.coinsByParent",
530            "control.wallet.peak",
531            "control.wallet.syncStatus",
532            "control.peerCounts",
533        ]
534        .into_iter()
535        .collect();
536        assert_eq!(
537            expected_open.len(),
538            10,
539            "the open surface is ten named methods"
540        );
541        let actual_open: BTreeSet<&str> = ControlMethod::ALL
542            .iter()
543            .filter(|m| !m.requires_auth())
544            .map(|m| m.name())
545            .collect();
546        assert_eq!(actual_open, expected_open);
547    }
548
549    /// **The gated wallet methods are the push, the arrival cursor, and the three enrolment
550    /// methods.** The fixture varies one thing -- which wallet method is asked -- against a category
551    /// whose other members ARE open, so both nearest wrong implementations fail here: one that opens
552    /// the whole category (the state this crate shipped in at `1190a18`) and one that gates it
553    /// wholesale.
554    ///
555    /// Written out in catalog order rather than derived, so a method joining the gated side is a
556    /// deliberate edit here -- the review step a broadcast, or an enrolment, must never slip past.
557    #[test]
558    fn the_gated_wallet_methods_are_the_push_the_cursor_and_enrolment() {
559        let gated: Vec<&str> = ControlMethod::ALL
560            .iter()
561            .filter(|m| m.category() == Category::Wallet && m.requires_auth())
562            .map(|m| m.name())
563            .collect();
564        assert_eq!(
565            gated,
566            vec![
567                "control.wallet.arrivals",
568                "control.wallet.broadcast",
569                "control.wallet.watch",
570                "control.wallet.unwatch",
571                "control.wallet.watched",
572            ]
573        );
574        assert!(!ControlMethod::WalletBroadcast.is_open_read());
575    }
576
577    /// **The arrival cursor is NOT an open read, and the reason is not "is it a chain read?".**
578    ///
579    /// The rule is *who names the address*. `control.wallet.arrivals` takes only a cursor, so the
580    /// node volunteers its OWN watched puzzle hashes and the receive history behind them -- the
581    /// node-to-address association, which is not public, and which a token-less caller could then
582    /// replay into the caller-addressed reads.
583    ///
584    /// The control keeps `control.wallet.coinById` in the same assertion: it is the neighbour the
585    /// analogy was drawn from, it is still open, and it stays open because its CALLER supplies the
586    /// coin id. Without that control this test would also pass on a wholesale gating of the wallet
587    /// category, which is a different (and wrong) implementation.
588    #[test]
589    fn the_arrival_cursor_is_not_an_open_read() {
590        assert!(
591            !ControlMethod::WalletArrivals.is_open_read(),
592            "control.wallet.arrivals discloses this node's OWN watched puzzle hashes to a caller \
593             that supplied nothing, so it MUST NOT be served token-less"
594        );
595        assert!(ControlMethod::WalletArrivals.requires_auth());
596        assert!(
597            ControlMethod::WalletCoinById.is_open_read(),
598            "the caller-addressed reads stay open -- the fix is the membership rule, not gating \
599             the wallet category"
600        );
601    }
602
603    /// **The control plane names every chain primitive `ChainSource` needs.**
604    ///
605    /// The list is written out rather than derived, because the property under test is a claim about
606    /// ANOTHER crate's trait (`dig-chainsource-interface`'s `ChainSource`) that no compiler here can
607    /// check. Five of its seven methods need a control method of their own. The other two need none:
608    /// `parent_spend` is a trait DEFAULT composed from `coin_record` + `coin_spend`, and
609    /// `resolve_singleton_lineage` is composed CLIENT-side from the primitives below rather than
610    /// served as a walk the node performs.
611    ///
612    /// `block_timestamp` is deliberately ABSENT from the control plane. dig-node's light client
613    /// (`chia-peer`'s `ChiaPeerProvider`) does not index block timestamps and answers `Unsupported`,
614    /// so a control method for it could only ever be refused — a surface that looks live and does
615    /// nothing. A consumer mirrors that refusal honestly; if one ever genuinely needs the value, the
616    /// method is an additive minor at that point.
617    ///
618    /// A missing name here is not a cosmetic gap: a client that cannot answer one of these cannot
619    /// implement the trait at all, which is what made a dig-profile mint structurally impossible
620    /// through the node before these two were added (dig_ecosystem#2572).
621    #[test]
622    fn the_catalog_serves_every_chain_source_primitive() {
623        for wire in [
624            "control.wallet.coinById",      // coin_record
625            "control.wallet.coins",         // coin_records_by_puzzle_hash
626            "control.wallet.peak",          // peak_height
627            "control.wallet.coinsByParent", // coin_records_by_parent
628            "control.wallet.coinSpend",     // coin_spend
629        ] {
630            assert!(
631                ControlMethod::from_name(wire).is_some(),
632                "{wire} is required to implement ChainSource over the control plane"
633            );
634        }
635    }
636
637    /// **The two chain primitives are `coinById`'s neighbours, not `arrivals`'.**
638    ///
639    /// Each takes a caller-supplied coin id and returns a deterministic public chain fact,
640    /// so the membership rule — *who names the subject* — puts them on the open side. The gated
641    /// control in the same assertion is what makes the test load-bearing: without it, a wholesale
642    /// opening of the wallet category would pass, and that is a different (and wrong) implementation.
643    #[test]
644    fn the_chain_primitives_are_caller_named_open_reads() {
645        for method in [
646            ControlMethod::WalletCoinSpend,
647            ControlMethod::WalletCoinsByParent,
648        ] {
649            assert!(
650                method.is_open_read(),
651                "{} names its subject in the request and discloses no node-to-address \
652                 association, exactly like control.wallet.coinById",
653                method.name()
654            );
655            assert!(!method.requires_auth());
656        }
657        assert!(
658            ControlMethod::WalletArrivals.requires_auth(),
659            "the caller-supplies-nothing read stays gated -- the rule is who names the subject, \
660             not whether the bytes are on chain"
661        );
662        assert!(ControlMethod::WalletBroadcast.requires_auth());
663    }
664
665    /// **All three enrolment methods are gated — including the one that only reads.**
666    ///
667    /// `control.wallet.watch` and `.unwatch` aim what the node follows, so they are mutations and the
668    /// question barely arises. `control.wallet.watched` is the one a future reader will be tempted to
669    /// open, because it returns nothing but public keys and every other wallet READ in this catalog is
670    /// open. It stays gated under the SAME rule that gates `control.wallet.arrivals`: the caller
671    /// supplies nothing, so the node volunteers its OWN enrolled keys — the node-to-key association,
672    /// which is not public, and which a token-less caller could replay straight into the
673    /// caller-addressed reads.
674    ///
675    /// The control keeps `control.wallet.coinById` open in the same assertion. Without it this test
676    /// would also pass on a wholesale gating of the wallet category, which is a different (and wrong)
677    /// implementation.
678    #[test]
679    fn the_enrolment_methods_are_gated_including_the_read() {
680        for wire in [
681            "control.wallet.watch",
682            "control.wallet.unwatch",
683            "control.wallet.watched",
684        ] {
685            let method = ControlMethod::from_name(wire)
686                .unwrap_or_else(|| panic!("{wire} must be in the catalog"));
687            assert!(
688                !method.is_open_read(),
689                "{wire} either aims this node's subscriptions or names the keys it already \
690                 follows, so it MUST NOT be served token-less"
691            );
692            assert!(method.requires_auth(), "{wire} must require the token");
693            assert_eq!(method.category(), Category::Wallet);
694            assert_eq!(method.routing(), Routing::Delegated);
695        }
696        assert!(
697            ControlMethod::WalletCoinById.is_open_read(),
698            "the caller-addressed reads stay open -- enrolment is gated by the membership rule, \
699             not by gating the wallet category"
700        );
701    }
702
703    #[test]
704    fn only_pairing_bootstrap_is_open_bootstrap_routed() {
705        for &m in ControlMethod::ALL {
706            let open_bootstrap = matches!(
707                m,
708                ControlMethod::PairingRequest | ControlMethod::PairingPoll
709            );
710            assert_eq!(
711                m.routing() == Routing::OpenBootstrap,
712                open_bootstrap,
713                "{} routing mismatch",
714                m.name()
715            );
716        }
717    }
718
719    #[test]
720    fn pairing_admin_methods_are_exactly_three() {
721        let admin: Vec<&str> = ControlMethod::ALL
722            .iter()
723            .filter(|m| m.is_pairing_admin())
724            .map(|m| m.name())
725            .collect();
726        assert_eq!(
727            admin,
728            vec![
729                "control.pairing.list",
730                "control.pairing.approve",
731                "control.pairing.revoke"
732            ]
733        );
734    }
735
736    #[test]
737    fn delegated_set_matches_the_engine_surface() {
738        let delegated: BTreeSet<&str> = ControlMethod::ALL
739            .iter()
740            .filter(|m| m.routing() == Routing::Delegated)
741            .map(|m| m.name())
742            .collect();
743        let expected: BTreeSet<&str> = [
744            "control.wallet.coins",
745            "control.wallet.coinById",
746            "control.wallet.coinSpend",
747            "control.wallet.coinsByParent",
748            "control.wallet.arrivals",
749            "control.wallet.peak",
750            "control.wallet.syncStatus",
751            "control.wallet.broadcast",
752            "control.wallet.watch",
753            "control.wallet.unwatch",
754            "control.wallet.watched",
755            "control.peerStatus",
756            "control.peerCounts",
757            "control.peers.connect",
758            "control.peers.disconnect",
759            "control.subscribe",
760            "control.unsubscribe",
761            "control.listSubscriptions",
762            "control.wallet.balance",
763        ]
764        .into_iter()
765        .collect();
766        assert_eq!(delegated, expected);
767    }
768
769    #[test]
770    fn every_method_has_a_nonempty_summary() {
771        for &m in ControlMethod::ALL {
772            assert!(!m.summary().is_empty(), "{} has no summary", m.name());
773        }
774    }
775}