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/// `control.log.setLevel` params.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct SetLevelParams {
96    /// An `EnvFilter` directive, e.g. `"debug"` or `"info,dig_node_core=debug"`.
97    pub filter: String,
98}
99control_call!(SetLevelParams => ControlMethod::LogSetLevel, results::SetLevelResult);
100
101/// `control.cache.setCap` params.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct SetCapParams {
104    /// The cache size cap in bytes (floored at 64 MiB by the node).
105    pub cap_bytes: u64,
106}
107control_call!(SetCapParams => ControlMethod::CacheSetCap, results::SetCapResult);
108
109/// `control.hostedStores.pin` params.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct PinParams {
112    /// A store reference: `storeId` or `storeId:rootHash`.
113    pub store: String,
114}
115control_call!(PinParams => ControlMethod::HostedStoresPin, results::PinResult);
116
117/// `control.hostedStores.unpin` params.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct UnpinParams {
120    /// A store reference: `storeId` or `storeId:rootHash`.
121    pub store: String,
122}
123control_call!(UnpinParams => ControlMethod::HostedStoresUnpin, results::UnpinResult);
124
125/// `control.hostedStores.status` params.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct HostedStoreStatusParams {
128    /// A store reference: `storeId` or `storeId:rootHash`.
129    pub store: String,
130}
131control_call!(HostedStoreStatusParams => ControlMethod::HostedStoresStatus, results::HostedStoreStatusResult);
132
133/// `control.sync.trigger` params.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct SyncTriggerParams {
136    /// A capsule reference: `storeId:rootHash` (a concrete root is required).
137    pub store: String,
138}
139control_call!(SyncTriggerParams => ControlMethod::SyncTrigger, results::SyncTriggerResult);
140
141/// `control.updater.setChannel` params.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct SetChannelParams {
144    /// The update channel (`"nightly"` | `"stable"`; the beacon CLI is the sole validator).
145    pub channel: String,
146}
147control_call!(SetChannelParams => ControlMethod::UpdaterSetChannel, serde_json::Value);
148
149/// `control.updater.pause` params.
150#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
151pub struct PauseParams {
152    /// The unix-seconds time to pause until; omit to pause indefinitely.
153    #[serde(skip_serializing_if = "Option::is_none", default)]
154    pub until: Option<u64>,
155}
156control_call!(PauseParams => ControlMethod::UpdaterPause, serde_json::Value);
157
158/// `control.pairing.approve` params.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct ApproveParams {
161    /// The pending pairing's id (from `pairing.request`).
162    pub pairing_id: String,
163}
164control_call!(ApproveParams => ControlMethod::PairingApprove, results::PairingApproveResult);
165
166/// `control.pairing.revoke` params.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct RevokeParams {
169    /// The short id of the paired token to revoke.
170    pub token_id: String,
171}
172control_call!(RevokeParams => ControlMethod::PairingRevoke, results::PairingRevokeResult);
173
174/// `control.peers.connect` params.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176pub struct PeersConnectParams {
177    /// A peer address to dial, or an already-connected peer_id to resolve.
178    pub peer: String,
179}
180control_call!(PeersConnectParams => ControlMethod::PeersConnect, results::PeersConnectResult);
181
182/// `control.peers.disconnect` params.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184pub struct PeersDisconnectParams {
185    /// The peer_id to drop.
186    pub peer: String,
187}
188control_call!(PeersDisconnectParams => ControlMethod::PeersDisconnect, results::PeersDisconnectResult);
189
190/// The CANONICAL textual form of a Chia peer address, or an `INVALID_PARAMS` error.
191///
192/// Every `ip` this crate declares — in `control.chiaPeers.add` / `.remove` params and in every
193/// result that echoes one — is a bare IP literal in this form. A conforming node MUST canonicalise
194/// through this function on the way IN and store the result, so `add`, `remove` and `list` all
195/// spell the same peer the same way.
196///
197/// **Why the contract owns this rather than each implementation.** `remove` is the only way to
198/// un-trust a peer that is believed WITHOUT corroboration, and it matches by address. If the form
199/// is left to the implementation, an operator who adds `2001:db8::1` and removes `2001:DB8:0:0::1`
200/// has named the same peer twice and un-trusted nothing — an un-trust that silently does not
201/// happen. Canonicalising is what makes the two spellings one key.
202///
203/// The rules, normatively:
204///
205/// - the value MUST parse as an IPv4 or IPv6 literal ([`std::net::IpAddr`]). A hostname, an empty
206///   string, an `ip:port`, a CIDR block or a bracketed `[..]` form is REJECTED. Rejecting
207///   non-literals is also what bounds the ban list: `remove {ban: true}` persists a row keyed by
208///   this string, so an unvalidated key is unbounded at-rest growth driven by one small call;
209/// - surrounding whitespace is trimmed before parsing, and nothing else is;
210/// - the canonical rendering is [`std::net::IpAddr`]'s own `Display` — dotted-quad for v4, and for
211///   v6 the RFC 5952 lowercase, maximally-compressed form, WITHOUT brackets and WITHOUT a zone id.
212///
213/// Bracketing belongs to the socket-address form, never to this field: see
214/// [`chia_peer_endpoint`], which is the ONLY sanctioned way to join an `ip` to a port.
215///
216/// ```
217/// use dig_node_control_interface::params::canonical_peer_ip;
218/// assert_eq!(canonical_peer_ip(" 2001:0DB8:0000::1 ").unwrap(), "2001:db8::1");
219/// assert!(canonical_peer_ip("[::1]").is_err());
220/// assert!(canonical_peer_ip("node.example.com").is_err());
221/// ```
222pub fn canonical_peer_ip(raw: &str) -> Result<String, ControlError> {
223    raw.trim()
224        .parse::<std::net::IpAddr>()
225        .map(|ip| ip.to_string())
226        .map_err(|_| {
227            ControlError::of(
228                ControlErrorCode::InvalidParams,
229                format!(
230                    "ip must be a bare IPv4 or IPv6 literal (no brackets, no port, no hostname), got: {raw:?}"
231                ),
232            )
233        })
234}
235
236/// Join a canonical peer `ip` to a port, bracketing IPv6 as RFC 3986 requires.
237///
238/// The one place a peer address and a port are ever concatenated. Formatting `"{ip}:{port}"` by
239/// hand is the bug this exists to prevent: `::1` and `8444` render as `::1:8444`, which is not a
240/// malformed string a parser rejects — it is a DIFFERENT valid IPv6 address, so the mistake
241/// survives validation and silently retargets the peer.
242///
243/// ```
244/// use dig_node_control_interface::params::chia_peer_endpoint;
245/// assert_eq!(chia_peer_endpoint("::1", 8444), "[::1]:8444");
246/// assert_eq!(chia_peer_endpoint("203.0.113.7", 8444), "203.0.113.7:8444");
247/// ```
248pub fn chia_peer_endpoint(ip: &str, port: u16) -> String {
249    match ip.trim().parse::<std::net::IpAddr>() {
250        Ok(std::net::IpAddr::V6(v6)) => format!("[{v6}]:{port}"),
251        Ok(std::net::IpAddr::V4(v4)) => format!("{v4}:{port}"),
252        Err(_) => format!("{ip}:{port}"),
253    }
254}
255
256/// The maximum number of BANNED Chia peers a conforming node persists.
257///
258/// A ban is a row written at the request of one small control call and kept across restarts, so
259/// without a ceiling the blocklist is unbounded at-rest state a caller can grow for free. On
260/// overflow a node MUST evict its OLDEST ban rather than refuse the newest — a bounded list that
261/// forgets is recoverable, and a full one that refuses turns the ceiling into a denial of the ban
262/// facility itself.
263///
264/// The value is deliberately generous against the honest use (a handful of misbehaving peers) and
265/// small against the abusive one. Banned entries are enumerable through `control.chiaPeers.list`
266/// and clearable through `control.chiaPeers.remove` with `ban: false`.
267pub const MAX_BANNED_CHIA_PEERS: usize = 256;
268
269/// `control.chiaPeers.add` params — the Chia full node to start TRUSTING.
270///
271/// Trust is the whole point of this call and it is not free: a trusted peer is exempted from the
272/// corroboration this node otherwise requires (NC-12 — dialled peers are untrusted, and agreement
273/// across several concurrently-queried peers is what makes a chain read safe). An implementation
274/// MUST tell the person that before it writes the entry.
275///
276/// The trust NC-12 authorises is the operator declaring a node THEIR OWN. That is what justifies
277/// the unbounded authority the entry carries, and it does not extend to a node somebody else runs.
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct ChiaPeersAddParams {
280    /// The peer's IP address, canonical per [`canonical_peer_ip`] — a bare literal, no brackets,
281    /// no port. The standard full-node port is assumed (the Sage `add_peer` request shape carries
282    /// no port either).
283    pub ip: String,
284}
285control_call!(ChiaPeersAddParams => ControlMethod::ChiaPeersAdd, results::ChiaPeersAddResult);
286
287/// `control.chiaPeers.remove` params — the Chia full node to stop trusting.
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289pub struct ChiaPeersRemoveParams {
290    /// The peer's IP address, canonical per [`canonical_peer_ip`]. Matching is by that canonical
291    /// form, so an address spelled differently from the stored entry still names the same peer.
292    pub ip: String,
293    /// Ban rather than forget: the peer is kept but excluded, so discovery cannot re-add it.
294    /// Absent means `false`, which merely forgets the entry.
295    ///
296    /// A ban is EXACT-match on this one address — never a subnet — and it is local to this node,
297    /// with no effect on any other node's peer set. It persists until removed, bounded by
298    /// [`MAX_BANNED_CHIA_PEERS`], is enumerated by `control.chiaPeers.list` (`banned: true`), and
299    /// is cleared by calling `remove` again with `ban: false`. **Clearing a ban that way grants no
300    /// trust**: `add` also un-bans, but `add` confers the corroboration bypass, so it must not be
301    /// the only route back — an over-broad ban is not a reason to trust the peer it hit.
302    ///
303    /// Every banned peer is one fewer source of agreement. NC-12 rests on several independently
304    /// chosen peers agreeing, so a caller that bans steadily shrinks the honest pool toward the
305    /// peers it chose; that is why banning is on the master-token tier
306    /// ([`ControlMethod::requires_master_token`]) and why the set is bounded and visible.
307    #[serde(default)]
308    pub ban: bool,
309}
310control_call!(ChiaPeersRemoveParams => ControlMethod::ChiaPeersRemove, results::ChiaPeersRemoveResult);
311
312/// WHAT a subscription follows: ordinary content, or a dig-profile.
313///
314/// Serializes to a lowercase, language-neutral wire token (`"capsule"` / `"profile"`).
315///
316/// # Absent means [`Capsule`](SubscriptionKind::Capsule), and that is a contract, not a convenience
317///
318/// Every subscription written before this field existed is untagged, and a node's
319/// `subscriptions.json` on a real machine is FULL of them. A required `kind` would make those rows
320/// fail to deserialize, and a node that cannot read its own subscription file starts with an empty
321/// one — an upgrade that silently unsubscribes a user from everything they had. So the field is
322/// `#[serde(default)]` everywhere it appears, and the default is the meaning those untagged rows
323/// already had: a capsule.
324#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(rename_all = "lowercase")]
326pub enum SubscriptionKind {
327    /// Ordinary store content — the meaning every untagged subscription already carries.
328    #[default]
329    Capsule,
330    /// A dig-profile: the node additionally follows the profile ROOT and syncs its body from peers.
331    Profile,
332}
333
334/// `control.subscribe` params.
335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
336pub struct SubscribeParams {
337    /// The store id to subscribe to.
338    pub store_id: String,
339    /// What the subscription follows. OMITTED means [`SubscriptionKind::Capsule`], so an older
340    /// client's request and an untagged on-disk row both keep their existing meaning.
341    #[serde(default)]
342    pub kind: SubscriptionKind,
343}
344control_call!(SubscribeParams => ControlMethod::Subscribe, results::SubscribeResult);
345
346/// `control.unsubscribe` params.
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
348pub struct UnsubscribeParams {
349    /// The store id to stop watching.
350    pub store_id: String,
351}
352control_call!(UnsubscribeParams => ControlMethod::Unsubscribe, results::UnsubscribeResult);
353
354/// The length of a CAT asset id (TAIL hash) in hex characters: a 32-byte hash.
355pub const ASSET_ID_HEX_LEN: usize = 64;
356
357/// A CAT asset id — the TAIL hash that names one token on the Chia chain.
358///
359/// Stored as the 32 raw bytes rather than a `String` so two spellings of the same id (uppercase,
360/// `0x`-prefixed) cannot compare unequal. Parsing normalizes; emission is always lowercase and
361/// unprefixed.
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
363pub struct AssetId([u8; 32]);
364
365/// Why an asset id could not be parsed. Deliberately small — an id is either 32 bytes of hex or it
366/// is not an id, and guessing at a near-miss is how a wallet ends up reading the wrong token.
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub enum AssetIdParseError {
369    /// Not exactly [`ASSET_ID_HEX_LEN`] hex characters (after an optional `0x` is stripped).
370    WrongLength {
371        /// The length actually supplied.
372        got: usize,
373    },
374    /// A character outside `[0-9a-fA-F]`.
375    NotHex,
376}
377
378impl core::fmt::Display for AssetIdParseError {
379    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
380        match self {
381            Self::WrongLength { got } => write!(
382                f,
383                "asset id must be {ASSET_ID_HEX_LEN} hex characters, got {got}"
384            ),
385            Self::NotHex => f.write_str("asset id contains a non-hexadecimal character"),
386        }
387    }
388}
389
390impl std::error::Error for AssetIdParseError {}
391
392impl AssetId {
393    /// Wrap 32 already-decoded bytes. `const` so canonical ids can be declared as constants.
394    pub const fn new(bytes: [u8; 32]) -> Self {
395        Self(bytes)
396    }
397
398    /// The raw 32 bytes.
399    pub const fn as_bytes(&self) -> &[u8; 32] {
400        &self.0
401    }
402
403    /// Parse a hex asset id. A `0x` prefix and uppercase digits are TOLERATED on input — both are
404    /// what a block explorer prints — and normalized away; neither is ever emitted.
405    pub fn from_hex(hex: &str) -> Result<Self, AssetIdParseError> {
406        let body = hex.strip_prefix("0x").unwrap_or(hex);
407        if body.len() != ASSET_ID_HEX_LEN {
408            return Err(AssetIdParseError::WrongLength { got: body.len() });
409        }
410        let mut bytes = [0u8; 32];
411        for (byte, pair) in bytes.iter_mut().zip(body.as_bytes().chunks_exact(2)) {
412            let hi = decode_hex_digit(pair[0])?;
413            let lo = decode_hex_digit(pair[1])?;
414            *byte = (hi << 4) | lo;
415        }
416        Ok(Self(bytes))
417    }
418
419    /// The canonical wire spelling: lowercase, unprefixed, [`ASSET_ID_HEX_LEN`] characters.
420    pub fn to_hex(&self) -> String {
421        use core::fmt::Write as _;
422        self.0
423            .iter()
424            .fold(String::with_capacity(ASSET_ID_HEX_LEN), |mut acc, byte| {
425                let _ = write!(acc, "{byte:02x}");
426                acc
427            })
428    }
429}
430
431/// Decode one ASCII hex digit into its nibble value.
432fn decode_hex_digit(c: u8) -> Result<u8, AssetIdParseError> {
433    match c {
434        b'0'..=b'9' => Ok(c - b'0'),
435        b'a'..=b'f' => Ok(c - b'a' + 10),
436        b'A'..=b'F' => Ok(c - b'A' + 10),
437        _ => Err(AssetIdParseError::NotHex),
438    }
439}
440
441impl core::fmt::Display for AssetId {
442    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
443        f.write_str(&self.to_hex())
444    }
445}
446
447/// The asset a wallet balance/coin read is denominated in: native XCH, or any CAT by asset id.
448///
449/// # Why there is no separate `Dig` variant
450///
451/// $DIG is a CAT, so it is [`Asset::DIG`] — an associated constant, not a variant. A three-variant
452/// `{Xch, Dig, Cat(id)}` would give $DIG two INEQUAL spellings, and a balance or coin list filtered
453/// by one of them would silently omit everything carrying the other: a wallet reporting half a
454/// balance as though it were the whole. One token, one value.
455///
456/// # Wire form (additive — CLAUDE.md §5.1)
457///
458/// | Value | JSON |
459/// |---|---|
460/// | [`Asset::Xch`] | `"xch"` |
461/// | [`Asset::DIG`] | `"dig"` |
462/// | any other CAT | `{"cat":"<64-hex>"}` |
463///
464/// Both legacy tokens are still ACCEPTED and `"dig"` is still EMITTED, so a node or client built
465/// before this release keeps understanding, and being understood by, one built after it.
466/// `{"cat":"<the $DIG asset id>"}` is also accepted and normalizes to [`Asset::DIG`].
467///
468/// Byte-identical to dig-app's consumer type (`dig-app-core::wallet::state::Asset`), recorded in the
469/// `canonical` skill so the two implementations cannot drift.
470#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
471pub enum Asset {
472    /// Native Chia (XCH), denominated in mojos.
473    Xch,
474    /// A CAT, named by its asset id and denominated in its own base units.
475    Cat(AssetId),
476}
477
478/// The legacy wire token for native Chia.
479const XCH_TOKEN: &str = "xch";
480/// The legacy wire token for $DIG, still emitted so older peers keep understanding this crate.
481const DIG_TOKEN: &str = "dig";
482
483impl Asset {
484    /// Canonical $DIG CAT asset id (TAIL hash) on Chia mainnet, as lowercase hex.
485    ///
486    /// CONTRACT: byte-identical to `dig_constants::DIG_ASSET_ID`, `chip35_dl_coin::DIG_ASSET_ID`,
487    /// and digstore-chain's. It is duplicated here rather than imported because this crate is a
488    /// level-00 foundation crate and may not depend sideways on `dig-constants` (CLAUDE.md
489    /// Appendix B). `dig_asset_id_hex_and_bytes_are_the_same_id` pins THIS crate's two spellings
490    /// of it together; agreement with the ecosystem constant is a source-of-truth obligation on
491    /// whoever changes it, recorded in the `canonical` skill.
492    pub const DIG_ASSET_ID_HEX: &'static str =
493        "a406d3a9de984d03c9591c10d917593b434d5263cabe2b42f6b367df16832f81";
494
495    /// $DIG, the CAT every capsule payment is denominated in.
496    pub const DIG: Asset = Asset::Cat(AssetId::new([
497        0xa4, 0x06, 0xd3, 0xa9, 0xde, 0x98, 0x4d, 0x03, 0xc9, 0x59, 0x1c, 0x10, 0xd9, 0x17, 0x59,
498        0x3b, 0x43, 0x4d, 0x52, 0x63, 0xca, 0xbe, 0x2b, 0x42, 0xf6, 0xb3, 0x67, 0xdf, 0x16, 0x83,
499        0x2f, 0x81,
500    ]));
501
502    /// Whether this asset is $DIG, however it was spelled on the wire.
503    pub fn is_dig(&self) -> bool {
504        *self == Self::DIG
505    }
506
507    /// The CAT asset id, or `None` for native XCH.
508    pub fn asset_id(&self) -> Option<&AssetId> {
509        match self {
510            Self::Xch => None,
511            Self::Cat(id) => Some(id),
512        }
513    }
514}
515
516impl Serialize for Asset {
517    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
518        match self {
519            Self::Xch => serializer.serialize_str(XCH_TOKEN),
520            // $DIG keeps its legacy token: a node built before the widening understands only the
521            // two bare strings, and emitting the tagged form for it would break that direction.
522            other if other.is_dig() => serializer.serialize_str(DIG_TOKEN),
523            Self::Cat(id) => {
524                use serde::ser::SerializeMap as _;
525                let mut map = serializer.serialize_map(Some(1))?;
526                map.serialize_entry("cat", &id.to_hex())?;
527                map.end()
528            }
529        }
530    }
531}
532
533impl<'de> Deserialize<'de> for Asset {
534    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
535        /// The two accepted wire shapes, kept private so the tagged form is the ONLY map that
536        /// parses — an unknown key must be an error, never a silently ignored asset name.
537        #[derive(Deserialize)]
538        #[serde(untagged, deny_unknown_fields)]
539        enum Wire {
540            Token(String),
541            Tagged { cat: String },
542        }
543
544        match Wire::deserialize(deserializer).map_err(|_| {
545            serde::de::Error::custom(
546                "expected \"xch\", \"dig\", or {\"cat\":\"<64-hex asset id>\"}",
547            )
548        })? {
549            Wire::Token(token) if token == XCH_TOKEN => Ok(Self::Xch),
550            Wire::Token(token) if token == DIG_TOKEN => Ok(Self::DIG),
551            Wire::Token(token) => Err(serde::de::Error::custom(format!(
552                "unknown asset {token:?}: expected \"xch\", \"dig\", or {{\"cat\":\"<64-hex asset id>\"}}"
553            ))),
554            Wire::Tagged { cat } => AssetId::from_hex(&cat)
555                .map(Self::Cat)
556                .map_err(serde::de::Error::custom),
557        }
558    }
559}
560
561/// `control.wallet.balance` params: which address + asset to read the balance of.
562///
563/// A READ over the loopback control plane — never a spend. Field names + the [`Asset`] wire form are
564/// byte-identical to dig-app's frozen `BalanceRequest`, so the node reads exactly what dig-app emits.
565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
566pub struct WalletBalanceParams {
567    /// The `xch1…` address to read the balance of.
568    pub address: String,
569    /// The asset to read the balance for.
570    pub asset: Asset,
571}
572control_call!(WalletBalanceParams => ControlMethod::WalletBalance, results::WalletBalanceResult);
573
574/// `control.wallet.coins` params: which address + asset to read spendable coins for.
575///
576/// Field-for-field identical to [`WalletBalanceParams`] — a balance is this read reduced to a sum —
577/// and byte-identical to dig-app's frozen `CoinsRequest`, so adopting the method is a body swap
578/// rather than a re-shape.
579#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
580pub struct WalletCoinsParams {
581    /// The `xch1…` address to read coins for.
582    pub address: String,
583    /// The asset to read coins for.
584    pub asset: Asset,
585}
586control_call!(WalletCoinsParams => ControlMethod::WalletCoins, results::WalletCoinsResult);
587
588/// The length of a coin id in lowercase hex characters: a 32-byte hash.
589const COIN_ID_HEX_LEN: usize = 64;
590
591/// `control.wallet.coinById` params: WHICH coin, named by its own id.
592///
593/// # Why there is no `asset` here
594///
595/// A coin id names one coin on one chain. It is not scoped to an address and not scoped to an
596/// asset, and a node reading a coin record learns neither — so an asset parameter here could only
597/// be a claim the read never checks. The answer's
598/// [`asset`](crate::results::WalletCoinRecord::asset) is `null` for the same reason.
599///
600/// # Why the method exists at all
601///
602/// [`WalletCoinsParams`] answers by ADDRESS and lists UNSPENT coins only. A mint's evidence is the
603/// opposite shape: the created DID coin (which sits at nobody's wallet address) and the funding
604/// coin the mint SPENT (which is, by then, gone from every unspent list). Without a by-id read a
605/// pushed mint can never be observed — a permanent "pending" with the money already spent.
606#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
607pub struct WalletCoinByIdParams {
608    /// The coin id: lowercase 64-hex, unprefixed. A `0x` prefix is TOLERATED on input (block
609    /// explorers print one) and normalized away by [`Self::validated`]; it is never emitted.
610    pub coin_id: String,
611}
612control_call!(WalletCoinByIdParams => ControlMethod::WalletCoinById, results::WalletCoinByIdResult);
613
614/// `control.wallet.coinSpend` params: WHICH coin's spend to read, named by that coin's own id.
615///
616/// # Why the spend and the coin record are separate methods
617///
618/// [`WalletCoinByIdParams`] answers *what is this coin, and was it spent?* — an id, an amount, a
619/// puzzle HASH and two heights. None of that reveals what the spend DID. Reconstructing a lineage
620/// (which is how a dig-profile's DID singleton is followed forward) needs the puzzle REVEAL and the
621/// solution, and those exist only in the spend. A caller holding a coin record alone can see that a
622/// coin is gone and cannot see what it became.
623///
624/// # The id names the SPENT coin, not the spend
625///
626/// A spend has no id of its own on chain; it is identified by the coin it consumed. So the parameter
627/// is the same 64-hex coin id [`WalletCoinByIdParams`] takes, validated by the same rule, and the
628/// two methods are asked with the identical value.
629#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
630pub struct WalletCoinSpendParams {
631    /// The SPENT coin's id: lowercase 64-hex, unprefixed. A `0x` prefix is TOLERATED on input and
632    /// normalized away by [`Self::validated`]; it is never emitted.
633    pub coin_id: String,
634}
635control_call!(WalletCoinSpendParams => ControlMethod::WalletCoinSpend, results::WalletCoinSpendResult);
636
637/// `control.wallet.coinsByParent` params: WHICH coin's direct children to read.
638///
639/// # One hop, and the field name says so
640///
641/// The field is `parent_coin_id` rather than `coin_id` because the coin named here is the one being
642/// asked ABOUT as a parent — it is never the coin the caller wants back. A walk up or down a lineage
643/// is the CALLER's composition of repeated single hops; the node performs exactly one. Naming it
644/// `coin_id` would make a recursive reading of the method plausible from the request alone, and a
645/// caller expecting a whole lineage from one call would read a one-hop answer as a truncated chain.
646///
647/// # Bounded, because a parent's child count is not
648///
649/// This is the only OPEN wallet read whose answer has unbounded cardinality — every other one
650/// returns a single record (`coinById`, `peak`, `syncStatus`) or is already paged (`arrivals`). So
651/// the read is PAGED, and the page is bounded by [`COINS_BY_PARENT_MAX_LIMIT`].
652///
653/// **The bound is the ONLY thing bounding this call — there is no rate limiter anywhere behind it.**
654/// dig-node's control plane has no request rate limiting of any kind (dig_ecosystem#2577); the
655/// bandwidth limiter it does have governs content serving and is not on this path. A future reader
656/// weighing whether to relax this cap should assume no limiter exists, because none does.
657///
658/// That matters more than a local resource bound would, because the node does not necessarily answer
659/// from its own replica: on the fallback tier it forwards a caller-supplied identifier to a
660/// THIRD-PARTY coinset HTTPS oracle. An unbounded page is therefore unbounded work against somebody
661/// else's service, requested by a token-less caller on a loopback endpoint.
662///
663/// Paging rather than a bare cap, because a bare cap is a dead end: a parent with more children than
664/// the cap could never be fully enumerated, and this method exists to WALK a lineage. A walk that
665/// cannot see past the cap is a walk that silently stops.
666#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
667pub struct WalletCoinsByParentParams {
668    /// The PARENT coin's id: lowercase 64-hex, unprefixed. A `0x` prefix is TOLERATED on input and
669    /// normalized away by [`Self::validated`]; it is never emitted.
670    pub parent_coin_id: String,
671    /// Resume STRICTLY AFTER this child, in the read's
672    /// [documented order](results::WalletCoinsByParentResult). `None` starts at the first child.
673    ///
674    /// This is the value the previous page handed back as
675    /// [`cursor`](results::WalletCoinsByParentResult::cursor) — never a value the caller invented,
676    /// and never a marker for where the chain "got to". `control.wallet.arrivals` records why that
677    /// distinction loses rows; this read avoids the trap by having no such marker to reach for.
678    #[serde(default, skip_serializing_if = "Option::is_none")]
679    pub after_coin_id: Option<String>,
680    /// The page size. `None` asks for [`COINS_BY_PARENT_DEFAULT_LIMIT`].
681    ///
682    /// A value above [`COINS_BY_PARENT_MAX_LIMIT`], or a zero, is REFUSED as `INVALID_PARAMS` rather
683    /// than clamped — see [`Self::validated`].
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub limit: Option<u32>,
686}
687control_call!(WalletCoinsByParentParams => ControlMethod::WalletCoinsByParent, results::WalletCoinsByParentResult);
688
689/// `control.wallet.arrivals` — confirmed INCOMING funds recorded since a cursor position.
690///
691/// The answer to "was I just paid?", which neither a balance nor a coin list can give: a balance
692/// moves for the user's OWN change too, and an unspent-coin list cannot say which of its coins are
693/// new. Each row the node returns is a coin it determined ARRIVED — confirmed on chain, above the
694/// wallet's arrival baseline, not previously reported, and not created by spending one of the
695/// wallet's own coins. The determination is the NODE's; a client MUST NOT re-derive it, because the
696/// signals it takes (spent parents, the catch-up baseline) live only in the node's replica.
697///
698/// # A cursor, not a stream
699///
700/// The control envelope is strictly request→response, so this is polled. A client resumes from
701/// [`WalletArrivalsResult::cursor`](results::WalletArrivalsResult::cursor) — the last position it
702/// was actually handed — and NEVER from `latest`; see that field for why the distinction loses a
703/// notification when it is collapsed.
704///
705/// # `after_seq` is unsigned so a rewind is unexpressible
706///
707/// Positions are monotonic and start at 1, so there is no meaning for a negative one. `0` is the
708/// beginning of the ledger and is what a client sends when it deliberately wants everything.
709#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
710pub struct WalletArrivalsParams {
711    /// Return arrivals STRICTLY after this position. `0` starts at the beginning of the ledger,
712    /// and is also what an omitted field means — the same default the node applies.
713    #[serde(default)]
714    pub after_seq: u64,
715    /// The page size. `None` asks for the node's default rather than a number this client invented;
716    /// a node clamps whatever it is given to its own maximum.
717    #[serde(default, skip_serializing_if = "Option::is_none")]
718    pub limit: Option<u32>,
719}
720control_call!(WalletArrivalsParams => ControlMethod::WalletArrivals, results::WalletArrivalsResult);
721
722fn normalize_coin_id(coin_id: &str) -> Option<&str> {
723    let normalized = coin_id.strip_prefix("0x").unwrap_or(coin_id);
724    let well_formed = normalized.len() == COIN_ID_HEX_LEN
725        && normalized
726            .bytes()
727            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
728    well_formed.then_some(normalized)
729}
730
731/// Give a single-coin-id params type its validating `Deserialize` and its `validated` constructor.
732///
733/// The three by-coin reads (`coinById`, `coinSpend`, `coinsByParent`) enforce the IDENTICAL id rule
734/// under three different field names, and the rule is normative rather than incidental — see
735/// [`WalletCoinByIdParams::validated`] for why a malformed id must be refused BEFORE a chain is
736/// consulted. Written once here so a fourth by-coin read cannot arrive with a subtly looser copy of
737/// it, and so a change to the rule cannot land on two of three types.
738macro_rules! coin_id_params {
739    ($ty:ident, $field:ident, $raw:ident, $error:expr) => {
740        impl<'de> Deserialize<'de> for $ty {
741            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
742            where
743                D: serde::Deserializer<'de>,
744            {
745                #[derive(Deserialize)]
746                struct $raw {
747                    $field: String,
748                }
749
750                let raw = $raw::deserialize(deserializer)?;
751                let $field = normalize_coin_id(&raw.$field)
752                    .ok_or_else(|| serde::de::Error::custom($error))?
753                    .to_owned();
754                Ok(Self { $field })
755            }
756        }
757
758        impl $ty {
759            /// Normalize and check the coin id, or reject the request as `-32602 INVALID_PARAMS`.
760            ///
761            /// A malformed id is a malformed REQUEST, and the node refuses it here — before
762            /// consulting any chain. That ordering is normative rather than an optimisation: were a
763            /// bad id allowed through, the read would come back empty, and the caller would be told
764            /// the honest-looking answer *the chain holds nothing* about a coin it never actually
765            /// asked after. An unanswerable question and a chain that answered "no" must never wear
766            /// the same shape.
767            ///
768            /// Accepts exactly two spellings — 64 lowercase hex characters, or the same 64 preceded
769            /// by `0x`. Uppercase, whitespace and every other length are refused, because the
770            /// contract's hex wire form is lowercase and unprefixed everywhere else in this crate.
771            pub fn validated(self) -> Result<Self, crate::error::ControlError> {
772                let normalized = normalize_coin_id(&self.$field).ok_or_else(|| {
773                    crate::error::ControlError::of(
774                        crate::error::ControlErrorCode::InvalidParams,
775                        $error,
776                    )
777                })?;
778                Ok($ty {
779                    $field: normalized.to_owned(),
780                })
781            }
782        }
783    };
784}
785
786const COIN_ID_ERROR: &str = "coin_id must be lowercase 64-hex, optionally 0x-prefixed";
787const PARENT_COIN_ID_ERROR: &str =
788    "parent_coin_id must be lowercase 64-hex, optionally 0x-prefixed";
789const AFTER_COIN_ID_ERROR: &str = "after_coin_id must be lowercase 64-hex, optionally 0x-prefixed";
790
791coin_id_params!(
792    WalletCoinByIdParams,
793    coin_id,
794    RawWalletCoinByIdParams,
795    COIN_ID_ERROR
796);
797coin_id_params!(
798    WalletCoinSpendParams,
799    coin_id,
800    RawWalletCoinSpendParams,
801    COIN_ID_ERROR
802);
803/// The page size `control.wallet.coinsByParent` uses when the caller names none.
804///
805/// A spend in the lineages this read exists to follow — a singleton, a DID, an ordinary transfer —
806/// creates a small handful of children, so one default page covers a realistic hop in a single round
807/// trip and a caller never pages at all.
808pub const COINS_BY_PARENT_DEFAULT_LIMIT: u32 = 100;
809
810/// The largest page `control.wallet.coinsByParent` will accept, derived from the transport's own
811/// frame limit rather than chosen for feel.
812///
813/// dig-ipc-protocol caps a control frame at `MAX_FRAME_BYTES` = 1 MiB (its `SPEC.md` §
814/// bounds), and that is the hard ceiling every answer on this plane has to fit inside. A
815/// [`WalletCoinRecord`](results::WalletCoinRecord) is at most ~350 bytes of JSON — three 64-hex
816/// hashes at 66 bytes quoted, a 20-digit `u64`, two 10-digit heights, and their keys — so the
817/// arithmetic that fixes this number is:
818///
819/// ```text
820/// 1 MiB / 350 B  ~=  2,996 records is where a page STOPS FITTING
821/// 1,000 records  ~=  350 KB, roughly a third of the frame
822/// ```
823///
824/// The cap is set at a third of what fits, not at what fits, so the envelope, the freshness fields
825/// and any future additive member cannot push a legal page over the transport's limit. A larger
826/// value would put the contract's own maximum inside the region where a conforming node's honest
827/// answer is undeliverable — the failure would surface as a truncated frame, not as a refusal.
828pub const COINS_BY_PARENT_MAX_LIMIT: u32 = 1_000;
829
830const COINS_BY_PARENT_LIMIT_ERROR: &str = "limit must be between 1 and 1000";
831
832impl<'de> Deserialize<'de> for WalletCoinsByParentParams {
833    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
834    where
835        D: serde::Deserializer<'de>,
836    {
837        #[derive(Deserialize)]
838        struct RawWalletCoinsByParentParams {
839            parent_coin_id: String,
840            #[serde(default)]
841            after_coin_id: Option<String>,
842            #[serde(default)]
843            limit: Option<u32>,
844        }
845
846        let raw = RawWalletCoinsByParentParams::deserialize(deserializer)?;
847        let parent_coin_id = normalize_coin_id(&raw.parent_coin_id)
848            .ok_or_else(|| serde::de::Error::custom(PARENT_COIN_ID_ERROR))?
849            .to_owned();
850        let after_coin_id = raw
851            .after_coin_id
852            .map(|id| {
853                normalize_coin_id(&id)
854                    .map(str::to_owned)
855                    .ok_or_else(|| serde::de::Error::custom(AFTER_COIN_ID_ERROR))
856            })
857            .transpose()?;
858        if !raw.limit.map_or(true, is_legal_page) {
859            return Err(serde::de::Error::custom(COINS_BY_PARENT_LIMIT_ERROR));
860        }
861        Ok(Self {
862            parent_coin_id,
863            after_coin_id,
864            limit: raw.limit,
865        })
866    }
867}
868
869/// Is this a page size the contract accepts — at least one row, at most the frame-derived maximum?
870fn is_legal_page(limit: u32) -> bool {
871    (1..=COINS_BY_PARENT_MAX_LIMIT).contains(&limit)
872}
873
874impl WalletCoinsByParentParams {
875    /// A first page of children for one parent: the node's default size, starting at the beginning.
876    ///
877    /// The common case, and the one a caller should not have to spell out — naming a page size means
878    /// asserting a number this caller invented over the one the contract chose.
879    pub fn first_page(parent_coin_id: impl Into<String>) -> Self {
880        Self {
881            parent_coin_id: parent_coin_id.into(),
882            after_coin_id: None,
883            limit: None,
884        }
885    }
886
887    /// The page size this request asks for, resolving `None` to [`COINS_BY_PARENT_DEFAULT_LIMIT`].
888    ///
889    /// Stated once here so a node and a client cannot resolve the same omitted field to two
890    /// different numbers — a disagreement that would show up as a page boundary in the wrong place,
891    /// which is exactly where a paged walk loses rows.
892    pub fn effective_limit(&self) -> u32 {
893        self.limit.unwrap_or(COINS_BY_PARENT_DEFAULT_LIMIT)
894    }
895
896    /// Normalize and check both ids and the page bound, or reject as `-32602 INVALID_PARAMS`.
897    ///
898    /// The ids follow the rule every by-coin read in this crate follows (lowercase 64-hex, `0x`
899    /// tolerated on input and never emitted).
900    ///
901    /// An out-of-range `limit` is REFUSED, never clamped. That is a deliberate departure from
902    /// `control.wallet.arrivals`, which lets a node clamp: this read's page boundary is what a
903    /// caller RESUMES from, so a silently shrunk page hands back a cursor for a position the caller
904    /// did not ask about, and a caller that believed its own number would mis-size every subsequent
905    /// request. Refusing keeps the caller's model of the page and the node's identical, which is the
906    /// same reason a 65-hex coin id is refused rather than truncated.
907    ///
908    /// `limit: 0` is refused for a separate reason: a page that can hold nothing makes no progress,
909    /// so a caller looping until a page comes back short would loop forever.
910    pub fn validated(self) -> Result<Self, crate::error::ControlError> {
911        fn invalid(message: &'static str) -> crate::error::ControlError {
912            crate::error::ControlError::of(crate::error::ControlErrorCode::InvalidParams, message)
913        }
914
915        let parent_coin_id = normalize_coin_id(&self.parent_coin_id)
916            .ok_or_else(|| invalid(PARENT_COIN_ID_ERROR))?
917            .to_owned();
918        let after_coin_id = self
919            .after_coin_id
920            .as_deref()
921            .map(|id| {
922                normalize_coin_id(id)
923                    .map(str::to_owned)
924                    .ok_or_else(|| invalid(AFTER_COIN_ID_ERROR))
925            })
926            .transpose()?;
927        if !self.limit.map_or(true, is_legal_page) {
928            return Err(invalid(COINS_BY_PARENT_LIMIT_ERROR));
929        }
930        Ok(WalletCoinsByParentParams {
931            parent_coin_id,
932            after_coin_id,
933            limit: self.limit,
934        })
935    }
936}
937
938/// `control.wallet.peak` params — none.
939///
940/// The peak is a property of the node's chain view, not of any address, which is exactly why it is
941/// its own method: [`results::WalletBalanceResult::peak_height`] is `null` on every fallback-tier
942/// answer, so a caller bounding a claimed confirmation cannot rely on getting one from a balance.
943#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
944pub struct WalletPeakParams {}
945control_call!(WalletPeakParams => ControlMethod::WalletPeak, results::WalletPeakResult);
946
947/// `control.peerCounts` params — none.
948///
949/// The counts describe this node's own connectivity on each network, so there is nothing to scope
950/// the question by. One call answers for BOTH networks deliberately: a consumer that had to collect
951/// the DIG count from a peer method and the Chia count from a wallet method is a consumer that can
952/// reach for the wrong one.
953#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
954pub struct PeerCountsParams {}
955control_call!(PeerCountsParams => ControlMethod::PeerCounts, results::PeerCountsResult);
956
957/// `control.wallet.syncStatus` params — none.
958///
959/// The wallet's sync progress is a property of the node's own chain replica, not of any address, so
960/// there is nothing to scope the question by. Deliberately NOT confused with `control.sync.status`,
961/// which reports §21 DIG STORE sync and has nothing to do with the chain.
962#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
963pub struct WalletSyncStatusParams {}
964control_call!(WalletSyncStatusParams => ControlMethod::WalletSyncStatus, results::WalletSyncStatusResult);
965
966/// `control.wallet.broadcast` params: an ALREADY-SIGNED spend bundle to push.
967///
968/// # The custody boundary (§908)
969///
970/// This carries signed bytes and nothing else. There is deliberately no key, no seed, no phrase and
971/// no unsigned-spend-plus-key field here, and there never may be: the node's role on the money path
972/// is to read chain state and to push what somebody else signed. A parameter that let the node
973/// produce a signature would move custody into an identity-agnostic daemon, which is the one thing
974/// the boundary exists to prevent.
975#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
976pub struct WalletBroadcastParams {
977    /// The signed spend bundle: lowercase hex of its chia `Streamable` serialization.
978    pub signed_bundle_hex: String,
979}
980control_call!(WalletBroadcastParams => ControlMethod::WalletBroadcast, results::WalletBroadcastResult);
981
982/// The length of a BLS G1 public key in lowercase hex characters: 48 bytes.
983const PUBLIC_KEY_HEX_LEN: usize = 96;
984
985/// `control.wallet.watch` params: which PUBLIC keys the node should follow.
986///
987/// # Keys, never puzzle hashes
988///
989/// The node already derives addresses from the public keys it holds in its own custody, through one
990/// standard derivation. Enrolling KEYS reuses that exact derivation for a client's keys too, so the
991/// ecosystem has ONE mapping from key to address and a client and a node can never disagree about
992/// which addresses a key covers. Enrolling puzzle hashes instead would make every client re-derive
993/// independently, and a client whose derivation window is narrower than the node's would silently
994/// under-report the money it owns.
995///
996/// # No key material crosses (§908)
997///
998/// A G1 public key is public. There is deliberately no seed, phrase, private key or signature field
999/// here, and there never may be: enrolment tells the node what to WATCH, and watching needs nothing
1000/// a signature could be produced from.
1001///
1002/// # Idempotent, so a client can reconcile without asking first
1003///
1004/// Submitting keys the node already follows is a SUCCESS that changes nothing — see
1005/// [`WalletWatchResult`](results::WalletWatchResult). Duplicates WITHIN one request count once. An
1006/// empty list is accepted and does nothing, because "enrol everything in this (currently empty) set"
1007/// is a reconciliation a client legitimately performs.
1008#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1009pub struct WalletWatchParams {
1010    /// The public keys to enrol: lowercase 96-hex, unprefixed. A `0x` prefix is TOLERATED on input
1011    /// and normalized away by [`Self::validated`]; it is never emitted.
1012    pub public_keys: Vec<String>,
1013}
1014control_call!(WalletWatchParams => ControlMethod::WalletWatch, results::WalletWatchResult);
1015
1016/// `control.wallet.unwatch` params: which PUBLIC keys the node should stop following.
1017///
1018/// Takes the same wire form as [`WalletWatchParams`] under the same field name, so a client
1019/// reverses an enrolment by re-sending exactly what it sent. Deregistering a key that was never
1020/// enrolled is a success, not an error — the mirror of enrolment's idempotence, and what lets a
1021/// client assert an end state rather than compute a diff.
1022#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1023pub struct WalletUnwatchParams {
1024    /// The public keys to deregister: lowercase 96-hex, unprefixed. A `0x` prefix is TOLERATED on
1025    /// input and normalized away by [`Self::validated`]; it is never emitted.
1026    pub public_keys: Vec<String>,
1027}
1028control_call!(WalletUnwatchParams => ControlMethod::WalletUnwatch, results::WalletUnwatchResult);
1029
1030/// `control.wallet.watched` params — none.
1031///
1032/// The enrolled set is a property of the node, so there is nothing to scope the question by. This
1033/// is why the method is TOKEN-GATED although it only reads: the caller supplies nothing, so the
1034/// answer is the node's OWN key set — see [`ControlMethod::is_open_read`].
1035#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1036pub struct WalletWatchedParams {}
1037control_call!(WalletWatchedParams => ControlMethod::WalletWatched, results::WalletWatchedResult);
1038
1039fn normalize_public_key(public_key: &str) -> Option<&str> {
1040    let normalized = public_key.strip_prefix("0x").unwrap_or(public_key);
1041    let well_formed = normalized.len() == PUBLIC_KEY_HEX_LEN
1042        && normalized
1043            .bytes()
1044            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
1045    well_formed.then_some(normalized)
1046}
1047
1048/// Give an enrolment params type its validating `Deserialize` and its `validated` constructor.
1049///
1050/// `watch` and `unwatch` carry the IDENTICAL key list under the identical field name, and must
1051/// enforce the identical rule: a client reverses an enrolment by re-sending what it sent, so a
1052/// spelling one method accepts and the other refuses would leave a key enrolled forever. Written
1053/// once so the two cannot drift apart.
1054macro_rules! public_keys_params {
1055    ($ty:ident, $raw:ident, $error:expr) => {
1056        impl<'de> Deserialize<'de> for $ty {
1057            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1058            where
1059                D: serde::Deserializer<'de>,
1060            {
1061                #[derive(Deserialize)]
1062                struct $raw {
1063                    public_keys: Vec<String>,
1064                }
1065
1066                let raw = $raw::deserialize(deserializer)?;
1067                let public_keys = raw
1068                    .public_keys
1069                    .iter()
1070                    .map(|key| {
1071                        normalize_public_key(key)
1072                            .map(str::to_owned)
1073                            .ok_or_else(|| serde::de::Error::custom($error))
1074                    })
1075                    .collect::<Result<Vec<_>, _>>()?;
1076                Ok(Self { public_keys })
1077            }
1078        }
1079
1080        impl $ty {
1081            /// Normalize and check every key, or reject the request as `-32602 INVALID_PARAMS`.
1082            ///
1083            /// The whole request is refused when ANY key is malformed — never the well-formed
1084            /// subset. A partial enrolment would leave the node following fewer addresses than the
1085            /// client believes it asked for, and the client's next balance read would report a
1086            /// shortfall as though the money were not there. One bad key is a malformed REQUEST.
1087            ///
1088            /// Accepts exactly two spellings per key — 96 lowercase hex characters, or the same 96
1089            /// preceded by `0x`. Uppercase, whitespace and every other length are refused, because
1090            /// the contract's hex wire form is lowercase and unprefixed everywhere else.
1091            pub fn validated(self) -> Result<Self, crate::error::ControlError> {
1092                let public_keys = self
1093                    .public_keys
1094                    .iter()
1095                    .map(|key| {
1096                        normalize_public_key(key).map(str::to_owned).ok_or_else(|| {
1097                            crate::error::ControlError::of(
1098                                crate::error::ControlErrorCode::InvalidParams,
1099                                $error,
1100                            )
1101                        })
1102                    })
1103                    .collect::<Result<Vec<_>, _>>()?;
1104                Ok(Self { public_keys })
1105            }
1106        }
1107    };
1108}
1109
1110const PUBLIC_KEYS_ERROR: &str =
1111    "public_keys must each be lowercase 96-hex (a 48-byte G1 key), optionally 0x-prefixed";
1112
1113public_keys_params!(WalletWatchParams, RawWalletWatch, PUBLIC_KEYS_ERROR);
1114public_keys_params!(WalletUnwatchParams, RawWalletUnwatch, PUBLIC_KEYS_ERROR);
1115
1116/// `pairing.request` params (OPEN — no token).
1117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1118pub struct RequestParams {
1119    /// A human-readable name for the requesting client (shown to the operator).
1120    pub client_name: String,
1121}
1122control_call!(RequestParams => ControlMethod::PairingRequest, results::PairingRequestResult);
1123
1124/// `pairing.poll` params (OPEN — no token).
1125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1126pub struct PollParams {
1127    /// The pairing id to poll.
1128    pub pairing_id: String,
1129}
1130control_call!(PollParams => ControlMethod::PairingPoll, results::PairingPollResult);
1131
1132/// The largest dig-profile body the control plane accepts or returns, in bytes: **4 MiB**.
1133///
1134/// # Why the contract carries the cap instead of the implementation
1135///
1136/// A client that learns the bound by exceeding it learns it as a failed round trip, after paying to
1137/// serialize and send megabytes. Stated here, an app checks before it sends and can tell a person
1138/// *this profile is too large* rather than *something went wrong*.
1139///
1140/// # Why this number
1141///
1142/// It is HALF of dig-gossip's `WS_MAX_MESSAGE_BYTES` (8 MiB), the frame ceiling a body must fit
1143/// inside when a node serves it to a peer over `PROFILE_BODY` (opcode 225). A body accepted here
1144/// but unservable there would be stored and then permanently unsyncable — accepted by the node the
1145/// app talks to and invisible to every other node. The halving leaves room for the framing,
1146/// envelope and base64 expansion that sit around the bytes on that hop.
1147///
1148/// The cap is on the DECODED body, not on the base64 text that carries it.
1149pub const MAX_BODY_BYTES: usize = 4 * 1024 * 1024;
1150
1151/// `control.profile.putBody` params: the profile body a confirmed chain root commits to.
1152///
1153/// # The node CHECKS the root; it does not take the caller's word for it
1154///
1155/// `root` is a CLAIM, and the node's obligation is to refuse it when it is false. An implementation
1156/// MUST independently resolve the profile's root on chain, recompute the root of the supplied body,
1157/// and reject the call unless the two agree and that root is CONFIRMED. dig-app is a caller like
1158/// any other here: it holds the key and signs the root (§908), but the bytes it then hands over
1159/// arrive at the node exactly as a peer's bytes do, and the standing rule for this epic is that a
1160/// body is checked against the on-chain root and anything that does not match is rejected.
1161///
1162/// A method documented as *the caller supplies a matching root* invites an implementation that
1163/// stores what it is given. That implementation turns the control plane into a way to make a node
1164/// serve arbitrary bytes to the network under someone else's profile id.
1165///
1166/// # No key material crosses (§908)
1167///
1168/// There is deliberately no seed, private key, signature or unsigned spend field here, and there
1169/// never may be. The node persists, serves and fetches bodies; it never signs.
1170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1171pub struct ProfilePutBodyParams {
1172    /// The profile's store id: lowercase 64-hex, unprefixed.
1173    pub store_id: String,
1174    /// The root the body is claimed to hash to: lowercase 64-hex, unprefixed. Checked against the
1175    /// chain, never trusted.
1176    pub root: String,
1177    /// The body itself, standard base64 (padded) of its `DPB` serialization. The DECODED length
1178    /// MUST NOT exceed [`MAX_BODY_BYTES`]; a larger body is refused as `INVALID_PARAMS`.
1179    pub body_b64: String,
1180}
1181control_call!(ProfilePutBodyParams => ControlMethod::ProfilePutBody, results::ProfilePutBodyResult);
1182
1183/// `control.profile.getBody` params: WHICH profile body to read, named by store id + root.
1184///
1185/// The root is part of the question rather than part of the answer: a caller asks for the body at a
1186/// root it already knows, so it can never be handed a body for a DIFFERENT root and mistake it for
1187/// the one it asked about. A node holding no body at that root answers `body_b64: null`.
1188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1189pub struct ProfileGetBodyParams {
1190    /// The profile's store id: lowercase 64-hex, unprefixed.
1191    pub store_id: String,
1192    /// The root to read the body at: lowercase 64-hex, unprefixed.
1193    pub root: String,
1194}
1195control_call!(ProfileGetBodyParams => ControlMethod::ProfileGetBody, results::ProfileGetBodyResult);
1196
1197#[cfg(test)]
1198mod tests {
1199    use super::*;
1200    use crate::traits::build_request;
1201    use serde_json::json;
1202
1203    /// An asset id that is emphatically NOT $DIG, so a round-trip through it cannot be satisfied
1204    /// by an implementation that only ever answers about $DIG.
1205    const OTHER_CAT_HEX: &str = "1c2b3a4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809";
1206
1207    /// The legacy spellings are what an ALREADY-DEPLOYED dig-node and dig-app emit today. This is
1208    /// the load-bearing additive-discipline test (§5.1): if widening the enum ever stops accepting
1209    /// them, every peer built before this release becomes unreadable.
1210    #[test]
1211    fn legacy_xch_and_dig_spellings_still_deserialize() {
1212        assert_eq!(
1213            serde_json::from_value::<Asset>(json!("xch")).unwrap(),
1214            Asset::Xch
1215        );
1216        assert_eq!(
1217            serde_json::from_value::<Asset>(json!("dig")).unwrap(),
1218            Asset::DIG
1219        );
1220    }
1221
1222    /// $DIG keeps EMITTING its legacy token. A newer client talking to an OLDER node must still be
1223    /// understood, and an older node knows only `"xch"`/`"dig"` — so emitting `{"cat":…}` for $DIG
1224    /// would break the compatibility direction the legacy test above cannot see.
1225    #[test]
1226    fn dig_still_serializes_to_its_legacy_token() {
1227        assert_eq!(serde_json::to_value(Asset::DIG).unwrap(), json!("dig"));
1228        assert_eq!(serde_json::to_value(Asset::Xch).unwrap(), json!("xch"));
1229    }
1230
1231    /// The new capability: an arbitrary CAT, named by asset id, survives a full round-trip.
1232    #[test]
1233    fn an_arbitrary_cat_round_trips_by_asset_id() {
1234        let asset = Asset::Cat(AssetId::from_hex(OTHER_CAT_HEX).unwrap());
1235        let wire = serde_json::to_value(asset).unwrap();
1236        assert_eq!(wire, json!({ "cat": OTHER_CAT_HEX }));
1237        assert_eq!(serde_json::from_value::<Asset>(wire).unwrap(), asset);
1238    }
1239
1240    /// $DIG has exactly ONE value, however it was spelled on the wire.
1241    ///
1242    /// This is the test that rules out a three-variant `{Xch, Dig, Cat(id)}`, where
1243    /// `Dig != Cat(DIG_ASSET_ID)` and a balance filtered by one spelling silently omits the coins
1244    /// carrying the other — a wallet reporting half a balance as if it were the whole.
1245    #[test]
1246    fn dig_spelled_either_way_is_one_and_the_same_value() {
1247        let via_token: Asset = serde_json::from_value(json!("dig")).unwrap();
1248        let via_asset_id: Asset =
1249            serde_json::from_value(json!({ "cat": Asset::DIG_ASSET_ID_HEX })).unwrap();
1250        assert_eq!(via_token, via_asset_id);
1251        assert_eq!(via_token, Asset::DIG);
1252        assert!(via_asset_id.is_dig());
1253    }
1254
1255    /// The 64-hex length is a published bound, so it is pinned from BOTH sides: one nibble short
1256    /// and one nibble long must both fail, and exactly 64 must pass.
1257    #[test]
1258    fn asset_id_hex_length_is_bounded_on_both_sides() {
1259        assert!(AssetId::from_hex(&"a".repeat(63)).is_err());
1260        assert!(AssetId::from_hex(&"a".repeat(64)).is_ok());
1261        assert!(AssetId::from_hex(&"a".repeat(65)).is_err());
1262    }
1263
1264    /// Input is tolerant of the two forms a human copies out of a block explorer; output is not.
1265    #[test]
1266    fn asset_id_normalizes_prefix_and_case_but_emits_lowercase_unprefixed() {
1267        let upper = OTHER_CAT_HEX.to_uppercase();
1268        let canonical = AssetId::from_hex(OTHER_CAT_HEX).unwrap();
1269        assert_eq!(AssetId::from_hex(&upper).unwrap(), canonical);
1270        assert_eq!(AssetId::from_hex(&format!("0x{upper}")).unwrap(), canonical);
1271        assert_eq!(canonical.to_hex(), OTHER_CAT_HEX);
1272    }
1273
1274    /// The $DIG asset id is written twice in this file — once as hex for callers, once as bytes so
1275    /// [`Asset::DIG`] can be `const`. A typo in either would make `"dig"` and the tagged form name
1276    /// different tokens, which is precisely the split this design exists to prevent.
1277    #[test]
1278    fn dig_asset_id_hex_and_bytes_are_the_same_id() {
1279        let from_hex = AssetId::from_hex(Asset::DIG_ASSET_ID_HEX).unwrap();
1280        assert_eq!(Asset::DIG.asset_id(), Some(&from_hex));
1281        assert_eq!(from_hex.to_hex(), Asset::DIG_ASSET_ID_HEX);
1282        assert_eq!(Asset::Xch.asset_id(), None);
1283    }
1284
1285    /// A rejection has to say WHICH way the input was wrong, or the caller is left guessing at a
1286    /// value it can see but cannot fix.
1287    #[test]
1288    fn parse_errors_name_the_defect_and_reach_the_wire() {
1289        assert_eq!(
1290            AssetId::from_hex("ab").unwrap_err(),
1291            AssetIdParseError::WrongLength { got: 2 }
1292        );
1293        assert!(AssetIdParseError::WrongLength { got: 2 }
1294            .to_string()
1295            .contains("64 hex characters"));
1296        assert!(AssetIdParseError::NotHex
1297            .to_string()
1298            .contains("non-hexadecimal"));
1299        assert!(AssetId::from_hex(&"3c".repeat(32))
1300            .unwrap()
1301            .to_string()
1302            .starts_with("3c3c"));
1303        // The deserializer surfaces the reason rather than a bare "invalid value".
1304        let err = serde_json::from_value::<Asset>(json!({ "cat": "zz" })).unwrap_err();
1305        assert!(err.to_string().contains("64 hex characters"), "{err}");
1306    }
1307
1308    #[test]
1309    fn malformed_assets_are_rejected_rather_than_guessed_at() {
1310        assert!(AssetId::from_hex(&"z".repeat(64)).is_err());
1311        // An unknown bare token is not silently treated as a CAT name.
1312        assert!(serde_json::from_value::<Asset>(json!("usdc")).is_err());
1313        // The tagged form carries exactly one recognized key.
1314        assert!(serde_json::from_value::<Asset>(json!({})).is_err());
1315        assert!(serde_json::from_value::<Asset>(json!({ "tail": OTHER_CAT_HEX })).is_err());
1316        assert!(serde_json::from_value::<Asset>(json!({ "cat": "ab" })).is_err());
1317    }
1318
1319    /// The params types that CARRY an asset must carry the widened one — the whole point of the
1320    /// change is that these two questions become askable about an arbitrary CAT.
1321    #[test]
1322    fn balance_and_coins_reads_can_name_an_arbitrary_cat() {
1323        let cat = Asset::Cat(AssetId::from_hex(OTHER_CAT_HEX).unwrap());
1324        assert_eq!(
1325            serde_json::to_value(WalletBalanceParams {
1326                address: "xch1exampleaddr".into(),
1327                asset: cat,
1328            })
1329            .unwrap(),
1330            json!({ "address": "xch1exampleaddr", "asset": { "cat": OTHER_CAT_HEX } })
1331        );
1332        assert_eq!(
1333            serde_json::to_value(WalletCoinsParams {
1334                address: "xch1exampleaddr".into(),
1335                asset: cat,
1336            })
1337            .unwrap(),
1338            json!({ "address": "xch1exampleaddr", "asset": { "cat": OTHER_CAT_HEX } })
1339        );
1340    }
1341
1342    #[test]
1343    fn no_param_call_serializes_params_to_empty_object() {
1344        let req = build_request(1.into(), &StatusParams {});
1345        assert_eq!(req.method, "control.status");
1346        assert_eq!(req.params, json!({}));
1347    }
1348
1349    #[test]
1350    fn data_param_call_carries_its_fields() {
1351        let req = build_request(2.into(), &SetCapParams { cap_bytes: 128 });
1352        assert_eq!(req.method, "control.cache.setCap");
1353        assert_eq!(req.params, json!({ "cap_bytes": 128 }));
1354    }
1355
1356    #[test]
1357    fn pause_omits_until_when_indefinite() {
1358        assert_eq!(
1359            serde_json::to_value(PauseParams { until: None }).unwrap(),
1360            json!({})
1361        );
1362        assert_eq!(
1363            serde_json::to_value(PauseParams { until: Some(99) }).unwrap(),
1364            json!({ "until": 99 })
1365        );
1366    }
1367
1368    #[test]
1369    fn method_binding_matches_the_catalog_name() {
1370        assert_eq!(
1371            SetUpstreamParams::METHOD.name(),
1372            "control.config.setUpstream"
1373        );
1374        assert_eq!(RequestParams::METHOD.name(), "pairing.request");
1375        assert_eq!(PollParams::METHOD.name(), "pairing.poll");
1376    }
1377
1378    /// **Two spellings of one address canonicalise to one key — which is what makes `remove` able
1379    /// to find what `add` stored.**
1380    ///
1381    /// The fixture is deliberately not a pair of identical strings: each pair differs in the ways
1382    /// a person actually types an address (case, leading zeroes, an uncompressed run, whitespace),
1383    /// so a canonicaliser that only trimmed would pass the last pair and fail the rest.
1384    #[test]
1385    fn spellings_of_the_same_peer_canonicalise_to_one_key() {
1386        for (typed, also_typed) in [
1387            ("2001:0db8:0000:0000:0000:0000:0000:0001", "2001:DB8::1"),
1388            ("::1", "0:0:0:0:0:0:0:1"),
1389            (" 203.0.113.7 ", "203.0.113.7"),
1390        ] {
1391            let a = canonical_peer_ip(typed).expect("typed form is a literal");
1392            let b = canonical_peer_ip(also_typed).expect("second form is a literal");
1393            assert_eq!(a, b, "{typed} and {also_typed} name one peer");
1394        }
1395        // And the canonical form is the compressed lowercase one, not merely SOME agreed form.
1396        assert_eq!(canonical_peer_ip("2001:DB8:0:0::1").unwrap(), "2001:db8::1");
1397    }
1398
1399    /// **Anything that is not a bare IP literal is REFUSED.**
1400    ///
1401    /// This is the bound on the ban list: `remove {ban: true}` persists a row keyed by this
1402    /// string, so accepting arbitrary text is unbounded at-rest growth for the cost of one call.
1403    /// Each rejected case is one a naive implementation accepts: a hostname resolves, a bracketed
1404    /// form round-trips through some parsers, and `ip:port` is what a person copies out of a log.
1405    #[test]
1406    fn a_peer_ip_that_is_not_a_bare_literal_is_refused() {
1407        for bad in [
1408            "",
1409            "   ",
1410            "node.example.com",
1411            "[2001:db8::1]",
1412            "[2001:db8::1]:8444",
1413            "203.0.113.7:8444",
1414            "203.0.113.0/24",
1415            "not an address",
1416        ] {
1417            let Err(err) = canonical_peer_ip(bad) else {
1418                panic!("{bad:?} must be refused, it was accepted");
1419            };
1420            assert_eq!(
1421                err.code_enum(),
1422                Some(ControlErrorCode::InvalidParams),
1423                "{bad:?} must be an INVALID_PARAMS refusal, not some other failure"
1424            );
1425        }
1426    }
1427
1428    /// **Joining an address to a port brackets IPv6 — the un-bracketed form is a DIFFERENT valid
1429    /// address, so the mistake survives validation.**
1430    ///
1431    /// `::1` + `8444` formatted by hand is `::1:8444`, which parses fine and points somewhere
1432    /// else. The assertion is therefore not merely "it contains brackets": the naive rendering is
1433    /// checked to be a parseable address that is NOT the peer, which is what makes the bug silent.
1434    #[test]
1435    fn joining_a_v6_peer_to_a_port_cannot_produce_a_different_address() {
1436        assert_eq!(chia_peer_endpoint("::1", 8444), "[::1]:8444");
1437        assert_eq!(
1438            chia_peer_endpoint("2001:db8::1", 8444),
1439            "[2001:db8::1]:8444"
1440        );
1441        assert_eq!(chia_peer_endpoint("203.0.113.7", 8444), "203.0.113.7:8444");
1442
1443        let naive = format!("{}:{}", "::1", 8444);
1444        let hijacked: std::net::IpAddr =
1445            naive.parse().expect("the naive join is itself an address");
1446        assert_ne!(
1447            hijacked,
1448            "::1".parse::<std::net::IpAddr>().unwrap(),
1449            "the naive join must be a DIFFERENT address — that is why the helper exists"
1450        );
1451        assert_ne!(chia_peer_endpoint("::1", 8444), naive);
1452    }
1453}