Skip to main content

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}