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/// WHAT a subscription follows: ordinary content, or a dig-profile.
190///
191/// Serializes to a lowercase, language-neutral wire token (`"capsule"` / `"profile"`).
192///
193/// # Absent means [`Capsule`](SubscriptionKind::Capsule), and that is a contract, not a convenience
194///
195/// Every subscription written before this field existed is untagged, and a node's
196/// `subscriptions.json` on a real machine is FULL of them. A required `kind` would make those rows
197/// fail to deserialize, and a node that cannot read its own subscription file starts with an empty
198/// one — an upgrade that silently unsubscribes a user from everything they had. So the field is
199/// `#[serde(default)]` everywhere it appears, and the default is the meaning those untagged rows
200/// already had: a capsule.
201#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "lowercase")]
203pub enum SubscriptionKind {
204    /// Ordinary store content — the meaning every untagged subscription already carries.
205    #[default]
206    Capsule,
207    /// A dig-profile: the node additionally follows the profile ROOT and syncs its body from peers.
208    Profile,
209}
210
211/// `control.subscribe` params.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213pub struct SubscribeParams {
214    /// The store id to subscribe to.
215    pub store_id: String,
216    /// What the subscription follows. OMITTED means [`SubscriptionKind::Capsule`], so an older
217    /// client's request and an untagged on-disk row both keep their existing meaning.
218    #[serde(default)]
219    pub kind: SubscriptionKind,
220}
221control_call!(SubscribeParams => ControlMethod::Subscribe, results::SubscribeResult);
222
223/// `control.unsubscribe` params.
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225pub struct UnsubscribeParams {
226    /// The store id to stop watching.
227    pub store_id: String,
228}
229control_call!(UnsubscribeParams => ControlMethod::Unsubscribe, results::UnsubscribeResult);
230
231/// The asset a wallet balance/coin read is denominated in.
232///
233/// Serializes to a lowercase, language-neutral wire token (`"xch"` / `"dig"`) — byte-identical to the
234/// frozen consumer type in dig-app (`dig-app-core::wallet::state::Asset`), so the contract and the
235/// consumer share one wire form. Extended additively as the wallet grows to hold more CAT types.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(rename_all = "lowercase")]
238pub enum Asset {
239    /// Native Chia (XCH), denominated in mojos.
240    Xch,
241    /// The DIG CAT, denominated in its base units.
242    Dig,
243}
244
245/// `control.wallet.balance` params: which address + asset to read the balance of.
246///
247/// A READ over the loopback control plane — never a spend. Field names + the [`Asset`] wire form are
248/// byte-identical to dig-app's frozen `BalanceRequest`, so the node reads exactly what dig-app emits.
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct WalletBalanceParams {
251    /// The `xch1…` address to read the balance of.
252    pub address: String,
253    /// The asset to read the balance for.
254    pub asset: Asset,
255}
256control_call!(WalletBalanceParams => ControlMethod::WalletBalance, results::WalletBalanceResult);
257
258/// `control.wallet.coins` params: which address + asset to read spendable coins for.
259///
260/// Field-for-field identical to [`WalletBalanceParams`] — a balance is this read reduced to a sum —
261/// and byte-identical to dig-app's frozen `CoinsRequest`, so adopting the method is a body swap
262/// rather than a re-shape.
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub struct WalletCoinsParams {
265    /// The `xch1…` address to read coins for.
266    pub address: String,
267    /// The asset to read coins for.
268    pub asset: Asset,
269}
270control_call!(WalletCoinsParams => ControlMethod::WalletCoins, results::WalletCoinsResult);
271
272/// The length of a coin id in lowercase hex characters: a 32-byte hash.
273const COIN_ID_HEX_LEN: usize = 64;
274
275/// `control.wallet.coinById` params: WHICH coin, named by its own id.
276///
277/// # Why there is no `asset` here
278///
279/// A coin id names one coin on one chain. It is not scoped to an address and not scoped to an
280/// asset, and a node reading a coin record learns neither — so an asset parameter here could only
281/// be a claim the read never checks. The answer's
282/// [`asset`](crate::results::WalletCoinRecord::asset) is `null` for the same reason.
283///
284/// # Why the method exists at all
285///
286/// [`WalletCoinsParams`] answers by ADDRESS and lists UNSPENT coins only. A mint's evidence is the
287/// opposite shape: the created DID coin (which sits at nobody's wallet address) and the funding
288/// coin the mint SPENT (which is, by then, gone from every unspent list). Without a by-id read a
289/// pushed mint can never be observed — a permanent "pending" with the money already spent.
290#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
291pub struct WalletCoinByIdParams {
292    /// The coin id: lowercase 64-hex, unprefixed. A `0x` prefix is TOLERATED on input (block
293    /// explorers print one) and normalized away by [`Self::validated`]; it is never emitted.
294    pub coin_id: String,
295}
296control_call!(WalletCoinByIdParams => ControlMethod::WalletCoinById, results::WalletCoinByIdResult);
297
298/// `control.wallet.coinSpend` params: WHICH coin's spend to read, named by that coin's own id.
299///
300/// # Why the spend and the coin record are separate methods
301///
302/// [`WalletCoinByIdParams`] answers *what is this coin, and was it spent?* — an id, an amount, a
303/// puzzle HASH and two heights. None of that reveals what the spend DID. Reconstructing a lineage
304/// (which is how a dig-profile's DID singleton is followed forward) needs the puzzle REVEAL and the
305/// solution, and those exist only in the spend. A caller holding a coin record alone can see that a
306/// coin is gone and cannot see what it became.
307///
308/// # The id names the SPENT coin, not the spend
309///
310/// A spend has no id of its own on chain; it is identified by the coin it consumed. So the parameter
311/// is the same 64-hex coin id [`WalletCoinByIdParams`] takes, validated by the same rule, and the
312/// two methods are asked with the identical value.
313#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
314pub struct WalletCoinSpendParams {
315    /// The SPENT coin's id: lowercase 64-hex, unprefixed. A `0x` prefix is TOLERATED on input and
316    /// normalized away by [`Self::validated`]; it is never emitted.
317    pub coin_id: String,
318}
319control_call!(WalletCoinSpendParams => ControlMethod::WalletCoinSpend, results::WalletCoinSpendResult);
320
321/// `control.wallet.coinsByParent` params: WHICH coin's direct children to read.
322///
323/// # One hop, and the field name says so
324///
325/// The field is `parent_coin_id` rather than `coin_id` because the coin named here is the one being
326/// asked ABOUT as a parent — it is never the coin the caller wants back. A walk up or down a lineage
327/// is the CALLER's composition of repeated single hops; the node performs exactly one. Naming it
328/// `coin_id` would make a recursive reading of the method plausible from the request alone, and a
329/// caller expecting a whole lineage from one call would read a one-hop answer as a truncated chain.
330///
331/// # Bounded, because a parent's child count is not
332///
333/// This is the only OPEN wallet read whose answer has unbounded cardinality — every other one
334/// returns a single record (`coinById`, `peak`, `syncStatus`) or is already paged (`arrivals`). So
335/// the read is PAGED, and the page is bounded by [`COINS_BY_PARENT_MAX_LIMIT`].
336///
337/// **The bound is the ONLY thing bounding this call — there is no rate limiter anywhere behind it.**
338/// dig-node's control plane has no request rate limiting of any kind (dig_ecosystem#2577); the
339/// bandwidth limiter it does have governs content serving and is not on this path. A future reader
340/// weighing whether to relax this cap should assume no limiter exists, because none does.
341///
342/// That matters more than a local resource bound would, because the node does not necessarily answer
343/// from its own replica: on the fallback tier it forwards a caller-supplied identifier to a
344/// THIRD-PARTY coinset HTTPS oracle. An unbounded page is therefore unbounded work against somebody
345/// else's service, requested by a token-less caller on a loopback endpoint.
346///
347/// Paging rather than a bare cap, because a bare cap is a dead end: a parent with more children than
348/// the cap could never be fully enumerated, and this method exists to WALK a lineage. A walk that
349/// cannot see past the cap is a walk that silently stops.
350#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
351pub struct WalletCoinsByParentParams {
352    /// The PARENT coin's id: lowercase 64-hex, unprefixed. A `0x` prefix is TOLERATED on input and
353    /// normalized away by [`Self::validated`]; it is never emitted.
354    pub parent_coin_id: String,
355    /// Resume STRICTLY AFTER this child, in the read's
356    /// [documented order](results::WalletCoinsByParentResult). `None` starts at the first child.
357    ///
358    /// This is the value the previous page handed back as
359    /// [`cursor`](results::WalletCoinsByParentResult::cursor) — never a value the caller invented,
360    /// and never a marker for where the chain "got to". `control.wallet.arrivals` records why that
361    /// distinction loses rows; this read avoids the trap by having no such marker to reach for.
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub after_coin_id: Option<String>,
364    /// The page size. `None` asks for [`COINS_BY_PARENT_DEFAULT_LIMIT`].
365    ///
366    /// A value above [`COINS_BY_PARENT_MAX_LIMIT`], or a zero, is REFUSED as `INVALID_PARAMS` rather
367    /// than clamped — see [`Self::validated`].
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub limit: Option<u32>,
370}
371control_call!(WalletCoinsByParentParams => ControlMethod::WalletCoinsByParent, results::WalletCoinsByParentResult);
372
373/// `control.wallet.arrivals` — confirmed INCOMING funds recorded since a cursor position.
374///
375/// The answer to "was I just paid?", which neither a balance nor a coin list can give: a balance
376/// moves for the user's OWN change too, and an unspent-coin list cannot say which of its coins are
377/// new. Each row the node returns is a coin it determined ARRIVED — confirmed on chain, above the
378/// wallet's arrival baseline, not previously reported, and not created by spending one of the
379/// wallet's own coins. The determination is the NODE's; a client MUST NOT re-derive it, because the
380/// signals it takes (spent parents, the catch-up baseline) live only in the node's replica.
381///
382/// # A cursor, not a stream
383///
384/// The control envelope is strictly request→response, so this is polled. A client resumes from
385/// [`WalletArrivalsResult::cursor`](results::WalletArrivalsResult::cursor) — the last position it
386/// was actually handed — and NEVER from `latest`; see that field for why the distinction loses a
387/// notification when it is collapsed.
388///
389/// # `after_seq` is unsigned so a rewind is unexpressible
390///
391/// Positions are monotonic and start at 1, so there is no meaning for a negative one. `0` is the
392/// beginning of the ledger and is what a client sends when it deliberately wants everything.
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
394pub struct WalletArrivalsParams {
395    /// Return arrivals STRICTLY after this position. `0` starts at the beginning of the ledger,
396    /// and is also what an omitted field means — the same default the node applies.
397    #[serde(default)]
398    pub after_seq: u64,
399    /// The page size. `None` asks for the node's default rather than a number this client invented;
400    /// a node clamps whatever it is given to its own maximum.
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub limit: Option<u32>,
403}
404control_call!(WalletArrivalsParams => ControlMethod::WalletArrivals, results::WalletArrivalsResult);
405
406fn normalize_coin_id(coin_id: &str) -> Option<&str> {
407    let normalized = coin_id.strip_prefix("0x").unwrap_or(coin_id);
408    let well_formed = normalized.len() == COIN_ID_HEX_LEN
409        && normalized
410            .bytes()
411            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
412    well_formed.then_some(normalized)
413}
414
415/// Give a single-coin-id params type its validating `Deserialize` and its `validated` constructor.
416///
417/// The three by-coin reads (`coinById`, `coinSpend`, `coinsByParent`) enforce the IDENTICAL id rule
418/// under three different field names, and the rule is normative rather than incidental — see
419/// [`WalletCoinByIdParams::validated`] for why a malformed id must be refused BEFORE a chain is
420/// consulted. Written once here so a fourth by-coin read cannot arrive with a subtly looser copy of
421/// it, and so a change to the rule cannot land on two of three types.
422macro_rules! coin_id_params {
423    ($ty:ident, $field:ident, $raw:ident, $error:expr) => {
424        impl<'de> Deserialize<'de> for $ty {
425            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
426            where
427                D: serde::Deserializer<'de>,
428            {
429                #[derive(Deserialize)]
430                struct $raw {
431                    $field: String,
432                }
433
434                let raw = $raw::deserialize(deserializer)?;
435                let $field = normalize_coin_id(&raw.$field)
436                    .ok_or_else(|| serde::de::Error::custom($error))?
437                    .to_owned();
438                Ok(Self { $field })
439            }
440        }
441
442        impl $ty {
443            /// Normalize and check the coin id, or reject the request as `-32602 INVALID_PARAMS`.
444            ///
445            /// A malformed id is a malformed REQUEST, and the node refuses it here — before
446            /// consulting any chain. That ordering is normative rather than an optimisation: were a
447            /// bad id allowed through, the read would come back empty, and the caller would be told
448            /// the honest-looking answer *the chain holds nothing* about a coin it never actually
449            /// asked after. An unanswerable question and a chain that answered "no" must never wear
450            /// the same shape.
451            ///
452            /// Accepts exactly two spellings — 64 lowercase hex characters, or the same 64 preceded
453            /// by `0x`. Uppercase, whitespace and every other length are refused, because the
454            /// contract's hex wire form is lowercase and unprefixed everywhere else in this crate.
455            pub fn validated(self) -> Result<Self, crate::error::ControlError> {
456                let normalized = normalize_coin_id(&self.$field).ok_or_else(|| {
457                    crate::error::ControlError::of(
458                        crate::error::ControlErrorCode::InvalidParams,
459                        $error,
460                    )
461                })?;
462                Ok($ty {
463                    $field: normalized.to_owned(),
464                })
465            }
466        }
467    };
468}
469
470const COIN_ID_ERROR: &str = "coin_id must be lowercase 64-hex, optionally 0x-prefixed";
471const PARENT_COIN_ID_ERROR: &str =
472    "parent_coin_id must be lowercase 64-hex, optionally 0x-prefixed";
473const AFTER_COIN_ID_ERROR: &str = "after_coin_id must be lowercase 64-hex, optionally 0x-prefixed";
474
475coin_id_params!(
476    WalletCoinByIdParams,
477    coin_id,
478    RawWalletCoinByIdParams,
479    COIN_ID_ERROR
480);
481coin_id_params!(
482    WalletCoinSpendParams,
483    coin_id,
484    RawWalletCoinSpendParams,
485    COIN_ID_ERROR
486);
487/// The page size `control.wallet.coinsByParent` uses when the caller names none.
488///
489/// A spend in the lineages this read exists to follow — a singleton, a DID, an ordinary transfer —
490/// creates a small handful of children, so one default page covers a realistic hop in a single round
491/// trip and a caller never pages at all.
492pub const COINS_BY_PARENT_DEFAULT_LIMIT: u32 = 100;
493
494/// The largest page `control.wallet.coinsByParent` will accept, derived from the transport's own
495/// frame limit rather than chosen for feel.
496///
497/// dig-ipc-protocol caps a control frame at `MAX_FRAME_BYTES` = 1 MiB (its `SPEC.md` §
498/// bounds), and that is the hard ceiling every answer on this plane has to fit inside. A
499/// [`WalletCoinRecord`](results::WalletCoinRecord) is at most ~350 bytes of JSON — three 64-hex
500/// hashes at 66 bytes quoted, a 20-digit `u64`, two 10-digit heights, and their keys — so the
501/// arithmetic that fixes this number is:
502///
503/// ```text
504/// 1 MiB / 350 B  ~=  2,996 records is where a page STOPS FITTING
505/// 1,000 records  ~=  350 KB, roughly a third of the frame
506/// ```
507///
508/// The cap is set at a third of what fits, not at what fits, so the envelope, the freshness fields
509/// and any future additive member cannot push a legal page over the transport's limit. A larger
510/// value would put the contract's own maximum inside the region where a conforming node's honest
511/// answer is undeliverable — the failure would surface as a truncated frame, not as a refusal.
512pub const COINS_BY_PARENT_MAX_LIMIT: u32 = 1_000;
513
514const COINS_BY_PARENT_LIMIT_ERROR: &str = "limit must be between 1 and 1000";
515
516impl<'de> Deserialize<'de> for WalletCoinsByParentParams {
517    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
518    where
519        D: serde::Deserializer<'de>,
520    {
521        #[derive(Deserialize)]
522        struct RawWalletCoinsByParentParams {
523            parent_coin_id: String,
524            #[serde(default)]
525            after_coin_id: Option<String>,
526            #[serde(default)]
527            limit: Option<u32>,
528        }
529
530        let raw = RawWalletCoinsByParentParams::deserialize(deserializer)?;
531        let parent_coin_id = normalize_coin_id(&raw.parent_coin_id)
532            .ok_or_else(|| serde::de::Error::custom(PARENT_COIN_ID_ERROR))?
533            .to_owned();
534        let after_coin_id = raw
535            .after_coin_id
536            .map(|id| {
537                normalize_coin_id(&id)
538                    .map(str::to_owned)
539                    .ok_or_else(|| serde::de::Error::custom(AFTER_COIN_ID_ERROR))
540            })
541            .transpose()?;
542        if !raw.limit.map_or(true, is_legal_page) {
543            return Err(serde::de::Error::custom(COINS_BY_PARENT_LIMIT_ERROR));
544        }
545        Ok(Self {
546            parent_coin_id,
547            after_coin_id,
548            limit: raw.limit,
549        })
550    }
551}
552
553/// Is this a page size the contract accepts — at least one row, at most the frame-derived maximum?
554fn is_legal_page(limit: u32) -> bool {
555    (1..=COINS_BY_PARENT_MAX_LIMIT).contains(&limit)
556}
557
558impl WalletCoinsByParentParams {
559    /// A first page of children for one parent: the node's default size, starting at the beginning.
560    ///
561    /// The common case, and the one a caller should not have to spell out — naming a page size means
562    /// asserting a number this caller invented over the one the contract chose.
563    pub fn first_page(parent_coin_id: impl Into<String>) -> Self {
564        Self {
565            parent_coin_id: parent_coin_id.into(),
566            after_coin_id: None,
567            limit: None,
568        }
569    }
570
571    /// The page size this request asks for, resolving `None` to [`COINS_BY_PARENT_DEFAULT_LIMIT`].
572    ///
573    /// Stated once here so a node and a client cannot resolve the same omitted field to two
574    /// different numbers — a disagreement that would show up as a page boundary in the wrong place,
575    /// which is exactly where a paged walk loses rows.
576    pub fn effective_limit(&self) -> u32 {
577        self.limit.unwrap_or(COINS_BY_PARENT_DEFAULT_LIMIT)
578    }
579
580    /// Normalize and check both ids and the page bound, or reject as `-32602 INVALID_PARAMS`.
581    ///
582    /// The ids follow the rule every by-coin read in this crate follows (lowercase 64-hex, `0x`
583    /// tolerated on input and never emitted).
584    ///
585    /// An out-of-range `limit` is REFUSED, never clamped. That is a deliberate departure from
586    /// `control.wallet.arrivals`, which lets a node clamp: this read's page boundary is what a
587    /// caller RESUMES from, so a silently shrunk page hands back a cursor for a position the caller
588    /// did not ask about, and a caller that believed its own number would mis-size every subsequent
589    /// request. Refusing keeps the caller's model of the page and the node's identical, which is the
590    /// same reason a 65-hex coin id is refused rather than truncated.
591    ///
592    /// `limit: 0` is refused for a separate reason: a page that can hold nothing makes no progress,
593    /// so a caller looping until a page comes back short would loop forever.
594    pub fn validated(self) -> Result<Self, crate::error::ControlError> {
595        fn invalid(message: &'static str) -> crate::error::ControlError {
596            crate::error::ControlError::of(crate::error::ControlErrorCode::InvalidParams, message)
597        }
598
599        let parent_coin_id = normalize_coin_id(&self.parent_coin_id)
600            .ok_or_else(|| invalid(PARENT_COIN_ID_ERROR))?
601            .to_owned();
602        let after_coin_id = self
603            .after_coin_id
604            .as_deref()
605            .map(|id| {
606                normalize_coin_id(id)
607                    .map(str::to_owned)
608                    .ok_or_else(|| invalid(AFTER_COIN_ID_ERROR))
609            })
610            .transpose()?;
611        if !self.limit.map_or(true, is_legal_page) {
612            return Err(invalid(COINS_BY_PARENT_LIMIT_ERROR));
613        }
614        Ok(WalletCoinsByParentParams {
615            parent_coin_id,
616            after_coin_id,
617            limit: self.limit,
618        })
619    }
620}
621
622/// `control.wallet.peak` params — none.
623///
624/// The peak is a property of the node's chain view, not of any address, which is exactly why it is
625/// its own method: [`results::WalletBalanceResult::peak_height`] is `null` on every fallback-tier
626/// answer, so a caller bounding a claimed confirmation cannot rely on getting one from a balance.
627#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
628pub struct WalletPeakParams {}
629control_call!(WalletPeakParams => ControlMethod::WalletPeak, results::WalletPeakResult);
630
631/// `control.peerCounts` params — none.
632///
633/// The counts describe this node's own connectivity on each network, so there is nothing to scope
634/// the question by. One call answers for BOTH networks deliberately: a consumer that had to collect
635/// the DIG count from a peer method and the Chia count from a wallet method is a consumer that can
636/// reach for the wrong one.
637#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
638pub struct PeerCountsParams {}
639control_call!(PeerCountsParams => ControlMethod::PeerCounts, results::PeerCountsResult);
640
641/// `control.wallet.syncStatus` params — none.
642///
643/// The wallet's sync progress is a property of the node's own chain replica, not of any address, so
644/// there is nothing to scope the question by. Deliberately NOT confused with `control.sync.status`,
645/// which reports §21 DIG STORE sync and has nothing to do with the chain.
646#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
647pub struct WalletSyncStatusParams {}
648control_call!(WalletSyncStatusParams => ControlMethod::WalletSyncStatus, results::WalletSyncStatusResult);
649
650/// `control.wallet.broadcast` params: an ALREADY-SIGNED spend bundle to push.
651///
652/// # The custody boundary (§908)
653///
654/// This carries signed bytes and nothing else. There is deliberately no key, no seed, no phrase and
655/// no unsigned-spend-plus-key field here, and there never may be: the node's role on the money path
656/// is to read chain state and to push what somebody else signed. A parameter that let the node
657/// produce a signature would move custody into an identity-agnostic daemon, which is the one thing
658/// the boundary exists to prevent.
659#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
660pub struct WalletBroadcastParams {
661    /// The signed spend bundle: lowercase hex of its chia `Streamable` serialization.
662    pub signed_bundle_hex: String,
663}
664control_call!(WalletBroadcastParams => ControlMethod::WalletBroadcast, results::WalletBroadcastResult);
665
666/// The length of a BLS G1 public key in lowercase hex characters: 48 bytes.
667const PUBLIC_KEY_HEX_LEN: usize = 96;
668
669/// `control.wallet.watch` params: which PUBLIC keys the node should follow.
670///
671/// # Keys, never puzzle hashes
672///
673/// The node already derives addresses from the public keys it holds in its own custody, through one
674/// standard derivation. Enrolling KEYS reuses that exact derivation for a client's keys too, so the
675/// ecosystem has ONE mapping from key to address and a client and a node can never disagree about
676/// which addresses a key covers. Enrolling puzzle hashes instead would make every client re-derive
677/// independently, and a client whose derivation window is narrower than the node's would silently
678/// under-report the money it owns.
679///
680/// # No key material crosses (§908)
681///
682/// A G1 public key is public. There is deliberately no seed, phrase, private key or signature field
683/// here, and there never may be: enrolment tells the node what to WATCH, and watching needs nothing
684/// a signature could be produced from.
685///
686/// # Idempotent, so a client can reconcile without asking first
687///
688/// Submitting keys the node already follows is a SUCCESS that changes nothing — see
689/// [`WalletWatchResult`](results::WalletWatchResult). Duplicates WITHIN one request count once. An
690/// empty list is accepted and does nothing, because "enrol everything in this (currently empty) set"
691/// is a reconciliation a client legitimately performs.
692#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
693pub struct WalletWatchParams {
694    /// The public keys to enrol: lowercase 96-hex, unprefixed. A `0x` prefix is TOLERATED on input
695    /// and normalized away by [`Self::validated`]; it is never emitted.
696    pub public_keys: Vec<String>,
697}
698control_call!(WalletWatchParams => ControlMethod::WalletWatch, results::WalletWatchResult);
699
700/// `control.wallet.unwatch` params: which PUBLIC keys the node should stop following.
701///
702/// Takes the same wire form as [`WalletWatchParams`] under the same field name, so a client
703/// reverses an enrolment by re-sending exactly what it sent. Deregistering a key that was never
704/// enrolled is a success, not an error — the mirror of enrolment's idempotence, and what lets a
705/// client assert an end state rather than compute a diff.
706#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
707pub struct WalletUnwatchParams {
708    /// The public keys to deregister: lowercase 96-hex, unprefixed. A `0x` prefix is TOLERATED on
709    /// input and normalized away by [`Self::validated`]; it is never emitted.
710    pub public_keys: Vec<String>,
711}
712control_call!(WalletUnwatchParams => ControlMethod::WalletUnwatch, results::WalletUnwatchResult);
713
714/// `control.wallet.watched` params — none.
715///
716/// The enrolled set is a property of the node, so there is nothing to scope the question by. This
717/// is why the method is TOKEN-GATED although it only reads: the caller supplies nothing, so the
718/// answer is the node's OWN key set — see [`ControlMethod::is_open_read`].
719#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
720pub struct WalletWatchedParams {}
721control_call!(WalletWatchedParams => ControlMethod::WalletWatched, results::WalletWatchedResult);
722
723fn normalize_public_key(public_key: &str) -> Option<&str> {
724    let normalized = public_key.strip_prefix("0x").unwrap_or(public_key);
725    let well_formed = normalized.len() == PUBLIC_KEY_HEX_LEN
726        && normalized
727            .bytes()
728            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
729    well_formed.then_some(normalized)
730}
731
732/// Give an enrolment params type its validating `Deserialize` and its `validated` constructor.
733///
734/// `watch` and `unwatch` carry the IDENTICAL key list under the identical field name, and must
735/// enforce the identical rule: a client reverses an enrolment by re-sending what it sent, so a
736/// spelling one method accepts and the other refuses would leave a key enrolled forever. Written
737/// once so the two cannot drift apart.
738macro_rules! public_keys_params {
739    ($ty:ident, $raw:ident, $error:expr) => {
740        impl<'de> Deserialize<'de> for $ty {
741            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
742            where
743                D: serde::Deserializer<'de>,
744            {
745                #[derive(Deserialize)]
746                struct $raw {
747                    public_keys: Vec<String>,
748                }
749
750                let raw = $raw::deserialize(deserializer)?;
751                let public_keys = raw
752                    .public_keys
753                    .iter()
754                    .map(|key| {
755                        normalize_public_key(key)
756                            .map(str::to_owned)
757                            .ok_or_else(|| serde::de::Error::custom($error))
758                    })
759                    .collect::<Result<Vec<_>, _>>()?;
760                Ok(Self { public_keys })
761            }
762        }
763
764        impl $ty {
765            /// Normalize and check every key, or reject the request as `-32602 INVALID_PARAMS`.
766            ///
767            /// The whole request is refused when ANY key is malformed — never the well-formed
768            /// subset. A partial enrolment would leave the node following fewer addresses than the
769            /// client believes it asked for, and the client's next balance read would report a
770            /// shortfall as though the money were not there. One bad key is a malformed REQUEST.
771            ///
772            /// Accepts exactly two spellings per key — 96 lowercase hex characters, or the same 96
773            /// preceded by `0x`. Uppercase, whitespace and every other length are refused, because
774            /// the contract's hex wire form is lowercase and unprefixed everywhere else.
775            pub fn validated(self) -> Result<Self, crate::error::ControlError> {
776                let public_keys = self
777                    .public_keys
778                    .iter()
779                    .map(|key| {
780                        normalize_public_key(key).map(str::to_owned).ok_or_else(|| {
781                            crate::error::ControlError::of(
782                                crate::error::ControlErrorCode::InvalidParams,
783                                $error,
784                            )
785                        })
786                    })
787                    .collect::<Result<Vec<_>, _>>()?;
788                Ok(Self { public_keys })
789            }
790        }
791    };
792}
793
794const PUBLIC_KEYS_ERROR: &str =
795    "public_keys must each be lowercase 96-hex (a 48-byte G1 key), optionally 0x-prefixed";
796
797public_keys_params!(WalletWatchParams, RawWalletWatch, PUBLIC_KEYS_ERROR);
798public_keys_params!(WalletUnwatchParams, RawWalletUnwatch, PUBLIC_KEYS_ERROR);
799
800/// `pairing.request` params (OPEN — no token).
801#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
802pub struct RequestParams {
803    /// A human-readable name for the requesting client (shown to the operator).
804    pub client_name: String,
805}
806control_call!(RequestParams => ControlMethod::PairingRequest, results::PairingRequestResult);
807
808/// `pairing.poll` params (OPEN — no token).
809#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
810pub struct PollParams {
811    /// The pairing id to poll.
812    pub pairing_id: String,
813}
814control_call!(PollParams => ControlMethod::PairingPoll, results::PairingPollResult);
815
816/// The largest dig-profile body the control plane accepts or returns, in bytes: **4 MiB**.
817///
818/// # Why the contract carries the cap instead of the implementation
819///
820/// A client that learns the bound by exceeding it learns it as a failed round trip, after paying to
821/// serialize and send megabytes. Stated here, an app checks before it sends and can tell a person
822/// *this profile is too large* rather than *something went wrong*.
823///
824/// # Why this number
825///
826/// It is HALF of dig-gossip's `WS_MAX_MESSAGE_BYTES` (8 MiB), the frame ceiling a body must fit
827/// inside when a node serves it to a peer over `PROFILE_BODY` (opcode 225). A body accepted here
828/// but unservable there would be stored and then permanently unsyncable — accepted by the node the
829/// app talks to and invisible to every other node. The halving leaves room for the framing,
830/// envelope and base64 expansion that sit around the bytes on that hop.
831///
832/// The cap is on the DECODED body, not on the base64 text that carries it.
833pub const MAX_BODY_BYTES: usize = 4 * 1024 * 1024;
834
835/// `control.profile.putBody` params: the profile body a confirmed chain root commits to.
836///
837/// # The node CHECKS the root; it does not take the caller's word for it
838///
839/// `root` is a CLAIM, and the node's obligation is to refuse it when it is false. An implementation
840/// MUST independently resolve the profile's root on chain, recompute the root of the supplied body,
841/// and reject the call unless the two agree and that root is CONFIRMED. dig-app is a caller like
842/// any other here: it holds the key and signs the root (§908), but the bytes it then hands over
843/// arrive at the node exactly as a peer's bytes do, and the standing rule for this epic is that a
844/// body is checked against the on-chain root and anything that does not match is rejected.
845///
846/// A method documented as *the caller supplies a matching root* invites an implementation that
847/// stores what it is given. That implementation turns the control plane into a way to make a node
848/// serve arbitrary bytes to the network under someone else's profile id.
849///
850/// # No key material crosses (§908)
851///
852/// There is deliberately no seed, private key, signature or unsigned spend field here, and there
853/// never may be. The node persists, serves and fetches bodies; it never signs.
854#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
855pub struct ProfilePutBodyParams {
856    /// The profile's store id: lowercase 64-hex, unprefixed.
857    pub store_id: String,
858    /// The root the body is claimed to hash to: lowercase 64-hex, unprefixed. Checked against the
859    /// chain, never trusted.
860    pub root: String,
861    /// The body itself, standard base64 (padded) of its `DPB` serialization. The DECODED length
862    /// MUST NOT exceed [`MAX_BODY_BYTES`]; a larger body is refused as `INVALID_PARAMS`.
863    pub body_b64: String,
864}
865control_call!(ProfilePutBodyParams => ControlMethod::ProfilePutBody, results::ProfilePutBodyResult);
866
867/// `control.profile.getBody` params: WHICH profile body to read, named by store id + root.
868///
869/// The root is part of the question rather than part of the answer: a caller asks for the body at a
870/// root it already knows, so it can never be handed a body for a DIFFERENT root and mistake it for
871/// the one it asked about. A node holding no body at that root answers `body_b64: null`.
872#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
873pub struct ProfileGetBodyParams {
874    /// The profile's store id: lowercase 64-hex, unprefixed.
875    pub store_id: String,
876    /// The root to read the body at: lowercase 64-hex, unprefixed.
877    pub root: String,
878}
879control_call!(ProfileGetBodyParams => ControlMethod::ProfileGetBody, results::ProfileGetBodyResult);
880
881#[cfg(test)]
882mod tests {
883    use super::*;
884    use crate::traits::build_request;
885    use serde_json::json;
886
887    #[test]
888    fn no_param_call_serializes_params_to_empty_object() {
889        let req = build_request(1.into(), &StatusParams {});
890        assert_eq!(req.method, "control.status");
891        assert_eq!(req.params, json!({}));
892    }
893
894    #[test]
895    fn data_param_call_carries_its_fields() {
896        let req = build_request(2.into(), &SetCapParams { cap_bytes: 128 });
897        assert_eq!(req.method, "control.cache.setCap");
898        assert_eq!(req.params, json!({ "cap_bytes": 128 }));
899    }
900
901    #[test]
902    fn pause_omits_until_when_indefinite() {
903        assert_eq!(
904            serde_json::to_value(PauseParams { until: None }).unwrap(),
905            json!({})
906        );
907        assert_eq!(
908            serde_json::to_value(PauseParams { until: Some(99) }).unwrap(),
909            json!({ "until": 99 })
910        );
911    }
912
913    #[test]
914    fn method_binding_matches_the_catalog_name() {
915        assert_eq!(
916            SetUpstreamParams::METHOD.name(),
917            "control.config.setUpstream"
918        );
919        assert_eq!(RequestParams::METHOD.name(), "pairing.request");
920        assert_eq!(PollParams::METHOD.name(), "pairing.poll");
921    }
922}