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.log.setLevel`
109 async fn log_set_level(
110 &self,
111 params: params::SetLevelParams,
112 ) -> Result<results::SetLevelResult, ControlError>;
113 /// `control.cache.get`
114 async fn cache_get(&self) -> Result<results::CacheView, ControlError>;
115 /// `control.cache.setCap`
116 async fn cache_set_cap(
117 &self,
118 params: params::SetCapParams,
119 ) -> Result<results::SetCapResult, ControlError>;
120 /// `control.cache.clear`
121 async fn cache_clear(&self) -> Result<results::CacheClearResult, ControlError>;
122 /// `control.hostedStores.list`
123 async fn hosted_stores_list(&self) -> Result<results::HostedStoresListResult, ControlError>;
124 /// `control.hostedStores.pin`
125 async fn hosted_stores_pin(
126 &self,
127 params: params::PinParams,
128 ) -> Result<results::PinResult, ControlError>;
129 /// `control.hostedStores.unpin`
130 async fn hosted_stores_unpin(
131 &self,
132 params: params::UnpinParams,
133 ) -> Result<results::UnpinResult, ControlError>;
134 /// `control.hostedStores.status`
135 async fn hosted_stores_status(
136 &self,
137 params: params::HostedStoreStatusParams,
138 ) -> Result<results::HostedStoreStatusResult, ControlError>;
139 /// `control.capsule.fetch`
140 async fn capsule_fetch(
141 &self,
142 params: params::CapsuleFetchParams,
143 ) -> Result<results::CapsuleFetchResult, ControlError>;
144 /// `control.sync.status`
145 async fn sync_status(&self) -> Result<results::SyncStatusResult, ControlError>;
146 /// `control.sync.trigger`
147 async fn sync_trigger(
148 &self,
149 params: params::SyncTriggerParams,
150 ) -> Result<results::SyncTriggerResult, ControlError>;
151 /// `control.updater.status`
152 async fn updater_status(&self) -> Result<Value, ControlError>;
153 /// `control.updater.setChannel`
154 async fn updater_set_channel(
155 &self,
156 params: params::SetChannelParams,
157 ) -> Result<Value, ControlError>;
158 /// `control.updater.pause`
159 async fn updater_pause(&self, params: params::PauseParams) -> Result<Value, ControlError>;
160 /// `control.updater.resume`
161 async fn updater_resume(&self) -> Result<Value, ControlError>;
162 /// `control.updater.checkNow`
163 async fn updater_check_now(&self) -> Result<Value, ControlError>;
164 /// `control.pairing.list`
165 async fn pairing_list(&self) -> Result<Value, ControlError>;
166 /// `control.pairing.approve`
167 async fn pairing_approve(
168 &self,
169 params: params::ApproveParams,
170 ) -> Result<results::PairingApproveResult, ControlError>;
171 /// `control.pairing.revoke`
172 async fn pairing_revoke(
173 &self,
174 params: params::RevokeParams,
175 ) -> Result<results::PairingRevokeResult, ControlError>;
176 /// `control.peerStatus`
177 async fn peer_status(&self) -> Result<Value, ControlError>;
178 /// `control.peers.connect`
179 async fn peers_connect(
180 &self,
181 params: params::PeersConnectParams,
182 ) -> Result<results::PeersConnectResult, ControlError>;
183 /// `control.peers.disconnect`
184 async fn peers_disconnect(
185 &self,
186 params: params::PeersDisconnectParams,
187 ) -> Result<results::PeersDisconnectResult, ControlError>;
188 /// `control.chiaPeers.add` — start trusting a Chia full node the operator RUNS.
189 ///
190 /// An implementation MUST write through to the ONE peer store its wallet replica reads; a
191 /// second peer list is a drift bug waiting to happen.
192 ///
193 /// Three obligations the shell cannot infer from the types:
194 ///
195 /// - it requires the MASTER token ([`ControlMethod::requires_master_token`]). The entry it
196 /// writes carries authority that outlives the calling token, and `control.pairing.revoke`
197 /// does not remove it;
198 /// - `params.ip` is canonicalised with [`crate::params::canonical_peer_ip`] and STORED in that
199 /// form, so `remove` and `list` can match what `add` wrote;
200 /// - `corroboration_bypassed` reports the RESULTING trust state and `notice` carries the
201 /// node's own warning verbatim. Reporting a bypass that did not happen tells an operator
202 /// they configured a node they did not.
203 async fn chia_peers_add(
204 &self,
205 params: params::ChiaPeersAddParams,
206 ) -> Result<results::ChiaPeersAddResult, ControlError>;
207 /// `control.chiaPeers.list` — the tracked Chia full-node peers, banned ones included.
208 ///
209 /// Ordinary token tier: a read that confers nothing, and a paired client that cannot show the
210 /// operator this list cannot show them the trust state they are subject to.
211 async fn chia_peers_list(&self) -> Result<results::ChiaPeersListResult, ControlError>;
212 /// `control.chiaPeers.remove` — stop trusting a Chia full node.
213 ///
214 /// MASTER token, like `add`. This is the ONLY un-trust remedy, so an implementation MUST
215 /// return [`results::ChiaPeerRemovalOutcome::NoSuchPeer`] when nothing matched rather than
216 /// reporting a removal it did not perform.
217 async fn chia_peers_remove(
218 &self,
219 params: params::ChiaPeersRemoveParams,
220 ) -> Result<results::ChiaPeersRemoveResult, ControlError>;
221 /// `control.subscribe`
222 ///
223 /// `params.kind` is OPTIONAL on the wire and absent means
224 /// [`SubscriptionKind::Capsule`](params::SubscriptionKind::Capsule). An implementation MUST
225 /// persist untagged rows it already holds as capsules rather than discarding them: a node that
226 /// refuses to read its own pre-existing `subscriptions.json` starts with an empty one, and the
227 /// upgrade silently unsubscribes the user from everything.
228 async fn subscribe(
229 &self,
230 params: params::SubscribeParams,
231 ) -> Result<results::SubscribeResult, ControlError>;
232 /// `control.unsubscribe`
233 async fn unsubscribe(
234 &self,
235 params: params::UnsubscribeParams,
236 ) -> Result<results::UnsubscribeResult, ControlError>;
237 /// `control.listSubscriptions`
238 async fn list_subscriptions(&self) -> Result<results::ListSubscriptionsResult, ControlError>;
239 /// `control.wallet.balance` (READ-only)
240 async fn wallet_balance(
241 &self,
242 params: params::WalletBalanceParams,
243 ) -> Result<results::WalletBalanceResult, ControlError>;
244 /// `control.wallet.coins` (READ-only, OPEN)
245 ///
246 /// An empty `coins` list MUST mean "a chain was consulted and this address holds nothing".
247 /// A read that could not consult a chain MUST return the matching catalogued error instead.
248 async fn wallet_coins(
249 &self,
250 params: params::WalletCoinsParams,
251 ) -> Result<results::WalletCoinsResult, ControlError>;
252 /// `control.wallet.coinById` (READ-only, OPEN)
253 ///
254 /// `Ok(coin: None)` MUST mean "a chain was consulted and holds no such coin". A read that could
255 /// not consult a chain MUST return the matching catalogued error instead — a caller that cannot
256 /// tell those apart reports a spent mint as pending forever.
257 ///
258 /// The params are validated at DESERIALIZATION (lowercase 64-hex, `0x` stripped), so any path
259 /// that decodes `WalletCoinByIdParams` refuses malformed ids as `INVALID_PARAMS` before this
260 /// method is called.
261 async fn wallet_coin_by_id(
262 &self,
263 params: params::WalletCoinByIdParams,
264 ) -> Result<results::WalletCoinByIdResult, ControlError>;
265 /// `control.wallet.coinSpend` (READ-only, OPEN)
266 ///
267 /// `Ok(spend: None)` MUST mean "a chain was consulted and holds no spend of that coin" — the
268 /// coin is unspent, or unknown. A read that could not consult a chain MUST return the matching
269 /// catalogued error instead: a caller following a singleton forward reads "no spend" as *this is
270 /// the tip* and stops walking, so a failure disguised as absence produces a spend built against
271 /// a superseded singleton.
272 ///
273 /// A returned spend's `puzzle_reveal` MUST tree-hash to the spent coin's own `puzzle_hash`, and
274 /// the implementation MUST fail closed — an error, never an unverified reveal — when it does not
275 /// or when the reveal will not parse. The reveal comes from a peer, and a peer can lie.
276 ///
277 /// The params are validated at DESERIALIZATION (lowercase 64-hex, `0x` stripped), so any path
278 /// that decodes `WalletCoinSpendParams` refuses malformed ids as `INVALID_PARAMS` before this
279 /// method is called.
280 async fn wallet_coin_spend(
281 &self,
282 params: params::WalletCoinSpendParams,
283 ) -> Result<results::WalletCoinSpendResult, ControlError>;
284 /// `control.wallet.coinsByParent` (READ-only, OPEN)
285 ///
286 /// Returns the parent's DIRECT children and nothing further. An implementation MUST NOT recurse:
287 /// a transitive walk over caller-supplied input is unbounded work the caller cannot bound, and a
288 /// partial walk returned as a complete one is a lineage with a silent hole in it.
289 ///
290 /// An empty list MUST mean "a chain was consulted and this parent created no known children".
291 /// A read that could not consult a chain MUST return the matching catalogued error instead.
292 ///
293 /// The answer is ONE PAGE. An implementation MUST return at most
294 /// `params.effective_limit()` records, in ASCENDING `coin_id` order, starting strictly after
295 /// `params.after_coin_id` when one is given; it MUST set `complete` to whether the page carries
296 /// the last child; and it MUST set `cursor` to the last record it actually returned (`None` for
297 /// an empty page). It MUST NOT report `complete: true` on a page it truncated — a caller reads
298 /// that as the end of a lineage branch. The params are validated at DESERIALIZATION, so an
299 /// out-of-range page size is refused as `INVALID_PARAMS` before this method is called.
300 ///
301 /// Every record MUST report `asset: None`: naming a coin by its parent classifies nothing, and
302 /// asserting a class this read never verified is a claim a caller would then spend against.
303 async fn wallet_coins_by_parent(
304 &self,
305 params: params::WalletCoinsByParentParams,
306 ) -> Result<results::WalletCoinsByParentResult, ControlError>;
307 /// `control.wallet.arrivals` (READ-only, TOKEN-GATED)
308 ///
309 /// Gated although it is a read: the caller supplies only a cursor, so the answer names this
310 /// node's OWN watched puzzle hashes and the receive history behind them.
311 ///
312 /// Every returned row MUST be a CONFIRMED arrival that the node itself judged: above its arrival
313 /// baseline, not previously reported, and not the wallet's own change. An implementation MUST NOT
314 /// emit a mempool sighting here, and MUST answer an empty page rather than an error when it has
315 /// no baseline — "nothing arrived" is the honest answer from a wallet that cannot yet tell
316 /// history from news.
317 ///
318 /// `cursor` MUST be the position of the last row actually returned (or the caller's `after_seq`
319 /// for an empty page) and MUST NOT be `latest`; see
320 /// [`WalletArrivalsResult::latest`](results::WalletArrivalsResult::latest).
321 async fn wallet_arrivals(
322 &self,
323 params: params::WalletArrivalsParams,
324 ) -> Result<results::WalletArrivalsResult, ControlError>;
325 /// `control.wallet.peak` (READ-only, OPEN)
326 async fn wallet_peak(&self) -> Result<results::WalletPeakResult, ControlError>;
327 /// `control.peerCounts` (READ-only, OPEN)
328 ///
329 /// `dig_peer_count` MUST be dig-node-core's `connected_peers` — the same figure
330 /// `control.peerStatus` reports — and `chia_peer_count` MUST be the SAME observation
331 /// `wallet_sync_status` reports, served from ONE source so the two answers agree. `None` means
332 /// the count cannot be observed; a network that is not running is UNKNOWN, never `Some(0)`.
333 async fn peer_counts(&self) -> Result<results::PeerCountsResult, ControlError>;
334 /// `control.wallet.syncStatus` (READ-only, OPEN)
335 ///
336 /// `WalletSyncPhase::Synced` MUST require BOTH that the initial catch-up completed and that at
337 /// least one Chia peer connection is live now, which makes it strictly stronger than
338 /// `WalletPeakResult::synced`. `peak_height` MUST be the node's OWN replica's height or `None`,
339 /// never an oracle's, and `chia_peer_count` counts CHIA full-node peers -- never DIG peers.
340 async fn wallet_sync_status(&self) -> Result<results::WalletSyncStatusResult, ControlError>;
341 /// `control.wallet.broadcast` (TOKEN-GATED)
342 ///
343 /// Pushes an ALREADY-SIGNED bundle: the implementation never signs, and never receives anything
344 /// it could sign with (§908). A mempool refusal is `Ok` with `accepted: false`; failing to
345 /// reach a mempool is `Err`.
346 async fn wallet_broadcast(
347 &self,
348 params: params::WalletBroadcastParams,
349 ) -> Result<results::WalletBroadcastResult, ControlError>;
350 /// `control.wallet.watch` (TOKEN-GATED)
351 ///
352 /// Enrols PUBLIC keys for the node's chain replica to follow. The implementation MUST derive the
353 /// addresses itself, from the SAME derivation it applies to the keys already in its own custody
354 /// — a second derivation is a second opinion about which addresses a key covers, and the client
355 /// would read the difference as missing money.
356 ///
357 /// It MUST be IDEMPOTENT: keys already enrolled are reported as `added: 0` and the call
358 /// succeeds. It MUST persist the enrolment across restarts — a set that evaporates on restart
359 /// makes a node that syncs today and reports a zero balance tomorrow.
360 ///
361 /// The params are validated at DESERIALIZATION (lowercase 96-hex, `0x` stripped), so any path
362 /// that decodes `WalletWatchParams` refuses a malformed key as `INVALID_PARAMS` — for the WHOLE
363 /// request — before this method is called.
364 async fn wallet_watch(
365 &self,
366 params: params::WalletWatchParams,
367 ) -> Result<results::WalletWatchResult, ControlError>;
368 /// `control.wallet.unwatch` (TOKEN-GATED)
369 ///
370 /// Deregisters keys, and the following MUST actually stop: the addresses leave the replica's
371 /// watched set, not merely the list this node reports. A registry that keeps syncing what it
372 /// says it forgot is the failure this method exists to make impossible.
373 ///
374 /// Deregistering a key that was never enrolled is a success reporting `removed: 0`.
375 async fn wallet_unwatch(
376 &self,
377 params: params::WalletUnwatchParams,
378 ) -> Result<results::WalletUnwatchResult, ControlError>;
379 /// `control.wallet.watched` (READ-only, TOKEN-GATED)
380 ///
381 /// Gated although it is a read: the caller supplies nothing, so the answer names this node's OWN
382 /// enrolled keys.
383 ///
384 /// MUST return exactly the keys enrolment added, in the wire form they were accepted in, so a
385 /// client can compare its own set against the node's by value. MUST NOT include the node's own
386 /// custody keys: this method reports what was ENROLLED through it, and a caller reconciling
387 /// against a superset would unwatch keys it never watched.
388 async fn wallet_watched(&self) -> Result<results::WalletWatchedResult, ControlError>;
389 /// `control.wallet.reservations.held` (READ-only, TOKEN-GATED)
390 ///
391 /// Gated although it is a read: the caller supplies nothing, so the answer names this node's OWN
392 /// in-flight commitments.
393 ///
394 /// An implementation MUST read its own clock rather than accept one, MUST omit every reservation
395 /// that has already lapsed at that instant, and MUST report the clock it used as `as_of_unix`.
396 ///
397 /// An implementation that cannot read its reservation set MUST return
398 /// [`ControlErrorCode::WalletReservationsUnavailable`](crate::error::ControlErrorCode::WalletReservationsUnavailable)
399 /// and MUST NOT return an empty list. "Nothing is held" permits a caller to spend; "I cannot
400 /// tell" must stop it, and the two are indistinguishable once collapsed.
401 async fn wallet_reservations_held(
402 &self,
403 ) -> Result<results::WalletReservationsHeldResult, ControlError>;
404 /// `control.wallet.reservations.reserve` (TOKEN-GATED)
405 ///
406 /// Acquisition MUST be atomic across concurrent callers: take EVERY coin in `coin_ids` or take
407 /// none. On a clash an implementation MUST have written nothing and MUST answer
408 /// [`ControlErrorCode::WalletCoinsReserved`](crate::error::ControlErrorCode::WalletCoinsReserved)
409 /// — never a shortfall code, because the user has the money and is waiting on a settlement, and
410 /// never a partial success, because a caller believing it holds inputs it does not is the state
411 /// all-or-none exists to make unreachable.
412 ///
413 /// An empty `coin_ids` MUST succeed, returning a handle that releases nothing.
414 ///
415 /// The hold MUST expire on its own. An implementation clamps the requested `ttl_secs` to its own
416 /// maximum, applies its default when none is given, and MUST report the lifetime it actually
417 /// applied — a caller told nothing would release on a schedule the node does not keep.
418 ///
419 /// An implementation MUST NOT require, accept or store key material here (§908). A reservation
420 /// is bookkeeping: it narrows what a selector will choose and authorizes nothing.
421 async fn wallet_reservations_reserve(
422 &self,
423 params: params::WalletReservationsReserveParams,
424 ) -> Result<results::WalletReservationsReserveResult, ControlError>;
425 /// `control.wallet.reservations.release` (TOKEN-GATED)
426 ///
427 /// Releasing a handle that names no live reservation — lapsed, or already released — MUST be a
428 /// SUCCESS reporting `released: false`, never an error. A caller releasing on confirmation
429 /// cannot know whether the TTL got there first, and an error there teaches callers to discard
430 /// the result, which is how the release path stops being called at all.
431 ///
432 /// An implementation MUST free every coin the handle holds, or none of them, for the same reason
433 /// acquisition is all-or-none: a half-freed reservation leaves coins held by a handle the caller
434 /// has thrown away, and only the TTL would ever recover them.
435 async fn wallet_reservations_release(
436 &self,
437 params: params::WalletReservationsReleaseParams,
438 ) -> Result<results::WalletReservationsReleaseResult, ControlError>;
439 /// `control.spends.list` (READ-only, TOKEN-GATED)
440 ///
441 /// One page of the automated-spend audit record — the spends this node made WITHOUT
442 /// per-transaction approval. Gated although it is a read: the caller names no identifier, so the
443 /// answer is this node's OWN spending history.
444 ///
445 /// An implementation MUST NOT let this call initiate, sign, retry, cancel or amend a spend, and
446 /// MUST NOT expose any control method that edits or deletes an entry. The record replaces
447 /// authorization with accountability, and an editable record accounts for nothing.
448 ///
449 /// Four obligations, each of which a plausible implementation gets wrong:
450 ///
451 /// - **Report the failure STAGE, never a bare "failed."** Only
452 /// [`SpendFailureStage::Signing`](results::SpendFailureStage::Signing) means the money
453 /// definitely did not move; a broadcast or confirmation failure is an unknown outcome. An
454 /// implementation that flattens the stage makes every client structurally unable to tell a
455 /// person the truth about their money.
456 /// - **Keep [`Unresolved`](results::SpendOutcome::Unresolved) distinct from `Failed`.** It means
457 /// the node signed and does not know how it ended.
458 /// - **State completeness explicitly.** `complete` MUST be `false` whenever a matching row was
459 /// withheld, and `cursor` MUST be the id of the last row actually returned.
460 /// - **Report unreadable entries.** `unreadable_lines` MUST count entries the node could not
461 /// parse; a trail that lost rows must never read as a tidy shorter one. A record that could not
462 /// be read AT ALL is
463 /// [`SpendAuditUnreadable`](crate::error::ControlErrorCode::SpendAuditUnreadable), never an
464 /// empty page — while a record that was never written IS an empty page, because a node that has
465 /// never spent automatically is the ordinary case.
466 async fn spends_list(
467 &self,
468 params: params::SpendsListParams,
469 ) -> Result<results::SpendsListResult, ControlError>;
470 /// `control.collateral.requirement` (TOKEN-GATED)
471 ///
472 /// This epoch's derived per-store collateral requirement, with the census inputs behind it.
473 ///
474 /// Gated although the figure itself is derivable from chain by anyone: the UNKNOWN branch names
475 /// this node's own census position, and the caller supplies no identifier, so the answer is a
476 /// fact about this node rather than a relayed public one.
477 ///
478 /// Three obligations, each of which a plausible implementation gets wrong:
479 ///
480 /// - **Answer `unknown` WITH a reason rather than a number the node does not have.** A node that
481 /// has not censused the epoch, or that sits inside `CENSUS_FINALITY_DEPTH_BLOCKS` of the tip,
482 /// MUST return [`Unknown`](results::CollateralRequirementResult::Unknown). It MUST NOT return
483 /// a zero, a stale epoch's figure presented as this epoch's, or an error that a client would
484 /// render as "no collateral required" — under-posting costs the operator that epoch's rewards.
485 /// - **Report the protocol version that COMPUTED the epoch**, not the newest version this build
486 /// implements. The two differ exactly when a node has upgraded mid-schedule, which is the one
487 /// case where a client needs to know the difference.
488 /// - **Never derive the figure from the local safety margin.** The margin MUST NOT reach any
489 /// value another node derives; `required_per_store_dig_base_units` is the pre-margin
490 /// requirement, and a node that returned the margined amount here would make its own
491 /// preference look like the network's price.
492 async fn collateral_requirement(
493 &self,
494 ) -> Result<results::CollateralRequirementResult, ControlError>;
495
496 /// `control.collateral.margin.get` (TOKEN-GATED)
497 ///
498 /// The node's local safety margin in basis points.
499 ///
500 /// A node whose stored configuration predates the field MUST answer
501 /// [`DEFAULT_SAFETY_MARGIN_BP`](params::DEFAULT_SAFETY_MARGIN_BP), never `0`: a zero margin is a
502 /// deliberate choice to post the requirement exactly, and reporting it for a config that never
503 /// expressed one tells the operator they opted out of a cushion they never declined.
504 async fn collateral_margin_get(&self) -> Result<results::CollateralMarginResult, ControlError>;
505
506 /// `control.collateral.margin.set` (TOKEN-GATED)
507 ///
508 /// Persist the node's local safety margin and return the margin now in force.
509 ///
510 /// The node is the authoritative home for this setting — the flywheel is headless, so a machine
511 /// with no GUI must be able to set it — and dig-app is a remote control for the same value.
512 ///
513 /// Two obligations:
514 ///
515 /// - **Persist it**, so it survives a restart. A margin that lapses to the default on reboot
516 /// silently changes what the node posts.
517 /// - **Return what was actually stored.** The returned `margin_bp` MUST equal the accepted
518 /// request's, because a value above [`MAX_SAFETY_MARGIN_BP`](params::MAX_SAFETY_MARGIN_BP) is
519 /// REFUSED rather than clamped. An implementation that clamped and returned the clamped value
520 /// would leave the caller's stored intent and the node's behaviour disagreeing on the money
521 /// path.
522 async fn collateral_margin_set(
523 &self,
524 params: params::CollateralMarginSetParams,
525 ) -> Result<results::CollateralMarginResult, ControlError>;
526
527 /// `control.collateral.buffer` (TOKEN-GATED)
528 ///
529 /// The $DIG this node recommends holding, and its funding position against that figure.
530 ///
531 /// Gated although it is a read, for the same reason `control.wallet.watched` is: the caller
532 /// supplies nothing, so the answer is this node's OWN served set, operator preference and
533 /// balance — an association, not a relayed public fact.
534 ///
535 /// Four obligations, each of which a plausible implementation gets wrong:
536 ///
537 /// - **Answer `unknown` WITH a reason rather than a number the node does not have.** A zero here
538 /// reads as *no buffer needed* — the money lie in its purest form, because an operator acting
539 /// on it posts nothing and loses the epoch. An implementation MUST NOT substitute a zero, a
540 /// previous epoch's buffer presented as this one's, or an error a client renders as "nothing
541 /// required".
542 /// - **Count the pairs THIS NODE serves.** `pairs_served_by_this_node` is this node's own
543 /// `(owner, store, root)` set. The census `stores` figure from
544 /// `control.collateral.requirement` is a network-wide advertisement count and MUST NOT be
545 /// substituted for it; a node that cannot enumerate its own set answers
546 /// [`ServedSetUnknown`](results::CollateralBufferUnknownReason::ServedSetUnknown).
547 /// - **State the horizon actually used.** `horizon_epochs` and `escalation_ceiling_micros` MUST
548 /// describe the headroom this answer contains, not a documented default. Escalation compounds
549 /// at up to +12.5% per epoch ([`ESCALATION_UP_STEP_DENOM`](params::ESCALATION_UP_STEP_DENOM)), so a
550 /// buffer quoted against an unstated horizon cannot be checked by anyone.
551 /// - **Decide the funding state here, once.** `funding_state` is the node's verdict, not a hint;
552 /// an implementation that returned a placeholder and left clients to threshold the numbers
553 /// themselves recreates the rival derivations this method exists to prevent.
554 async fn collateral_buffer(&self) -> Result<results::CollateralBufferResult, ControlError>;
555
556 /// `control.mirror.bondStates` (TOKEN-GATED)
557 ///
558 /// The per-`(store, root)` state of every mirror bond this node holds, and the $DIG they lock.
559 ///
560 /// An implementation MUST:
561 ///
562 /// - **Keep the seven states apart.** `unfunded` is the only genuine out-of-funds state.
563 /// `deferred` (no priced requirement), `pending` (submitted, unconfirmed), `withheld`
564 /// (`Relayed` provenance) and `disabled` (the node-wide switch) all mean "no coin yet" and
565 /// none of them means "send money". Collapsing any of them into `unfunded` is the dig-app#300
566 /// defect this method exists to remove.
567 /// - **Read `bonded` and `reclaiming` amounts FROM THE COIN**, never from this epoch's
568 /// requirement. A coin created under a previous requirement locks the previous amount, and
569 /// the current price is not a fact about an existing coin.
570 /// - **Enumerate the SERVED set, not only the desired-bond set.**
571 /// [`Withheld`](results::MirrorBondState::Withheld) means a capsule this node holds with
572 /// `Relayed` provenance, which is by construction absent from the `Held` set; a derivation
573 /// keyed on `Held` alone can never emit it and silently answers "no such row" where the
574 /// contract promises "withheld on purpose". An implementation that CANNOT see provenance MUST
575 /// answer
576 /// [`ProvenanceUnknown`](results::MirrorBondStatesUnknownReason::ProvenanceUnknown) for the
577 /// whole call and MUST NOT return a `known` page — a page with its withheld rows silently
578 /// missing claims a completeness the node knows it lacks.
579 /// - **Answer [`Unknown`](results::MirrorBondStatesResult::Unknown) for the WHOLE call** when it
580 /// cannot enumerate its bonds, cannot read chain, cannot read its own in-flight creates, or
581 /// cannot determine provenance.
582 /// There is no per-row unknown and no empty-list fallback: `entries: []` with
583 /// `complete: true` asserts this node holds no bonds, and a partial list read as a complete
584 /// one hides exactly the bonds nobody is watching.
585 /// - **Compute `locked_dig_base_units` over the WHOLE set, including reclaiming coins**, and
586 /// never over the page. A reclaim in flight still locks its money.
587 /// - **Order rows by ascending `(store_id, root)` and keep that order stable across the pages of
588 /// one walk**, over the LOWERCASE unprefixed hex spelling of both halves, since `after` means
589 /// *strictly after this key in that order* and uppercase hex sorts elsewhere. Set `complete`
590 /// explicitly, and set `cursor` to the key of the LAST row actually handed back (`null` for an
591 /// empty page) — never to a position the node "got to".
592 async fn mirror_bond_states(
593 &self,
594 params: params::MirrorBondStatesParams,
595 ) -> Result<results::MirrorBondStatesResult, ControlError>;
596
597 /// `control.profile.putBody` (TOKEN-GATED)
598 ///
599 /// An implementation MUST independently resolve the profile's root ON CHAIN, recompute the root
600 /// of the supplied body, and REFUSE the call unless the two agree and that root is confirmed.
601 /// The caller's `root` is a claim to be checked — never a fact to be trusted — and dig-app gets
602 /// no exemption: it holds the key and signs the root (§908), but the bytes reach the node the
603 /// same way a peer's bytes do, and the same check binds both. An implementation that stores
604 /// what it is handed makes this node serve arbitrary bytes under someone else's profile id.
605 ///
606 /// A body whose DECODED length exceeds [`MAX_BODY_BYTES`](params::MAX_BODY_BYTES) (4 MiB) MUST
607 /// be refused as `INVALID_PARAMS`, before it is persisted: a body larger than that cannot be
608 /// served to a peer inside dig-gossip's frame ceiling, so accepting it would store something
609 /// permanently unsyncable.
610 ///
611 /// Returning `Ok` therefore asserts BOTH that the root was confirmed on chain and that the body
612 /// is persisted and servable. A refusal is an error, never an `Ok` carrying `stored: false`.
613 async fn profile_put_body(
614 &self,
615 params: params::ProfilePutBodyParams,
616 ) -> Result<results::ProfilePutBodyResult, ControlError>;
617 /// `control.profile.getBody` (READ-only, TOKEN-GATED)
618 ///
619 /// `Ok(body_b64: None)` MUST mean "this node was consulted and holds no body at that root". A
620 /// read that FAILED MUST return a catalogued error instead — a caller that reads a failure as
621 /// absence renders an existing profile as an empty one.
622 ///
623 /// The returned `root` MUST be the root the caller asked for; a node MUST NOT substitute a
624 /// newer body it happens to hold.
625 async fn profile_get_body(
626 &self,
627 params: params::ProfileGetBodyParams,
628 ) -> Result<results::ProfileGetBodyResult, ControlError>;
629 /// `pairing.request` (OPEN)
630 async fn pairing_request(
631 &self,
632 params: params::RequestParams,
633 ) -> Result<results::PairingRequestResult, ControlError>;
634 /// `pairing.poll` (OPEN)
635 async fn pairing_poll(
636 &self,
637 params: params::PollParams,
638 ) -> Result<results::PairingPollResult, ControlError>;
639
640 /// Route a raw JSON-RPC request to the right typed method and build the response envelope.
641 ///
642 /// Deserializes the params for methods that take them, calls the handler, and serializes the
643 /// typed result. An unknown method → `METHOD_NOT_FOUND`; malformed params → `INVALID_PARAMS`.
644 /// This is the single seam a server dispatches through — the KATs exercise it end-to-end.
645 async fn dispatch(&self, request: JsonRpcRequest) -> JsonRpcResponse {
646 let id = request.id.clone();
647 let Some(method) = ControlMethod::from_name(&request.method) else {
648 return JsonRpcResponse::error(
649 id,
650 ControlError::of(
651 ControlErrorCode::MethodNotFound,
652 format!("unknown control method: {}", request.method),
653 ),
654 );
655 };
656 match self.dispatch_method(method, request.params).await {
657 Ok(result) => JsonRpcResponse::success(id, result),
658 Err(err) => JsonRpcResponse::error(id, err),
659 }
660 }
661
662 /// Route to the typed method by [`ControlMethod`], returning the result as a [`Value`]. Split
663 /// from [`dispatch`](ControlHandler::dispatch) so the envelope wrapping stays in one place.
664 #[doc(hidden)]
665 async fn dispatch_method(
666 &self,
667 method: ControlMethod,
668 params: Value,
669 ) -> Result<Value, ControlError> {
670 /// Deserialize a method's params, mapping a shape error to `INVALID_PARAMS`.
671 fn decode<T: DeserializeOwned>(params: Value) -> Result<T, ControlError> {
672 serde_json::from_value(params)
673 .map_err(|e| ControlError::of(ControlErrorCode::InvalidParams, e.to_string()))
674 }
675 /// Serialize a typed result to a `Value` (infallible for our derive-Serialize results).
676 fn encode<T: Serialize>(value: T) -> Result<Value, ControlError> {
677 serde_json::to_value(value)
678 .map_err(|e| ControlError::of(ControlErrorCode::ControlError, e.to_string()))
679 }
680 match method {
681 ControlMethod::Status => encode(self.status().await?),
682 ControlMethod::ConfigGet => encode(self.config_get().await?),
683 ControlMethod::ConfigSetUpstream => {
684 encode(self.config_set_upstream(decode(params)?).await?)
685 }
686 ControlMethod::LogSetLevel => encode(self.log_set_level(decode(params)?).await?),
687 ControlMethod::CacheGet => encode(self.cache_get().await?),
688 ControlMethod::CacheSetCap => encode(self.cache_set_cap(decode(params)?).await?),
689 ControlMethod::CacheClear => encode(self.cache_clear().await?),
690 ControlMethod::HostedStoresList => encode(self.hosted_stores_list().await?),
691 ControlMethod::HostedStoresPin => {
692 encode(self.hosted_stores_pin(decode(params)?).await?)
693 }
694 ControlMethod::HostedStoresUnpin => {
695 encode(self.hosted_stores_unpin(decode(params)?).await?)
696 }
697 ControlMethod::HostedStoresStatus => {
698 encode(self.hosted_stores_status(decode(params)?).await?)
699 }
700 ControlMethod::CapsuleFetch => encode(self.capsule_fetch(decode(params)?).await?),
701 ControlMethod::SyncStatus => encode(self.sync_status().await?),
702 ControlMethod::SyncTrigger => encode(self.sync_trigger(decode(params)?).await?),
703 ControlMethod::UpdaterStatus => self.updater_status().await,
704 ControlMethod::UpdaterSetChannel => self.updater_set_channel(decode(params)?).await,
705 ControlMethod::UpdaterPause => self.updater_pause(decode(params)?).await,
706 ControlMethod::UpdaterResume => self.updater_resume().await,
707 ControlMethod::UpdaterCheckNow => self.updater_check_now().await,
708 ControlMethod::PairingList => self.pairing_list().await,
709 ControlMethod::PairingApprove => encode(self.pairing_approve(decode(params)?).await?),
710 ControlMethod::PairingRevoke => encode(self.pairing_revoke(decode(params)?).await?),
711 ControlMethod::PeerStatus => self.peer_status().await,
712 ControlMethod::PeerCounts => encode(self.peer_counts().await?),
713 ControlMethod::PeersConnect => encode(self.peers_connect(decode(params)?).await?),
714 ControlMethod::PeersDisconnect => encode(self.peers_disconnect(decode(params)?).await?),
715 ControlMethod::ChiaPeersAdd => encode(self.chia_peers_add(decode(params)?).await?),
716 ControlMethod::ChiaPeersList => encode(self.chia_peers_list().await?),
717 ControlMethod::ChiaPeersRemove => {
718 encode(self.chia_peers_remove(decode(params)?).await?)
719 }
720 ControlMethod::Subscribe => encode(self.subscribe(decode(params)?).await?),
721 ControlMethod::Unsubscribe => encode(self.unsubscribe(decode(params)?).await?),
722 ControlMethod::ListSubscriptions => encode(self.list_subscriptions().await?),
723 ControlMethod::WalletBalance => encode(self.wallet_balance(decode(params)?).await?),
724 ControlMethod::WalletCoins => encode(self.wallet_coins(decode(params)?).await?),
725 // Re-validated here idempotently; deserialization already enforced the same rule.
726 ControlMethod::WalletCoinById => {
727 let params: params::WalletCoinByIdParams = decode(params)?;
728 encode(self.wallet_coin_by_id(params.validated()?).await?)
729 }
730 // Re-validated here idempotently; deserialization already enforced the same rule.
731 ControlMethod::WalletCoinSpend => {
732 let params: params::WalletCoinSpendParams = decode(params)?;
733 encode(self.wallet_coin_spend(params.validated()?).await?)
734 }
735 ControlMethod::WalletCoinsByParent => {
736 let params: params::WalletCoinsByParentParams = decode(params)?;
737 encode(self.wallet_coins_by_parent(params.validated()?).await?)
738 }
739 ControlMethod::WalletArrivals => encode(self.wallet_arrivals(decode(params)?).await?),
740 ControlMethod::WalletPeak => encode(self.wallet_peak().await?),
741 ControlMethod::WalletSyncStatus => encode(self.wallet_sync_status().await?),
742 ControlMethod::WalletBroadcast => encode(self.wallet_broadcast(decode(params)?).await?),
743 // Re-validated here idempotently; deserialization already enforced the same rule.
744 ControlMethod::WalletWatch => {
745 let params: params::WalletWatchParams = decode(params)?;
746 encode(self.wallet_watch(params.validated()?).await?)
747 }
748 // Re-validated here idempotently; deserialization already enforced the same rule.
749 ControlMethod::WalletUnwatch => {
750 let params: params::WalletUnwatchParams = decode(params)?;
751 encode(self.wallet_unwatch(params.validated()?).await?)
752 }
753 ControlMethod::WalletWatched => encode(self.wallet_watched().await?),
754 ControlMethod::WalletReservationsHeld => encode(self.wallet_reservations_held().await?),
755 ControlMethod::WalletReservationsReserve => {
756 encode(self.wallet_reservations_reserve(decode(params)?).await?)
757 }
758 ControlMethod::WalletReservationsRelease => {
759 encode(self.wallet_reservations_release(decode(params)?).await?)
760 }
761 ControlMethod::CollateralRequirement => encode(self.collateral_requirement().await?),
762 ControlMethod::CollateralBuffer => encode(self.collateral_buffer().await?),
763 // Re-validated here idempotently; `MirrorBondStatesParams`'s own `Deserialize` already
764 // enforced the page bound. A limit above the cap is REFUSED, never clamped, or the
765 // cursor handed back names a position the caller never asked about.
766 ControlMethod::MirrorBondStates => {
767 let params: params::MirrorBondStatesParams = decode(params)?;
768 encode(self.mirror_bond_states(params.validated()?).await?)
769 }
770 ControlMethod::CollateralMarginGet => encode(self.collateral_margin_get().await?),
771 // `CollateralMarginSetParams` derives `Deserialize`, so decoding enforces NOTHING beyond
772 // the field's type. `validated()` here is the SOLE enforcement of `MAX_SAFETY_MARGIN_BP`
773 // on this money-path mutation — dropping it admits an unbounded margin, and the margin
774 // arithmetic saturates rather than failing, so the result is a silently enormous posting.
775 ControlMethod::CollateralMarginSet => {
776 let params: params::CollateralMarginSetParams = decode(params)?;
777 encode(self.collateral_margin_set(params.validated()?).await?)
778 }
779 // Re-validated here idempotently; deserialization already enforced the same rule.
780 ControlMethod::SpendsList => {
781 let params: params::SpendsListParams = decode(params)?;
782 encode(self.spends_list(params.validated()?).await?)
783 }
784 ControlMethod::ProfilePutBody => encode(self.profile_put_body(decode(params)?).await?),
785 ControlMethod::ProfileGetBody => encode(self.profile_get_body(decode(params)?).await?),
786 ControlMethod::PairingRequest => encode(self.pairing_request(decode(params)?).await?),
787 ControlMethod::PairingPoll => encode(self.pairing_poll(decode(params)?).await?),
788 }
789 }
790}