dig_node_control_interface/params.rs
1//! Typed request params for the control methods, each bound to its method + result via
2//! [`crate::traits::ControlCall`].
3//!
4//! One params type per method (even where two methods share the same field shape, e.g. the four
5//! `{ store }` methods) so the compile-time method↔params↔result binding is exact: a caller passes
6//! `PinParams { store }` and the type system yields a [`PinResult`](crate::results::PinResult).
7//! Field names are the exact wire names dig-node reads.
8
9use serde::{Deserialize, Serialize};
10
11use crate::method::ControlMethod;
12use crate::results;
13use crate::traits::ControlCall;
14
15/// Bind a params type to its wire method + typed result.
16macro_rules! control_call {
17 ($ty:ty => $method:expr, $out:ty) => {
18 impl ControlCall for $ty {
19 const METHOD: ControlMethod = $method;
20 type Output = $out;
21 }
22 };
23}
24
25/// Define a no-param call: an empty params struct (serializes to `{}`) bound to its method + result.
26macro_rules! no_params {
27 ($(#[$doc:meta])* $name:ident => $method:expr, $out:ty) => {
28 $(#[$doc])*
29 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
30 pub struct $name {}
31 control_call!($name => $method, $out);
32 };
33}
34
35no_params!(
36 /// `control.status` params (none).
37 StatusParams => ControlMethod::Status, results::StatusResult
38);
39no_params!(
40 /// `control.config.get` params (none).
41 ConfigGetParams => ControlMethod::ConfigGet, results::ConfigResult
42);
43no_params!(
44 /// `control.cache.get` params (none).
45 CacheGetParams => ControlMethod::CacheGet, results::CacheView
46);
47no_params!(
48 /// `control.cache.clear` params (none).
49 CacheClearParams => ControlMethod::CacheClear, results::CacheClearResult
50);
51no_params!(
52 /// `control.hostedStores.list` params (none).
53 HostedStoresListParams => ControlMethod::HostedStoresList, results::HostedStoresListResult
54);
55no_params!(
56 /// `control.sync.status` params (none).
57 SyncStatusParams => ControlMethod::SyncStatus, results::SyncStatusResult
58);
59no_params!(
60 /// `control.updater.status` params (none). Result is the proxied beacon status.
61 UpdaterStatusParams => ControlMethod::UpdaterStatus, serde_json::Value
62);
63no_params!(
64 /// `control.updater.resume` params (none).
65 UpdaterResumeParams => ControlMethod::UpdaterResume, serde_json::Value
66);
67no_params!(
68 /// `control.updater.checkNow` params (none).
69 UpdaterCheckNowParams => ControlMethod::UpdaterCheckNow, serde_json::Value
70);
71no_params!(
72 /// `control.pairing.list` params (none). Result is the pending + issued-token list.
73 PairingListParams => ControlMethod::PairingList, serde_json::Value
74);
75no_params!(
76 /// `control.peerStatus` params (none). Result is the peer-pool snapshot.
77 PeerStatusParams => ControlMethod::PeerStatus, serde_json::Value
78);
79no_params!(
80 /// `control.listSubscriptions` params (none).
81 ListSubscriptionsParams => ControlMethod::ListSubscriptions, results::ListSubscriptionsResult
82);
83
84/// `control.config.setUpstream` params.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct SetUpstreamParams {
87 /// The upstream DIG RPC URL to persist (blank clears the override).
88 pub upstream: String,
89}
90control_call!(SetUpstreamParams => ControlMethod::ConfigSetUpstream, results::SetUpstreamResult);
91
92/// `control.log.setLevel` params.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct SetLevelParams {
95 /// An `EnvFilter` directive, e.g. `"debug"` or `"info,dig_node_core=debug"`.
96 pub filter: String,
97}
98control_call!(SetLevelParams => ControlMethod::LogSetLevel, results::SetLevelResult);
99
100/// `control.cache.setCap` params.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct SetCapParams {
103 /// The cache size cap in bytes (floored at 64 MiB by the node).
104 pub cap_bytes: u64,
105}
106control_call!(SetCapParams => ControlMethod::CacheSetCap, results::SetCapResult);
107
108/// `control.hostedStores.pin` params.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct PinParams {
111 /// A store reference: `storeId` or `storeId:rootHash`.
112 pub store: String,
113}
114control_call!(PinParams => ControlMethod::HostedStoresPin, results::PinResult);
115
116/// `control.hostedStores.unpin` params.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct UnpinParams {
119 /// A store reference: `storeId` or `storeId:rootHash`.
120 pub store: String,
121}
122control_call!(UnpinParams => ControlMethod::HostedStoresUnpin, results::UnpinResult);
123
124/// `control.hostedStores.status` params.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct HostedStoreStatusParams {
127 /// A store reference: `storeId` or `storeId:rootHash`.
128 pub store: String,
129}
130control_call!(HostedStoreStatusParams => ControlMethod::HostedStoresStatus, results::HostedStoreStatusResult);
131
132/// `control.sync.trigger` params.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct SyncTriggerParams {
135 /// A capsule reference: `storeId:rootHash` (a concrete root is required).
136 pub store: String,
137}
138control_call!(SyncTriggerParams => ControlMethod::SyncTrigger, results::SyncTriggerResult);
139
140/// `control.updater.setChannel` params.
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142pub struct SetChannelParams {
143 /// The update channel (`"nightly"` | `"stable"`; the beacon CLI is the sole validator).
144 pub channel: String,
145}
146control_call!(SetChannelParams => ControlMethod::UpdaterSetChannel, serde_json::Value);
147
148/// `control.updater.pause` params.
149#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
150pub struct PauseParams {
151 /// The unix-seconds time to pause until; omit to pause indefinitely.
152 #[serde(skip_serializing_if = "Option::is_none", default)]
153 pub until: Option<u64>,
154}
155control_call!(PauseParams => ControlMethod::UpdaterPause, serde_json::Value);
156
157/// `control.pairing.approve` params.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct ApproveParams {
160 /// The pending pairing's id (from `pairing.request`).
161 pub pairing_id: String,
162}
163control_call!(ApproveParams => ControlMethod::PairingApprove, results::PairingApproveResult);
164
165/// `control.pairing.revoke` params.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct RevokeParams {
168 /// The short id of the paired token to revoke.
169 pub token_id: String,
170}
171control_call!(RevokeParams => ControlMethod::PairingRevoke, results::PairingRevokeResult);
172
173/// `control.peers.connect` params.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct PeersConnectParams {
176 /// A peer address to dial, or an already-connected peer_id to resolve.
177 pub peer: String,
178}
179control_call!(PeersConnectParams => ControlMethod::PeersConnect, results::PeersConnectResult);
180
181/// `control.peers.disconnect` params.
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183pub struct PeersDisconnectParams {
184 /// The peer_id to drop.
185 pub peer: String,
186}
187control_call!(PeersDisconnectParams => ControlMethod::PeersDisconnect, results::PeersDisconnectResult);
188
189/// `control.subscribe` params.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct SubscribeParams {
192 /// The store id to subscribe to.
193 pub store_id: String,
194}
195control_call!(SubscribeParams => ControlMethod::Subscribe, results::SubscribeResult);
196
197/// `control.unsubscribe` params.
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199pub struct UnsubscribeParams {
200 /// The store id to stop watching.
201 pub store_id: String,
202}
203control_call!(UnsubscribeParams => ControlMethod::Unsubscribe, results::UnsubscribeResult);
204
205/// The asset a wallet balance/coin read is denominated in.
206///
207/// Serializes to a lowercase, language-neutral wire token (`"xch"` / `"dig"`) — byte-identical to the
208/// frozen consumer type in dig-app (`dig-app-core::wallet::state::Asset`), so the contract and the
209/// consumer share one wire form. Extended additively as the wallet grows to hold more CAT types.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "lowercase")]
212pub enum Asset {
213 /// Native Chia (XCH), denominated in mojos.
214 Xch,
215 /// The DIG CAT, denominated in its base units.
216 Dig,
217}
218
219/// `control.wallet.balance` params: which address + asset to read the balance of.
220///
221/// A READ over the loopback control plane — never a spend. Field names + the [`Asset`] wire form are
222/// byte-identical to dig-app's frozen `BalanceRequest`, so the node reads exactly what dig-app emits.
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224pub struct WalletBalanceParams {
225 /// The `xch1…` address to read the balance of.
226 pub address: String,
227 /// The asset to read the balance for.
228 pub asset: Asset,
229}
230control_call!(WalletBalanceParams => ControlMethod::WalletBalance, results::WalletBalanceResult);
231
232/// `control.wallet.coins` params: which address + asset to read spendable coins for.
233///
234/// Field-for-field identical to [`WalletBalanceParams`] — a balance is this read reduced to a sum —
235/// and byte-identical to dig-app's frozen `CoinsRequest`, so adopting the method is a body swap
236/// rather than a re-shape.
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238pub struct WalletCoinsParams {
239 /// The `xch1…` address to read coins for.
240 pub address: String,
241 /// The asset to read coins for.
242 pub asset: Asset,
243}
244control_call!(WalletCoinsParams => ControlMethod::WalletCoins, results::WalletCoinsResult);
245
246/// The length of a coin id in lowercase hex characters: a 32-byte hash.
247const COIN_ID_HEX_LEN: usize = 64;
248
249/// `control.wallet.coinById` params: WHICH coin, named by its own id.
250///
251/// # Why there is no `asset` here
252///
253/// A coin id names one coin on one chain. It is not scoped to an address and not scoped to an
254/// asset, and a node reading a coin record learns neither — so an asset parameter here could only
255/// be a claim the read never checks. The answer's
256/// [`asset`](crate::results::WalletCoinRecord::asset) is `null` for the same reason.
257///
258/// # Why the method exists at all
259///
260/// [`WalletCoinsParams`] answers by ADDRESS and lists UNSPENT coins only. A mint's evidence is the
261/// opposite shape: the created DID coin (which sits at nobody's wallet address) and the funding
262/// coin the mint SPENT (which is, by then, gone from every unspent list). Without a by-id read a
263/// pushed mint can never be observed — a permanent "pending" with the money already spent.
264#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
265pub struct WalletCoinByIdParams {
266 /// The coin id: lowercase 64-hex, unprefixed. A `0x` prefix is TOLERATED on input (block
267 /// explorers print one) and normalized away by [`Self::validated`]; it is never emitted.
268 pub coin_id: String,
269}
270control_call!(WalletCoinByIdParams => ControlMethod::WalletCoinById, results::WalletCoinByIdResult);
271
272/// `control.wallet.arrivals` — confirmed INCOMING funds recorded since a cursor position.
273///
274/// The answer to "was I just paid?", which neither a balance nor a coin list can give: a balance
275/// moves for the user's OWN change too, and an unspent-coin list cannot say which of its coins are
276/// new. Each row the node returns is a coin it determined ARRIVED — confirmed on chain, above the
277/// wallet's arrival baseline, not previously reported, and not created by spending one of the
278/// wallet's own coins. The determination is the NODE's; a client MUST NOT re-derive it, because the
279/// signals it takes (spent parents, the catch-up baseline) live only in the node's replica.
280///
281/// # A cursor, not a stream
282///
283/// The control envelope is strictly request→response, so this is polled. A client resumes from
284/// [`WalletArrivalsResult::cursor`](results::WalletArrivalsResult::cursor) — the last position it
285/// was actually handed — and NEVER from `latest`; see that field for why the distinction loses a
286/// notification when it is collapsed.
287///
288/// # `after_seq` is unsigned so a rewind is unexpressible
289///
290/// Positions are monotonic and start at 1, so there is no meaning for a negative one. `0` is the
291/// beginning of the ledger and is what a client sends when it deliberately wants everything.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
293pub struct WalletArrivalsParams {
294 /// Return arrivals STRICTLY after this position. `0` starts at the beginning of the ledger,
295 /// and is also what an omitted field means — the same default the node applies.
296 #[serde(default)]
297 pub after_seq: u64,
298 /// The page size. `None` asks for the node's default rather than a number this client invented;
299 /// a node clamps whatever it is given to its own maximum.
300 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub limit: Option<u32>,
302}
303control_call!(WalletArrivalsParams => ControlMethod::WalletArrivals, results::WalletArrivalsResult);
304
305const COIN_ID_ERROR: &str = "coin_id must be lowercase 64-hex, optionally 0x-prefixed";
306
307fn normalize_coin_id(coin_id: &str) -> Option<&str> {
308 let normalized = coin_id.strip_prefix("0x").unwrap_or(coin_id);
309 let well_formed = normalized.len() == COIN_ID_HEX_LEN
310 && normalized
311 .bytes()
312 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
313 well_formed.then_some(normalized)
314}
315
316impl<'de> Deserialize<'de> for WalletCoinByIdParams {
317 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
318 where
319 D: serde::Deserializer<'de>,
320 {
321 #[derive(Deserialize)]
322 struct RawWalletCoinByIdParams {
323 coin_id: String,
324 }
325
326 let raw = RawWalletCoinByIdParams::deserialize(deserializer)?;
327 let coin_id = normalize_coin_id(&raw.coin_id)
328 .ok_or_else(|| serde::de::Error::custom(COIN_ID_ERROR))?
329 .to_owned();
330 Ok(Self { coin_id })
331 }
332}
333
334impl WalletCoinByIdParams {
335 /// Normalize and check the coin id, or reject the request as `-32602 INVALID_PARAMS`.
336 ///
337 /// A malformed id is a malformed REQUEST, and the node refuses it here — before consulting any
338 /// chain. That ordering is normative rather than an optimisation: were a bad id allowed through,
339 /// the read would come back with no such coin, and the caller would be told the honest-looking
340 /// answer `coin: null` about a coin it never actually asked after. An unanswerable question and
341 /// a chain that answered "no" must never wear the same shape.
342 ///
343 /// Accepts exactly two spellings — 64 lowercase hex characters, or the same 64 preceded by
344 /// `0x`. Uppercase, whitespace and every other length are refused, because the contract's hex
345 /// wire form is lowercase and unprefixed everywhere else in this crate.
346 pub fn validated(self) -> Result<Self, crate::error::ControlError> {
347 let normalized = normalize_coin_id(&self.coin_id).ok_or_else(|| {
348 crate::error::ControlError::of(
349 crate::error::ControlErrorCode::InvalidParams,
350 COIN_ID_ERROR,
351 )
352 })?;
353 Ok(WalletCoinByIdParams {
354 coin_id: normalized.to_owned(),
355 })
356 }
357}
358
359/// `control.wallet.peak` params — none.
360///
361/// The peak is a property of the node's chain view, not of any address, which is exactly why it is
362/// its own method: [`results::WalletBalanceResult::peak_height`] is `null` on every fallback-tier
363/// answer, so a caller bounding a claimed confirmation cannot rely on getting one from a balance.
364#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
365pub struct WalletPeakParams {}
366control_call!(WalletPeakParams => ControlMethod::WalletPeak, results::WalletPeakResult);
367
368/// `control.peerCounts` params — none.
369///
370/// The counts describe this node's own connectivity on each network, so there is nothing to scope
371/// the question by. One call answers for BOTH networks deliberately: a consumer that had to collect
372/// the DIG count from a peer method and the Chia count from a wallet method is a consumer that can
373/// reach for the wrong one.
374#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
375pub struct PeerCountsParams {}
376control_call!(PeerCountsParams => ControlMethod::PeerCounts, results::PeerCountsResult);
377
378/// `control.wallet.syncStatus` params — none.
379///
380/// The wallet's sync progress is a property of the node's own chain replica, not of any address, so
381/// there is nothing to scope the question by. Deliberately NOT confused with `control.sync.status`,
382/// which reports §21 DIG STORE sync and has nothing to do with the chain.
383#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
384pub struct WalletSyncStatusParams {}
385control_call!(WalletSyncStatusParams => ControlMethod::WalletSyncStatus, results::WalletSyncStatusResult);
386
387/// `control.wallet.broadcast` params: an ALREADY-SIGNED spend bundle to push.
388///
389/// # The custody boundary (§908)
390///
391/// This carries signed bytes and nothing else. There is deliberately no key, no seed, no phrase and
392/// no unsigned-spend-plus-key field here, and there never may be: the node's role on the money path
393/// is to read chain state and to push what somebody else signed. A parameter that let the node
394/// produce a signature would move custody into an identity-agnostic daemon, which is the one thing
395/// the boundary exists to prevent.
396#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
397pub struct WalletBroadcastParams {
398 /// The signed spend bundle: lowercase hex of its chia `Streamable` serialization.
399 pub signed_bundle_hex: String,
400}
401control_call!(WalletBroadcastParams => ControlMethod::WalletBroadcast, results::WalletBroadcastResult);
402
403/// `pairing.request` params (OPEN — no token).
404#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
405pub struct RequestParams {
406 /// A human-readable name for the requesting client (shown to the operator).
407 pub client_name: String,
408}
409control_call!(RequestParams => ControlMethod::PairingRequest, results::PairingRequestResult);
410
411/// `pairing.poll` params (OPEN — no token).
412#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
413pub struct PollParams {
414 /// The pairing id to poll.
415 pub pairing_id: String,
416}
417control_call!(PollParams => ControlMethod::PairingPoll, results::PairingPollResult);
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422 use crate::traits::build_request;
423 use serde_json::json;
424
425 #[test]
426 fn no_param_call_serializes_params_to_empty_object() {
427 let req = build_request(1.into(), &StatusParams {});
428 assert_eq!(req.method, "control.status");
429 assert_eq!(req.params, json!({}));
430 }
431
432 #[test]
433 fn data_param_call_carries_its_fields() {
434 let req = build_request(2.into(), &SetCapParams { cap_bytes: 128 });
435 assert_eq!(req.method, "control.cache.setCap");
436 assert_eq!(req.params, json!({ "cap_bytes": 128 }));
437 }
438
439 #[test]
440 fn pause_omits_until_when_indefinite() {
441 assert_eq!(
442 serde_json::to_value(PauseParams { until: None }).unwrap(),
443 json!({})
444 );
445 assert_eq!(
446 serde_json::to_value(PauseParams { until: Some(99) }).unwrap(),
447 json!({ "until": 99 })
448 );
449 }
450
451 #[test]
452 fn method_binding_matches_the_catalog_name() {
453 assert_eq!(
454 SetUpstreamParams::METHOD.name(),
455 "control.config.setUpstream"
456 );
457 assert_eq!(RequestParams::METHOD.name(), "pairing.request");
458 assert_eq!(PollParams::METHOD.name(), "pairing.poll");
459 }
460}