Skip to main content

dig_rpc_protocol/
types.rs

1//! Request/response wire types for every DIG-node RPC method.
2//!
3//! Each type is `serde`-derived and models a method's params or result
4//! field-for-field with the canonical implementation (the digstore `dig-node`
5//! crate). Fields that appear only in one profile or only on the first window of
6//! a paged stream are `Option` and doc-flagged.
7//!
8//! Hex-encoded identifiers (`store_id`, `root`, `retrieval_key`, `peer_id`) are
9//! carried as `String` on the wire — lower-case 64-hex — because the interface
10//! crate does no crypto and imposes no byte-array dependency. Callers validate
11//! length/charset at their boundary.
12//!
13//! # Two content profiles, one chunk type
14//!
15//! [`ContentChunk`] models both the node profile (`dig.getContent` on the local
16//! dig-node) and the network profile (`rpc.dig.net`). The network-profile-only
17//! fields — [`total_length`](ContentChunk::total_length),
18//! [`length`](ContentChunk::length), [`program_hash`](ContentChunk::program_hash),
19//! [`offset`](ContentChunk::offset) — are `Option` so one type serves both
20//! surfaces with no silent split.
21
22use serde::{Deserialize, Serialize};
23
24/// A lower-case 64-hex identifier on the wire (e.g. a `store_id`, `root`,
25/// `retrieval_key`, or `peer_id`). A type alias for documentation; validation is
26/// the boundary's job.
27pub type HexId = String;
28
29// ===========================================================================
30// Shared value objects
31// ===========================================================================
32
33/// A peer's dialable network endpoint.
34///
35/// IPv6-first per the ecosystem networking rule: an address list orders
36/// global-unicast IPv6 ahead of IPv4 fallback, and a wildcard bind
37/// (`[::]`/`0.0.0.0`) is never advertised.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
40pub struct PeerAddress {
41    /// The host — an IPv6 or IPv4 literal (never a wildcard).
42    pub host: String,
43    /// The TCP port.
44    pub port: u16,
45    /// How the address was discovered: `direct`, `reflexive`, `mapped`, or
46    /// `relay`.
47    pub kind: String,
48}
49
50/// A content provider: a holder's stable `peer_id` plus its candidate addresses.
51///
52/// The address list is byte-compatible with [`dig.getPeers`](crate::method::Method::GetPeers)
53/// and the DHT provider shape.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
56pub struct Provider {
57    /// The holder's stable `peer_id` = `SHA-256(TLS SPKI DER)`, 64-hex.
58    pub peer_id: HexId,
59    /// The holder's candidate addresses (IPv6-first).
60    pub addresses: Vec<PeerAddress>,
61}
62
63/// The content item a redirect points at: `store_id` [+ `root` [+
64/// `retrieval_key`]], each lower-case 64-hex — the exact item to re-request.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
66#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
67pub struct ContentRef {
68    /// The store launcher id (always present).
69    pub store_id: HexId,
70    /// The generation root (present for capsule/resource granularity).
71    #[serde(skip_serializing_if = "Option::is_none", default)]
72    pub root: Option<HexId>,
73    /// The resource retrieval key (present for resource granularity).
74    #[serde(skip_serializing_if = "Option::is_none", default)]
75    pub retrieval_key: Option<HexId>,
76}
77
78/// The `error.data.redirect` payload of a
79/// [`ContentRedirect`](crate::error::ErrorCode::ContentRedirect) (`-32008`).
80///
81/// The node does not hold the content but located peers that do; the caller
82/// re-requests against one of `providers`, echoing `redirect_depth` in its
83/// params so the hop budget stays bounded (stop at `max_redirects`).
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
86pub struct RedirectInfo {
87    /// The content the caller should re-request.
88    pub content: ContentRef,
89    /// The holders (peer_id + candidate addresses) to re-request against.
90    pub providers: Vec<Provider>,
91    /// The hop count the caller must echo on its re-request.
92    pub redirect_depth: u64,
93    /// The redirect budget — stop redirecting when `redirect_depth` reaches this.
94    pub max_redirects: u64,
95}
96
97// ===========================================================================
98// dig.getContent  (PUBLIC-READ, also peer-reachable)
99// ===========================================================================
100
101/// Params for [`dig.getContent`](crate::method::Method::GetContent) — a verified
102/// resource-window read.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
105pub struct GetContentParams {
106    /// The CHIP-0035 singleton launcher id (64-hex).
107    pub store_id: HexId,
108    /// `SHA-256(urn)` — the only URN-derived value sent to a node (64-hex).
109    pub retrieval_key: HexId,
110    /// The generation root (64-hex). Empty / `"latest"` / absent ⇒ resolve the
111    /// chain tip.
112    #[serde(skip_serializing_if = "Option::is_none", default)]
113    pub root: Option<HexId>,
114    /// The window start offset (default 0).
115    #[serde(skip_serializing_if = "Option::is_none", default)]
116    pub offset: Option<u64>,
117    /// Retrieval mode: `"speed"` (default) or `"privacy"` (onion — target).
118    #[serde(skip_serializing_if = "Option::is_none", default)]
119    pub mode: Option<String>,
120    /// The redirect budget already consumed (echoed from a `-32008` redirect).
121    #[serde(skip_serializing_if = "Option::is_none", default)]
122    pub redirect_depth: Option<u64>,
123}
124
125/// One window of a resource's ciphertext — the chunk wire object.
126///
127/// Serves BOTH the node profile (`dig.getContent` on the local dig-node) and the
128/// network profile (`rpc.dig.net`). Node-profile responses omit the
129/// network-profile-only fields; the doc on each field says which profile
130/// populates it.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
133pub struct ContentChunk {
134    /// This window's bytes, base64. Both profiles.
135    pub ciphertext: String,
136    /// The resolved generation root (64-hex). Both profiles.
137    pub root: HexId,
138    /// Whether this window ends the resource. Both profiles.
139    pub complete: bool,
140    /// The next offset; present iff not complete. Both profiles.
141    #[serde(skip_serializing_if = "Option::is_none", default)]
142    pub next_offset: Option<u64>,
143    /// Whole-resource merkle proof, base64. First window only (`offset == 0`).
144    /// Both profiles.
145    #[serde(skip_serializing_if = "Option::is_none", default)]
146    pub inclusion_proof: Option<String>,
147    /// Per-chunk ciphertext lengths of the full resource. First window only;
148    /// empty ⇒ single chunk. Both profiles.
149    #[serde(skip_serializing_if = "Option::is_none", default)]
150    pub chunk_lens: Option<Vec<u64>>,
151    /// Where the window was served from: `"local"` (this device's cache) or
152    /// `"remote"` (freshly fetched). **Node profile only** — additive tag the
153    /// in-process node sets; absent on the network profile.
154    #[serde(skip_serializing_if = "Option::is_none", default)]
155    pub source: Option<String>,
156    /// The full resource ciphertext length (pre-windowing). **Network profile
157    /// only.**
158    #[serde(skip_serializing_if = "Option::is_none", default)]
159    pub total_length: Option<u64>,
160    /// This window's byte length. **Network profile only** (the node profile's
161    /// length is implicit in `ciphertext`).
162    #[serde(skip_serializing_if = "Option::is_none", default)]
163    pub length: Option<u64>,
164    /// The window start offset (echoed). **Network profile only.**
165    #[serde(skip_serializing_if = "Option::is_none", default)]
166    pub offset: Option<u64>,
167    /// `SHA-256(.dig bytes)` — the on-chain program identity (64-hex).
168    /// **Network profile only.**
169    #[serde(skip_serializing_if = "Option::is_none", default)]
170    pub program_hash: Option<HexId>,
171}
172
173// ===========================================================================
174// dig.getAnchoredRoot  (PUBLIC-READ, also peer-reachable)
175// ===========================================================================
176
177/// Params for [`dig.getAnchoredRoot`](crate::method::Method::GetAnchoredRoot).
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
180pub struct GetAnchoredRootParams {
181    /// The store launcher id (64-hex).
182    pub store_id: HexId,
183}
184
185/// Result for [`dig.getAnchoredRoot`](crate::method::Method::GetAnchoredRoot) —
186/// the store's current chain-anchored tip root.
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
188#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
189pub struct AnchoredRoot {
190    /// The store launcher id (echoed, 64-hex).
191    pub store_id: HexId,
192    /// The chain-anchored tip root (64-hex).
193    pub root: HexId,
194}
195
196// ===========================================================================
197// dig.getCollection / dig.listCollectionItems  (PUBLIC-READ, also peer)
198// ===========================================================================
199
200/// Params for [`dig.getCollection`](crate::method::Method::GetCollection).
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
203pub struct GetCollectionParams {
204    /// The NFT launcher ids to resolve. Capped at 10,000 (over-cap ⇒ `-32602`).
205    pub launcher_ids: Vec<HexId>,
206    /// The optional collection creator DID (64-hex).
207    #[serde(skip_serializing_if = "Option::is_none", default)]
208    pub did: Option<HexId>,
209}
210
211/// Result for [`dig.getCollection`](crate::method::Method::GetCollection) —
212/// collection-level facts computed from DIG's own coinset data.
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
214#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
215pub struct Collection {
216    /// The resolved creator DID (64-hex), if any.
217    #[serde(skip_serializing_if = "Option::is_none", default)]
218    pub did: Option<HexId>,
219    /// The DID declared by the caller / metadata (64-hex), if any.
220    #[serde(skip_serializing_if = "Option::is_none", default)]
221    pub declared_did: Option<HexId>,
222    /// The number of launcher ids requested.
223    pub item_count: u64,
224    /// How many resolved to live NFTs.
225    pub resolved_count: u64,
226    /// The uniform royalty in basis points, if resolvable.
227    #[serde(skip_serializing_if = "Option::is_none", default)]
228    pub royalty_basis_points: Option<u64>,
229}
230
231/// Params for
232/// [`dig.listCollectionItems`](crate::method::Method::ListCollectionItems).
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
235pub struct ListCollectionItemsParams {
236    /// The NFT launcher ids. Capped at 10,000 (over-cap ⇒ `-32602`).
237    pub launcher_ids: Vec<HexId>,
238    /// Page start (default 0).
239    #[serde(skip_serializing_if = "Option::is_none", default)]
240    pub offset: Option<u64>,
241    /// Page size (default 50, capped at 200).
242    #[serde(skip_serializing_if = "Option::is_none", default)]
243    pub limit: Option<u64>,
244}
245
246/// CHIP-0007 NFT metadata for one collection item.
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
248#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
249pub struct NftMetadata {
250    /// Edition ordinal, if any.
251    #[serde(skip_serializing_if = "Option::is_none", default)]
252    pub edition_number: Option<u64>,
253    /// Edition total, if any.
254    #[serde(skip_serializing_if = "Option::is_none", default)]
255    pub edition_total: Option<u64>,
256    /// Data URIs.
257    #[serde(default)]
258    pub data_uris: Vec<String>,
259    /// `SHA-256` of the data (64-hex), if any.
260    #[serde(skip_serializing_if = "Option::is_none", default)]
261    pub data_hash: Option<HexId>,
262    /// Metadata URIs.
263    #[serde(default)]
264    pub metadata_uris: Vec<String>,
265    /// `SHA-256` of the metadata document (64-hex), if any.
266    #[serde(skip_serializing_if = "Option::is_none", default)]
267    pub metadata_hash: Option<HexId>,
268    /// License URIs.
269    #[serde(default)]
270    pub license_uris: Vec<String>,
271    /// `SHA-256` of the license (64-hex), if any.
272    #[serde(skip_serializing_if = "Option::is_none", default)]
273    pub license_hash: Option<HexId>,
274}
275
276/// One resolved collection item — its current on-chain owner, royalty, and
277/// CHIP-0007 metadata.
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
280pub struct CollectionItem {
281    /// The NFT launcher id (64-hex).
282    pub launcher_id: HexId,
283    /// The current coin id (64-hex).
284    pub coin_id: HexId,
285    /// The current owner DID (64-hex), if any.
286    #[serde(skip_serializing_if = "Option::is_none", default)]
287    pub owner_did: Option<HexId>,
288    /// The royalty puzzle hash (64-hex).
289    pub royalty_puzzle_hash: HexId,
290    /// The royalty in basis points.
291    pub royalty_basis_points: u64,
292    /// The current owner puzzle hash (64-hex).
293    pub owner_puzzle_hash: HexId,
294    /// The CHIP-0007 metadata, if resolvable.
295    #[serde(skip_serializing_if = "Option::is_none", default)]
296    pub metadata: Option<NftMetadata>,
297}
298
299/// Result for
300/// [`dig.listCollectionItems`](crate::method::Method::ListCollectionItems) — a
301/// page of resolved items.
302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
303#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
304pub struct CollectionItemsPage {
305    /// This page's items.
306    pub items: Vec<CollectionItem>,
307    /// The page start (echoed).
308    pub offset: u64,
309    /// The page size (echoed).
310    pub limit: u64,
311    /// The total item count across the whole (capped) launcher set.
312    pub total: u64,
313    /// The next page's offset, or `null` when exhausted.
314    #[serde(skip_serializing_if = "Option::is_none", default)]
315    pub next_offset: Option<u64>,
316}
317
318// ===========================================================================
319// dig.getNetworkInfo  (PEER)
320// ===========================================================================
321
322/// The node's relay reservation posture.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
325pub struct RelayStatus {
326    /// The relay endpoint URL (e.g. `wss://relay.dig.net:9450`).
327    pub url: String,
328    /// Whether a relay reservation is currently held.
329    pub reserved: bool,
330}
331
332/// Result for [`dig.getNetworkInfo`](crate::method::Method::GetNetworkInfo) —
333/// this node's own peer-network posture.
334#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
335#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
336pub struct NetworkInfo {
337    /// This node's stable `peer_id` = `SHA-256(TLS SPKI DER)` (64-hex), or
338    /// `null` when no identity is configured.
339    #[serde(skip_serializing_if = "Option::is_none", default)]
340    pub peer_id: Option<HexId>,
341    /// The DIG network id (e.g. `DIG_MAINNET`).
342    pub network_id: String,
343    /// The first advertised (dialable) candidate address, `host:port`.
344    pub listen_addr: String,
345    /// The STUN-discovered reflexive address, if known.
346    #[serde(skip_serializing_if = "Option::is_none", default)]
347    pub reflexive_addr: Option<String>,
348    /// All advertised candidate addresses (IPv6-first).
349    pub candidate_addresses: Vec<String>,
350    /// Reachability posture: `"direct"` or `"relayed"`.
351    pub reachability: String,
352    /// The relay reservation posture.
353    pub relay: RelayStatus,
354}
355
356// ===========================================================================
357// dig.getPeers  (PEER)
358// ===========================================================================
359
360/// Result for [`dig.getPeers`](crate::method::Method::GetPeers) — the peers this
361/// node currently knows (peer exchange over RPC).
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
363#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
364pub struct PeersList {
365    /// The known peers (peer_id + candidate addresses).
366    pub peers: Vec<Provider>,
367}
368
369// ===========================================================================
370// dig.announce  (PEER)
371// ===========================================================================
372
373/// Params for [`dig.announce`](crate::method::Method::Announce).
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
376pub struct AnnounceParams {
377    /// The announcing peer's `peer_id` (64-hex).
378    pub peer_id: HexId,
379    /// The announcing peer's candidate addresses.
380    pub addresses: Vec<PeerAddress>,
381}
382
383/// Result for [`dig.announce`](crate::method::Method::Announce).
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
386pub struct AnnounceAck {
387    /// Whether the announcement was accepted.
388    pub accepted: bool,
389    /// How many peers this node now knows.
390    pub known_peers: u64,
391}
392
393// ===========================================================================
394// dig.getAvailability  (PEER)
395// ===========================================================================
396
397/// One availability query item. Granularity is inferred from which fields are
398/// present: `store_id` only ⇒ which roots are held; `+root` ⇒ a capsule; `+root
399/// +retrieval_key` ⇒ a resource.
400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
401#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
402pub struct AvailabilityQuery {
403    /// The store launcher id (64-hex, required).
404    pub store_id: HexId,
405    /// The generation root (64-hex), for capsule/resource granularity.
406    #[serde(skip_serializing_if = "Option::is_none", default)]
407    pub root: Option<HexId>,
408    /// The resource retrieval key (64-hex), for resource granularity.
409    #[serde(skip_serializing_if = "Option::is_none", default)]
410    pub retrieval_key: Option<HexId>,
411}
412
413/// Params for [`dig.getAvailability`](crate::method::Method::GetAvailability).
414///
415/// # Construction
416///
417/// Like [`FetchRangeParams`], this type is `#[non_exhaustive]`: build it with
418/// [`new`](Self::new) plus the `with_*` setters rather than a struct literal, so a
419/// future additive field is a PATCH for every consumer instead of a semver cascade.
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
422#[non_exhaustive]
423pub struct GetAvailabilityParams {
424    /// The items to check. Capped at 512 per batch (past-cap items are dropped).
425    pub items: Vec<AvailabilityQuery>,
426    /// The hop budget already consumed by this ask. Absent means zero — read it
427    /// through [`hops_consumed`](Self::hops_consumed), never directly.
428    ///
429    /// # What it means for an availability ask
430    ///
431    /// An availability answer is not only *this* node's holdings: on a miss it may
432    /// name the holders it located, in
433    /// [`AvailabilityAnswer::providers`](AvailabilityAnswer::providers) — the same
434    /// enrichment a [`RedirectInfo`] carries. A responder that cannot answer from
435    /// what it holds MAY ask its own peers, so one caller's question can walk
436    /// several hops, and each hop is a node spending someone else's bandwidth.
437    /// This field is what bounds that walk.
438    ///
439    /// It is the SAME budget, counted the SAME way, as
440    /// [`RedirectInfo::redirect_depth`] and as the `redirect_depth` that
441    /// [`GetContentParams`] and [`FetchRangeParams`] already echo: the number of
442    /// hops ALREADY CONSUMED when this ask arrives, counting UP from zero — never a
443    /// remaining allowance counting down. A responder that asks onward sends
444    /// `hops_consumed() + 1` (saturating at the type maximum), and MUST NOT ask
445    /// onward when that would reach the budget it advertises as
446    /// [`RedirectInfo::max_redirects`].
447    ///
448    /// Absent reads as a fresh, unhopped ask, so a client written before this field
449    /// existed is served unchanged.
450    #[serde(skip_serializing_if = "Option::is_none", default)]
451    pub redirect_depth: Option<u64>,
452}
453
454impl GetAvailabilityParams {
455    /// An availability batch for `items`, asked at hop zero.
456    pub fn new(items: Vec<AvailabilityQuery>) -> Self {
457        GetAvailabilityParams {
458            items,
459            redirect_depth: None,
460        }
461    }
462
463    /// Echo the hop budget already consumed — from a `-32008` redirect, or from the
464    /// ask this one is being made on behalf of. See
465    /// [`redirect_depth`](Self::redirect_depth).
466    pub fn with_redirect_depth(mut self, redirect_depth: u64) -> Self {
467        self.redirect_depth = Some(redirect_depth);
468        self
469    }
470
471    /// The hops already consumed by this ask.
472    ///
473    /// The single home for the "absent means zero" rule. A responder that reached for
474    /// `redirect_depth.is_some()` instead would read every pre-0.8 client's ask as
475    /// budget-free and forward it without bound — the amplification the budget exists
476    /// to stop.
477    pub fn hops_consumed(&self) -> u64 {
478        self.redirect_depth.unwrap_or(0)
479    }
480}
481
482/// One availability answer. Only the fields relevant to the query's granularity
483/// are populated.
484#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
485#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
486pub struct AvailabilityAnswer {
487    /// Whether this node holds the queried item.
488    pub available: bool,
489    /// The roots held (store-granularity queries only).
490    #[serde(skip_serializing_if = "Option::is_none", default)]
491    pub roots: Option<Vec<HexId>>,
492    /// The full resource ciphertext length (resource-granularity only).
493    #[serde(skip_serializing_if = "Option::is_none", default)]
494    pub total_length: Option<u64>,
495    /// The chunk count (resource-granularity only).
496    #[serde(skip_serializing_if = "Option::is_none", default)]
497    pub chunk_count: Option<u64>,
498    /// Whether the whole item is held (root/resource-granularity only).
499    #[serde(skip_serializing_if = "Option::is_none", default)]
500    pub complete: Option<bool>,
501    /// Providers that hold the item — present on a miss when holders were
502    /// located (enriched answer).
503    #[serde(skip_serializing_if = "Option::is_none", default)]
504    pub providers: Option<Vec<Provider>>,
505}
506
507/// Result for [`dig.getAvailability`](crate::method::Method::GetAvailability) —
508/// one answer per query item, in order.
509#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
510#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
511pub struct AvailabilityBatch {
512    /// The per-item answers (index-aligned to the query items served).
513    pub items: Vec<AvailabilityAnswer>,
514}
515
516// ===========================================================================
517// dig.listInventory  (PEER)
518// ===========================================================================
519
520/// Params for [`dig.listInventory`](crate::method::Method::ListInventory).
521#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
522#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
523pub struct ListInventoryParams {
524    /// The store to list roots for (64-hex). Absent ⇒ list all stores served.
525    #[serde(skip_serializing_if = "Option::is_none", default)]
526    pub store_id: Option<HexId>,
527    /// The maximum number of entries to return.
528    #[serde(skip_serializing_if = "Option::is_none", default)]
529    pub limit: Option<u64>,
530}
531
532/// Result for [`dig.listInventory`](crate::method::Method::ListInventory).
533///
534/// With a `store_id` the node returns the roots it holds for that store; without
535/// one it returns the stores it serves. `#[serde(untagged)]` keeps the wire flat
536/// (`{"roots": …}` or `{"stores": …}`).
537#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
538#[serde(untagged)]
539#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
540pub enum Inventory {
541    /// The roots held for a specific store.
542    ForStore {
543        /// The store launcher id (echoed, 64-hex).
544        store_id: HexId,
545        /// The roots this node holds for the store.
546        roots: Vec<HexId>,
547    },
548    /// The stores this node serves (no `store_id` given).
549    AllStores {
550        /// The store launcher ids served.
551        stores: Vec<HexId>,
552    },
553}
554
555// ===========================================================================
556// dig.fetchRange  (PEER)
557// ===========================================================================
558
559/// Params for [`dig.fetchRange`](crate::method::Method::FetchRange) — a single
560/// range frame of a resource this node holds.
561///
562/// # Construction
563///
564/// Like [`RangeFrame`], this type is `#[non_exhaustive]`: build it with
565/// [`resource`](Self::resource) plus the `with_*` setters rather than a struct
566/// literal, so a future additive field is a PATCH for every consumer instead of a
567/// semver cascade.
568///
569/// # Cross-repo contract
570///
571/// [`skip_layout`](Self::skip_layout) is byte-identical to
572/// `dig_nat::mux::RangeRequest::skip_layout`, pinned in
573/// `tests/nat_wire_mirror.rs`. The two enclosing types deliberately differ in every
574/// other respect — dig-nat's `RangeRequest` is a length-prefixed stream preamble,
575/// this is a JSON-RPC params object with a `redirect_depth` dig-nat has no notion
576/// of — so the byte-identical contract here is the FIELD, not the object.
577#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
578#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
579#[non_exhaustive]
580pub struct FetchRangeParams {
581    /// The store launcher id (64-hex, required).
582    pub store_id: HexId,
583    /// The generation root (64-hex, required for a resource fetch).
584    pub root: HexId,
585    /// `SHA-256(urn)` (64-hex, required for a resource fetch).
586    pub retrieval_key: HexId,
587    /// The range start (default 0).
588    #[serde(skip_serializing_if = "Option::is_none", default)]
589    pub offset: Option<u64>,
590    /// The range length in bytes (> 0; clamped to the window cap).
591    pub length: u64,
592    /// Whole-capsule mode (default false). Capsule range fetch is not yet
593    /// served; a `true` here yields `-32004`.
594    #[serde(skip_serializing_if = "Option::is_none", default)]
595    pub capsule: Option<bool>,
596    /// The redirect budget already consumed (echoed from a `-32008` redirect).
597    #[serde(skip_serializing_if = "Option::is_none", default)]
598    pub redirect_depth: Option<u64>,
599    /// Suppress the resource-scaling layout metadata (`chunk_lens` +
600    /// `inclusion_proof`) on this stream's frames, because the client already holds
601    /// the commitment for this `root`.
602    ///
603    /// A client that has already read the layout once — a resumed download, a second
604    /// range of the same resource, a parallel fetch from another holder — does not
605    /// need it again, and re-sending it costs a whole paged prologue PER STREAM: a
606    /// 1,048,576-chunk layout is roughly 7.3 MB, which a 64-way parallel plan would
607    /// otherwise pay 64 times over. Suppressing it is the difference between a
608    /// bounded and an unbounded cost on the read path.
609    ///
610    /// Absent or `false` preserves the pre-0.6.0 behaviour, so an older holder that
611    /// ignores this field is never broken by it — it simply sends metadata the client
612    /// discards. Read the rule through
613    /// [`suppresses_layout`](Self::suppresses_layout) rather than re-deriving it.
614    ///
615    /// The fixed-size identity fields ([`root`](RangeFrame::root),
616    /// [`total_length`](RangeFrame::total_length),
617    /// [`chunk_count`](RangeFrame::chunk_count),
618    /// [`chunk_index`](RangeFrame::chunk_index)) are NOT suppressed: they are what
619    /// detects a wrong-generation holder on arrival, and a client that stopped
620    /// receiving them would lose that check on exactly the streams it fetches most.
621    #[serde(default, skip_serializing_if = "Option::is_none")]
622    pub skip_layout: Option<bool>,
623}
624
625impl FetchRangeParams {
626    /// A range request for one content resource: `length` bytes of
627    /// `retrieval_key`'s ciphertext at the generation `root`.
628    pub fn resource(
629        store_id: impl Into<HexId>,
630        root: impl Into<HexId>,
631        retrieval_key: impl Into<HexId>,
632        length: u64,
633    ) -> Self {
634        FetchRangeParams {
635            store_id: store_id.into(),
636            root: root.into(),
637            retrieval_key: retrieval_key.into(),
638            offset: None,
639            length,
640            capsule: None,
641            redirect_depth: None,
642            skip_layout: None,
643        }
644    }
645
646    /// Start the range at `offset` rather than at 0.
647    pub fn with_offset(mut self, offset: u64) -> Self {
648        self.offset = Some(offset);
649        self
650    }
651
652    /// Request whole-capsule mode. Capsule range fetch is not yet served — a `true`
653    /// here yields
654    /// [`ResourceUnavailable`](crate::error::ErrorCode::ResourceUnavailable).
655    pub fn with_capsule(mut self, capsule: bool) -> Self {
656        self.capsule = Some(capsule);
657        self
658    }
659
660    /// Echo the redirect budget already consumed, from a `-32008` redirect.
661    pub fn with_redirect_depth(mut self, redirect_depth: u64) -> Self {
662        self.redirect_depth = Some(redirect_depth);
663        self
664    }
665
666    /// Ask the holder to omit the resource-scaling layout metadata, because this
667    /// client already holds the commitment for this `root`. See
668    /// [`skip_layout`](Self::skip_layout).
669    pub fn with_skip_layout(mut self, skip_layout: bool) -> Self {
670        self.skip_layout = Some(skip_layout);
671        self
672    }
673
674    /// Whether this request suppresses the resource-scaling layout metadata.
675    ///
676    /// The single home for the "absent or `false` means SEND the layout" rule. A
677    /// serve path that reached for `skip_layout.is_some()` instead would suppress the
678    /// layout for a client that had explicitly asked for it — unrecoverable for that
679    /// client, since the layout is a decrypt input it cannot obtain any other way on
680    /// that stream.
681    pub fn suppresses_layout(&self) -> bool {
682        self.skip_layout.unwrap_or(false)
683    }
684}
685
686/// One range frame of a resource: a byte window, plus the per-resource
687/// verification metadata that makes the window independently checkable.
688///
689/// The metadata splits in two by whether it scales with the resource, and the
690/// split decides which frames carry it:
691///
692/// - **The identity set — [`root`](Self::root),
693///   [`total_length`](Self::total_length), [`chunk_count`](Self::chunk_count),
694///   plus [`chunk_index`](Self::chunk_index) when the window begins on a chunk
695///   boundary — rides EVERY frame.** It is fixed-size, so carrying it everywhere
696///   costs a bounded number of bytes, and it is what lets a client fetching in
697///   parallel from many holders reject a wrong-generation or wrong-layout source
698///   the moment a frame arrives, rather than after paying for the whole resource
699///   in bandwidth.
700/// - **The resource-scaling set — [`chunk_lens`](Self::chunk_lens) and
701///   [`inclusion_proof`](Self::inclusion_proof) — rides the first frame, or a
702///   paged prologue, once per range stream.** Repeating it per frame would cost
703///   proportionally to the resource against a frame budget with no slack; a layout
704///   too large to state on one frame is paged instead, each page stamped with the
705///   [`chunk_lens_offset`](Self::chunk_lens_offset) it begins at.
706///
707/// The window is exactly the span the caller requested — never widened.
708///
709/// # Construction
710///
711/// This type is [`#[non_exhaustive]`](https://doc.rust-lang.org/reference/attributes/type_system.html):
712/// build it with [`data`](Self::data) and the `with_*` setters rather than a struct
713/// literal. That is deliberate — the wire form grows as the protocol does, and
714/// routing construction through named setters means a future additive field is a
715/// PATCH release for every consumer instead of another semver cascade. It also
716/// makes the two frame shapes different call chains rather than one call with a
717/// pile of `None`s, so a continuation frame cannot accidentally claim a layout it
718/// is not stating.
719///
720/// # Cross-repo contract
721///
722/// The wire form is **byte-identical** to `dig_nat::mux::RangeFrame`, the
723/// streaming implementation of this frame (`SYSTEM.md` → "Canonical DIG-node RPC
724/// interface"). Field names, encodings, and the population rule above are pinned
725/// against dig-nat's actual output in `tests/nat_wire_mirror.rs`; a change to any
726/// of them lands in both crates in the same unit of work or not at all.
727#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
728#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
729#[non_exhaustive]
730pub struct RangeFrame {
731    /// The window start offset (echoed).
732    pub offset: u64,
733    /// This window's byte length.
734    pub length: u64,
735    /// This window's ciphertext, base64.
736    pub bytes: String,
737    /// Whether this frame ends the resource.
738    pub complete: bool,
739    /// The full resource ciphertext length. Part of the fixed-size **identity
740    /// set**, so it rides EVERY frame.
741    #[serde(skip_serializing_if = "Option::is_none", default)]
742    pub total_length: Option<u64>,
743    /// Per-chunk ciphertext lengths of the full resource, in order — the layout a
744    /// reader needs before it can decrypt (per-chunk AEAD needs the WHOLE array,
745    /// and a reader rejects an array whose sum differs from
746    /// [`total_length`](Self::total_length)).
747    ///
748    /// Resource-scaling, so it rides the first frame or a **paged prologue**, once
749    /// per range stream — never repeated on continuation frames. When paged, this
750    /// is one page of the array and
751    /// [`chunk_lens_offset`](Self::chunk_lens_offset) states the entry it begins
752    /// at.
753    #[serde(skip_serializing_if = "Option::is_none", default)]
754    pub chunk_lens: Option<Vec<u64>>,
755    /// This frame's first chunk index — the pre-existing alias of
756    /// [`first_chunk_index`](Self::first_chunk_index), carrying the same value, and
757    /// the name dig-nat emits.
758    ///
759    /// Part of the **identity set**: it rides every frame whose window begins on a
760    /// chunk boundary, and is OMITTED (rather than guessed) on a mid-chunk window.
761    /// Being fixed-size, it is settable on its own — see
762    /// [`with_chunk_index`](Self::with_chunk_index) — precisely so a continuation
763    /// frame can state it without dragging along the once-per-stream
764    /// [`inclusion_proof`](Self::inclusion_proof).
765    #[serde(skip_serializing_if = "Option::is_none", default)]
766    pub chunk_index: Option<u64>,
767    /// Whole-resource merkle proof against [`root`](Self::root), base64, relayed
768    /// verbatim.
769    ///
770    /// Resource-scaling, so it rides the first frame or the paged prologue, once
771    /// per range stream. A holder MUST NOT repeat it per frame: it is bounded at
772    /// 4,096 base64 bytes, which against the frame budget leaves no slack for the
773    /// payload the frame exists to carry.
774    #[serde(skip_serializing_if = "Option::is_none", default)]
775    pub inclusion_proof: Option<String>,
776    /// The chain-anchored root (64-hex) this frame's resource verified against.
777    /// Part of the fixed-size **identity set**, so it rides EVERY frame.
778    ///
779    /// NOT A TRUST ANCHOR BY ITSELF. The client resolves the resource's root from
780    /// the URN (chain-anchored) and PINS it before fetching; a peer-declared value
781    /// never replaces that pinned root. What this field provides is a
782    /// generation-CONSISTENCY check: a frame declaring a root other than the pinned
783    /// one is REJECTED and attributed to the offending peer (NC-9 fail-closed). So
784    /// a declared root can only ever cause rejection — it can never move the pinned
785    /// root, and never makes an unverified frame acceptable.
786    #[serde(skip_serializing_if = "Option::is_none", default)]
787    pub root: Option<HexId>,
788    /// **RESERVED — not currently derivable; a server MUST NOT emit it.**
789    ///
790    /// Per-chunk merkle inclusion proofs for the chunks a frame covers. No such
791    /// proof exists in the current store format: the generation root's merkle
792    /// leaves are per-RESOURCE (a leaf is the SHA-256 of a resource's WHOLE
793    /// ciphertext), so a single chunk has no leaf to prove. A client MUST NOT
794    /// require this field, and per-range verification instead uses the
795    /// whole-resource [`inclusion_proof`](Self::inclusion_proof) together with the
796    /// per-frame [`root`](Self::root)/[`chunk_lens`](Self::chunk_lens) metadata.
797    ///
798    /// Making it derivable requires a per-resource chunk-level commitment in the
799    /// store format first (tracked as `dig_ecosystem#1601`). The field is kept in
800    /// the wire type, unused, so populating it later is additive (§5.1); each entry
801    /// would be an opaque base64 proof blob, since this pure level-00 wire type
802    /// MUST NOT depend on the merkle primitive.
803    #[serde(skip_serializing_if = "Option::is_none", default)]
804    pub range_proof: Option<Vec<String>>,
805    /// The chunk index of the first chunk in this frame (0-based, into the
806    /// resource's chunk sequence described by [`chunk_lens`](Self::chunk_lens)).
807    ///
808    /// Present only when the frame's window begins EXACTLY on a chunk boundary; a
809    /// mid-chunk window omits it rather than assert an index the caller's own
810    /// alignment check would contradict. The served window is exactly the requested
811    /// span — a server MUST NOT widen a range to a chunk boundary — so a frame is
812    /// chunk-aligned only when the caller asked for an aligned span.
813    #[serde(skip_serializing_if = "Option::is_none", default)]
814    pub first_chunk_index: Option<u64>,
815    /// The resource's TOTAL chunk count — how many entries the fully reassembled
816    /// [`chunk_lens`](Self::chunk_lens) array has.
817    ///
818    /// Fixed-size, so it belongs to the **identity set** and rides EVERY frame.
819    /// Together with [`root`](Self::root) and
820    /// [`total_length`](Self::total_length) it is what lets a reader detect a
821    /// wrong-generation or wrong-layout holder on the first frame it receives. It is
822    /// also how a reader sizes the array it is paging in, and therefore how it knows
823    /// a **paged prologue** is complete: the prologue ends when the reader holds
824    /// `chunk_count` entries, which no single page can tell it.
825    #[serde(default, skip_serializing_if = "Option::is_none")]
826    pub chunk_count: Option<u64>,
827    /// The index into the resource's [`chunk_lens`](Self::chunk_lens) array at which
828    /// THIS frame's page begins — how a **paged prologue** is located and
829    /// reassembled.
830    ///
831    /// A resource whose layout exceeds the per-frame entry cap cannot state it on
832    /// one frame, so the sender pages it: successive frames each carry up to that
833    /// many entries, stamped with the offset they start at. A reader places each page
834    /// at its offset and holds the whole array once it has
835    /// [`chunk_count`](Self::chunk_count) entries.
836    ///
837    /// Absent means "this frame's `chunk_lens`, if any, begins at entry 0" — the
838    /// single-frame layout, which is the shape every pre-0.6.0 producer emits. So an
839    /// older frame decodes with exactly its original meaning (§5.1).
840    #[serde(default, skip_serializing_if = "Option::is_none")]
841    pub chunk_lens_offset: Option<u64>,
842}
843
844impl RangeFrame {
845    /// A **data frame**: `length` bytes of base64 ciphertext at `offset`, carrying
846    /// no metadata — the bare shape every continuation frame starts from.
847    ///
848    /// `length` is stated rather than derived because [`bytes`](Self::bytes) is
849    /// already base64 on this type, and recovering the raw window length from it
850    /// would need a base64 codec this pure level-00 wire crate deliberately does not
851    /// depend on. A serve path passes the length it served.
852    pub fn data(offset: u64, length: u64, bytes: impl Into<String>) -> Self {
853        RangeFrame {
854            offset,
855            length,
856            bytes: bytes.into(),
857            complete: false,
858            total_length: None,
859            chunk_lens: None,
860            chunk_index: None,
861            inclusion_proof: None,
862            root: None,
863            range_proof: None,
864            first_chunk_index: None,
865            chunk_count: None,
866            chunk_lens_offset: None,
867        }
868    }
869
870    /// Mark this as the final frame of the range.
871    pub fn with_complete(mut self, complete: bool) -> Self {
872        self.complete = complete;
873        self
874    }
875
876    /// The fixed-size **identity set** every frame of a range carries: the
877    /// generation `root` (64-hex) the range is served from, the resource's
878    /// ciphertext `total_length`, and its `chunk_count`.
879    ///
880    /// These three are what let a reader reject a wrong-generation or wrong-layout
881    /// holder the moment a frame arrives — which the resource-scaling metadata never
882    /// could, since it arrives once. Call this on every frame.
883    pub fn with_identity(
884        mut self,
885        root: impl Into<HexId>,
886        total_length: u64,
887        chunk_count: u64,
888    ) -> Self {
889        self.root = Some(root.into());
890        self.total_length = Some(total_length);
891        self.chunk_count = Some(chunk_count);
892        self
893    }
894
895    /// State [`chunk_index`](Self::chunk_index) — the chunk this frame's window
896    /// begins on — for a chunk-aligned window.
897    ///
898    /// Separate from [`with_inclusion_proof`](Self::with_inclusion_proof) on purpose:
899    /// the index is fixed-size identity metadata that rides every aligned frame,
900    /// while the proof is once-per-stream, so binding them together would force a
901    /// producer to either repeat a proof it MUST NOT repeat or bypass this API. Omit
902    /// the call entirely for a mid-chunk window.
903    pub fn with_chunk_index(mut self, chunk_index: u64) -> Self {
904        self.chunk_index = Some(chunk_index);
905        self
906    }
907
908    /// Additionally state [`first_chunk_index`](Self::first_chunk_index), this
909    /// crate's v0.4.0 alias of [`chunk_index`](Self::chunk_index).
910    ///
911    /// Both names carry the same value. dig-nat emits only `chunk_index`, so
912    /// [`with_chunk_index`](Self::with_chunk_index) alone is the interoperable
913    /// choice; a producer serving readers that expect the newer name states both.
914    pub fn with_first_chunk_index(mut self, first_chunk_index: u64) -> Self {
915        self.first_chunk_index = Some(first_chunk_index);
916        self
917    }
918
919    /// One page of the resource's `chunk_lens` array, beginning at entry
920    /// `chunk_lens_offset`.
921    ///
922    /// Call it once with offset `0` for a layout that fits a single frame, or once
923    /// per page of a **paged prologue**. A page is only ever useful as part of a
924    /// complete set: `chunk_lens` is a decrypt input, and a reader needs all
925    /// [`chunk_count`](Self::chunk_count) entries before it can decrypt anything.
926    pub fn with_chunk_lens_page(mut self, chunk_lens_offset: u64, chunk_lens: Vec<u64>) -> Self {
927        self.chunk_lens_offset = Some(chunk_lens_offset);
928        self.chunk_lens = Some(chunk_lens);
929        self
930    }
931
932    /// The whole-resource merkle inclusion proof against
933    /// [`root`](Self::root) (base64, relayed verbatim).
934    ///
935    /// Resource-scaling: state it on the first frame or the prologue, once per range
936    /// stream, never per frame.
937    pub fn with_inclusion_proof(mut self, inclusion_proof: impl Into<String>) -> Self {
938        self.inclusion_proof = Some(inclusion_proof.into());
939        self
940    }
941
942    /// State the **RESERVED** [`range_proof`](Self::range_proof) field.
943    ///
944    /// A server MUST NOT emit it — no per-chunk proof is derivable from the current
945    /// store format (see the field's own documentation). The setter exists so the
946    /// shape stays constructible for the conformance vectors that pin it, and so no
947    /// field of this `#[non_exhaustive]` type is unreachable; it is not a serve-path
948    /// call.
949    pub fn with_range_proof(mut self, range_proof: Vec<String>) -> Self {
950        self.range_proof = Some(range_proof);
951        self
952    }
953}
954
955// ===========================================================================
956// dig.getModuleInfo / dig.fetchModuleRange  (PEER — whole-module pull, #1576)
957// ===========================================================================
958
959/// Params for [`dig.getModuleInfo`](crate::method::Method::GetModuleInfo) — the
960/// handshake a peer reads before range-pulling a whole `.dig` module for
961/// `(store, root)`.
962#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
963#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
964pub struct GetModuleInfoParams {
965    /// The store launcher id (64-hex, required).
966    pub store_id: HexId,
967    /// The generation root whose `.dig` module is being pulled (64-hex, required).
968    pub root: HexId,
969}
970
971/// Result for [`dig.getModuleInfo`](crate::method::Method::GetModuleInfo) — the
972/// transfer descriptor of a whole `.dig` module.
973///
974/// The whole-module blob is content-addressed + immutable (the `.dig` container
975/// is byte-identical by construction). [`module_hash`](Self::module_hash) is the
976/// content id of the assembled blob; a puller verifies each pulled range against
977/// [`chunk_hashes`](Self::chunk_hashes) (per-peer attribution on a multi-source
978/// pull) and the fully-assembled blob against `module_hash`, THEN verifies the
979/// assembled module against its chain-anchored root before admitting + resharing
980/// (NC-9 verified-content-not-safe-content).
981#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
982#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
983pub struct ModuleInfo {
984    /// The total byte length of the whole `.dig` module blob.
985    pub total_size: u64,
986    /// The content id of the fully-assembled module blob (64-hex `SHA-256` of the
987    /// module bytes). The puller checks the assembled blob against this.
988    pub module_hash: HexId,
989    /// Per-chunk content hashes (64-hex each) in ascending chunk order, covering
990    /// the blob in [`total_size`](Self::total_size)-spanning fixed-size chunks
991    /// (the trailing chunk may be short). A puller checks each pulled
992    /// [`RangeFrame`] against the covering entries for per-source attribution on a
993    /// multi-source pull (a tampered range fails closed before assembly).
994    pub chunk_hashes: Vec<HexId>,
995    /// Per-chunk byte lengths (in the same order as [`chunk_hashes`](Self::chunk_hashes)).
996    /// MUST have the same length as `chunk_hashes` and MUST sum to `total_size`.
997    /// A puller uses these to map a fetched byte range to the covering chunk hash(es).
998    pub chunk_lens: Vec<u64>,
999}
1000
1001/// Params for [`dig.fetchModuleRange`](crate::method::Method::FetchModuleRange) —
1002/// a single range frame of the whole `.dig` module blob for `(store, root)`.
1003///
1004/// The response reuses [`RangeFrame`]: [`bytes`](RangeFrame::bytes) carries the
1005/// window of the module blob (base64), [`total_length`](RangeFrame::total_length)
1006/// echoes the whole-module size on the first frame, and
1007/// [`complete`](RangeFrame::complete) ends the stream.
1008#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1009#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1010pub struct FetchModuleRangeParams {
1011    /// The store launcher id (64-hex, required).
1012    pub store_id: HexId,
1013    /// The generation root whose `.dig` module is being pulled (64-hex, required).
1014    pub root: HexId,
1015    /// The range start into the module blob (default 0).
1016    #[serde(skip_serializing_if = "Option::is_none", default)]
1017    pub offset: Option<u64>,
1018    /// The range length in bytes (> 0; clamped to the window cap).
1019    pub length: u64,
1020}
1021
1022// ===========================================================================
1023// dig.stage  (CONTROL — loopback / in-process only)
1024// ===========================================================================
1025
1026/// Params for [`dig.stage`](crate::method::Method::Stage) — compile a local
1027/// folder into a capsule `.dig` module in-process.
1028#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1029#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1030pub struct StageParams {
1031    /// The absolute path to the folder to compile.
1032    pub dir: String,
1033    /// The target store launcher id (64-hex). Absent ⇒ an ephemeral,
1034    /// content-derived id (a preview).
1035    #[serde(skip_serializing_if = "Option::is_none", default)]
1036    pub store_id: Option<HexId>,
1037    /// The store salt (64-hex). Present ⇒ a private store.
1038    #[serde(skip_serializing_if = "Option::is_none", default)]
1039    pub salt: Option<HexId>,
1040    /// Optional DIGHub-style manifest metadata to embed.
1041    #[serde(skip_serializing_if = "Option::is_none", default)]
1042    pub metadata: Option<serde_json::Value>,
1043}
1044
1045/// Result for [`dig.stage`](crate::method::Method::Stage) — the compiled capsule.
1046#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1047#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1048pub struct StageResult {
1049    /// The canonical capsule identity, `storeId:rootHash`.
1050    pub capsule: String,
1051    /// The store launcher id (64-hex).
1052    pub store_id: HexId,
1053    /// The compiled generation root (64-hex).
1054    pub root: HexId,
1055    /// The filesystem path to the compiled `.dig` module.
1056    pub module_path: String,
1057    /// The module size in bytes.
1058    pub size: u64,
1059    /// The `chia://storeId:rootHash/` content address.
1060    #[serde(skip_serializing_if = "Option::is_none", default)]
1061    pub content_address: Option<String>,
1062    /// The relative paths compiled into the capsule.
1063    #[serde(default)]
1064    pub files: Vec<String>,
1065    /// Whether this is an ephemeral preview (not advancing a real store).
1066    #[serde(skip_serializing_if = "Option::is_none", default)]
1067    pub ephemeral: Option<bool>,
1068}
1069
1070// ===========================================================================
1071// cache.*  (CONTROL — loopback / in-process only)
1072// ===========================================================================
1073
1074/// Result for [`cache.getConfig`](crate::method::Method::CacheGetConfig).
1075///
1076/// The canonical field name for the cache path is `cache_dir` everywhere (the
1077/// shell's historical `dir` is unified onto this name).
1078#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1079#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1080pub struct CacheConfig {
1081    /// The on-disk cache size cap in bytes (floored at 64 MiB).
1082    pub cap_bytes: u64,
1083    /// The bytes currently used.
1084    pub used_bytes: u64,
1085    /// The effective resolved cache directory.
1086    pub cache_dir: String,
1087    /// Whether that directory is the canonical shared location (vs a
1088    /// process-private fallback).
1089    pub shared: bool,
1090}
1091
1092/// Params for [`cache.setCapBytes`](crate::method::Method::CacheSetCapBytes).
1093#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1094#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1095pub struct SetCapBytesParams {
1096    /// The requested cap in bytes (floored at 64 MiB by the node).
1097    pub cap_bytes: u64,
1098}
1099
1100/// Result for [`cache.setCapBytes`](crate::method::Method::CacheSetCapBytes).
1101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1102#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1103pub struct SetCapBytesResult {
1104    /// The effective cap after flooring.
1105    pub cap_bytes: u64,
1106}
1107
1108/// One durable cached-module entry.
1109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1110#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1111pub struct CachedCapsule {
1112    /// The canonical capsule identity, `storeId:rootHash`.
1113    pub capsule: String,
1114    /// The store launcher id (64-hex).
1115    pub store_id: HexId,
1116    /// The generation root (64-hex).
1117    pub root: HexId,
1118    /// The module size in bytes.
1119    pub size_bytes: u64,
1120    /// When the module was last used (unix ms).
1121    pub last_used_unix_ms: u64,
1122}
1123
1124/// Result for [`cache.listCached`](crate::method::Method::CacheListCached).
1125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1126#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1127pub struct CachedList {
1128    /// The cached capsules.
1129    pub cached: Vec<CachedCapsule>,
1130}
1131
1132/// Params for a capsule-keyed cache op
1133/// ([`cache.removeCached`](crate::method::Method::CacheRemoveCached),
1134/// [`cache.fetchAndCache`](crate::method::Method::CacheFetchAndCache)).
1135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1136#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1137pub struct CapsuleKey {
1138    /// The store launcher id (64-hex).
1139    pub store_id: HexId,
1140    /// The generation root (64-hex).
1141    pub root: HexId,
1142}
1143
1144/// Result for [`cache.removeCached`](crate::method::Method::CacheRemoveCached).
1145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1146#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1147pub struct RemoveCachedResult {
1148    /// Whether an entry was removed.
1149    pub removed: bool,
1150}
1151
1152/// Result for [`cache.fetchAndCache`](crate::method::Method::CacheFetchAndCache).
1153///
1154/// A failed fetch is reported in-band (`status = "failed"` + `message`) so the
1155/// caller can show it without treating it as a transport error.
1156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1157#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1158pub struct FetchAndCacheResult {
1159    /// `"cached"`, `"already_cached"`, or `"failed"`.
1160    pub status: String,
1161    /// The fetched module size in bytes (on success).
1162    #[serde(skip_serializing_if = "Option::is_none", default)]
1163    pub size_bytes: Option<u64>,
1164    /// The served generation root (64-hex, on success).
1165    #[serde(skip_serializing_if = "Option::is_none", default)]
1166    pub served_root: Option<HexId>,
1167    /// The failure message (on `status = "failed"`).
1168    #[serde(skip_serializing_if = "Option::is_none", default)]
1169    pub message: Option<String>,
1170}
1171
1172// ===========================================================================
1173// control.peerStatus  (CONTROL — loopback / in-process only)
1174// ===========================================================================
1175
1176/// Result for [`control.peerStatus`](crate::method::Method::ControlPeerStatus) —
1177/// a snapshot of the node's L7 peer network. Always safe to call; reports
1178/// `running: false` on the FFI path.
1179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1180#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1181pub struct PeerStatusSnapshot {
1182    /// Whether a peer network is currently active.
1183    pub running: bool,
1184    /// This node's `peer_id` (64-hex), if a peer network is running.
1185    #[serde(skip_serializing_if = "Option::is_none", default)]
1186    pub peer_id: Option<HexId>,
1187    /// The DIG network id.
1188    pub network_id: String,
1189    /// The relay reservation posture.
1190    pub relay: RelayStatus,
1191    /// The number of currently connected peers.
1192    pub connected_peers: u64,
1193    /// The last peer-network error, if any.
1194    #[serde(skip_serializing_if = "Option::is_none", default)]
1195    pub last_error: Option<String>,
1196}
1197
1198// ===========================================================================
1199// cache.stats  (CONTROL — loopback / in-process only)
1200// ===========================================================================
1201
1202/// The decoded-content cache hit/miss counters carried in
1203/// [`CacheStats`](CacheStats::content_cache).
1204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1205#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1206pub struct ContentCacheCounters {
1207    /// Session decoded-content cache hits.
1208    pub hits: u64,
1209    /// Session decoded-content cache misses.
1210    pub misses: u64,
1211}
1212
1213/// Result for [`cache.stats`](crate::method::Method::CacheStats) — cache
1214/// telemetry beside [`cache.getConfig`](crate::method::Method::CacheGetConfig):
1215/// the reserved cap + live usage, the cached-capsule count + total on-disk
1216/// bytes, and the session eviction + content-cache counters.
1217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1218#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1219pub struct CacheStats {
1220    /// The on-disk cache size cap in bytes.
1221    pub cap_bytes: u64,
1222    /// The bytes currently used on disk.
1223    pub used_bytes: u64,
1224    /// The number of durable cached capsules.
1225    pub entry_count: u64,
1226    /// The total on-disk bytes across the cached capsules.
1227    pub total_bytes: u64,
1228    /// Capsules evicted this session.
1229    pub evicted_count: u64,
1230    /// Bytes evicted this session.
1231    pub evicted_bytes: u64,
1232    /// The decoded-content cache hit/miss counters.
1233    pub content_cache: ContentCacheCounters,
1234}
1235
1236// ===========================================================================
1237// control.subscribe / control.unsubscribe / control.listSubscriptions
1238// (CONTROL — loopback / in-process only)
1239// ===========================================================================
1240
1241/// Params for [`control.subscribe`](crate::method::Method::ControlSubscribe) and
1242/// [`control.unsubscribe`](crate::method::Method::ControlUnsubscribe).
1243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1244#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1245pub struct SubscribeParams {
1246    /// The store launcher id to (un)subscribe (64-hex).
1247    pub store_id: HexId,
1248}
1249
1250/// Result for [`control.subscribe`](crate::method::Method::ControlSubscribe).
1251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1252#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1253pub struct SubscribeResult {
1254    /// Always `true` — the store is subscribed after this call.
1255    pub subscribed: bool,
1256    /// Whether this call ADDED the subscription (`false` ⇒ already subscribed).
1257    pub added: bool,
1258    /// The canonical persisted store id (trimmed + lower-cased, 64-hex).
1259    pub store_id: HexId,
1260}
1261
1262/// Result for [`control.unsubscribe`](crate::method::Method::ControlUnsubscribe).
1263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1264#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1265pub struct UnsubscribeResult {
1266    /// Always `false` — the store is not subscribed after this call.
1267    pub subscribed: bool,
1268    /// Whether this call REMOVED a subscription (`false` ⇒ was not subscribed).
1269    pub removed: bool,
1270    /// The canonical persisted store id (trimmed + lower-cased, 64-hex).
1271    pub store_id: HexId,
1272}
1273
1274/// Result for
1275/// [`control.listSubscriptions`](crate::method::Method::ControlListSubscriptions).
1276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1277#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1278pub struct SubscriptionsList {
1279    /// The persisted subscribed store ids (64-hex each).
1280    pub subscriptions: Vec<HexId>,
1281    /// The subscription count (`subscriptions.len()`).
1282    pub count: u64,
1283}
1284
1285// ===========================================================================
1286// control.peers.connect / control.peers.disconnect
1287// (CONTROL — loopback / in-process only)
1288// ===========================================================================
1289
1290/// Params for [`control.peers.connect`](crate::method::Method::ControlPeersConnect)
1291/// and [`control.peers.disconnect`](crate::method::Method::ControlPeersDisconnect).
1292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1293#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1294pub struct PeerConnectParams {
1295    /// The peer to dial/drop — a dialable address, or a known peer's `peer_id`
1296    /// (64-hex) to resolve an already-connected peer.
1297    pub peer: String,
1298}
1299
1300/// Result for
1301/// [`control.peers.connect`](crate::method::Method::ControlPeersConnect).
1302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1303#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1304pub struct PeerConnectResult {
1305    /// Always `true` on success — the peer is a counted, connected pool member.
1306    pub connected: bool,
1307    /// The connected peer's stable `peer_id` (64-hex).
1308    pub peer_id: HexId,
1309}
1310
1311/// Result for
1312/// [`control.peers.disconnect`](crate::method::Method::ControlPeersDisconnect).
1313///
1314/// Idempotent: disconnecting a peer that is not connected succeeds as a no-op.
1315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1316#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1317pub struct PeerDisconnectResult {
1318    /// Always `true` — the peer is not in the pool after this call.
1319    pub disconnected: bool,
1320    /// The dropped peer's `peer_id` (trimmed + lower-cased, 64-hex).
1321    pub peer_id: HexId,
1322}
1323
1324// ===========================================================================
1325// dig.health / dig.methods / rpc.discover  (discovery)
1326// ===========================================================================
1327
1328/// Result for [`dig.health`](crate::method::Method::Health) — liveness + a
1329/// capability summary.
1330#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1331#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1332pub struct Health {
1333    /// Liveness — `"ok"` when the node can serve.
1334    pub status: String,
1335    /// The node's software version.
1336    #[serde(skip_serializing_if = "Option::is_none", default)]
1337    pub version: Option<String>,
1338    /// The DIG network id the node serves.
1339    #[serde(skip_serializing_if = "Option::is_none", default)]
1340    pub network_id: Option<String>,
1341    /// The method names this node implements (its profile).
1342    #[serde(default)]
1343    pub methods: Vec<String>,
1344}
1345
1346/// Result for [`dig.methods`](crate::method::Method::Methods) — the method names
1347/// this node implements (agent self-describe).
1348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1349#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1350pub struct Methods {
1351    /// The implemented method names.
1352    pub methods: Vec<String>,
1353}
1354
1355#[cfg(test)]
1356mod tests {
1357    use super::*;
1358    use serde_json::json;
1359
1360    /// **Proves:** `ContentChunk` round-trips a node-profile window (no
1361    /// network-profile fields) without inventing keys.
1362    /// **Catches:** a missing `skip_serializing_if` that would leak `null`
1363    /// network-profile fields onto the node profile.
1364    #[test]
1365    fn content_chunk_node_profile_is_lean() {
1366        let c = ContentChunk {
1367            ciphertext: "AAA=".into(),
1368            root: "ab".repeat(32),
1369            complete: false,
1370            next_offset: Some(3_145_728),
1371            inclusion_proof: Some("cHJvb2Y=".into()),
1372            chunk_lens: Some(vec![10, 20]),
1373            source: Some("local".into()),
1374            total_length: None,
1375            length: None,
1376            offset: None,
1377            program_hash: None,
1378        };
1379        let v = serde_json::to_value(&c).unwrap();
1380        assert_eq!(v["source"], "local");
1381        assert!(
1382            v.get("total_length").is_none(),
1383            "node profile must omit total_length"
1384        );
1385        assert!(v.get("program_hash").is_none());
1386        assert_eq!(serde_json::from_value::<ContentChunk>(v).unwrap(), c);
1387    }
1388
1389    /// **Proves:** the network-profile fields serialize when present.
1390    #[test]
1391    fn content_chunk_network_profile_carries_extras() {
1392        let c = ContentChunk {
1393            ciphertext: "AAA=".into(),
1394            root: "cd".repeat(32),
1395            complete: true,
1396            next_offset: None,
1397            inclusion_proof: None,
1398            chunk_lens: None,
1399            source: None,
1400            total_length: Some(100),
1401            length: Some(100),
1402            offset: Some(0),
1403            program_hash: Some("ef".repeat(32)),
1404        };
1405        let v = serde_json::to_value(&c).unwrap();
1406        assert_eq!(v["total_length"], 100);
1407        assert_eq!(v["length"], 100);
1408        assert!(v.get("source").is_none());
1409    }
1410
1411    /// **Proves:** the untagged `Inventory` picks `ForStore` vs `AllStores` by
1412    /// shape.
1413    /// **Catches:** a lost `#[serde(untagged)]` that would tag the variant.
1414    #[test]
1415    fn inventory_untagged_by_shape() {
1416        let for_store = Inventory::ForStore {
1417            store_id: "ab".repeat(32),
1418            roots: vec!["cd".repeat(32)],
1419        };
1420        let s = serde_json::to_string(&for_store).unwrap();
1421        assert!(s.contains("\"roots\""));
1422        assert!(!s.contains("ForStore"));
1423        assert_eq!(serde_json::from_str::<Inventory>(&s).unwrap(), for_store);
1424
1425        let all = Inventory::AllStores {
1426            stores: vec!["ef".repeat(32)],
1427        };
1428        let s = serde_json::to_string(&all).unwrap();
1429        assert!(s.contains("\"stores\""));
1430        assert_eq!(serde_json::from_str::<Inventory>(&s).unwrap(), all);
1431    }
1432
1433    /// **Proves:** `RedirectInfo` serializes the full redirect payload the
1434    /// `-32008` envelope carries.
1435    #[test]
1436    fn redirect_info_shape() {
1437        let r = RedirectInfo {
1438            content: ContentRef {
1439                store_id: "ab".repeat(32),
1440                root: Some("cd".repeat(32)),
1441                retrieval_key: Some("ef".repeat(32)),
1442            },
1443            providers: vec![Provider {
1444                peer_id: "12".repeat(32),
1445                addresses: vec![PeerAddress {
1446                    host: "::1".into(),
1447                    port: 9444,
1448                    kind: "direct".into(),
1449                }],
1450            }],
1451            redirect_depth: 1,
1452            max_redirects: 4,
1453        };
1454        let v = serde_json::to_value(&r).unwrap();
1455        assert_eq!(v["redirect_depth"], 1);
1456        assert_eq!(v["max_redirects"], 4);
1457        assert_eq!(v["providers"][0]["addresses"][0]["host"], "::1");
1458        assert_eq!(serde_json::from_value::<RedirectInfo>(v).unwrap(), r);
1459    }
1460
1461    /// **Proves:** `cache.stats` models the live dig-node result field-for-field
1462    /// (the nested `content_cache{hits,misses}` object included).
1463    /// **Catches:** a drift from the node's `cache.stats` wire shape (#1075).
1464    #[test]
1465    fn cache_stats_wire_shape() {
1466        let s = CacheStats {
1467            cap_bytes: 1 << 30,
1468            used_bytes: 2048,
1469            entry_count: 3,
1470            total_bytes: 2048,
1471            evicted_count: 1,
1472            evicted_bytes: 512,
1473            content_cache: ContentCacheCounters { hits: 7, misses: 2 },
1474        };
1475        let v = serde_json::to_value(s).unwrap();
1476        assert_eq!(v["cap_bytes"], 1 << 30);
1477        assert_eq!(v["entry_count"], 3);
1478        assert_eq!(v["content_cache"]["hits"], 7);
1479        assert_eq!(v["content_cache"]["misses"], 2);
1480        assert_eq!(serde_json::from_value::<CacheStats>(v).unwrap(), s);
1481    }
1482
1483    /// **Proves:** the subscription-management results carry the exact
1484    /// `{subscribed, added|removed, store_id}` / `{subscriptions, count}` shapes
1485    /// the live node returns.
1486    #[test]
1487    fn subscription_result_shapes() {
1488        let sub = SubscribeResult {
1489            subscribed: true,
1490            added: true,
1491            store_id: "ab".repeat(32),
1492        };
1493        let v = serde_json::to_value(&sub).unwrap();
1494        assert_eq!(v["subscribed"], true);
1495        assert_eq!(v["added"], true);
1496        assert_eq!(serde_json::from_value::<SubscribeResult>(v).unwrap(), sub);
1497
1498        let unsub = UnsubscribeResult {
1499            subscribed: false,
1500            removed: true,
1501            store_id: "cd".repeat(32),
1502        };
1503        let v = serde_json::to_value(&unsub).unwrap();
1504        assert_eq!(v["subscribed"], false);
1505        assert_eq!(v["removed"], true);
1506        assert_eq!(
1507            serde_json::from_value::<UnsubscribeResult>(v).unwrap(),
1508            unsub
1509        );
1510
1511        let list = SubscriptionsList {
1512            subscriptions: vec!["ef".repeat(32)],
1513            count: 1,
1514        };
1515        let v = serde_json::to_value(&list).unwrap();
1516        assert_eq!(v["count"], 1);
1517        assert_eq!(
1518            serde_json::from_value::<SubscriptionsList>(v).unwrap(),
1519            list
1520        );
1521    }
1522
1523    /// **Proves:** `ModuleInfo` carries `chunk_lens` covering every chunk, and
1524    /// round-trips with unknown future fields.
1525    /// **Catches:** a missing `chunk_lens` field that would leave a puller unable
1526    /// to map a fetched byte range to its covering chunk hash.
1527    /// **Invariants enforced by docs:** `chunk_lens` must have the same length as
1528    /// `chunk_hashes` and must sum to `total_size`.
1529    #[test]
1530    fn module_info_chunk_lens_shape() {
1531        let info = ModuleInfo {
1532            total_size: 1024,
1533            module_hash: "ab".repeat(32),
1534            chunk_hashes: vec!["cd".repeat(32), "ef".repeat(32)],
1535            chunk_lens: vec![512, 512],
1536        };
1537        let v = serde_json::to_value(&info).unwrap();
1538        assert_eq!(v["total_size"], 1024);
1539        assert_eq!(v["chunk_hashes"].as_array().unwrap().len(), 2);
1540        assert_eq!(v["chunk_lens"].as_array().unwrap().len(), 2);
1541        assert_eq!(v["chunk_lens"][0], 512);
1542        assert_eq!(v["chunk_lens"][1], 512);
1543        assert_eq!(serde_json::from_value::<ModuleInfo>(v).unwrap(), info);
1544    }
1545
1546    /// **Proves:** `ModuleInfo` deserialization REJECTS missing `chunk_lens` field.
1547    /// This is a REQUIRED field (not optional) — omitting it from the wire is a
1548    /// protocol violation and must fail-closed.
1549    #[test]
1550    fn module_info_rejects_missing_chunk_lens() {
1551        let json_str = r#"{"total_size": 2048, "module_hash": "1122334455667788990011223344556677889900112233445566778899001122", "chunk_hashes": []}"#;
1552        let result: Result<ModuleInfo, _> = serde_json::from_str(json_str);
1553        assert!(
1554            result.is_err(),
1555            "ModuleInfo must reject JSON missing the required chunk_lens field"
1556        );
1557        let err = result.unwrap_err();
1558        assert!(
1559            err.to_string().contains("chunk_lens"),
1560            "error message should mention chunk_lens: {}",
1561            err
1562        );
1563    }
1564
1565    /// **Proves:** the peer connect/disconnect params + results round-trip and
1566    /// match the node's `{connected|disconnected, peer_id}` shapes.
1567    #[test]
1568    fn peer_connect_disconnect_shapes() {
1569        let p = PeerConnectParams {
1570            peer: "12".repeat(32),
1571        };
1572        let v = serde_json::to_value(&p).unwrap();
1573        assert_eq!(serde_json::from_value::<PeerConnectParams>(v).unwrap(), p);
1574
1575        let c = PeerConnectResult {
1576            connected: true,
1577            peer_id: "12".repeat(32),
1578        };
1579        let v = serde_json::to_value(&c).unwrap();
1580        assert_eq!(v["connected"], true);
1581        assert_eq!(serde_json::from_value::<PeerConnectResult>(v).unwrap(), c);
1582
1583        let d = PeerDisconnectResult {
1584            disconnected: true,
1585            peer_id: "34".repeat(32),
1586        };
1587        let v = serde_json::to_value(&d).unwrap();
1588        assert_eq!(v["disconnected"], true);
1589        assert_eq!(
1590            serde_json::from_value::<PeerDisconnectResult>(v).unwrap(),
1591            d
1592        );
1593    }
1594
1595    /// **Proves:** `cache.getConfig` uses the canonical `cache_dir` field name.
1596    /// **Catches:** a regression to the shell's historical `dir` name.
1597    #[test]
1598    fn cache_config_field_name_is_cache_dir() {
1599        let c = CacheConfig {
1600            cap_bytes: 1 << 30,
1601            used_bytes: 0,
1602            cache_dir: "/var/cache/dig".into(),
1603            shared: true,
1604        };
1605        let v = serde_json::to_value(&c).unwrap();
1606        assert!(v.get("cache_dir").is_some());
1607        assert!(v.get("dir").is_none(), "must not use the legacy `dir` name");
1608    }
1609
1610    /// **Proves:** an OLDER client's `dig.getAvailability` params — written
1611    /// before the hop budget existed — still deserialize, and read as a fresh,
1612    /// unhopped ask.
1613    /// **Catches:** a `redirect_depth` declared as a required `u64`, which
1614    /// rejects exactly these params with `missing field redirect_depth` and would
1615    /// make every pre-0.8 caller's ask a parse error at the peer boundary.
1616    /// **Guarded by:** the field's `Option` TYPE. `serde`'s derive already reads a
1617    /// missing `Option` field as `None`, so the `#[serde(default)]` beside it is
1618    /// parity with the sibling params types rather than the live guard — removing
1619    /// it alone leaves this test green (mutant-tested). Do not cite the attribute
1620    /// as the thing that keeps older clients working.
1621    #[test]
1622    fn get_availability_params_accepts_an_older_clients_params() {
1623        let older = json!({
1624            "items": [ { "store_id": "ab".repeat(32) } ]
1625        });
1626        let p: GetAvailabilityParams = serde_json::from_value(older).unwrap();
1627        assert_eq!(p.items.len(), 1);
1628        assert_eq!(p.redirect_depth, None, "an absent budget stays absent");
1629        assert_eq!(p.hops_consumed(), 0, "absent means zero hops consumed");
1630    }
1631
1632    /// **Proves:** a hop-zero ask serializes to exactly the pre-0.8 bytes — the
1633    /// `redirect_depth` key is absent, not `null`.
1634    /// **Catches:** a bare `#[serde(default)]` without `skip_serializing_if`,
1635    /// which would add `"redirect_depth": null` to every existing caller's
1636    /// frame and change the wire for callers that never opted in.
1637    #[test]
1638    fn get_availability_params_omits_an_absent_hop_budget() {
1639        let p = GetAvailabilityParams::new(vec![AvailabilityQuery {
1640            store_id: "ab".repeat(32),
1641            root: None,
1642            retrieval_key: None,
1643        }]);
1644        let v = serde_json::to_value(&p).unwrap();
1645        let keys: Vec<&String> = v.as_object().unwrap().keys().collect();
1646        assert_eq!(keys, vec!["items"], "hop-zero params carry only `items`");
1647    }
1648
1649    /// **Proves:** a hopped ask round-trips its budget under the `redirect_depth`
1650    /// key, and reads back through `hops_consumed`.
1651    #[test]
1652    fn get_availability_params_round_trips_the_hop_budget() {
1653        let p = GetAvailabilityParams::new(vec![AvailabilityQuery {
1654            store_id: "cd".repeat(32),
1655            root: Some("ef".repeat(32)),
1656            retrieval_key: None,
1657        }])
1658        .with_redirect_depth(2);
1659        let v = serde_json::to_value(&p).unwrap();
1660        assert_eq!(v["redirect_depth"], 2);
1661        assert_eq!(p.hops_consumed(), 2);
1662        assert_eq!(
1663            serde_json::from_value::<GetAvailabilityParams>(v).unwrap(),
1664            p
1665        );
1666    }
1667
1668    /// **Proves:** the hop budget an availability ask carries is the SAME field,
1669    /// with the same key, type and value, that a `-32008` redirect hands back and
1670    /// that `dig.getContent` / `dig.fetchRange` already echo — one field, one
1671    /// interpretation, counted UP toward `max_redirects`.
1672    /// **Catches:** a second reading of the budget in this crate (a remaining
1673    /// allowance counting DOWN, a differently-named key, a differently-typed
1674    /// value) — the byte-drift the shipped redirect contract exists to prevent.
1675    #[test]
1676    fn availability_hop_budget_mirrors_the_redirect_budget() {
1677        let handed_back = RedirectInfo {
1678            content: ContentRef {
1679                store_id: "ab".repeat(32),
1680                root: None,
1681                retrieval_key: None,
1682            },
1683            providers: vec![],
1684            redirect_depth: 3,
1685            max_redirects: 4,
1686        };
1687        let echoed = handed_back.redirect_depth;
1688
1689        let availability = serde_json::to_value(
1690            GetAvailabilityParams::new(vec![AvailabilityQuery {
1691                store_id: "ab".repeat(32),
1692                root: None,
1693                retrieval_key: None,
1694            }])
1695            .with_redirect_depth(echoed),
1696        )
1697        .unwrap();
1698        let content = serde_json::to_value(GetContentParams {
1699            store_id: "ab".repeat(32),
1700            retrieval_key: "cd".repeat(32),
1701            root: None,
1702            offset: None,
1703            mode: None,
1704            redirect_depth: Some(echoed),
1705        })
1706        .unwrap();
1707        let range = serde_json::to_value(
1708            FetchRangeParams::resource("ab".repeat(32), "cd".repeat(32), "ef".repeat(32), 1)
1709                .with_redirect_depth(echoed),
1710        )
1711        .unwrap();
1712
1713        for (method, params) in [
1714            ("dig.getAvailability", &availability),
1715            ("dig.getContent", &content),
1716            ("dig.fetchRange", &range),
1717        ] {
1718            assert_eq!(
1719                params["redirect_depth"], 3,
1720                "{method} must carry the echoed depth under `redirect_depth`"
1721            );
1722        }
1723        assert!(
1724            handed_back.redirect_depth < handed_back.max_redirects,
1725            "the budget counts UP toward `max_redirects`"
1726        );
1727    }
1728
1729    /// **Proves:** a NEWER client's params — carrying a field this build does not
1730    /// know — still deserialize, so a hop-bearing ask is never refused outright by
1731    /// an older responder that simply ignores the budget.
1732    /// **Catches:** a `#[serde(deny_unknown_fields)]` added to the params type,
1733    /// which would turn every forward-compatible extension into a hard parse
1734    /// failure at the peer boundary.
1735    #[test]
1736    fn get_availability_params_tolerates_an_unknown_field() {
1737        let newer = json!({
1738            "items": [ { "store_id": "ab".repeat(32) } ],
1739            "redirect_depth": 1,
1740            "a_field_this_build_does_not_know": true
1741        });
1742        let p: GetAvailabilityParams = serde_json::from_value(newer).unwrap();
1743        assert_eq!(p.hops_consumed(), 1);
1744    }
1745}