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::error::{ControlError, ControlErrorCode};
12use crate::method::ControlMethod;
13use crate::results;
14use crate::traits::ControlCall;
15
16/// Bind a params type to its wire method + typed result.
17macro_rules! control_call {
18    ($ty:ty => $method:expr, $out:ty) => {
19        impl ControlCall for $ty {
20            const METHOD: ControlMethod = $method;
21            type Output = $out;
22        }
23    };
24}
25
26/// Define a no-param call: an empty params struct (serializes to `{}`) bound to its method + result.
27macro_rules! no_params {
28    ($(#[$doc:meta])* $name:ident => $method:expr, $out:ty) => {
29        $(#[$doc])*
30        #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
31        pub struct $name {}
32        control_call!($name => $method, $out);
33    };
34}
35
36no_params!(
37    /// `control.status` params (none).
38    StatusParams => ControlMethod::Status, results::StatusResult
39);
40no_params!(
41    /// `control.config.get` params (none).
42    ConfigGetParams => ControlMethod::ConfigGet, results::ConfigResult
43);
44no_params!(
45    /// `control.cache.get` params (none).
46    CacheGetParams => ControlMethod::CacheGet, results::CacheView
47);
48no_params!(
49    /// `control.cache.clear` params (none).
50    CacheClearParams => ControlMethod::CacheClear, results::CacheClearResult
51);
52no_params!(
53    /// `control.hostedStores.list` params (none).
54    HostedStoresListParams => ControlMethod::HostedStoresList, results::HostedStoresListResult
55);
56no_params!(
57    /// `control.sync.status` params (none).
58    SyncStatusParams => ControlMethod::SyncStatus, results::SyncStatusResult
59);
60no_params!(
61    /// `control.updater.status` params (none). Result is the proxied beacon status.
62    UpdaterStatusParams => ControlMethod::UpdaterStatus, serde_json::Value
63);
64no_params!(
65    /// `control.updater.resume` params (none).
66    UpdaterResumeParams => ControlMethod::UpdaterResume, serde_json::Value
67);
68no_params!(
69    /// `control.updater.checkNow` params (none).
70    UpdaterCheckNowParams => ControlMethod::UpdaterCheckNow, serde_json::Value
71);
72no_params!(
73    /// `control.pairing.list` params (none). Result is the pending + issued-token list.
74    PairingListParams => ControlMethod::PairingList, serde_json::Value
75);
76no_params!(
77    /// `control.peerStatus` params (none). Result is the peer-pool snapshot.
78    PeerStatusParams => ControlMethod::PeerStatus, serde_json::Value
79);
80no_params!(
81    /// `control.listSubscriptions` params (none).
82    ListSubscriptionsParams => ControlMethod::ListSubscriptions, results::ListSubscriptionsResult
83);
84
85/// `control.config.setUpstream` params.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct SetUpstreamParams {
88    /// The upstream DIG RPC URL to persist (blank clears the override).
89    pub upstream: String,
90}
91control_call!(SetUpstreamParams => ControlMethod::ConfigSetUpstream, results::SetUpstreamResult);
92
93/// Whether `entry` is an absolute URL with both a scheme and a host.
94///
95/// The ONE check [`SetMirrorAdvertiseUrlsParams::validated`] performs — dig-node#562's own
96/// `Rejection::NotAbsolute` criterion, restated here rather than imported (`dig-node-service` sits
97/// above this crate in the dependency hierarchy, so this crate cannot depend on it, CLAUDE.md
98/// Appendix B).
99///
100/// Deliberately NOT a routability or "this machine only" check. dig-node#562 established that an
101/// operator's LAN or private address is a legitimate, deliberate choice risking only their own
102/// stake, while the SAME address derived automatically is a broken reading of this node's own
103/// position — the two paths apply different rules to identical bytes. This contract cannot tell
104/// which path a byte string will take (that is dig-node's own `classify`'s job, applied when it
105/// turns this override into what it actually publishes), so it must not apply the stricter rule to
106/// both. A well-meaning validator here would look like a safety improvement and would silently
107/// break every LAN-only deployment instead.
108fn is_well_formed_advertise_url(entry: &str) -> bool {
109    url::Url::parse(entry)
110        .ok()
111        .is_some_and(|parsed| parsed.host().is_some())
112}
113
114/// `control.config.setMirrorAdvertiseUrls` params.
115///
116/// # The three states this ONE optional field carries, and why an empty list is refused
117///
118/// - `None` (absent, or JSON `null`) CLEARS the override, reverting to dig-node#562's DERIVED
119///   default — this node's own discovered public address.
120/// - `Some(urls)` with at least one entry SETS the override to exactly those URLs.
121/// - `Some(vec![])` — an EXPLICIT empty list — is REFUSED as `-32602 INVALID_PARAMS` rather than
122///   silently taken to mean either of the above. It is genuinely ambiguous between "advertise
123///   nothing" (forfeiting every mirror reward this node could otherwise earn this epoch) and "go
124///   back to automatic" — and guessing wrong either forfeits money the operator did not intend to
125///   give up, or silently overrides a deliberate choice to stop advertising. See [`Self::validated`].
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct SetMirrorAdvertiseUrlsParams {
128    /// The URLs to advertise, or `None` to clear the override and revert to the derived default.
129    /// See the type doc for why `Some(vec![])` is refused rather than accepted as either meaning.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub urls: Option<Vec<String>>,
132}
133
134impl SetMirrorAdvertiseUrlsParams {
135    /// Refuse an explicit empty list, and refuse any entry that is not a well-formed absolute URL,
136    /// both as `-32602 INVALID_PARAMS`. `None` and a non-empty list of well-formed URLs both pass
137    /// through unchanged.
138    ///
139    /// Refused rather than guessed at, for the same reason [`CollateralMarginSetParams::validated`]
140    /// refuses rather than clamps: a caller is stating an intent right now, and silently applying a
141    /// different meaning than the one requested would make a subsequent
142    /// [`MirrorAdvertiseView`](results::MirrorAdvertiseView) disagree with what the caller believes
143    /// it set — on a surface that gates real mirror-coin rewards.
144    pub fn validated(self) -> Result<Self, ControlError> {
145        fn invalid(message: impl Into<String>) -> ControlError {
146            ControlError::of(ControlErrorCode::InvalidParams, message)
147        }
148
149        match &self.urls {
150            Some(urls) if urls.is_empty() => Err(invalid(
151                "urls must be omitted/null to clear the override, or a NON-EMPTY list to set \
152                 one -- an explicit empty list is ambiguous between the two and is refused",
153            )),
154            Some(urls) => {
155                if let Some(bad) = urls.iter().find(|u| !is_well_formed_advertise_url(u)) {
156                    return Err(invalid(format!(
157                        "{bad:?} is not a well-formed absolute URL (a scheme and a host are required)"
158                    )));
159                }
160                Ok(self)
161            }
162            None => Ok(self),
163        }
164    }
165}
166control_call!(SetMirrorAdvertiseUrlsParams => ControlMethod::ConfigSetMirrorAdvertiseUrls, results::SetMirrorAdvertiseUrlsResult);
167
168/// `control.log.setLevel` params.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct SetLevelParams {
171    /// An `EnvFilter` directive, e.g. `"debug"` or `"info,dig_node_core=debug"`.
172    pub filter: String,
173}
174control_call!(SetLevelParams => ControlMethod::LogSetLevel, results::SetLevelResult);
175
176/// `control.cache.setCap` params.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct SetCapParams {
179    /// The cache size cap in bytes (floored at 64 MiB by the node).
180    pub cap_bytes: u64,
181}
182control_call!(SetCapParams => ControlMethod::CacheSetCap, results::SetCapResult);
183
184/// `control.hostedStores.pin` params.
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186pub struct PinParams {
187    /// A store reference: `storeId` or `storeId:rootHash`.
188    pub store: String,
189}
190control_call!(PinParams => ControlMethod::HostedStoresPin, results::PinResult);
191
192/// `control.hostedStores.unpin` params.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub struct UnpinParams {
195    /// A store reference: `storeId` or `storeId:rootHash`.
196    pub store: String,
197}
198control_call!(UnpinParams => ControlMethod::HostedStoresUnpin, results::UnpinResult);
199
200/// `control.hostedStores.status` params.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct HostedStoreStatusParams {
203    /// A store reference: `storeId` or `storeId:rootHash`.
204    pub store: String,
205}
206control_call!(HostedStoreStatusParams => ControlMethod::HostedStoresStatus, results::HostedStoreStatusResult);
207
208/// `control.capsule.fetch` params: WHICH capsule to pull, named by store id + root — the same
209/// two-field shape as [`ProfileGetBodyParams`], not the colon-joined `store` string
210/// [`PinParams`]/[`SyncTriggerParams`] use, because the caller always knows both parts already
211/// (there is no store-level-only fetch).
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213pub struct CapsuleFetchParams {
214    /// The store id to fetch a capsule of.
215    pub store: String,
216    /// The capsule root to fetch.
217    pub root: String,
218}
219control_call!(CapsuleFetchParams => ControlMethod::CapsuleFetch, results::CapsuleFetchResult);
220
221/// `control.sync.trigger` params.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223pub struct SyncTriggerParams {
224    /// A capsule reference: `storeId:rootHash` (a concrete root is required).
225    pub store: String,
226}
227control_call!(SyncTriggerParams => ControlMethod::SyncTrigger, results::SyncTriggerResult);
228
229/// `control.updater.setChannel` params.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct SetChannelParams {
232    /// The update channel (`"nightly"` | `"stable"`; the beacon CLI is the sole validator).
233    pub channel: String,
234}
235control_call!(SetChannelParams => ControlMethod::UpdaterSetChannel, serde_json::Value);
236
237/// `control.updater.pause` params.
238#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
239pub struct PauseParams {
240    /// The unix-seconds time to pause until; omit to pause indefinitely.
241    #[serde(skip_serializing_if = "Option::is_none", default)]
242    pub until: Option<u64>,
243}
244control_call!(PauseParams => ControlMethod::UpdaterPause, serde_json::Value);
245
246/// `control.pairing.approve` params.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248pub struct ApproveParams {
249    /// The pending pairing's id (from `pairing.request`).
250    pub pairing_id: String,
251}
252control_call!(ApproveParams => ControlMethod::PairingApprove, results::PairingApproveResult);
253
254/// `control.pairing.revoke` params.
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256pub struct RevokeParams {
257    /// The short id of the paired token to revoke.
258    pub token_id: String,
259}
260control_call!(RevokeParams => ControlMethod::PairingRevoke, results::PairingRevokeResult);
261
262/// `control.peers.connect` params.
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub struct PeersConnectParams {
265    /// A peer address to dial, or an already-connected peer_id to resolve.
266    pub peer: String,
267}
268control_call!(PeersConnectParams => ControlMethod::PeersConnect, results::PeersConnectResult);
269
270/// `control.peers.disconnect` params.
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272pub struct PeersDisconnectParams {
273    /// The peer_id to drop.
274    pub peer: String,
275}
276control_call!(PeersDisconnectParams => ControlMethod::PeersDisconnect, results::PeersDisconnectResult);
277
278/// The CANONICAL textual form of a Chia peer address, or an `INVALID_PARAMS` error.
279///
280/// Every `ip` this crate declares — in `control.chiaPeers.add` / `.remove` params and in every
281/// result that echoes one — is a bare IP literal in this form. A conforming node MUST canonicalise
282/// through this function on the way IN and store the result, so `add`, `remove` and `list` all
283/// spell the same peer the same way.
284///
285/// **Why the contract owns this rather than each implementation.** `remove` is the only way to
286/// un-trust a peer that is believed WITHOUT corroboration, and it matches by address. If the form
287/// is left to the implementation, an operator who adds `2001:db8::1` and removes `2001:DB8:0:0::1`
288/// has named the same peer twice and un-trusted nothing — an un-trust that silently does not
289/// happen. Canonicalising is what makes the two spellings one key.
290///
291/// The rules, normatively:
292///
293/// - the value MUST parse as an IPv4 or IPv6 literal ([`std::net::IpAddr`]). A hostname, an empty
294///   string, an `ip:port`, a CIDR block or a bracketed `[..]` form is REJECTED. Rejecting
295///   non-literals is also what bounds the ban list: `remove {ban: true}` persists a row keyed by
296///   this string, so an unvalidated key is unbounded at-rest growth driven by one small call;
297/// - surrounding whitespace is trimmed before parsing, and nothing else is;
298/// - the canonical rendering is [`std::net::IpAddr`]'s own `Display` — dotted-quad for v4, and for
299///   v6 the RFC 5952 lowercase, maximally-compressed form, WITHOUT brackets and WITHOUT a zone id.
300///
301/// Bracketing belongs to the socket-address form, never to this field: see
302/// [`chia_peer_endpoint`], which is the ONLY sanctioned way to join an `ip` to a port.
303///
304/// ```
305/// use dig_node_control_interface::params::canonical_peer_ip;
306/// assert_eq!(canonical_peer_ip(" 2001:0DB8:0000::1 ").unwrap(), "2001:db8::1");
307/// assert!(canonical_peer_ip("[::1]").is_err());
308/// assert!(canonical_peer_ip("node.example.com").is_err());
309/// ```
310pub fn canonical_peer_ip(raw: &str) -> Result<String, ControlError> {
311    raw.trim()
312        .parse::<std::net::IpAddr>()
313        .map(|ip| ip.to_string())
314        .map_err(|_| {
315            ControlError::of(
316                ControlErrorCode::InvalidParams,
317                format!(
318                    "ip must be a bare IPv4 or IPv6 literal (no brackets, no port, no hostname), got: {raw:?}"
319                ),
320            )
321        })
322}
323
324/// Join a canonical peer `ip` to a port, bracketing IPv6 as RFC 3986 requires.
325///
326/// The one place a peer address and a port are ever concatenated. Formatting `"{ip}:{port}"` by
327/// hand is the bug this exists to prevent: `::1` and `8444` render as `::1:8444`, which is not a
328/// malformed string a parser rejects — it is a DIFFERENT valid IPv6 address, so the mistake
329/// survives validation and silently retargets the peer.
330///
331/// ```
332/// use dig_node_control_interface::params::chia_peer_endpoint;
333/// assert_eq!(chia_peer_endpoint("::1", 8444), "[::1]:8444");
334/// assert_eq!(chia_peer_endpoint("203.0.113.7", 8444), "203.0.113.7:8444");
335/// ```
336pub fn chia_peer_endpoint(ip: &str, port: u16) -> String {
337    match ip.trim().parse::<std::net::IpAddr>() {
338        Ok(std::net::IpAddr::V6(v6)) => format!("[{v6}]:{port}"),
339        Ok(std::net::IpAddr::V4(v4)) => format!("{v4}:{port}"),
340        Err(_) => format!("{ip}:{port}"),
341    }
342}
343
344/// The maximum number of BANNED Chia peers a conforming node persists.
345///
346/// A ban is a row written at the request of one small control call and kept across restarts, so
347/// without a ceiling the blocklist is unbounded at-rest state a caller can grow for free. On
348/// overflow a node MUST evict its OLDEST ban rather than refuse the newest — a bounded list that
349/// forgets is recoverable, and a full one that refuses turns the ceiling into a denial of the ban
350/// facility itself.
351///
352/// The value is deliberately generous against the honest use (a handful of misbehaving peers) and
353/// small against the abusive one. Banned entries are enumerable through `control.chiaPeers.list`
354/// and clearable through `control.chiaPeers.remove` with `ban: false`.
355pub const MAX_BANNED_CHIA_PEERS: usize = 256;
356
357/// `control.chiaPeers.add` params — the Chia full node to start TRUSTING.
358///
359/// Trust is the whole point of this call and it is not free: a trusted peer is exempted from the
360/// corroboration this node otherwise requires (NC-12 — dialled peers are untrusted, and agreement
361/// across several concurrently-queried peers is what makes a chain read safe). An implementation
362/// MUST tell the person that before it writes the entry.
363///
364/// The trust NC-12 authorises is the operator declaring a node THEIR OWN. That is what justifies
365/// the unbounded authority the entry carries, and it does not extend to a node somebody else runs.
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367pub struct ChiaPeersAddParams {
368    /// The peer's IP address, canonical per [`canonical_peer_ip`] — a bare literal, no brackets,
369    /// no port. The standard full-node port is assumed (the Sage `add_peer` request shape carries
370    /// no port either).
371    pub ip: String,
372}
373control_call!(ChiaPeersAddParams => ControlMethod::ChiaPeersAdd, results::ChiaPeersAddResult);
374
375/// `control.chiaPeers.remove` params — the Chia full node to stop trusting.
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
377pub struct ChiaPeersRemoveParams {
378    /// The peer's IP address, canonical per [`canonical_peer_ip`]. Matching is by that canonical
379    /// form, so an address spelled differently from the stored entry still names the same peer.
380    pub ip: String,
381    /// Ban rather than forget: the peer is kept but excluded, so discovery cannot re-add it.
382    /// Absent means `false`, which merely forgets the entry.
383    ///
384    /// A ban is EXACT-match on this one address — never a subnet — and it is local to this node,
385    /// with no effect on any other node's peer set. It persists until removed, bounded by
386    /// [`MAX_BANNED_CHIA_PEERS`], is enumerated by `control.chiaPeers.list` (`banned: true`), and
387    /// is cleared by calling `remove` again with `ban: false`. **Clearing a ban that way grants no
388    /// trust**: `add` also un-bans, but `add` confers the corroboration bypass, so it must not be
389    /// the only route back — an over-broad ban is not a reason to trust the peer it hit.
390    ///
391    /// Every banned peer is one fewer source of agreement. NC-12 rests on several independently
392    /// chosen peers agreeing, so a caller that bans steadily shrinks the honest pool toward the
393    /// peers it chose; that is why banning is on the master-token tier
394    /// ([`ControlMethod::requires_master_token`]) and why the set is bounded and visible.
395    #[serde(default)]
396    pub ban: bool,
397}
398control_call!(ChiaPeersRemoveParams => ControlMethod::ChiaPeersRemove, results::ChiaPeersRemoveResult);
399
400/// WHAT a subscription follows: ordinary content, or a dig-profile.
401///
402/// Serializes to a lowercase, language-neutral wire token (`"capsule"` / `"profile"`).
403///
404/// # Absent means [`Capsule`](SubscriptionKind::Capsule), and that is a contract, not a convenience
405///
406/// Every subscription written before this field existed is untagged, and a node's
407/// `subscriptions.json` on a real machine is FULL of them. A required `kind` would make those rows
408/// fail to deserialize, and a node that cannot read its own subscription file starts with an empty
409/// one — an upgrade that silently unsubscribes a user from everything they had. So the field is
410/// `#[serde(default)]` everywhere it appears, and the default is the meaning those untagged rows
411/// already had: a capsule.
412#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
413#[serde(rename_all = "lowercase")]
414pub enum SubscriptionKind {
415    /// Ordinary store content — the meaning every untagged subscription already carries.
416    #[default]
417    Capsule,
418    /// A dig-profile: the node additionally follows the profile ROOT and syncs its body from peers.
419    Profile,
420}
421
422/// `control.subscribe` params.
423#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
424pub struct SubscribeParams {
425    /// The store id to subscribe to.
426    pub store_id: String,
427    /// What the subscription follows. OMITTED means [`SubscriptionKind::Capsule`], so an older
428    /// client's request and an untagged on-disk row both keep their existing meaning.
429    #[serde(default)]
430    pub kind: SubscriptionKind,
431}
432control_call!(SubscribeParams => ControlMethod::Subscribe, results::SubscribeResult);
433
434/// `control.unsubscribe` params.
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436pub struct UnsubscribeParams {
437    /// The store id to stop watching.
438    pub store_id: String,
439}
440control_call!(UnsubscribeParams => ControlMethod::Unsubscribe, results::UnsubscribeResult);
441
442/// The length of a CAT asset id (TAIL hash) in hex characters: a 32-byte hash.
443pub const ASSET_ID_HEX_LEN: usize = 64;
444
445/// A CAT asset id — the TAIL hash that names one token on the Chia chain.
446///
447/// Stored as the 32 raw bytes rather than a `String` so two spellings of the same id (uppercase,
448/// `0x`-prefixed) cannot compare unequal. Parsing normalizes; emission is always lowercase and
449/// unprefixed.
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
451pub struct AssetId([u8; 32]);
452
453/// Why an asset id could not be parsed. Deliberately small — an id is either 32 bytes of hex or it
454/// is not an id, and guessing at a near-miss is how a wallet ends up reading the wrong token.
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum AssetIdParseError {
457    /// Not exactly [`ASSET_ID_HEX_LEN`] hex characters (after an optional `0x` is stripped).
458    WrongLength {
459        /// The length actually supplied.
460        got: usize,
461    },
462    /// A character outside `[0-9a-fA-F]`.
463    NotHex,
464}
465
466impl core::fmt::Display for AssetIdParseError {
467    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
468        match self {
469            Self::WrongLength { got } => write!(
470                f,
471                "asset id must be {ASSET_ID_HEX_LEN} hex characters, got {got}"
472            ),
473            Self::NotHex => f.write_str("asset id contains a non-hexadecimal character"),
474        }
475    }
476}
477
478impl std::error::Error for AssetIdParseError {}
479
480impl AssetId {
481    /// Wrap 32 already-decoded bytes. `const` so canonical ids can be declared as constants.
482    pub const fn new(bytes: [u8; 32]) -> Self {
483        Self(bytes)
484    }
485
486    /// The raw 32 bytes.
487    pub const fn as_bytes(&self) -> &[u8; 32] {
488        &self.0
489    }
490
491    /// Parse a hex asset id. A `0x` prefix and uppercase digits are TOLERATED on input — both are
492    /// what a block explorer prints — and normalized away; neither is ever emitted.
493    pub fn from_hex(hex: &str) -> Result<Self, AssetIdParseError> {
494        let body = hex.strip_prefix("0x").unwrap_or(hex);
495        if body.len() != ASSET_ID_HEX_LEN {
496            return Err(AssetIdParseError::WrongLength { got: body.len() });
497        }
498        let mut bytes = [0u8; 32];
499        for (byte, pair) in bytes.iter_mut().zip(body.as_bytes().chunks_exact(2)) {
500            let hi = decode_hex_digit(pair[0])?;
501            let lo = decode_hex_digit(pair[1])?;
502            *byte = (hi << 4) | lo;
503        }
504        Ok(Self(bytes))
505    }
506
507    /// The canonical wire spelling: lowercase, unprefixed, [`ASSET_ID_HEX_LEN`] characters.
508    pub fn to_hex(&self) -> String {
509        use core::fmt::Write as _;
510        self.0
511            .iter()
512            .fold(String::with_capacity(ASSET_ID_HEX_LEN), |mut acc, byte| {
513                let _ = write!(acc, "{byte:02x}");
514                acc
515            })
516    }
517}
518
519/// Decode one ASCII hex digit into its nibble value.
520fn decode_hex_digit(c: u8) -> Result<u8, AssetIdParseError> {
521    match c {
522        b'0'..=b'9' => Ok(c - b'0'),
523        b'a'..=b'f' => Ok(c - b'a' + 10),
524        b'A'..=b'F' => Ok(c - b'A' + 10),
525        _ => Err(AssetIdParseError::NotHex),
526    }
527}
528
529impl core::fmt::Display for AssetId {
530    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
531        f.write_str(&self.to_hex())
532    }
533}
534
535/// The asset a wallet balance/coin read is denominated in: native XCH, or any CAT by asset id.
536///
537/// # Why there is no separate `Dig` variant
538///
539/// $DIG is a CAT, so it is [`Asset::DIG`] — an associated constant, not a variant. A three-variant
540/// `{Xch, Dig, Cat(id)}` would give $DIG two INEQUAL spellings, and a balance or coin list filtered
541/// by one of them would silently omit everything carrying the other: a wallet reporting half a
542/// balance as though it were the whole. One token, one value.
543///
544/// # Wire form (additive — CLAUDE.md §5.1)
545///
546/// | Value | JSON |
547/// |---|---|
548/// | [`Asset::Xch`] | `"xch"` |
549/// | [`Asset::DIG`] | `"dig"` |
550/// | any other CAT | `{"cat":"<64-hex>"}` |
551///
552/// Both legacy tokens are still ACCEPTED and `"dig"` is still EMITTED, so a node or client built
553/// before this release keeps understanding, and being understood by, one built after it.
554/// `{"cat":"<the $DIG asset id>"}` is also accepted and normalizes to [`Asset::DIG`].
555///
556/// Byte-identical to dig-app's consumer type (`dig-app-core::wallet::state::Asset`), recorded in the
557/// `canonical` skill so the two implementations cannot drift.
558#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
559pub enum Asset {
560    /// Native Chia (XCH), denominated in mojos.
561    Xch,
562    /// A CAT, named by its asset id and denominated in its own base units.
563    Cat(AssetId),
564}
565
566/// The legacy wire token for native Chia.
567const XCH_TOKEN: &str = "xch";
568/// The legacy wire token for $DIG, still emitted so older peers keep understanding this crate.
569const DIG_TOKEN: &str = "dig";
570
571impl Asset {
572    /// Canonical $DIG CAT asset id (TAIL hash) on Chia mainnet, as lowercase hex.
573    ///
574    /// CONTRACT: byte-identical to `dig_constants::DIG_ASSET_ID`, `chip35_dl_coin::DIG_ASSET_ID`,
575    /// and digstore-chain's. It is duplicated here rather than imported because this crate is a
576    /// level-00 foundation crate and may not depend sideways on `dig-constants` (CLAUDE.md
577    /// Appendix B). `dig_asset_id_hex_and_bytes_are_the_same_id` pins THIS crate's two spellings
578    /// of it together; agreement with the ecosystem constant is a source-of-truth obligation on
579    /// whoever changes it, recorded in the `canonical` skill.
580    pub const DIG_ASSET_ID_HEX: &'static str =
581        "a406d3a9de984d03c9591c10d917593b434d5263cabe2b42f6b367df16832f81";
582
583    /// $DIG, the CAT every capsule payment is denominated in.
584    pub const DIG: Asset = Asset::Cat(AssetId::new([
585        0xa4, 0x06, 0xd3, 0xa9, 0xde, 0x98, 0x4d, 0x03, 0xc9, 0x59, 0x1c, 0x10, 0xd9, 0x17, 0x59,
586        0x3b, 0x43, 0x4d, 0x52, 0x63, 0xca, 0xbe, 0x2b, 0x42, 0xf6, 0xb3, 0x67, 0xdf, 0x16, 0x83,
587        0x2f, 0x81,
588    ]));
589
590    /// Whether this asset is $DIG, however it was spelled on the wire.
591    pub fn is_dig(&self) -> bool {
592        *self == Self::DIG
593    }
594
595    /// The CAT asset id, or `None` for native XCH.
596    pub fn asset_id(&self) -> Option<&AssetId> {
597        match self {
598            Self::Xch => None,
599            Self::Cat(id) => Some(id),
600        }
601    }
602}
603
604impl Serialize for Asset {
605    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
606        match self {
607            Self::Xch => serializer.serialize_str(XCH_TOKEN),
608            // $DIG keeps its legacy token: a node built before the widening understands only the
609            // two bare strings, and emitting the tagged form for it would break that direction.
610            other if other.is_dig() => serializer.serialize_str(DIG_TOKEN),
611            Self::Cat(id) => {
612                use serde::ser::SerializeMap as _;
613                let mut map = serializer.serialize_map(Some(1))?;
614                map.serialize_entry("cat", &id.to_hex())?;
615                map.end()
616            }
617        }
618    }
619}
620
621impl<'de> Deserialize<'de> for Asset {
622    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
623        /// The two accepted wire shapes, kept private so the tagged form is the ONLY map that
624        /// parses — an unknown key must be an error, never a silently ignored asset name.
625        #[derive(Deserialize)]
626        #[serde(untagged, deny_unknown_fields)]
627        enum Wire {
628            Token(String),
629            Tagged { cat: String },
630        }
631
632        match Wire::deserialize(deserializer).map_err(|_| {
633            serde::de::Error::custom(
634                "expected \"xch\", \"dig\", or {\"cat\":\"<64-hex asset id>\"}",
635            )
636        })? {
637            Wire::Token(token) if token == XCH_TOKEN => Ok(Self::Xch),
638            Wire::Token(token) if token == DIG_TOKEN => Ok(Self::DIG),
639            Wire::Token(token) => Err(serde::de::Error::custom(format!(
640                "unknown asset {token:?}: expected \"xch\", \"dig\", or {{\"cat\":\"<64-hex asset id>\"}}"
641            ))),
642            Wire::Tagged { cat } => AssetId::from_hex(&cat)
643                .map(Self::Cat)
644                .map_err(serde::de::Error::custom),
645        }
646    }
647}
648
649/// `control.wallet.balance` params: which address + asset to read the balance of.
650///
651/// A READ over the loopback control plane — never a spend. Field names + the [`Asset`] wire form are
652/// byte-identical to dig-app's frozen `BalanceRequest`, so the node reads exactly what dig-app emits.
653#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
654pub struct WalletBalanceParams {
655    /// The `xch1…` address to read the balance of.
656    pub address: String,
657    /// The asset to read the balance for.
658    pub asset: Asset,
659}
660control_call!(WalletBalanceParams => ControlMethod::WalletBalance, results::WalletBalanceResult);
661
662/// `control.wallet.coins` params: which address + asset to read spendable coins for, and which
663/// PAGE of them.
664///
665/// The address + asset pair is byte-identical to dig-app's frozen `CoinsRequest` and to
666/// [`WalletBalanceParams`] — a balance is this read reduced to a sum — so the paging fields are
667/// purely additive and a caller that names neither asks exactly what it asked before.
668///
669/// # Bounded, because an address's coin count is not (dig-node#381)
670///
671/// A funded address accumulates coins without limit, and every change coin a spend produces adds
672/// one. An unpaged read therefore has unbounded cardinality on the same loopback control plane that
673/// has NO request rate limiting of any kind (dig_ecosystem#2577) — the identical exposure
674/// [`WalletCoinsByParentParams`] documents at length, for the identical reason, and on the fallback
675/// tier the work lands on a third-party coinset oracle rather than on this node.
676///
677/// Paged rather than capped, for the reason its sibling records: a bare cap is a dead end, because
678/// an address holding more coins than the cap could never be fully enumerated, and this read exists
679/// so a caller can BUILD A SPEND from the coins it names. A spend built from a silently truncated
680/// coin set refuses with a shortfall that is not true.
681///
682/// # The paging rules are the sibling's rules, deliberately
683///
684/// ASCENDING `coin_id`, a cursor the caller was HANDED rather than an offset, and an out-of-range
685/// `limit` REFUSED rather than clamped. See [`WalletCoinsByParentParams`] for why each of those is
686/// the money-safe choice; a second set of paging semantics on the same plane would be a place for
687/// the two to disagree.
688#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
689pub struct WalletCoinsParams {
690    /// The `xch1…` address to read coins for.
691    pub address: String,
692    /// The asset to read coins for.
693    pub asset: Asset,
694    /// Resume STRICTLY AFTER this coin, in ascending `coin_id` order. `None` starts at the first.
695    ///
696    /// This is the value the previous page handed back as
697    /// [`cursor`](results::WalletCoinsResult::cursor) — never a value the caller invented, and never
698    /// a marker for where the chain got to.
699    #[serde(default, skip_serializing_if = "Option::is_none")]
700    pub after_coin_id: Option<String>,
701    /// The page size. `None` asks for [`COINS_DEFAULT_LIMIT`].
702    ///
703    /// A value above [`COINS_MAX_LIMIT`], or a zero, is REFUSED as `INVALID_PARAMS` rather than
704    /// clamped — see [`Self::validated`].
705    #[serde(default, skip_serializing_if = "Option::is_none")]
706    pub limit: Option<u32>,
707}
708control_call!(WalletCoinsParams => ControlMethod::WalletCoins, results::WalletCoinsResult);
709
710/// The length of a coin id in lowercase hex characters: a 32-byte hash.
711const COIN_ID_HEX_LEN: usize = 64;
712
713/// `control.wallet.coinById` params: WHICH coin, named by its own id.
714///
715/// # Why there is no `asset` here
716///
717/// A coin id names one coin on one chain. It is not scoped to an address and not scoped to an
718/// asset, and a node reading a coin record learns neither — so an asset parameter here could only
719/// be a claim the read never checks. The answer's
720/// [`asset`](crate::results::WalletCoinRecord::asset) is `null` for the same reason.
721///
722/// # Why the method exists at all
723///
724/// [`WalletCoinsParams`] answers by ADDRESS and lists UNSPENT coins only. A mint's evidence is the
725/// opposite shape: the created DID coin (which sits at nobody's wallet address) and the funding
726/// coin the mint SPENT (which is, by then, gone from every unspent list). Without a by-id read a
727/// pushed mint can never be observed — a permanent "pending" with the money already spent.
728#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
729pub struct WalletCoinByIdParams {
730    /// The coin id: lowercase 64-hex, unprefixed. A `0x` prefix is TOLERATED on input (block
731    /// explorers print one) and normalized away by [`Self::validated`]; it is never emitted.
732    pub coin_id: String,
733}
734control_call!(WalletCoinByIdParams => ControlMethod::WalletCoinById, results::WalletCoinByIdResult);
735
736/// `control.wallet.coinSpend` params: WHICH coin's spend to read, named by that coin's own id.
737///
738/// # Why the spend and the coin record are separate methods
739///
740/// [`WalletCoinByIdParams`] answers *what is this coin, and was it spent?* — an id, an amount, a
741/// puzzle HASH and two heights. None of that reveals what the spend DID. Reconstructing a lineage
742/// (which is how a dig-profile's DID singleton is followed forward) needs the puzzle REVEAL and the
743/// solution, and those exist only in the spend. A caller holding a coin record alone can see that a
744/// coin is gone and cannot see what it became.
745///
746/// # The id names the SPENT coin, not the spend
747///
748/// A spend has no id of its own on chain; it is identified by the coin it consumed. So the parameter
749/// is the same 64-hex coin id [`WalletCoinByIdParams`] takes, validated by the same rule, and the
750/// two methods are asked with the identical value.
751#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
752pub struct WalletCoinSpendParams {
753    /// The SPENT coin's id: lowercase 64-hex, unprefixed. A `0x` prefix is TOLERATED on input and
754    /// normalized away by [`Self::validated`]; it is never emitted.
755    pub coin_id: String,
756}
757control_call!(WalletCoinSpendParams => ControlMethod::WalletCoinSpend, results::WalletCoinSpendResult);
758
759/// `control.wallet.coinsByParent` params: WHICH coin's direct children to read.
760///
761/// # One hop, and the field name says so
762///
763/// The field is `parent_coin_id` rather than `coin_id` because the coin named here is the one being
764/// asked ABOUT as a parent — it is never the coin the caller wants back. A walk up or down a lineage
765/// is the CALLER's composition of repeated single hops; the node performs exactly one. Naming it
766/// `coin_id` would make a recursive reading of the method plausible from the request alone, and a
767/// caller expecting a whole lineage from one call would read a one-hop answer as a truncated chain.
768///
769/// # Bounded, because a parent's child count is not
770///
771/// This is the only OPEN wallet read whose answer has unbounded cardinality — every other one
772/// returns a single record (`coinById`, `peak`, `syncStatus`) or is already paged (`arrivals`). So
773/// the read is PAGED, and the page is bounded by [`COINS_BY_PARENT_MAX_LIMIT`].
774///
775/// **The bound is the ONLY thing bounding this call — there is no rate limiter anywhere behind it.**
776/// dig-node's control plane has no request rate limiting of any kind (dig_ecosystem#2577); the
777/// bandwidth limiter it does have governs content serving and is not on this path. A future reader
778/// weighing whether to relax this cap should assume no limiter exists, because none does.
779///
780/// That matters more than a local resource bound would, because the node does not necessarily answer
781/// from its own replica: on the fallback tier it forwards a caller-supplied identifier to a
782/// THIRD-PARTY coinset HTTPS oracle. An unbounded page is therefore unbounded work against somebody
783/// else's service, requested by a token-less caller on a loopback endpoint.
784///
785/// Paging rather than a bare cap, because a bare cap is a dead end: a parent with more children than
786/// the cap could never be fully enumerated, and this method exists to WALK a lineage. A walk that
787/// cannot see past the cap is a walk that silently stops.
788#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
789pub struct WalletCoinsByParentParams {
790    /// The PARENT coin's id: lowercase 64-hex, unprefixed. A `0x` prefix is TOLERATED on input and
791    /// normalized away by [`Self::validated`]; it is never emitted.
792    pub parent_coin_id: String,
793    /// Resume STRICTLY AFTER this child, in the read's
794    /// [documented order](results::WalletCoinsByParentResult). `None` starts at the first child.
795    ///
796    /// This is the value the previous page handed back as
797    /// [`cursor`](results::WalletCoinsByParentResult::cursor) — never a value the caller invented,
798    /// and never a marker for where the chain "got to". `control.wallet.arrivals` records why that
799    /// distinction loses rows; this read avoids the trap by having no such marker to reach for.
800    #[serde(default, skip_serializing_if = "Option::is_none")]
801    pub after_coin_id: Option<String>,
802    /// The page size. `None` asks for [`COINS_BY_PARENT_DEFAULT_LIMIT`].
803    ///
804    /// A value above [`COINS_BY_PARENT_MAX_LIMIT`], or a zero, is REFUSED as `INVALID_PARAMS` rather
805    /// than clamped — see [`Self::validated`].
806    #[serde(default, skip_serializing_if = "Option::is_none")]
807    pub limit: Option<u32>,
808}
809control_call!(WalletCoinsByParentParams => ControlMethod::WalletCoinsByParent, results::WalletCoinsByParentResult);
810
811/// `control.wallet.arrivals` — confirmed INCOMING funds recorded since a cursor position.
812///
813/// The answer to "was I just paid?", which neither a balance nor a coin list can give: a balance
814/// moves for the user's OWN change too, and an unspent-coin list cannot say which of its coins are
815/// new. Each row the node returns is a coin it determined ARRIVED — confirmed on chain, above the
816/// wallet's arrival baseline, not previously reported, and not created by spending one of the
817/// wallet's own coins. The determination is the NODE's; a client MUST NOT re-derive it, because the
818/// signals it takes (spent parents, the catch-up baseline) live only in the node's replica.
819///
820/// # A cursor, not a stream
821///
822/// The control envelope is strictly request→response, so this is polled. A client resumes from
823/// [`WalletArrivalsResult::cursor`](results::WalletArrivalsResult::cursor) — the last position it
824/// was actually handed — and NEVER from `latest`; see that field for why the distinction loses a
825/// notification when it is collapsed.
826///
827/// # `after_seq` is unsigned so a rewind is unexpressible
828///
829/// Positions are monotonic and start at 1, so there is no meaning for a negative one. `0` is the
830/// beginning of the ledger and is what a client sends when it deliberately wants everything.
831#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
832pub struct WalletArrivalsParams {
833    /// Return arrivals STRICTLY after this position. `0` starts at the beginning of the ledger,
834    /// and is also what an omitted field means — the same default the node applies.
835    #[serde(default)]
836    pub after_seq: u64,
837    /// The page size. `None` asks for the node's default rather than a number this client invented;
838    /// a node clamps whatever it is given to its own maximum.
839    #[serde(default, skip_serializing_if = "Option::is_none")]
840    pub limit: Option<u32>,
841}
842control_call!(WalletArrivalsParams => ControlMethod::WalletArrivals, results::WalletArrivalsResult);
843
844fn normalize_coin_id(coin_id: &str) -> Option<&str> {
845    let normalized = coin_id.strip_prefix("0x").unwrap_or(coin_id);
846    let well_formed = normalized.len() == COIN_ID_HEX_LEN
847        && normalized
848            .bytes()
849            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
850    well_formed.then_some(normalized)
851}
852
853/// Give a single-coin-id params type its validating `Deserialize` and its `validated` constructor.
854///
855/// The three by-coin reads (`coinById`, `coinSpend`, `coinsByParent`) enforce the IDENTICAL id rule
856/// under three different field names, and the rule is normative rather than incidental — see
857/// [`WalletCoinByIdParams::validated`] for why a malformed id must be refused BEFORE a chain is
858/// consulted. Written once here so a fourth by-coin read cannot arrive with a subtly looser copy of
859/// it, and so a change to the rule cannot land on two of three types.
860macro_rules! coin_id_params {
861    ($ty:ident, $field:ident, $raw:ident, $error:expr) => {
862        impl<'de> Deserialize<'de> for $ty {
863            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
864            where
865                D: serde::Deserializer<'de>,
866            {
867                #[derive(Deserialize)]
868                struct $raw {
869                    $field: String,
870                }
871
872                let raw = $raw::deserialize(deserializer)?;
873                let $field = normalize_coin_id(&raw.$field)
874                    .ok_or_else(|| serde::de::Error::custom($error))?
875                    .to_owned();
876                Ok(Self { $field })
877            }
878        }
879
880        impl $ty {
881            /// Normalize and check the coin id, or reject the request as `-32602 INVALID_PARAMS`.
882            ///
883            /// A malformed id is a malformed REQUEST, and the node refuses it here — before
884            /// consulting any chain. That ordering is normative rather than an optimisation: were a
885            /// bad id allowed through, the read would come back empty, and the caller would be told
886            /// the honest-looking answer *the chain holds nothing* about a coin it never actually
887            /// asked after. An unanswerable question and a chain that answered "no" must never wear
888            /// the same shape.
889            ///
890            /// Accepts exactly two spellings — 64 lowercase hex characters, or the same 64 preceded
891            /// by `0x`. Uppercase, whitespace and every other length are refused, because the
892            /// contract's hex wire form is lowercase and unprefixed everywhere else in this crate.
893            pub fn validated(self) -> Result<Self, crate::error::ControlError> {
894                let normalized = normalize_coin_id(&self.$field).ok_or_else(|| {
895                    crate::error::ControlError::of(
896                        crate::error::ControlErrorCode::InvalidParams,
897                        $error,
898                    )
899                })?;
900                Ok($ty {
901                    $field: normalized.to_owned(),
902                })
903            }
904        }
905    };
906}
907
908const COIN_ID_ERROR: &str = "coin_id must be lowercase 64-hex, optionally 0x-prefixed";
909const PARENT_COIN_ID_ERROR: &str =
910    "parent_coin_id must be lowercase 64-hex, optionally 0x-prefixed";
911const AFTER_COIN_ID_ERROR: &str = "after_coin_id must be lowercase 64-hex, optionally 0x-prefixed";
912
913coin_id_params!(
914    WalletCoinByIdParams,
915    coin_id,
916    RawWalletCoinByIdParams,
917    COIN_ID_ERROR
918);
919coin_id_params!(
920    WalletCoinSpendParams,
921    coin_id,
922    RawWalletCoinSpendParams,
923    COIN_ID_ERROR
924);
925/// The page size `control.wallet.coinsByParent` uses when the caller names none.
926///
927/// A spend in the lineages this read exists to follow — a singleton, a DID, an ordinary transfer —
928/// creates a small handful of children, so one default page covers a realistic hop in a single round
929/// trip and a caller never pages at all.
930pub const COINS_BY_PARENT_DEFAULT_LIMIT: u32 = 100;
931
932/// The largest page `control.wallet.coinsByParent` will accept, derived from the transport's own
933/// frame limit rather than chosen for feel.
934///
935/// dig-ipc-protocol caps a control frame at `MAX_FRAME_BYTES` = 1 MiB (its `SPEC.md` §
936/// bounds), and that is the hard ceiling every answer on this plane has to fit inside. A
937/// [`WalletCoinRecord`](results::WalletCoinRecord) is at most ~350 bytes of JSON — three 64-hex
938/// hashes at 66 bytes quoted, a 20-digit `u64`, two 10-digit heights, and their keys — so the
939/// arithmetic that fixes this number is:
940///
941/// ```text
942/// 1 MiB / 350 B  ~=  2,996 records is where a page STOPS FITTING
943/// 1,000 records  ~=  350 KB, roughly a third of the frame
944/// ```
945///
946/// The cap is set at a third of what fits, not at what fits, so the envelope, the freshness fields
947/// and any future additive member cannot push a legal page over the transport's limit. A larger
948/// value would put the contract's own maximum inside the region where a conforming node's honest
949/// answer is undeliverable — the failure would surface as a truncated frame, not as a refusal.
950pub const COINS_BY_PARENT_MAX_LIMIT: u32 = 1_000;
951
952/// The page bound message BOTH paged coin reads refuse with.
953///
954/// Shared rather than duplicated: the two reads page the same record type over the same frame, so
955/// two copies of this sentence could only ever differ by drifting apart.
956const PAGE_LIMIT_ERROR: &str = "limit must be between 1 and 1000";
957
958/// The page size `control.wallet.coins` uses when the caller names none (dig-node#381).
959///
960/// Pinned TO its sibling rather than restated. Both reads page the same
961/// [`WalletCoinRecord`](results::WalletCoinRecord) over the same transport frame, so the derivation
962/// that fixes one fixes the other, and a second literal here would be a second thing to forget when
963/// the frame limit moves.
964pub const COINS_DEFAULT_LIMIT: u32 = COINS_BY_PARENT_DEFAULT_LIMIT;
965
966/// The largest page `control.wallet.coins` will accept — the same frame-derived ceiling
967/// [`COINS_BY_PARENT_MAX_LIMIT`] documents the arithmetic for, and pinned to it for the reason
968/// [`COINS_DEFAULT_LIMIT`] gives.
969pub const COINS_MAX_LIMIT: u32 = COINS_BY_PARENT_MAX_LIMIT;
970
971impl<'de> Deserialize<'de> for WalletCoinsParams {
972    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
973    where
974        D: serde::Deserializer<'de>,
975    {
976        #[derive(Deserialize)]
977        struct RawWalletCoinsParams {
978            address: String,
979            asset: Asset,
980            #[serde(default)]
981            after_coin_id: Option<String>,
982            #[serde(default)]
983            limit: Option<u32>,
984        }
985
986        let raw = RawWalletCoinsParams::deserialize(deserializer)?;
987        let after_coin_id = raw
988            .after_coin_id
989            .map(|id| {
990                normalize_coin_id(&id)
991                    .map(str::to_owned)
992                    .ok_or_else(|| serde::de::Error::custom(AFTER_COIN_ID_ERROR))
993            })
994            .transpose()?;
995        if !raw.limit.map_or(true, is_legal_page) {
996            return Err(serde::de::Error::custom(PAGE_LIMIT_ERROR));
997        }
998        Ok(Self {
999            address: raw.address,
1000            asset: raw.asset,
1001            after_coin_id,
1002            limit: raw.limit,
1003        })
1004    }
1005}
1006
1007impl WalletCoinsParams {
1008    /// A first page of coins at one address for one asset: the node's default size, from the start.
1009    ///
1010    /// The common case, and the one a caller should not have to spell out — naming a page size means
1011    /// asserting a number this caller invented over the one the contract chose.
1012    pub fn first_page(address: impl Into<String>, asset: Asset) -> Self {
1013        Self {
1014            address: address.into(),
1015            asset,
1016            after_coin_id: None,
1017            limit: None,
1018        }
1019    }
1020
1021    /// The page size this request asks for, resolving `None` to [`COINS_DEFAULT_LIMIT`].
1022    ///
1023    /// Stated once here so a node and a client cannot resolve the same omitted field to two
1024    /// different numbers — a disagreement that shows up as a page boundary in the wrong place,
1025    /// which is exactly where a paged walk loses rows. On a coin read a lost row is a coin the
1026    /// caller cannot spend.
1027    pub fn effective_limit(&self) -> u32 {
1028        self.limit.unwrap_or(COINS_DEFAULT_LIMIT)
1029    }
1030
1031    /// Normalize the cursor and check the page bound, or reject as `-32602 INVALID_PARAMS`.
1032    ///
1033    /// The address is NOT validated here: it is decoded by the node's own bech32m reader, which is
1034    /// the only thing that can tell a well-formed address from a well-formed string, and this crate
1035    /// has never claimed otherwise for [`WalletBalanceParams`] either.
1036    ///
1037    /// An out-of-range `limit` is REFUSED, never clamped, for the reason
1038    /// [`WalletCoinsByParentParams::validated`] states: a silently shrunk page hands back a cursor
1039    /// for a position the caller did not ask about.
1040    pub fn validated(self) -> Result<Self, crate::error::ControlError> {
1041        fn invalid(message: &'static str) -> crate::error::ControlError {
1042            crate::error::ControlError::of(crate::error::ControlErrorCode::InvalidParams, message)
1043        }
1044
1045        let after_coin_id = self
1046            .after_coin_id
1047            .as_deref()
1048            .map(|id| {
1049                normalize_coin_id(id)
1050                    .map(str::to_owned)
1051                    .ok_or_else(|| invalid(AFTER_COIN_ID_ERROR))
1052            })
1053            .transpose()?;
1054        if !self.limit.map_or(true, is_legal_page) {
1055            return Err(invalid(PAGE_LIMIT_ERROR));
1056        }
1057        Ok(WalletCoinsParams {
1058            address: self.address,
1059            asset: self.asset,
1060            after_coin_id,
1061            limit: self.limit,
1062        })
1063    }
1064}
1065
1066impl<'de> Deserialize<'de> for WalletCoinsByParentParams {
1067    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1068    where
1069        D: serde::Deserializer<'de>,
1070    {
1071        #[derive(Deserialize)]
1072        struct RawWalletCoinsByParentParams {
1073            parent_coin_id: String,
1074            #[serde(default)]
1075            after_coin_id: Option<String>,
1076            #[serde(default)]
1077            limit: Option<u32>,
1078        }
1079
1080        let raw = RawWalletCoinsByParentParams::deserialize(deserializer)?;
1081        let parent_coin_id = normalize_coin_id(&raw.parent_coin_id)
1082            .ok_or_else(|| serde::de::Error::custom(PARENT_COIN_ID_ERROR))?
1083            .to_owned();
1084        let after_coin_id = raw
1085            .after_coin_id
1086            .map(|id| {
1087                normalize_coin_id(&id)
1088                    .map(str::to_owned)
1089                    .ok_or_else(|| serde::de::Error::custom(AFTER_COIN_ID_ERROR))
1090            })
1091            .transpose()?;
1092        if !raw.limit.map_or(true, is_legal_page) {
1093            return Err(serde::de::Error::custom(PAGE_LIMIT_ERROR));
1094        }
1095        Ok(Self {
1096            parent_coin_id,
1097            after_coin_id,
1098            limit: raw.limit,
1099        })
1100    }
1101}
1102
1103/// Is this a page size the contract accepts — at least one row, at most the frame-derived maximum?
1104fn is_legal_page(limit: u32) -> bool {
1105    (1..=COINS_BY_PARENT_MAX_LIMIT).contains(&limit)
1106}
1107
1108impl WalletCoinsByParentParams {
1109    /// A first page of children for one parent: the node's default size, starting at the beginning.
1110    ///
1111    /// The common case, and the one a caller should not have to spell out — naming a page size means
1112    /// asserting a number this caller invented over the one the contract chose.
1113    pub fn first_page(parent_coin_id: impl Into<String>) -> Self {
1114        Self {
1115            parent_coin_id: parent_coin_id.into(),
1116            after_coin_id: None,
1117            limit: None,
1118        }
1119    }
1120
1121    /// The page size this request asks for, resolving `None` to [`COINS_BY_PARENT_DEFAULT_LIMIT`].
1122    ///
1123    /// Stated once here so a node and a client cannot resolve the same omitted field to two
1124    /// different numbers — a disagreement that would show up as a page boundary in the wrong place,
1125    /// which is exactly where a paged walk loses rows.
1126    pub fn effective_limit(&self) -> u32 {
1127        self.limit.unwrap_or(COINS_BY_PARENT_DEFAULT_LIMIT)
1128    }
1129
1130    /// Normalize and check both ids and the page bound, or reject as `-32602 INVALID_PARAMS`.
1131    ///
1132    /// The ids follow the rule every by-coin read in this crate follows (lowercase 64-hex, `0x`
1133    /// tolerated on input and never emitted).
1134    ///
1135    /// An out-of-range `limit` is REFUSED, never clamped. That is a deliberate departure from
1136    /// `control.wallet.arrivals`, which lets a node clamp: this read's page boundary is what a
1137    /// caller RESUMES from, so a silently shrunk page hands back a cursor for a position the caller
1138    /// did not ask about, and a caller that believed its own number would mis-size every subsequent
1139    /// request. Refusing keeps the caller's model of the page and the node's identical, which is the
1140    /// same reason a 65-hex coin id is refused rather than truncated.
1141    ///
1142    /// `limit: 0` is refused for a separate reason: a page that can hold nothing makes no progress,
1143    /// so a caller looping until a page comes back short would loop forever.
1144    pub fn validated(self) -> Result<Self, crate::error::ControlError> {
1145        fn invalid(message: &'static str) -> crate::error::ControlError {
1146            crate::error::ControlError::of(crate::error::ControlErrorCode::InvalidParams, message)
1147        }
1148
1149        let parent_coin_id = normalize_coin_id(&self.parent_coin_id)
1150            .ok_or_else(|| invalid(PARENT_COIN_ID_ERROR))?
1151            .to_owned();
1152        let after_coin_id = self
1153            .after_coin_id
1154            .as_deref()
1155            .map(|id| {
1156                normalize_coin_id(id)
1157                    .map(str::to_owned)
1158                    .ok_or_else(|| invalid(AFTER_COIN_ID_ERROR))
1159            })
1160            .transpose()?;
1161        if !self.limit.map_or(true, is_legal_page) {
1162            return Err(invalid(PAGE_LIMIT_ERROR));
1163        }
1164        Ok(WalletCoinsByParentParams {
1165            parent_coin_id,
1166            after_coin_id,
1167            limit: self.limit,
1168        })
1169    }
1170}
1171
1172/// `control.wallet.peak` params — none.
1173///
1174/// The peak is a property of the node's chain view, not of any address, which is exactly why it is
1175/// its own method: [`results::WalletBalanceResult::peak_height`] is `null` on every fallback-tier
1176/// answer, so a caller bounding a claimed confirmation cannot rely on getting one from a balance.
1177#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1178pub struct WalletPeakParams {}
1179control_call!(WalletPeakParams => ControlMethod::WalletPeak, results::WalletPeakResult);
1180
1181/// `control.wallet.operatorAddress` params — none.
1182///
1183/// There is exactly one operator wallet per node, so there is nothing to scope the question by.
1184/// Deliberately NOT an address-taking method: a caller cannot ask *is THIS address the operator
1185/// wallet* by supplying a candidate, because a node answering yes/no about supplied addresses is an
1186/// oracle for probing which addresses a machine controls.
1187#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1188pub struct WalletOperatorAddressParams {}
1189control_call!(WalletOperatorAddressParams => ControlMethod::WalletOperatorAddress, results::WalletOperatorAddressResult);
1190
1191/// `control.peerCounts` params — none.
1192///
1193/// The counts describe this node's own connectivity on each network, so there is nothing to scope
1194/// the question by. One call answers for BOTH networks deliberately: a consumer that had to collect
1195/// the DIG count from a peer method and the Chia count from a wallet method is a consumer that can
1196/// reach for the wrong one.
1197#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1198pub struct PeerCountsParams {}
1199control_call!(PeerCountsParams => ControlMethod::PeerCounts, results::PeerCountsResult);
1200
1201/// `control.wallet.syncStatus` params — none.
1202///
1203/// The wallet's sync progress is a property of the node's own chain replica, not of any address, so
1204/// there is nothing to scope the question by. Deliberately NOT confused with `control.sync.status`,
1205/// which reports §21 DIG STORE sync and has nothing to do with the chain.
1206#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1207pub struct WalletSyncStatusParams {}
1208control_call!(WalletSyncStatusParams => ControlMethod::WalletSyncStatus, results::WalletSyncStatusResult);
1209
1210/// `control.wallet.broadcast` params: an ALREADY-SIGNED spend bundle to push.
1211///
1212/// # The custody boundary (§908)
1213///
1214/// This carries signed bytes and nothing else. There is deliberately no key, no seed, no phrase and
1215/// no unsigned-spend-plus-key field here, and there never may be: the node's role on the money path
1216/// is to read chain state and to push what somebody else signed. A parameter that let the node
1217/// produce a signature would move custody into an identity-agnostic daemon, which is the one thing
1218/// the boundary exists to prevent.
1219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1220pub struct WalletBroadcastParams {
1221    /// The signed spend bundle: lowercase hex of its chia `Streamable` serialization.
1222    pub signed_bundle_hex: String,
1223}
1224control_call!(WalletBroadcastParams => ControlMethod::WalletBroadcast, results::WalletBroadcastResult);
1225
1226/// The length of a BLS G1 public key in lowercase hex characters: 48 bytes.
1227const PUBLIC_KEY_HEX_LEN: usize = 96;
1228
1229/// `control.wallet.watch` params: which PUBLIC keys the node should follow.
1230///
1231/// # Keys, never puzzle hashes
1232///
1233/// The node already derives addresses from the public keys it holds in its own custody, through one
1234/// standard derivation. Enrolling KEYS reuses that exact derivation for a client's keys too, so the
1235/// ecosystem has ONE mapping from key to address and a client and a node can never disagree about
1236/// which addresses a key covers. Enrolling puzzle hashes instead would make every client re-derive
1237/// independently, and a client whose derivation window is narrower than the node's would silently
1238/// under-report the money it owns.
1239///
1240/// # No key material crosses (§908)
1241///
1242/// A G1 public key is public. There is deliberately no seed, phrase, private key or signature field
1243/// here, and there never may be: enrolment tells the node what to WATCH, and watching needs nothing
1244/// a signature could be produced from.
1245///
1246/// # Idempotent, so a client can reconcile without asking first
1247///
1248/// Submitting keys the node already follows is a SUCCESS that changes nothing — see
1249/// [`WalletWatchResult`](results::WalletWatchResult). Duplicates WITHIN one request count once. An
1250/// empty list is accepted and does nothing, because "enrol everything in this (currently empty) set"
1251/// is a reconciliation a client legitimately performs.
1252#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1253pub struct WalletWatchParams {
1254    /// The public keys to enrol: lowercase 96-hex, unprefixed. A `0x` prefix is TOLERATED on input
1255    /// and normalized away by [`Self::validated`]; it is never emitted.
1256    pub public_keys: Vec<String>,
1257}
1258control_call!(WalletWatchParams => ControlMethod::WalletWatch, results::WalletWatchResult);
1259
1260/// `control.wallet.unwatch` params: which PUBLIC keys the node should stop following.
1261///
1262/// Takes the same wire form as [`WalletWatchParams`] under the same field name, so a client
1263/// reverses an enrolment by re-sending exactly what it sent. Deregistering a key that was never
1264/// enrolled is a success, not an error — the mirror of enrolment's idempotence, and what lets a
1265/// client assert an end state rather than compute a diff.
1266#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1267pub struct WalletUnwatchParams {
1268    /// The public keys to deregister: lowercase 96-hex, unprefixed. A `0x` prefix is TOLERATED on
1269    /// input and normalized away by [`Self::validated`]; it is never emitted.
1270    pub public_keys: Vec<String>,
1271}
1272control_call!(WalletUnwatchParams => ControlMethod::WalletUnwatch, results::WalletUnwatchResult);
1273
1274/// `control.wallet.watched` params — none.
1275///
1276/// The enrolled set is a property of the node, so there is nothing to scope the question by. This
1277/// is why the method is TOKEN-GATED although it only reads: the caller supplies nothing, so the
1278/// answer is the node's OWN key set — see [`ControlMethod::is_open_read`].
1279#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1280pub struct WalletWatchedParams {}
1281control_call!(WalletWatchedParams => ControlMethod::WalletWatched, results::WalletWatchedResult);
1282
1283fn normalize_public_key(public_key: &str) -> Option<&str> {
1284    let normalized = public_key.strip_prefix("0x").unwrap_or(public_key);
1285    let well_formed = normalized.len() == PUBLIC_KEY_HEX_LEN
1286        && normalized
1287            .bytes()
1288            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
1289    well_formed.then_some(normalized)
1290}
1291
1292/// Give an enrolment params type its validating `Deserialize` and its `validated` constructor.
1293///
1294/// `watch` and `unwatch` carry the IDENTICAL key list under the identical field name, and must
1295/// enforce the identical rule: a client reverses an enrolment by re-sending what it sent, so a
1296/// spelling one method accepts and the other refuses would leave a key enrolled forever. Written
1297/// once so the two cannot drift apart.
1298macro_rules! public_keys_params {
1299    ($ty:ident, $raw:ident, $error:expr) => {
1300        impl<'de> Deserialize<'de> for $ty {
1301            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1302            where
1303                D: serde::Deserializer<'de>,
1304            {
1305                #[derive(Deserialize)]
1306                struct $raw {
1307                    public_keys: Vec<String>,
1308                }
1309
1310                let raw = $raw::deserialize(deserializer)?;
1311                let public_keys = raw
1312                    .public_keys
1313                    .iter()
1314                    .map(|key| {
1315                        normalize_public_key(key)
1316                            .map(str::to_owned)
1317                            .ok_or_else(|| serde::de::Error::custom($error))
1318                    })
1319                    .collect::<Result<Vec<_>, _>>()?;
1320                Ok(Self { public_keys })
1321            }
1322        }
1323
1324        impl $ty {
1325            /// Normalize and check every key, or reject the request as `-32602 INVALID_PARAMS`.
1326            ///
1327            /// The whole request is refused when ANY key is malformed — never the well-formed
1328            /// subset. A partial enrolment would leave the node following fewer addresses than the
1329            /// client believes it asked for, and the client's next balance read would report a
1330            /// shortfall as though the money were not there. One bad key is a malformed REQUEST.
1331            ///
1332            /// Accepts exactly two spellings per key — 96 lowercase hex characters, or the same 96
1333            /// preceded by `0x`. Uppercase, whitespace and every other length are refused, because
1334            /// the contract's hex wire form is lowercase and unprefixed everywhere else.
1335            pub fn validated(self) -> Result<Self, crate::error::ControlError> {
1336                let public_keys = self
1337                    .public_keys
1338                    .iter()
1339                    .map(|key| {
1340                        normalize_public_key(key).map(str::to_owned).ok_or_else(|| {
1341                            crate::error::ControlError::of(
1342                                crate::error::ControlErrorCode::InvalidParams,
1343                                $error,
1344                            )
1345                        })
1346                    })
1347                    .collect::<Result<Vec<_>, _>>()?;
1348                Ok(Self { public_keys })
1349            }
1350        }
1351    };
1352}
1353
1354const PUBLIC_KEYS_ERROR: &str =
1355    "public_keys must each be lowercase 96-hex (a 48-byte G1 key), optionally 0x-prefixed";
1356
1357public_keys_params!(WalletWatchParams, RawWalletWatch, PUBLIC_KEYS_ERROR);
1358public_keys_params!(WalletUnwatchParams, RawWalletUnwatch, PUBLIC_KEYS_ERROR);
1359
1360/// Every id in a reservation's `coin_ids` takes the same wire form the single-coin reads accept,
1361/// so a client can feed a coin straight from `control.wallet.coins` into a reservation.
1362const COIN_IDS_ERROR: &str =
1363    "coin_ids must each be lowercase 64-hex (a 32-byte coin id), optionally 0x-prefixed";
1364
1365/// `control.wallet.reservations.held` params — none.
1366///
1367/// # Why the caller does not supply the time
1368///
1369/// dig-account's `CoinReservationStore::held(now_unix)` takes the current time from its caller,
1370/// because in-process both sides share one clock and one trust domain. Across the control boundary
1371/// they share neither, and a caller-supplied `now` would be a lapse oracle: a far-future value makes
1372/// every live reservation read as expired, and the caller then selects coins another process is
1373/// about to spend. The node reads its OWN clock and reports it back as
1374/// [`as_of_unix`](results::WalletReservationsHeldResult::as_of_unix), so a client can SEE skew
1375/// rather than impose it.
1376#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1377pub struct WalletReservationsHeldParams {}
1378control_call!(WalletReservationsHeldParams => ControlMethod::WalletReservationsHeld, results::WalletReservationsHeldResult);
1379
1380/// `control.wallet.reservations.reserve` params: take EVERY named coin, or take none.
1381///
1382/// The wire form of `dig_account::wallet::reservation::CoinReservationStore::reserve_all`, so a
1383/// client backing that seam with the node forwards its arguments rather than translating them.
1384///
1385/// # All-or-none is the whole contract
1386///
1387/// Reading the held set, selecting, then reserving is check-then-act: two processes both read an
1388/// empty set and both take the same coin. Atomic acquisition is what closes that window, so a
1389/// conflict on ANY coin refuses the WHOLE call and reserves nothing — see
1390/// [`ControlErrorCode::WalletCoinsReserved`]. A caller that loses re-reads
1391/// [`WalletReservationsHeldParams`] and re-selects from what remains.
1392///
1393/// # An empty list is a success, not an error
1394///
1395/// It yields a handle that releases nothing, which is what the caller asked for. This matches
1396/// dig-account exactly: an empty reservation can never conflict, so refusing it would make a
1397/// legitimate no-op selection look like a malformed request.
1398///
1399/// # No key material (§908)
1400///
1401/// A coin id is a public chain fact. There is no seed, key, signature or bundle field here and
1402/// there never may be: a reservation is BOOKKEEPING — it narrows what a selector will choose and
1403/// authorizes nothing.
1404#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1405pub struct WalletReservationsReserveParams {
1406    /// The coin ids to hold: lowercase 64-hex, unprefixed. A `0x` prefix is TOLERATED on input and
1407    /// normalized away by [`Self::validated`]; it is never emitted.
1408    pub coin_ids: Vec<String>,
1409    /// How long the hold should live, in seconds; `None` asks for the node's default.
1410    ///
1411    /// A REQUEST, never a command. The node clamps this to its own maximum and returns the value it
1412    /// actually applied as
1413    /// [`ttl_secs`](results::WalletReservationsReserveResult::ttl_secs) — an unclamped caller-chosen
1414    /// lifetime is a lockout weapon, since one call could hold a wallet's coins away from its owner
1415    /// for as long as it liked.
1416    #[serde(skip_serializing_if = "Option::is_none")]
1417    pub ttl_secs: Option<u64>,
1418}
1419control_call!(WalletReservationsReserveParams => ControlMethod::WalletReservationsReserve, results::WalletReservationsReserveResult);
1420
1421/// `control.wallet.reservations.release` params: free one reservation now, ahead of its TTL.
1422///
1423/// # The release path is why a reservation is safe at all
1424///
1425/// A hold with no way out is a wallet that locks itself out of its own funds, which is worse than
1426/// the double-select it prevents. Two mechanisms keep that impossible and BOTH are required: the
1427/// TTL bounds every hold whether or not anyone releases it, and this method lets a caller that
1428/// KNOWS the answer — the spend settled, or was definitively rejected — stop holding the user's
1429/// coins over a question the chain has already resolved.
1430///
1431/// # Releasing an unknown or lapsed id is a SUCCESS
1432///
1433/// A caller releasing on confirmation cannot know whether the TTL got there first, and making that
1434/// race an error would teach callers to ignore the result — which is how a release stops being
1435/// called at all. See [`WalletReservationsReleaseResult`](results::WalletReservationsReleaseResult).
1436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1437pub struct WalletReservationsReleaseParams {
1438    /// The handle returned by
1439    /// [`reserve`](results::WalletReservationsReserveResult::reservation_id).
1440    ///
1441    /// OPAQUE: a client stores it and sends it back, and MUST NOT parse, derive or construct one.
1442    /// The node mints it, and how it is spelled is free to differ between a hold taken before a
1443    /// broadcast and one recorded for a bundle already in flight.
1444    pub reservation_id: String,
1445}
1446control_call!(WalletReservationsReleaseParams => ControlMethod::WalletReservationsRelease, results::WalletReservationsReleaseResult);
1447
1448/// `control.wallet.resetCoinDb` params: discard the cached coin database and re-sync from chain.
1449///
1450/// # `confirm` is the on-wire acknowledgement, not a default
1451///
1452/// The field defaults to `false` under [`Deserialize`] so an OMITTED field reads exactly like an
1453/// explicit `false` -- a client that forgot the flag gets the same refusal as one that typed it. The
1454/// handler MUST refuse with `INVALID_PARAMS` unless `confirm == true`; nothing about deserializing
1455/// this struct performs the reset.
1456///
1457/// # No key material is affected
1458///
1459/// Every table this clears is chain-derived and reproduced by syncing; a seed or key is never
1460/// touched. See [`WalletResetCoinDbResult`](results::WalletResetCoinDbResult) for what is reported
1461/// back, and the handler's own contract for the SpendInFlight refusal that protects a hold in
1462/// progress.
1463#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1464pub struct WalletResetCoinDbParams {
1465    /// Must be `true` or the call is refused as `INVALID_PARAMS`. Defaults to `false` when omitted,
1466    /// so an omitted field is refused exactly like an explicit one.
1467    #[serde(default)]
1468    pub confirm: bool,
1469}
1470control_call!(WalletResetCoinDbParams => ControlMethod::WalletResetCoinDb, results::WalletResetCoinDbResult);
1471
1472impl<'de> Deserialize<'de> for WalletReservationsReserveParams {
1473    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1474    where
1475        D: serde::Deserializer<'de>,
1476    {
1477        #[derive(Deserialize)]
1478        struct RawReserve {
1479            coin_ids: Vec<String>,
1480            #[serde(default)]
1481            ttl_secs: Option<u64>,
1482        }
1483
1484        let raw = RawReserve::deserialize(deserializer)?;
1485        let coin_ids = raw
1486            .coin_ids
1487            .iter()
1488            .map(|id| {
1489                normalize_coin_id(id)
1490                    .map(str::to_owned)
1491                    .ok_or_else(|| serde::de::Error::custom(COIN_IDS_ERROR))
1492            })
1493            .collect::<Result<Vec<_>, _>>()?;
1494        Ok(Self {
1495            coin_ids,
1496            ttl_secs: raw.ttl_secs,
1497        })
1498    }
1499}
1500
1501impl WalletReservationsReserveParams {
1502    /// Normalize and check every coin id, or reject the request as `-32602 INVALID_PARAMS`.
1503    ///
1504    /// The WHOLE request is refused when any id is malformed, never the well-formed subset. A
1505    /// partial reservation would leave the caller believing it holds inputs it does not — the exact
1506    /// state all-or-none acquisition exists to make unreachable.
1507    pub fn validated(self) -> Result<Self, ControlError> {
1508        let coin_ids = self
1509            .coin_ids
1510            .iter()
1511            .map(|id| {
1512                normalize_coin_id(id).map(str::to_owned).ok_or_else(|| {
1513                    ControlError::of(ControlErrorCode::InvalidParams, COIN_IDS_ERROR)
1514                })
1515            })
1516            .collect::<Result<Vec<_>, _>>()?;
1517        Ok(Self {
1518            coin_ids,
1519            ttl_secs: self.ttl_secs,
1520        })
1521    }
1522}
1523
1524/// `pairing.request` params (OPEN — no token).
1525#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1526pub struct RequestParams {
1527    /// A human-readable name for the requesting client (shown to the operator).
1528    pub client_name: String,
1529}
1530control_call!(RequestParams => ControlMethod::PairingRequest, results::PairingRequestResult);
1531
1532/// `pairing.poll` params (OPEN — no token).
1533#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1534pub struct PollParams {
1535    /// The pairing id to poll.
1536    pub pairing_id: String,
1537}
1538control_call!(PollParams => ControlMethod::PairingPoll, results::PairingPollResult);
1539
1540/// The largest dig-profile body the control plane accepts or returns, in bytes: **4 MiB**.
1541///
1542/// # Why the contract carries the cap instead of the implementation
1543///
1544/// A client that learns the bound by exceeding it learns it as a failed round trip, after paying to
1545/// serialize and send megabytes. Stated here, an app checks before it sends and can tell a person
1546/// *this profile is too large* rather than *something went wrong*.
1547///
1548/// # Why this number
1549///
1550/// It is HALF of dig-gossip's `WS_MAX_MESSAGE_BYTES` (8 MiB), the frame ceiling a body must fit
1551/// inside when a node serves it to a peer over `PROFILE_BODY` (opcode 225). A body accepted here
1552/// but unservable there would be stored and then permanently unsyncable — accepted by the node the
1553/// app talks to and invisible to every other node. The halving leaves room for the framing,
1554/// envelope and base64 expansion that sit around the bytes on that hop.
1555///
1556/// The cap is on the DECODED body, not on the base64 text that carries it.
1557pub const MAX_BODY_BYTES: usize = 4 * 1024 * 1024;
1558
1559/// `control.profile.putBody` params: the profile body a confirmed chain root commits to.
1560///
1561/// # The node CHECKS the root; it does not take the caller's word for it
1562///
1563/// `root` is a CLAIM, and the node's obligation is to refuse it when it is false. An implementation
1564/// MUST independently resolve the profile's root on chain, recompute the root of the supplied body,
1565/// and reject the call unless the two agree and that root is CONFIRMED. dig-app is a caller like
1566/// any other here: it holds the key and signs the root (§908), but the bytes it then hands over
1567/// arrive at the node exactly as a peer's bytes do, and the standing rule for this epic is that a
1568/// body is checked against the on-chain root and anything that does not match is rejected.
1569///
1570/// A method documented as *the caller supplies a matching root* invites an implementation that
1571/// stores what it is given. That implementation turns the control plane into a way to make a node
1572/// serve arbitrary bytes to the network under someone else's profile id.
1573///
1574/// # No key material crosses (§908)
1575///
1576/// There is deliberately no seed, private key, signature or unsigned spend field here, and there
1577/// never may be. The node persists, serves and fetches bodies; it never signs.
1578#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1579pub struct ProfilePutBodyParams {
1580    /// The profile's store id: lowercase 64-hex, unprefixed.
1581    pub store_id: String,
1582    /// The root the body is claimed to hash to: lowercase 64-hex, unprefixed. Checked against the
1583    /// chain, never trusted.
1584    pub root: String,
1585    /// The body itself, standard base64 (padded) of its `DPB` serialization. The DECODED length
1586    /// MUST NOT exceed [`MAX_BODY_BYTES`]; a larger body is refused as `INVALID_PARAMS`.
1587    pub body_b64: String,
1588}
1589control_call!(ProfilePutBodyParams => ControlMethod::ProfilePutBody, results::ProfilePutBodyResult);
1590
1591/// `control.profile.getBody` params: WHICH profile body to read, named by store id + root.
1592///
1593/// The root is part of the question rather than part of the answer: a caller asks for the body at a
1594/// root it already knows, so it can never be handed a body for a DIFFERENT root and mistake it for
1595/// the one it asked about. A node holding no body at that root answers `body_b64: null`.
1596#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1597pub struct ProfileGetBodyParams {
1598    /// The profile's store id: lowercase 64-hex, unprefixed.
1599    pub store_id: String,
1600    /// The root to read the body at: lowercase 64-hex, unprefixed.
1601    pub root: String,
1602}
1603control_call!(ProfileGetBodyParams => ControlMethod::ProfileGetBody, results::ProfileGetBodyResult);
1604
1605/// The page size `control.spends.list` uses when the caller names none.
1606///
1607/// Stated on the contract rather than chosen by each side, so a node and a client cannot resolve the
1608/// same omitted field to two different numbers — a disagreement that shows up as a page boundary in
1609/// the wrong place, which is where a paged walk loses rows.
1610pub const SPENDS_LIST_DEFAULT_LIMIT: u32 = 50;
1611
1612/// The largest page `control.spends.list` will serve.
1613///
1614/// The audit record grows without limit — it is append-only and every automated cycle adds to it —
1615/// so an unbounded read is unbounded work and an unbounded response. The bound is declared here from
1616/// the start rather than added later, because a method that ships unbounded teaches every client to
1617/// expect the whole record in one call, and bounding it afterwards is then a breaking change.
1618///
1619/// **This bound is the only thing bounding the call.** dig-node's control plane has no request rate
1620/// limiting of any kind (dig_ecosystem#2577), so a future reader weighing a larger cap should assume
1621/// no limiter exists behind it, because none does.
1622pub const SPENDS_LIST_MAX_LIMIT: u32 = 500;
1623
1624/// The one refusal message for an out-of-range `control.spends.list` page size.
1625const SPENDS_LIST_LIMIT_ERROR: &str = "limit must be between 1 and 500 spends per page";
1626
1627/// `control.spends.list` params: WHICH automated spends to read, and how much of the record at once.
1628///
1629/// # A read, and only a read
1630///
1631/// Nothing here initiates, signs, cancels or amends a spend, and the catalog offers no method that
1632/// does. The record exists to make automatic signing accountable, and a surface able to edit it
1633/// would be a surface able to edit the evidence.
1634///
1635/// # Every filter is an AND; an unset filter constrains nothing
1636///
1637/// Omitting a field means "do not narrow on this", never "match nothing". A client that sends no
1638/// field at all asks for the newest page of the whole record.
1639///
1640/// # Paged, and the page boundary is the caller's
1641///
1642/// Rows come newest-initiated first (the full ordering rule is on
1643/// [`SpendsListResult`](results::SpendsListResult)) and a caller resumes from
1644/// [`after_id`](Self::after_id) — the id of the last row it was actually HANDED. An out-of-range
1645/// [`limit`](Self::limit) is REFUSED rather than clamped, matching
1646/// [`WalletCoinsByParentParams`] and for the same reason: a silently shrunk page hands back a cursor
1647/// for a position the caller did not ask about.
1648#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
1649pub struct SpendsListParams {
1650    /// Only spends INITIATED at or after this unix-ms instant. Inclusive.
1651    #[serde(default, skip_serializing_if = "Option::is_none")]
1652    pub since_ms: Option<u64>,
1653    /// Only spends INITIATED strictly before this unix-ms instant. Exclusive.
1654    ///
1655    /// Half-open with [`since_ms`](Self::since_ms) so consecutive windows tile the record exactly:
1656    /// one window's `until_ms` is the next window's `since_ms`, and no spend is counted twice or
1657    /// dropped between them.
1658    #[serde(default, skip_serializing_if = "Option::is_none")]
1659    pub until_ms: Option<u64>,
1660    /// Only spends serving this store id.
1661    #[serde(default, skip_serializing_if = "Option::is_none")]
1662    pub store_id: Option<String>,
1663    /// Only this kind of spend — the stable token the producer stamped (`"mirror-coin"`, …).
1664    ///
1665    /// An open string rather than an enum, because a new producer must be able to appear in the
1666    /// record without a release of this crate. An unrecognised kind matches nothing rather than
1667    /// erroring, which is the same answer a caller gets for a real kind that has no rows yet.
1668    #[serde(default, skip_serializing_if = "Option::is_none")]
1669    pub kind: Option<String>,
1670    /// Only this outcome, by its [`SpendOutcome::token`](results::SpendOutcome::token) —
1671    /// `pending` / `submitted` / `confirmed` / `failed` / `unresolved`.
1672    ///
1673    /// **`failed` does NOT mean "the money stayed put"**, so a client filtering on it must still
1674    /// read each row's stage — see [`SpendOutcome::Failed`](results::SpendOutcome::Failed). A UI
1675    /// that offers a "failed" filter and renders its rows as untouched money asserts something the
1676    /// node does not know.
1677    #[serde(default, skip_serializing_if = "Option::is_none")]
1678    pub status: Option<String>,
1679    /// Resume STRICTLY AFTER this audit id, in the read's documented order. `None` starts at the
1680    /// newest matching row.
1681    ///
1682    /// This is the value the previous page handed back as
1683    /// [`cursor`](results::SpendsListResult::cursor) — never an id the caller kept for another
1684    /// reason, and never a timestamp. Resuming by time would drop every spend sharing the boundary
1685    /// millisecond.
1686    #[serde(default, skip_serializing_if = "Option::is_none")]
1687    pub after_id: Option<String>,
1688    /// The page size. `None` asks for [`SPENDS_LIST_DEFAULT_LIMIT`].
1689    ///
1690    /// A zero, or a value above [`SPENDS_LIST_MAX_LIMIT`], is REFUSED as `INVALID_PARAMS` rather
1691    /// than clamped — see [`Self::validated`].
1692    #[serde(default, skip_serializing_if = "Option::is_none")]
1693    pub limit: Option<u32>,
1694}
1695
1696impl SpendsListParams {
1697    /// The page size this request asks for, resolving `None` to [`SPENDS_LIST_DEFAULT_LIMIT`].
1698    ///
1699    /// Stated once here so the node and the client cannot resolve the same omitted field to two
1700    /// different numbers.
1701    pub fn effective_limit(&self) -> u32 {
1702        self.limit.unwrap_or(SPENDS_LIST_DEFAULT_LIMIT)
1703    }
1704
1705    /// Check the page bound, or reject as `-32602 INVALID_PARAMS`.
1706    ///
1707    /// `limit: 0` is refused because a page that can hold nothing makes no progress: a caller
1708    /// looping until [`complete`](results::SpendsListResult::complete) would loop forever. A limit
1709    /// above the cap is refused rather than clamped so the caller's model of the page and the node's
1710    /// stay identical.
1711    ///
1712    /// The time window is deliberately NOT validated. `since_ms > until_ms` is a well-formed request
1713    /// for an empty window, and an empty window has an honest answer — no rows — which is a
1714    /// different thing from a malformed request.
1715    pub fn validated(self) -> Result<Self, ControlError> {
1716        if let Some(limit) = self.limit {
1717            if limit == 0 || limit > SPENDS_LIST_MAX_LIMIT {
1718                return Err(ControlError::of(
1719                    ControlErrorCode::InvalidParams,
1720                    SPENDS_LIST_LIMIT_ERROR,
1721                ));
1722            }
1723        }
1724        Ok(self)
1725    }
1726}
1727
1728impl<'de> Deserialize<'de> for SpendsListParams {
1729    /// Validates on the way in, so a node cannot forget to call [`Self::validated`].
1730    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1731    where
1732        D: serde::Deserializer<'de>,
1733    {
1734        #[derive(Deserialize)]
1735        struct Raw {
1736            #[serde(default)]
1737            since_ms: Option<u64>,
1738            #[serde(default)]
1739            until_ms: Option<u64>,
1740            #[serde(default)]
1741            store_id: Option<String>,
1742            #[serde(default)]
1743            kind: Option<String>,
1744            #[serde(default)]
1745            status: Option<String>,
1746            #[serde(default)]
1747            after_id: Option<String>,
1748            #[serde(default)]
1749            limit: Option<u32>,
1750        }
1751        let raw = Raw::deserialize(deserializer)?;
1752        SpendsListParams {
1753            since_ms: raw.since_ms,
1754            until_ms: raw.until_ms,
1755            store_id: raw.store_id,
1756            kind: raw.kind,
1757            status: raw.status,
1758            after_id: raw.after_id,
1759            limit: raw.limit,
1760        }
1761        .validated()
1762        .map_err(serde::de::Error::custom)
1763    }
1764}
1765control_call!(SpendsListParams => ControlMethod::SpendsList, results::SpendsListResult);
1766
1767/// The page size `control.mirror.bondStates` returns when a caller names none.
1768///
1769/// Sized for a pane rather than for a walk: dig-app#300 renders every bond of an ordinary node in
1770/// one view, and a node serving fewer than this many `(store, root)` pairs never pages at all.
1771pub const MIRROR_BOND_STATES_DEFAULT_LIMIT: u32 = 100;
1772
1773/// The largest page `control.mirror.bondStates` will return.
1774///
1775/// Pinned TO [`COINS_BY_PARENT_MAX_LIMIT`] rather than repeating its number, because it is bounded
1776/// by the same arithmetic: a bond row carries a coin id and a handful of integers, so a page of
1777/// bonds and a page of coins occupy the same order of envelope. Written as the constant so the two
1778/// cannot drift into two different numbers that were only ever meant to be one.
1779pub const MIRROR_BOND_STATES_MAX_LIMIT: u32 = COINS_BY_PARENT_MAX_LIMIT;
1780
1781const MIRROR_BOND_STATES_LIMIT_ERROR: &str = "limit must be between 1 and 1000 bonds per page";
1782
1783const MIRROR_BOND_AFTER_ERROR: &str =
1784    "after.store_id and after.root must each be lowercase 64-hex, optionally 0x-prefixed";
1785
1786/// `control.mirror.bondStates` params — one page of this node's mirror bond states.
1787///
1788/// # The caller narrows nothing
1789///
1790/// There is no store filter. The answer is this node's OWN bond set, and both consuming surfaces
1791/// (dig-app#300's pane, dig-app#289's locked total) want all of it; a client wanting one store
1792/// filters the page it was handed. Adding a filter would also make
1793/// [`locked_dig_base_units`](results::MirrorBondStatesResult::Known::locked_dig_base_units) — a
1794/// whole-set figure — read as a filtered one, which is the money lie this method is built to avoid.
1795///
1796/// # Paged, and the page boundary is the caller's
1797///
1798/// Rows come in ascending `(store_id, root)` and a caller resumes from [`after`](Self::after) — the
1799/// key of the last row it was actually HANDED. An out-of-range [`limit`](Self::limit) is REFUSED
1800/// rather than clamped, matching [`WalletCoinsByParentParams`] and for the same reason: a silently
1801/// shrunk page hands back a cursor for a position the caller did not ask about.
1802#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
1803pub struct MirrorBondStatesParams {
1804    /// Resume STRICTLY AFTER this `(store_id, root)`, in ascending key order. `None` starts at the
1805    /// first bond.
1806    ///
1807    /// The value the previous page handed back as
1808    /// [`cursor`](results::MirrorBondStatesResult::Known::cursor) — never a key the caller kept for
1809    /// another reason. Resuming by `store_id` alone would drop every remaining root of the store
1810    /// the boundary fell inside.
1811    ///
1812    /// Both halves are LOWERCASE 64-hex, unprefixed, and a `0x` prefix is tolerated on input and
1813    /// normalized away by [`validated`](Self::validated). Anything else is REFUSED — see there for
1814    /// why a malformed cursor must never be read as "start from the beginning".
1815    #[serde(default, skip_serializing_if = "Option::is_none")]
1816    pub after: Option<results::MirrorBondKey>,
1817    /// The page size. `None` asks for [`MIRROR_BOND_STATES_DEFAULT_LIMIT`].
1818    ///
1819    /// A zero, or a value above [`MIRROR_BOND_STATES_MAX_LIMIT`], is REFUSED as `INVALID_PARAMS`
1820    /// rather than clamped — see [`Self::validated`].
1821    #[serde(default, skip_serializing_if = "Option::is_none")]
1822    pub limit: Option<u32>,
1823}
1824
1825impl MirrorBondStatesParams {
1826    /// The page size this request asks for, resolving `None` to
1827    /// [`MIRROR_BOND_STATES_DEFAULT_LIMIT`].
1828    ///
1829    /// Stated once here so the node and the client cannot resolve the same omitted field to two
1830    /// different numbers.
1831    pub fn effective_limit(&self) -> u32 {
1832        self.limit.unwrap_or(MIRROR_BOND_STATES_DEFAULT_LIMIT)
1833    }
1834
1835    /// Check the page bound, or reject as `-32602 INVALID_PARAMS`.
1836    ///
1837    /// `limit: 0` is refused because a page that can hold nothing makes no progress: a caller
1838    /// looping until `complete` would loop forever. A limit above the cap is refused rather than
1839    /// clamped so the caller's model of the page and the node's stay identical.
1840    ///
1841    /// [`after`](Self::after) is normalized on the same terms as every other hex id in this crate
1842    /// ([`WalletCoinsParams::validated`]) and a malformed one is REFUSED rather than dropped. That
1843    /// refusal is the point: this order is ascending over the key's STRING form, so a
1844    /// `0x`-prefixed key sorts before every canonical one and a node that quietly ignored it would
1845    /// RESTART the walk while looking like it resumed. On the surface a locked-$DIG total is
1846    /// summed from, a silently repeated page is wrong in the reassuring direction, and it is
1847    /// indistinguishable from a correct answer. Coercing an unparseable cursor to start-of-set is
1848    /// the same defect by another route, and it would contradict this method's own
1849    /// refuse-don't-clamp rule for `limit`.
1850    pub fn validated(self) -> Result<Self, ControlError> {
1851        if let Some(limit) = self.limit {
1852            if limit == 0 || limit > MIRROR_BOND_STATES_MAX_LIMIT {
1853                return Err(ControlError::of(
1854                    ControlErrorCode::InvalidParams,
1855                    MIRROR_BOND_STATES_LIMIT_ERROR,
1856                ));
1857            }
1858        }
1859        let after = self.after.map(normalize_bond_key).transpose()?;
1860        Ok(MirrorBondStatesParams { after, ..self })
1861    }
1862}
1863
1864/// Normalize both halves of a bond cursor, or reject the whole key.
1865///
1866/// Reuses [`normalize_coin_id`] rather than restating the rule: a store id, a root and a coin id
1867/// are all 32-byte hex on this wire, and a second copy of the rule is how two of three end up
1868/// accepting different spellings.
1869fn normalize_bond_key(key: results::MirrorBondKey) -> Result<results::MirrorBondKey, ControlError> {
1870    let malformed = || ControlError::of(ControlErrorCode::InvalidParams, MIRROR_BOND_AFTER_ERROR);
1871    let store_id = normalize_coin_id(&key.store_id)
1872        .ok_or_else(malformed)?
1873        .to_owned();
1874    let root = normalize_coin_id(&key.root)
1875        .ok_or_else(malformed)?
1876        .to_owned();
1877    Ok(results::MirrorBondKey { store_id, root })
1878}
1879
1880impl<'de> Deserialize<'de> for MirrorBondStatesParams {
1881    /// Validates on the way in, so a node cannot forget to call [`Self::validated`].
1882    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1883    where
1884        D: serde::Deserializer<'de>,
1885    {
1886        #[derive(Deserialize)]
1887        struct Raw {
1888            #[serde(default)]
1889            after: Option<results::MirrorBondKey>,
1890            #[serde(default)]
1891            limit: Option<u32>,
1892        }
1893        let raw = Raw::deserialize(deserializer)?;
1894        MirrorBondStatesParams {
1895            after: raw.after,
1896            limit: raw.limit,
1897        }
1898        .validated()
1899        .map_err(serde::de::Error::custom)
1900    }
1901}
1902control_call!(MirrorBondStatesParams => ControlMethod::MirrorBondStates, results::MirrorBondStatesResult);
1903
1904/// `control.mirror.reconcile` params: reconcile this node's mirror coins to its CURRENT advertise
1905/// URL.
1906///
1907/// # `dry_run` prices the plan without spending
1908///
1909/// `true` returns [`results::MirrorReconcileResult::Planned`] — how many capsules are stale, how
1910/// many are affordable, the collateral round-trip and fee — and spends nothing. `false` (the
1911/// default when omitted) executes the plan for real. Unlike [`WalletResetCoinDbParams::confirm`],
1912/// an omitted field here does NOT
1913/// refuse the call: this field means "did the caller ask to preview", not "did the caller confirm
1914/// a destructive action", so omitting it reads as "reconcile for real", the same as writing
1915/// `false` explicitly.
1916///
1917/// # This call takes no other parameter
1918///
1919/// There is no URL to pass: the URL this reconciles TOWARD is whatever this node is CURRENTLY
1920/// configured to advertise (dig-node#562's derived-or-override view), never a value the caller
1921/// supplies. A caller wanting a DIFFERENT URL sets it first via
1922/// [`SetMirrorAdvertiseUrlsParams`], then reconciles.
1923#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1924pub struct MirrorReconcileParams {
1925    /// Price the plan without spending. Defaults to `false` when omitted — an omitted field
1926    /// reconciles for REAL.
1927    #[serde(default)]
1928    pub dry_run: bool,
1929}
1930control_call!(MirrorReconcileParams => ControlMethod::MirrorReconcile, results::MirrorReconcileResult);
1931
1932#[cfg(test)]
1933mod tests {
1934    use super::*;
1935    use crate::traits::build_request;
1936    use serde_json::json;
1937
1938    /// An asset id that is emphatically NOT $DIG, so a round-trip through it cannot be satisfied
1939    /// by an implementation that only ever answers about $DIG.
1940    const OTHER_CAT_HEX: &str = "1c2b3a4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809";
1941
1942    /// The legacy spellings are what an ALREADY-DEPLOYED dig-node and dig-app emit today. This is
1943    /// the load-bearing additive-discipline test (§5.1): if widening the enum ever stops accepting
1944    /// them, every peer built before this release becomes unreadable.
1945    #[test]
1946    fn legacy_xch_and_dig_spellings_still_deserialize() {
1947        assert_eq!(
1948            serde_json::from_value::<Asset>(json!("xch")).unwrap(),
1949            Asset::Xch
1950        );
1951        assert_eq!(
1952            serde_json::from_value::<Asset>(json!("dig")).unwrap(),
1953            Asset::DIG
1954        );
1955    }
1956
1957    /// $DIG keeps EMITTING its legacy token. A newer client talking to an OLDER node must still be
1958    /// understood, and an older node knows only `"xch"`/`"dig"` — so emitting `{"cat":…}` for $DIG
1959    /// would break the compatibility direction the legacy test above cannot see.
1960    #[test]
1961    fn dig_still_serializes_to_its_legacy_token() {
1962        assert_eq!(serde_json::to_value(Asset::DIG).unwrap(), json!("dig"));
1963        assert_eq!(serde_json::to_value(Asset::Xch).unwrap(), json!("xch"));
1964    }
1965
1966    /// The new capability: an arbitrary CAT, named by asset id, survives a full round-trip.
1967    #[test]
1968    fn an_arbitrary_cat_round_trips_by_asset_id() {
1969        let asset = Asset::Cat(AssetId::from_hex(OTHER_CAT_HEX).unwrap());
1970        let wire = serde_json::to_value(asset).unwrap();
1971        assert_eq!(wire, json!({ "cat": OTHER_CAT_HEX }));
1972        assert_eq!(serde_json::from_value::<Asset>(wire).unwrap(), asset);
1973    }
1974
1975    /// $DIG has exactly ONE value, however it was spelled on the wire.
1976    ///
1977    /// This is the test that rules out a three-variant `{Xch, Dig, Cat(id)}`, where
1978    /// `Dig != Cat(DIG_ASSET_ID)` and a balance filtered by one spelling silently omits the coins
1979    /// carrying the other — a wallet reporting half a balance as if it were the whole.
1980    #[test]
1981    fn dig_spelled_either_way_is_one_and_the_same_value() {
1982        let via_token: Asset = serde_json::from_value(json!("dig")).unwrap();
1983        let via_asset_id: Asset =
1984            serde_json::from_value(json!({ "cat": Asset::DIG_ASSET_ID_HEX })).unwrap();
1985        assert_eq!(via_token, via_asset_id);
1986        assert_eq!(via_token, Asset::DIG);
1987        assert!(via_asset_id.is_dig());
1988    }
1989
1990    /// The 64-hex length is a published bound, so it is pinned from BOTH sides: one nibble short
1991    /// and one nibble long must both fail, and exactly 64 must pass.
1992    #[test]
1993    fn asset_id_hex_length_is_bounded_on_both_sides() {
1994        assert!(AssetId::from_hex(&"a".repeat(63)).is_err());
1995        assert!(AssetId::from_hex(&"a".repeat(64)).is_ok());
1996        assert!(AssetId::from_hex(&"a".repeat(65)).is_err());
1997    }
1998
1999    /// Input is tolerant of the two forms a human copies out of a block explorer; output is not.
2000    #[test]
2001    fn asset_id_normalizes_prefix_and_case_but_emits_lowercase_unprefixed() {
2002        let upper = OTHER_CAT_HEX.to_uppercase();
2003        let canonical = AssetId::from_hex(OTHER_CAT_HEX).unwrap();
2004        assert_eq!(AssetId::from_hex(&upper).unwrap(), canonical);
2005        assert_eq!(AssetId::from_hex(&format!("0x{upper}")).unwrap(), canonical);
2006        assert_eq!(canonical.to_hex(), OTHER_CAT_HEX);
2007    }
2008
2009    /// The $DIG asset id is written twice in this file — once as hex for callers, once as bytes so
2010    /// [`Asset::DIG`] can be `const`. A typo in either would make `"dig"` and the tagged form name
2011    /// different tokens, which is precisely the split this design exists to prevent.
2012    #[test]
2013    fn dig_asset_id_hex_and_bytes_are_the_same_id() {
2014        let from_hex = AssetId::from_hex(Asset::DIG_ASSET_ID_HEX).unwrap();
2015        assert_eq!(Asset::DIG.asset_id(), Some(&from_hex));
2016        assert_eq!(from_hex.to_hex(), Asset::DIG_ASSET_ID_HEX);
2017        assert_eq!(Asset::Xch.asset_id(), None);
2018    }
2019
2020    /// A rejection has to say WHICH way the input was wrong, or the caller is left guessing at a
2021    /// value it can see but cannot fix.
2022    #[test]
2023    fn parse_errors_name_the_defect_and_reach_the_wire() {
2024        assert_eq!(
2025            AssetId::from_hex("ab").unwrap_err(),
2026            AssetIdParseError::WrongLength { got: 2 }
2027        );
2028        assert!(AssetIdParseError::WrongLength { got: 2 }
2029            .to_string()
2030            .contains("64 hex characters"));
2031        assert!(AssetIdParseError::NotHex
2032            .to_string()
2033            .contains("non-hexadecimal"));
2034        assert!(AssetId::from_hex(&"3c".repeat(32))
2035            .unwrap()
2036            .to_string()
2037            .starts_with("3c3c"));
2038        // The deserializer surfaces the reason rather than a bare "invalid value".
2039        let err = serde_json::from_value::<Asset>(json!({ "cat": "zz" })).unwrap_err();
2040        assert!(err.to_string().contains("64 hex characters"), "{err}");
2041    }
2042
2043    #[test]
2044    fn malformed_assets_are_rejected_rather_than_guessed_at() {
2045        assert!(AssetId::from_hex(&"z".repeat(64)).is_err());
2046        // An unknown bare token is not silently treated as a CAT name.
2047        assert!(serde_json::from_value::<Asset>(json!("usdc")).is_err());
2048        // The tagged form carries exactly one recognized key.
2049        assert!(serde_json::from_value::<Asset>(json!({})).is_err());
2050        assert!(serde_json::from_value::<Asset>(json!({ "tail": OTHER_CAT_HEX })).is_err());
2051        assert!(serde_json::from_value::<Asset>(json!({ "cat": "ab" })).is_err());
2052    }
2053
2054    /// The params types that CARRY an asset must carry the widened one — the whole point of the
2055    /// change is that these two questions become askable about an arbitrary CAT.
2056    #[test]
2057    fn balance_and_coins_reads_can_name_an_arbitrary_cat() {
2058        let cat = Asset::Cat(AssetId::from_hex(OTHER_CAT_HEX).unwrap());
2059        assert_eq!(
2060            serde_json::to_value(WalletBalanceParams {
2061                address: "xch1exampleaddr".into(),
2062                asset: cat,
2063            })
2064            .unwrap(),
2065            json!({ "address": "xch1exampleaddr", "asset": { "cat": OTHER_CAT_HEX } })
2066        );
2067        assert_eq!(
2068            serde_json::to_value(WalletCoinsParams::first_page("xch1exampleaddr", cat)).unwrap(),
2069            json!({ "address": "xch1exampleaddr", "asset": { "cat": OTHER_CAT_HEX } })
2070        );
2071    }
2072
2073    #[test]
2074    fn no_param_call_serializes_params_to_empty_object() {
2075        let req = build_request(1.into(), &StatusParams {});
2076        assert_eq!(req.method, "control.status");
2077        assert_eq!(req.params, json!({}));
2078    }
2079
2080    #[test]
2081    fn data_param_call_carries_its_fields() {
2082        let req = build_request(2.into(), &SetCapParams { cap_bytes: 128 });
2083        assert_eq!(req.method, "control.cache.setCap");
2084        assert_eq!(req.params, json!({ "cap_bytes": 128 }));
2085    }
2086
2087    #[test]
2088    fn pause_omits_until_when_indefinite() {
2089        assert_eq!(
2090            serde_json::to_value(PauseParams { until: None }).unwrap(),
2091            json!({})
2092        );
2093        assert_eq!(
2094            serde_json::to_value(PauseParams { until: Some(99) }).unwrap(),
2095            json!({ "until": 99 })
2096        );
2097    }
2098
2099    #[test]
2100    fn method_binding_matches_the_catalog_name() {
2101        assert_eq!(
2102            SetUpstreamParams::METHOD.name(),
2103            "control.config.setUpstream"
2104        );
2105        assert_eq!(RequestParams::METHOD.name(), "pairing.request");
2106        assert_eq!(PollParams::METHOD.name(), "pairing.poll");
2107    }
2108
2109    /// **Two spellings of one address canonicalise to one key — which is what makes `remove` able
2110    /// to find what `add` stored.**
2111    ///
2112    /// The fixture is deliberately not a pair of identical strings: each pair differs in the ways
2113    /// a person actually types an address (case, leading zeroes, an uncompressed run, whitespace),
2114    /// so a canonicaliser that only trimmed would pass the last pair and fail the rest.
2115    #[test]
2116    fn spellings_of_the_same_peer_canonicalise_to_one_key() {
2117        for (typed, also_typed) in [
2118            ("2001:0db8:0000:0000:0000:0000:0000:0001", "2001:DB8::1"),
2119            ("::1", "0:0:0:0:0:0:0:1"),
2120            (" 203.0.113.7 ", "203.0.113.7"),
2121        ] {
2122            let a = canonical_peer_ip(typed).expect("typed form is a literal");
2123            let b = canonical_peer_ip(also_typed).expect("second form is a literal");
2124            assert_eq!(a, b, "{typed} and {also_typed} name one peer");
2125        }
2126        // And the canonical form is the compressed lowercase one, not merely SOME agreed form.
2127        assert_eq!(canonical_peer_ip("2001:DB8:0:0::1").unwrap(), "2001:db8::1");
2128    }
2129
2130    /// **Anything that is not a bare IP literal is REFUSED.**
2131    ///
2132    /// This is the bound on the ban list: `remove {ban: true}` persists a row keyed by this
2133    /// string, so accepting arbitrary text is unbounded at-rest growth for the cost of one call.
2134    /// Each rejected case is one a naive implementation accepts: a hostname resolves, a bracketed
2135    /// form round-trips through some parsers, and `ip:port` is what a person copies out of a log.
2136    #[test]
2137    fn a_peer_ip_that_is_not_a_bare_literal_is_refused() {
2138        for bad in [
2139            "",
2140            "   ",
2141            "node.example.com",
2142            "[2001:db8::1]",
2143            "[2001:db8::1]:8444",
2144            "203.0.113.7:8444",
2145            "203.0.113.0/24",
2146            "not an address",
2147        ] {
2148            let Err(err) = canonical_peer_ip(bad) else {
2149                panic!("{bad:?} must be refused, it was accepted");
2150            };
2151            assert_eq!(
2152                err.code_enum(),
2153                Some(ControlErrorCode::InvalidParams),
2154                "{bad:?} must be an INVALID_PARAMS refusal, not some other failure"
2155            );
2156        }
2157    }
2158
2159    /// **Joining an address to a port brackets IPv6 — the un-bracketed form is a DIFFERENT valid
2160    /// address, so the mistake survives validation.**
2161    ///
2162    /// `::1` + `8444` formatted by hand is `::1:8444`, which parses fine and points somewhere
2163    /// else. The assertion is therefore not merely "it contains brackets": the naive rendering is
2164    /// checked to be a parseable address that is NOT the peer, which is what makes the bug silent.
2165    #[test]
2166    fn joining_a_v6_peer_to_a_port_cannot_produce_a_different_address() {
2167        assert_eq!(chia_peer_endpoint("::1", 8444), "[::1]:8444");
2168        assert_eq!(
2169            chia_peer_endpoint("2001:db8::1", 8444),
2170            "[2001:db8::1]:8444"
2171        );
2172        assert_eq!(chia_peer_endpoint("203.0.113.7", 8444), "203.0.113.7:8444");
2173
2174        let naive = format!("{}:{}", "::1", 8444);
2175        let hijacked: std::net::IpAddr =
2176            naive.parse().expect("the naive join is itself an address");
2177        assert_ne!(
2178            hijacked,
2179            "::1".parse::<std::net::IpAddr>().unwrap(),
2180            "the naive join must be a DIFFERENT address — that is why the helper exists"
2181        );
2182        assert_ne!(chia_peer_endpoint("::1", 8444), naive);
2183    }
2184}
2185
2186/// The default local safety margin, in basis points — `+1%`.
2187///
2188/// The same value as `dig_mirror_collateral::SAFETY_MARGIN_BP_DEFAULT`, restated here rather than
2189/// imported: `dig-mirror-collateral` sits at the SAME crate level as this contract, and a
2190/// same-level dependency is forbidden (CLAUDE.md Appendix B). It is published on the contract so a
2191/// config written before the field existed loads as the default rather than as a zero margin.
2192///
2193/// The default errs HIGH because the failure is asymmetric: under-posting likely costs an epoch's
2194/// rewards, while over-posting costs only the opportunity cost of the locked $DIG.
2195pub const DEFAULT_SAFETY_MARGIN_BP: u64 = 100;
2196
2197/// The largest safety margin a node accepts, in basis points — `10_000`, i.e. +100%.
2198///
2199/// A margin is a cushion against the requirement rising, so doubling the requirement is already far
2200/// past any honest cushion. The bound exists because `.set` is a MONEY-PATH mutation reachable with
2201/// an ordinary paired token: an unbounded `u64` lets a caller commit the operator to locking an
2202/// arbitrary multiple of every store's requirement, and the margin arithmetic saturates rather than
2203/// failing, so an absurd value produces a silently enormous posting instead of an error.
2204pub const MAX_SAFETY_MARGIN_BP: u64 = 10_000;
2205
2206no_params!(
2207    /// `control.collateral.requirement` params (none).
2208    ///
2209    /// The caller supplies no epoch: the answer is the epoch the NODE currently derives, so a
2210    /// caller-named epoch would invite a client to render a requirement for an epoch that is not
2211    /// the one being posted against.
2212    CollateralRequirementParams => ControlMethod::CollateralRequirement,
2213    results::CollateralRequirementResult
2214);
2215/// The horizon, in future epochs, a node covers when it recommends a buffer without being told
2216/// otherwise — `4`.
2217///
2218/// Published on the contract so a client can recognise the ordinary case, NOT so it can assume one:
2219/// the horizon a node actually used always travels in
2220/// [`CollateralBufferResult::Known`](crate::results::CollateralBufferResult::Known), and a reader
2221/// that substituted this constant for a payload it failed to read would state a claim the node
2222/// never made.
2223///
2224/// Four epochs bounds the compounded escalation ceiling at roughly x1.60. Fewer leaves a node one
2225/// bad epoch from `dangerously_low`; many more prices in a x4.62 worst case the controller reaches
2226/// only by escalating every single epoch, and locking $DIG against it has a real opportunity cost.
2227pub const DEFAULT_BUFFER_HORIZON_EPOCHS: u32 = 4;
2228
2229/// The per-epoch escalation step denominator — `8`, i.e. the requirement can rise by at most
2230/// `+1/8` (`+12.5%`) in one epoch.
2231///
2232/// The same value as `dig_mirror_collateral::UP_STEP_DENOM`, restated here for the same reason
2233/// [`DEFAULT_SAFETY_MARGIN_BP`] is: `dig-mirror-collateral` sits at the SAME crate level as this
2234/// contract and a same-level dependency is forbidden (CLAUDE.md Appendix B).
2235///
2236/// It is a CEILING on one epoch's rise, and it compounds across epochs — which is precisely why the
2237/// horizon must travel with any buffer derived from it. It is not a forecast: inside the
2238/// controller's dead band the requirement does not move at all.
2239pub const ESCALATION_UP_STEP_DENOM: u64 = 8;
2240
2241no_params!(
2242    /// `control.collateral.margin.get` params (none).
2243    CollateralMarginGetParams => ControlMethod::CollateralMarginGet,
2244    results::CollateralMarginResult
2245);
2246
2247/// `control.collateral.margin.set` params.
2248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2249pub struct CollateralMarginSetParams {
2250    /// The margin in BASIS POINTS over the requirement (`100` is +1%), at most
2251    /// [`MAX_SAFETY_MARGIN_BP`].
2252    ///
2253    /// Basis points, never a percentage and never a float: it is the unit
2254    /// `dig_mirror_collateral::apply_safety_margin` takes and the unit dig-app `SPEC.md` §3.7b
2255    /// fixes for `collateral.margin_bp`. A 1 bp margin (0.01%) is a legal choice and any conversion
2256    /// to whole percent would erase it.
2257    pub margin_bp: u64,
2258}
2259
2260impl CollateralMarginSetParams {
2261    /// Refuse a margin above [`MAX_SAFETY_MARGIN_BP`] as `-32602 INVALID_PARAMS`.
2262    ///
2263    /// Refused rather than clamped, and the asymmetry with dig-app is deliberate. dig-app CLAMPS a
2264    /// stored margin that exceeds its own ceiling, because refusing a value already on disk would
2265    /// leave the node posting the lower amount it was trying to move away from. This is the
2266    /// opposite situation: a caller is stating an intent right now, and silently applying a
2267    /// different number than the one requested would make a subsequent
2268    /// [`CollateralMarginResult`](crate::results::CollateralMarginResult) disagree with what the
2269    /// caller believes it set — on the money path.
2270    pub fn validated(self) -> Result<Self, ControlError> {
2271        if self.margin_bp > MAX_SAFETY_MARGIN_BP {
2272            return Err(ControlError::of(
2273                ControlErrorCode::InvalidParams,
2274                format!(
2275                    "margin_bp must be at most {MAX_SAFETY_MARGIN_BP} basis points (+100%); got {}",
2276                    self.margin_bp
2277                ),
2278            ));
2279        }
2280        Ok(self)
2281    }
2282}
2283control_call!(CollateralMarginSetParams => ControlMethod::CollateralMarginSet, results::CollateralMarginResult);
2284
2285no_params!(
2286    /// `control.collateral.buffer` params (none).
2287    ///
2288    /// The caller names neither an epoch nor a horizon. Both are the NODE's: the served set and
2289    /// the reclaim state the buffer rests on exist only for the epoch being posted against, and a
2290    /// caller-chosen horizon would let a client quietly shrink the recommendation on a money
2291    /// surface by asking for a shorter one. The horizon the node used is returned instead.
2292    CollateralBufferParams => ControlMethod::CollateralBuffer,
2293    results::CollateralBufferResult
2294);