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:443`).
327 pub url: String,
328 /// Whether a relay reservation is currently held.
329 pub reserved: bool,
330}
331
332/// Result for [`dig.getNetworkInfo`](crate::method::Method::GetNetworkInfo) —
333/// this node's own peer-network posture.
334#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
335#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
336pub struct NetworkInfo {
337 /// This node's stable `peer_id` = `SHA-256(TLS SPKI DER)` (64-hex), or
338 /// `null` when no identity is configured.
339 #[serde(skip_serializing_if = "Option::is_none", default)]
340 pub peer_id: Option<HexId>,
341 /// The DIG network id (e.g. `DIG_MAINNET`).
342 pub network_id: String,
343 /// The first advertised (dialable) candidate address, `host:port`.
344 pub listen_addr: String,
345 /// The STUN-discovered reflexive address, if known.
346 #[serde(skip_serializing_if = "Option::is_none", default)]
347 pub reflexive_addr: Option<String>,
348 /// All advertised candidate addresses (IPv6-first).
349 pub candidate_addresses: Vec<String>,
350 /// Reachability posture: `"direct"` or `"relayed"`.
351 pub reachability: String,
352 /// The relay reservation posture.
353 pub relay: RelayStatus,
354}
355
356// ===========================================================================
357// dig.getPeers (PEER)
358// ===========================================================================
359
360/// Result for [`dig.getPeers`](crate::method::Method::GetPeers) — the peers this
361/// node currently knows (peer exchange over RPC).
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
363#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
364pub struct PeersList {
365 /// The known peers (peer_id + candidate addresses).
366 pub peers: Vec<Provider>,
367}
368
369// ===========================================================================
370// dig.announce (PEER)
371// ===========================================================================
372
373/// Params for [`dig.announce`](crate::method::Method::Announce).
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
376pub struct AnnounceParams {
377 /// The announcing peer's `peer_id` (64-hex).
378 pub peer_id: HexId,
379 /// The announcing peer's candidate addresses.
380 pub addresses: Vec<PeerAddress>,
381}
382
383/// Result for [`dig.announce`](crate::method::Method::Announce).
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
386pub struct AnnounceAck {
387 /// Whether the announcement was accepted.
388 pub accepted: bool,
389 /// How many peers this node now knows.
390 pub known_peers: u64,
391}
392
393// ===========================================================================
394// dig.getAvailability (PEER)
395// ===========================================================================
396
397/// One availability query item. Granularity is inferred from which fields are
398/// present: `store_id` only ⇒ which roots are held; `+root` ⇒ a capsule; `+root
399/// +retrieval_key` ⇒ a resource.
400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
401#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
402pub struct AvailabilityQuery {
403 /// The store launcher id (64-hex, required).
404 pub store_id: HexId,
405 /// The generation root (64-hex), for capsule/resource granularity.
406 #[serde(skip_serializing_if = "Option::is_none", default)]
407 pub root: Option<HexId>,
408 /// The resource retrieval key (64-hex), for resource granularity.
409 #[serde(skip_serializing_if = "Option::is_none", default)]
410 pub retrieval_key: Option<HexId>,
411}
412
413/// Params for [`dig.getAvailability`](crate::method::Method::GetAvailability).
414///
415/// # Construction
416///
417/// Like [`FetchRangeParams`], this type is `#[non_exhaustive]`: build it with
418/// [`new`](Self::new) plus the `with_*` setters rather than a struct literal, so a
419/// future additive field is a PATCH for every consumer instead of a semver cascade.
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
422#[non_exhaustive]
423pub struct GetAvailabilityParams {
424 /// The items to check. Capped at 512 per batch (past-cap items are dropped).
425 pub items: Vec<AvailabilityQuery>,
426 /// The hop budget already consumed by this ask. Absent means zero — read it
427 /// through [`hops_consumed`](Self::hops_consumed), never directly.
428 ///
429 /// # What it means for an availability ask
430 ///
431 /// An availability answer is not only *this* node's holdings: on a miss it may
432 /// name the holders it located, in
433 /// [`AvailabilityAnswer::providers`](AvailabilityAnswer::providers) — the same
434 /// enrichment a [`RedirectInfo`] carries. A responder that cannot answer from
435 /// what it holds MAY ask its own peers, so one caller's question can walk
436 /// several hops, and each hop is a node spending someone else's bandwidth.
437 /// This field is what bounds that walk.
438 ///
439 /// It is the SAME budget, counted the SAME way, as
440 /// [`RedirectInfo::redirect_depth`] and as the `redirect_depth` that
441 /// [`GetContentParams`] and [`FetchRangeParams`] already echo: the number of
442 /// hops ALREADY CONSUMED when this ask arrives, counting UP from zero — never a
443 /// remaining allowance counting down. A responder that asks onward sends
444 /// `hops_consumed() + 1` (saturating at the type maximum), and MUST NOT ask
445 /// onward when that would reach the budget it advertises as
446 /// [`RedirectInfo::max_redirects`].
447 ///
448 /// Absent reads as a fresh, unhopped ask, so a client written before this field
449 /// existed is served unchanged.
450 #[serde(skip_serializing_if = "Option::is_none", default)]
451 pub redirect_depth: Option<u64>,
452 /// The wall-clock TIME this ask may still spend, in milliseconds. Absent means
453 /// unbudgeted — read it through [`budget_ms`](Self::budget_ms), never directly.
454 ///
455 /// # Why this is its OWN field and not the hop budget
456 ///
457 /// [`redirect_depth`](Self::redirect_depth) counts hops UP from zero toward a
458 /// ceiling; this counts milliseconds DOWN toward zero. The two move in opposite
459 /// directions along different axes, so one integer cannot carry both, and folding
460 /// them together would make "one more hop" and "more time" the same request.
461 ///
462 /// # The contract a relaying responder MUST honour
463 ///
464 /// A responder that asks its own peers onward MUST pass a value it has decremented
465 /// by the time it has itself already spent, and it MUST NOT grant a child less time
466 /// than the work it asks that child to do. Concretely: a parent that asks `n`
467 /// children SEQUENTIALLY must divide the remaining budget between them, and a
468 /// parent with less time left than one round trip needs MUST answer
469 /// [`ContentMissInconclusive`](crate::error::ErrorCode::ContentMissInconclusive)
470 /// rather than ask onward and then time out.
471 ///
472 /// This exists because the alternative is measurable: a FIXED per-ask bound with
473 /// sequential asks and a fan-out greater than one guarantees the second hop times
474 /// out, and a responder that reads that timeout as a miss reports a CONFIDENT
475 /// not-found for content it never looked for.
476 ///
477 /// Absent reads as unbudgeted, so a client written before this field existed is
478 /// served exactly as it was.
479 #[serde(skip_serializing_if = "Option::is_none", default)]
480 pub budget_ms: Option<u64>,
481 /// An opaque identity for this ask, for cross-path dedup. Absent means the caller
482 /// opted out of dedup — read it through [`ask_id`](Self::ask_id).
483 ///
484 /// # What it is for
485 ///
486 /// A recursive ask walks a graph, not a tree. Two disjoint paths can arrive at the
487 /// same responder, and without a shared identity that responder cannot tell a
488 /// re-walk from a fresh question — so a diamond in the peer graph does not
489 /// terminate. A responder that has already seen an `ask_id` MUST answer from what
490 /// it already knows instead of asking onward again.
491 ///
492 /// # What it is NOT
493 ///
494 /// It is **not** the JSON-RPC `id`. That field correlates one request with one
495 /// response on one connection; it is chosen per-connection, is commonly a small
496 /// constant, and says nothing about whether two arrivals are the same ask. An
497 /// implementation that reused the JSON-RPC `id` for dedup would either collide
498 /// every unrelated ask together or dedup nothing at all.
499 ///
500 /// # Requirements
501 ///
502 /// 32 lowercase hex characters: **16 unpredictable random bytes**, freshly drawn
503 /// by the ORIGINATOR and copied verbatim by every relaying hop. It MUST be
504 /// unpredictable, because a value an attacker can guess lets that attacker
505 /// pre-poison a responder dedup memo and suppress an ask that has not happened
506 /// yet. A responder MUST NOT derive anything from its value beyond EQUALITY — it
507 /// carries no structure, no origin, no timestamp and no ordering.
508 #[serde(skip_serializing_if = "Option::is_none", default)]
509 pub ask_id: Option<String>,
510}
511
512impl GetAvailabilityParams {
513 /// An availability batch for `items`, asked at hop zero.
514 pub fn new(items: Vec<AvailabilityQuery>) -> Self {
515 GetAvailabilityParams {
516 items,
517 redirect_depth: None,
518 budget_ms: None,
519 ask_id: None,
520 }
521 }
522
523 /// Echo the hop budget already consumed — from a `-32008` redirect, or from the
524 /// ask this one is being made on behalf of. See
525 /// [`redirect_depth`](Self::redirect_depth).
526 pub fn with_redirect_depth(mut self, redirect_depth: u64) -> Self {
527 self.redirect_depth = Some(redirect_depth);
528 self
529 }
530
531 /// The hops already consumed by this ask.
532 ///
533 /// The single home for the "absent means zero" rule. A responder that reached for
534 /// `redirect_depth.is_some()` instead would read every pre-0.8 client's ask as
535 /// budget-free and forward it without bound — the amplification the budget exists
536 /// to stop.
537 pub fn hops_consumed(&self) -> u64 {
538 self.redirect_depth.unwrap_or(0)
539 }
540
541 /// Set the remaining time budget for this ask. See [`budget_ms`](Self::budget_ms).
542 pub fn with_budget_ms(mut self, budget_ms: u64) -> Self {
543 self.budget_ms = Some(budget_ms);
544 self
545 }
546
547 /// The time this ask may still spend, or `None` when the caller sent no budget.
548 ///
549 /// Deliberately NOT collapsed to a number, unlike
550 /// [`hops_consumed`](Self::hops_consumed): there is no safe scalar default. Zero
551 /// would refuse every older caller ask outright, and any positive default would
552 /// silently impose one node idea of patience on another node question. A responder
553 /// that receives `None` applies its OWN policy and passes on what it granted.
554 pub fn budget_ms(&self) -> Option<u64> {
555 self.budget_ms
556 }
557
558 /// Set the cross-path dedup identity. See [`ask_id`](Self::ask_id).
559 pub fn with_ask_id(mut self, ask_id: impl Into<String>) -> Self {
560 self.ask_id = Some(ask_id.into());
561 self
562 }
563
564 /// The dedup identity, or `None` when the caller opted out of dedup.
565 pub fn ask_id(&self) -> Option<&str> {
566 self.ask_id.as_deref()
567 }
568}
569
570/// One availability answer. Only the fields relevant to the query's granularity
571/// are populated.
572#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
573#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
574pub struct AvailabilityAnswer {
575 /// Whether this node holds the queried item.
576 pub available: bool,
577 /// The roots held (store-granularity queries only).
578 #[serde(skip_serializing_if = "Option::is_none", default)]
579 pub roots: Option<Vec<HexId>>,
580 /// The full resource ciphertext length (resource-granularity only).
581 #[serde(skip_serializing_if = "Option::is_none", default)]
582 pub total_length: Option<u64>,
583 /// The chunk count (resource-granularity only).
584 #[serde(skip_serializing_if = "Option::is_none", default)]
585 pub chunk_count: Option<u64>,
586 /// Whether the whole item is held (root/resource-granularity only).
587 #[serde(skip_serializing_if = "Option::is_none", default)]
588 pub complete: Option<bool>,
589 /// Providers that hold the item — present on a miss when holders were
590 /// located (enriched answer).
591 #[serde(skip_serializing_if = "Option::is_none", default)]
592 pub providers: Option<Vec<Provider>>,
593 /// Whether this responder actually ESTABLISHED that nobody holds the item.
594 ///
595 /// Only meaningful beside `available: false`; on a hit the item is held and there
596 /// is nothing to establish.
597 ///
598 /// # Absent is a THIRD state, not `false`
599 ///
600 /// - `Some(true)` — the responder looked, reached everything it meant to reach,
601 /// and asserts absence. A client MAY stop searching.
602 /// - `Some(false)` — the responder looked and could NOT establish absence: a hop
603 /// timed out, was unreachable, or refused uninformatively. A client MUST keep
604 /// looking. This is the in-band form of
605 /// [`ContentMissInconclusive`](crate::error::ErrorCode::ContentMissInconclusive),
606 /// for a batch where only SOME items were inconclusive and the call itself
607 /// therefore succeeded.
608 /// - `None` — the responder predates this field and makes NO claim either way.
609 /// It is NOT `Some(false)`: `Some(false)` is a responder telling you its search
610 /// was incomplete, while `None` is a responder that cannot describe its search at
611 /// all. Conflating them lets an older server every miss be read as a positive
612 /// report of incompleteness; conflating it the other way (`unwrap_or(true)`)
613 /// turns an unknown into an assertion of absence. Read it through
614 /// [`absence_established_or_unknown`](AvailabilityAnswer::absence_established_or_unknown),
615 /// which keeps the three states distinct.
616 #[serde(skip_serializing_if = "Option::is_none", default)]
617 pub absence_established: Option<bool>,
618}
619
620impl AvailabilityAnswer {
621 /// Whether absence was established, as a THREE-state answer: `Some(true)`
622 /// asserted, `Some(false)` explicitly not established, `None` unknown because the
623 /// responder predates the field.
624 ///
625 /// A pass-through, and that is the point — it is the named home for the rule that
626 /// there is no safe collapse to `bool`. A client that wants to stop searching MUST
627 /// require `Some(true)`.
628 pub fn absence_established_or_unknown(&self) -> Option<bool> {
629 self.absence_established
630 }
631}
632
633/// Result for [`dig.getAvailability`](crate::method::Method::GetAvailability) —
634/// one answer per query item, in order.
635#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
636#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
637pub struct AvailabilityBatch {
638 /// The per-item answers (index-aligned to the query items served).
639 pub items: Vec<AvailabilityAnswer>,
640}
641
642// ===========================================================================
643// dig.listInventory (PEER)
644// ===========================================================================
645
646/// Params for [`dig.listInventory`](crate::method::Method::ListInventory).
647#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
648#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
649pub struct ListInventoryParams {
650 /// The store to list roots for (64-hex). Absent ⇒ list all stores served.
651 #[serde(skip_serializing_if = "Option::is_none", default)]
652 pub store_id: Option<HexId>,
653 /// The maximum number of entries to return.
654 #[serde(skip_serializing_if = "Option::is_none", default)]
655 pub limit: Option<u64>,
656}
657
658/// Result for [`dig.listInventory`](crate::method::Method::ListInventory).
659///
660/// With a `store_id` the node returns the roots it holds for that store; without
661/// one it returns the stores it serves. `#[serde(untagged)]` keeps the wire flat
662/// (`{"roots": …}` or `{"stores": …}`).
663#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
664#[serde(untagged)]
665#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
666pub enum Inventory {
667 /// The roots held for a specific store.
668 ForStore {
669 /// The store launcher id (echoed, 64-hex).
670 store_id: HexId,
671 /// The roots this node holds for the store.
672 roots: Vec<HexId>,
673 },
674 /// The stores this node serves (no `store_id` given).
675 AllStores {
676 /// The store launcher ids served.
677 stores: Vec<HexId>,
678 },
679}
680
681// ===========================================================================
682// dig.fetchRange (PEER)
683// ===========================================================================
684
685/// Params for [`dig.fetchRange`](crate::method::Method::FetchRange) — a single
686/// range frame of a resource this node holds.
687///
688/// # Construction
689///
690/// Like [`RangeFrame`], this type is `#[non_exhaustive]`: build it with
691/// [`resource`](Self::resource) plus the `with_*` setters rather than a struct
692/// literal, so a future additive field is a PATCH for every consumer instead of a
693/// semver cascade.
694///
695/// # Cross-repo contract
696///
697/// [`skip_layout`](Self::skip_layout) is byte-identical to
698/// `dig_nat::mux::RangeRequest::skip_layout`, pinned in
699/// `tests/nat_wire_mirror.rs`. The two enclosing types deliberately differ in every
700/// other respect — dig-nat's `RangeRequest` is a length-prefixed stream preamble,
701/// this is a JSON-RPC params object with a `redirect_depth` dig-nat has no notion
702/// of — so the byte-identical contract here is the FIELD, not the object.
703#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
704#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
705#[non_exhaustive]
706pub struct FetchRangeParams {
707 /// The store launcher id (64-hex, required).
708 pub store_id: HexId,
709 /// The generation root (64-hex, required for a resource fetch).
710 pub root: HexId,
711 /// `SHA-256(urn)` (64-hex, required for a resource fetch).
712 pub retrieval_key: HexId,
713 /// The range start (default 0).
714 #[serde(skip_serializing_if = "Option::is_none", default)]
715 pub offset: Option<u64>,
716 /// The range length in bytes (> 0; clamped to the window cap).
717 pub length: u64,
718 /// Whole-capsule mode (default false). Capsule range fetch is not yet
719 /// served; a `true` here yields `-32004`.
720 #[serde(skip_serializing_if = "Option::is_none", default)]
721 pub capsule: Option<bool>,
722 /// The redirect budget already consumed (echoed from a `-32008` redirect).
723 #[serde(skip_serializing_if = "Option::is_none", default)]
724 pub redirect_depth: Option<u64>,
725 /// Suppress the resource-scaling layout metadata (`chunk_lens` +
726 /// `inclusion_proof`) on this stream's frames, because the client already holds
727 /// the commitment for this `root`.
728 ///
729 /// A client that has already read the layout once — a resumed download, a second
730 /// range of the same resource, a parallel fetch from another holder — does not
731 /// need it again, and re-sending it costs a whole paged prologue PER STREAM: a
732 /// 1,048,576-chunk layout is roughly 7.3 MB, which a 64-way parallel plan would
733 /// otherwise pay 64 times over. Suppressing it is the difference between a
734 /// bounded and an unbounded cost on the read path.
735 ///
736 /// Absent or `false` preserves the pre-0.6.0 behaviour, so an older holder that
737 /// ignores this field is never broken by it — it simply sends metadata the client
738 /// discards. Read the rule through
739 /// [`suppresses_layout`](Self::suppresses_layout) rather than re-deriving it.
740 ///
741 /// The fixed-size identity fields ([`root`](RangeFrame::root),
742 /// [`total_length`](RangeFrame::total_length),
743 /// [`chunk_count`](RangeFrame::chunk_count),
744 /// [`chunk_index`](RangeFrame::chunk_index)) are NOT suppressed: they are what
745 /// detects a wrong-generation holder on arrival, and a client that stopped
746 /// receiving them would lose that check on exactly the streams it fetches most.
747 #[serde(default, skip_serializing_if = "Option::is_none")]
748 pub skip_layout: Option<bool>,
749}
750
751impl FetchRangeParams {
752 /// A range request for one content resource: `length` bytes of
753 /// `retrieval_key`'s ciphertext at the generation `root`.
754 pub fn resource(
755 store_id: impl Into<HexId>,
756 root: impl Into<HexId>,
757 retrieval_key: impl Into<HexId>,
758 length: u64,
759 ) -> Self {
760 FetchRangeParams {
761 store_id: store_id.into(),
762 root: root.into(),
763 retrieval_key: retrieval_key.into(),
764 offset: None,
765 length,
766 capsule: None,
767 redirect_depth: None,
768 skip_layout: None,
769 }
770 }
771
772 /// Start the range at `offset` rather than at 0.
773 pub fn with_offset(mut self, offset: u64) -> Self {
774 self.offset = Some(offset);
775 self
776 }
777
778 /// Request whole-capsule mode. Capsule range fetch is not yet served — a `true`
779 /// here yields
780 /// [`ResourceUnavailable`](crate::error::ErrorCode::ResourceUnavailable).
781 pub fn with_capsule(mut self, capsule: bool) -> Self {
782 self.capsule = Some(capsule);
783 self
784 }
785
786 /// Echo the redirect budget already consumed, from a `-32008` redirect.
787 pub fn with_redirect_depth(mut self, redirect_depth: u64) -> Self {
788 self.redirect_depth = Some(redirect_depth);
789 self
790 }
791
792 /// Ask the holder to omit the resource-scaling layout metadata, because this
793 /// client already holds the commitment for this `root`. See
794 /// [`skip_layout`](Self::skip_layout).
795 pub fn with_skip_layout(mut self, skip_layout: bool) -> Self {
796 self.skip_layout = Some(skip_layout);
797 self
798 }
799
800 /// Whether this request suppresses the resource-scaling layout metadata.
801 ///
802 /// The single home for the "absent or `false` means SEND the layout" rule. A
803 /// serve path that reached for `skip_layout.is_some()` instead would suppress the
804 /// layout for a client that had explicitly asked for it — unrecoverable for that
805 /// client, since the layout is a decrypt input it cannot obtain any other way on
806 /// that stream.
807 pub fn suppresses_layout(&self) -> bool {
808 self.skip_layout.unwrap_or(false)
809 }
810}
811
812/// One range frame of a resource: a byte window, plus the per-resource
813/// verification metadata that makes the window independently checkable.
814///
815/// The metadata splits in two by whether it scales with the resource, and the
816/// split decides which frames carry it:
817///
818/// - **The identity set — [`root`](Self::root),
819/// [`total_length`](Self::total_length), [`chunk_count`](Self::chunk_count),
820/// plus [`chunk_index`](Self::chunk_index) when the window begins on a chunk
821/// boundary — rides EVERY frame.** It is fixed-size, so carrying it everywhere
822/// costs a bounded number of bytes, and it is what lets a client fetching in
823/// parallel from many holders reject a wrong-generation or wrong-layout source
824/// the moment a frame arrives, rather than after paying for the whole resource
825/// in bandwidth.
826/// - **The resource-scaling set — [`chunk_lens`](Self::chunk_lens) and
827/// [`inclusion_proof`](Self::inclusion_proof) — rides the first frame, or a
828/// paged prologue, once per range stream.** Repeating it per frame would cost
829/// proportionally to the resource against a frame budget with no slack; a layout
830/// too large to state on one frame is paged instead, each page stamped with the
831/// [`chunk_lens_offset`](Self::chunk_lens_offset) it begins at.
832///
833/// The window is exactly the span the caller requested — never widened.
834///
835/// # Construction
836///
837/// This type is [`#[non_exhaustive]`](https://doc.rust-lang.org/reference/attributes/type_system.html):
838/// build it with [`data`](Self::data) and the `with_*` setters rather than a struct
839/// literal. That is deliberate — the wire form grows as the protocol does, and
840/// routing construction through named setters means a future additive field is a
841/// PATCH release for every consumer instead of another semver cascade. It also
842/// makes the two frame shapes different call chains rather than one call with a
843/// pile of `None`s, so a continuation frame cannot accidentally claim a layout it
844/// is not stating.
845///
846/// # Cross-repo contract
847///
848/// The wire form is **byte-identical** to `dig_nat::mux::RangeFrame`, the
849/// streaming implementation of this frame (`SYSTEM.md` → "Canonical DIG-node RPC
850/// interface"). Field names, encodings, and the population rule above are pinned
851/// against dig-nat's actual output in `tests/nat_wire_mirror.rs`; a change to any
852/// of them lands in both crates in the same unit of work or not at all.
853#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
854#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
855#[non_exhaustive]
856pub struct RangeFrame {
857 /// The window start offset (echoed).
858 pub offset: u64,
859 /// This window's byte length.
860 pub length: u64,
861 /// This window's ciphertext, base64.
862 pub bytes: String,
863 /// Whether this frame ends the resource.
864 pub complete: bool,
865 /// The full resource ciphertext length. Part of the fixed-size **identity
866 /// set**, so it rides EVERY frame.
867 #[serde(skip_serializing_if = "Option::is_none", default)]
868 pub total_length: Option<u64>,
869 /// Per-chunk ciphertext lengths of the full resource, in order — the layout a
870 /// reader needs before it can decrypt (per-chunk AEAD needs the WHOLE array,
871 /// and a reader rejects an array whose sum differs from
872 /// [`total_length`](Self::total_length)).
873 ///
874 /// Resource-scaling, so it rides the first frame or a **paged prologue**, once
875 /// per range stream — never repeated on continuation frames. When paged, this
876 /// is one page of the array and
877 /// [`chunk_lens_offset`](Self::chunk_lens_offset) states the entry it begins
878 /// at.
879 #[serde(skip_serializing_if = "Option::is_none", default)]
880 pub chunk_lens: Option<Vec<u64>>,
881 /// This frame's first chunk index — the pre-existing alias of
882 /// [`first_chunk_index`](Self::first_chunk_index), carrying the same value, and
883 /// the name dig-nat emits.
884 ///
885 /// Part of the **identity set**: it rides every frame whose window begins on a
886 /// chunk boundary, and is OMITTED (rather than guessed) on a mid-chunk window.
887 /// Being fixed-size, it is settable on its own — see
888 /// [`with_chunk_index`](Self::with_chunk_index) — precisely so a continuation
889 /// frame can state it without dragging along the once-per-stream
890 /// [`inclusion_proof`](Self::inclusion_proof).
891 #[serde(skip_serializing_if = "Option::is_none", default)]
892 pub chunk_index: Option<u64>,
893 /// Whole-resource merkle proof against [`root`](Self::root), base64, relayed
894 /// verbatim.
895 ///
896 /// Resource-scaling, so it rides the first frame or the paged prologue, once
897 /// per range stream. A holder MUST NOT repeat it per frame: it is bounded at
898 /// 4,096 base64 bytes, which against the frame budget leaves no slack for the
899 /// payload the frame exists to carry.
900 #[serde(skip_serializing_if = "Option::is_none", default)]
901 pub inclusion_proof: Option<String>,
902 /// The chain-anchored root (64-hex) this frame's resource verified against.
903 /// Part of the fixed-size **identity set**, so it rides EVERY frame.
904 ///
905 /// NOT A TRUST ANCHOR BY ITSELF. The client resolves the resource's root from
906 /// the URN (chain-anchored) and PINS it before fetching; a peer-declared value
907 /// never replaces that pinned root. What this field provides is a
908 /// generation-CONSISTENCY check: a frame declaring a root other than the pinned
909 /// one is REJECTED and attributed to the offending peer (NC-9 fail-closed). So
910 /// a declared root can only ever cause rejection — it can never move the pinned
911 /// root, and never makes an unverified frame acceptable.
912 #[serde(skip_serializing_if = "Option::is_none", default)]
913 pub root: Option<HexId>,
914 /// **RESERVED — not currently derivable; a server MUST NOT emit it.**
915 ///
916 /// Per-chunk merkle inclusion proofs for the chunks a frame covers. No such
917 /// proof exists in the current store format: the generation root's merkle
918 /// leaves are per-RESOURCE (a leaf is the SHA-256 of a resource's WHOLE
919 /// ciphertext), so a single chunk has no leaf to prove. A client MUST NOT
920 /// require this field, and per-range verification instead uses the
921 /// whole-resource [`inclusion_proof`](Self::inclusion_proof) together with the
922 /// per-frame [`root`](Self::root)/[`chunk_lens`](Self::chunk_lens) metadata.
923 ///
924 /// Making it derivable requires a per-resource chunk-level commitment in the
925 /// store format first (tracked as `dig_ecosystem#1601`). The field is kept in
926 /// the wire type, unused, so populating it later is additive (§5.1); each entry
927 /// would be an opaque base64 proof blob, since this pure level-00 wire type
928 /// MUST NOT depend on the merkle primitive.
929 #[serde(skip_serializing_if = "Option::is_none", default)]
930 pub range_proof: Option<Vec<String>>,
931 /// The chunk index of the first chunk in this frame (0-based, into the
932 /// resource's chunk sequence described by [`chunk_lens`](Self::chunk_lens)).
933 ///
934 /// Present only when the frame's window begins EXACTLY on a chunk boundary; a
935 /// mid-chunk window omits it rather than assert an index the caller's own
936 /// alignment check would contradict. The served window is exactly the requested
937 /// span — a server MUST NOT widen a range to a chunk boundary — so a frame is
938 /// chunk-aligned only when the caller asked for an aligned span.
939 #[serde(skip_serializing_if = "Option::is_none", default)]
940 pub first_chunk_index: Option<u64>,
941 /// The resource's TOTAL chunk count — how many entries the fully reassembled
942 /// [`chunk_lens`](Self::chunk_lens) array has.
943 ///
944 /// Fixed-size, so it belongs to the **identity set** and rides EVERY frame.
945 /// Together with [`root`](Self::root) and
946 /// [`total_length`](Self::total_length) it is what lets a reader detect a
947 /// wrong-generation or wrong-layout holder on the first frame it receives. It is
948 /// also how a reader sizes the array it is paging in, and therefore how it knows
949 /// a **paged prologue** is complete: the prologue ends when the reader holds
950 /// `chunk_count` entries, which no single page can tell it.
951 #[serde(default, skip_serializing_if = "Option::is_none")]
952 pub chunk_count: Option<u64>,
953 /// The index into the resource's [`chunk_lens`](Self::chunk_lens) array at which
954 /// THIS frame's page begins — how a **paged prologue** is located and
955 /// reassembled.
956 ///
957 /// A resource whose layout exceeds the per-frame entry cap cannot state it on
958 /// one frame, so the sender pages it: successive frames each carry up to that
959 /// many entries, stamped with the offset they start at. A reader places each page
960 /// at its offset and holds the whole array once it has
961 /// [`chunk_count`](Self::chunk_count) entries.
962 ///
963 /// Absent means "this frame's `chunk_lens`, if any, begins at entry 0" — the
964 /// single-frame layout, which is the shape every pre-0.6.0 producer emits. So an
965 /// older frame decodes with exactly its original meaning (§5.1).
966 #[serde(default, skip_serializing_if = "Option::is_none")]
967 pub chunk_lens_offset: Option<u64>,
968}
969
970impl RangeFrame {
971 /// A **data frame**: `length` bytes of base64 ciphertext at `offset`, carrying
972 /// no metadata — the bare shape every continuation frame starts from.
973 ///
974 /// `length` is stated rather than derived because [`bytes`](Self::bytes) is
975 /// already base64 on this type, and recovering the raw window length from it
976 /// would need a base64 codec this pure level-00 wire crate deliberately does not
977 /// depend on. A serve path passes the length it served.
978 pub fn data(offset: u64, length: u64, bytes: impl Into<String>) -> Self {
979 RangeFrame {
980 offset,
981 length,
982 bytes: bytes.into(),
983 complete: false,
984 total_length: None,
985 chunk_lens: None,
986 chunk_index: None,
987 inclusion_proof: None,
988 root: None,
989 range_proof: None,
990 first_chunk_index: None,
991 chunk_count: None,
992 chunk_lens_offset: None,
993 }
994 }
995
996 /// Mark this as the final frame of the range.
997 pub fn with_complete(mut self, complete: bool) -> Self {
998 self.complete = complete;
999 self
1000 }
1001
1002 /// The fixed-size **identity set** every frame of a range carries: the
1003 /// generation `root` (64-hex) the range is served from, the resource's
1004 /// ciphertext `total_length`, and its `chunk_count`.
1005 ///
1006 /// These three are what let a reader reject a wrong-generation or wrong-layout
1007 /// holder the moment a frame arrives — which the resource-scaling metadata never
1008 /// could, since it arrives once. Call this on every frame.
1009 pub fn with_identity(
1010 mut self,
1011 root: impl Into<HexId>,
1012 total_length: u64,
1013 chunk_count: u64,
1014 ) -> Self {
1015 self.root = Some(root.into());
1016 self.total_length = Some(total_length);
1017 self.chunk_count = Some(chunk_count);
1018 self
1019 }
1020
1021 /// State [`chunk_index`](Self::chunk_index) — the chunk this frame's window
1022 /// begins on — for a chunk-aligned window.
1023 ///
1024 /// Separate from [`with_inclusion_proof`](Self::with_inclusion_proof) on purpose:
1025 /// the index is fixed-size identity metadata that rides every aligned frame,
1026 /// while the proof is once-per-stream, so binding them together would force a
1027 /// producer to either repeat a proof it MUST NOT repeat or bypass this API. Omit
1028 /// the call entirely for a mid-chunk window.
1029 pub fn with_chunk_index(mut self, chunk_index: u64) -> Self {
1030 self.chunk_index = Some(chunk_index);
1031 self
1032 }
1033
1034 /// Additionally state [`first_chunk_index`](Self::first_chunk_index), this
1035 /// crate's v0.4.0 alias of [`chunk_index`](Self::chunk_index).
1036 ///
1037 /// Both names carry the same value. dig-nat emits only `chunk_index`, so
1038 /// [`with_chunk_index`](Self::with_chunk_index) alone is the interoperable
1039 /// choice; a producer serving readers that expect the newer name states both.
1040 pub fn with_first_chunk_index(mut self, first_chunk_index: u64) -> Self {
1041 self.first_chunk_index = Some(first_chunk_index);
1042 self
1043 }
1044
1045 /// One page of the resource's `chunk_lens` array, beginning at entry
1046 /// `chunk_lens_offset`.
1047 ///
1048 /// Call it once with offset `0` for a layout that fits a single frame, or once
1049 /// per page of a **paged prologue**. A page is only ever useful as part of a
1050 /// complete set: `chunk_lens` is a decrypt input, and a reader needs all
1051 /// [`chunk_count`](Self::chunk_count) entries before it can decrypt anything.
1052 pub fn with_chunk_lens_page(mut self, chunk_lens_offset: u64, chunk_lens: Vec<u64>) -> Self {
1053 self.chunk_lens_offset = Some(chunk_lens_offset);
1054 self.chunk_lens = Some(chunk_lens);
1055 self
1056 }
1057
1058 /// The whole-resource merkle inclusion proof against
1059 /// [`root`](Self::root) (base64, relayed verbatim).
1060 ///
1061 /// Resource-scaling: state it on the first frame or the prologue, once per range
1062 /// stream, never per frame.
1063 pub fn with_inclusion_proof(mut self, inclusion_proof: impl Into<String>) -> Self {
1064 self.inclusion_proof = Some(inclusion_proof.into());
1065 self
1066 }
1067
1068 /// State the **RESERVED** [`range_proof`](Self::range_proof) field.
1069 ///
1070 /// A server MUST NOT emit it — no per-chunk proof is derivable from the current
1071 /// store format (see the field's own documentation). The setter exists so the
1072 /// shape stays constructible for the conformance vectors that pin it, and so no
1073 /// field of this `#[non_exhaustive]` type is unreachable; it is not a serve-path
1074 /// call.
1075 pub fn with_range_proof(mut self, range_proof: Vec<String>) -> Self {
1076 self.range_proof = Some(range_proof);
1077 self
1078 }
1079}
1080
1081// ===========================================================================
1082// dig.getModuleInfo / dig.fetchModuleRange (PEER — whole-module pull, #1576)
1083// ===========================================================================
1084
1085/// Params for [`dig.getModuleInfo`](crate::method::Method::GetModuleInfo) — the
1086/// handshake a peer reads before range-pulling a whole `.dig` module for
1087/// `(store, root)`.
1088#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1089#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1090pub struct GetModuleInfoParams {
1091 /// The store launcher id (64-hex, required).
1092 pub store_id: HexId,
1093 /// The generation root whose `.dig` module is being pulled (64-hex, required).
1094 pub root: HexId,
1095}
1096
1097/// Result for [`dig.getModuleInfo`](crate::method::Method::GetModuleInfo) — the
1098/// transfer descriptor of a whole `.dig` module.
1099///
1100/// The whole-module blob is content-addressed + immutable (the `.dig` container
1101/// is byte-identical by construction). [`module_hash`](Self::module_hash) is the
1102/// content id of the assembled blob; a puller verifies each pulled range against
1103/// [`chunk_hashes`](Self::chunk_hashes) (per-peer attribution on a multi-source
1104/// pull) and the fully-assembled blob against `module_hash`, THEN verifies the
1105/// assembled module against its chain-anchored root before admitting + resharing
1106/// (NC-9 verified-content-not-safe-content).
1107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1108#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1109pub struct ModuleInfo {
1110 /// The total byte length of the whole `.dig` module blob.
1111 pub total_size: u64,
1112 /// The content id of the fully-assembled module blob (64-hex `SHA-256` of the
1113 /// module bytes). The puller checks the assembled blob against this.
1114 pub module_hash: HexId,
1115 /// Per-chunk content hashes (64-hex each) in ascending chunk order, covering
1116 /// the blob in [`total_size`](Self::total_size)-spanning fixed-size chunks
1117 /// (the trailing chunk may be short). A puller checks each pulled
1118 /// [`RangeFrame`] against the covering entries for per-source attribution on a
1119 /// multi-source pull (a tampered range fails closed before assembly).
1120 pub chunk_hashes: Vec<HexId>,
1121 /// Per-chunk byte lengths (in the same order as [`chunk_hashes`](Self::chunk_hashes)).
1122 /// MUST have the same length as `chunk_hashes` and MUST sum to `total_size`.
1123 /// A puller uses these to map a fetched byte range to the covering chunk hash(es).
1124 pub chunk_lens: Vec<u64>,
1125}
1126
1127/// Params for [`dig.fetchModuleRange`](crate::method::Method::FetchModuleRange) —
1128/// a single range frame of the whole `.dig` module blob for `(store, root)`.
1129///
1130/// The response reuses [`RangeFrame`]: [`bytes`](RangeFrame::bytes) carries the
1131/// window of the module blob (base64), [`total_length`](RangeFrame::total_length)
1132/// echoes the whole-module size on the first frame, and
1133/// [`complete`](RangeFrame::complete) ends the stream.
1134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1135#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1136pub struct FetchModuleRangeParams {
1137 /// The store launcher id (64-hex, required).
1138 pub store_id: HexId,
1139 /// The generation root whose `.dig` module is being pulled (64-hex, required).
1140 pub root: HexId,
1141 /// The range start into the module blob (default 0).
1142 #[serde(skip_serializing_if = "Option::is_none", default)]
1143 pub offset: Option<u64>,
1144 /// The range length in bytes (> 0; clamped to the window cap).
1145 pub length: u64,
1146}
1147
1148// ===========================================================================
1149// dig.stage (CONTROL — loopback / in-process only)
1150// ===========================================================================
1151
1152/// Params for [`dig.stage`](crate::method::Method::Stage) — compile a local
1153/// folder into a capsule `.dig` module in-process.
1154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1155#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1156pub struct StageParams {
1157 /// The absolute path to the folder to compile.
1158 pub dir: String,
1159 /// The target store launcher id (64-hex). Absent ⇒ an ephemeral,
1160 /// content-derived id (a preview).
1161 #[serde(skip_serializing_if = "Option::is_none", default)]
1162 pub store_id: Option<HexId>,
1163 /// The store salt (64-hex). Present ⇒ a private store.
1164 #[serde(skip_serializing_if = "Option::is_none", default)]
1165 pub salt: Option<HexId>,
1166 /// Optional DIGHub-style manifest metadata to embed.
1167 #[serde(skip_serializing_if = "Option::is_none", default)]
1168 pub metadata: Option<serde_json::Value>,
1169}
1170
1171/// Result for [`dig.stage`](crate::method::Method::Stage) — the compiled capsule.
1172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1173#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1174pub struct StageResult {
1175 /// The canonical capsule identity, `storeId:rootHash`.
1176 pub capsule: String,
1177 /// The store launcher id (64-hex).
1178 pub store_id: HexId,
1179 /// The compiled generation root (64-hex).
1180 pub root: HexId,
1181 /// The filesystem path to the compiled `.dig` module.
1182 pub module_path: String,
1183 /// The module size in bytes.
1184 pub size: u64,
1185 /// The `chia://storeId:rootHash/` content address.
1186 #[serde(skip_serializing_if = "Option::is_none", default)]
1187 pub content_address: Option<String>,
1188 /// The relative paths compiled into the capsule.
1189 #[serde(default)]
1190 pub files: Vec<String>,
1191 /// Whether this is an ephemeral preview (not advancing a real store).
1192 #[serde(skip_serializing_if = "Option::is_none", default)]
1193 pub ephemeral: Option<bool>,
1194}
1195
1196// ===========================================================================
1197// cache.* (CONTROL — loopback / in-process only)
1198// ===========================================================================
1199
1200/// Result for [`cache.getConfig`](crate::method::Method::CacheGetConfig).
1201///
1202/// The canonical field name for the cache path is `cache_dir` everywhere (the
1203/// shell's historical `dir` is unified onto this name).
1204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1205#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1206pub struct CacheConfig {
1207 /// The on-disk cache size cap in bytes (floored at 64 MiB).
1208 pub cap_bytes: u64,
1209 /// The bytes currently used.
1210 pub used_bytes: u64,
1211 /// The effective resolved cache directory.
1212 pub cache_dir: String,
1213 /// Whether that directory is the canonical shared location (vs a
1214 /// process-private fallback).
1215 pub shared: bool,
1216}
1217
1218/// Params for [`cache.setCapBytes`](crate::method::Method::CacheSetCapBytes).
1219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1220#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1221pub struct SetCapBytesParams {
1222 /// The requested cap in bytes (floored at 64 MiB by the node).
1223 pub cap_bytes: u64,
1224}
1225
1226/// Result for [`cache.setCapBytes`](crate::method::Method::CacheSetCapBytes).
1227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1228#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1229pub struct SetCapBytesResult {
1230 /// The effective cap after flooring.
1231 pub cap_bytes: u64,
1232}
1233
1234/// One durable cached-module entry.
1235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1236#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1237pub struct CachedCapsule {
1238 /// The canonical capsule identity, `storeId:rootHash`.
1239 pub capsule: String,
1240 /// The store launcher id (64-hex).
1241 pub store_id: HexId,
1242 /// The generation root (64-hex).
1243 pub root: HexId,
1244 /// The module size in bytes.
1245 pub size_bytes: u64,
1246 /// When the module was last used (unix ms).
1247 pub last_used_unix_ms: u64,
1248}
1249
1250/// Result for [`cache.listCached`](crate::method::Method::CacheListCached).
1251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1252#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1253pub struct CachedList {
1254 /// The cached capsules.
1255 pub cached: Vec<CachedCapsule>,
1256}
1257
1258/// Params for a capsule-keyed cache op
1259/// ([`cache.removeCached`](crate::method::Method::CacheRemoveCached),
1260/// [`cache.fetchAndCache`](crate::method::Method::CacheFetchAndCache)).
1261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1262#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1263pub struct CapsuleKey {
1264 /// The store launcher id (64-hex).
1265 pub store_id: HexId,
1266 /// The generation root (64-hex).
1267 pub root: HexId,
1268}
1269
1270/// Result for [`cache.removeCached`](crate::method::Method::CacheRemoveCached).
1271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1272#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1273pub struct RemoveCachedResult {
1274 /// Whether an entry was removed.
1275 pub removed: bool,
1276}
1277
1278/// Result for [`cache.fetchAndCache`](crate::method::Method::CacheFetchAndCache).
1279///
1280/// A failed fetch is reported in-band (`status = "failed"` + `message`) so the
1281/// caller can show it without treating it as a transport error.
1282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1283#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1284pub struct FetchAndCacheResult {
1285 /// `"cached"`, `"already_cached"`, or `"failed"`.
1286 pub status: String,
1287 /// The fetched module size in bytes (on success).
1288 #[serde(skip_serializing_if = "Option::is_none", default)]
1289 pub size_bytes: Option<u64>,
1290 /// The served generation root (64-hex, on success).
1291 #[serde(skip_serializing_if = "Option::is_none", default)]
1292 pub served_root: Option<HexId>,
1293 /// The failure message (on `status = "failed"`).
1294 #[serde(skip_serializing_if = "Option::is_none", default)]
1295 pub message: Option<String>,
1296}
1297
1298// ===========================================================================
1299// control.peerStatus (CONTROL — loopback / in-process only)
1300// ===========================================================================
1301
1302/// Result for [`control.peerStatus`](crate::method::Method::ControlPeerStatus) —
1303/// a snapshot of the node's L7 peer network. Always safe to call; reports
1304/// `running: false` on the FFI path.
1305#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1306#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1307pub struct PeerStatusSnapshot {
1308 /// Whether a peer network is currently active.
1309 pub running: bool,
1310 /// This node's `peer_id` (64-hex), if a peer network is running.
1311 #[serde(skip_serializing_if = "Option::is_none", default)]
1312 pub peer_id: Option<HexId>,
1313 /// The DIG network id.
1314 pub network_id: String,
1315 /// The relay reservation posture.
1316 pub relay: RelayStatus,
1317 /// The number of currently connected peers.
1318 pub connected_peers: u64,
1319 /// The last peer-network error, if any.
1320 #[serde(skip_serializing_if = "Option::is_none", default)]
1321 pub last_error: Option<String>,
1322}
1323
1324// ===========================================================================
1325// cache.stats (CONTROL — loopback / in-process only)
1326// ===========================================================================
1327
1328/// The decoded-content cache hit/miss counters carried in
1329/// [`CacheStats`](CacheStats::content_cache).
1330#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1331#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1332pub struct ContentCacheCounters {
1333 /// Session decoded-content cache hits.
1334 pub hits: u64,
1335 /// Session decoded-content cache misses.
1336 pub misses: u64,
1337}
1338
1339/// Result for [`cache.stats`](crate::method::Method::CacheStats) — cache
1340/// telemetry beside [`cache.getConfig`](crate::method::Method::CacheGetConfig):
1341/// the reserved cap + live usage, the cached-capsule count + total on-disk
1342/// bytes, and the session eviction + content-cache counters.
1343#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1344#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1345pub struct CacheStats {
1346 /// The on-disk cache size cap in bytes.
1347 pub cap_bytes: u64,
1348 /// The bytes currently used on disk.
1349 pub used_bytes: u64,
1350 /// The number of durable cached capsules.
1351 pub entry_count: u64,
1352 /// The total on-disk bytes across the cached capsules.
1353 pub total_bytes: u64,
1354 /// Capsules evicted this session.
1355 pub evicted_count: u64,
1356 /// Bytes evicted this session.
1357 pub evicted_bytes: u64,
1358 /// The decoded-content cache hit/miss counters.
1359 pub content_cache: ContentCacheCounters,
1360}
1361
1362// ===========================================================================
1363// control.subscribe / control.unsubscribe / control.listSubscriptions
1364// (CONTROL — loopback / in-process only)
1365// ===========================================================================
1366
1367/// Params for [`control.subscribe`](crate::method::Method::ControlSubscribe) and
1368/// [`control.unsubscribe`](crate::method::Method::ControlUnsubscribe).
1369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1370#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1371pub struct SubscribeParams {
1372 /// The store launcher id to (un)subscribe (64-hex).
1373 pub store_id: HexId,
1374}
1375
1376/// Result for [`control.subscribe`](crate::method::Method::ControlSubscribe).
1377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1378#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1379pub struct SubscribeResult {
1380 /// Always `true` — the store is subscribed after this call.
1381 pub subscribed: bool,
1382 /// Whether this call ADDED the subscription (`false` ⇒ already subscribed).
1383 pub added: bool,
1384 /// The canonical persisted store id (trimmed + lower-cased, 64-hex).
1385 pub store_id: HexId,
1386}
1387
1388/// Result for [`control.unsubscribe`](crate::method::Method::ControlUnsubscribe).
1389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1390#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1391pub struct UnsubscribeResult {
1392 /// Always `false` — the store is not subscribed after this call.
1393 pub subscribed: bool,
1394 /// Whether this call REMOVED a subscription (`false` ⇒ was not subscribed).
1395 pub removed: bool,
1396 /// The canonical persisted store id (trimmed + lower-cased, 64-hex).
1397 pub store_id: HexId,
1398}
1399
1400/// Result for
1401/// [`control.listSubscriptions`](crate::method::Method::ControlListSubscriptions).
1402#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1403#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1404pub struct SubscriptionsList {
1405 /// The persisted subscribed store ids (64-hex each).
1406 pub subscriptions: Vec<HexId>,
1407 /// The subscription count (`subscriptions.len()`).
1408 pub count: u64,
1409}
1410
1411// ===========================================================================
1412// control.peers.connect / control.peers.disconnect
1413// (CONTROL — loopback / in-process only)
1414// ===========================================================================
1415
1416/// Params for [`control.peers.connect`](crate::method::Method::ControlPeersConnect)
1417/// and [`control.peers.disconnect`](crate::method::Method::ControlPeersDisconnect).
1418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1419#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1420pub struct PeerConnectParams {
1421 /// The peer to dial/drop — a dialable address, or a known peer's `peer_id`
1422 /// (64-hex) to resolve an already-connected peer.
1423 pub peer: String,
1424}
1425
1426/// Result for
1427/// [`control.peers.connect`](crate::method::Method::ControlPeersConnect).
1428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1429#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1430pub struct PeerConnectResult {
1431 /// Always `true` on success — the peer is a counted, connected pool member.
1432 pub connected: bool,
1433 /// The connected peer's stable `peer_id` (64-hex).
1434 pub peer_id: HexId,
1435}
1436
1437/// Result for
1438/// [`control.peers.disconnect`](crate::method::Method::ControlPeersDisconnect).
1439///
1440/// Idempotent: disconnecting a peer that is not connected succeeds as a no-op.
1441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1442#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1443pub struct PeerDisconnectResult {
1444 /// Always `true` — the peer is not in the pool after this call.
1445 pub disconnected: bool,
1446 /// The dropped peer's `peer_id` (trimmed + lower-cased, 64-hex).
1447 pub peer_id: HexId,
1448}
1449
1450// ===========================================================================
1451// dig.listRewardDistributors / dig.getRewardProverStatus / dig.getRewardDistributor
1452// (CONTROL — loopback / in-process only; dig-rewards-coin SPEC.md §2.3 / §2.6)
1453// ===========================================================================
1454
1455/// The always-on reward prover loop's state — SPEC §2.3, the closed set.
1456///
1457/// `#[non_exhaustive]`-equivalent by convention rather than attribute (the SPEC
1458/// pins this to an exact nine-member set; a variant needs a SPEC amendment, not
1459/// a semver-additive appendix). Deserialization is fail-closed: no
1460/// `#[serde(other)]` catch-all and no `Default` impl, so an unknown wire string
1461/// (a newer node, a typo) is a hard parse error rather than a silently-coerced
1462/// state.
1463#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1464#[serde(rename_all = "camelCase")]
1465#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1466pub enum ProverState {
1467 /// No distributor assigned; the loop is parked.
1468 Idle,
1469 /// A cycle is in progress.
1470 Running,
1471 /// The local capsule/store copy this cycle needs is missing.
1472 LocalCopyMissing,
1473 /// The chain source (full node / peer) is unreachable this cycle.
1474 ChainSourceUnavailable,
1475 /// The distributor is unfunded — no reserve to pay a cycle out of.
1476 Unfunded,
1477 /// The fee budget for entry-set writes is exhausted for this cycle.
1478 FeeBudgetExhausted,
1479 /// The entry set is at capacity; no further entries can be added.
1480 EntrySetFull,
1481 /// Paused by an operator action.
1482 Paused,
1483 /// Stopped; the loop will not run again without an explicit restart.
1484 Stopped,
1485}
1486
1487/// The reward prover loop's running counters — SPEC §2.3.
1488#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1489#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1490pub struct ProverCounters {
1491 /// Distinct mirrors observed across all cycles.
1492 pub mirrors_seen: u64,
1493 /// Ranged capsule challenges issued.
1494 pub challenges_issued: u64,
1495 /// Challenges that passed verification.
1496 pub challenges_passed: u64,
1497 /// Challenges that failed verification.
1498 pub challenges_failed: u64,
1499 /// Entry-set entries added.
1500 pub entries_added: u64,
1501 /// Entry-set entries removed.
1502 pub entries_removed: u64,
1503 /// The current entry-set size.
1504 pub entry_count: u64,
1505 /// The distributor's reserve, in base units.
1506 pub reserve_base_units: u64,
1507 /// Total paid out over the loop's lifetime, in base units.
1508 pub total_paid_out_base_units: u64,
1509}
1510
1511/// One distributor's prover-loop status — SPEC §2.3 / §2.4.
1512///
1513/// # No health boolean, no pre-computed staleness
1514///
1515/// This type carries no `healthy`/`ok`/`up`/`running`/`stale` field and no
1516/// `seconds_since_last_run`. SPEC §2.4: a wedged loop cannot report its own
1517/// wedging — a boolean the writer sets on every successful cycle reads `true`
1518/// forever after exactly the failure it exists to reveal, because the write
1519/// that would flip it never runs. The reader derives staleness itself from
1520/// [`last_cycle_completed_at`](Self::last_cycle_completed_at) /
1521/// [`next_cycle_due_at`](Self::next_cycle_due_at) against
1522/// [`observed_at`](Self::observed_at) and its own clock. Contrast
1523/// [`GetRewardDistributorResult::entry_set_stale`], which IS a boolean — it is
1524/// permitted there because it is computed from the singleton's on-chain spend
1525/// history by the responder at read time, not self-reported by the writer this
1526/// type describes.
1527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1528#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1529pub struct RewardProverStatus {
1530 /// The distributor singleton's launcher id (64-hex).
1531 pub launcher_id: HexId,
1532 /// The backing store's launcher id (64-hex).
1533 pub store_id: HexId,
1534 /// The store's current generation root (64-hex).
1535 pub root: HexId,
1536 /// The loop's current state.
1537 pub prover_state: ProverState,
1538 /// Unix seconds the loop entered `prover_state`.
1539 pub prover_state_since: u64,
1540 /// Unix seconds the current/most recent cycle started, if any has run.
1541 pub last_cycle_started_at: Option<u64>,
1542 /// Unix seconds the most recent cycle completed, if any has completed.
1543 pub last_cycle_completed_at: Option<u64>,
1544 /// Unix seconds the next cycle is scheduled, if the loop is scheduling one.
1545 pub next_cycle_due_at: Option<u64>,
1546 /// Unix seconds of the most recent entry-set write, if any.
1547 pub last_entry_write_at: Option<u64>,
1548 /// Consecutive cycle failures (resets to 0 on a completed cycle).
1549 pub consecutive_cycle_failures: u32,
1550 /// Entry writes queued but not yet committed.
1551 pub pending_entry_writes: u32,
1552 /// Unix seconds this status was assembled (the reader's staleness anchor).
1553 pub observed_at: u64,
1554 /// The loop's running counters.
1555 pub counters: ProverCounters,
1556}
1557
1558/// Params for
1559/// [`dig.getRewardProverStatus`](crate::method::Method::GetRewardProverStatus).
1560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1561#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1562pub struct GetRewardProverStatusParams {
1563 /// Restrict to one distributor's launcher id (64-hex). Absent ⇒ every
1564 /// distributor this node runs a prover loop for.
1565 #[serde(skip_serializing_if = "Option::is_none", default)]
1566 pub launcher_id: Option<HexId>,
1567}
1568
1569/// Result for
1570/// [`dig.getRewardProverStatus`](crate::method::Method::GetRewardProverStatus).
1571#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1572#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1573pub struct GetRewardProverStatusResult {
1574 /// One entry per prover loop this node runs, in no particular order — or
1575 /// the statement that the prover registry was never consulted.
1576 ///
1577 /// This is a [`Half`] for the same reason as
1578 /// [`ListRewardDistributorsResult`]'s two halves: a bare empty vector cannot
1579 /// tell an operator "this node runs no prover loops" apart from "I could not
1580 /// read the registry", and on a reward surface the second reads as the first
1581 /// while meaning the opposite. It describes the responder's own consultation
1582 /// of its prover registry, never a chain fact about any one distributor.
1583 pub statuses: Half<RewardProverStatus>,
1584}
1585
1586/// A minimal distributor reference — SPEC §2.6.
1587#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1588#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1589pub struct RewardDistributorRef {
1590 /// The distributor singleton's launcher id (64-hex).
1591 pub launcher_id: HexId,
1592 /// The backing store's launcher id (64-hex).
1593 pub store_id: HexId,
1594 /// The store's current generation root (64-hex).
1595 pub root: HexId,
1596}
1597
1598/// One collection a responder either consulted — and can therefore answer for —
1599/// or did not: dig-rewards-coin SPEC §12.5 clause 6.
1600///
1601/// # Why the items live INSIDE the variant
1602///
1603/// Clause 6: "the absence MUST be surfaced, not swallowed", and it "MUST be
1604/// dated by an `observed_at` and MUST NOT be presented as a bare zero". A bare
1605/// `claimable: []` cannot tell an operator apart "I looked and I hold no mirror
1606/// claims" from "nothing looked" — a funder-only responder emitting the empty
1607/// vector reads as the former while meaning the latter.
1608///
1609/// An observation carried *beside* its collection would leave
1610/// `{"outcome": "not_consulted", "items": [seven things]}` expressible, forbidden
1611/// only by prose. Folding the items into
1612/// [`Consulted`](Self::Consulted) makes that contradiction unconstructible in
1613/// Rust and unparseable on the wire: the arm that says nothing looked has no
1614/// field to put items in, and the arm that carries items has already said it
1615/// looked. A partially consulted half, if this crate ever needs one, is a third
1616/// arm rather than a new convention layered over the same two fields.
1617///
1618/// # What it deliberately cannot say
1619///
1620/// It describes the **responder's own consultation of the collection**, never a
1621/// chain fact about any one member. Clause 7 forbids a consumer reconstructing
1622/// "never admitted" from "evicted after settlement", and there is no arm, reason
1623/// code or per-member record here that could carry that split: after a
1624/// `Consulted` read, a distributor the peer was never admitted to and one it was
1625/// evicted from are both simply absent from `items`, exactly as before.
1626///
1627/// This does **not** discharge clause 6's *per-member* dated absence, which
1628/// needs a field this wire does not yet carry. What it closes is the
1629/// per-collection consultation record.
1630///
1631/// # Relation to `absence_established`
1632///
1633/// [`AvailabilityAnswer::absence_established`] is this crate's earlier, weaker
1634/// expression of the same idea: a marker *beside* the data, so "absence not
1635/// established, and here are seven items" stays representable and is forbidden
1636/// only by prose. `Half` is the intended direction for new shapes.
1637/// `AvailabilityAnswer` keeps its form because it has shipped consumers; it is
1638/// not a second pattern to copy.
1639///
1640/// # Fail-closed
1641///
1642/// Matching [`ProverState`]: internally tagged on `outcome`, no
1643/// `#[serde(other)]`, no `Default`, no `skip_serializing_if`, and `observed_at`
1644/// required in **every** arm. An unknown or missing outcome, or a missing date,
1645/// is a hard parse error rather than a silently coerced "consulted".
1646///
1647/// ```
1648/// use dig_rpc_protocol::types::Half;
1649///
1650/// let looked: Half<u32> = Half::Consulted { observed_at: 1_700, items: vec![] };
1651/// let did_not: Half<u32> = Half::NotConsulted { observed_at: 1_700 };
1652/// assert_eq!(looked.items(), Some(&[][..]));
1653/// assert_eq!(did_not.items(), None);
1654/// assert_eq!(did_not.observed_at(), 1_700);
1655/// ```
1656#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1657#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
1658#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1659pub enum Half<T> {
1660 /// The collection WAS consulted, and `items` is its complete answer as of
1661 /// `observed_at` (Unix seconds). An empty `items` here means "none", and the
1662 /// reader derives staleness itself from `observed_at`.
1663 Consulted {
1664 /// Unix seconds the collection was read.
1665 observed_at: u64,
1666 /// The complete answer as of `observed_at`; empty means "none".
1667 items: Vec<T>,
1668 },
1669 /// The collection was NOT consulted as of `observed_at` (Unix seconds):
1670 /// nothing looked, so there is no answer here to read as "none".
1671 NotConsulted {
1672 /// Unix seconds this answer was assembled without consulting the
1673 /// collection.
1674 observed_at: u64,
1675 },
1676}
1677
1678impl<T> Half<T> {
1679 /// The Unix seconds this half was observed — present in both arms, so a
1680 /// reader always has a staleness anchor.
1681 pub fn observed_at(&self) -> u64 {
1682 match self {
1683 Half::Consulted { observed_at, .. } | Half::NotConsulted { observed_at } => {
1684 *observed_at
1685 }
1686 }
1687 }
1688
1689 /// The consulted answer, or `None` when nothing looked.
1690 ///
1691 /// `Some(&[])` means "consulted, and there are none"; `None` means "not
1692 /// consulted" — the distinction a bare `Vec` cannot make.
1693 pub fn items(&self) -> Option<&[T]> {
1694 match self {
1695 Half::Consulted { items, .. } => Some(items),
1696 Half::NotConsulted { .. } => None,
1697 }
1698 }
1699}
1700
1701/// Result for
1702/// [`dig.listRewardDistributors`](crate::method::Method::ListRewardDistributors)
1703/// — SPEC §2.6: "the distributors this node funds, and the distributors this
1704/// node has a claim to as a mirror".
1705///
1706/// # No `Default`, by design
1707///
1708/// `Default` is not derived here and MUST NOT be re-added. A default could only
1709/// be the empty, consulted-looking answer — the exact ambiguity [`Half`] exists
1710/// to remove (SPEC §12.5 clause 6). Every producer must state, per half, whether
1711/// it consulted that half.
1712#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1713#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1714pub struct ListRewardDistributorsResult {
1715 /// Distributors this node funds (the reserve is this node's), or the
1716 /// statement that the funded half was never consulted.
1717 ///
1718 /// A responder whose funded set is unconfigured, unreadable, or failed to
1719 /// read must say so here rather than emit an empty list, which would read as
1720 /// "this node funds nothing" — a claim about the operator's own money.
1721 pub funded: Half<RewardDistributorRef>,
1722 /// Distributors this node has a claim to as a mirror but does not fund, or
1723 /// the statement that the claimable half was never consulted.
1724 pub claimable: Half<RewardDistributorRef>,
1725}
1726
1727/// Params for
1728/// [`dig.getRewardDistributor`](crate::method::Method::GetRewardDistributor).
1729#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1730#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1731pub struct GetRewardDistributorParams {
1732 /// The distributor singleton's launcher id (64-hex, required).
1733 pub launcher_id: HexId,
1734}
1735
1736/// Result for
1737/// [`dig.getRewardDistributor`](crate::method::Method::GetRewardDistributor) —
1738/// SPEC §2.6, third method: chain-derived distributor state only, never the
1739/// local prover loop's own state (see [`RewardProverStatus`] for that).
1740///
1741/// # `entry_set_stale` lives HERE, never on `RewardProverStatus`
1742///
1743/// This is the one boolean in the reward-distributor surface, and it belongs
1744/// here specifically: per SPEC §12.4 it is computed by the responder from the
1745/// distributor singleton's on-chain spend history at read time, not
1746/// self-reported by a possibly-wedged writer. Copying it onto
1747/// [`RewardProverStatus`] would reintroduce exactly the self-reported-health
1748/// failure that type's doc comment forbids — see that type for the full
1749/// argument.
1750#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1751#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1752pub struct GetRewardDistributorResult {
1753 /// The distributor singleton's launcher id (64-hex).
1754 pub launcher_id: HexId,
1755 /// The backing store's launcher id (64-hex).
1756 pub store_id: HexId,
1757 /// The store's current generation root (64-hex).
1758 pub root: HexId,
1759 /// The payout epoch length, in seconds.
1760 pub epoch_seconds: u64,
1761 /// Unix seconds the first epoch started.
1762 pub first_epoch_start: u64,
1763 /// The reserve threshold, in base units, that triggers a payout.
1764 pub payout_threshold: u64,
1765 /// The distributor's fee, in basis points.
1766 pub fee_bps: u16,
1767 /// The share of a clawed-back commitment the committer recovers, in basis
1768 /// points — SPEC §7.5. `withdrawal_share_bps = 9000` means a clawback
1769 /// returns 90% of the committed value; the remaining 10% is forfeited to
1770 /// the reserve as a deterrent against the funder (SPEC §7.5 clause 1: it
1771 /// is priced correctly and is **not** compensation to induced mirrors).
1772 /// Curried at launch and immutable, same as [`Self::fee_bps`] beside it.
1773 pub withdrawal_share_bps: u16,
1774 /// The current reserve, in base units.
1775 pub reserve_base_units: u64,
1776 /// The current entry-set size.
1777 pub entry_count: u64,
1778 /// The current epoch index (`0`-based from `first_epoch_start`).
1779 pub current_distributor_epoch: u64,
1780 /// Unix seconds of the most recent entry-set write on chain, if any.
1781 /// `None` together with a non-zero `reserve_base_units` **implies
1782 /// stale**: an entry set that has never been written is maximally
1783 /// stale, not unknown, and a consumer MUST NOT render it as blank or
1784 /// "unknown" (SPEC §2.4 cl. 1 — silence is not an acceptable
1785 /// representation of "not distributing").
1786 pub last_entry_write_at: Option<u64>,
1787 /// `true` when the entry set has not changed in
1788 /// `STALE_ENTRY_SET_SECONDS = 172_800` (48 h) **while the reserve is
1789 /// non-zero** — SPEC §12.4. A drained distributor with a frozen entry set
1790 /// is not stale, it is [`Unfunded`](crate::types::ProverState::Unfunded);
1791 /// the non-zero-reserve conjunct exists to keep the two states distinct.
1792 /// The responder computes this at read time from the singleton's own
1793 /// on-chain spend history — it is not self-reported, so a wedged prover
1794 /// cannot fake it. See the type doc for why this boolean is safe here and
1795 /// forbidden on [`RewardProverStatus`].
1796 pub entry_set_stale: bool,
1797 /// Unix seconds this result was assembled.
1798 pub observed_at: u64,
1799}
1800
1801/// Params for
1802/// [`dig.listRewardDistributorCommitments`](crate::method::Method::ListRewardDistributorCommitments).
1803#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1804#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1805pub struct ListRewardDistributorCommitmentsParams {
1806 /// The distributor singleton's launcher id (64-hex, required).
1807 pub launcher_id: HexId,
1808}
1809
1810/// One clawback commitment slot for a distributor epoch — SPEC §7.4 clause 5.
1811///
1812/// # `recoverable_base_units` is NOT `rewards_base_units`
1813///
1814/// Committed is not recoverable: SPEC §7.4 clause 4 / §7.5 return only
1815/// `withdrawal_share_bps / 10000` of the committed value on clawback (the
1816/// remainder is forfeited to the reserve — see
1817/// [`GetRewardDistributorResult::withdrawal_share_bps`]). Reporting only
1818/// `rewards_base_units` and letting a caller label it "recoverable" would
1819/// overstate every clawback by the forfeit fraction — exactly the
1820/// one-balance-figure money-honesty failure SPEC §7.4 clause 5 forbids,
1821/// relocated from a single total into a single per-slot figure. So the
1822/// **responder** must compute `recoverable_base_units` itself, with integer
1823/// arithmetic in the order `rewards_base_units * withdrawal_share_bps /
1824/// 10_000` — multiply then divide, no floats, truncated (never rounded up:
1825/// rounding up would promise money the chain will not return). This type
1826/// does not enforce that computation — see below.
1827///
1828/// Only the holder of the key for `clawback_puzzle_hash` may claw this slot
1829/// back — not an operator role, not the manager singleton, and not the
1830/// launcher (SPEC §7.4 clause 3). This field is the proof of entitlement; a
1831/// reader must not mistake it for a display label.
1832///
1833/// This type does not enforce any of the above: `recoverable_base_units` is
1834/// a bare `pub u64` with no constructor or validation. The **responder**
1835/// must compute it in the order described; nothing here checks that it did.
1836#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1837#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1838pub struct RewardDistributorCommitment {
1839 /// The epoch this commitment slot funds.
1840 pub epoch_start: u64,
1841 /// The chain's `clawback_ph` (SPEC / `chia-sdk-types`
1842 /// `RewardDistributorCommitmentSlotValue`): the puzzle hash whose key
1843 /// holder alone may claw this slot back.
1844 pub clawback_puzzle_hash: HexId,
1845 /// The committed amount, in base units.
1846 pub rewards_base_units: u64,
1847 /// The amount actually recoverable on clawback, in base units —
1848 /// `rewards_base_units * withdrawal_share_bps / 10_000`, integer
1849 /// arithmetic, truncated down. See the type doc for why this must never
1850 /// be derived by a caller from `rewards_base_units` alone.
1851 ///
1852 /// This is share arithmetic only. It is **NOT an eligibility claim**: it
1853 /// says what fraction of the slot would return, not that the caller may
1854 /// claw it back. Entitlement is key-holding against
1855 /// `clawback_puzzle_hash` and nothing else (SPEC §7.4 cl. 3).
1856 pub recoverable_base_units: u64,
1857}
1858
1859/// Result for
1860/// [`dig.listRewardDistributorCommitments`](crate::method::Method::ListRewardDistributorCommitments)
1861/// — SPEC §7.4 clause 5: per-epoch commitment slots, never a single balance
1862/// figure.
1863#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1864#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1865pub struct ListRewardDistributorCommitmentsResult {
1866 /// The distributor singleton's launcher id (64-hex).
1867 pub launcher_id: HexId,
1868 /// The curried launch constant, echoed so a caller's row math (dividing
1869 /// `recoverable_base_units` by `rewards_base_units`) is auditable against
1870 /// the value that actually governs it. A responder MUST use this echoed
1871 /// value — never a compiled-in constant — when computing each row's
1872 /// `recoverable_base_units`, because the share is a launch-curried,
1873 /// per-distributor value that differs across distributors. A conforming
1874 /// responder MUST NOT emit a value above `10_000`.
1875 pub withdrawal_share_bps: u16,
1876 /// The payout epoch length, in seconds — a launch-curried, immutable
1877 /// distributor constant, echoed here so a caller can compute an epoch's
1878 /// end (`epoch_start + epoch_seconds`) or place a commitment on a
1879 /// calendar without a second `dig.getRewardDistributor` call. This is
1880 /// per-distributor and defaulted, not a fixed 7-day value, so it must
1881 /// not be hardcoded.
1882 pub epoch_seconds: u64,
1883 /// One entry per commitment slot. Empty is legitimate: a distributor
1884 /// funded only via `AddIncentives` has no clawback-eligible slots at all
1885 /// — an irrevocable donation, not an error.
1886 pub commitments: Vec<RewardDistributorCommitment>,
1887 /// Unix seconds this result was assembled.
1888 pub observed_at: u64,
1889}
1890
1891// ===========================================================================
1892// dig.getPayeeRewardClaimStatus
1893// (CONTROL — loopback / in-process only; dig-rewards-coin SPEC §12.5 clause 6,
1894// §2.4)
1895// ===========================================================================
1896
1897/// The subject of a **payee-side** status answer.
1898///
1899/// One variant on purpose, and named for that one variant on purpose. A payee's
1900/// claim-side posture and a funder's distributor-side posture are different
1901/// answers with different money behind them, and this crate keeps them in
1902/// different types rather than in two arms of one enum — so a payee-shaped
1903/// result structurally cannot be built carrying a funder's subject, whatever a
1904/// responder gets wrong. A funder answer, if this crate ever grows one, gets its
1905/// own type and its own literal.
1906///
1907/// The name carries that rule. A `RewardSubject` invites a `Funder` arm, and the
1908/// moment it has one the type guarantees nothing; `PayeeSubject` cannot absorb a
1909/// funder arm without a rename obviously wrong at the call site, which is the
1910/// point.
1911#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1912#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1913pub enum PayeeSubject {
1914 /// The node answering is speaking as a **payee** — a mirror with a claim,
1915 /// not the party that funded the reserve.
1916 #[serde(rename = "payee")]
1917 Payee,
1918}
1919
1920/// What a node can say about its own claim log: it read the log and has a count,
1921/// or it did not read the log and has no count to give.
1922///
1923/// # Why the count is inside the variant
1924///
1925/// The count and the fact that anything was counted cannot be separated, for the
1926/// same reason [`Half`]'s items live inside `Consulted`. A node whose claim log
1927/// is missing, unreadable, or failed to read has nothing to report, and a
1928/// `claims_submitted_count: 0` beside a freshly stamped `observed_at` is worse
1929/// than an undated zero: the date actively reassures the reader that something
1930/// looked at the log just now and found nothing, which is dig-rewards-coin SPEC
1931/// §12.5 clause 6's "reassuring zero" reproduced inside the type added to remove
1932/// it. There is no arm here that can carry a count without having read the log.
1933///
1934/// This is a sibling of [`Half`] rather than an instance of it: `Half` answers
1935/// for a *collection* and carries `items`, and forcing a scalar tally through it
1936/// would mean either a vacuous `Vec` or a wire key that names the wrong thing.
1937/// The vocabulary — `outcome`, two arms, `observed_at` in both — is deliberately
1938/// identical, so a reader who has learned one has learned the other.
1939///
1940/// `observed_at` dates the **consultation**, not the assembly of the answer:
1941/// in [`Consulted`](Self::Consulted) it is when the log was read, and in
1942/// [`NotConsulted`](Self::NotConsulted) it is when the responder established
1943/// that it could not read it. Fail-closed like [`Half`]: internally tagged, no
1944/// `#[serde(other)]`, no `Default`, no `skip_serializing_if`.
1945#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1946#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
1947#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1948pub enum ClaimLogObservation {
1949 /// The claim log WAS read as of `observed_at` (Unix seconds), and
1950 /// `claims_submitted_count` is what it held.
1951 Consulted {
1952 /// Unix seconds the claim log was read.
1953 observed_at: u64,
1954 /// How many claim submissions this node has made as a payee. A count of
1955 /// attempts, not of money: it says nothing about how much was settled,
1956 /// and a reader MUST NOT treat it as an amount or as a success count.
1957 claims_submitted_count: u64,
1958 },
1959 /// The claim log was NOT read as of `observed_at` (Unix seconds) — missing,
1960 /// unreadable, or the read failed. There is deliberately no count here.
1961 NotConsulted {
1962 /// Unix seconds the responder established it could not read the log.
1963 observed_at: u64,
1964 },
1965}
1966
1967/// Result for
1968/// [`dig.getPayeeRewardClaimStatus`](crate::method::Method::GetPayeeRewardClaimStatus)
1969/// — the claim-side posture of the node answering, as a payee.
1970///
1971/// # The subject is on the wire, not inferred from the endpoint
1972///
1973/// [`subject`](Self::subject) is required and is the literal `"payee"`. It is
1974/// not redundant with the method name: this epic shipped a defect in which a
1975/// **funder's** distributor-wide total was rendered to a **payee** as that
1976/// operator's own earnings, overstating by up to 250x, and it passed security
1977/// review and a full green CI because no test asserted *whose* money the number
1978/// was. A renderer that reads `subject` off the payload cannot make that
1979/// substitution silently; one that infers the subject from which endpoint it
1980/// thinks it called can.
1981///
1982/// # No monetary amount, at all
1983///
1984/// There is deliberately no amount field here — not optional, not nullable,
1985/// absent. A payload that carries no amount has nothing a UI can misrender as
1986/// earnings, and that absence is the whole defence; an `Option<u64>` would not
1987/// be, because the misrendering path is a present number attributed to the wrong
1988/// party. A payee that wants its own settled history reads its own past
1989/// `InitiatePayout` spends, which are on chain and are evidence (SPEC §12.5
1990/// clause 7). The payout puzzle hash is likewise absent and MUST NOT be added:
1991/// it is the payee's payment identity, and nothing here needs it.
1992///
1993/// # No `#[serde(default)]`, no `skip_serializing_if`
1994///
1995/// Every field is required on the wire in both directions. A defaulting field is
1996/// a field a producer can omit and a consumer will invent — which for
1997/// [`subject`](Self::subject) would restore exactly the inferred-subject hazard
1998/// above, and for [`claim_log`](Self::claim_log) would manufacture a
1999/// consultation nobody performed.
2000#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2001#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2002pub struct PayeeClaimStatus {
2003 /// Always [`PayeeSubject::Payee`]; serialises as the literal
2004 /// `"subject": "payee"`.
2005 pub subject: PayeeSubject,
2006 /// This node's claim log: read, with the count it held, or not read at all.
2007 ///
2008 /// The count lives inside the observation so it cannot be separated from the
2009 /// fact that something read the log. The reader derives staleness itself
2010 /// from the observation's `observed_at` and its own clock (SPEC §2.4); no
2011 /// staleness is pre-computed here.
2012 pub claim_log: ClaimLogObservation,
2013}
2014
2015// ===========================================================================
2016// dig.health / dig.methods / rpc.discover (discovery)
2017// ===========================================================================
2018
2019/// Result for [`dig.health`](crate::method::Method::Health) — liveness + a
2020/// capability summary.
2021#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2022#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2023pub struct Health {
2024 /// Liveness — `"ok"` when the node can serve.
2025 pub status: String,
2026 /// The node's software version.
2027 #[serde(skip_serializing_if = "Option::is_none", default)]
2028 pub version: Option<String>,
2029 /// The DIG network id the node serves.
2030 #[serde(skip_serializing_if = "Option::is_none", default)]
2031 pub network_id: Option<String>,
2032 /// The method names this node implements (its profile).
2033 #[serde(default)]
2034 pub methods: Vec<String>,
2035}
2036
2037/// Result for [`dig.methods`](crate::method::Method::Methods) — the method names
2038/// this node implements (agent self-describe).
2039#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2040#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2041pub struct Methods {
2042 /// The implemented method names.
2043 pub methods: Vec<String>,
2044}
2045
2046#[cfg(test)]
2047mod tests {
2048 use super::*;
2049 use serde_json::json;
2050
2051 /// The JSON object keys of `value`, sorted — so a key-set assertion reads as
2052 /// one line and fails naming the field that appeared or vanished.
2053 fn sorted_keys(value: &serde_json::Value) -> Vec<&str> {
2054 let mut keys: Vec<&str> = value
2055 .as_object()
2056 .expect("expected a JSON object")
2057 .keys()
2058 .map(String::as_str)
2059 .collect();
2060 keys.sort_unstable();
2061 keys
2062 }
2063
2064 /// **Proves:** `ContentChunk` round-trips a node-profile window (no
2065 /// network-profile fields) without inventing keys.
2066 /// **Catches:** a missing `skip_serializing_if` that would leak `null`
2067 /// network-profile fields onto the node profile.
2068 #[test]
2069 fn content_chunk_node_profile_is_lean() {
2070 let c = ContentChunk {
2071 ciphertext: "AAA=".into(),
2072 root: "ab".repeat(32),
2073 complete: false,
2074 next_offset: Some(3_145_728),
2075 inclusion_proof: Some("cHJvb2Y=".into()),
2076 chunk_lens: Some(vec![10, 20]),
2077 source: Some("local".into()),
2078 total_length: None,
2079 length: None,
2080 offset: None,
2081 program_hash: None,
2082 };
2083 let v = serde_json::to_value(&c).unwrap();
2084 assert_eq!(v["source"], "local");
2085 assert!(
2086 v.get("total_length").is_none(),
2087 "node profile must omit total_length"
2088 );
2089 assert!(v.get("program_hash").is_none());
2090 assert_eq!(serde_json::from_value::<ContentChunk>(v).unwrap(), c);
2091 }
2092
2093 /// **Proves:** the network-profile fields serialize when present.
2094 #[test]
2095 fn content_chunk_network_profile_carries_extras() {
2096 let c = ContentChunk {
2097 ciphertext: "AAA=".into(),
2098 root: "cd".repeat(32),
2099 complete: true,
2100 next_offset: None,
2101 inclusion_proof: None,
2102 chunk_lens: None,
2103 source: None,
2104 total_length: Some(100),
2105 length: Some(100),
2106 offset: Some(0),
2107 program_hash: Some("ef".repeat(32)),
2108 };
2109 let v = serde_json::to_value(&c).unwrap();
2110 assert_eq!(v["total_length"], 100);
2111 assert_eq!(v["length"], 100);
2112 assert!(v.get("source").is_none());
2113 }
2114
2115 /// **Proves:** the untagged `Inventory` picks `ForStore` vs `AllStores` by
2116 /// shape.
2117 /// **Catches:** a lost `#[serde(untagged)]` that would tag the variant.
2118 #[test]
2119 fn inventory_untagged_by_shape() {
2120 let for_store = Inventory::ForStore {
2121 store_id: "ab".repeat(32),
2122 roots: vec!["cd".repeat(32)],
2123 };
2124 let s = serde_json::to_string(&for_store).unwrap();
2125 assert!(s.contains("\"roots\""));
2126 assert!(!s.contains("ForStore"));
2127 assert_eq!(serde_json::from_str::<Inventory>(&s).unwrap(), for_store);
2128
2129 let all = Inventory::AllStores {
2130 stores: vec!["ef".repeat(32)],
2131 };
2132 let s = serde_json::to_string(&all).unwrap();
2133 assert!(s.contains("\"stores\""));
2134 assert_eq!(serde_json::from_str::<Inventory>(&s).unwrap(), all);
2135 }
2136
2137 /// **Proves:** `RedirectInfo` serializes the full redirect payload the
2138 /// `-32008` envelope carries.
2139 #[test]
2140 fn redirect_info_shape() {
2141 let r = RedirectInfo {
2142 content: ContentRef {
2143 store_id: "ab".repeat(32),
2144 root: Some("cd".repeat(32)),
2145 retrieval_key: Some("ef".repeat(32)),
2146 },
2147 providers: vec![Provider {
2148 peer_id: "12".repeat(32),
2149 addresses: vec![PeerAddress {
2150 host: "::1".into(),
2151 port: 9444,
2152 kind: "direct".into(),
2153 }],
2154 }],
2155 redirect_depth: 1,
2156 max_redirects: 4,
2157 };
2158 let v = serde_json::to_value(&r).unwrap();
2159 assert_eq!(v["redirect_depth"], 1);
2160 assert_eq!(v["max_redirects"], 4);
2161 assert_eq!(v["providers"][0]["addresses"][0]["host"], "::1");
2162 assert_eq!(serde_json::from_value::<RedirectInfo>(v).unwrap(), r);
2163 }
2164
2165 /// **Proves:** `cache.stats` models the live dig-node result field-for-field
2166 /// (the nested `content_cache{hits,misses}` object included).
2167 /// **Catches:** a drift from the node's `cache.stats` wire shape (#1075).
2168 #[test]
2169 fn cache_stats_wire_shape() {
2170 let s = CacheStats {
2171 cap_bytes: 1 << 30,
2172 used_bytes: 2048,
2173 entry_count: 3,
2174 total_bytes: 2048,
2175 evicted_count: 1,
2176 evicted_bytes: 512,
2177 content_cache: ContentCacheCounters { hits: 7, misses: 2 },
2178 };
2179 let v = serde_json::to_value(s).unwrap();
2180 assert_eq!(v["cap_bytes"], 1 << 30);
2181 assert_eq!(v["entry_count"], 3);
2182 assert_eq!(v["content_cache"]["hits"], 7);
2183 assert_eq!(v["content_cache"]["misses"], 2);
2184 assert_eq!(serde_json::from_value::<CacheStats>(v).unwrap(), s);
2185 }
2186
2187 /// **Proves:** the subscription-management results carry the exact
2188 /// `{subscribed, added|removed, store_id}` / `{subscriptions, count}` shapes
2189 /// the live node returns.
2190 #[test]
2191 fn subscription_result_shapes() {
2192 let sub = SubscribeResult {
2193 subscribed: true,
2194 added: true,
2195 store_id: "ab".repeat(32),
2196 };
2197 let v = serde_json::to_value(&sub).unwrap();
2198 assert_eq!(v["subscribed"], true);
2199 assert_eq!(v["added"], true);
2200 assert_eq!(serde_json::from_value::<SubscribeResult>(v).unwrap(), sub);
2201
2202 let unsub = UnsubscribeResult {
2203 subscribed: false,
2204 removed: true,
2205 store_id: "cd".repeat(32),
2206 };
2207 let v = serde_json::to_value(&unsub).unwrap();
2208 assert_eq!(v["subscribed"], false);
2209 assert_eq!(v["removed"], true);
2210 assert_eq!(
2211 serde_json::from_value::<UnsubscribeResult>(v).unwrap(),
2212 unsub
2213 );
2214
2215 let list = SubscriptionsList {
2216 subscriptions: vec!["ef".repeat(32)],
2217 count: 1,
2218 };
2219 let v = serde_json::to_value(&list).unwrap();
2220 assert_eq!(v["count"], 1);
2221 assert_eq!(
2222 serde_json::from_value::<SubscriptionsList>(v).unwrap(),
2223 list
2224 );
2225 }
2226
2227 /// **Proves:** `ModuleInfo` carries `chunk_lens` covering every chunk, and
2228 /// round-trips with unknown future fields.
2229 /// **Catches:** a missing `chunk_lens` field that would leave a puller unable
2230 /// to map a fetched byte range to its covering chunk hash.
2231 /// **Invariants enforced by docs:** `chunk_lens` must have the same length as
2232 /// `chunk_hashes` and must sum to `total_size`.
2233 #[test]
2234 fn module_info_chunk_lens_shape() {
2235 let info = ModuleInfo {
2236 total_size: 1024,
2237 module_hash: "ab".repeat(32),
2238 chunk_hashes: vec!["cd".repeat(32), "ef".repeat(32)],
2239 chunk_lens: vec![512, 512],
2240 };
2241 let v = serde_json::to_value(&info).unwrap();
2242 assert_eq!(v["total_size"], 1024);
2243 assert_eq!(v["chunk_hashes"].as_array().unwrap().len(), 2);
2244 assert_eq!(v["chunk_lens"].as_array().unwrap().len(), 2);
2245 assert_eq!(v["chunk_lens"][0], 512);
2246 assert_eq!(v["chunk_lens"][1], 512);
2247 assert_eq!(serde_json::from_value::<ModuleInfo>(v).unwrap(), info);
2248 }
2249
2250 /// **Proves:** `ModuleInfo` deserialization REJECTS missing `chunk_lens` field.
2251 /// This is a REQUIRED field (not optional) — omitting it from the wire is a
2252 /// protocol violation and must fail-closed.
2253 #[test]
2254 fn module_info_rejects_missing_chunk_lens() {
2255 let json_str = r#"{"total_size": 2048, "module_hash": "1122334455667788990011223344556677889900112233445566778899001122", "chunk_hashes": []}"#;
2256 let result: Result<ModuleInfo, _> = serde_json::from_str(json_str);
2257 assert!(
2258 result.is_err(),
2259 "ModuleInfo must reject JSON missing the required chunk_lens field"
2260 );
2261 let err = result.unwrap_err();
2262 assert!(
2263 err.to_string().contains("chunk_lens"),
2264 "error message should mention chunk_lens: {}",
2265 err
2266 );
2267 }
2268
2269 /// **Proves:** the peer connect/disconnect params + results round-trip and
2270 /// match the node's `{connected|disconnected, peer_id}` shapes.
2271 #[test]
2272 fn peer_connect_disconnect_shapes() {
2273 let p = PeerConnectParams {
2274 peer: "12".repeat(32),
2275 };
2276 let v = serde_json::to_value(&p).unwrap();
2277 assert_eq!(serde_json::from_value::<PeerConnectParams>(v).unwrap(), p);
2278
2279 let c = PeerConnectResult {
2280 connected: true,
2281 peer_id: "12".repeat(32),
2282 };
2283 let v = serde_json::to_value(&c).unwrap();
2284 assert_eq!(v["connected"], true);
2285 assert_eq!(serde_json::from_value::<PeerConnectResult>(v).unwrap(), c);
2286
2287 let d = PeerDisconnectResult {
2288 disconnected: true,
2289 peer_id: "34".repeat(32),
2290 };
2291 let v = serde_json::to_value(&d).unwrap();
2292 assert_eq!(v["disconnected"], true);
2293 assert_eq!(
2294 serde_json::from_value::<PeerDisconnectResult>(v).unwrap(),
2295 d
2296 );
2297 }
2298
2299 /// **Proves:** `cache.getConfig` uses the canonical `cache_dir` field name.
2300 /// **Catches:** a regression to the shell's historical `dir` name.
2301 #[test]
2302 fn cache_config_field_name_is_cache_dir() {
2303 let c = CacheConfig {
2304 cap_bytes: 1 << 30,
2305 used_bytes: 0,
2306 cache_dir: "/var/cache/dig".into(),
2307 shared: true,
2308 };
2309 let v = serde_json::to_value(&c).unwrap();
2310 assert!(v.get("cache_dir").is_some());
2311 assert!(v.get("dir").is_none(), "must not use the legacy `dir` name");
2312 }
2313
2314 /// **Proves:** an OLDER client's `dig.getAvailability` params — written
2315 /// before the hop budget existed — still deserialize, and read as a fresh,
2316 /// unhopped ask.
2317 /// **Catches:** a `redirect_depth` declared as a required `u64`, which
2318 /// rejects exactly these params with `missing field redirect_depth` and would
2319 /// make every pre-0.8 caller's ask a parse error at the peer boundary.
2320 /// **Guarded by:** the field's `Option` TYPE. `serde`'s derive already reads a
2321 /// missing `Option` field as `None`, so the `#[serde(default)]` beside it is
2322 /// parity with the sibling params types rather than the live guard — removing
2323 /// it alone leaves this test green (mutant-tested). Do not cite the attribute
2324 /// as the thing that keeps older clients working.
2325 #[test]
2326 fn get_availability_params_accepts_an_older_clients_params() {
2327 let older = json!({
2328 "items": [ { "store_id": "ab".repeat(32) } ]
2329 });
2330 let p: GetAvailabilityParams = serde_json::from_value(older).unwrap();
2331 assert_eq!(p.items.len(), 1);
2332 assert_eq!(p.redirect_depth, None, "an absent budget stays absent");
2333 assert_eq!(p.hops_consumed(), 0, "absent means zero hops consumed");
2334 }
2335
2336 /// **Proves:** a hop-zero ask serializes to exactly the pre-0.8 bytes — the
2337 /// `redirect_depth` key is absent, not `null`.
2338 /// **Catches:** a bare `#[serde(default)]` without `skip_serializing_if`,
2339 /// which would add `"redirect_depth": null` to every existing caller's
2340 /// frame and change the wire for callers that never opted in.
2341 #[test]
2342 fn get_availability_params_omits_an_absent_hop_budget() {
2343 let p = GetAvailabilityParams::new(vec![AvailabilityQuery {
2344 store_id: "ab".repeat(32),
2345 root: None,
2346 retrieval_key: None,
2347 }]);
2348 let v = serde_json::to_value(&p).unwrap();
2349 let keys: Vec<&String> = v.as_object().unwrap().keys().collect();
2350 assert_eq!(keys, vec!["items"], "hop-zero params carry only `items`");
2351 }
2352
2353 /// **Proves:** a hopped ask round-trips its budget under the `redirect_depth`
2354 /// key, and reads back through `hops_consumed`.
2355 #[test]
2356 fn get_availability_params_round_trips_the_hop_budget() {
2357 let p = GetAvailabilityParams::new(vec![AvailabilityQuery {
2358 store_id: "cd".repeat(32),
2359 root: Some("ef".repeat(32)),
2360 retrieval_key: None,
2361 }])
2362 .with_redirect_depth(2);
2363 let v = serde_json::to_value(&p).unwrap();
2364 assert_eq!(v["redirect_depth"], 2);
2365 assert_eq!(p.hops_consumed(), 2);
2366 assert_eq!(
2367 serde_json::from_value::<GetAvailabilityParams>(v).unwrap(),
2368 p
2369 );
2370 }
2371
2372 /// **Proves:** an older client params object — written before ANY of the
2373 /// recursive-ask fields existed — still deserializes, and every new field reads
2374 /// as its documented absent value.
2375 /// **Catches:** any of the three declared as required, which would turn every
2376 /// pre-0.9 caller ask into `missing field` at the peer boundary.
2377 #[test]
2378 fn get_availability_params_accepts_a_client_older_than_the_recursive_ask() {
2379 let older = json!({ "items": [ { "store_id": "ab".repeat(32) } ] });
2380 let p: GetAvailabilityParams = serde_json::from_value(older).unwrap();
2381
2382 assert_eq!(p.budget_ms(), None, "absent budget_ms means unbudgeted");
2383 assert_eq!(p.ask_id(), None, "absent ask_id means dedup opted out");
2384 assert_eq!(p.hops_consumed(), 0);
2385 }
2386
2387 /// **Proves:** a params object carrying no recursive-ask fields serializes to
2388 /// exactly the pre-0.9 bytes — the three new keys are ABSENT, not `null`.
2389 /// **Catches:** a bare `#[serde(default)]` without `skip_serializing_if`, which
2390 /// would add `"budget_ms": null` and `"ask_id": null` to the frame of every
2391 /// caller that never opted in.
2392 #[test]
2393 fn get_availability_params_omits_absent_recursive_ask_fields() {
2394 let p = GetAvailabilityParams::new(vec![AvailabilityQuery {
2395 store_id: "ab".repeat(32),
2396 root: None,
2397 retrieval_key: None,
2398 }]);
2399 let v = serde_json::to_value(&p).unwrap();
2400 let keys: Vec<&String> = v.as_object().unwrap().keys().collect();
2401 assert_eq!(keys, vec!["items"], "a plain ask carries only `items`");
2402 }
2403
2404 /// **Proves:** the time budget and the hop budget are two INDEPENDENT fields
2405 /// under two distinct keys, each round-tripping its own value.
2406 /// **Catches:** the shape defect this addition exists to prevent — folding the
2407 /// time budget into `redirect_depth`. The fixture sets them to DIFFERENT values
2408 /// (2 hops, 9000 ms) precisely so a single backing integer cannot satisfy both
2409 /// assertions; equal values would pass under either shape.
2410 #[test]
2411 fn the_time_budget_is_a_separate_field_from_the_hop_budget() {
2412 let p = GetAvailabilityParams::new(vec![AvailabilityQuery {
2413 store_id: "cd".repeat(32),
2414 root: None,
2415 retrieval_key: None,
2416 }])
2417 .with_redirect_depth(2)
2418 .with_budget_ms(9_000);
2419
2420 let v = serde_json::to_value(&p).unwrap();
2421 assert_eq!(v["redirect_depth"], 2, "hops counted UP from zero");
2422 assert_eq!(v["budget_ms"], 9_000, "milliseconds counted DOWN to zero");
2423 assert_eq!(p.hops_consumed(), 2);
2424 assert_eq!(p.budget_ms(), Some(9_000));
2425 assert_eq!(
2426 serde_json::from_value::<GetAvailabilityParams>(v).unwrap(),
2427 p
2428 );
2429 }
2430
2431 /// **Proves:** a zero time budget survives the wire as `Some(0)` and is NOT
2432 /// erased into `None`.
2433 /// **Catches:** a `skip_serializing_if` written over the VALUE rather than the
2434 /// Option (`is_zero`-style), which would make "you have no time left, do not ask
2435 /// onward" indistinguishable from "unbudgeted, use your own policy" — exactly
2436 /// inverting the field on the one hop where it matters most.
2437 #[test]
2438 fn a_zero_time_budget_is_not_the_same_as_an_absent_one() {
2439 let exhausted = GetAvailabilityParams::new(vec![]).with_budget_ms(0);
2440 let v = serde_json::to_value(&exhausted).unwrap();
2441
2442 assert_eq!(v["budget_ms"], 0, "an exhausted budget stays on the wire");
2443 assert_eq!(
2444 serde_json::from_value::<GetAvailabilityParams>(v)
2445 .unwrap()
2446 .budget_ms(),
2447 Some(0)
2448 );
2449 assert_eq!(
2450 GetAvailabilityParams::new(vec![]).budget_ms(),
2451 None,
2452 "unbudgeted is a different state from budget zero"
2453 );
2454 }
2455
2456 /// **Proves:** `ask_id` round-trips verbatim under its own key, and is NOT the
2457 /// JSON-RPC `id`.
2458 /// **Catches:** an implementation that reuses the envelope correlator for dedup.
2459 /// The fixture puts a hardcoded `"id": 1` — the exact value dig-node was sending
2460 /// — beside a real 16-byte ask id in one envelope, so a reader that took the
2461 /// correlator would see `1` and disagree with both assertions.
2462 #[test]
2463 fn the_ask_id_is_not_the_jsonrpc_correlator() {
2464 const ASK_ID: &str = "3f9c1a04b7e25d68f0a1c3b5d7e9f012";
2465 assert_eq!(ASK_ID.len(), 32, "16 random bytes as lowercase hex");
2466
2467 let envelope = json!({
2468 "jsonrpc": "2.0",
2469 "id": 1,
2470 "method": "dig.getAvailability",
2471 "params": {
2472 "items": [ { "store_id": "ab".repeat(32) } ],
2473 "ask_id": ASK_ID,
2474 }
2475 });
2476
2477 let p: GetAvailabilityParams = serde_json::from_value(envelope["params"].clone()).unwrap();
2478 assert_eq!(p.ask_id(), Some(ASK_ID));
2479 assert_ne!(
2480 p.ask_id(),
2481 Some("1"),
2482 "the dedup identity must not be read from the envelope `id`"
2483 );
2484 assert_eq!(envelope["id"], 1, "the correlator is untouched beside it");
2485 }
2486
2487 /// **Proves:** `absence_established` distinguishes THREE states on the wire —
2488 /// asserted, explicitly-not-established, and unknown-because-older-server — and
2489 /// that the unknown state serializes as an ABSENT key rather than `false`.
2490 /// **Catches:** the collapse this field exists to prevent. The fixture carries
2491 /// all three answers in ONE batch, so a `bool` with `#[serde(default)]` (which
2492 /// would read the old server answer as `false`) makes the second and third
2493 /// answers compare EQUAL and the test fails; a fixture with only one answer
2494 /// could not see that.
2495 #[test]
2496 fn absence_established_keeps_absent_distinct_from_false() {
2497 let asserted = AvailabilityAnswer {
2498 available: false,
2499 absence_established: Some(true),
2500 ..Default::default()
2501 };
2502 let inconclusive = AvailabilityAnswer {
2503 available: false,
2504 absence_established: Some(false),
2505 ..Default::default()
2506 };
2507 let older_server = AvailabilityAnswer {
2508 available: false,
2509 ..Default::default()
2510 };
2511
2512 assert_ne!(
2513 inconclusive, older_server,
2514 "an explicit `false` is a claim; an absent field is not"
2515 );
2516 assert_eq!(asserted.absence_established_or_unknown(), Some(true));
2517 assert_eq!(inconclusive.absence_established_or_unknown(), Some(false));
2518 assert_eq!(
2519 older_server.absence_established_or_unknown(),
2520 None,
2521 "an older server makes no claim either way"
2522 );
2523
2524 let batch = serde_json::to_value(AvailabilityBatch {
2525 items: vec![asserted, inconclusive, older_server],
2526 })
2527 .unwrap();
2528 assert_eq!(batch["items"][0]["absence_established"], true);
2529 assert_eq!(batch["items"][1]["absence_established"], false);
2530 assert!(
2531 batch["items"][2].get("absence_established").is_none(),
2532 "the unknown state is an absent key, never `false` and never `null`"
2533 );
2534 }
2535
2536 /// **Proves:** an OLDER client can still read a NEWER answer — the added field
2537 /// does not break the shipped shape (§5.1).
2538 #[test]
2539 fn an_answer_carrying_the_new_field_still_parses_as_the_shipped_shape() {
2540 let newer = json!({
2541 "items": [ { "available": false, "absence_established": true } ]
2542 });
2543 let b: AvailabilityBatch = serde_json::from_value(newer).unwrap();
2544 assert_eq!(b.items.len(), 1);
2545 assert!(!b.items[0].available);
2546 assert_eq!(b.items[0].absence_established_or_unknown(), Some(true));
2547 }
2548
2549 /// **Proves:** the hop budget an availability ask carries is the SAME field,
2550 /// with the same key, type and value, that a `-32008` redirect hands back and
2551 /// that `dig.getContent` / `dig.fetchRange` already echo — one field, one
2552 /// interpretation, counted UP toward `max_redirects`.
2553 /// **Catches:** a second reading of the budget in this crate (a remaining
2554 /// allowance counting DOWN, a differently-named key, a differently-typed
2555 /// value) — the byte-drift the shipped redirect contract exists to prevent.
2556 #[test]
2557 fn availability_hop_budget_mirrors_the_redirect_budget() {
2558 let handed_back = RedirectInfo {
2559 content: ContentRef {
2560 store_id: "ab".repeat(32),
2561 root: None,
2562 retrieval_key: None,
2563 },
2564 providers: vec![],
2565 redirect_depth: 3,
2566 max_redirects: 4,
2567 };
2568 let echoed = handed_back.redirect_depth;
2569
2570 let availability = serde_json::to_value(
2571 GetAvailabilityParams::new(vec![AvailabilityQuery {
2572 store_id: "ab".repeat(32),
2573 root: None,
2574 retrieval_key: None,
2575 }])
2576 .with_redirect_depth(echoed),
2577 )
2578 .unwrap();
2579 let content = serde_json::to_value(GetContentParams {
2580 store_id: "ab".repeat(32),
2581 retrieval_key: "cd".repeat(32),
2582 root: None,
2583 offset: None,
2584 mode: None,
2585 redirect_depth: Some(echoed),
2586 })
2587 .unwrap();
2588 let range = serde_json::to_value(
2589 FetchRangeParams::resource("ab".repeat(32), "cd".repeat(32), "ef".repeat(32), 1)
2590 .with_redirect_depth(echoed),
2591 )
2592 .unwrap();
2593
2594 for (method, params) in [
2595 ("dig.getAvailability", &availability),
2596 ("dig.getContent", &content),
2597 ("dig.fetchRange", &range),
2598 ] {
2599 assert_eq!(
2600 params["redirect_depth"], 3,
2601 "{method} must carry the echoed depth under `redirect_depth`"
2602 );
2603 }
2604 assert!(
2605 handed_back.redirect_depth < handed_back.max_redirects,
2606 "the budget counts UP toward `max_redirects`"
2607 );
2608 }
2609
2610 /// **Proves:** a NEWER client's params — carrying a field this build does not
2611 /// know — still deserialize, so a hop-bearing ask is never refused outright by
2612 /// an older responder that simply ignores the budget.
2613 /// **Catches:** a `#[serde(deny_unknown_fields)]` added to the params type,
2614 /// which would turn every forward-compatible extension into a hard parse
2615 /// failure at the peer boundary.
2616 #[test]
2617 fn get_availability_params_tolerates_an_unknown_field() {
2618 let newer = json!({
2619 "items": [ { "store_id": "ab".repeat(32) } ],
2620 "redirect_depth": 1,
2621 "a_field_this_build_does_not_know": true
2622 });
2623 let p: GetAvailabilityParams = serde_json::from_value(newer).unwrap();
2624 assert_eq!(p.hops_consumed(), 1);
2625 }
2626
2627 // -----------------------------------------------------------------
2628 // dig.listRewardDistributors / dig.getRewardProverStatus /
2629 // dig.getRewardDistributor (#3250, dig-rewards-coin SPEC §2.3/§2.6)
2630 // -----------------------------------------------------------------
2631
2632 fn sample_prover_status() -> RewardProverStatus {
2633 RewardProverStatus {
2634 launcher_id: "ab".repeat(32),
2635 store_id: "cd".repeat(32),
2636 root: "ef".repeat(32),
2637 prover_state: ProverState::Running,
2638 prover_state_since: 1_000,
2639 last_cycle_started_at: Some(1_050),
2640 last_cycle_completed_at: None,
2641 next_cycle_due_at: Some(1_600),
2642 last_entry_write_at: Some(900),
2643 consecutive_cycle_failures: 0,
2644 pending_entry_writes: 2,
2645 observed_at: 1_700,
2646 counters: ProverCounters {
2647 mirrors_seen: 3,
2648 challenges_issued: 10,
2649 challenges_passed: 9,
2650 challenges_failed: 1,
2651 entries_added: 5,
2652 entries_removed: 1,
2653 entry_count: 4,
2654 reserve_base_units: 12_345,
2655 total_paid_out_base_units: 6_789,
2656 },
2657 }
2658 }
2659
2660 /// **Proves:** `RewardProverStatus` round-trips through serde.
2661 #[test]
2662 fn reward_prover_status_round_trips() {
2663 let status = sample_prover_status();
2664 let json = serde_json::to_string(&status).unwrap();
2665 let back: RewardProverStatus = serde_json::from_str(&json).unwrap();
2666 assert_eq!(status, back);
2667 }
2668
2669 /// **Proves:** `RewardProverStatus`'s JSON key set is EXACTLY the SPEC §2.3
2670 /// field list, and contains NEITHER a health boolean NOR a pre-computed
2671 /// staleness field — SPEC §2.4: a wedged loop cannot report its own
2672 /// wedging, so a boolean the writer sets reads true forever after the
2673 /// failure it exists to reveal.
2674 /// **Catches:** a `healthy`/`ok`/`up`/`running`/`stale`/
2675 /// `seconds_since_last_run`/`is_healthy` field reintroduced onto the
2676 /// self-reported prover record.
2677 #[test]
2678 fn reward_prover_status_has_no_health_boolean_and_exact_keys() {
2679 let value = serde_json::to_value(sample_prover_status()).unwrap();
2680 let obj = value.as_object().unwrap();
2681 let mut got: Vec<&str> = obj.keys().map(String::as_str).collect();
2682 got.sort_unstable();
2683
2684 let mut want = vec![
2685 "launcher_id",
2686 "store_id",
2687 "root",
2688 "prover_state",
2689 "prover_state_since",
2690 "last_cycle_started_at",
2691 "next_cycle_due_at",
2692 "last_entry_write_at",
2693 "consecutive_cycle_failures",
2694 "pending_entry_writes",
2695 "observed_at",
2696 "counters",
2697 ];
2698 // `last_cycle_completed_at` is `None` in the fixture and this type has
2699 // no `skip_serializing_if`, so it still serializes as `null` — include it.
2700 want.push("last_cycle_completed_at");
2701 want.sort_unstable();
2702 assert_eq!(got, want);
2703
2704 // Recurse into `counters` too — a smuggled health flag could hide one
2705 // level down, and a substring check on the serialized string would
2706 // miss it (and would also be actively wrong: `ProverState::Running`
2707 // legitimately serializes the *value* `"running"`, so a
2708 // `!s.contains("running")` assertion fails on honest input while
2709 // still passing a smuggled `isRunning` key).
2710 let counters_keys: Vec<&str> = value["counters"]
2711 .as_object()
2712 .unwrap()
2713 .keys()
2714 .map(String::as_str)
2715 .collect();
2716 let mut all_keys = got.clone();
2717 all_keys.extend(counters_keys);
2718
2719 for forbidden in [
2720 "healthy",
2721 "ok",
2722 "up",
2723 "running",
2724 "isRunning",
2725 "stale",
2726 "isStale",
2727 "staleness",
2728 "secondsSinceLastRun",
2729 "uptime",
2730 "alive",
2731 "live",
2732 "lastRunSecondsAgo",
2733 ] {
2734 assert!(
2735 !all_keys.contains(&forbidden),
2736 "RewardProverStatus (incl. counters) must not carry `{forbidden}` (SPEC §2.4)"
2737 );
2738 }
2739
2740 // Also assert directly against the Rust field list, independent of the
2741 // JSON round-trip, so a `#[serde(rename)]` cannot hide a violation.
2742 got.retain(|k| *k != "prover_state"); // enum-typed; checked separately below.
2743 }
2744
2745 /// **Proves:** `ProverState` deserializes each of the nine named SPEC §2.3
2746 /// variants and REJECTS an unknown string — fail-closed: no
2747 /// `#[serde(other)]`, no `Default`.
2748 #[test]
2749 fn prover_state_covers_the_closed_set_and_rejects_unknown() {
2750 let known = [
2751 ("idle", ProverState::Idle),
2752 ("running", ProverState::Running),
2753 ("localCopyMissing", ProverState::LocalCopyMissing),
2754 (
2755 "chainSourceUnavailable",
2756 ProverState::ChainSourceUnavailable,
2757 ),
2758 ("unfunded", ProverState::Unfunded),
2759 ("feeBudgetExhausted", ProverState::FeeBudgetExhausted),
2760 ("entrySetFull", ProverState::EntrySetFull),
2761 ("paused", ProverState::Paused),
2762 ("stopped", ProverState::Stopped),
2763 ];
2764 assert_eq!(known.len(), 9, "the SPEC §2.3 set has exactly nine members");
2765 for (wire, variant) in known {
2766 let got: ProverState = serde_json::from_value(json!(wire)).unwrap();
2767 assert_eq!(got, variant, "{wire}");
2768 assert_eq!(serde_json::to_value(variant).unwrap(), json!(wire));
2769 }
2770
2771 let err = serde_json::from_value::<ProverState>(json!("somethingElse"));
2772 assert!(
2773 err.is_err(),
2774 "an unknown ProverState string must be rejected"
2775 );
2776 }
2777
2778 /// **Proves:** `GetRewardProverStatusParams` / `GetRewardProverStatusResult`
2779 /// round-trip, and the statuses half is a `Half`.
2780 #[test]
2781 fn get_reward_prover_status_types_round_trip() {
2782 let params = GetRewardProverStatusParams {
2783 launcher_id: Some("ab".repeat(32)),
2784 };
2785 let back: GetRewardProverStatusParams =
2786 serde_json::from_str(&serde_json::to_string(¶ms).unwrap()).unwrap();
2787 assert_eq!(params, back);
2788
2789 let result = GetRewardProverStatusResult {
2790 statuses: Half::Consulted {
2791 observed_at: 1_700,
2792 items: vec![sample_prover_status()],
2793 },
2794 };
2795 let back: GetRewardProverStatusResult =
2796 serde_json::from_str(&serde_json::to_string(&result).unwrap()).unwrap();
2797 assert_eq!(result, back);
2798 }
2799
2800 /// **Proves:** "this node runs no prover loops" and "this node could not
2801 /// read its prover registry" are DIFFERENT wire payloads, each dated.
2802 /// **Catches:** `GetRewardProverStatusResult.statuses` reverting to a bare
2803 /// `Vec`, where an unreadable registry renders as a confident "none" on a
2804 /// reward surface.
2805 #[test]
2806 fn unconsulted_prover_registry_is_distinguishable_from_an_empty_one() {
2807 let not_consulted = GetRewardProverStatusResult {
2808 statuses: Half::NotConsulted { observed_at: 1_700 },
2809 };
2810 let consulted_and_empty = GetRewardProverStatusResult {
2811 statuses: Half::Consulted {
2812 observed_at: 1_700,
2813 items: vec![],
2814 },
2815 };
2816
2817 let a = serde_json::to_value(¬_consulted).unwrap();
2818 let b = serde_json::to_value(&consulted_and_empty).unwrap();
2819 assert_ne!(
2820 a, b,
2821 "an unread prover registry must not serialise as an empty one"
2822 );
2823 assert_eq!(a["statuses"]["outcome"], "not_consulted");
2824 assert_eq!(a["statuses"]["observed_at"], 1_700);
2825 assert_eq!(b["statuses"]["outcome"], "consulted");
2826 assert_eq!(b["statuses"]["observed_at"], 1_700);
2827
2828 let back: GetRewardProverStatusResult = serde_json::from_value(a).unwrap();
2829 assert_eq!(back, not_consulted);
2830 let back: GetRewardProverStatusResult = serde_json::from_value(b).unwrap();
2831 assert_eq!(back, consulted_and_empty);
2832 }
2833
2834 /// **Proves:** `ListRewardDistributorsResult` round-trips and its JSON keys
2835 /// are exactly `funded` / `claimable`, each a `Half` object carrying its own
2836 /// `outcome`, `observed_at` and `items` (SPEC §2.6, §12.5 clause 6).
2837 #[test]
2838 fn list_reward_distributors_result_round_trips_with_exact_keys() {
2839 let result = ListRewardDistributorsResult {
2840 funded: Half::Consulted {
2841 observed_at: 1_700,
2842 items: vec![RewardDistributorRef {
2843 launcher_id: "11".repeat(32),
2844 store_id: "22".repeat(32),
2845 root: "33".repeat(32),
2846 }],
2847 },
2848 claimable: Half::Consulted {
2849 observed_at: 1_700,
2850 items: vec![],
2851 },
2852 };
2853 let value = serde_json::to_value(&result).unwrap();
2854 assert_eq!(sorted_keys(&value), vec!["claimable", "funded"]);
2855 assert_eq!(
2856 sorted_keys(&value["funded"]),
2857 vec!["items", "observed_at", "outcome"],
2858 "a half must carry its items INSIDE the tagged observation"
2859 );
2860
2861 let back: ListRewardDistributorsResult = serde_json::from_value(value).unwrap();
2862 assert_eq!(result, back);
2863 }
2864
2865 /// **Proves:** "the mirror-claim half was not consulted" and "it was
2866 /// consulted and is empty" are DIFFERENT wire payloads — the whole point of
2867 /// SPEC §12.5 clause 6. Both carry an `observed_at`, so neither is a bare,
2868 /// undated zero.
2869 /// **Catches:** the dated absence collapsing back into a plain empty vector,
2870 /// which is how a funder-only answer came to read as "you have no mirror
2871 /// claims".
2872 #[test]
2873 fn unconsulted_claimable_half_is_distinguishable_from_an_empty_one() {
2874 let not_consulted = ListRewardDistributorsResult {
2875 funded: Half::Consulted {
2876 observed_at: 1_700,
2877 items: vec![],
2878 },
2879 claimable: Half::NotConsulted { observed_at: 1_700 },
2880 };
2881 let consulted_and_empty = ListRewardDistributorsResult {
2882 claimable: Half::Consulted {
2883 observed_at: 1_700,
2884 items: vec![],
2885 },
2886 ..not_consulted.clone()
2887 };
2888
2889 let a = serde_json::to_value(¬_consulted).unwrap();
2890 let b = serde_json::to_value(&consulted_and_empty).unwrap();
2891 assert_ne!(
2892 a, b,
2893 "an unconsulted half must not serialise as an empty one"
2894 );
2895 assert_eq!(a["claimable"]["outcome"], "not_consulted");
2896 assert_eq!(a["claimable"]["observed_at"], 1_700);
2897 assert_eq!(b["claimable"]["outcome"], "consulted");
2898
2899 let back: ListRewardDistributorsResult = serde_json::from_value(a).unwrap();
2900 assert_eq!(back, not_consulted);
2901 }
2902
2903 /// **Proves:** "this node's funded half was not consulted" and "it was
2904 /// consulted and is empty" are DIFFERENT wire payloads, each dated, exactly
2905 /// as for the mirror-claim half (SPEC §12.5 clause 6).
2906 /// **Catches:** a node that cannot read its own funder registry -- not
2907 /// configured, persisted state corrupt, or the read failed -- reporting an
2908 /// empty funded list, which states that the operator funds nothing.
2909 #[test]
2910 fn unconsulted_funded_half_is_distinguishable_from_an_empty_one() {
2911 let not_consulted = ListRewardDistributorsResult {
2912 funded: Half::NotConsulted { observed_at: 1_700 },
2913 claimable: Half::Consulted {
2914 observed_at: 1_700,
2915 items: vec![],
2916 },
2917 };
2918 let consulted_and_empty = ListRewardDistributorsResult {
2919 funded: Half::Consulted {
2920 observed_at: 1_700,
2921 items: vec![],
2922 },
2923 ..not_consulted.clone()
2924 };
2925
2926 let a = serde_json::to_value(¬_consulted).unwrap();
2927 let b = serde_json::to_value(&consulted_and_empty).unwrap();
2928 assert_ne!(
2929 a, b,
2930 "an unconsulted funded half must not serialise as an empty one"
2931 );
2932 assert_eq!(a["funded"]["outcome"], "not_consulted");
2933 assert_eq!(a["funded"]["observed_at"], 1_700);
2934 assert_eq!(b["funded"]["outcome"], "consulted");
2935 assert_eq!(b["funded"]["observed_at"], 1_700);
2936
2937 let back: ListRewardDistributorsResult = serde_json::from_value(a).unwrap();
2938 assert_eq!(back, not_consulted);
2939 let back: ListRewardDistributorsResult = serde_json::from_value(b).unwrap();
2940 assert_eq!(back, consulted_and_empty);
2941 }
2942
2943 /// **Proves:** "nothing looked, and here are the results" is UNREPRESENTABLE
2944 /// — in Rust, because [`Half::NotConsulted`] has no `items` field at all; and
2945 /// on the wire, because the arm denies unknown fields, so a producer that
2946 /// emits both is a parse ERROR rather than having its items silently dropped.
2947 /// **Catches:** the observation drifting back out beside the collection,
2948 /// where only prose forbids the contradiction (dig_ecosystem#3269
2949 /// Condition 1).
2950 #[test]
2951 fn not_consulted_carrying_items_does_not_parse() {
2952 let contradiction = serde_json::json!({
2953 "outcome": "not_consulted",
2954 "observed_at": 1_700,
2955 "items": [{"launcher_id": "11".repeat(32),
2956 "store_id": "22".repeat(32),
2957 "root": "33".repeat(32)}],
2958 });
2959 assert!(
2960 serde_json::from_value::<Half<RewardDistributorRef>>(contradiction).is_err(),
2961 "\"not consulted, and here are the items\" must not parse"
2962 );
2963 }
2964
2965 /// **Proves:** `Half` is fail-closed — an unknown outcome, or one missing its
2966 /// `observed_at`, is a parse ERROR, never a silently coerced "consulted" (the
2967 /// `ProverState` discipline, SPEC §12.5 clause 6's ban on an undated absence).
2968 #[test]
2969 fn half_rejects_unknown_and_undated_outcomes() {
2970 let unknown = serde_json::json!({"outcome": "maybe", "observed_at": 1, "items": []});
2971 assert!(serde_json::from_value::<Half<RewardDistributorRef>>(unknown).is_err());
2972
2973 let undated = serde_json::json!({"outcome": "not_consulted"});
2974 assert!(serde_json::from_value::<Half<RewardDistributorRef>>(undated).is_err());
2975
2976 let undated_consulted = serde_json::json!({"outcome": "consulted", "items": []});
2977 assert!(serde_json::from_value::<Half<RewardDistributorRef>>(undated_consulted).is_err());
2978
2979 // A half that says it looked owes an answer, even an empty one.
2980 let itemless = serde_json::json!({"outcome": "consulted", "observed_at": 1});
2981 assert!(serde_json::from_value::<Half<RewardDistributorRef>>(itemless).is_err());
2982 }
2983
2984 /// **Proves:** [`Half::items`] distinguishes "consulted, none" (`Some(&[])`)
2985 /// from "not consulted" (`None`), and [`Half::observed_at`] answers in both
2986 /// arms — so a consumer never has to re-match to get a staleness anchor.
2987 #[test]
2988 fn half_accessors_keep_the_distinction() {
2989 let consulted: Half<RewardDistributorRef> = Half::Consulted {
2990 observed_at: 1_700,
2991 items: vec![],
2992 };
2993 let not_consulted: Half<RewardDistributorRef> = Half::NotConsulted { observed_at: 1_701 };
2994 assert_eq!(consulted.items().map(<[_]>::len), Some(0));
2995 assert!(not_consulted.items().is_none());
2996 assert_eq!(consulted.observed_at(), 1_700);
2997 assert_eq!(not_consulted.observed_at(), 1_701);
2998 }
2999
3000 /// A `ListRewardDistributorsResult` body with every key present, so a test
3001 /// can delete exactly one and assert the deletion is what broke it.
3002 fn full_list_reward_distributors_body() -> serde_json::Value {
3003 serde_json::json!({
3004 "funded": {"outcome": "consulted", "observed_at": 1_700, "items": []},
3005 "claimable": {"outcome": "consulted", "observed_at": 1_700, "items": []},
3006 })
3007 }
3008
3009 /// **Proves:** omitting `funded` -- or `claimable` -- is a PARSE ERROR, not a
3010 /// default, while the same body with both keys present parses. Neither half
3011 /// may be silently assumed consulted.
3012 /// **Catches:** `#[serde(default)]` (or a re-derived `Default`) being added
3013 /// as a compatibility convenience, which would make every legacy payload
3014 /// parse as "both halves consulted" -- the undated bare zero SPEC §12.5
3015 /// clause 6 forbids.
3016 #[test]
3017 fn omitting_either_half_is_a_parse_error() {
3018 serde_json::from_value::<ListRewardDistributorsResult>(full_list_reward_distributors_body())
3019 .expect("the complete body must parse, or the omission assertions prove nothing");
3020
3021 for omitted in ["funded", "claimable"] {
3022 let mut body = full_list_reward_distributors_body();
3023 body.as_object_mut().unwrap().remove(omitted).unwrap();
3024 assert!(
3025 serde_json::from_value::<ListRewardDistributorsResult>(body).is_err(),
3026 "omitting {omitted} must fail to parse, never default to consulted"
3027 );
3028 }
3029 }
3030
3031 /// **Proves:** `PayeeClaimStatus` carries the literal `"subject": "payee"`
3032 /// and a claim-log observation — and NO monetary field and no payout puzzle
3033 /// hash (dig_ecosystem#3269).
3034 /// **Catches:** an amount or a payment identity creeping back onto the payee
3035 /// payload, which is the shape that let a funder's total render as one
3036 /// operator's personal earnings.
3037 #[test]
3038 fn payee_claim_status_names_its_subject_and_carries_no_money() {
3039 let status = PayeeClaimStatus {
3040 subject: PayeeSubject::Payee,
3041 claim_log: ClaimLogObservation::Consulted {
3042 observed_at: 1_700,
3043 claims_submitted_count: 3,
3044 },
3045 };
3046 let value = serde_json::to_value(status).unwrap();
3047 assert_eq!(value["subject"], "payee");
3048 assert_eq!(
3049 sorted_keys(&value),
3050 vec!["claim_log", "subject"],
3051 "PayeeClaimStatus gained a field -- if it names money or a payee's \
3052 payment identity, it must not ship"
3053 );
3054 assert_eq!(
3055 sorted_keys(&value["claim_log"]),
3056 vec!["claims_submitted_count", "observed_at", "outcome"],
3057 "the claim log gained a field -- the same money rule applies inside it"
3058 );
3059
3060 let back: PayeeClaimStatus = serde_json::from_value(value).unwrap();
3061 assert_eq!(status, back);
3062 }
3063
3064 /// **Proves:** every field of `PayeeClaimStatus` is REQUIRED on the wire —
3065 /// nothing defaults, so a producer cannot omit the subject and have a
3066 /// consumer invent it.
3067 #[test]
3068 fn payee_claim_status_fields_do_not_default() {
3069 let full = || {
3070 serde_json::json!({
3071 "subject": "payee",
3072 "claim_log": {
3073 "outcome": "consulted",
3074 "observed_at": 1_700,
3075 "claims_submitted_count": 3,
3076 },
3077 })
3078 };
3079 serde_json::from_value::<PayeeClaimStatus>(full())
3080 .expect("the complete body must parse, or the omission assertions prove nothing");
3081
3082 for missing in ["subject", "claim_log"] {
3083 let mut value = full();
3084 value.as_object_mut().unwrap().remove(missing).unwrap();
3085 assert!(
3086 serde_json::from_value::<PayeeClaimStatus>(value).is_err(),
3087 "{missing} must be required"
3088 );
3089 }
3090
3091 let wrong_subject = serde_json::json!({
3092 "subject": "funder",
3093 "claim_log": {
3094 "outcome": "consulted",
3095 "observed_at": 1_700,
3096 "claims_submitted_count": 3,
3097 },
3098 });
3099 assert!(
3100 serde_json::from_value::<PayeeClaimStatus>(wrong_subject).is_err(),
3101 "only the literal \"payee\" subject may parse into PayeeClaimStatus"
3102 );
3103 }
3104
3105 /// **Proves:** a node that never read its claim log cannot emit a count at
3106 /// all — there is no field for one in `not_consulted`, and a payload that
3107 /// carries one anyway is a parse ERROR. "Consulted and zero" and "never
3108 /// looked" are different, dated payloads.
3109 /// **Catches:** dig_ecosystem#3269 Condition 3 — a freshly dated
3110 /// `claims_submitted_count: 0` emitted by a node whose log was unreadable,
3111 /// which is SPEC §12.5 clause 6's reassuring zero with a timestamp on it.
3112 #[test]
3113 fn an_unread_claim_log_cannot_report_a_count() {
3114 let unread = PayeeClaimStatus {
3115 subject: PayeeSubject::Payee,
3116 claim_log: ClaimLogObservation::NotConsulted { observed_at: 1_700 },
3117 };
3118 let read_and_zero = PayeeClaimStatus {
3119 subject: PayeeSubject::Payee,
3120 claim_log: ClaimLogObservation::Consulted {
3121 observed_at: 1_700,
3122 claims_submitted_count: 0,
3123 },
3124 };
3125
3126 let a = serde_json::to_value(unread).unwrap();
3127 let b = serde_json::to_value(read_and_zero).unwrap();
3128 assert_ne!(a, b, "an unread claim log must not serialise as a zero one");
3129 assert_eq!(a["claim_log"]["outcome"], "not_consulted");
3130 assert_eq!(a["claim_log"]["observed_at"], 1_700);
3131 assert!(
3132 a["claim_log"].get("claims_submitted_count").is_none(),
3133 "an unread log has no count to give"
3134 );
3135 assert_eq!(b["claim_log"]["claims_submitted_count"], 0);
3136
3137 let contradiction = serde_json::json!({
3138 "outcome": "not_consulted",
3139 "observed_at": 1_700,
3140 "claims_submitted_count": 0,
3141 });
3142 assert!(
3143 serde_json::from_value::<ClaimLogObservation>(contradiction).is_err(),
3144 "\"never looked, and the count is zero\" must not parse"
3145 );
3146
3147 let undated = serde_json::json!({"outcome": "not_consulted"});
3148 assert!(serde_json::from_value::<ClaimLogObservation>(undated).is_err());
3149
3150 let countless = serde_json::json!({"outcome": "consulted", "observed_at": 1_700});
3151 assert!(
3152 serde_json::from_value::<ClaimLogObservation>(countless).is_err(),
3153 "a log that says it was read owes a count"
3154 );
3155 }
3156
3157 /// **Proves:** `GetRewardDistributorResult` round-trips, and `entry_set_stale`
3158 /// IS present on this chain-derived type (contrast `RewardProverStatus`,
3159 /// which must never carry it — SPEC §12.4 vs §2.4).
3160 #[test]
3161 fn get_reward_distributor_result_round_trips_and_carries_entry_set_stale() {
3162 let params = GetRewardDistributorParams {
3163 launcher_id: "ab".repeat(32),
3164 };
3165 let back: GetRewardDistributorParams =
3166 serde_json::from_str(&serde_json::to_string(¶ms).unwrap()).unwrap();
3167 assert_eq!(params, back);
3168
3169 let result = GetRewardDistributorResult {
3170 launcher_id: "ab".repeat(32),
3171 store_id: "cd".repeat(32),
3172 root: "ef".repeat(32),
3173 epoch_seconds: 86_400,
3174 first_epoch_start: 1_000,
3175 payout_threshold: 500_000,
3176 fee_bps: 250,
3177 withdrawal_share_bps: 9_000,
3178 reserve_base_units: 1_000_000,
3179 entry_count: 42,
3180 current_distributor_epoch: 7,
3181 last_entry_write_at: Some(1_650),
3182 entry_set_stale: true,
3183 observed_at: 1_700,
3184 };
3185 let value = serde_json::to_value(&result).unwrap();
3186 assert_eq!(value["entry_set_stale"], true);
3187 let back: GetRewardDistributorResult = serde_json::from_value(value).unwrap();
3188 assert_eq!(result, back);
3189 }
3190
3191 /// **Proves:** `entry_set_stale` is placed on exactly one of the two
3192 /// reward-distributor result types — present on the chain-derived
3193 /// `GetRewardDistributorResult`, absent from the self-reported
3194 /// `RewardProverStatus` — SPEC §12.4 vs §2.4.
3195 /// **Catches:** the staleness boolean migrating (or being copy-pasted)
3196 /// onto the self-reported type, which would let a wedged prover fake
3197 /// liveness by simply never flipping it.
3198 #[test]
3199 fn entry_set_stale_is_placed_on_the_chain_derived_result_only() {
3200 let prover_status = serde_json::to_value(sample_prover_status()).unwrap();
3201 assert!(
3202 !prover_status
3203 .as_object()
3204 .unwrap()
3205 .contains_key("entry_set_stale"),
3206 "RewardProverStatus must never carry entry_set_stale (SPEC §2.4)"
3207 );
3208
3209 let distributor_result = GetRewardDistributorResult {
3210 launcher_id: "ab".repeat(32),
3211 store_id: "cd".repeat(32),
3212 root: "ef".repeat(32),
3213 epoch_seconds: 86_400,
3214 first_epoch_start: 1_000,
3215 payout_threshold: 500_000,
3216 fee_bps: 250,
3217 withdrawal_share_bps: 9_000,
3218 reserve_base_units: 1_000_000,
3219 entry_count: 42,
3220 current_distributor_epoch: 7,
3221 last_entry_write_at: None,
3222 entry_set_stale: false,
3223 observed_at: 1_700,
3224 };
3225 let value = serde_json::to_value(&distributor_result).unwrap();
3226 assert!(
3227 value.as_object().unwrap().contains_key("entry_set_stale"),
3228 "GetRewardDistributorResult must carry entry_set_stale (SPEC §12.4)"
3229 );
3230 }
3231
3232 /// **Proves:** `GetRewardProverStatusParams` with an absent `launcher_id`
3233 /// deserializes from an empty JSON object to `None` — the field is
3234 /// genuinely optional on the wire, not merely optional in Rust.
3235 #[test]
3236 fn get_reward_prover_status_params_absent_launcher_id_is_none() {
3237 let parsed: GetRewardProverStatusParams = serde_json::from_value(json!({})).unwrap();
3238 assert_eq!(parsed.launcher_id, None);
3239
3240 // And the round trip the other way: `Some` serializes the key back out.
3241 let with_id = GetRewardProverStatusParams {
3242 launcher_id: Some("ab".repeat(32)),
3243 };
3244 let value = serde_json::to_value(&with_id).unwrap();
3245 assert_eq!(value["launcher_id"], json!("ab".repeat(32)));
3246 }
3247
3248 /// **Proves:** `ListRewardDistributorCommitmentsResult` round-trips,
3249 /// including the legitimate EMPTY `commitments` case (a distributor
3250 /// funded only via `AddIncentives` has no clawback slots at all — an
3251 /// irrevocable donation, not an error) — SPEC §7.4 clause 5.
3252 #[test]
3253 fn list_reward_distributor_commitments_round_trips_with_empty_commitments() {
3254 let params = ListRewardDistributorCommitmentsParams {
3255 launcher_id: "ab".repeat(32),
3256 };
3257 let back: ListRewardDistributorCommitmentsParams =
3258 serde_json::from_str(&serde_json::to_string(¶ms).unwrap()).unwrap();
3259 assert_eq!(params, back);
3260
3261 let result = ListRewardDistributorCommitmentsResult {
3262 launcher_id: "ab".repeat(32),
3263 withdrawal_share_bps: 9_000,
3264 epoch_seconds: 86_400,
3265 commitments: vec![],
3266 observed_at: 1_700,
3267 };
3268 let back: ListRewardDistributorCommitmentsResult =
3269 serde_json::from_str(&serde_json::to_string(&result).unwrap()).unwrap();
3270 assert_eq!(result, back);
3271 assert_eq!(back.epoch_seconds, 86_400);
3272 }
3273
3274 /// **Proves:** `recoverable_base_units` is `rewards_base_units *
3275 /// withdrawal_share_bps / 10_000`, computed with integer arithmetic
3276 /// (multiply then divide) that TRUNCATES rather than rounds up — SPEC
3277 /// §7.4 clause 4 / §7.5.
3278 /// **Catches:** a rounded-up recoverable amount, which would promise
3279 /// money the chain will not return on clawback.
3280 #[test]
3281 fn commitment_recoverable_amount_truncates_and_never_exceeds_committed() {
3282 let withdrawal_share_bps: u64 = 9_000;
3283
3284 // Evenly divisible: 1_000 * 9000 / 10000 = 900.
3285 let even = RewardDistributorCommitment {
3286 epoch_start: 10,
3287 clawback_puzzle_hash: "aa".repeat(32),
3288 rewards_base_units: 1_000,
3289 recoverable_base_units: 1_000 * withdrawal_share_bps / 10_000,
3290 };
3291 assert_eq!(even.recoverable_base_units, 900);
3292
3293 // Not evenly divisible: 1_001 * 9000 / 10000 = 900.9 -> 900, not 901.
3294 let odd = RewardDistributorCommitment {
3295 epoch_start: 11,
3296 clawback_puzzle_hash: "bb".repeat(32),
3297 rewards_base_units: 1_001,
3298 recoverable_base_units: 1_001 * withdrawal_share_bps / 10_000,
3299 };
3300 assert_eq!(
3301 odd.recoverable_base_units, 900,
3302 "a non-evenly-divisible amount must truncate down, never round up"
3303 );
3304
3305 for commitment in [even, odd] {
3306 assert!(
3307 commitment.recoverable_base_units <= commitment.rewards_base_units,
3308 "recoverable_base_units must never exceed rewards_base_units"
3309 );
3310 }
3311 }
3312}