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}