Skip to main content

dig_node_control_interface/
params.rs

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