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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
415#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
416pub struct GetAvailabilityParams {
417    /// The items to check. Capped at 512 per batch (past-cap items are dropped).
418    pub items: Vec<AvailabilityQuery>,
419}
420
421/// One availability answer. Only the fields relevant to the query's granularity
422/// are populated.
423#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
424#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
425pub struct AvailabilityAnswer {
426    /// Whether this node holds the queried item.
427    pub available: bool,
428    /// The roots held (store-granularity queries only).
429    #[serde(skip_serializing_if = "Option::is_none", default)]
430    pub roots: Option<Vec<HexId>>,
431    /// The full resource ciphertext length (resource-granularity only).
432    #[serde(skip_serializing_if = "Option::is_none", default)]
433    pub total_length: Option<u64>,
434    /// The chunk count (resource-granularity only).
435    #[serde(skip_serializing_if = "Option::is_none", default)]
436    pub chunk_count: Option<u64>,
437    /// Whether the whole item is held (root/resource-granularity only).
438    #[serde(skip_serializing_if = "Option::is_none", default)]
439    pub complete: Option<bool>,
440    /// Providers that hold the item — present on a miss when holders were
441    /// located (enriched answer).
442    #[serde(skip_serializing_if = "Option::is_none", default)]
443    pub providers: Option<Vec<Provider>>,
444}
445
446/// Result for [`dig.getAvailability`](crate::method::Method::GetAvailability) —
447/// one answer per query item, in order.
448#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
449#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
450pub struct AvailabilityBatch {
451    /// The per-item answers (index-aligned to the query items served).
452    pub items: Vec<AvailabilityAnswer>,
453}
454
455// ===========================================================================
456// dig.listInventory  (PEER)
457// ===========================================================================
458
459/// Params for [`dig.listInventory`](crate::method::Method::ListInventory).
460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
461#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
462pub struct ListInventoryParams {
463    /// The store to list roots for (64-hex). Absent ⇒ list all stores served.
464    #[serde(skip_serializing_if = "Option::is_none", default)]
465    pub store_id: Option<HexId>,
466    /// The maximum number of entries to return.
467    #[serde(skip_serializing_if = "Option::is_none", default)]
468    pub limit: Option<u64>,
469}
470
471/// Result for [`dig.listInventory`](crate::method::Method::ListInventory).
472///
473/// With a `store_id` the node returns the roots it holds for that store; without
474/// one it returns the stores it serves. `#[serde(untagged)]` keeps the wire flat
475/// (`{"roots": …}` or `{"stores": …}`).
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
477#[serde(untagged)]
478#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
479pub enum Inventory {
480    /// The roots held for a specific store.
481    ForStore {
482        /// The store launcher id (echoed, 64-hex).
483        store_id: HexId,
484        /// The roots this node holds for the store.
485        roots: Vec<HexId>,
486    },
487    /// The stores this node serves (no `store_id` given).
488    AllStores {
489        /// The store launcher ids served.
490        stores: Vec<HexId>,
491    },
492}
493
494// ===========================================================================
495// dig.fetchRange  (PEER)
496// ===========================================================================
497
498/// Params for [`dig.fetchRange`](crate::method::Method::FetchRange) — a single
499/// range frame of a resource this node holds.
500#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
501#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
502pub struct FetchRangeParams {
503    /// The store launcher id (64-hex, required).
504    pub store_id: HexId,
505    /// The generation root (64-hex, required for a resource fetch).
506    pub root: HexId,
507    /// `SHA-256(urn)` (64-hex, required for a resource fetch).
508    pub retrieval_key: HexId,
509    /// The range start (default 0).
510    #[serde(skip_serializing_if = "Option::is_none", default)]
511    pub offset: Option<u64>,
512    /// The range length in bytes (> 0; clamped to the window cap).
513    pub length: u64,
514    /// Whole-capsule mode (default false). Capsule range fetch is not yet
515    /// served; a `true` here yields `-32004`.
516    #[serde(skip_serializing_if = "Option::is_none", default)]
517    pub capsule: Option<bool>,
518    /// The redirect budget already consumed (echoed from a `-32008` redirect).
519    #[serde(skip_serializing_if = "Option::is_none", default)]
520    pub redirect_depth: Option<u64>,
521}
522
523/// One range frame of a resource. The first frame (`offset == 0`) carries the
524/// per-resource verification metadata; later frames carry only the window.
525#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
526#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
527pub struct RangeFrame {
528    /// The window start offset (echoed).
529    pub offset: u64,
530    /// This window's byte length.
531    pub length: u64,
532    /// This window's ciphertext, base64.
533    pub bytes: String,
534    /// Whether this frame ends the resource.
535    pub complete: bool,
536    /// The full resource ciphertext length. First frame only.
537    #[serde(skip_serializing_if = "Option::is_none", default)]
538    pub total_length: Option<u64>,
539    /// Per-chunk ciphertext lengths of the full resource. First frame only.
540    #[serde(skip_serializing_if = "Option::is_none", default)]
541    pub chunk_lens: Option<Vec<u64>>,
542    /// This frame's chunk index. First frame only (`0`).
543    #[serde(skip_serializing_if = "Option::is_none", default)]
544    pub chunk_index: Option<u64>,
545    /// Whole-resource merkle proof, base64. First frame only.
546    #[serde(skip_serializing_if = "Option::is_none", default)]
547    pub inclusion_proof: Option<String>,
548    /// The chain-anchored root (64-hex). First frame only.
549    ///
550    /// Advisory ECHO only. A verifier MUST NOT trust this value: it substitutes
551    /// its OWN URN-pinned root when folding [`range_proof`](Self::range_proof)
552    /// and rejects any frame whose proof does not reach that pinned root
553    /// (NC-9 fail-closed).
554    #[serde(skip_serializing_if = "Option::is_none", default)]
555    pub root: Option<HexId>,
556    /// Per-chunk merkle inclusion proofs for the chunks this frame covers, in
557    /// ascending chunk order — present on ANY frame, unlike the first-frame-only
558    /// whole-resource [`inclusion_proof`](Self::inclusion_proof).
559    ///
560    /// Each entry is base64 of an opaque per-chunk merkle proof blob (the
561    /// canonical node emits `dig_capsule::MerkleProof::encode`). This crate is a
562    /// pure level-00 wire type and MUST NOT depend on the merkle primitive, so
563    /// the proof stays an opaque string here; the consumer decodes each blob and
564    /// folds its leaf → the client's PINNED root (never the peer's echoed
565    /// [`root`](Self::root)) to verify the chunk.
566    ///
567    /// The server expands a requested byte range to the covering whole-chunk
568    /// span, so a frame's chunks align with [`chunk_lens`](Self::chunk_lens);
569    /// `range_proof.len()` equals the number of chunks in this frame and pairs
570    /// index-for-index with [`first_chunk_index`](Self::first_chunk_index)`..`.
571    #[serde(skip_serializing_if = "Option::is_none", default)]
572    pub range_proof: Option<Vec<String>>,
573    /// The chunk index of the first chunk in this frame (0-based, into the
574    /// resource's chunk sequence described by [`chunk_lens`](Self::chunk_lens)).
575    ///
576    /// With `range_proof.len()` this pins each entry of
577    /// [`range_proof`](Self::range_proof) to an absolute chunk index, giving the
578    /// full byte↔chunk↔proof mapping.
579    #[serde(skip_serializing_if = "Option::is_none", default)]
580    pub first_chunk_index: Option<u64>,
581}
582
583// ===========================================================================
584// dig.getModuleInfo / dig.fetchModuleRange  (PEER — whole-module pull, #1576)
585// ===========================================================================
586
587/// Params for [`dig.getModuleInfo`](crate::method::Method::GetModuleInfo) — the
588/// handshake a peer reads before range-pulling a whole `.dig` module for
589/// `(store, root)`.
590#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
591#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
592pub struct GetModuleInfoParams {
593    /// The store launcher id (64-hex, required).
594    pub store_id: HexId,
595    /// The generation root whose `.dig` module is being pulled (64-hex, required).
596    pub root: HexId,
597}
598
599/// Result for [`dig.getModuleInfo`](crate::method::Method::GetModuleInfo) — the
600/// transfer descriptor of a whole `.dig` module.
601///
602/// The whole-module blob is content-addressed + immutable (the `.dig` container
603/// is byte-identical by construction). [`module_hash`](Self::module_hash) is the
604/// content id of the assembled blob; a puller verifies each pulled range against
605/// [`chunk_hashes`](Self::chunk_hashes) (per-peer attribution on a multi-source
606/// pull) and the fully-assembled blob against `module_hash`, THEN verifies the
607/// assembled module against its chain-anchored root before admitting + resharing
608/// (NC-9 verified-content-not-safe-content).
609#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
610#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
611pub struct ModuleInfo {
612    /// The total byte length of the whole `.dig` module blob.
613    pub total_size: u64,
614    /// The content id of the fully-assembled module blob (64-hex `SHA-256` of the
615    /// module bytes). The puller checks the assembled blob against this.
616    pub module_hash: HexId,
617    /// Per-chunk content hashes (64-hex each) in ascending chunk order, covering
618    /// the blob in [`total_size`](Self::total_size)-spanning fixed-size chunks
619    /// (the trailing chunk may be short). A puller checks each pulled
620    /// [`RangeFrame`] against the covering entries for per-source attribution on a
621    /// multi-source pull (a tampered range fails closed before assembly).
622    pub chunk_hashes: Vec<HexId>,
623    /// Per-chunk byte lengths (in the same order as [`chunk_hashes`](Self::chunk_hashes)).
624    /// MUST have the same length as `chunk_hashes` and MUST sum to `total_size`.
625    /// A puller uses these to map a fetched byte range to the covering chunk hash(es).
626    pub chunk_lens: Vec<u64>,
627}
628
629/// Params for [`dig.fetchModuleRange`](crate::method::Method::FetchModuleRange) —
630/// a single range frame of the whole `.dig` module blob for `(store, root)`.
631///
632/// The response reuses [`RangeFrame`]: [`bytes`](RangeFrame::bytes) carries the
633/// window of the module blob (base64), [`total_length`](RangeFrame::total_length)
634/// echoes the whole-module size on the first frame, and
635/// [`complete`](RangeFrame::complete) ends the stream.
636#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
637#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
638pub struct FetchModuleRangeParams {
639    /// The store launcher id (64-hex, required).
640    pub store_id: HexId,
641    /// The generation root whose `.dig` module is being pulled (64-hex, required).
642    pub root: HexId,
643    /// The range start into the module blob (default 0).
644    #[serde(skip_serializing_if = "Option::is_none", default)]
645    pub offset: Option<u64>,
646    /// The range length in bytes (> 0; clamped to the window cap).
647    pub length: u64,
648}
649
650// ===========================================================================
651// dig.stage  (CONTROL — loopback / in-process only)
652// ===========================================================================
653
654/// Params for [`dig.stage`](crate::method::Method::Stage) — compile a local
655/// folder into a capsule `.dig` module in-process.
656#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
657#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
658pub struct StageParams {
659    /// The absolute path to the folder to compile.
660    pub dir: String,
661    /// The target store launcher id (64-hex). Absent ⇒ an ephemeral,
662    /// content-derived id (a preview).
663    #[serde(skip_serializing_if = "Option::is_none", default)]
664    pub store_id: Option<HexId>,
665    /// The store salt (64-hex). Present ⇒ a private store.
666    #[serde(skip_serializing_if = "Option::is_none", default)]
667    pub salt: Option<HexId>,
668    /// Optional DIGHub-style manifest metadata to embed.
669    #[serde(skip_serializing_if = "Option::is_none", default)]
670    pub metadata: Option<serde_json::Value>,
671}
672
673/// Result for [`dig.stage`](crate::method::Method::Stage) — the compiled capsule.
674#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
675#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
676pub struct StageResult {
677    /// The canonical capsule identity, `storeId:rootHash`.
678    pub capsule: String,
679    /// The store launcher id (64-hex).
680    pub store_id: HexId,
681    /// The compiled generation root (64-hex).
682    pub root: HexId,
683    /// The filesystem path to the compiled `.dig` module.
684    pub module_path: String,
685    /// The module size in bytes.
686    pub size: u64,
687    /// The `chia://storeId:rootHash/` content address.
688    #[serde(skip_serializing_if = "Option::is_none", default)]
689    pub content_address: Option<String>,
690    /// The relative paths compiled into the capsule.
691    #[serde(default)]
692    pub files: Vec<String>,
693    /// Whether this is an ephemeral preview (not advancing a real store).
694    #[serde(skip_serializing_if = "Option::is_none", default)]
695    pub ephemeral: Option<bool>,
696}
697
698// ===========================================================================
699// cache.*  (CONTROL — loopback / in-process only)
700// ===========================================================================
701
702/// Result for [`cache.getConfig`](crate::method::Method::CacheGetConfig).
703///
704/// The canonical field name for the cache path is `cache_dir` everywhere (the
705/// shell's historical `dir` is unified onto this name).
706#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
707#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
708pub struct CacheConfig {
709    /// The on-disk cache size cap in bytes (floored at 64 MiB).
710    pub cap_bytes: u64,
711    /// The bytes currently used.
712    pub used_bytes: u64,
713    /// The effective resolved cache directory.
714    pub cache_dir: String,
715    /// Whether that directory is the canonical shared location (vs a
716    /// process-private fallback).
717    pub shared: bool,
718}
719
720/// Params for [`cache.setCapBytes`](crate::method::Method::CacheSetCapBytes).
721#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
722#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
723pub struct SetCapBytesParams {
724    /// The requested cap in bytes (floored at 64 MiB by the node).
725    pub cap_bytes: u64,
726}
727
728/// Result for [`cache.setCapBytes`](crate::method::Method::CacheSetCapBytes).
729#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
730#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
731pub struct SetCapBytesResult {
732    /// The effective cap after flooring.
733    pub cap_bytes: u64,
734}
735
736/// One durable cached-module entry.
737#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
738#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
739pub struct CachedCapsule {
740    /// The canonical capsule identity, `storeId:rootHash`.
741    pub capsule: String,
742    /// The store launcher id (64-hex).
743    pub store_id: HexId,
744    /// The generation root (64-hex).
745    pub root: HexId,
746    /// The module size in bytes.
747    pub size_bytes: u64,
748    /// When the module was last used (unix ms).
749    pub last_used_unix_ms: u64,
750}
751
752/// Result for [`cache.listCached`](crate::method::Method::CacheListCached).
753#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
754#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
755pub struct CachedList {
756    /// The cached capsules.
757    pub cached: Vec<CachedCapsule>,
758}
759
760/// Params for a capsule-keyed cache op
761/// ([`cache.removeCached`](crate::method::Method::CacheRemoveCached),
762/// [`cache.fetchAndCache`](crate::method::Method::CacheFetchAndCache)).
763#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
764#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
765pub struct CapsuleKey {
766    /// The store launcher id (64-hex).
767    pub store_id: HexId,
768    /// The generation root (64-hex).
769    pub root: HexId,
770}
771
772/// Result for [`cache.removeCached`](crate::method::Method::CacheRemoveCached).
773#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
774#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
775pub struct RemoveCachedResult {
776    /// Whether an entry was removed.
777    pub removed: bool,
778}
779
780/// Result for [`cache.fetchAndCache`](crate::method::Method::CacheFetchAndCache).
781///
782/// A failed fetch is reported in-band (`status = "failed"` + `message`) so the
783/// caller can show it without treating it as a transport error.
784#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
785#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
786pub struct FetchAndCacheResult {
787    /// `"cached"`, `"already_cached"`, or `"failed"`.
788    pub status: String,
789    /// The fetched module size in bytes (on success).
790    #[serde(skip_serializing_if = "Option::is_none", default)]
791    pub size_bytes: Option<u64>,
792    /// The served generation root (64-hex, on success).
793    #[serde(skip_serializing_if = "Option::is_none", default)]
794    pub served_root: Option<HexId>,
795    /// The failure message (on `status = "failed"`).
796    #[serde(skip_serializing_if = "Option::is_none", default)]
797    pub message: Option<String>,
798}
799
800// ===========================================================================
801// control.peerStatus  (CONTROL — loopback / in-process only)
802// ===========================================================================
803
804/// Result for [`control.peerStatus`](crate::method::Method::ControlPeerStatus) —
805/// a snapshot of the node's L7 peer network. Always safe to call; reports
806/// `running: false` on the FFI path.
807#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
808#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
809pub struct PeerStatusSnapshot {
810    /// Whether a peer network is currently active.
811    pub running: bool,
812    /// This node's `peer_id` (64-hex), if a peer network is running.
813    #[serde(skip_serializing_if = "Option::is_none", default)]
814    pub peer_id: Option<HexId>,
815    /// The DIG network id.
816    pub network_id: String,
817    /// The relay reservation posture.
818    pub relay: RelayStatus,
819    /// The number of currently connected peers.
820    pub connected_peers: u64,
821    /// The last peer-network error, if any.
822    #[serde(skip_serializing_if = "Option::is_none", default)]
823    pub last_error: Option<String>,
824}
825
826// ===========================================================================
827// cache.stats  (CONTROL — loopback / in-process only)
828// ===========================================================================
829
830/// The decoded-content cache hit/miss counters carried in
831/// [`CacheStats`](CacheStats::content_cache).
832#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
833#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
834pub struct ContentCacheCounters {
835    /// Session decoded-content cache hits.
836    pub hits: u64,
837    /// Session decoded-content cache misses.
838    pub misses: u64,
839}
840
841/// Result for [`cache.stats`](crate::method::Method::CacheStats) — cache
842/// telemetry beside [`cache.getConfig`](crate::method::Method::CacheGetConfig):
843/// the reserved cap + live usage, the cached-capsule count + total on-disk
844/// bytes, and the session eviction + content-cache counters.
845#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
846#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
847pub struct CacheStats {
848    /// The on-disk cache size cap in bytes.
849    pub cap_bytes: u64,
850    /// The bytes currently used on disk.
851    pub used_bytes: u64,
852    /// The number of durable cached capsules.
853    pub entry_count: u64,
854    /// The total on-disk bytes across the cached capsules.
855    pub total_bytes: u64,
856    /// Capsules evicted this session.
857    pub evicted_count: u64,
858    /// Bytes evicted this session.
859    pub evicted_bytes: u64,
860    /// The decoded-content cache hit/miss counters.
861    pub content_cache: ContentCacheCounters,
862}
863
864// ===========================================================================
865// control.subscribe / control.unsubscribe / control.listSubscriptions
866// (CONTROL — loopback / in-process only)
867// ===========================================================================
868
869/// Params for [`control.subscribe`](crate::method::Method::ControlSubscribe) and
870/// [`control.unsubscribe`](crate::method::Method::ControlUnsubscribe).
871#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
872#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
873pub struct SubscribeParams {
874    /// The store launcher id to (un)subscribe (64-hex).
875    pub store_id: HexId,
876}
877
878/// Result for [`control.subscribe`](crate::method::Method::ControlSubscribe).
879#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
880#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
881pub struct SubscribeResult {
882    /// Always `true` — the store is subscribed after this call.
883    pub subscribed: bool,
884    /// Whether this call ADDED the subscription (`false` ⇒ already subscribed).
885    pub added: bool,
886    /// The canonical persisted store id (trimmed + lower-cased, 64-hex).
887    pub store_id: HexId,
888}
889
890/// Result for [`control.unsubscribe`](crate::method::Method::ControlUnsubscribe).
891#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
892#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
893pub struct UnsubscribeResult {
894    /// Always `false` — the store is not subscribed after this call.
895    pub subscribed: bool,
896    /// Whether this call REMOVED a subscription (`false` ⇒ was not subscribed).
897    pub removed: bool,
898    /// The canonical persisted store id (trimmed + lower-cased, 64-hex).
899    pub store_id: HexId,
900}
901
902/// Result for
903/// [`control.listSubscriptions`](crate::method::Method::ControlListSubscriptions).
904#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
905#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
906pub struct SubscriptionsList {
907    /// The persisted subscribed store ids (64-hex each).
908    pub subscriptions: Vec<HexId>,
909    /// The subscription count (`subscriptions.len()`).
910    pub count: u64,
911}
912
913// ===========================================================================
914// control.peers.connect / control.peers.disconnect
915// (CONTROL — loopback / in-process only)
916// ===========================================================================
917
918/// Params for [`control.peers.connect`](crate::method::Method::ControlPeersConnect)
919/// and [`control.peers.disconnect`](crate::method::Method::ControlPeersDisconnect).
920#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
921#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
922pub struct PeerConnectParams {
923    /// The peer to dial/drop — a dialable address, or a known peer's `peer_id`
924    /// (64-hex) to resolve an already-connected peer.
925    pub peer: String,
926}
927
928/// Result for
929/// [`control.peers.connect`](crate::method::Method::ControlPeersConnect).
930#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
931#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
932pub struct PeerConnectResult {
933    /// Always `true` on success — the peer is a counted, connected pool member.
934    pub connected: bool,
935    /// The connected peer's stable `peer_id` (64-hex).
936    pub peer_id: HexId,
937}
938
939/// Result for
940/// [`control.peers.disconnect`](crate::method::Method::ControlPeersDisconnect).
941///
942/// Idempotent: disconnecting a peer that is not connected succeeds as a no-op.
943#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
944#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
945pub struct PeerDisconnectResult {
946    /// Always `true` — the peer is not in the pool after this call.
947    pub disconnected: bool,
948    /// The dropped peer's `peer_id` (trimmed + lower-cased, 64-hex).
949    pub peer_id: HexId,
950}
951
952// ===========================================================================
953// dig.health / dig.methods / rpc.discover  (discovery)
954// ===========================================================================
955
956/// Result for [`dig.health`](crate::method::Method::Health) — liveness + a
957/// capability summary.
958#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
959#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
960pub struct Health {
961    /// Liveness — `"ok"` when the node can serve.
962    pub status: String,
963    /// The node's software version.
964    #[serde(skip_serializing_if = "Option::is_none", default)]
965    pub version: Option<String>,
966    /// The DIG network id the node serves.
967    #[serde(skip_serializing_if = "Option::is_none", default)]
968    pub network_id: Option<String>,
969    /// The method names this node implements (its profile).
970    #[serde(default)]
971    pub methods: Vec<String>,
972}
973
974/// Result for [`dig.methods`](crate::method::Method::Methods) — the method names
975/// this node implements (agent self-describe).
976#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
977#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
978pub struct Methods {
979    /// The implemented method names.
980    pub methods: Vec<String>,
981}
982
983#[cfg(test)]
984mod tests {
985    use super::*;
986
987    /// **Proves:** `ContentChunk` round-trips a node-profile window (no
988    /// network-profile fields) without inventing keys.
989    /// **Catches:** a missing `skip_serializing_if` that would leak `null`
990    /// network-profile fields onto the node profile.
991    #[test]
992    fn content_chunk_node_profile_is_lean() {
993        let c = ContentChunk {
994            ciphertext: "AAA=".into(),
995            root: "ab".repeat(32),
996            complete: false,
997            next_offset: Some(3_145_728),
998            inclusion_proof: Some("cHJvb2Y=".into()),
999            chunk_lens: Some(vec![10, 20]),
1000            source: Some("local".into()),
1001            total_length: None,
1002            length: None,
1003            offset: None,
1004            program_hash: None,
1005        };
1006        let v = serde_json::to_value(&c).unwrap();
1007        assert_eq!(v["source"], "local");
1008        assert!(
1009            v.get("total_length").is_none(),
1010            "node profile must omit total_length"
1011        );
1012        assert!(v.get("program_hash").is_none());
1013        assert_eq!(serde_json::from_value::<ContentChunk>(v).unwrap(), c);
1014    }
1015
1016    /// **Proves:** the network-profile fields serialize when present.
1017    #[test]
1018    fn content_chunk_network_profile_carries_extras() {
1019        let c = ContentChunk {
1020            ciphertext: "AAA=".into(),
1021            root: "cd".repeat(32),
1022            complete: true,
1023            next_offset: None,
1024            inclusion_proof: None,
1025            chunk_lens: None,
1026            source: None,
1027            total_length: Some(100),
1028            length: Some(100),
1029            offset: Some(0),
1030            program_hash: Some("ef".repeat(32)),
1031        };
1032        let v = serde_json::to_value(&c).unwrap();
1033        assert_eq!(v["total_length"], 100);
1034        assert_eq!(v["length"], 100);
1035        assert!(v.get("source").is_none());
1036    }
1037
1038    /// **Proves:** the untagged `Inventory` picks `ForStore` vs `AllStores` by
1039    /// shape.
1040    /// **Catches:** a lost `#[serde(untagged)]` that would tag the variant.
1041    #[test]
1042    fn inventory_untagged_by_shape() {
1043        let for_store = Inventory::ForStore {
1044            store_id: "ab".repeat(32),
1045            roots: vec!["cd".repeat(32)],
1046        };
1047        let s = serde_json::to_string(&for_store).unwrap();
1048        assert!(s.contains("\"roots\""));
1049        assert!(!s.contains("ForStore"));
1050        assert_eq!(serde_json::from_str::<Inventory>(&s).unwrap(), for_store);
1051
1052        let all = Inventory::AllStores {
1053            stores: vec!["ef".repeat(32)],
1054        };
1055        let s = serde_json::to_string(&all).unwrap();
1056        assert!(s.contains("\"stores\""));
1057        assert_eq!(serde_json::from_str::<Inventory>(&s).unwrap(), all);
1058    }
1059
1060    /// **Proves:** `RedirectInfo` serializes the full redirect payload the
1061    /// `-32008` envelope carries.
1062    #[test]
1063    fn redirect_info_shape() {
1064        let r = RedirectInfo {
1065            content: ContentRef {
1066                store_id: "ab".repeat(32),
1067                root: Some("cd".repeat(32)),
1068                retrieval_key: Some("ef".repeat(32)),
1069            },
1070            providers: vec![Provider {
1071                peer_id: "12".repeat(32),
1072                addresses: vec![PeerAddress {
1073                    host: "::1".into(),
1074                    port: 9444,
1075                    kind: "direct".into(),
1076                }],
1077            }],
1078            redirect_depth: 1,
1079            max_redirects: 4,
1080        };
1081        let v = serde_json::to_value(&r).unwrap();
1082        assert_eq!(v["redirect_depth"], 1);
1083        assert_eq!(v["max_redirects"], 4);
1084        assert_eq!(v["providers"][0]["addresses"][0]["host"], "::1");
1085        assert_eq!(serde_json::from_value::<RedirectInfo>(v).unwrap(), r);
1086    }
1087
1088    /// **Proves:** `cache.stats` models the live dig-node result field-for-field
1089    /// (the nested `content_cache{hits,misses}` object included).
1090    /// **Catches:** a drift from the node's `cache.stats` wire shape (#1075).
1091    #[test]
1092    fn cache_stats_wire_shape() {
1093        let s = CacheStats {
1094            cap_bytes: 1 << 30,
1095            used_bytes: 2048,
1096            entry_count: 3,
1097            total_bytes: 2048,
1098            evicted_count: 1,
1099            evicted_bytes: 512,
1100            content_cache: ContentCacheCounters { hits: 7, misses: 2 },
1101        };
1102        let v = serde_json::to_value(s).unwrap();
1103        assert_eq!(v["cap_bytes"], 1 << 30);
1104        assert_eq!(v["entry_count"], 3);
1105        assert_eq!(v["content_cache"]["hits"], 7);
1106        assert_eq!(v["content_cache"]["misses"], 2);
1107        assert_eq!(serde_json::from_value::<CacheStats>(v).unwrap(), s);
1108    }
1109
1110    /// **Proves:** the subscription-management results carry the exact
1111    /// `{subscribed, added|removed, store_id}` / `{subscriptions, count}` shapes
1112    /// the live node returns.
1113    #[test]
1114    fn subscription_result_shapes() {
1115        let sub = SubscribeResult {
1116            subscribed: true,
1117            added: true,
1118            store_id: "ab".repeat(32),
1119        };
1120        let v = serde_json::to_value(&sub).unwrap();
1121        assert_eq!(v["subscribed"], true);
1122        assert_eq!(v["added"], true);
1123        assert_eq!(serde_json::from_value::<SubscribeResult>(v).unwrap(), sub);
1124
1125        let unsub = UnsubscribeResult {
1126            subscribed: false,
1127            removed: true,
1128            store_id: "cd".repeat(32),
1129        };
1130        let v = serde_json::to_value(&unsub).unwrap();
1131        assert_eq!(v["subscribed"], false);
1132        assert_eq!(v["removed"], true);
1133        assert_eq!(
1134            serde_json::from_value::<UnsubscribeResult>(v).unwrap(),
1135            unsub
1136        );
1137
1138        let list = SubscriptionsList {
1139            subscriptions: vec!["ef".repeat(32)],
1140            count: 1,
1141        };
1142        let v = serde_json::to_value(&list).unwrap();
1143        assert_eq!(v["count"], 1);
1144        assert_eq!(
1145            serde_json::from_value::<SubscriptionsList>(v).unwrap(),
1146            list
1147        );
1148    }
1149
1150    /// **Proves:** `ModuleInfo` carries `chunk_lens` covering every chunk, and
1151    /// round-trips with unknown future fields.
1152    /// **Catches:** a missing `chunk_lens` field that would leave a puller unable
1153    /// to map a fetched byte range to its covering chunk hash.
1154    /// **Invariants enforced by docs:** `chunk_lens` must have the same length as
1155    /// `chunk_hashes` and must sum to `total_size`.
1156    #[test]
1157    fn module_info_chunk_lens_shape() {
1158        let info = ModuleInfo {
1159            total_size: 1024,
1160            module_hash: "ab".repeat(32),
1161            chunk_hashes: vec!["cd".repeat(32), "ef".repeat(32)],
1162            chunk_lens: vec![512, 512],
1163        };
1164        let v = serde_json::to_value(&info).unwrap();
1165        assert_eq!(v["total_size"], 1024);
1166        assert_eq!(v["chunk_hashes"].as_array().unwrap().len(), 2);
1167        assert_eq!(v["chunk_lens"].as_array().unwrap().len(), 2);
1168        assert_eq!(v["chunk_lens"][0], 512);
1169        assert_eq!(v["chunk_lens"][1], 512);
1170        assert_eq!(serde_json::from_value::<ModuleInfo>(v).unwrap(), info);
1171    }
1172
1173    /// **Proves:** `ModuleInfo` deserialization REJECTS missing `chunk_lens` field.
1174    /// This is a REQUIRED field (not optional) — omitting it from the wire is a
1175    /// protocol violation and must fail-closed.
1176    #[test]
1177    fn module_info_rejects_missing_chunk_lens() {
1178        let json_str = r#"{"total_size": 2048, "module_hash": "1122334455667788990011223344556677889900112233445566778899001122", "chunk_hashes": []}"#;
1179        let result: Result<ModuleInfo, _> = serde_json::from_str(json_str);
1180        assert!(
1181            result.is_err(),
1182            "ModuleInfo must reject JSON missing the required chunk_lens field"
1183        );
1184        let err = result.unwrap_err();
1185        assert!(
1186            err.to_string().contains("chunk_lens"),
1187            "error message should mention chunk_lens: {}",
1188            err
1189        );
1190    }
1191
1192    /// **Proves:** the peer connect/disconnect params + results round-trip and
1193    /// match the node's `{connected|disconnected, peer_id}` shapes.
1194    #[test]
1195    fn peer_connect_disconnect_shapes() {
1196        let p = PeerConnectParams {
1197            peer: "12".repeat(32),
1198        };
1199        let v = serde_json::to_value(&p).unwrap();
1200        assert_eq!(serde_json::from_value::<PeerConnectParams>(v).unwrap(), p);
1201
1202        let c = PeerConnectResult {
1203            connected: true,
1204            peer_id: "12".repeat(32),
1205        };
1206        let v = serde_json::to_value(&c).unwrap();
1207        assert_eq!(v["connected"], true);
1208        assert_eq!(serde_json::from_value::<PeerConnectResult>(v).unwrap(), c);
1209
1210        let d = PeerDisconnectResult {
1211            disconnected: true,
1212            peer_id: "34".repeat(32),
1213        };
1214        let v = serde_json::to_value(&d).unwrap();
1215        assert_eq!(v["disconnected"], true);
1216        assert_eq!(
1217            serde_json::from_value::<PeerDisconnectResult>(v).unwrap(),
1218            d
1219        );
1220    }
1221
1222    /// **Proves:** `cache.getConfig` uses the canonical `cache_dir` field name.
1223    /// **Catches:** a regression to the shell's historical `dir` name.
1224    #[test]
1225    fn cache_config_field_name_is_cache_dir() {
1226        let c = CacheConfig {
1227            cap_bytes: 1 << 30,
1228            used_bytes: 0,
1229            cache_dir: "/var/cache/dig".into(),
1230            shared: true,
1231        };
1232        let v = serde_json::to_value(&c).unwrap();
1233        assert!(v.get("cache_dir").is_some());
1234        assert!(v.get("dir").is_none(), "must not use the legacy `dir` name");
1235    }
1236}