Skip to main content

dig_node_control_interface/
traits.rs

1//! The two contract traits: the client-facing call builder/parser and the node-facing handler.
2//!
3//! * [`ControlCall`] binds a typed params struct to its [`ControlMethod`] and its typed result — so
4//!   a caller writes `client.request(&SetCapParams { cap_bytes })` and gets back a `SetCapResult`,
5//!   never a stringly-typed `Value`.
6//! * [`ControlClient`] is what a CLIENT depends on: build a JSON-RPC request from a typed call, and
7//!   parse a response back into the typed result (or a [`ControlError`]). Pure — no transport; the
8//!   consumer carries the bytes over dig-ipc / loopback-mTLS itself.
9//! * [`ControlHandler`] is what a NODE implements to SERVE the surface: one typed method per control
10//!   method, plus a provided [`dispatch`](ControlHandler::dispatch) that routes a raw request to the
11//!   right method — the single anti-drift seam the conformance KATs exercise.
12
13use async_trait::async_trait;
14use serde::de::DeserializeOwned;
15use serde::Serialize;
16use serde_json::Value;
17
18use crate::envelope::{JsonRpcRequest, JsonRpcResponse, RequestId};
19use crate::error::{ControlError, ControlErrorCode};
20use crate::method::ControlMethod;
21use crate::params;
22use crate::results;
23
24/// A typed control call: a params struct that knows its [`ControlMethod`] and its result type.
25///
26/// Implemented by every struct in [`crate::params`]; this is what makes
27/// [`ControlClient::parse_response`] return the right typed result for each method at compile time.
28pub trait ControlCall: Serialize {
29    /// The wire method this call invokes.
30    const METHOD: ControlMethod;
31    /// The typed result this call returns on success.
32    type Output: DeserializeOwned;
33}
34
35/// Serialize a typed call's params into a JSON object (`{}` for a no-param call, never `null`).
36fn params_value<C: ControlCall>(call: &C) -> Value {
37    match serde_json::to_value(call) {
38        Ok(Value::Null) => Value::Object(Default::default()),
39        Ok(v) => v,
40        Err(_) => Value::Object(Default::default()),
41    }
42}
43
44/// Build the JSON-RPC request envelope for a typed control call. Pure.
45pub fn build_request<C: ControlCall>(id: RequestId, call: &C) -> JsonRpcRequest {
46    JsonRpcRequest::new(id, C::METHOD.name(), params_value(call))
47}
48
49/// Parse a JSON-RPC response into a typed result, or the [`ControlError`] it carried. Pure.
50pub fn parse_response<C: ControlCall>(
51    response: JsonRpcResponse,
52) -> Result<C::Output, ControlError> {
53    let value = response.into_result()?;
54    serde_json::from_value(value).map_err(|e| {
55        ControlError::of(
56            ControlErrorCode::ControlError,
57            format!("failed to parse {} result: {e}", C::METHOD.name()),
58        )
59    })
60}
61
62/// The client-facing half of the contract: turn typed calls into requests and responses back into
63/// typed results.
64///
65/// The default implementations cover every client; a consumer implements this trait only to
66/// customise request construction (e.g. attaching the control token in a bespoke way). The blanket
67/// [`DefaultControlClient`] gives callers the standard behaviour for free.
68pub trait ControlClient {
69    /// Build the request envelope for a typed call with the given request `id`.
70    fn build_request<C: ControlCall>(&self, id: RequestId, call: &C) -> JsonRpcRequest {
71        build_request(id, call)
72    }
73
74    /// Parse a response envelope into the typed result for call type `C`.
75    fn parse_response<C: ControlCall>(
76        &self,
77        response: JsonRpcResponse,
78    ) -> Result<C::Output, ControlError> {
79        parse_response::<C>(response)
80    }
81}
82
83/// The standard, zero-configuration [`ControlClient`] using the default request/response behaviour.
84#[derive(Debug, Clone, Copy, Default)]
85pub struct DefaultControlClient;
86
87impl ControlClient for DefaultControlClient {}
88
89/// The node-facing half of the contract: a running node implements this to SERVE the control
90/// surface. Each method is typed to the catalog's params/results; the provided
91/// [`dispatch`](ControlHandler::dispatch) routes a raw [`JsonRpcRequest`] to the right method so a
92/// server needs only one entry point and can never mis-route.
93///
94/// Open/proxied shapes (the updater beacon status, the pairing list, the peer-pool snapshot) return
95/// [`Value`] rather than a frozen struct, matching the catalog's [`ControlCall::Output`] for those
96/// methods.
97#[async_trait]
98pub trait ControlHandler: Sync {
99    /// `control.status`
100    async fn status(&self) -> Result<results::StatusResult, ControlError>;
101    /// `control.config.get`
102    async fn config_get(&self) -> Result<results::ConfigResult, ControlError>;
103    /// `control.config.setUpstream`
104    async fn config_set_upstream(
105        &self,
106        params: params::SetUpstreamParams,
107    ) -> Result<results::SetUpstreamResult, ControlError>;
108    /// `control.config.setMirrorAdvertiseUrls`
109    ///
110    /// An implementation MUST:
111    ///
112    /// - **Answer `requires_restart` TRUTHFULLY for its OWN implementation — never a value this
113    ///   contract fixes.** dig-node#562's advertise decision is recomputed every mirror-coin-
114    ///   creation pass rather than read once at start, so LIVE is a real, reachable answer here,
115    ///   unlike `config_set_upstream`'s override. It is not, however, automatic: a persisted
116    ///   override only counts as live once dig-node's OWN recomputation actually reads it from
117    ///   somewhere this call wrote to. Reading it from `DIG_MIRROR_ADVERTISE_URLS`, an OS
118    ///   ENVIRONMENT VARIABLE, does NOT qualify — a running process cannot change its own
119    ///   environment, so an implementation backed by that variable MUST answer `true`. Answering
120    ///   `false` while still reading the environment would tell dig-app "saved" about a URL the
121    ///   node is not actually advertising, which is a lie about whether a privileged action took
122    ///   effect — the one thing this contract never defers.
123    /// - **Never apply dig-node#562's routability or "this machine only" checks to REJECT the
124    ///   call.** Those gate what a DERIVED address may publish; an operator's value is a
125    ///   deliberate choice (dig-node#562), and rejecting a LAN address here would silently break
126    ///   every LAN-only deployment. `params.validated()` already performed the ONE check this
127    ///   contract makes — well-formedness — before this method is ever called.
128    /// - **Return the view a follow-up `config_get` would show ONCE `requires_restart` clears**,
129    ///   so a caller never has to re-read to learn what was persisted — see
130    ///   [`results::SetMirrorAdvertiseUrlsResult`].
131    async fn config_set_mirror_advertise_urls(
132        &self,
133        params: params::SetMirrorAdvertiseUrlsParams,
134    ) -> Result<results::SetMirrorAdvertiseUrlsResult, ControlError>;
135    /// `control.log.setLevel`
136    async fn log_set_level(
137        &self,
138        params: params::SetLevelParams,
139    ) -> Result<results::SetLevelResult, ControlError>;
140    /// `control.cache.get`
141    async fn cache_get(&self) -> Result<results::CacheView, ControlError>;
142    /// `control.cache.setCap`
143    async fn cache_set_cap(
144        &self,
145        params: params::SetCapParams,
146    ) -> Result<results::SetCapResult, ControlError>;
147    /// `control.cache.clear`
148    async fn cache_clear(&self) -> Result<results::CacheClearResult, ControlError>;
149    /// `control.hostedStores.list`
150    async fn hosted_stores_list(&self) -> Result<results::HostedStoresListResult, ControlError>;
151    /// `control.hostedStores.pin`
152    async fn hosted_stores_pin(
153        &self,
154        params: params::PinParams,
155    ) -> Result<results::PinResult, ControlError>;
156    /// `control.hostedStores.unpin`
157    async fn hosted_stores_unpin(
158        &self,
159        params: params::UnpinParams,
160    ) -> Result<results::UnpinResult, ControlError>;
161    /// `control.hostedStores.status`
162    async fn hosted_stores_status(
163        &self,
164        params: params::HostedStoreStatusParams,
165    ) -> Result<results::HostedStoreStatusResult, ControlError>;
166    /// `control.capsule.fetch`
167    async fn capsule_fetch(
168        &self,
169        params: params::CapsuleFetchParams,
170    ) -> Result<results::CapsuleFetchResult, ControlError>;
171    /// `control.sync.status`
172    async fn sync_status(&self) -> Result<results::SyncStatusResult, ControlError>;
173    /// `control.sync.trigger`
174    async fn sync_trigger(
175        &self,
176        params: params::SyncTriggerParams,
177    ) -> Result<results::SyncTriggerResult, ControlError>;
178    /// `control.updater.status`
179    async fn updater_status(&self) -> Result<Value, ControlError>;
180    /// `control.updater.setChannel`
181    async fn updater_set_channel(
182        &self,
183        params: params::SetChannelParams,
184    ) -> Result<Value, ControlError>;
185    /// `control.updater.pause`
186    async fn updater_pause(&self, params: params::PauseParams) -> Result<Value, ControlError>;
187    /// `control.updater.resume`
188    async fn updater_resume(&self) -> Result<Value, ControlError>;
189    /// `control.updater.checkNow`
190    async fn updater_check_now(&self) -> Result<Value, ControlError>;
191    /// `control.pairing.list`
192    async fn pairing_list(&self) -> Result<Value, ControlError>;
193    /// `control.pairing.approve`
194    async fn pairing_approve(
195        &self,
196        params: params::ApproveParams,
197    ) -> Result<results::PairingApproveResult, ControlError>;
198    /// `control.pairing.revoke`
199    async fn pairing_revoke(
200        &self,
201        params: params::RevokeParams,
202    ) -> Result<results::PairingRevokeResult, ControlError>;
203    /// `control.peerStatus`
204    async fn peer_status(&self) -> Result<Value, ControlError>;
205    /// `control.peers.connect`
206    async fn peers_connect(
207        &self,
208        params: params::PeersConnectParams,
209    ) -> Result<results::PeersConnectResult, ControlError>;
210    /// `control.peers.disconnect`
211    async fn peers_disconnect(
212        &self,
213        params: params::PeersDisconnectParams,
214    ) -> Result<results::PeersDisconnectResult, ControlError>;
215    /// `control.chiaPeers.add` — start trusting a Chia full node the operator RUNS.
216    ///
217    /// An implementation MUST write through to the ONE peer store its wallet replica reads; a
218    /// second peer list is a drift bug waiting to happen.
219    ///
220    /// Three obligations the shell cannot infer from the types:
221    ///
222    /// - it requires the MASTER token ([`ControlMethod::requires_master_token`]). The entry it
223    ///   writes carries authority that outlives the calling token, and `control.pairing.revoke`
224    ///   does not remove it;
225    /// - `params.ip` is canonicalised with [`crate::params::canonical_peer_ip`] and STORED in that
226    ///   form, so `remove` and `list` can match what `add` wrote;
227    /// - `corroboration_bypassed` reports the RESULTING trust state and `notice` carries the
228    ///   node's own warning verbatim. Reporting a bypass that did not happen tells an operator
229    ///   they configured a node they did not.
230    async fn chia_peers_add(
231        &self,
232        params: params::ChiaPeersAddParams,
233    ) -> Result<results::ChiaPeersAddResult, ControlError>;
234    /// `control.chiaPeers.list` — the tracked Chia full-node peers, banned ones included.
235    ///
236    /// Ordinary token tier: a read that confers nothing, and a paired client that cannot show the
237    /// operator this list cannot show them the trust state they are subject to.
238    async fn chia_peers_list(&self) -> Result<results::ChiaPeersListResult, ControlError>;
239    /// `control.chiaPeers.remove` — stop trusting a Chia full node.
240    ///
241    /// MASTER token, like `add`. This is the ONLY un-trust remedy, so an implementation MUST
242    /// return [`results::ChiaPeerRemovalOutcome::NoSuchPeer`] when nothing matched rather than
243    /// reporting a removal it did not perform.
244    async fn chia_peers_remove(
245        &self,
246        params: params::ChiaPeersRemoveParams,
247    ) -> Result<results::ChiaPeersRemoveResult, ControlError>;
248    /// `control.subscribe`
249    ///
250    /// `params.kind` is OPTIONAL on the wire and absent means
251    /// [`SubscriptionKind::Capsule`](params::SubscriptionKind::Capsule). An implementation MUST
252    /// persist untagged rows it already holds as capsules rather than discarding them: a node that
253    /// refuses to read its own pre-existing `subscriptions.json` starts with an empty one, and the
254    /// upgrade silently unsubscribes the user from everything.
255    async fn subscribe(
256        &self,
257        params: params::SubscribeParams,
258    ) -> Result<results::SubscribeResult, ControlError>;
259    /// `control.unsubscribe`
260    async fn unsubscribe(
261        &self,
262        params: params::UnsubscribeParams,
263    ) -> Result<results::UnsubscribeResult, ControlError>;
264    /// `control.listSubscriptions`
265    async fn list_subscriptions(&self) -> Result<results::ListSubscriptionsResult, ControlError>;
266    /// `control.wallet.balance` (READ-only)
267    async fn wallet_balance(
268        &self,
269        params: params::WalletBalanceParams,
270    ) -> Result<results::WalletBalanceResult, ControlError>;
271    /// `control.wallet.coins` (READ-only, OPEN)
272    ///
273    /// An empty `coins` list MUST mean "a chain was consulted and this address holds nothing".
274    /// A read that could not consult a chain MUST return the matching catalogued error instead.
275    async fn wallet_coins(
276        &self,
277        params: params::WalletCoinsParams,
278    ) -> Result<results::WalletCoinsResult, ControlError>;
279    /// `control.wallet.coinById` (READ-only, OPEN)
280    ///
281    /// `Ok(coin: None)` MUST mean "a chain was consulted and holds no such coin". A read that could
282    /// not consult a chain MUST return the matching catalogued error instead — a caller that cannot
283    /// tell those apart reports a spent mint as pending forever.
284    ///
285    /// The params are validated at DESERIALIZATION (lowercase 64-hex, `0x` stripped), so any path
286    /// that decodes `WalletCoinByIdParams` refuses malformed ids as `INVALID_PARAMS` before this
287    /// method is called.
288    async fn wallet_coin_by_id(
289        &self,
290        params: params::WalletCoinByIdParams,
291    ) -> Result<results::WalletCoinByIdResult, ControlError>;
292    /// `control.wallet.coinSpend` (READ-only, OPEN)
293    ///
294    /// `Ok(spend: None)` MUST mean "a chain was consulted and holds no spend of that coin" — the
295    /// coin is unspent, or unknown. A read that could not consult a chain MUST return the matching
296    /// catalogued error instead: a caller following a singleton forward reads "no spend" as *this is
297    /// the tip* and stops walking, so a failure disguised as absence produces a spend built against
298    /// a superseded singleton.
299    ///
300    /// A returned spend's `puzzle_reveal` MUST tree-hash to the spent coin's own `puzzle_hash`, and
301    /// the implementation MUST fail closed — an error, never an unverified reveal — when it does not
302    /// or when the reveal will not parse. The reveal comes from a peer, and a peer can lie.
303    ///
304    /// The params are validated at DESERIALIZATION (lowercase 64-hex, `0x` stripped), so any path
305    /// that decodes `WalletCoinSpendParams` refuses malformed ids as `INVALID_PARAMS` before this
306    /// method is called.
307    async fn wallet_coin_spend(
308        &self,
309        params: params::WalletCoinSpendParams,
310    ) -> Result<results::WalletCoinSpendResult, ControlError>;
311    /// `control.wallet.coinsByParent` (READ-only, OPEN)
312    ///
313    /// Returns the parent's DIRECT children and nothing further. An implementation MUST NOT recurse:
314    /// a transitive walk over caller-supplied input is unbounded work the caller cannot bound, and a
315    /// partial walk returned as a complete one is a lineage with a silent hole in it.
316    ///
317    /// An empty list MUST mean "a chain was consulted and this parent created no known children".
318    /// A read that could not consult a chain MUST return the matching catalogued error instead.
319    ///
320    /// The answer is ONE PAGE. An implementation MUST return at most
321    /// `params.effective_limit()` records, in ASCENDING `coin_id` order, starting strictly after
322    /// `params.after_coin_id` when one is given; it MUST set `complete` to whether the page carries
323    /// the last child; and it MUST set `cursor` to the last record it actually returned (`None` for
324    /// an empty page). It MUST NOT report `complete: true` on a page it truncated — a caller reads
325    /// that as the end of a lineage branch. The params are validated at DESERIALIZATION, so an
326    /// out-of-range page size is refused as `INVALID_PARAMS` before this method is called.
327    ///
328    /// Every record MUST report `asset: None`: naming a coin by its parent classifies nothing, and
329    /// asserting a class this read never verified is a claim a caller would then spend against.
330    async fn wallet_coins_by_parent(
331        &self,
332        params: params::WalletCoinsByParentParams,
333    ) -> Result<results::WalletCoinsByParentResult, ControlError>;
334    /// `control.wallet.arrivals` (READ-only, TOKEN-GATED)
335    ///
336    /// Gated although it is a read: the caller supplies only a cursor, so the answer names this
337    /// node's OWN watched puzzle hashes and the receive history behind them.
338    ///
339    /// Every returned row MUST be a CONFIRMED arrival that the node itself judged: above its arrival
340    /// baseline, not previously reported, and not the wallet's own change. An implementation MUST NOT
341    /// emit a mempool sighting here, and MUST answer an empty page rather than an error when it has
342    /// no baseline — "nothing arrived" is the honest answer from a wallet that cannot yet tell
343    /// history from news.
344    ///
345    /// `cursor` MUST be the position of the last row actually returned (or the caller's `after_seq`
346    /// for an empty page) and MUST NOT be `latest`; see
347    /// [`WalletArrivalsResult::latest`](results::WalletArrivalsResult::latest).
348    async fn wallet_arrivals(
349        &self,
350        params: params::WalletArrivalsParams,
351    ) -> Result<results::WalletArrivalsResult, ControlError>;
352    /// `control.wallet.peak` (READ-only, OPEN)
353    async fn wallet_peak(&self) -> Result<results::WalletPeakResult, ControlError>;
354    /// `control.wallet.operatorAddress` (READ-only, TOKEN-GATED)
355    ///
356    /// An implementation MUST:
357    ///
358    /// - **Answer for its OWN operator wallet** -- the machine-custody autoseed wallet that pays
359    ///   mirror-coin collateral -- and never for the user's wallet, a watched address, or an
360    ///   upstream node's wallet. The method exists because those were indistinguishable, and an
361    ///   implementation that forwarded it would recreate the confusion exactly.
362    /// - **Return ONLY the address and its puzzle hash.** No seed, no mnemonic, no private or
363    ///   extended key, no derivation material, in any encoding (§908). This method says where money
364    ///   can be SENT; nothing here may help anyone spend it.
365    /// - **Perform no mutation.** It MUST NOT create, unseal-and-cache, rotate or initialise a
366    ///   wallet as a side effect of being read. A node without one answers
367    ///   [`NotInitialized`](results::WalletOperatorAddressUnavailableReason::NotInitialized).
368    /// - **Answer [`Unavailable`](results::WalletOperatorAddressResult::Unavailable) with a reason,
369    ///   never a blank or placeholder address.** An address a client renders is an address somebody
370    ///   may send money to.
371    async fn wallet_operator_address(
372        &self,
373    ) -> Result<results::WalletOperatorAddressResult, ControlError>;
374    /// `control.peerCounts` (READ-only, OPEN)
375    ///
376    /// `dig_peer_count` MUST be dig-node-core's `connected_peers` — the same figure
377    /// `control.peerStatus` reports — and `chia_peer_count` MUST be the SAME observation
378    /// `wallet_sync_status` reports, served from ONE source so the two answers agree. `None` means
379    /// the count cannot be observed; a network that is not running is UNKNOWN, never `Some(0)`.
380    async fn peer_counts(&self) -> Result<results::PeerCountsResult, ControlError>;
381    /// `control.wallet.syncStatus` (READ-only, OPEN)
382    ///
383    /// `WalletSyncPhase::Synced` MUST require BOTH that the initial catch-up completed and that at
384    /// least one Chia peer connection is live now, which makes it strictly stronger than
385    /// `WalletPeakResult::synced`. `peak_height` MUST be the node's OWN replica's height or `None`,
386    /// never an oracle's, and `chia_peer_count` counts CHIA full-node peers -- never DIG peers.
387    async fn wallet_sync_status(&self) -> Result<results::WalletSyncStatusResult, ControlError>;
388    /// `control.wallet.broadcast` (TOKEN-GATED)
389    ///
390    /// Pushes an ALREADY-SIGNED bundle: the implementation never signs, and never receives anything
391    /// it could sign with (§908). A mempool refusal is `Ok` with `accepted: false`; failing to
392    /// reach a mempool is `Err`.
393    async fn wallet_broadcast(
394        &self,
395        params: params::WalletBroadcastParams,
396    ) -> Result<results::WalletBroadcastResult, ControlError>;
397    /// `control.wallet.watch` (TOKEN-GATED)
398    ///
399    /// Enrols PUBLIC keys for the node's chain replica to follow. The implementation MUST derive the
400    /// addresses itself, from the SAME derivation it applies to the keys already in its own custody
401    /// — a second derivation is a second opinion about which addresses a key covers, and the client
402    /// would read the difference as missing money.
403    ///
404    /// It MUST be IDEMPOTENT: keys already enrolled are reported as `added: 0` and the call
405    /// succeeds. It MUST persist the enrolment across restarts — a set that evaporates on restart
406    /// makes a node that syncs today and reports a zero balance tomorrow.
407    ///
408    /// The params are validated at DESERIALIZATION (lowercase 96-hex, `0x` stripped), so any path
409    /// that decodes `WalletWatchParams` refuses a malformed key as `INVALID_PARAMS` — for the WHOLE
410    /// request — before this method is called.
411    async fn wallet_watch(
412        &self,
413        params: params::WalletWatchParams,
414    ) -> Result<results::WalletWatchResult, ControlError>;
415    /// `control.wallet.unwatch` (TOKEN-GATED)
416    ///
417    /// Deregisters keys, and the following MUST actually stop: the addresses leave the replica's
418    /// watched set, not merely the list this node reports. A registry that keeps syncing what it
419    /// says it forgot is the failure this method exists to make impossible.
420    ///
421    /// Deregistering a key that was never enrolled is a success reporting `removed: 0`.
422    async fn wallet_unwatch(
423        &self,
424        params: params::WalletUnwatchParams,
425    ) -> Result<results::WalletUnwatchResult, ControlError>;
426    /// `control.wallet.watched` (READ-only, TOKEN-GATED)
427    ///
428    /// Gated although it is a read: the caller supplies nothing, so the answer names this node's OWN
429    /// enrolled keys.
430    ///
431    /// MUST return exactly the keys enrolment added, in the wire form they were accepted in, so a
432    /// client can compare its own set against the node's by value. MUST NOT include the node's own
433    /// custody keys: this method reports what was ENROLLED through it, and a caller reconciling
434    /// against a superset would unwatch keys it never watched.
435    async fn wallet_watched(&self) -> Result<results::WalletWatchedResult, ControlError>;
436    /// `control.wallet.resetCoinDb` (TOKEN-GATED, DESTRUCTIVE)
437    ///
438    /// Discards this node's cached coin database and re-syncs it from chain. Every dropped table is
439    /// chain-derived and reproduced by syncing -- no key material is affected. The implementation
440    /// MUST refuse as `INVALID_PARAMS` unless the caller set `confirm = true`, and MUST refuse a
441    /// reset while a spend is in flight (a hold this call would otherwise silently invalidate) --
442    /// see [`WalletResetCoinDbParams`](params::WalletResetCoinDbParams).
443    async fn wallet_reset_coin_db(
444        &self,
445        params: params::WalletResetCoinDbParams,
446    ) -> Result<results::WalletResetCoinDbResult, ControlError>;
447    /// `control.wallet.reservations.held` (READ-only, TOKEN-GATED)
448    ///
449    /// Gated although it is a read: the caller supplies nothing, so the answer names this node's OWN
450    /// in-flight commitments.
451    ///
452    /// An implementation MUST read its own clock rather than accept one, MUST omit every reservation
453    /// that has already lapsed at that instant, and MUST report the clock it used as `as_of_unix`.
454    ///
455    /// An implementation that cannot read its reservation set MUST return
456    /// [`ControlErrorCode::WalletReservationsUnavailable`]
457    /// and MUST NOT return an empty list. "Nothing is held" permits a caller to spend; "I cannot
458    /// tell" must stop it, and the two are indistinguishable once collapsed.
459    async fn wallet_reservations_held(
460        &self,
461    ) -> Result<results::WalletReservationsHeldResult, ControlError>;
462    /// `control.wallet.reservations.reserve` (TOKEN-GATED)
463    ///
464    /// Acquisition MUST be atomic across concurrent callers: take EVERY coin in `coin_ids` or take
465    /// none. On a clash an implementation MUST have written nothing and MUST answer
466    /// [`ControlErrorCode::WalletCoinsReserved`]
467    /// — never a shortfall code, because the user has the money and is waiting on a settlement, and
468    /// never a partial success, because a caller believing it holds inputs it does not is the state
469    /// all-or-none exists to make unreachable.
470    ///
471    /// An empty `coin_ids` MUST succeed, returning a handle that releases nothing.
472    ///
473    /// The hold MUST expire on its own. An implementation clamps the requested `ttl_secs` to its own
474    /// maximum, applies its default when none is given, and MUST report the lifetime it actually
475    /// applied — a caller told nothing would release on a schedule the node does not keep.
476    ///
477    /// An implementation MUST NOT require, accept or store key material here (§908). A reservation
478    /// is bookkeeping: it narrows what a selector will choose and authorizes nothing.
479    async fn wallet_reservations_reserve(
480        &self,
481        params: params::WalletReservationsReserveParams,
482    ) -> Result<results::WalletReservationsReserveResult, ControlError>;
483    /// `control.wallet.reservations.release` (TOKEN-GATED)
484    ///
485    /// Releasing a handle that names no live reservation — lapsed, or already released — MUST be a
486    /// SUCCESS reporting `released: false`, never an error. A caller releasing on confirmation
487    /// cannot know whether the TTL got there first, and an error there teaches callers to discard
488    /// the result, which is how the release path stops being called at all.
489    ///
490    /// An implementation MUST free every coin the handle holds, or none of them, for the same reason
491    /// acquisition is all-or-none: a half-freed reservation leaves coins held by a handle the caller
492    /// has thrown away, and only the TTL would ever recover them.
493    async fn wallet_reservations_release(
494        &self,
495        params: params::WalletReservationsReleaseParams,
496    ) -> Result<results::WalletReservationsReleaseResult, ControlError>;
497    /// `control.spends.list` (READ-only, TOKEN-GATED)
498    ///
499    /// One page of the automated-spend audit record — the spends this node made WITHOUT
500    /// per-transaction approval. Gated although it is a read: the caller names no identifier, so the
501    /// answer is this node's OWN spending history.
502    ///
503    /// An implementation MUST NOT let this call initiate, sign, retry, cancel or amend a spend, and
504    /// MUST NOT expose any control method that edits or deletes an entry. The record replaces
505    /// authorization with accountability, and an editable record accounts for nothing.
506    ///
507    /// Four obligations, each of which a plausible implementation gets wrong:
508    ///
509    /// - **Report the failure STAGE, never a bare "failed."** Only
510    ///   [`SpendFailureStage::Signing`](results::SpendFailureStage::Signing) means the money
511    ///   definitely did not move; a broadcast or confirmation failure is an unknown outcome. An
512    ///   implementation that flattens the stage makes every client structurally unable to tell a
513    ///   person the truth about their money.
514    /// - **Keep [`Unresolved`](results::SpendOutcome::Unresolved) distinct from `Failed`.** It means
515    ///   the node signed and does not know how it ended.
516    /// - **State completeness explicitly.** `complete` MUST be `false` whenever a matching row was
517    ///   withheld, and `cursor` MUST be the id of the last row actually returned.
518    /// - **Report unreadable entries.** `unreadable_lines` MUST count entries the node could not
519    ///   parse; a trail that lost rows must never read as a tidy shorter one. A record that could not
520    ///   be read AT ALL is
521    ///   [`SpendAuditUnreadable`](crate::error::ControlErrorCode::SpendAuditUnreadable), never an
522    ///   empty page — while a record that was never written IS an empty page, because a node that has
523    ///   never spent automatically is the ordinary case.
524    async fn spends_list(
525        &self,
526        params: params::SpendsListParams,
527    ) -> Result<results::SpendsListResult, ControlError>;
528    /// `control.collateral.requirement` (TOKEN-GATED)
529    ///
530    /// This epoch's derived per-store collateral requirement, with the census inputs behind it.
531    ///
532    /// Gated although the figure itself is derivable from chain by anyone: the UNKNOWN branch names
533    /// this node's own census position, and the caller supplies no identifier, so the answer is a
534    /// fact about this node rather than a relayed public one.
535    ///
536    /// Three obligations, each of which a plausible implementation gets wrong:
537    ///
538    /// - **Answer `unknown` WITH a reason rather than a number the node does not have.** A node that
539    ///   has not censused the epoch, or that sits inside `CENSUS_FINALITY_DEPTH_BLOCKS` of the tip,
540    ///   MUST return [`Unknown`](results::CollateralRequirementResult::Unknown). It MUST NOT return
541    ///   a zero, a stale epoch's figure presented as this epoch's, or an error that a client would
542    ///   render as "no collateral required" — under-posting costs the operator that epoch's rewards.
543    /// - **Report the protocol version that COMPUTED the epoch**, not the newest version this build
544    ///   implements. The two differ exactly when a node has upgraded mid-schedule, which is the one
545    ///   case where a client needs to know the difference.
546    /// - **Never derive the figure from the local safety margin.** The margin MUST NOT reach any
547    ///   value another node derives; `required_per_store_dig_base_units` is the pre-margin
548    ///   requirement, and a node that returned the margined amount here would make its own
549    ///   preference look like the network's price.
550    async fn collateral_requirement(
551        &self,
552    ) -> Result<results::CollateralRequirementResult, ControlError>;
553
554    /// `control.collateral.margin.get` (TOKEN-GATED)
555    ///
556    /// The node's local safety margin in basis points.
557    ///
558    /// A node whose stored configuration predates the field MUST answer
559    /// [`DEFAULT_SAFETY_MARGIN_BP`](params::DEFAULT_SAFETY_MARGIN_BP), never `0`: a zero margin is a
560    /// deliberate choice to post the requirement exactly, and reporting it for a config that never
561    /// expressed one tells the operator they opted out of a cushion they never declined.
562    async fn collateral_margin_get(&self) -> Result<results::CollateralMarginResult, ControlError>;
563
564    /// `control.collateral.margin.set` (TOKEN-GATED)
565    ///
566    /// Persist the node's local safety margin and return the margin now in force.
567    ///
568    /// The node is the authoritative home for this setting — the flywheel is headless, so a machine
569    /// with no GUI must be able to set it — and dig-app is a remote control for the same value.
570    ///
571    /// Two obligations:
572    ///
573    /// - **Persist it**, so it survives a restart. A margin that lapses to the default on reboot
574    ///   silently changes what the node posts.
575    /// - **Return what was actually stored.** The returned `margin_bp` MUST equal the accepted
576    ///   request's, because a value above [`MAX_SAFETY_MARGIN_BP`](params::MAX_SAFETY_MARGIN_BP) is
577    ///   REFUSED rather than clamped. An implementation that clamped and returned the clamped value
578    ///   would leave the caller's stored intent and the node's behaviour disagreeing on the money
579    ///   path.
580    async fn collateral_margin_set(
581        &self,
582        params: params::CollateralMarginSetParams,
583    ) -> Result<results::CollateralMarginResult, ControlError>;
584
585    /// `control.collateral.buffer` (TOKEN-GATED)
586    ///
587    /// The $DIG this node recommends holding, and its funding position against that figure.
588    ///
589    /// Gated although it is a read, for the same reason `control.wallet.watched` is: the caller
590    /// supplies nothing, so the answer is this node's OWN served set, operator preference and
591    /// balance — an association, not a relayed public fact.
592    ///
593    /// Four obligations, each of which a plausible implementation gets wrong:
594    ///
595    /// - **Answer `unknown` WITH a reason rather than a number the node does not have.** A zero here
596    ///   reads as *no buffer needed* — the money lie in its purest form, because an operator acting
597    ///   on it posts nothing and loses the epoch. An implementation MUST NOT substitute a zero, a
598    ///   previous epoch's buffer presented as this one's, or an error a client renders as "nothing
599    ///   required".
600    /// - **Count the pairs THIS NODE serves.** `pairs_served_by_this_node` is this node's own
601    ///   `(owner, store, root)` set. The census `stores` figure from
602    ///   `control.collateral.requirement` is a network-wide advertisement count and MUST NOT be
603    ///   substituted for it; a node that cannot enumerate its own set answers
604    ///   [`ServedSetUnknown`](results::CollateralBufferUnknownReason::ServedSetUnknown).
605    /// - **State the horizon actually used.** `horizon_epochs` and `escalation_ceiling_micros` MUST
606    ///   describe the headroom this answer contains, not a documented default. Escalation compounds
607    ///   at up to +12.5% per epoch ([`ESCALATION_UP_STEP_DENOM`](params::ESCALATION_UP_STEP_DENOM)), so a
608    ///   buffer quoted against an unstated horizon cannot be checked by anyone.
609    /// - **Decide the funding state here, once.** `funding_state` is the node's verdict, not a hint;
610    ///   an implementation that returned a placeholder and left clients to threshold the numbers
611    ///   themselves recreates the rival derivations this method exists to prevent.
612    async fn collateral_buffer(&self) -> Result<results::CollateralBufferResult, ControlError>;
613
614    /// `control.mirror.bondStates` (TOKEN-GATED)
615    ///
616    /// The per-`(store, root)` state of every mirror bond this node holds, and the $DIG they lock.
617    ///
618    /// An implementation MUST:
619    ///
620    /// - **Keep the eight states apart.** `unfunded` is the only genuine out-of-funds state.
621    ///   `deferred` (no priced requirement), `pending` (submitted, unconfirmed), `withheld`
622    ///   (`Relayed` provenance), `disabled` (the node-wide switch) and `unadvertised` (that switch
623    ///   ON, but nothing publishable to advertise) all mean "no coin yet" and none of them means
624    ///   "send money". Collapsing any of them into `unfunded` is the dig-app#300 defect this method
625    ///   exists to remove.
626    /// - **Answer [`Unadvertised`](results::MirrorBondState::Unadvertised) -- never
627    ///   [`Disabled`](results::MirrorBondState::Disabled) -- when the node's own switch is ON and
628    ///   its advertise-URL list has no publishable entry.** The two are both node-wide and both
629    ///   mean "no coin", but they differ in REMEDY and in whether anything is wrong: `disabled` is
630    ///   the operator's own decision and a client MUST NOT present it as a fault, so serving it
631    ///   here would oblige a conforming client to stay silent about the single reason the node
632    ///   bonds nothing.
633    /// - **Read `bonded` and `reclaiming` amounts FROM THE COIN**, never from this epoch's
634    ///   requirement. A coin created under a previous requirement locks the previous amount, and
635    ///   the current price is not a fact about an existing coin.
636    /// - **Enumerate the SERVED set, not only the desired-bond set.**
637    ///   [`Withheld`](results::MirrorBondState::Withheld) means a capsule this node holds with
638    ///   `Relayed` provenance, which is by construction absent from the `Held` set; a derivation
639    ///   keyed on `Held` alone can never emit it and silently answers "no such row" where the
640    ///   contract promises "withheld on purpose". An implementation that CANNOT see provenance MUST
641    ///   answer
642    ///   [`ProvenanceUnknown`](results::MirrorBondStatesUnknownReason::ProvenanceUnknown) for the
643    ///   whole call and MUST NOT return a `known` page — a page with its withheld rows silently
644    ///   missing claims a completeness the node knows it lacks.
645    /// - **Answer [`Unknown`](results::MirrorBondStatesResult::Unknown) for the WHOLE call** when it
646    ///   cannot enumerate its bonds, cannot read chain, cannot read its own in-flight creates, or
647    ///   cannot determine provenance.
648    ///   There is no per-row unknown and no empty-list fallback: `entries: []` with
649    ///   `complete: true` asserts this node holds no bonds, and a partial list read as a complete
650    ///   one hides exactly the bonds nobody is watching.
651    /// - **Compute `locked_dig_base_units` over the WHOLE set, including reclaiming coins**, and
652    ///   never over the page. A reclaim in flight still locks its money.
653    /// - **Order rows by ascending `(store_id, root)` and keep that order stable across the pages of
654    ///   one walk**, over the LOWERCASE unprefixed hex spelling of both halves, since `after` means
655    ///   *strictly after this key in that order* and uppercase hex sorts elsewhere. Set `complete`
656    ///   explicitly, and set `cursor` to the key of the LAST row actually handed back (`null` for an
657    ///   empty page) — never to a position the node "got to".
658    async fn mirror_bond_states(
659        &self,
660        params: params::MirrorBondStatesParams,
661    ) -> Result<results::MirrorBondStatesResult, ControlError>;
662
663    /// `control.mirror.reconcile` (TOKEN-GATED)
664    ///
665    /// Reconcile this node's mirror coins to its CURRENT advertise URL: reclaim every coin
666    /// advertising a stale URL, then recreate it advertising the URL this node advertises TODAY.
667    /// Shared by the manual "reset mirrors" action and the same primitive the node's DAILY
668    /// detector runs (dig-node#570) — build ONE reconcile primitive and serve both triggers
669    /// through it (dig_ecosystem#3203); a node that diverges into two implementations of a
670    /// money-moving operation with different guards has a live defect, not a style difference.
671    ///
672    /// # The trap this method exists to not fall into
673    ///
674    /// A naive reclaim-then-remint can leave the operator WORSE OFF: reclaim-first funds the
675    /// create behind it, so reclaiming first and only then discovering the new URL cannot be
676    /// established leaves the node with ZERO bonds, having spent money to get there, when it was
677    /// bonded before the call. An implementation MUST:
678    ///
679    /// - **Establish and validate the new URL BEFORE reclaiming anything.** The candidate URL
680    ///   must be CORROBORATED (dig-node#566), never merely discovered. If it cannot be
681    ///   established, refuse as
682    ///   [`NotPublishing`](results::MirrorReconcileRefusal::NotPublishing) and do NOTHING — no
683    ///   reclaim, no create, no partial progress.
684    /// - **Refuse a no-op rather than spend to reach it.** When the candidate URL is identical to
685    ///   what this node's existing coins already advertise, refuse as
686    ///   [`UrlUnchanged`](results::MirrorReconcileRefusal::UrlUnchanged) rather than reclaim and
687    ///   recreate coins that would advertise the same thing.
688    /// - **Size the affordable prefix K BEFORE attempting any reclaim, and reclaim exactly K —
689    ///   never more.** K is the same quantity [`Planned`](results::MirrorReconcileResult::Planned)
690    ///   already priced: the wallet's balance augmented by the K coins' OWN collateral (since
691    ///   reclaiming them is what funds their matching recreate). A wallet that cannot afford even
692    ///   K = 1 refuses as
693    ///   [`InsufficientFunds`](results::MirrorReconcileRefusal::InsufficientFunds) rather than
694    ///   reclaim a coin it cannot afford to recreate. Reclaiming more than K leaves the node
695    ///   holding uncollateralised capsules it has stopped advertising — the half-run failure this
696    ///   whole method exists to prevent; "no worse off" means the bond COUNT is unchanged, not
697    ///   merely that no fee was wasted.
698    /// - **Report [`Submitted`](results::MirrorReconcileResult::Submitted), never `completed` or
699    ///   `partial` — those outcomes do not exist.** A reclaim and a create are separate spend
700    ///   bundles, and a create's funding is a scan of CONFIRMED coins: the reclaimed collateral is
701    ///   chain-visible only in a LATER pass. An implementation MUST NOT wait for confirmation
702    ///   inside this call, and MUST NOT report a `created` count it cannot back — only how many
703    ///   reclaims the mempool accepted or rejected, and how many recreates are therefore owed to
704    ///   the ordinary create pass. `left_unchanged > 0` is a NORMAL outcome, not an error.
705    /// - **`Refused` MUST mean nothing was spent, unconditionally.** A caller distinguishes
706    ///   `refused` from `submitted` precisely so it never has to guess whether a "no" cost money;
707    ///   an implementation that spends anything under a `Refused` outcome breaks that guarantee.
708    /// - **`dry_run: true` prices the plan and spends nothing** —
709    ///   [`Planned`](results::MirrorReconcileResult::Planned) reports the same capsule/collateral/
710    ///   fee figures a real run would incur, computed against the SAME validation (corroborated
711    ///   URL, changed URL, affordability) a real run performs, so a dry run that would in fact
712    ///   refuse MUST report [`Refused`](results::MirrorReconcileResult::Refused), never a
713    ///   [`Planned`](results::MirrorReconcileResult::Planned) plan for a call that cannot execute.
714    async fn mirror_reconcile(
715        &self,
716        params: params::MirrorReconcileParams,
717    ) -> Result<results::MirrorReconcileResult, ControlError>;
718
719    /// `control.profile.putBody` (TOKEN-GATED)
720    ///
721    /// An implementation MUST independently resolve the profile's root ON CHAIN, recompute the root
722    /// of the supplied body, and REFUSE the call unless the two agree and that root is confirmed.
723    /// The caller's `root` is a claim to be checked — never a fact to be trusted — and dig-app gets
724    /// no exemption: it holds the key and signs the root (§908), but the bytes reach the node the
725    /// same way a peer's bytes do, and the same check binds both. An implementation that stores
726    /// what it is handed makes this node serve arbitrary bytes under someone else's profile id.
727    ///
728    /// A body whose DECODED length exceeds [`MAX_BODY_BYTES`](params::MAX_BODY_BYTES) (4 MiB) MUST
729    /// be refused as `INVALID_PARAMS`, before it is persisted: a body larger than that cannot be
730    /// served to a peer inside dig-gossip's frame ceiling, so accepting it would store something
731    /// permanently unsyncable.
732    ///
733    /// Returning `Ok` therefore asserts BOTH that the root was confirmed on chain and that the body
734    /// is persisted and servable. A refusal is an error, never an `Ok` carrying `stored: false`.
735    async fn profile_put_body(
736        &self,
737        params: params::ProfilePutBodyParams,
738    ) -> Result<results::ProfilePutBodyResult, ControlError>;
739    /// `control.profile.getBody` (READ-only, TOKEN-GATED)
740    ///
741    /// `Ok(body_b64: None)` MUST mean "this node was consulted and holds no body at that root". A
742    /// read that FAILED MUST return a catalogued error instead — a caller that reads a failure as
743    /// absence renders an existing profile as an empty one.
744    ///
745    /// The returned `root` MUST be the root the caller asked for; a node MUST NOT substitute a
746    /// newer body it happens to hold.
747    async fn profile_get_body(
748        &self,
749        params: params::ProfileGetBodyParams,
750    ) -> Result<results::ProfileGetBodyResult, ControlError>;
751    /// `pairing.request` (OPEN)
752    async fn pairing_request(
753        &self,
754        params: params::RequestParams,
755    ) -> Result<results::PairingRequestResult, ControlError>;
756    /// `pairing.poll` (OPEN)
757    async fn pairing_poll(
758        &self,
759        params: params::PollParams,
760    ) -> Result<results::PairingPollResult, ControlError>;
761
762    /// Route a raw JSON-RPC request to the right typed method and build the response envelope.
763    ///
764    /// Deserializes the params for methods that take them, calls the handler, and serializes the
765    /// typed result. An unknown method → `METHOD_NOT_FOUND`; malformed params → `INVALID_PARAMS`.
766    /// This is the single seam a server dispatches through — the KATs exercise it end-to-end.
767    async fn dispatch(&self, request: JsonRpcRequest) -> JsonRpcResponse {
768        let id = request.id.clone();
769        let Some(method) = ControlMethod::from_name(&request.method) else {
770            return JsonRpcResponse::error(
771                id,
772                ControlError::of(
773                    ControlErrorCode::MethodNotFound,
774                    format!("unknown control method: {}", request.method),
775                ),
776            );
777        };
778        match self.dispatch_method(method, request.params).await {
779            Ok(result) => JsonRpcResponse::success(id, result),
780            Err(err) => JsonRpcResponse::error(id, err),
781        }
782    }
783
784    /// Route to the typed method by [`ControlMethod`], returning the result as a [`Value`]. Split
785    /// from [`dispatch`](ControlHandler::dispatch) so the envelope wrapping stays in one place.
786    #[doc(hidden)]
787    async fn dispatch_method(
788        &self,
789        method: ControlMethod,
790        params: Value,
791    ) -> Result<Value, ControlError> {
792        /// Deserialize a method's params, mapping a shape error to `INVALID_PARAMS`.
793        fn decode<T: DeserializeOwned>(params: Value) -> Result<T, ControlError> {
794            serde_json::from_value(params)
795                .map_err(|e| ControlError::of(ControlErrorCode::InvalidParams, e.to_string()))
796        }
797        /// Serialize a typed result to a `Value` (infallible for our derive-Serialize results).
798        fn encode<T: Serialize>(value: T) -> Result<Value, ControlError> {
799            serde_json::to_value(value)
800                .map_err(|e| ControlError::of(ControlErrorCode::ControlError, e.to_string()))
801        }
802        match method {
803            ControlMethod::Status => encode(self.status().await?),
804            ControlMethod::ConfigGet => encode(self.config_get().await?),
805            ControlMethod::ConfigSetUpstream => {
806                encode(self.config_set_upstream(decode(params)?).await?)
807            }
808            // `SetMirrorAdvertiseUrlsParams` derives `Deserialize`, so decoding enforces NOTHING
809            // beyond the field's type. `validated()` here is the SOLE enforcement of the
810            // reject-empty-list rule AND the well-formed-URL check -- dropping it admits an
811            // ambiguous or malformed override straight into the node's mirror-coin memo.
812            ControlMethod::ConfigSetMirrorAdvertiseUrls => {
813                let params: params::SetMirrorAdvertiseUrlsParams = decode(params)?;
814                encode(
815                    self.config_set_mirror_advertise_urls(params.validated()?)
816                        .await?,
817                )
818            }
819            ControlMethod::LogSetLevel => encode(self.log_set_level(decode(params)?).await?),
820            ControlMethod::CacheGet => encode(self.cache_get().await?),
821            ControlMethod::CacheSetCap => encode(self.cache_set_cap(decode(params)?).await?),
822            ControlMethod::CacheClear => encode(self.cache_clear().await?),
823            ControlMethod::HostedStoresList => encode(self.hosted_stores_list().await?),
824            ControlMethod::HostedStoresPin => {
825                encode(self.hosted_stores_pin(decode(params)?).await?)
826            }
827            ControlMethod::HostedStoresUnpin => {
828                encode(self.hosted_stores_unpin(decode(params)?).await?)
829            }
830            ControlMethod::HostedStoresStatus => {
831                encode(self.hosted_stores_status(decode(params)?).await?)
832            }
833            ControlMethod::CapsuleFetch => encode(self.capsule_fetch(decode(params)?).await?),
834            ControlMethod::SyncStatus => encode(self.sync_status().await?),
835            ControlMethod::SyncTrigger => encode(self.sync_trigger(decode(params)?).await?),
836            ControlMethod::UpdaterStatus => self.updater_status().await,
837            ControlMethod::UpdaterSetChannel => self.updater_set_channel(decode(params)?).await,
838            ControlMethod::UpdaterPause => self.updater_pause(decode(params)?).await,
839            ControlMethod::UpdaterResume => self.updater_resume().await,
840            ControlMethod::UpdaterCheckNow => self.updater_check_now().await,
841            ControlMethod::PairingList => self.pairing_list().await,
842            ControlMethod::PairingApprove => encode(self.pairing_approve(decode(params)?).await?),
843            ControlMethod::PairingRevoke => encode(self.pairing_revoke(decode(params)?).await?),
844            ControlMethod::PeerStatus => self.peer_status().await,
845            ControlMethod::PeerCounts => encode(self.peer_counts().await?),
846            ControlMethod::PeersConnect => encode(self.peers_connect(decode(params)?).await?),
847            ControlMethod::PeersDisconnect => encode(self.peers_disconnect(decode(params)?).await?),
848            ControlMethod::ChiaPeersAdd => encode(self.chia_peers_add(decode(params)?).await?),
849            ControlMethod::ChiaPeersList => encode(self.chia_peers_list().await?),
850            ControlMethod::ChiaPeersRemove => {
851                encode(self.chia_peers_remove(decode(params)?).await?)
852            }
853            ControlMethod::Subscribe => encode(self.subscribe(decode(params)?).await?),
854            ControlMethod::Unsubscribe => encode(self.unsubscribe(decode(params)?).await?),
855            ControlMethod::ListSubscriptions => encode(self.list_subscriptions().await?),
856            ControlMethod::WalletBalance => encode(self.wallet_balance(decode(params)?).await?),
857            ControlMethod::WalletCoins => encode(self.wallet_coins(decode(params)?).await?),
858            // Re-validated here idempotently; deserialization already enforced the same rule.
859            ControlMethod::WalletCoinById => {
860                let params: params::WalletCoinByIdParams = decode(params)?;
861                encode(self.wallet_coin_by_id(params.validated()?).await?)
862            }
863            // Re-validated here idempotently; deserialization already enforced the same rule.
864            ControlMethod::WalletCoinSpend => {
865                let params: params::WalletCoinSpendParams = decode(params)?;
866                encode(self.wallet_coin_spend(params.validated()?).await?)
867            }
868            ControlMethod::WalletCoinsByParent => {
869                let params: params::WalletCoinsByParentParams = decode(params)?;
870                encode(self.wallet_coins_by_parent(params.validated()?).await?)
871            }
872            ControlMethod::WalletArrivals => encode(self.wallet_arrivals(decode(params)?).await?),
873            ControlMethod::WalletPeak => encode(self.wallet_peak().await?),
874            ControlMethod::WalletOperatorAddress => encode(self.wallet_operator_address().await?),
875            ControlMethod::WalletSyncStatus => encode(self.wallet_sync_status().await?),
876            ControlMethod::WalletBroadcast => encode(self.wallet_broadcast(decode(params)?).await?),
877            // Re-validated here idempotently; deserialization already enforced the same rule.
878            ControlMethod::WalletWatch => {
879                let params: params::WalletWatchParams = decode(params)?;
880                encode(self.wallet_watch(params.validated()?).await?)
881            }
882            // Re-validated here idempotently; deserialization already enforced the same rule.
883            ControlMethod::WalletUnwatch => {
884                let params: params::WalletUnwatchParams = decode(params)?;
885                encode(self.wallet_unwatch(params.validated()?).await?)
886            }
887            ControlMethod::WalletWatched => encode(self.wallet_watched().await?),
888            // Re-validated here idempotently; deserialization already enforced the same rule.
889            ControlMethod::WalletResetCoinDb => {
890                let params: params::WalletResetCoinDbParams = decode(params)?;
891                encode(self.wallet_reset_coin_db(params).await?)
892            }
893            ControlMethod::WalletReservationsHeld => encode(self.wallet_reservations_held().await?),
894            ControlMethod::WalletReservationsReserve => {
895                encode(self.wallet_reservations_reserve(decode(params)?).await?)
896            }
897            ControlMethod::WalletReservationsRelease => {
898                encode(self.wallet_reservations_release(decode(params)?).await?)
899            }
900            ControlMethod::CollateralRequirement => encode(self.collateral_requirement().await?),
901            ControlMethod::CollateralBuffer => encode(self.collateral_buffer().await?),
902            // Re-validated here idempotently; `MirrorBondStatesParams`'s own `Deserialize` already
903            // enforced the page bound. A limit above the cap is REFUSED, never clamped, or the
904            // cursor handed back names a position the caller never asked about.
905            ControlMethod::MirrorBondStates => {
906                let params: params::MirrorBondStatesParams = decode(params)?;
907                encode(self.mirror_bond_states(params.validated()?).await?)
908            }
909            ControlMethod::MirrorReconcile => encode(self.mirror_reconcile(decode(params)?).await?),
910            ControlMethod::CollateralMarginGet => encode(self.collateral_margin_get().await?),
911            // `CollateralMarginSetParams` derives `Deserialize`, so decoding enforces NOTHING beyond
912            // the field's type. `validated()` here is the SOLE enforcement of `MAX_SAFETY_MARGIN_BP`
913            // on this money-path mutation — dropping it admits an unbounded margin, and the margin
914            // arithmetic saturates rather than failing, so the result is a silently enormous posting.
915            ControlMethod::CollateralMarginSet => {
916                let params: params::CollateralMarginSetParams = decode(params)?;
917                encode(self.collateral_margin_set(params.validated()?).await?)
918            }
919            // Re-validated here idempotently; deserialization already enforced the same rule.
920            ControlMethod::SpendsList => {
921                let params: params::SpendsListParams = decode(params)?;
922                encode(self.spends_list(params.validated()?).await?)
923            }
924            ControlMethod::ProfilePutBody => encode(self.profile_put_body(decode(params)?).await?),
925            ControlMethod::ProfileGetBody => encode(self.profile_get_body(decode(params)?).await?),
926            ControlMethod::PairingRequest => encode(self.pairing_request(decode(params)?).await?),
927            ControlMethod::PairingPoll => encode(self.pairing_poll(decode(params)?).await?),
928        }
929    }
930}