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