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.coinSpend` params: WHICH coin's spend to read, named by that coin's own id.
273///
274/// # Why the spend and the coin record are separate methods
275///
276/// [`WalletCoinByIdParams`] answers *what is this coin, and was it spent?* — an id, an amount, a
277/// puzzle HASH and two heights. None of that reveals what the spend DID. Reconstructing a lineage
278/// (which is how a dig-profile's DID singleton is followed forward) needs the puzzle REVEAL and the
279/// solution, and those exist only in the spend. A caller holding a coin record alone can see that a
280/// coin is gone and cannot see what it became.
281///
282/// # The id names the SPENT coin, not the spend
283///
284/// A spend has no id of its own on chain; it is identified by the coin it consumed. So the parameter
285/// is the same 64-hex coin id [`WalletCoinByIdParams`] takes, validated by the same rule, and the
286/// two methods are asked with the identical value.
287#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
288pub struct WalletCoinSpendParams {
289 /// The SPENT coin's id: lowercase 64-hex, unprefixed. A `0x` prefix is TOLERATED on input and
290 /// normalized away by [`Self::validated`]; it is never emitted.
291 pub coin_id: String,
292}
293control_call!(WalletCoinSpendParams => ControlMethod::WalletCoinSpend, results::WalletCoinSpendResult);
294
295/// `control.wallet.coinsByParent` params: WHICH coin's direct children to read.
296///
297/// # One hop, and the field name says so
298///
299/// The field is `parent_coin_id` rather than `coin_id` because the coin named here is the one being
300/// asked ABOUT as a parent — it is never the coin the caller wants back. A walk up or down a lineage
301/// is the CALLER's composition of repeated single hops; the node performs exactly one. Naming it
302/// `coin_id` would make a recursive reading of the method plausible from the request alone, and a
303/// caller expecting a whole lineage from one call would read a one-hop answer as a truncated chain.
304///
305/// # Bounded, because a parent's child count is not
306///
307/// This is the only OPEN wallet read whose answer has unbounded cardinality — every other one
308/// returns a single record (`coinById`, `peak`, `syncStatus`) or is already paged (`arrivals`). So
309/// the read is PAGED, and the page is bounded by [`COINS_BY_PARENT_MAX_LIMIT`].
310///
311/// **The bound is the ONLY thing bounding this call — there is no rate limiter anywhere behind it.**
312/// dig-node's control plane has no request rate limiting of any kind (dig_ecosystem#2577); the
313/// bandwidth limiter it does have governs content serving and is not on this path. A future reader
314/// weighing whether to relax this cap should assume no limiter exists, because none does.
315///
316/// That matters more than a local resource bound would, because the node does not necessarily answer
317/// from its own replica: on the fallback tier it forwards a caller-supplied identifier to a
318/// THIRD-PARTY coinset HTTPS oracle. An unbounded page is therefore unbounded work against somebody
319/// else's service, requested by a token-less caller on a loopback endpoint.
320///
321/// Paging rather than a bare cap, because a bare cap is a dead end: a parent with more children than
322/// the cap could never be fully enumerated, and this method exists to WALK a lineage. A walk that
323/// cannot see past the cap is a walk that silently stops.
324#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
325pub struct WalletCoinsByParentParams {
326 /// The PARENT coin's id: lowercase 64-hex, unprefixed. A `0x` prefix is TOLERATED on input and
327 /// normalized away by [`Self::validated`]; it is never emitted.
328 pub parent_coin_id: String,
329 /// Resume STRICTLY AFTER this child, in the read's
330 /// [documented order](results::WalletCoinsByParentResult). `None` starts at the first child.
331 ///
332 /// This is the value the previous page handed back as
333 /// [`cursor`](results::WalletCoinsByParentResult::cursor) — never a value the caller invented,
334 /// and never a marker for where the chain "got to". `control.wallet.arrivals` records why that
335 /// distinction loses rows; this read avoids the trap by having no such marker to reach for.
336 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub after_coin_id: Option<String>,
338 /// The page size. `None` asks for [`COINS_BY_PARENT_DEFAULT_LIMIT`].
339 ///
340 /// A value above [`COINS_BY_PARENT_MAX_LIMIT`], or a zero, is REFUSED as `INVALID_PARAMS` rather
341 /// than clamped — see [`Self::validated`].
342 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub limit: Option<u32>,
344}
345control_call!(WalletCoinsByParentParams => ControlMethod::WalletCoinsByParent, results::WalletCoinsByParentResult);
346
347/// `control.wallet.arrivals` — confirmed INCOMING funds recorded since a cursor position.
348///
349/// The answer to "was I just paid?", which neither a balance nor a coin list can give: a balance
350/// moves for the user's OWN change too, and an unspent-coin list cannot say which of its coins are
351/// new. Each row the node returns is a coin it determined ARRIVED — confirmed on chain, above the
352/// wallet's arrival baseline, not previously reported, and not created by spending one of the
353/// wallet's own coins. The determination is the NODE's; a client MUST NOT re-derive it, because the
354/// signals it takes (spent parents, the catch-up baseline) live only in the node's replica.
355///
356/// # A cursor, not a stream
357///
358/// The control envelope is strictly request→response, so this is polled. A client resumes from
359/// [`WalletArrivalsResult::cursor`](results::WalletArrivalsResult::cursor) — the last position it
360/// was actually handed — and NEVER from `latest`; see that field for why the distinction loses a
361/// notification when it is collapsed.
362///
363/// # `after_seq` is unsigned so a rewind is unexpressible
364///
365/// Positions are monotonic and start at 1, so there is no meaning for a negative one. `0` is the
366/// beginning of the ledger and is what a client sends when it deliberately wants everything.
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
368pub struct WalletArrivalsParams {
369 /// Return arrivals STRICTLY after this position. `0` starts at the beginning of the ledger,
370 /// and is also what an omitted field means — the same default the node applies.
371 #[serde(default)]
372 pub after_seq: u64,
373 /// The page size. `None` asks for the node's default rather than a number this client invented;
374 /// a node clamps whatever it is given to its own maximum.
375 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub limit: Option<u32>,
377}
378control_call!(WalletArrivalsParams => ControlMethod::WalletArrivals, results::WalletArrivalsResult);
379
380fn normalize_coin_id(coin_id: &str) -> Option<&str> {
381 let normalized = coin_id.strip_prefix("0x").unwrap_or(coin_id);
382 let well_formed = normalized.len() == COIN_ID_HEX_LEN
383 && normalized
384 .bytes()
385 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
386 well_formed.then_some(normalized)
387}
388
389/// Give a single-coin-id params type its validating `Deserialize` and its `validated` constructor.
390///
391/// The three by-coin reads (`coinById`, `coinSpend`, `coinsByParent`) enforce the IDENTICAL id rule
392/// under three different field names, and the rule is normative rather than incidental — see
393/// [`WalletCoinByIdParams::validated`] for why a malformed id must be refused BEFORE a chain is
394/// consulted. Written once here so a fourth by-coin read cannot arrive with a subtly looser copy of
395/// it, and so a change to the rule cannot land on two of three types.
396macro_rules! coin_id_params {
397 ($ty:ident, $field:ident, $raw:ident, $error:expr) => {
398 impl<'de> Deserialize<'de> for $ty {
399 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
400 where
401 D: serde::Deserializer<'de>,
402 {
403 #[derive(Deserialize)]
404 struct $raw {
405 $field: String,
406 }
407
408 let raw = $raw::deserialize(deserializer)?;
409 let $field = normalize_coin_id(&raw.$field)
410 .ok_or_else(|| serde::de::Error::custom($error))?
411 .to_owned();
412 Ok(Self { $field })
413 }
414 }
415
416 impl $ty {
417 /// Normalize and check the coin id, or reject the request as `-32602 INVALID_PARAMS`.
418 ///
419 /// A malformed id is a malformed REQUEST, and the node refuses it here — before
420 /// consulting any chain. That ordering is normative rather than an optimisation: were a
421 /// bad id allowed through, the read would come back empty, and the caller would be told
422 /// the honest-looking answer *the chain holds nothing* about a coin it never actually
423 /// asked after. An unanswerable question and a chain that answered "no" must never wear
424 /// the same shape.
425 ///
426 /// Accepts exactly two spellings — 64 lowercase hex characters, or the same 64 preceded
427 /// by `0x`. Uppercase, whitespace and every other length are refused, because the
428 /// contract's hex wire form is lowercase and unprefixed everywhere else in this crate.
429 pub fn validated(self) -> Result<Self, crate::error::ControlError> {
430 let normalized = normalize_coin_id(&self.$field).ok_or_else(|| {
431 crate::error::ControlError::of(
432 crate::error::ControlErrorCode::InvalidParams,
433 $error,
434 )
435 })?;
436 Ok($ty {
437 $field: normalized.to_owned(),
438 })
439 }
440 }
441 };
442}
443
444const COIN_ID_ERROR: &str = "coin_id must be lowercase 64-hex, optionally 0x-prefixed";
445const PARENT_COIN_ID_ERROR: &str =
446 "parent_coin_id must be lowercase 64-hex, optionally 0x-prefixed";
447const AFTER_COIN_ID_ERROR: &str = "after_coin_id must be lowercase 64-hex, optionally 0x-prefixed";
448
449coin_id_params!(
450 WalletCoinByIdParams,
451 coin_id,
452 RawWalletCoinByIdParams,
453 COIN_ID_ERROR
454);
455coin_id_params!(
456 WalletCoinSpendParams,
457 coin_id,
458 RawWalletCoinSpendParams,
459 COIN_ID_ERROR
460);
461/// The page size `control.wallet.coinsByParent` uses when the caller names none.
462///
463/// A spend in the lineages this read exists to follow — a singleton, a DID, an ordinary transfer —
464/// creates a small handful of children, so one default page covers a realistic hop in a single round
465/// trip and a caller never pages at all.
466pub const COINS_BY_PARENT_DEFAULT_LIMIT: u32 = 100;
467
468/// The largest page `control.wallet.coinsByParent` will accept, derived from the transport's own
469/// frame limit rather than chosen for feel.
470///
471/// dig-ipc-protocol caps a control frame at `MAX_FRAME_BYTES` = 1 MiB (its `SPEC.md` §
472/// bounds), and that is the hard ceiling every answer on this plane has to fit inside. A
473/// [`WalletCoinRecord`](results::WalletCoinRecord) is at most ~350 bytes of JSON — three 64-hex
474/// hashes at 66 bytes quoted, a 20-digit `u64`, two 10-digit heights, and their keys — so the
475/// arithmetic that fixes this number is:
476///
477/// ```text
478/// 1 MiB / 350 B ~= 2,996 records is where a page STOPS FITTING
479/// 1,000 records ~= 350 KB, roughly a third of the frame
480/// ```
481///
482/// The cap is set at a third of what fits, not at what fits, so the envelope, the freshness fields
483/// and any future additive member cannot push a legal page over the transport's limit. A larger
484/// value would put the contract's own maximum inside the region where a conforming node's honest
485/// answer is undeliverable — the failure would surface as a truncated frame, not as a refusal.
486pub const COINS_BY_PARENT_MAX_LIMIT: u32 = 1_000;
487
488const COINS_BY_PARENT_LIMIT_ERROR: &str = "limit must be between 1 and 1000";
489
490impl<'de> Deserialize<'de> for WalletCoinsByParentParams {
491 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
492 where
493 D: serde::Deserializer<'de>,
494 {
495 #[derive(Deserialize)]
496 struct RawWalletCoinsByParentParams {
497 parent_coin_id: String,
498 #[serde(default)]
499 after_coin_id: Option<String>,
500 #[serde(default)]
501 limit: Option<u32>,
502 }
503
504 let raw = RawWalletCoinsByParentParams::deserialize(deserializer)?;
505 let parent_coin_id = normalize_coin_id(&raw.parent_coin_id)
506 .ok_or_else(|| serde::de::Error::custom(PARENT_COIN_ID_ERROR))?
507 .to_owned();
508 let after_coin_id = raw
509 .after_coin_id
510 .map(|id| {
511 normalize_coin_id(&id)
512 .map(str::to_owned)
513 .ok_or_else(|| serde::de::Error::custom(AFTER_COIN_ID_ERROR))
514 })
515 .transpose()?;
516 if !raw.limit.map_or(true, is_legal_page) {
517 return Err(serde::de::Error::custom(COINS_BY_PARENT_LIMIT_ERROR));
518 }
519 Ok(Self {
520 parent_coin_id,
521 after_coin_id,
522 limit: raw.limit,
523 })
524 }
525}
526
527/// Is this a page size the contract accepts — at least one row, at most the frame-derived maximum?
528fn is_legal_page(limit: u32) -> bool {
529 (1..=COINS_BY_PARENT_MAX_LIMIT).contains(&limit)
530}
531
532impl WalletCoinsByParentParams {
533 /// A first page of children for one parent: the node's default size, starting at the beginning.
534 ///
535 /// The common case, and the one a caller should not have to spell out — naming a page size means
536 /// asserting a number this caller invented over the one the contract chose.
537 pub fn first_page(parent_coin_id: impl Into<String>) -> Self {
538 Self {
539 parent_coin_id: parent_coin_id.into(),
540 after_coin_id: None,
541 limit: None,
542 }
543 }
544
545 /// The page size this request asks for, resolving `None` to [`COINS_BY_PARENT_DEFAULT_LIMIT`].
546 ///
547 /// Stated once here so a node and a client cannot resolve the same omitted field to two
548 /// different numbers — a disagreement that would show up as a page boundary in the wrong place,
549 /// which is exactly where a paged walk loses rows.
550 pub fn effective_limit(&self) -> u32 {
551 self.limit.unwrap_or(COINS_BY_PARENT_DEFAULT_LIMIT)
552 }
553
554 /// Normalize and check both ids and the page bound, or reject as `-32602 INVALID_PARAMS`.
555 ///
556 /// The ids follow the rule every by-coin read in this crate follows (lowercase 64-hex, `0x`
557 /// tolerated on input and never emitted).
558 ///
559 /// An out-of-range `limit` is REFUSED, never clamped. That is a deliberate departure from
560 /// `control.wallet.arrivals`, which lets a node clamp: this read's page boundary is what a
561 /// caller RESUMES from, so a silently shrunk page hands back a cursor for a position the caller
562 /// did not ask about, and a caller that believed its own number would mis-size every subsequent
563 /// request. Refusing keeps the caller's model of the page and the node's identical, which is the
564 /// same reason a 65-hex coin id is refused rather than truncated.
565 ///
566 /// `limit: 0` is refused for a separate reason: a page that can hold nothing makes no progress,
567 /// so a caller looping until a page comes back short would loop forever.
568 pub fn validated(self) -> Result<Self, crate::error::ControlError> {
569 fn invalid(message: &'static str) -> crate::error::ControlError {
570 crate::error::ControlError::of(crate::error::ControlErrorCode::InvalidParams, message)
571 }
572
573 let parent_coin_id = normalize_coin_id(&self.parent_coin_id)
574 .ok_or_else(|| invalid(PARENT_COIN_ID_ERROR))?
575 .to_owned();
576 let after_coin_id = self
577 .after_coin_id
578 .as_deref()
579 .map(|id| {
580 normalize_coin_id(id)
581 .map(str::to_owned)
582 .ok_or_else(|| invalid(AFTER_COIN_ID_ERROR))
583 })
584 .transpose()?;
585 if !self.limit.map_or(true, is_legal_page) {
586 return Err(invalid(COINS_BY_PARENT_LIMIT_ERROR));
587 }
588 Ok(WalletCoinsByParentParams {
589 parent_coin_id,
590 after_coin_id,
591 limit: self.limit,
592 })
593 }
594}
595
596/// `control.wallet.peak` params — none.
597///
598/// The peak is a property of the node's chain view, not of any address, which is exactly why it is
599/// its own method: [`results::WalletBalanceResult::peak_height`] is `null` on every fallback-tier
600/// answer, so a caller bounding a claimed confirmation cannot rely on getting one from a balance.
601#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
602pub struct WalletPeakParams {}
603control_call!(WalletPeakParams => ControlMethod::WalletPeak, results::WalletPeakResult);
604
605/// `control.peerCounts` params — none.
606///
607/// The counts describe this node's own connectivity on each network, so there is nothing to scope
608/// the question by. One call answers for BOTH networks deliberately: a consumer that had to collect
609/// the DIG count from a peer method and the Chia count from a wallet method is a consumer that can
610/// reach for the wrong one.
611#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
612pub struct PeerCountsParams {}
613control_call!(PeerCountsParams => ControlMethod::PeerCounts, results::PeerCountsResult);
614
615/// `control.wallet.syncStatus` params — none.
616///
617/// The wallet's sync progress is a property of the node's own chain replica, not of any address, so
618/// there is nothing to scope the question by. Deliberately NOT confused with `control.sync.status`,
619/// which reports §21 DIG STORE sync and has nothing to do with the chain.
620#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
621pub struct WalletSyncStatusParams {}
622control_call!(WalletSyncStatusParams => ControlMethod::WalletSyncStatus, results::WalletSyncStatusResult);
623
624/// `control.wallet.broadcast` params: an ALREADY-SIGNED spend bundle to push.
625///
626/// # The custody boundary (§908)
627///
628/// This carries signed bytes and nothing else. There is deliberately no key, no seed, no phrase and
629/// no unsigned-spend-plus-key field here, and there never may be: the node's role on the money path
630/// is to read chain state and to push what somebody else signed. A parameter that let the node
631/// produce a signature would move custody into an identity-agnostic daemon, which is the one thing
632/// the boundary exists to prevent.
633#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
634pub struct WalletBroadcastParams {
635 /// The signed spend bundle: lowercase hex of its chia `Streamable` serialization.
636 pub signed_bundle_hex: String,
637}
638control_call!(WalletBroadcastParams => ControlMethod::WalletBroadcast, results::WalletBroadcastResult);
639
640/// `pairing.request` params (OPEN — no token).
641#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
642pub struct RequestParams {
643 /// A human-readable name for the requesting client (shown to the operator).
644 pub client_name: String,
645}
646control_call!(RequestParams => ControlMethod::PairingRequest, results::PairingRequestResult);
647
648/// `pairing.poll` params (OPEN — no token).
649#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
650pub struct PollParams {
651 /// The pairing id to poll.
652 pub pairing_id: String,
653}
654control_call!(PollParams => ControlMethod::PairingPoll, results::PairingPollResult);
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659 use crate::traits::build_request;
660 use serde_json::json;
661
662 #[test]
663 fn no_param_call_serializes_params_to_empty_object() {
664 let req = build_request(1.into(), &StatusParams {});
665 assert_eq!(req.method, "control.status");
666 assert_eq!(req.params, json!({}));
667 }
668
669 #[test]
670 fn data_param_call_carries_its_fields() {
671 let req = build_request(2.into(), &SetCapParams { cap_bytes: 128 });
672 assert_eq!(req.method, "control.cache.setCap");
673 assert_eq!(req.params, json!({ "cap_bytes": 128 }));
674 }
675
676 #[test]
677 fn pause_omits_until_when_indefinite() {
678 assert_eq!(
679 serde_json::to_value(PauseParams { until: None }).unwrap(),
680 json!({})
681 );
682 assert_eq!(
683 serde_json::to_value(PauseParams { until: Some(99) }).unwrap(),
684 json!({ "until": 99 })
685 );
686 }
687
688 #[test]
689 fn method_binding_matches_the_catalog_name() {
690 assert_eq!(
691 SetUpstreamParams::METHOD.name(),
692 "control.config.setUpstream"
693 );
694 assert_eq!(RequestParams::METHOD.name(), "pairing.request");
695 assert_eq!(PollParams::METHOD.name(), "pairing.poll");
696 }
697}