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///
501/// # Construction
502///
503/// Like [`RangeFrame`], this type is `#[non_exhaustive]`: build it with
504/// [`resource`](Self::resource) plus the `with_*` setters rather than a struct
505/// literal, so a future additive field is a PATCH for every consumer instead of a
506/// semver cascade.
507///
508/// # Cross-repo contract
509///
510/// [`skip_layout`](Self::skip_layout) is byte-identical to
511/// `dig_nat::mux::RangeRequest::skip_layout`, pinned in
512/// `tests/nat_wire_mirror.rs`. The two enclosing types deliberately differ in every
513/// other respect — dig-nat's `RangeRequest` is a length-prefixed stream preamble,
514/// this is a JSON-RPC params object with a `redirect_depth` dig-nat has no notion
515/// of — so the byte-identical contract here is the FIELD, not the object.
516#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
517#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
518#[non_exhaustive]
519pub struct FetchRangeParams {
520 /// The store launcher id (64-hex, required).
521 pub store_id: HexId,
522 /// The generation root (64-hex, required for a resource fetch).
523 pub root: HexId,
524 /// `SHA-256(urn)` (64-hex, required for a resource fetch).
525 pub retrieval_key: HexId,
526 /// The range start (default 0).
527 #[serde(skip_serializing_if = "Option::is_none", default)]
528 pub offset: Option<u64>,
529 /// The range length in bytes (> 0; clamped to the window cap).
530 pub length: u64,
531 /// Whole-capsule mode (default false). Capsule range fetch is not yet
532 /// served; a `true` here yields `-32004`.
533 #[serde(skip_serializing_if = "Option::is_none", default)]
534 pub capsule: Option<bool>,
535 /// The redirect budget already consumed (echoed from a `-32008` redirect).
536 #[serde(skip_serializing_if = "Option::is_none", default)]
537 pub redirect_depth: Option<u64>,
538 /// Suppress the resource-scaling layout metadata (`chunk_lens` +
539 /// `inclusion_proof`) on this stream's frames, because the client already holds
540 /// the commitment for this `root`.
541 ///
542 /// A client that has already read the layout once — a resumed download, a second
543 /// range of the same resource, a parallel fetch from another holder — does not
544 /// need it again, and re-sending it costs a whole paged prologue PER STREAM: a
545 /// 1,048,576-chunk layout is roughly 7.3 MB, which a 64-way parallel plan would
546 /// otherwise pay 64 times over. Suppressing it is the difference between a
547 /// bounded and an unbounded cost on the read path.
548 ///
549 /// Absent or `false` preserves the pre-0.6.0 behaviour, so an older holder that
550 /// ignores this field is never broken by it — it simply sends metadata the client
551 /// discards. Read the rule through
552 /// [`suppresses_layout`](Self::suppresses_layout) rather than re-deriving it.
553 ///
554 /// The fixed-size identity fields ([`root`](RangeFrame::root),
555 /// [`total_length`](RangeFrame::total_length),
556 /// [`chunk_count`](RangeFrame::chunk_count),
557 /// [`chunk_index`](RangeFrame::chunk_index)) are NOT suppressed: they are what
558 /// detects a wrong-generation holder on arrival, and a client that stopped
559 /// receiving them would lose that check on exactly the streams it fetches most.
560 #[serde(default, skip_serializing_if = "Option::is_none")]
561 pub skip_layout: Option<bool>,
562}
563
564impl FetchRangeParams {
565 /// A range request for one content resource: `length` bytes of
566 /// `retrieval_key`'s ciphertext at the generation `root`.
567 pub fn resource(
568 store_id: impl Into<HexId>,
569 root: impl Into<HexId>,
570 retrieval_key: impl Into<HexId>,
571 length: u64,
572 ) -> Self {
573 FetchRangeParams {
574 store_id: store_id.into(),
575 root: root.into(),
576 retrieval_key: retrieval_key.into(),
577 offset: None,
578 length,
579 capsule: None,
580 redirect_depth: None,
581 skip_layout: None,
582 }
583 }
584
585 /// Start the range at `offset` rather than at 0.
586 pub fn with_offset(mut self, offset: u64) -> Self {
587 self.offset = Some(offset);
588 self
589 }
590
591 /// Request whole-capsule mode. Capsule range fetch is not yet served — a `true`
592 /// here yields
593 /// [`ResourceUnavailable`](crate::error::ErrorCode::ResourceUnavailable).
594 pub fn with_capsule(mut self, capsule: bool) -> Self {
595 self.capsule = Some(capsule);
596 self
597 }
598
599 /// Echo the redirect budget already consumed, from a `-32008` redirect.
600 pub fn with_redirect_depth(mut self, redirect_depth: u64) -> Self {
601 self.redirect_depth = Some(redirect_depth);
602 self
603 }
604
605 /// Ask the holder to omit the resource-scaling layout metadata, because this
606 /// client already holds the commitment for this `root`. See
607 /// [`skip_layout`](Self::skip_layout).
608 pub fn with_skip_layout(mut self, skip_layout: bool) -> Self {
609 self.skip_layout = Some(skip_layout);
610 self
611 }
612
613 /// Whether this request suppresses the resource-scaling layout metadata.
614 ///
615 /// The single home for the "absent or `false` means SEND the layout" rule. A
616 /// serve path that reached for `skip_layout.is_some()` instead would suppress the
617 /// layout for a client that had explicitly asked for it — unrecoverable for that
618 /// client, since the layout is a decrypt input it cannot obtain any other way on
619 /// that stream.
620 pub fn suppresses_layout(&self) -> bool {
621 self.skip_layout.unwrap_or(false)
622 }
623}
624
625/// One range frame of a resource: a byte window, plus the per-resource
626/// verification metadata that makes the window independently checkable.
627///
628/// The metadata splits in two by whether it scales with the resource, and the
629/// split decides which frames carry it:
630///
631/// - **The identity set — [`root`](Self::root),
632/// [`total_length`](Self::total_length), [`chunk_count`](Self::chunk_count),
633/// plus [`chunk_index`](Self::chunk_index) when the window begins on a chunk
634/// boundary — rides EVERY frame.** It is fixed-size, so carrying it everywhere
635/// costs a bounded number of bytes, and it is what lets a client fetching in
636/// parallel from many holders reject a wrong-generation or wrong-layout source
637/// the moment a frame arrives, rather than after paying for the whole resource
638/// in bandwidth.
639/// - **The resource-scaling set — [`chunk_lens`](Self::chunk_lens) and
640/// [`inclusion_proof`](Self::inclusion_proof) — rides the first frame, or a
641/// paged prologue, once per range stream.** Repeating it per frame would cost
642/// proportionally to the resource against a frame budget with no slack; a layout
643/// too large to state on one frame is paged instead, each page stamped with the
644/// [`chunk_lens_offset`](Self::chunk_lens_offset) it begins at.
645///
646/// The window is exactly the span the caller requested — never widened.
647///
648/// # Construction
649///
650/// This type is [`#[non_exhaustive]`](https://doc.rust-lang.org/reference/attributes/type_system.html):
651/// build it with [`data`](Self::data) and the `with_*` setters rather than a struct
652/// literal. That is deliberate — the wire form grows as the protocol does, and
653/// routing construction through named setters means a future additive field is a
654/// PATCH release for every consumer instead of another semver cascade. It also
655/// makes the two frame shapes different call chains rather than one call with a
656/// pile of `None`s, so a continuation frame cannot accidentally claim a layout it
657/// is not stating.
658///
659/// # Cross-repo contract
660///
661/// The wire form is **byte-identical** to `dig_nat::mux::RangeFrame`, the
662/// streaming implementation of this frame (`SYSTEM.md` → "Canonical DIG-node RPC
663/// interface"). Field names, encodings, and the population rule above are pinned
664/// against dig-nat's actual output in `tests/nat_wire_mirror.rs`; a change to any
665/// of them lands in both crates in the same unit of work or not at all.
666#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
667#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
668#[non_exhaustive]
669pub struct RangeFrame {
670 /// The window start offset (echoed).
671 pub offset: u64,
672 /// This window's byte length.
673 pub length: u64,
674 /// This window's ciphertext, base64.
675 pub bytes: String,
676 /// Whether this frame ends the resource.
677 pub complete: bool,
678 /// The full resource ciphertext length. Part of the fixed-size **identity
679 /// set**, so it rides EVERY frame.
680 #[serde(skip_serializing_if = "Option::is_none", default)]
681 pub total_length: Option<u64>,
682 /// Per-chunk ciphertext lengths of the full resource, in order — the layout a
683 /// reader needs before it can decrypt (per-chunk AEAD needs the WHOLE array,
684 /// and a reader rejects an array whose sum differs from
685 /// [`total_length`](Self::total_length)).
686 ///
687 /// Resource-scaling, so it rides the first frame or a **paged prologue**, once
688 /// per range stream — never repeated on continuation frames. When paged, this
689 /// is one page of the array and
690 /// [`chunk_lens_offset`](Self::chunk_lens_offset) states the entry it begins
691 /// at.
692 #[serde(skip_serializing_if = "Option::is_none", default)]
693 pub chunk_lens: Option<Vec<u64>>,
694 /// This frame's first chunk index — the pre-existing alias of
695 /// [`first_chunk_index`](Self::first_chunk_index), carrying the same value, and
696 /// the name dig-nat emits.
697 ///
698 /// Part of the **identity set**: it rides every frame whose window begins on a
699 /// chunk boundary, and is OMITTED (rather than guessed) on a mid-chunk window.
700 /// Being fixed-size, it is settable on its own — see
701 /// [`with_chunk_index`](Self::with_chunk_index) — precisely so a continuation
702 /// frame can state it without dragging along the once-per-stream
703 /// [`inclusion_proof`](Self::inclusion_proof).
704 #[serde(skip_serializing_if = "Option::is_none", default)]
705 pub chunk_index: Option<u64>,
706 /// Whole-resource merkle proof against [`root`](Self::root), base64, relayed
707 /// verbatim.
708 ///
709 /// Resource-scaling, so it rides the first frame or the paged prologue, once
710 /// per range stream. A holder MUST NOT repeat it per frame: it is bounded at
711 /// 4,096 base64 bytes, which against the frame budget leaves no slack for the
712 /// payload the frame exists to carry.
713 #[serde(skip_serializing_if = "Option::is_none", default)]
714 pub inclusion_proof: Option<String>,
715 /// The chain-anchored root (64-hex) this frame's resource verified against.
716 /// Part of the fixed-size **identity set**, so it rides EVERY frame.
717 ///
718 /// NOT A TRUST ANCHOR BY ITSELF. The client resolves the resource's root from
719 /// the URN (chain-anchored) and PINS it before fetching; a peer-declared value
720 /// never replaces that pinned root. What this field provides is a
721 /// generation-CONSISTENCY check: a frame declaring a root other than the pinned
722 /// one is REJECTED and attributed to the offending peer (NC-9 fail-closed). So
723 /// a declared root can only ever cause rejection — it can never move the pinned
724 /// root, and never makes an unverified frame acceptable.
725 #[serde(skip_serializing_if = "Option::is_none", default)]
726 pub root: Option<HexId>,
727 /// **RESERVED — not currently derivable; a server MUST NOT emit it.**
728 ///
729 /// Per-chunk merkle inclusion proofs for the chunks a frame covers. No such
730 /// proof exists in the current store format: the generation root's merkle
731 /// leaves are per-RESOURCE (a leaf is the SHA-256 of a resource's WHOLE
732 /// ciphertext), so a single chunk has no leaf to prove. A client MUST NOT
733 /// require this field, and per-range verification instead uses the
734 /// whole-resource [`inclusion_proof`](Self::inclusion_proof) together with the
735 /// per-frame [`root`](Self::root)/[`chunk_lens`](Self::chunk_lens) metadata.
736 ///
737 /// Making it derivable requires a per-resource chunk-level commitment in the
738 /// store format first (tracked as `dig_ecosystem#1601`). The field is kept in
739 /// the wire type, unused, so populating it later is additive (§5.1); each entry
740 /// would be an opaque base64 proof blob, since this pure level-00 wire type
741 /// MUST NOT depend on the merkle primitive.
742 #[serde(skip_serializing_if = "Option::is_none", default)]
743 pub range_proof: Option<Vec<String>>,
744 /// The chunk index of the first chunk in this frame (0-based, into the
745 /// resource's chunk sequence described by [`chunk_lens`](Self::chunk_lens)).
746 ///
747 /// Present only when the frame's window begins EXACTLY on a chunk boundary; a
748 /// mid-chunk window omits it rather than assert an index the caller's own
749 /// alignment check would contradict. The served window is exactly the requested
750 /// span — a server MUST NOT widen a range to a chunk boundary — so a frame is
751 /// chunk-aligned only when the caller asked for an aligned span.
752 #[serde(skip_serializing_if = "Option::is_none", default)]
753 pub first_chunk_index: Option<u64>,
754 /// The resource's TOTAL chunk count — how many entries the fully reassembled
755 /// [`chunk_lens`](Self::chunk_lens) array has.
756 ///
757 /// Fixed-size, so it belongs to the **identity set** and rides EVERY frame.
758 /// Together with [`root`](Self::root) and
759 /// [`total_length`](Self::total_length) it is what lets a reader detect a
760 /// wrong-generation or wrong-layout holder on the first frame it receives. It is
761 /// also how a reader sizes the array it is paging in, and therefore how it knows
762 /// a **paged prologue** is complete: the prologue ends when the reader holds
763 /// `chunk_count` entries, which no single page can tell it.
764 #[serde(default, skip_serializing_if = "Option::is_none")]
765 pub chunk_count: Option<u64>,
766 /// The index into the resource's [`chunk_lens`](Self::chunk_lens) array at which
767 /// THIS frame's page begins — how a **paged prologue** is located and
768 /// reassembled.
769 ///
770 /// A resource whose layout exceeds the per-frame entry cap cannot state it on
771 /// one frame, so the sender pages it: successive frames each carry up to that
772 /// many entries, stamped with the offset they start at. A reader places each page
773 /// at its offset and holds the whole array once it has
774 /// [`chunk_count`](Self::chunk_count) entries.
775 ///
776 /// Absent means "this frame's `chunk_lens`, if any, begins at entry 0" — the
777 /// single-frame layout, which is the shape every pre-0.6.0 producer emits. So an
778 /// older frame decodes with exactly its original meaning (§5.1).
779 #[serde(default, skip_serializing_if = "Option::is_none")]
780 pub chunk_lens_offset: Option<u64>,
781}
782
783impl RangeFrame {
784 /// A **data frame**: `length` bytes of base64 ciphertext at `offset`, carrying
785 /// no metadata — the bare shape every continuation frame starts from.
786 ///
787 /// `length` is stated rather than derived because [`bytes`](Self::bytes) is
788 /// already base64 on this type, and recovering the raw window length from it
789 /// would need a base64 codec this pure level-00 wire crate deliberately does not
790 /// depend on. A serve path passes the length it served.
791 pub fn data(offset: u64, length: u64, bytes: impl Into<String>) -> Self {
792 RangeFrame {
793 offset,
794 length,
795 bytes: bytes.into(),
796 complete: false,
797 total_length: None,
798 chunk_lens: None,
799 chunk_index: None,
800 inclusion_proof: None,
801 root: None,
802 range_proof: None,
803 first_chunk_index: None,
804 chunk_count: None,
805 chunk_lens_offset: None,
806 }
807 }
808
809 /// Mark this as the final frame of the range.
810 pub fn with_complete(mut self, complete: bool) -> Self {
811 self.complete = complete;
812 self
813 }
814
815 /// The fixed-size **identity set** every frame of a range carries: the
816 /// generation `root` (64-hex) the range is served from, the resource's
817 /// ciphertext `total_length`, and its `chunk_count`.
818 ///
819 /// These three are what let a reader reject a wrong-generation or wrong-layout
820 /// holder the moment a frame arrives — which the resource-scaling metadata never
821 /// could, since it arrives once. Call this on every frame.
822 pub fn with_identity(
823 mut self,
824 root: impl Into<HexId>,
825 total_length: u64,
826 chunk_count: u64,
827 ) -> Self {
828 self.root = Some(root.into());
829 self.total_length = Some(total_length);
830 self.chunk_count = Some(chunk_count);
831 self
832 }
833
834 /// State [`chunk_index`](Self::chunk_index) — the chunk this frame's window
835 /// begins on — for a chunk-aligned window.
836 ///
837 /// Separate from [`with_inclusion_proof`](Self::with_inclusion_proof) on purpose:
838 /// the index is fixed-size identity metadata that rides every aligned frame,
839 /// while the proof is once-per-stream, so binding them together would force a
840 /// producer to either repeat a proof it MUST NOT repeat or bypass this API. Omit
841 /// the call entirely for a mid-chunk window.
842 pub fn with_chunk_index(mut self, chunk_index: u64) -> Self {
843 self.chunk_index = Some(chunk_index);
844 self
845 }
846
847 /// Additionally state [`first_chunk_index`](Self::first_chunk_index), this
848 /// crate's v0.4.0 alias of [`chunk_index`](Self::chunk_index).
849 ///
850 /// Both names carry the same value. dig-nat emits only `chunk_index`, so
851 /// [`with_chunk_index`](Self::with_chunk_index) alone is the interoperable
852 /// choice; a producer serving readers that expect the newer name states both.
853 pub fn with_first_chunk_index(mut self, first_chunk_index: u64) -> Self {
854 self.first_chunk_index = Some(first_chunk_index);
855 self
856 }
857
858 /// One page of the resource's `chunk_lens` array, beginning at entry
859 /// `chunk_lens_offset`.
860 ///
861 /// Call it once with offset `0` for a layout that fits a single frame, or once
862 /// per page of a **paged prologue**. A page is only ever useful as part of a
863 /// complete set: `chunk_lens` is a decrypt input, and a reader needs all
864 /// [`chunk_count`](Self::chunk_count) entries before it can decrypt anything.
865 pub fn with_chunk_lens_page(mut self, chunk_lens_offset: u64, chunk_lens: Vec<u64>) -> Self {
866 self.chunk_lens_offset = Some(chunk_lens_offset);
867 self.chunk_lens = Some(chunk_lens);
868 self
869 }
870
871 /// The whole-resource merkle inclusion proof against
872 /// [`root`](Self::root) (base64, relayed verbatim).
873 ///
874 /// Resource-scaling: state it on the first frame or the prologue, once per range
875 /// stream, never per frame.
876 pub fn with_inclusion_proof(mut self, inclusion_proof: impl Into<String>) -> Self {
877 self.inclusion_proof = Some(inclusion_proof.into());
878 self
879 }
880
881 /// State the **RESERVED** [`range_proof`](Self::range_proof) field.
882 ///
883 /// A server MUST NOT emit it — no per-chunk proof is derivable from the current
884 /// store format (see the field's own documentation). The setter exists so the
885 /// shape stays constructible for the conformance vectors that pin it, and so no
886 /// field of this `#[non_exhaustive]` type is unreachable; it is not a serve-path
887 /// call.
888 pub fn with_range_proof(mut self, range_proof: Vec<String>) -> Self {
889 self.range_proof = Some(range_proof);
890 self
891 }
892}
893
894// ===========================================================================
895// dig.getModuleInfo / dig.fetchModuleRange (PEER — whole-module pull, #1576)
896// ===========================================================================
897
898/// Params for [`dig.getModuleInfo`](crate::method::Method::GetModuleInfo) — the
899/// handshake a peer reads before range-pulling a whole `.dig` module for
900/// `(store, root)`.
901#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
902#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
903pub struct GetModuleInfoParams {
904 /// The store launcher id (64-hex, required).
905 pub store_id: HexId,
906 /// The generation root whose `.dig` module is being pulled (64-hex, required).
907 pub root: HexId,
908}
909
910/// Result for [`dig.getModuleInfo`](crate::method::Method::GetModuleInfo) — the
911/// transfer descriptor of a whole `.dig` module.
912///
913/// The whole-module blob is content-addressed + immutable (the `.dig` container
914/// is byte-identical by construction). [`module_hash`](Self::module_hash) is the
915/// content id of the assembled blob; a puller verifies each pulled range against
916/// [`chunk_hashes`](Self::chunk_hashes) (per-peer attribution on a multi-source
917/// pull) and the fully-assembled blob against `module_hash`, THEN verifies the
918/// assembled module against its chain-anchored root before admitting + resharing
919/// (NC-9 verified-content-not-safe-content).
920#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
921#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
922pub struct ModuleInfo {
923 /// The total byte length of the whole `.dig` module blob.
924 pub total_size: u64,
925 /// The content id of the fully-assembled module blob (64-hex `SHA-256` of the
926 /// module bytes). The puller checks the assembled blob against this.
927 pub module_hash: HexId,
928 /// Per-chunk content hashes (64-hex each) in ascending chunk order, covering
929 /// the blob in [`total_size`](Self::total_size)-spanning fixed-size chunks
930 /// (the trailing chunk may be short). A puller checks each pulled
931 /// [`RangeFrame`] against the covering entries for per-source attribution on a
932 /// multi-source pull (a tampered range fails closed before assembly).
933 pub chunk_hashes: Vec<HexId>,
934 /// Per-chunk byte lengths (in the same order as [`chunk_hashes`](Self::chunk_hashes)).
935 /// MUST have the same length as `chunk_hashes` and MUST sum to `total_size`.
936 /// A puller uses these to map a fetched byte range to the covering chunk hash(es).
937 pub chunk_lens: Vec<u64>,
938}
939
940/// Params for [`dig.fetchModuleRange`](crate::method::Method::FetchModuleRange) —
941/// a single range frame of the whole `.dig` module blob for `(store, root)`.
942///
943/// The response reuses [`RangeFrame`]: [`bytes`](RangeFrame::bytes) carries the
944/// window of the module blob (base64), [`total_length`](RangeFrame::total_length)
945/// echoes the whole-module size on the first frame, and
946/// [`complete`](RangeFrame::complete) ends the stream.
947#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
948#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
949pub struct FetchModuleRangeParams {
950 /// The store launcher id (64-hex, required).
951 pub store_id: HexId,
952 /// The generation root whose `.dig` module is being pulled (64-hex, required).
953 pub root: HexId,
954 /// The range start into the module blob (default 0).
955 #[serde(skip_serializing_if = "Option::is_none", default)]
956 pub offset: Option<u64>,
957 /// The range length in bytes (> 0; clamped to the window cap).
958 pub length: u64,
959}
960
961// ===========================================================================
962// dig.stage (CONTROL — loopback / in-process only)
963// ===========================================================================
964
965/// Params for [`dig.stage`](crate::method::Method::Stage) — compile a local
966/// folder into a capsule `.dig` module in-process.
967#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
968#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
969pub struct StageParams {
970 /// The absolute path to the folder to compile.
971 pub dir: String,
972 /// The target store launcher id (64-hex). Absent ⇒ an ephemeral,
973 /// content-derived id (a preview).
974 #[serde(skip_serializing_if = "Option::is_none", default)]
975 pub store_id: Option<HexId>,
976 /// The store salt (64-hex). Present ⇒ a private store.
977 #[serde(skip_serializing_if = "Option::is_none", default)]
978 pub salt: Option<HexId>,
979 /// Optional DIGHub-style manifest metadata to embed.
980 #[serde(skip_serializing_if = "Option::is_none", default)]
981 pub metadata: Option<serde_json::Value>,
982}
983
984/// Result for [`dig.stage`](crate::method::Method::Stage) — the compiled capsule.
985#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
986#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
987pub struct StageResult {
988 /// The canonical capsule identity, `storeId:rootHash`.
989 pub capsule: String,
990 /// The store launcher id (64-hex).
991 pub store_id: HexId,
992 /// The compiled generation root (64-hex).
993 pub root: HexId,
994 /// The filesystem path to the compiled `.dig` module.
995 pub module_path: String,
996 /// The module size in bytes.
997 pub size: u64,
998 /// The `chia://storeId:rootHash/` content address.
999 #[serde(skip_serializing_if = "Option::is_none", default)]
1000 pub content_address: Option<String>,
1001 /// The relative paths compiled into the capsule.
1002 #[serde(default)]
1003 pub files: Vec<String>,
1004 /// Whether this is an ephemeral preview (not advancing a real store).
1005 #[serde(skip_serializing_if = "Option::is_none", default)]
1006 pub ephemeral: Option<bool>,
1007}
1008
1009// ===========================================================================
1010// cache.* (CONTROL — loopback / in-process only)
1011// ===========================================================================
1012
1013/// Result for [`cache.getConfig`](crate::method::Method::CacheGetConfig).
1014///
1015/// The canonical field name for the cache path is `cache_dir` everywhere (the
1016/// shell's historical `dir` is unified onto this name).
1017#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1018#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1019pub struct CacheConfig {
1020 /// The on-disk cache size cap in bytes (floored at 64 MiB).
1021 pub cap_bytes: u64,
1022 /// The bytes currently used.
1023 pub used_bytes: u64,
1024 /// The effective resolved cache directory.
1025 pub cache_dir: String,
1026 /// Whether that directory is the canonical shared location (vs a
1027 /// process-private fallback).
1028 pub shared: bool,
1029}
1030
1031/// Params for [`cache.setCapBytes`](crate::method::Method::CacheSetCapBytes).
1032#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1033#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1034pub struct SetCapBytesParams {
1035 /// The requested cap in bytes (floored at 64 MiB by the node).
1036 pub cap_bytes: u64,
1037}
1038
1039/// Result for [`cache.setCapBytes`](crate::method::Method::CacheSetCapBytes).
1040#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1041#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1042pub struct SetCapBytesResult {
1043 /// The effective cap after flooring.
1044 pub cap_bytes: u64,
1045}
1046
1047/// One durable cached-module entry.
1048#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1049#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1050pub struct CachedCapsule {
1051 /// The canonical capsule identity, `storeId:rootHash`.
1052 pub capsule: String,
1053 /// The store launcher id (64-hex).
1054 pub store_id: HexId,
1055 /// The generation root (64-hex).
1056 pub root: HexId,
1057 /// The module size in bytes.
1058 pub size_bytes: u64,
1059 /// When the module was last used (unix ms).
1060 pub last_used_unix_ms: u64,
1061}
1062
1063/// Result for [`cache.listCached`](crate::method::Method::CacheListCached).
1064#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1065#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1066pub struct CachedList {
1067 /// The cached capsules.
1068 pub cached: Vec<CachedCapsule>,
1069}
1070
1071/// Params for a capsule-keyed cache op
1072/// ([`cache.removeCached`](crate::method::Method::CacheRemoveCached),
1073/// [`cache.fetchAndCache`](crate::method::Method::CacheFetchAndCache)).
1074#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1075#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1076pub struct CapsuleKey {
1077 /// The store launcher id (64-hex).
1078 pub store_id: HexId,
1079 /// The generation root (64-hex).
1080 pub root: HexId,
1081}
1082
1083/// Result for [`cache.removeCached`](crate::method::Method::CacheRemoveCached).
1084#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1085#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1086pub struct RemoveCachedResult {
1087 /// Whether an entry was removed.
1088 pub removed: bool,
1089}
1090
1091/// Result for [`cache.fetchAndCache`](crate::method::Method::CacheFetchAndCache).
1092///
1093/// A failed fetch is reported in-band (`status = "failed"` + `message`) so the
1094/// caller can show it without treating it as a transport error.
1095#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1096#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1097pub struct FetchAndCacheResult {
1098 /// `"cached"`, `"already_cached"`, or `"failed"`.
1099 pub status: String,
1100 /// The fetched module size in bytes (on success).
1101 #[serde(skip_serializing_if = "Option::is_none", default)]
1102 pub size_bytes: Option<u64>,
1103 /// The served generation root (64-hex, on success).
1104 #[serde(skip_serializing_if = "Option::is_none", default)]
1105 pub served_root: Option<HexId>,
1106 /// The failure message (on `status = "failed"`).
1107 #[serde(skip_serializing_if = "Option::is_none", default)]
1108 pub message: Option<String>,
1109}
1110
1111// ===========================================================================
1112// control.peerStatus (CONTROL — loopback / in-process only)
1113// ===========================================================================
1114
1115/// Result for [`control.peerStatus`](crate::method::Method::ControlPeerStatus) —
1116/// a snapshot of the node's L7 peer network. Always safe to call; reports
1117/// `running: false` on the FFI path.
1118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1119#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1120pub struct PeerStatusSnapshot {
1121 /// Whether a peer network is currently active.
1122 pub running: bool,
1123 /// This node's `peer_id` (64-hex), if a peer network is running.
1124 #[serde(skip_serializing_if = "Option::is_none", default)]
1125 pub peer_id: Option<HexId>,
1126 /// The DIG network id.
1127 pub network_id: String,
1128 /// The relay reservation posture.
1129 pub relay: RelayStatus,
1130 /// The number of currently connected peers.
1131 pub connected_peers: u64,
1132 /// The last peer-network error, if any.
1133 #[serde(skip_serializing_if = "Option::is_none", default)]
1134 pub last_error: Option<String>,
1135}
1136
1137// ===========================================================================
1138// cache.stats (CONTROL — loopback / in-process only)
1139// ===========================================================================
1140
1141/// The decoded-content cache hit/miss counters carried in
1142/// [`CacheStats`](CacheStats::content_cache).
1143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1144#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1145pub struct ContentCacheCounters {
1146 /// Session decoded-content cache hits.
1147 pub hits: u64,
1148 /// Session decoded-content cache misses.
1149 pub misses: u64,
1150}
1151
1152/// Result for [`cache.stats`](crate::method::Method::CacheStats) — cache
1153/// telemetry beside [`cache.getConfig`](crate::method::Method::CacheGetConfig):
1154/// the reserved cap + live usage, the cached-capsule count + total on-disk
1155/// bytes, and the session eviction + content-cache counters.
1156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1157#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1158pub struct CacheStats {
1159 /// The on-disk cache size cap in bytes.
1160 pub cap_bytes: u64,
1161 /// The bytes currently used on disk.
1162 pub used_bytes: u64,
1163 /// The number of durable cached capsules.
1164 pub entry_count: u64,
1165 /// The total on-disk bytes across the cached capsules.
1166 pub total_bytes: u64,
1167 /// Capsules evicted this session.
1168 pub evicted_count: u64,
1169 /// Bytes evicted this session.
1170 pub evicted_bytes: u64,
1171 /// The decoded-content cache hit/miss counters.
1172 pub content_cache: ContentCacheCounters,
1173}
1174
1175// ===========================================================================
1176// control.subscribe / control.unsubscribe / control.listSubscriptions
1177// (CONTROL — loopback / in-process only)
1178// ===========================================================================
1179
1180/// Params for [`control.subscribe`](crate::method::Method::ControlSubscribe) and
1181/// [`control.unsubscribe`](crate::method::Method::ControlUnsubscribe).
1182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1183#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1184pub struct SubscribeParams {
1185 /// The store launcher id to (un)subscribe (64-hex).
1186 pub store_id: HexId,
1187}
1188
1189/// Result for [`control.subscribe`](crate::method::Method::ControlSubscribe).
1190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1191#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1192pub struct SubscribeResult {
1193 /// Always `true` — the store is subscribed after this call.
1194 pub subscribed: bool,
1195 /// Whether this call ADDED the subscription (`false` ⇒ already subscribed).
1196 pub added: bool,
1197 /// The canonical persisted store id (trimmed + lower-cased, 64-hex).
1198 pub store_id: HexId,
1199}
1200
1201/// Result for [`control.unsubscribe`](crate::method::Method::ControlUnsubscribe).
1202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1203#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1204pub struct UnsubscribeResult {
1205 /// Always `false` — the store is not subscribed after this call.
1206 pub subscribed: bool,
1207 /// Whether this call REMOVED a subscription (`false` ⇒ was not subscribed).
1208 pub removed: bool,
1209 /// The canonical persisted store id (trimmed + lower-cased, 64-hex).
1210 pub store_id: HexId,
1211}
1212
1213/// Result for
1214/// [`control.listSubscriptions`](crate::method::Method::ControlListSubscriptions).
1215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1216#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1217pub struct SubscriptionsList {
1218 /// The persisted subscribed store ids (64-hex each).
1219 pub subscriptions: Vec<HexId>,
1220 /// The subscription count (`subscriptions.len()`).
1221 pub count: u64,
1222}
1223
1224// ===========================================================================
1225// control.peers.connect / control.peers.disconnect
1226// (CONTROL — loopback / in-process only)
1227// ===========================================================================
1228
1229/// Params for [`control.peers.connect`](crate::method::Method::ControlPeersConnect)
1230/// and [`control.peers.disconnect`](crate::method::Method::ControlPeersDisconnect).
1231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1232#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1233pub struct PeerConnectParams {
1234 /// The peer to dial/drop — a dialable address, or a known peer's `peer_id`
1235 /// (64-hex) to resolve an already-connected peer.
1236 pub peer: String,
1237}
1238
1239/// Result for
1240/// [`control.peers.connect`](crate::method::Method::ControlPeersConnect).
1241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1242#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1243pub struct PeerConnectResult {
1244 /// Always `true` on success — the peer is a counted, connected pool member.
1245 pub connected: bool,
1246 /// The connected peer's stable `peer_id` (64-hex).
1247 pub peer_id: HexId,
1248}
1249
1250/// Result for
1251/// [`control.peers.disconnect`](crate::method::Method::ControlPeersDisconnect).
1252///
1253/// Idempotent: disconnecting a peer that is not connected succeeds as a no-op.
1254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1255#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1256pub struct PeerDisconnectResult {
1257 /// Always `true` — the peer is not in the pool after this call.
1258 pub disconnected: bool,
1259 /// The dropped peer's `peer_id` (trimmed + lower-cased, 64-hex).
1260 pub peer_id: HexId,
1261}
1262
1263// ===========================================================================
1264// dig.health / dig.methods / rpc.discover (discovery)
1265// ===========================================================================
1266
1267/// Result for [`dig.health`](crate::method::Method::Health) — liveness + a
1268/// capability summary.
1269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1270#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1271pub struct Health {
1272 /// Liveness — `"ok"` when the node can serve.
1273 pub status: String,
1274 /// The node's software version.
1275 #[serde(skip_serializing_if = "Option::is_none", default)]
1276 pub version: Option<String>,
1277 /// The DIG network id the node serves.
1278 #[serde(skip_serializing_if = "Option::is_none", default)]
1279 pub network_id: Option<String>,
1280 /// The method names this node implements (its profile).
1281 #[serde(default)]
1282 pub methods: Vec<String>,
1283}
1284
1285/// Result for [`dig.methods`](crate::method::Method::Methods) — the method names
1286/// this node implements (agent self-describe).
1287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1288#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1289pub struct Methods {
1290 /// The implemented method names.
1291 pub methods: Vec<String>,
1292}
1293
1294#[cfg(test)]
1295mod tests {
1296 use super::*;
1297
1298 /// **Proves:** `ContentChunk` round-trips a node-profile window (no
1299 /// network-profile fields) without inventing keys.
1300 /// **Catches:** a missing `skip_serializing_if` that would leak `null`
1301 /// network-profile fields onto the node profile.
1302 #[test]
1303 fn content_chunk_node_profile_is_lean() {
1304 let c = ContentChunk {
1305 ciphertext: "AAA=".into(),
1306 root: "ab".repeat(32),
1307 complete: false,
1308 next_offset: Some(3_145_728),
1309 inclusion_proof: Some("cHJvb2Y=".into()),
1310 chunk_lens: Some(vec![10, 20]),
1311 source: Some("local".into()),
1312 total_length: None,
1313 length: None,
1314 offset: None,
1315 program_hash: None,
1316 };
1317 let v = serde_json::to_value(&c).unwrap();
1318 assert_eq!(v["source"], "local");
1319 assert!(
1320 v.get("total_length").is_none(),
1321 "node profile must omit total_length"
1322 );
1323 assert!(v.get("program_hash").is_none());
1324 assert_eq!(serde_json::from_value::<ContentChunk>(v).unwrap(), c);
1325 }
1326
1327 /// **Proves:** the network-profile fields serialize when present.
1328 #[test]
1329 fn content_chunk_network_profile_carries_extras() {
1330 let c = ContentChunk {
1331 ciphertext: "AAA=".into(),
1332 root: "cd".repeat(32),
1333 complete: true,
1334 next_offset: None,
1335 inclusion_proof: None,
1336 chunk_lens: None,
1337 source: None,
1338 total_length: Some(100),
1339 length: Some(100),
1340 offset: Some(0),
1341 program_hash: Some("ef".repeat(32)),
1342 };
1343 let v = serde_json::to_value(&c).unwrap();
1344 assert_eq!(v["total_length"], 100);
1345 assert_eq!(v["length"], 100);
1346 assert!(v.get("source").is_none());
1347 }
1348
1349 /// **Proves:** the untagged `Inventory` picks `ForStore` vs `AllStores` by
1350 /// shape.
1351 /// **Catches:** a lost `#[serde(untagged)]` that would tag the variant.
1352 #[test]
1353 fn inventory_untagged_by_shape() {
1354 let for_store = Inventory::ForStore {
1355 store_id: "ab".repeat(32),
1356 roots: vec!["cd".repeat(32)],
1357 };
1358 let s = serde_json::to_string(&for_store).unwrap();
1359 assert!(s.contains("\"roots\""));
1360 assert!(!s.contains("ForStore"));
1361 assert_eq!(serde_json::from_str::<Inventory>(&s).unwrap(), for_store);
1362
1363 let all = Inventory::AllStores {
1364 stores: vec!["ef".repeat(32)],
1365 };
1366 let s = serde_json::to_string(&all).unwrap();
1367 assert!(s.contains("\"stores\""));
1368 assert_eq!(serde_json::from_str::<Inventory>(&s).unwrap(), all);
1369 }
1370
1371 /// **Proves:** `RedirectInfo` serializes the full redirect payload the
1372 /// `-32008` envelope carries.
1373 #[test]
1374 fn redirect_info_shape() {
1375 let r = RedirectInfo {
1376 content: ContentRef {
1377 store_id: "ab".repeat(32),
1378 root: Some("cd".repeat(32)),
1379 retrieval_key: Some("ef".repeat(32)),
1380 },
1381 providers: vec![Provider {
1382 peer_id: "12".repeat(32),
1383 addresses: vec![PeerAddress {
1384 host: "::1".into(),
1385 port: 9444,
1386 kind: "direct".into(),
1387 }],
1388 }],
1389 redirect_depth: 1,
1390 max_redirects: 4,
1391 };
1392 let v = serde_json::to_value(&r).unwrap();
1393 assert_eq!(v["redirect_depth"], 1);
1394 assert_eq!(v["max_redirects"], 4);
1395 assert_eq!(v["providers"][0]["addresses"][0]["host"], "::1");
1396 assert_eq!(serde_json::from_value::<RedirectInfo>(v).unwrap(), r);
1397 }
1398
1399 /// **Proves:** `cache.stats` models the live dig-node result field-for-field
1400 /// (the nested `content_cache{hits,misses}` object included).
1401 /// **Catches:** a drift from the node's `cache.stats` wire shape (#1075).
1402 #[test]
1403 fn cache_stats_wire_shape() {
1404 let s = CacheStats {
1405 cap_bytes: 1 << 30,
1406 used_bytes: 2048,
1407 entry_count: 3,
1408 total_bytes: 2048,
1409 evicted_count: 1,
1410 evicted_bytes: 512,
1411 content_cache: ContentCacheCounters { hits: 7, misses: 2 },
1412 };
1413 let v = serde_json::to_value(s).unwrap();
1414 assert_eq!(v["cap_bytes"], 1 << 30);
1415 assert_eq!(v["entry_count"], 3);
1416 assert_eq!(v["content_cache"]["hits"], 7);
1417 assert_eq!(v["content_cache"]["misses"], 2);
1418 assert_eq!(serde_json::from_value::<CacheStats>(v).unwrap(), s);
1419 }
1420
1421 /// **Proves:** the subscription-management results carry the exact
1422 /// `{subscribed, added|removed, store_id}` / `{subscriptions, count}` shapes
1423 /// the live node returns.
1424 #[test]
1425 fn subscription_result_shapes() {
1426 let sub = SubscribeResult {
1427 subscribed: true,
1428 added: true,
1429 store_id: "ab".repeat(32),
1430 };
1431 let v = serde_json::to_value(&sub).unwrap();
1432 assert_eq!(v["subscribed"], true);
1433 assert_eq!(v["added"], true);
1434 assert_eq!(serde_json::from_value::<SubscribeResult>(v).unwrap(), sub);
1435
1436 let unsub = UnsubscribeResult {
1437 subscribed: false,
1438 removed: true,
1439 store_id: "cd".repeat(32),
1440 };
1441 let v = serde_json::to_value(&unsub).unwrap();
1442 assert_eq!(v["subscribed"], false);
1443 assert_eq!(v["removed"], true);
1444 assert_eq!(
1445 serde_json::from_value::<UnsubscribeResult>(v).unwrap(),
1446 unsub
1447 );
1448
1449 let list = SubscriptionsList {
1450 subscriptions: vec!["ef".repeat(32)],
1451 count: 1,
1452 };
1453 let v = serde_json::to_value(&list).unwrap();
1454 assert_eq!(v["count"], 1);
1455 assert_eq!(
1456 serde_json::from_value::<SubscriptionsList>(v).unwrap(),
1457 list
1458 );
1459 }
1460
1461 /// **Proves:** `ModuleInfo` carries `chunk_lens` covering every chunk, and
1462 /// round-trips with unknown future fields.
1463 /// **Catches:** a missing `chunk_lens` field that would leave a puller unable
1464 /// to map a fetched byte range to its covering chunk hash.
1465 /// **Invariants enforced by docs:** `chunk_lens` must have the same length as
1466 /// `chunk_hashes` and must sum to `total_size`.
1467 #[test]
1468 fn module_info_chunk_lens_shape() {
1469 let info = ModuleInfo {
1470 total_size: 1024,
1471 module_hash: "ab".repeat(32),
1472 chunk_hashes: vec!["cd".repeat(32), "ef".repeat(32)],
1473 chunk_lens: vec![512, 512],
1474 };
1475 let v = serde_json::to_value(&info).unwrap();
1476 assert_eq!(v["total_size"], 1024);
1477 assert_eq!(v["chunk_hashes"].as_array().unwrap().len(), 2);
1478 assert_eq!(v["chunk_lens"].as_array().unwrap().len(), 2);
1479 assert_eq!(v["chunk_lens"][0], 512);
1480 assert_eq!(v["chunk_lens"][1], 512);
1481 assert_eq!(serde_json::from_value::<ModuleInfo>(v).unwrap(), info);
1482 }
1483
1484 /// **Proves:** `ModuleInfo` deserialization REJECTS missing `chunk_lens` field.
1485 /// This is a REQUIRED field (not optional) — omitting it from the wire is a
1486 /// protocol violation and must fail-closed.
1487 #[test]
1488 fn module_info_rejects_missing_chunk_lens() {
1489 let json_str = r#"{"total_size": 2048, "module_hash": "1122334455667788990011223344556677889900112233445566778899001122", "chunk_hashes": []}"#;
1490 let result: Result<ModuleInfo, _> = serde_json::from_str(json_str);
1491 assert!(
1492 result.is_err(),
1493 "ModuleInfo must reject JSON missing the required chunk_lens field"
1494 );
1495 let err = result.unwrap_err();
1496 assert!(
1497 err.to_string().contains("chunk_lens"),
1498 "error message should mention chunk_lens: {}",
1499 err
1500 );
1501 }
1502
1503 /// **Proves:** the peer connect/disconnect params + results round-trip and
1504 /// match the node's `{connected|disconnected, peer_id}` shapes.
1505 #[test]
1506 fn peer_connect_disconnect_shapes() {
1507 let p = PeerConnectParams {
1508 peer: "12".repeat(32),
1509 };
1510 let v = serde_json::to_value(&p).unwrap();
1511 assert_eq!(serde_json::from_value::<PeerConnectParams>(v).unwrap(), p);
1512
1513 let c = PeerConnectResult {
1514 connected: true,
1515 peer_id: "12".repeat(32),
1516 };
1517 let v = serde_json::to_value(&c).unwrap();
1518 assert_eq!(v["connected"], true);
1519 assert_eq!(serde_json::from_value::<PeerConnectResult>(v).unwrap(), c);
1520
1521 let d = PeerDisconnectResult {
1522 disconnected: true,
1523 peer_id: "34".repeat(32),
1524 };
1525 let v = serde_json::to_value(&d).unwrap();
1526 assert_eq!(v["disconnected"], true);
1527 assert_eq!(
1528 serde_json::from_value::<PeerDisconnectResult>(v).unwrap(),
1529 d
1530 );
1531 }
1532
1533 /// **Proves:** `cache.getConfig` uses the canonical `cache_dir` field name.
1534 /// **Catches:** a regression to the shell's historical `dir` name.
1535 #[test]
1536 fn cache_config_field_name_is_cache_dir() {
1537 let c = CacheConfig {
1538 cap_bytes: 1 << 30,
1539 used_bytes: 0,
1540 cache_dir: "/var/cache/dig".into(),
1541 shared: true,
1542 };
1543 let v = serde_json::to_value(&c).unwrap();
1544 assert!(v.get("cache_dir").is_some());
1545 assert!(v.get("dir").is_none(), "must not use the legacy `dir` name");
1546 }
1547}