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}