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