Skip to main content

dig_rpc_protocol/
types.rs

1//! Request/response wire types for every DIG-node RPC method.
2//!
3//! Each type is `serde`-derived and models a method's params or result
4//! field-for-field with the canonical implementation (the digstore `dig-node`
5//! crate). Fields that appear only in one profile or only on the first window of
6//! a paged stream are `Option` and doc-flagged.
7//!
8//! Hex-encoded identifiers (`store_id`, `root`, `retrieval_key`, `peer_id`) are
9//! carried as `String` on the wire — lower-case 64-hex — because the interface
10//! crate does no crypto and imposes no byte-array dependency. Callers validate
11//! length/charset at their boundary.
12//!
13//! # Two content profiles, one chunk type
14//!
15//! [`ContentChunk`] models both the node profile (`dig.getContent` on the local
16//! dig-node) and the network profile (`rpc.dig.net`). The network-profile-only
17//! fields — [`total_length`](ContentChunk::total_length),
18//! [`length`](ContentChunk::length), [`program_hash`](ContentChunk::program_hash),
19//! [`offset`](ContentChunk::offset) — are `Option` so one type serves both
20//! surfaces with no silent split.
21
22use serde::{Deserialize, Serialize};
23
24/// A lower-case 64-hex identifier on the wire (e.g. a `store_id`, `root`,
25/// `retrieval_key`, or `peer_id`). A type alias for documentation; validation is
26/// the boundary's job.
27pub type HexId = String;
28
29// ===========================================================================
30// Shared value objects
31// ===========================================================================
32
33/// A peer's dialable network endpoint.
34///
35/// IPv6-first per the ecosystem networking rule: an address list orders
36/// global-unicast IPv6 ahead of IPv4 fallback, and a wildcard bind
37/// (`[::]`/`0.0.0.0`) is never advertised.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
40pub struct PeerAddress {
41    /// The host — an IPv6 or IPv4 literal (never a wildcard).
42    pub host: String,
43    /// The TCP port.
44    pub port: u16,
45    /// How the address was discovered: `direct`, `reflexive`, `mapped`, or
46    /// `relay`.
47    pub kind: String,
48}
49
50/// A content provider: a holder's stable `peer_id` plus its candidate addresses.
51///
52/// The address list is byte-compatible with [`dig.getPeers`](crate::method::Method::GetPeers)
53/// and the DHT provider shape.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
56pub struct Provider {
57    /// The holder's stable `peer_id` = `SHA-256(TLS SPKI DER)`, 64-hex.
58    pub peer_id: HexId,
59    /// The holder's candidate addresses (IPv6-first).
60    pub addresses: Vec<PeerAddress>,
61}
62
63/// The content item a redirect points at: `store_id` [+ `root` [+
64/// `retrieval_key`]], each lower-case 64-hex — the exact item to re-request.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
66#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
67pub struct ContentRef {
68    /// The store launcher id (always present).
69    pub store_id: HexId,
70    /// The generation root (present for capsule/resource granularity).
71    #[serde(skip_serializing_if = "Option::is_none", default)]
72    pub root: Option<HexId>,
73    /// The resource retrieval key (present for resource granularity).
74    #[serde(skip_serializing_if = "Option::is_none", default)]
75    pub retrieval_key: Option<HexId>,
76}
77
78/// The `error.data.redirect` payload of a
79/// [`ContentRedirect`](crate::error::ErrorCode::ContentRedirect) (`-32008`).
80///
81/// The node does not hold the content but located peers that do; the caller
82/// re-requests against one of `providers`, echoing `redirect_depth` in its
83/// params so the hop budget stays bounded (stop at `max_redirects`).
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
86pub struct RedirectInfo {
87    /// The content the caller should re-request.
88    pub content: ContentRef,
89    /// The holders (peer_id + candidate addresses) to re-request against.
90    pub providers: Vec<Provider>,
91    /// The hop count the caller must echo on its re-request.
92    pub redirect_depth: u64,
93    /// The redirect budget — stop redirecting when `redirect_depth` reaches this.
94    pub max_redirects: u64,
95}
96
97// ===========================================================================
98// dig.getContent  (PUBLIC-READ, also peer-reachable)
99// ===========================================================================
100
101/// Params for [`dig.getContent`](crate::method::Method::GetContent) — a verified
102/// resource-window read.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
105pub struct GetContentParams {
106    /// The CHIP-0035 singleton launcher id (64-hex).
107    pub store_id: HexId,
108    /// `SHA-256(urn)` — the only URN-derived value sent to a node (64-hex).
109    pub retrieval_key: HexId,
110    /// The generation root (64-hex). Empty / `"latest"` / absent ⇒ resolve the
111    /// chain tip.
112    #[serde(skip_serializing_if = "Option::is_none", default)]
113    pub root: Option<HexId>,
114    /// The window start offset (default 0).
115    #[serde(skip_serializing_if = "Option::is_none", default)]
116    pub offset: Option<u64>,
117    /// Retrieval mode: `"speed"` (default) or `"privacy"` (onion — target).
118    #[serde(skip_serializing_if = "Option::is_none", default)]
119    pub mode: Option<String>,
120    /// The redirect budget already consumed (echoed from a `-32008` redirect).
121    #[serde(skip_serializing_if = "Option::is_none", default)]
122    pub redirect_depth: Option<u64>,
123}
124
125/// One window of a resource's ciphertext — the chunk wire object.
126///
127/// Serves BOTH the node profile (`dig.getContent` on the local dig-node) and the
128/// network profile (`rpc.dig.net`). Node-profile responses omit the
129/// network-profile-only fields; the doc on each field says which profile
130/// populates it.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
133pub struct ContentChunk {
134    /// This window's bytes, base64. Both profiles.
135    pub ciphertext: String,
136    /// The resolved generation root (64-hex). Both profiles.
137    pub root: HexId,
138    /// Whether this window ends the resource. Both profiles.
139    pub complete: bool,
140    /// The next offset; present iff not complete. Both profiles.
141    #[serde(skip_serializing_if = "Option::is_none", default)]
142    pub next_offset: Option<u64>,
143    /// Whole-resource merkle proof, base64. First window only (`offset == 0`).
144    /// Both profiles.
145    #[serde(skip_serializing_if = "Option::is_none", default)]
146    pub inclusion_proof: Option<String>,
147    /// Per-chunk ciphertext lengths of the full resource. First window only;
148    /// empty ⇒ single chunk. Both profiles.
149    #[serde(skip_serializing_if = "Option::is_none", default)]
150    pub chunk_lens: Option<Vec<u64>>,
151    /// Where the window was served from: `"local"` (this device's cache) or
152    /// `"remote"` (freshly fetched). **Node profile only** — additive tag the
153    /// in-process node sets; absent on the network profile.
154    #[serde(skip_serializing_if = "Option::is_none", default)]
155    pub source: Option<String>,
156    /// The full resource ciphertext length (pre-windowing). **Network profile
157    /// only.**
158    #[serde(skip_serializing_if = "Option::is_none", default)]
159    pub total_length: Option<u64>,
160    /// This window's byte length. **Network profile only** (the node profile's
161    /// length is implicit in `ciphertext`).
162    #[serde(skip_serializing_if = "Option::is_none", default)]
163    pub length: Option<u64>,
164    /// The window start offset (echoed). **Network profile only.**
165    #[serde(skip_serializing_if = "Option::is_none", default)]
166    pub offset: Option<u64>,
167    /// `SHA-256(.dig bytes)` — the on-chain program identity (64-hex).
168    /// **Network profile only.**
169    #[serde(skip_serializing_if = "Option::is_none", default)]
170    pub program_hash: Option<HexId>,
171}
172
173// ===========================================================================
174// dig.getAnchoredRoot  (PUBLIC-READ, also peer-reachable)
175// ===========================================================================
176
177/// Params for [`dig.getAnchoredRoot`](crate::method::Method::GetAnchoredRoot).
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
180pub struct GetAnchoredRootParams {
181    /// The store launcher id (64-hex).
182    pub store_id: HexId,
183}
184
185/// Result for [`dig.getAnchoredRoot`](crate::method::Method::GetAnchoredRoot) —
186/// the store's current chain-anchored tip root.
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
188#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
189pub struct AnchoredRoot {
190    /// The store launcher id (echoed, 64-hex).
191    pub store_id: HexId,
192    /// The chain-anchored tip root (64-hex).
193    pub root: HexId,
194}
195
196// ===========================================================================
197// dig.getCollection / dig.listCollectionItems  (PUBLIC-READ, also peer)
198// ===========================================================================
199
200/// Params for [`dig.getCollection`](crate::method::Method::GetCollection).
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
203pub struct GetCollectionParams {
204    /// The NFT launcher ids to resolve. Capped at 10,000 (over-cap ⇒ `-32602`).
205    pub launcher_ids: Vec<HexId>,
206    /// The optional collection creator DID (64-hex).
207    #[serde(skip_serializing_if = "Option::is_none", default)]
208    pub did: Option<HexId>,
209}
210
211/// Result for [`dig.getCollection`](crate::method::Method::GetCollection) —
212/// collection-level facts computed from DIG's own coinset data.
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
214#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
215pub struct Collection {
216    /// The resolved creator DID (64-hex), if any.
217    #[serde(skip_serializing_if = "Option::is_none", default)]
218    pub did: Option<HexId>,
219    /// The DID declared by the caller / metadata (64-hex), if any.
220    #[serde(skip_serializing_if = "Option::is_none", default)]
221    pub declared_did: Option<HexId>,
222    /// The number of launcher ids requested.
223    pub item_count: u64,
224    /// How many resolved to live NFTs.
225    pub resolved_count: u64,
226    /// The uniform royalty in basis points, if resolvable.
227    #[serde(skip_serializing_if = "Option::is_none", default)]
228    pub royalty_basis_points: Option<u64>,
229}
230
231/// Params for
232/// [`dig.listCollectionItems`](crate::method::Method::ListCollectionItems).
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
235pub struct ListCollectionItemsParams {
236    /// The NFT launcher ids. Capped at 10,000 (over-cap ⇒ `-32602`).
237    pub launcher_ids: Vec<HexId>,
238    /// Page start (default 0).
239    #[serde(skip_serializing_if = "Option::is_none", default)]
240    pub offset: Option<u64>,
241    /// Page size (default 50, capped at 200).
242    #[serde(skip_serializing_if = "Option::is_none", default)]
243    pub limit: Option<u64>,
244}
245
246/// CHIP-0007 NFT metadata for one collection item.
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
248#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
249pub struct NftMetadata {
250    /// Edition ordinal, if any.
251    #[serde(skip_serializing_if = "Option::is_none", default)]
252    pub edition_number: Option<u64>,
253    /// Edition total, if any.
254    #[serde(skip_serializing_if = "Option::is_none", default)]
255    pub edition_total: Option<u64>,
256    /// Data URIs.
257    #[serde(default)]
258    pub data_uris: Vec<String>,
259    /// `SHA-256` of the data (64-hex), if any.
260    #[serde(skip_serializing_if = "Option::is_none", default)]
261    pub data_hash: Option<HexId>,
262    /// Metadata URIs.
263    #[serde(default)]
264    pub metadata_uris: Vec<String>,
265    /// `SHA-256` of the metadata document (64-hex), if any.
266    #[serde(skip_serializing_if = "Option::is_none", default)]
267    pub metadata_hash: Option<HexId>,
268    /// License URIs.
269    #[serde(default)]
270    pub license_uris: Vec<String>,
271    /// `SHA-256` of the license (64-hex), if any.
272    #[serde(skip_serializing_if = "Option::is_none", default)]
273    pub license_hash: Option<HexId>,
274}
275
276/// One resolved collection item — its current on-chain owner, royalty, and
277/// CHIP-0007 metadata.
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
280pub struct CollectionItem {
281    /// The NFT launcher id (64-hex).
282    pub launcher_id: HexId,
283    /// The current coin id (64-hex).
284    pub coin_id: HexId,
285    /// The current owner DID (64-hex), if any.
286    #[serde(skip_serializing_if = "Option::is_none", default)]
287    pub owner_did: Option<HexId>,
288    /// The royalty puzzle hash (64-hex).
289    pub royalty_puzzle_hash: HexId,
290    /// The royalty in basis points.
291    pub royalty_basis_points: u64,
292    /// The current owner puzzle hash (64-hex).
293    pub owner_puzzle_hash: HexId,
294    /// The CHIP-0007 metadata, if resolvable.
295    #[serde(skip_serializing_if = "Option::is_none", default)]
296    pub metadata: Option<NftMetadata>,
297}
298
299/// Result for
300/// [`dig.listCollectionItems`](crate::method::Method::ListCollectionItems) — a
301/// page of resolved items.
302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
303#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
304pub struct CollectionItemsPage {
305    /// This page's items.
306    pub items: Vec<CollectionItem>,
307    /// The page start (echoed).
308    pub offset: u64,
309    /// The page size (echoed).
310    pub limit: u64,
311    /// The total item count across the whole (capped) launcher set.
312    pub total: u64,
313    /// The next page's offset, or `null` when exhausted.
314    #[serde(skip_serializing_if = "Option::is_none", default)]
315    pub next_offset: Option<u64>,
316}
317
318// ===========================================================================
319// dig.getNetworkInfo  (PEER)
320// ===========================================================================
321
322/// The node's relay reservation posture.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
325pub struct RelayStatus {
326    /// The relay endpoint URL (e.g. `wss://relay.dig.net: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///
1751/// # The chain-view anchor (`chain_peak_height` / `chain_peak_timestamp`) — SPEC §4.5
1752///
1753/// `observed_at` dates when THIS REPLY was assembled — a wall clock, and
1754/// nothing else. `entry_set_stale` above is computed on a different clock
1755/// entirely: the distributor singleton's on-chain peak. A stalled chain
1756/// source freezes that peak while the wall clock keeps advancing, which
1757/// freezes `entry_set_stale` at `false` under a freshly stamped
1758/// `observed_at` — the optimistic-staleness defect this field pair exists to
1759/// close (dig_ecosystem#3262). `chain_peak_height` and `chain_peak_timestamp`
1760/// expose the clock `entry_set_stale` was actually computed against, so a
1761/// reader can check the two are self-consistent:
1762///
1763/// ```text
1764/// entry_set_stale == (reserve_base_units > 0
1765///     && chain_peak_timestamp - last_entry_write_at.unwrap_or(0) >= 172_800)
1766/// ```
1767///
1768/// **What this check is, and is not.** Every value on the right-hand side —
1769/// `chain_peak_timestamp` included — comes from the SAME response and the
1770/// SAME responder as `entry_set_stale` itself. Re-deriving the formula from
1771/// the responder's own numbers catches an INTERNALLY INCONSISTENT responder
1772/// only; it is not evidence against a lying, wedged, or compromised one,
1773/// which can report any self-consistent triple it likes and pass this check
1774/// every time. What the anchor genuinely fixes is narrower, and real: an
1775/// HONEST but chain-stalled responder can no longer freeze `entry_set_stale`
1776/// at `false` under a freshly stamped `observed_at`, because its own stalled
1777/// chain clock now travels on the wire beside it — the defect
1778/// dig_ecosystem#3262 was filed for, and it is closed. Real protection
1779/// against a dishonest responder requires comparing `chain_peak_height`
1780/// against a chain view the consumer obtained INDEPENDENTLY of this
1781/// response — see [`Self::chain_peak_height`]. SPEC §4.5.
1782///
1783/// Both fields are **required**, never `Option`, never `0`. A responder that
1784/// cannot obtain the chain peak (dig-rewards-coin's `read_distributor`
1785/// refuses without one) MUST answer a JSON-RPC error, not a result with a
1786/// missing or zeroed anchor — there is no result arm for "I could not look".
1787#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1788#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1789pub struct GetRewardDistributorResult {
1790    /// The distributor singleton's launcher id (64-hex).
1791    pub launcher_id: HexId,
1792    /// The backing store's launcher id (64-hex).
1793    pub store_id: HexId,
1794    /// The store's current generation root (64-hex).
1795    pub root: HexId,
1796    /// The payout epoch length, in seconds.
1797    pub epoch_seconds: u64,
1798    /// Unix seconds the first epoch started.
1799    pub first_epoch_start: u64,
1800    /// The reserve threshold, in base units, that triggers a payout.
1801    pub payout_threshold: u64,
1802    /// The distributor's fee, in basis points.
1803    pub fee_bps: u16,
1804    /// The share of a clawed-back commitment the committer recovers, in basis
1805    /// points — SPEC §7.5. `withdrawal_share_bps = 9000` means a clawback
1806    /// returns 90% of the committed value; the remaining 10% is forfeited to
1807    /// the reserve as a deterrent against the funder (SPEC §7.5 clause 1: it
1808    /// is priced correctly and is **not** compensation to induced mirrors).
1809    /// Curried at launch and immutable, same as [`Self::fee_bps`] beside it.
1810    pub withdrawal_share_bps: u16,
1811    /// The current reserve, in base units.
1812    pub reserve_base_units: u64,
1813    /// The current entry-set size.
1814    pub entry_count: u64,
1815    /// The current epoch index (`0`-based from `first_epoch_start`).
1816    pub current_distributor_epoch: u64,
1817    /// Unix seconds of the most recent entry-set write on chain, if any.
1818    /// `None` together with a non-zero `reserve_base_units` **implies
1819    /// stale**: an entry set that has never been written is maximally
1820    /// stale, not unknown, and a consumer MUST NOT render it as blank or
1821    /// "unknown" (SPEC §2.4 cl. 1 — silence is not an acceptable
1822    /// representation of "not distributing").
1823    pub last_entry_write_at: Option<u64>,
1824    /// `true` when the entry set has not changed in
1825    /// `STALE_ENTRY_SET_SECONDS = 172_800` (48 h) **while the reserve is
1826    /// non-zero** — SPEC §12.4. A drained distributor with a frozen entry set
1827    /// is not stale, it is [`Unfunded`](crate::types::ProverState::Unfunded);
1828    /// the non-zero-reserve conjunct exists to keep the two states distinct.
1829    /// The responder computes this at read time from the singleton's own
1830    /// on-chain spend history — it is not self-reported, so a wedged prover
1831    /// cannot fake it. See the type doc for why this boolean is safe here and
1832    /// forbidden on [`RewardProverStatus`].
1833    pub entry_set_stale: bool,
1834    /// Unix seconds this result was assembled — a WALL clock. Says nothing
1835    /// about chain freshness; read `chain_peak_timestamp` for that (see the
1836    /// type doc's "chain-view anchor" section).
1837    pub observed_at: u64,
1838    /// The distributor singleton's chain peak height at read time
1839    /// (`ChainObservation::peak_height`, widened losslessly from `u32`).
1840    /// Required — a responder with no chain peak in hand errors instead of
1841    /// answering. This is the field that carries REAL protection against a
1842    /// dishonest responder: compare it against a chain view the consumer
1843    /// obtained independently, not against anything else in this response.
1844    /// See the type doc's trust-boundary section. SPEC §4.5.
1845    pub chain_peak_height: u64,
1846    /// `block_timestamp(chain_peak_height)` — the CHAIN clock, never the wall
1847    /// clock, and the exact clock [`Self::entry_set_stale`] is computed
1848    /// against. Required, never `0`: a responder with no chain peak errors
1849    /// instead of answering. Self-consistency against `entry_set_stale` only
1850    /// catches an internally inconsistent responder — see the type doc's
1851    /// trust-boundary section. SPEC §4.5.
1852    pub chain_peak_timestamp: u64,
1853}
1854
1855/// Params for
1856/// [`dig.listRewardDistributorCommitments`](crate::method::Method::ListRewardDistributorCommitments).
1857#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1858#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1859pub struct ListRewardDistributorCommitmentsParams {
1860    /// The distributor singleton's launcher id (64-hex, required).
1861    pub launcher_id: HexId,
1862}
1863
1864/// One clawback commitment slot for a distributor epoch — SPEC §7.4 clause 5.
1865///
1866/// # `recoverable_base_units` is NOT `rewards_base_units`
1867///
1868/// Committed is not recoverable: SPEC §7.4 clause 4 / §7.5 return only
1869/// `withdrawal_share_bps / 10000` of the committed value on clawback (the
1870/// remainder is forfeited to the reserve — see
1871/// [`GetRewardDistributorResult::withdrawal_share_bps`]). Reporting only
1872/// `rewards_base_units` and letting a caller label it "recoverable" would
1873/// overstate every clawback by the forfeit fraction — exactly the
1874/// one-balance-figure money-honesty failure SPEC §7.4 clause 5 forbids,
1875/// relocated from a single total into a single per-slot figure. So the
1876/// **responder** reports `recoverable_base_units` itself, taken from
1877/// `dig-rewards-coin` >= 0.10.0 `Commitment::recoverable_base_units() ->
1878/// Option<u64>`, decided on `ChainObservation::peak_timestamp` (the same
1879/// chain clock as `chain_peak_timestamp`). See the field for its three
1880/// states.
1881///
1882/// Only the holder of the key for `clawback_puzzle_hash` may claw this slot
1883/// back — not an operator role, not the manager singleton, and not the
1884/// launcher (SPEC §7.4 clause 3). This field is the proof of entitlement; a
1885/// reader must not mistake it for a display label.
1886///
1887/// This type does not enforce any of the above: the field is a bare `pub`
1888/// value with no constructor or validation, so nothing here checks that the
1889/// responder reported what the chain would do.
1890#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1891#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1892pub struct RewardDistributorCommitment {
1893    /// The epoch this commitment slot funds.
1894    pub epoch_start: u64,
1895    /// The chain's `clawback_ph` (SPEC / `chia-sdk-types`
1896    /// `RewardDistributorCommitmentSlotValue`): the puzzle hash whose key
1897    /// holder alone may claw this slot back.
1898    pub clawback_puzzle_hash: HexId,
1899    /// The committed amount, in base units.
1900    pub rewards_base_units: u64,
1901    /// The amount recoverable on clawback, in base units, as of
1902    /// `chain_peak_timestamp` — SPEC §4.6. Three states, all distinct:
1903    ///
1904    /// - `Some(n)` with `n > 0`: `n` base units are recoverable now.
1905    /// - `Some(0)`: a genuine zero share that the chain ACCEPTS.
1906    /// - `None`: the chain REFUSES the clawback (the epoch has started).
1907    ///
1908    /// `None` is encoded as the key present with JSON `null` (never an omitted
1909    /// key); an omitted key decodes to `None`. An adapter MUST NOT map `None`
1910    /// to `0` or `Some(0)` to `None`: either recreates the "not recoverable
1911    /// reads as zero" lie (#3439).
1912    ///
1913    /// This figure carries a clock, but it is still **NOT an eligibility
1914    /// claim**: entitlement is key-holding against `clawback_puzzle_hash` and
1915    /// nothing else (SPEC §7.4 cl. 3).
1916    pub recoverable_base_units: Option<u64>,
1917}
1918
1919/// Result for
1920/// [`dig.listRewardDistributorCommitments`](crate::method::Method::ListRewardDistributorCommitments)
1921/// — SPEC §7.4 clause 5: per-epoch commitment slots, never a single balance
1922/// figure.
1923///
1924/// # The chain-view anchor — SPEC §4.5
1925///
1926/// `commitments` is read off the distributor singleton's on-chain slots, so
1927/// it carries the same chain-view anchor as [`GetRewardDistributorResult`]
1928/// and for the same reason: `observed_at` is the wall clock of reply
1929/// assembly, `chain_peak_height` / `chain_peak_timestamp` are the chain's own
1930/// clock at read time. See that type's doc for the full argument and the
1931/// refusal rule (both anchor fields required, never `Option`, never `0`).
1932#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1933#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1934pub struct ListRewardDistributorCommitmentsResult {
1935    /// The distributor singleton's launcher id (64-hex).
1936    pub launcher_id: HexId,
1937    /// The curried launch constant, echoed so a caller's row math (dividing
1938    /// `recoverable_base_units` by `rewards_base_units`) is auditable against
1939    /// the value that actually governs it. A responder MUST use this echoed
1940    /// value — never a compiled-in constant — when computing each row's
1941    /// `recoverable_base_units`, because the share is a launch-curried,
1942    /// per-distributor value that differs across distributors. A conforming
1943    /// responder MUST NOT emit a value above `10_000`.
1944    pub withdrawal_share_bps: u16,
1945    /// The payout epoch length, in seconds — a launch-curried, immutable
1946    /// distributor constant, echoed here so a caller can compute an epoch's
1947    /// end (`epoch_start + epoch_seconds`) or place a commitment on a
1948    /// calendar without a second `dig.getRewardDistributor` call. This is
1949    /// per-distributor and defaulted, not a fixed 7-day value, so it must
1950    /// not be hardcoded.
1951    pub epoch_seconds: u64,
1952    /// One entry per commitment slot. Empty is legitimate: a distributor
1953    /// funded only via `AddIncentives` has no clawback-eligible slots at all
1954    /// — an irrevocable donation, not an error.
1955    pub commitments: Vec<RewardDistributorCommitment>,
1956    /// Unix seconds this result was assembled — a WALL clock. Says nothing
1957    /// about chain freshness; read `chain_peak_timestamp` for that.
1958    pub observed_at: u64,
1959    /// The distributor singleton's chain peak height at read time. Required
1960    /// — see [`GetRewardDistributorResult::chain_peak_height`]. SPEC §4.5.
1961    pub chain_peak_height: u64,
1962    /// `block_timestamp(chain_peak_height)` — the chain clock. Required, never
1963    /// `0` — see [`GetRewardDistributorResult::chain_peak_timestamp`]. SPEC
1964    /// §4.5.
1965    pub chain_peak_timestamp: u64,
1966}
1967
1968// ===========================================================================
1969// dig.getPayeeRewardClaimStatus
1970// (CONTROL — loopback / in-process only; dig-rewards-coin SPEC §12.5 clause 6,
1971//  §2.4)
1972// ===========================================================================
1973
1974/// The subject of a **payee-side** status answer.
1975///
1976/// One variant on purpose, and named for that one variant on purpose. A payee's
1977/// claim-side posture and a funder's distributor-side posture are different
1978/// answers with different money behind them, and this crate keeps them in
1979/// different types rather than in two arms of one enum — so a payee-shaped
1980/// result structurally cannot be built carrying a funder's subject, whatever a
1981/// responder gets wrong. A funder answer, if this crate ever grows one, gets its
1982/// own type and its own literal.
1983///
1984/// The name carries that rule. A `RewardSubject` invites a `Funder` arm, and the
1985/// moment it has one the type guarantees nothing; `PayeeSubject` cannot absorb a
1986/// funder arm without a rename obviously wrong at the call site, which is the
1987/// point.
1988#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1989#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1990pub enum PayeeSubject {
1991    /// The node answering is speaking as a **payee** — a mirror with a claim,
1992    /// not the party that funded the reserve.
1993    #[serde(rename = "payee")]
1994    Payee,
1995}
1996
1997/// What a node can say about its own claim log: it read the log and has a count,
1998/// or it did not read the log and has no count to give.
1999///
2000/// # Why the count is inside the variant
2001///
2002/// The count and the fact that anything was counted cannot be separated, for the
2003/// same reason [`Half`]'s items live inside `Consulted`. A node whose claim log
2004/// is missing, unreadable, or failed to read has nothing to report, and a
2005/// `claims_submitted_count: 0` beside a freshly stamped `observed_at` is worse
2006/// than an undated zero: the date actively reassures the reader that something
2007/// looked at the log just now and found nothing, which is dig-rewards-coin SPEC
2008/// §12.5 clause 6's "reassuring zero" reproduced inside the type added to remove
2009/// it. There is no arm here that can carry a count without having read the log.
2010///
2011/// This is a sibling of [`Half`] rather than an instance of it: `Half` answers
2012/// for a *collection* and carries `items`, and forcing a scalar tally through it
2013/// would mean either a vacuous `Vec` or a wire key that names the wrong thing.
2014/// The vocabulary — `outcome`, two arms, `observed_at` in both — is deliberately
2015/// identical, so a reader who has learned one has learned the other.
2016///
2017/// `observed_at` dates the **consultation**, not the assembly of the answer:
2018/// in [`Consulted`](Self::Consulted) it is when the log was read, and in
2019/// [`NotConsulted`](Self::NotConsulted) it is when the responder established
2020/// that it could not read it. Fail-closed like [`Half`]: internally tagged, no
2021/// `#[serde(other)]`, no `Default`, no `skip_serializing_if`.
2022#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2023#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
2024#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2025pub enum ClaimLogObservation {
2026    /// The claim log WAS read as of `observed_at` (Unix seconds), and
2027    /// `claims_submitted_count` is what it held.
2028    Consulted {
2029        /// Unix seconds the claim log was read.
2030        observed_at: u64,
2031        /// How many claim submissions this node has made as a payee. A count of
2032        /// attempts, not of money: it says nothing about how much was settled,
2033        /// and a reader MUST NOT treat it as an amount or as a success count.
2034        claims_submitted_count: u64,
2035    },
2036    /// The claim log was NOT read as of `observed_at` (Unix seconds) — missing,
2037    /// unreadable, or the read failed. There is deliberately no count here.
2038    NotConsulted {
2039        /// Unix seconds the responder established it could not read the log.
2040        observed_at: u64,
2041    },
2042}
2043
2044/// The claim loop's own condition, as the loop last reported it — a closed
2045/// set of seven members (SPEC §4.4.3). `ClaimLoopState` mirrors the producer's
2046/// enumeration one-to-one, payloads included: a wire type with fewer states
2047/// than the loop can be in is a new instance of the defect this method exists
2048/// to remove, a type that cannot say something went wrong.
2049///
2050/// The tag key is `kind`, not `state` — the field holding this type is already
2051/// named `state`, and `"state": { "state": … }` would put two meanings under
2052/// one key in adjacent positions.
2053///
2054/// [`ClaimableButNotClaiming`](Self::ClaimableButNotClaiming) is the state a
2055/// conforming producer reports **whenever claims exist that the loop did not
2056/// make and no cycle-wide fault is live** — the anti-silence signal this
2057/// method exists to carry. It is never folded into
2058/// [`Nominal`](Self::Nominal) for lack of anywhere else to go, and a
2059/// cycle-wide fault is never folded into it either.
2060///
2061/// The five payload-less members ([`Idle`](Self::Idle),
2062/// [`ChainSourceUnavailable`](Self::ChainSourceUnavailable),
2063/// [`PersistedStateCorrupt`](Self::PersistedStateCorrupt),
2064/// [`CadenceNotElapsed`](Self::CadenceNotElapsed), [`Nominal`](Self::Nominal))
2065/// serialise as exactly `{ "kind": … }`. serde does not apply
2066/// `deny_unknown_fields` to the payload-less members of an internally tagged
2067/// enum, so `{ "kind": "idle", "claimable": 5 }` parses as `Idle` with the
2068/// stray key silently dropped (SPEC §4.4.1's one stated vacuity) — a
2069/// conforming producer never emits the stray key, and a consumer MUST NOT
2070/// read any key but `kind` from a payload-less state. The property this type
2071/// does enforce is the one that carries weight: the counts a reader acts on
2072/// live on [`ClaimLoopObservation::Consulted`], where unknown and missing
2073/// keys ARE rejected.
2074///
2075/// Not `#[non_exhaustive]`, on purpose: a wildcard arm in a consumer is
2076/// `#[serde(other)]` relocated to the render path, deciding at compile time
2077/// that a state nobody has heard of renders as something bland. A new member
2078/// is a SPEC amendment and a breaking release for every consumer.
2079#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2080#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
2081#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2082pub enum ClaimLoopState {
2083    /// No cycle has been attempted yet.
2084    Idle,
2085    /// The chain seam reported itself unavailable; no cycle ran. The true
2086    /// state, not a silent no-op.
2087    ChainSourceUnavailable,
2088    /// The loop's persisted claim state was unreadable, unparsable, over its
2089    /// own budget or future-dated; the loop treats its spend window as
2090    /// exhausted and submits nothing this cycle. A refusal made visible, not
2091    /// a freeze that reads as [`Nominal`](Self::Nominal).
2092    PersistedStateCorrupt,
2093    /// The claim cadence has not elapsed since the last completed cycle: a
2094    /// deliberate skip, named as its own condition rather than left as the
2095    /// absence of one.
2096    CadenceNotElapsed,
2097    /// A chain call this cycle failed with a real error, distinct from "no
2098    /// chain at all".
2099    Faulted {
2100        /// Consecutive cycles, including the current one, on which the loop
2101        /// has observed a cycle-wide fault. From a conforming producer this
2102        /// is at least 1; the type does not reject `0`.
2103        cycles: u64,
2104    },
2105    /// Fewer distributors were claimed against this cycle than should have
2106    /// been, and no fault is live. **The anti-silence signal**: the loop is
2107    /// running and paying nobody, or not everybody. This is the state
2108    /// reported whenever claims exist and none are being made.
2109    ClaimableButNotClaiming {
2110        /// This cycle's distributors that should have been claimed against.
2111        /// A count of distributors, never an amount, and at least
2112        /// [`ClaimLoopObservation::Consulted`]'s own
2113        /// `distributors_claimable` — the producer folds in refusals it
2114        /// never counted as claimable.
2115        claimable: u64,
2116        /// This cycle's distributors actually claimed against. From a
2117        /// conforming producer, strictly less than `claimable`; the type
2118        /// does not reject the contrary.
2119        submitted: u64,
2120    },
2121    /// A cycle completed and none of the above is true.
2122    Nominal,
2123}
2124
2125/// What a node can say about its own claim loop: it read the loop's last
2126/// reported condition and has counts and a state, or it did not read the loop
2127/// and has nothing to give.
2128///
2129/// A sibling of [`ClaimLogObservation`], same vocabulary: `outcome`, two
2130/// arms, `observed_at` in both. `observed_at` dates the **consultation** —
2131/// in [`Consulted`](Self::Consulted) it is when the loop last wrote the
2132/// status this arm reports, stamped by the loop in the cycle that produced
2133/// these numbers or, before any cycle, when the loop published its initial
2134/// `idle`; a responder MUST NOT stamp it with the clock of the RPC handler
2135/// that read the status. Re-dating the snapshot at read time manufactures
2136/// freshness for a number nobody produced just now, and only an
2137/// `observed_at` that stops advancing tells a reader a stuck loop's last
2138/// `nominal` is its last word rather than its current one.
2139///
2140/// In [`NotConsulted`](Self::NotConsulted) it is when the responder
2141/// established the loop's status could not be read: the loop is not
2142/// constructed (claiming disabled or not started) or its status was
2143/// unreadable — not "the chain is unreachable" (that is a
2144/// [`ClaimLoopState`]) and not "no distributors" (that is a count).
2145///
2146/// Fail-closed like [`ClaimLogObservation`]: internally tagged, no
2147/// `#[serde(other)]`, no `Default`, no `skip_serializing_if`,
2148/// `deny_unknown_fields` on the whole enum — a count or a verdict cannot
2149/// ride an arm that says nothing was read.
2150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2151#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
2152#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2153pub enum ClaimLoopObservation {
2154    /// The claim loop's status WAS read as of `observed_at` (Unix seconds).
2155    Consulted {
2156        /// Unix seconds the claim loop last wrote the status this arm
2157        /// reports.
2158        observed_at: u64,
2159        /// How many reward distributors this node's claim loop has
2160        /// discovered as candidates it might hold an entry in. A count of
2161        /// distributors, never an amount.
2162        distributors_known: u64,
2163        /// How many of those the loop judged claimable in the cycle dated by
2164        /// `observed_at`. A count of distributors, never an amount, and a
2165        /// per-cycle reading, not a lifetime total.
2166        distributors_claimable: u64,
2167        /// The loop's condition in the cycle dated by `observed_at`.
2168        state: ClaimLoopState,
2169    },
2170    /// The claim loop's status was NOT read as of `observed_at` (Unix
2171    /// seconds) — the loop is not constructed or its status was unreadable.
2172    /// There is deliberately no count and no state here.
2173    NotConsulted {
2174        /// Unix seconds the responder established it could not read the
2175        /// loop's status.
2176        observed_at: u64,
2177    },
2178}
2179
2180/// Result for
2181/// [`dig.getPayeeRewardClaimStatus`](crate::method::Method::GetPayeeRewardClaimStatus)
2182/// — the claim-side posture of the node answering, as a payee.
2183///
2184/// # The subject is on the wire, not inferred from the endpoint
2185///
2186/// [`subject`](Self::subject) is required and is the literal `"payee"`. It is
2187/// not redundant with the method name: this epic shipped a defect in which a
2188/// **funder's** distributor-wide total was rendered to a **payee** as that
2189/// operator's own earnings, overstating by up to 250x, and it passed security
2190/// review and a full green CI because no test asserted *whose* money the number
2191/// was. A renderer that reads `subject` off the payload cannot make that
2192/// substitution silently; one that infers the subject from which endpoint it
2193/// thinks it called can.
2194///
2195/// # No monetary amount, at all
2196///
2197/// There is deliberately no amount field here — not optional, not nullable,
2198/// absent. A payload that carries no amount has nothing a UI can misrender as
2199/// earnings, and that absence is the whole defence; an `Option<u64>` would not
2200/// be, because the misrendering path is a present number attributed to the wrong
2201/// party. A payee that wants its own settled history reads its own past
2202/// `InitiatePayout` spends, which are on chain and are evidence (SPEC §12.5
2203/// clause 7). The payout puzzle hash is likewise absent and MUST NOT be added:
2204/// it is the payee's payment identity, and nothing here needs it.
2205///
2206/// # No `#[serde(default)]`, no `skip_serializing_if`
2207///
2208/// Every field is required on the wire in both directions. A defaulting field is
2209/// a field a producer can omit and a consumer will invent — which for
2210/// [`subject`](Self::subject) would restore exactly the inferred-subject hazard
2211/// above, and for [`claim_log`](Self::claim_log) would manufacture a
2212/// consultation nobody performed.
2213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2214#[serde(deny_unknown_fields)]
2215#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2216pub struct PayeeClaimStatus {
2217    /// Always [`PayeeSubject::Payee`]; serialises as the literal
2218    /// `"subject": "payee"`.
2219    pub subject: PayeeSubject,
2220    /// This node's claim log: read, with the count it held, or not read at all.
2221    ///
2222    /// The count lives inside the observation so it cannot be separated from the
2223    /// fact that something read the log. The reader derives staleness itself
2224    /// from the observation's `observed_at` and its own clock (SPEC §2.4); no
2225    /// staleness is pre-computed here.
2226    pub claim_log: ClaimLogObservation,
2227    /// This node's claim loop: read, with its counts and last-reported
2228    /// state, or not read at all (new in 0.13.0, SPEC §4.4).
2229    ///
2230    /// New in 0.13.0. `deny_unknown_fields` on this struct means a 0.12.0
2231    /// payload — which has no `claim_loop` key — fails to parse rather than
2232    /// silently answering with a missing field: version skew fails loudly in
2233    /// both directions.
2234    pub claim_loop: ClaimLoopObservation,
2235}
2236
2237// ===========================================================================
2238// dig.health / dig.methods / rpc.discover  (discovery)
2239// ===========================================================================
2240
2241/// Result for [`dig.health`](crate::method::Method::Health) — liveness + a
2242/// capability summary.
2243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2244#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2245pub struct Health {
2246    /// Liveness — `"ok"` when the node can serve.
2247    pub status: String,
2248    /// The node's software version.
2249    #[serde(skip_serializing_if = "Option::is_none", default)]
2250    pub version: Option<String>,
2251    /// The DIG network id the node serves.
2252    #[serde(skip_serializing_if = "Option::is_none", default)]
2253    pub network_id: Option<String>,
2254    /// The method names this node implements (its profile).
2255    #[serde(default)]
2256    pub methods: Vec<String>,
2257}
2258
2259/// Result for [`dig.methods`](crate::method::Method::Methods) — the method names
2260/// this node implements (agent self-describe).
2261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2262#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2263pub struct Methods {
2264    /// The implemented method names.
2265    pub methods: Vec<String>,
2266}
2267
2268#[cfg(test)]
2269mod tests {
2270    use super::*;
2271    use serde_json::json;
2272
2273    /// The JSON object keys of `value`, sorted — so a key-set assertion reads as
2274    /// one line and fails naming the field that appeared or vanished.
2275    fn sorted_keys(value: &serde_json::Value) -> Vec<&str> {
2276        let mut keys: Vec<&str> = value
2277            .as_object()
2278            .expect("expected a JSON object")
2279            .keys()
2280            .map(String::as_str)
2281            .collect();
2282        keys.sort_unstable();
2283        keys
2284    }
2285
2286    /// **Proves:** `ContentChunk` round-trips a node-profile window (no
2287    /// network-profile fields) without inventing keys.
2288    /// **Catches:** a missing `skip_serializing_if` that would leak `null`
2289    /// network-profile fields onto the node profile.
2290    #[test]
2291    fn content_chunk_node_profile_is_lean() {
2292        let c = ContentChunk {
2293            ciphertext: "AAA=".into(),
2294            root: "ab".repeat(32),
2295            complete: false,
2296            next_offset: Some(3_145_728),
2297            inclusion_proof: Some("cHJvb2Y=".into()),
2298            chunk_lens: Some(vec![10, 20]),
2299            source: Some("local".into()),
2300            total_length: None,
2301            length: None,
2302            offset: None,
2303            program_hash: None,
2304        };
2305        let v = serde_json::to_value(&c).unwrap();
2306        assert_eq!(v["source"], "local");
2307        assert!(
2308            v.get("total_length").is_none(),
2309            "node profile must omit total_length"
2310        );
2311        assert!(v.get("program_hash").is_none());
2312        assert_eq!(serde_json::from_value::<ContentChunk>(v).unwrap(), c);
2313    }
2314
2315    /// **Proves:** the network-profile fields serialize when present.
2316    #[test]
2317    fn content_chunk_network_profile_carries_extras() {
2318        let c = ContentChunk {
2319            ciphertext: "AAA=".into(),
2320            root: "cd".repeat(32),
2321            complete: true,
2322            next_offset: None,
2323            inclusion_proof: None,
2324            chunk_lens: None,
2325            source: None,
2326            total_length: Some(100),
2327            length: Some(100),
2328            offset: Some(0),
2329            program_hash: Some("ef".repeat(32)),
2330        };
2331        let v = serde_json::to_value(&c).unwrap();
2332        assert_eq!(v["total_length"], 100);
2333        assert_eq!(v["length"], 100);
2334        assert!(v.get("source").is_none());
2335    }
2336
2337    /// **Proves:** the untagged `Inventory` picks `ForStore` vs `AllStores` by
2338    /// shape.
2339    /// **Catches:** a lost `#[serde(untagged)]` that would tag the variant.
2340    #[test]
2341    fn inventory_untagged_by_shape() {
2342        let for_store = Inventory::ForStore {
2343            store_id: "ab".repeat(32),
2344            roots: vec!["cd".repeat(32)],
2345        };
2346        let s = serde_json::to_string(&for_store).unwrap();
2347        assert!(s.contains("\"roots\""));
2348        assert!(!s.contains("ForStore"));
2349        assert_eq!(serde_json::from_str::<Inventory>(&s).unwrap(), for_store);
2350
2351        let all = Inventory::AllStores {
2352            stores: vec!["ef".repeat(32)],
2353        };
2354        let s = serde_json::to_string(&all).unwrap();
2355        assert!(s.contains("\"stores\""));
2356        assert_eq!(serde_json::from_str::<Inventory>(&s).unwrap(), all);
2357    }
2358
2359    /// **Proves:** `RedirectInfo` serializes the full redirect payload the
2360    /// `-32008` envelope carries.
2361    #[test]
2362    fn redirect_info_shape() {
2363        let r = RedirectInfo {
2364            content: ContentRef {
2365                store_id: "ab".repeat(32),
2366                root: Some("cd".repeat(32)),
2367                retrieval_key: Some("ef".repeat(32)),
2368            },
2369            providers: vec![Provider {
2370                peer_id: "12".repeat(32),
2371                addresses: vec![PeerAddress {
2372                    host: "::1".into(),
2373                    port: 9444,
2374                    kind: "direct".into(),
2375                }],
2376            }],
2377            redirect_depth: 1,
2378            max_redirects: 4,
2379        };
2380        let v = serde_json::to_value(&r).unwrap();
2381        assert_eq!(v["redirect_depth"], 1);
2382        assert_eq!(v["max_redirects"], 4);
2383        assert_eq!(v["providers"][0]["addresses"][0]["host"], "::1");
2384        assert_eq!(serde_json::from_value::<RedirectInfo>(v).unwrap(), r);
2385    }
2386
2387    /// **Proves:** `cache.stats` models the live dig-node result field-for-field
2388    /// (the nested `content_cache{hits,misses}` object included).
2389    /// **Catches:** a drift from the node's `cache.stats` wire shape (#1075).
2390    #[test]
2391    fn cache_stats_wire_shape() {
2392        let s = CacheStats {
2393            cap_bytes: 1 << 30,
2394            used_bytes: 2048,
2395            entry_count: 3,
2396            total_bytes: 2048,
2397            evicted_count: 1,
2398            evicted_bytes: 512,
2399            content_cache: ContentCacheCounters { hits: 7, misses: 2 },
2400        };
2401        let v = serde_json::to_value(s).unwrap();
2402        assert_eq!(v["cap_bytes"], 1 << 30);
2403        assert_eq!(v["entry_count"], 3);
2404        assert_eq!(v["content_cache"]["hits"], 7);
2405        assert_eq!(v["content_cache"]["misses"], 2);
2406        assert_eq!(serde_json::from_value::<CacheStats>(v).unwrap(), s);
2407    }
2408
2409    /// **Proves:** the subscription-management results carry the exact
2410    /// `{subscribed, added|removed, store_id}` / `{subscriptions, count}` shapes
2411    /// the live node returns.
2412    #[test]
2413    fn subscription_result_shapes() {
2414        let sub = SubscribeResult {
2415            subscribed: true,
2416            added: true,
2417            store_id: "ab".repeat(32),
2418        };
2419        let v = serde_json::to_value(&sub).unwrap();
2420        assert_eq!(v["subscribed"], true);
2421        assert_eq!(v["added"], true);
2422        assert_eq!(serde_json::from_value::<SubscribeResult>(v).unwrap(), sub);
2423
2424        let unsub = UnsubscribeResult {
2425            subscribed: false,
2426            removed: true,
2427            store_id: "cd".repeat(32),
2428        };
2429        let v = serde_json::to_value(&unsub).unwrap();
2430        assert_eq!(v["subscribed"], false);
2431        assert_eq!(v["removed"], true);
2432        assert_eq!(
2433            serde_json::from_value::<UnsubscribeResult>(v).unwrap(),
2434            unsub
2435        );
2436
2437        let list = SubscriptionsList {
2438            subscriptions: vec!["ef".repeat(32)],
2439            count: 1,
2440        };
2441        let v = serde_json::to_value(&list).unwrap();
2442        assert_eq!(v["count"], 1);
2443        assert_eq!(
2444            serde_json::from_value::<SubscriptionsList>(v).unwrap(),
2445            list
2446        );
2447    }
2448
2449    /// **Proves:** `ModuleInfo` carries `chunk_lens` covering every chunk, and
2450    /// round-trips with unknown future fields.
2451    /// **Catches:** a missing `chunk_lens` field that would leave a puller unable
2452    /// to map a fetched byte range to its covering chunk hash.
2453    /// **Invariants enforced by docs:** `chunk_lens` must have the same length as
2454    /// `chunk_hashes` and must sum to `total_size`.
2455    #[test]
2456    fn module_info_chunk_lens_shape() {
2457        let info = ModuleInfo {
2458            total_size: 1024,
2459            module_hash: "ab".repeat(32),
2460            chunk_hashes: vec!["cd".repeat(32), "ef".repeat(32)],
2461            chunk_lens: vec![512, 512],
2462        };
2463        let v = serde_json::to_value(&info).unwrap();
2464        assert_eq!(v["total_size"], 1024);
2465        assert_eq!(v["chunk_hashes"].as_array().unwrap().len(), 2);
2466        assert_eq!(v["chunk_lens"].as_array().unwrap().len(), 2);
2467        assert_eq!(v["chunk_lens"][0], 512);
2468        assert_eq!(v["chunk_lens"][1], 512);
2469        assert_eq!(serde_json::from_value::<ModuleInfo>(v).unwrap(), info);
2470    }
2471
2472    /// **Proves:** `ModuleInfo` deserialization REJECTS missing `chunk_lens` field.
2473    /// This is a REQUIRED field (not optional) — omitting it from the wire is a
2474    /// protocol violation and must fail-closed.
2475    #[test]
2476    fn module_info_rejects_missing_chunk_lens() {
2477        let json_str = r#"{"total_size": 2048, "module_hash": "1122334455667788990011223344556677889900112233445566778899001122", "chunk_hashes": []}"#;
2478        let result: Result<ModuleInfo, _> = serde_json::from_str(json_str);
2479        assert!(
2480            result.is_err(),
2481            "ModuleInfo must reject JSON missing the required chunk_lens field"
2482        );
2483        let err = result.unwrap_err();
2484        assert!(
2485            err.to_string().contains("chunk_lens"),
2486            "error message should mention chunk_lens: {}",
2487            err
2488        );
2489    }
2490
2491    /// **Proves:** the peer connect/disconnect params + results round-trip and
2492    /// match the node's `{connected|disconnected, peer_id}` shapes.
2493    #[test]
2494    fn peer_connect_disconnect_shapes() {
2495        let p = PeerConnectParams {
2496            peer: "12".repeat(32),
2497        };
2498        let v = serde_json::to_value(&p).unwrap();
2499        assert_eq!(serde_json::from_value::<PeerConnectParams>(v).unwrap(), p);
2500
2501        let c = PeerConnectResult {
2502            connected: true,
2503            peer_id: "12".repeat(32),
2504        };
2505        let v = serde_json::to_value(&c).unwrap();
2506        assert_eq!(v["connected"], true);
2507        assert_eq!(serde_json::from_value::<PeerConnectResult>(v).unwrap(), c);
2508
2509        let d = PeerDisconnectResult {
2510            disconnected: true,
2511            peer_id: "34".repeat(32),
2512        };
2513        let v = serde_json::to_value(&d).unwrap();
2514        assert_eq!(v["disconnected"], true);
2515        assert_eq!(
2516            serde_json::from_value::<PeerDisconnectResult>(v).unwrap(),
2517            d
2518        );
2519    }
2520
2521    /// **Proves:** `cache.getConfig` uses the canonical `cache_dir` field name.
2522    /// **Catches:** a regression to the shell's historical `dir` name.
2523    #[test]
2524    fn cache_config_field_name_is_cache_dir() {
2525        let c = CacheConfig {
2526            cap_bytes: 1 << 30,
2527            used_bytes: 0,
2528            cache_dir: "/var/cache/dig".into(),
2529            shared: true,
2530        };
2531        let v = serde_json::to_value(&c).unwrap();
2532        assert!(v.get("cache_dir").is_some());
2533        assert!(v.get("dir").is_none(), "must not use the legacy `dir` name");
2534    }
2535
2536    /// **Proves:** an OLDER client's `dig.getAvailability` params — written
2537    /// before the hop budget existed — still deserialize, and read as a fresh,
2538    /// unhopped ask.
2539    /// **Catches:** a `redirect_depth` declared as a required `u64`, which
2540    /// rejects exactly these params with `missing field redirect_depth` and would
2541    /// make every pre-0.8 caller's ask a parse error at the peer boundary.
2542    /// **Guarded by:** the field's `Option` TYPE. `serde`'s derive already reads a
2543    /// missing `Option` field as `None`, so the `#[serde(default)]` beside it is
2544    /// parity with the sibling params types rather than the live guard — removing
2545    /// it alone leaves this test green (mutant-tested). Do not cite the attribute
2546    /// as the thing that keeps older clients working.
2547    #[test]
2548    fn get_availability_params_accepts_an_older_clients_params() {
2549        let older = json!({
2550            "items": [ { "store_id": "ab".repeat(32) } ]
2551        });
2552        let p: GetAvailabilityParams = serde_json::from_value(older).unwrap();
2553        assert_eq!(p.items.len(), 1);
2554        assert_eq!(p.redirect_depth, None, "an absent budget stays absent");
2555        assert_eq!(p.hops_consumed(), 0, "absent means zero hops consumed");
2556    }
2557
2558    /// **Proves:** a hop-zero ask serializes to exactly the pre-0.8 bytes — the
2559    /// `redirect_depth` key is absent, not `null`.
2560    /// **Catches:** a bare `#[serde(default)]` without `skip_serializing_if`,
2561    /// which would add `"redirect_depth": null` to every existing caller's
2562    /// frame and change the wire for callers that never opted in.
2563    #[test]
2564    fn get_availability_params_omits_an_absent_hop_budget() {
2565        let p = GetAvailabilityParams::new(vec![AvailabilityQuery {
2566            store_id: "ab".repeat(32),
2567            root: None,
2568            retrieval_key: None,
2569        }]);
2570        let v = serde_json::to_value(&p).unwrap();
2571        let keys: Vec<&String> = v.as_object().unwrap().keys().collect();
2572        assert_eq!(keys, vec!["items"], "hop-zero params carry only `items`");
2573    }
2574
2575    /// **Proves:** a hopped ask round-trips its budget under the `redirect_depth`
2576    /// key, and reads back through `hops_consumed`.
2577    #[test]
2578    fn get_availability_params_round_trips_the_hop_budget() {
2579        let p = GetAvailabilityParams::new(vec![AvailabilityQuery {
2580            store_id: "cd".repeat(32),
2581            root: Some("ef".repeat(32)),
2582            retrieval_key: None,
2583        }])
2584        .with_redirect_depth(2);
2585        let v = serde_json::to_value(&p).unwrap();
2586        assert_eq!(v["redirect_depth"], 2);
2587        assert_eq!(p.hops_consumed(), 2);
2588        assert_eq!(
2589            serde_json::from_value::<GetAvailabilityParams>(v).unwrap(),
2590            p
2591        );
2592    }
2593
2594    /// **Proves:** an older client params object — written before ANY of the
2595    /// recursive-ask fields existed — still deserializes, and every new field reads
2596    /// as its documented absent value.
2597    /// **Catches:** any of the three declared as required, which would turn every
2598    /// pre-0.9 caller ask into `missing field` at the peer boundary.
2599    #[test]
2600    fn get_availability_params_accepts_a_client_older_than_the_recursive_ask() {
2601        let older = json!({ "items": [ { "store_id": "ab".repeat(32) } ] });
2602        let p: GetAvailabilityParams = serde_json::from_value(older).unwrap();
2603
2604        assert_eq!(p.budget_ms(), None, "absent budget_ms means unbudgeted");
2605        assert_eq!(p.ask_id(), None, "absent ask_id means dedup opted out");
2606        assert_eq!(p.hops_consumed(), 0);
2607    }
2608
2609    /// **Proves:** a params object carrying no recursive-ask fields serializes to
2610    /// exactly the pre-0.9 bytes — the three new keys are ABSENT, not `null`.
2611    /// **Catches:** a bare `#[serde(default)]` without `skip_serializing_if`, which
2612    /// would add `"budget_ms": null` and `"ask_id": null` to the frame of every
2613    /// caller that never opted in.
2614    #[test]
2615    fn get_availability_params_omits_absent_recursive_ask_fields() {
2616        let p = GetAvailabilityParams::new(vec![AvailabilityQuery {
2617            store_id: "ab".repeat(32),
2618            root: None,
2619            retrieval_key: None,
2620        }]);
2621        let v = serde_json::to_value(&p).unwrap();
2622        let keys: Vec<&String> = v.as_object().unwrap().keys().collect();
2623        assert_eq!(keys, vec!["items"], "a plain ask carries only `items`");
2624    }
2625
2626    /// **Proves:** the time budget and the hop budget are two INDEPENDENT fields
2627    /// under two distinct keys, each round-tripping its own value.
2628    /// **Catches:** the shape defect this addition exists to prevent — folding the
2629    /// time budget into `redirect_depth`. The fixture sets them to DIFFERENT values
2630    /// (2 hops, 9000 ms) precisely so a single backing integer cannot satisfy both
2631    /// assertions; equal values would pass under either shape.
2632    #[test]
2633    fn the_time_budget_is_a_separate_field_from_the_hop_budget() {
2634        let p = GetAvailabilityParams::new(vec![AvailabilityQuery {
2635            store_id: "cd".repeat(32),
2636            root: None,
2637            retrieval_key: None,
2638        }])
2639        .with_redirect_depth(2)
2640        .with_budget_ms(9_000);
2641
2642        let v = serde_json::to_value(&p).unwrap();
2643        assert_eq!(v["redirect_depth"], 2, "hops counted UP from zero");
2644        assert_eq!(v["budget_ms"], 9_000, "milliseconds counted DOWN to zero");
2645        assert_eq!(p.hops_consumed(), 2);
2646        assert_eq!(p.budget_ms(), Some(9_000));
2647        assert_eq!(
2648            serde_json::from_value::<GetAvailabilityParams>(v).unwrap(),
2649            p
2650        );
2651    }
2652
2653    /// **Proves:** a zero time budget survives the wire as `Some(0)` and is NOT
2654    /// erased into `None`.
2655    /// **Catches:** a `skip_serializing_if` written over the VALUE rather than the
2656    /// Option (`is_zero`-style), which would make "you have no time left, do not ask
2657    /// onward" indistinguishable from "unbudgeted, use your own policy" — exactly
2658    /// inverting the field on the one hop where it matters most.
2659    #[test]
2660    fn a_zero_time_budget_is_not_the_same_as_an_absent_one() {
2661        let exhausted = GetAvailabilityParams::new(vec![]).with_budget_ms(0);
2662        let v = serde_json::to_value(&exhausted).unwrap();
2663
2664        assert_eq!(v["budget_ms"], 0, "an exhausted budget stays on the wire");
2665        assert_eq!(
2666            serde_json::from_value::<GetAvailabilityParams>(v)
2667                .unwrap()
2668                .budget_ms(),
2669            Some(0)
2670        );
2671        assert_eq!(
2672            GetAvailabilityParams::new(vec![]).budget_ms(),
2673            None,
2674            "unbudgeted is a different state from budget zero"
2675        );
2676    }
2677
2678    /// **Proves:** `ask_id` round-trips verbatim under its own key, and is NOT the
2679    /// JSON-RPC `id`.
2680    /// **Catches:** an implementation that reuses the envelope correlator for dedup.
2681    /// The fixture puts a hardcoded `"id": 1` — the exact value dig-node was sending
2682    /// — beside a real 16-byte ask id in one envelope, so a reader that took the
2683    /// correlator would see `1` and disagree with both assertions.
2684    #[test]
2685    fn the_ask_id_is_not_the_jsonrpc_correlator() {
2686        const ASK_ID: &str = "3f9c1a04b7e25d68f0a1c3b5d7e9f012";
2687        assert_eq!(ASK_ID.len(), 32, "16 random bytes as lowercase hex");
2688
2689        let envelope = json!({
2690            "jsonrpc": "2.0",
2691            "id": 1,
2692            "method": "dig.getAvailability",
2693            "params": {
2694                "items": [ { "store_id": "ab".repeat(32) } ],
2695                "ask_id": ASK_ID,
2696            }
2697        });
2698
2699        let p: GetAvailabilityParams = serde_json::from_value(envelope["params"].clone()).unwrap();
2700        assert_eq!(p.ask_id(), Some(ASK_ID));
2701        assert_ne!(
2702            p.ask_id(),
2703            Some("1"),
2704            "the dedup identity must not be read from the envelope `id`"
2705        );
2706        assert_eq!(envelope["id"], 1, "the correlator is untouched beside it");
2707    }
2708
2709    /// **Proves:** `absence_established` distinguishes THREE states on the wire —
2710    /// asserted, explicitly-not-established, and unknown-because-older-server — and
2711    /// that the unknown state serializes as an ABSENT key rather than `false`.
2712    /// **Catches:** the collapse this field exists to prevent. The fixture carries
2713    /// all three answers in ONE batch, so a `bool` with `#[serde(default)]` (which
2714    /// would read the old server answer as `false`) makes the second and third
2715    /// answers compare EQUAL and the test fails; a fixture with only one answer
2716    /// could not see that.
2717    #[test]
2718    fn absence_established_keeps_absent_distinct_from_false() {
2719        let asserted = AvailabilityAnswer {
2720            available: false,
2721            absence_established: Some(true),
2722            ..Default::default()
2723        };
2724        let inconclusive = AvailabilityAnswer {
2725            available: false,
2726            absence_established: Some(false),
2727            ..Default::default()
2728        };
2729        let older_server = AvailabilityAnswer {
2730            available: false,
2731            ..Default::default()
2732        };
2733
2734        assert_ne!(
2735            inconclusive, older_server,
2736            "an explicit `false` is a claim; an absent field is not"
2737        );
2738        assert_eq!(asserted.absence_established_or_unknown(), Some(true));
2739        assert_eq!(inconclusive.absence_established_or_unknown(), Some(false));
2740        assert_eq!(
2741            older_server.absence_established_or_unknown(),
2742            None,
2743            "an older server makes no claim either way"
2744        );
2745
2746        let batch = serde_json::to_value(AvailabilityBatch {
2747            items: vec![asserted, inconclusive, older_server],
2748        })
2749        .unwrap();
2750        assert_eq!(batch["items"][0]["absence_established"], true);
2751        assert_eq!(batch["items"][1]["absence_established"], false);
2752        assert!(
2753            batch["items"][2].get("absence_established").is_none(),
2754            "the unknown state is an absent key, never `false` and never `null`"
2755        );
2756    }
2757
2758    /// **Proves:** an OLDER client can still read a NEWER answer — the added field
2759    /// does not break the shipped shape (§5.1).
2760    #[test]
2761    fn an_answer_carrying_the_new_field_still_parses_as_the_shipped_shape() {
2762        let newer = json!({
2763            "items": [ { "available": false, "absence_established": true } ]
2764        });
2765        let b: AvailabilityBatch = serde_json::from_value(newer).unwrap();
2766        assert_eq!(b.items.len(), 1);
2767        assert!(!b.items[0].available);
2768        assert_eq!(b.items[0].absence_established_or_unknown(), Some(true));
2769    }
2770
2771    /// **Proves:** the hop budget an availability ask carries is the SAME field,
2772    /// with the same key, type and value, that a `-32008` redirect hands back and
2773    /// that `dig.getContent` / `dig.fetchRange` already echo — one field, one
2774    /// interpretation, counted UP toward `max_redirects`.
2775    /// **Catches:** a second reading of the budget in this crate (a remaining
2776    /// allowance counting DOWN, a differently-named key, a differently-typed
2777    /// value) — the byte-drift the shipped redirect contract exists to prevent.
2778    #[test]
2779    fn availability_hop_budget_mirrors_the_redirect_budget() {
2780        let handed_back = RedirectInfo {
2781            content: ContentRef {
2782                store_id: "ab".repeat(32),
2783                root: None,
2784                retrieval_key: None,
2785            },
2786            providers: vec![],
2787            redirect_depth: 3,
2788            max_redirects: 4,
2789        };
2790        let echoed = handed_back.redirect_depth;
2791
2792        let availability = serde_json::to_value(
2793            GetAvailabilityParams::new(vec![AvailabilityQuery {
2794                store_id: "ab".repeat(32),
2795                root: None,
2796                retrieval_key: None,
2797            }])
2798            .with_redirect_depth(echoed),
2799        )
2800        .unwrap();
2801        let content = serde_json::to_value(GetContentParams {
2802            store_id: "ab".repeat(32),
2803            retrieval_key: "cd".repeat(32),
2804            root: None,
2805            offset: None,
2806            mode: None,
2807            redirect_depth: Some(echoed),
2808        })
2809        .unwrap();
2810        let range = serde_json::to_value(
2811            FetchRangeParams::resource("ab".repeat(32), "cd".repeat(32), "ef".repeat(32), 1)
2812                .with_redirect_depth(echoed),
2813        )
2814        .unwrap();
2815
2816        for (method, params) in [
2817            ("dig.getAvailability", &availability),
2818            ("dig.getContent", &content),
2819            ("dig.fetchRange", &range),
2820        ] {
2821            assert_eq!(
2822                params["redirect_depth"], 3,
2823                "{method} must carry the echoed depth under `redirect_depth`"
2824            );
2825        }
2826        assert!(
2827            handed_back.redirect_depth < handed_back.max_redirects,
2828            "the budget counts UP toward `max_redirects`"
2829        );
2830    }
2831
2832    /// **Proves:** a NEWER client's params — carrying a field this build does not
2833    /// know — still deserialize, so a hop-bearing ask is never refused outright by
2834    /// an older responder that simply ignores the budget.
2835    /// **Catches:** a `#[serde(deny_unknown_fields)]` added to the params type,
2836    /// which would turn every forward-compatible extension into a hard parse
2837    /// failure at the peer boundary.
2838    #[test]
2839    fn get_availability_params_tolerates_an_unknown_field() {
2840        let newer = json!({
2841            "items": [ { "store_id": "ab".repeat(32) } ],
2842            "redirect_depth": 1,
2843            "a_field_this_build_does_not_know": true
2844        });
2845        let p: GetAvailabilityParams = serde_json::from_value(newer).unwrap();
2846        assert_eq!(p.hops_consumed(), 1);
2847    }
2848
2849    // -----------------------------------------------------------------
2850    // dig.listRewardDistributors / dig.getRewardProverStatus /
2851    // dig.getRewardDistributor  (#3250, dig-rewards-coin SPEC §2.3/§2.6)
2852    // -----------------------------------------------------------------
2853
2854    fn sample_prover_status() -> RewardProverStatus {
2855        RewardProverStatus {
2856            launcher_id: "ab".repeat(32),
2857            store_id: "cd".repeat(32),
2858            root: "ef".repeat(32),
2859            prover_state: ProverState::Running,
2860            prover_state_since: 1_000,
2861            last_cycle_started_at: Some(1_050),
2862            last_cycle_completed_at: None,
2863            next_cycle_due_at: Some(1_600),
2864            last_entry_write_at: Some(900),
2865            consecutive_cycle_failures: 0,
2866            pending_entry_writes: 2,
2867            observed_at: 1_700,
2868            counters: ProverCounters {
2869                mirrors_seen: 3,
2870                challenges_issued: 10,
2871                challenges_passed: 9,
2872                challenges_failed: 1,
2873                entries_added: 5,
2874                entries_removed: 1,
2875                entry_count: 4,
2876                reserve_base_units: 12_345,
2877                total_paid_out_base_units: 6_789,
2878            },
2879        }
2880    }
2881
2882    /// **Proves:** `RewardProverStatus` round-trips through serde.
2883    #[test]
2884    fn reward_prover_status_round_trips() {
2885        let status = sample_prover_status();
2886        let json = serde_json::to_string(&status).unwrap();
2887        let back: RewardProverStatus = serde_json::from_str(&json).unwrap();
2888        assert_eq!(status, back);
2889    }
2890
2891    /// **Proves:** `RewardProverStatus`'s JSON key set is EXACTLY the SPEC §2.3
2892    /// field list, and contains NEITHER a health boolean NOR a pre-computed
2893    /// staleness field — SPEC §2.4: a wedged loop cannot report its own
2894    /// wedging, so a boolean the writer sets reads true forever after the
2895    /// failure it exists to reveal.
2896    /// **Catches:** a `healthy`/`ok`/`up`/`running`/`stale`/
2897    /// `seconds_since_last_run`/`is_healthy` field reintroduced onto the
2898    /// self-reported prover record.
2899    #[test]
2900    fn reward_prover_status_has_no_health_boolean_and_exact_keys() {
2901        let value = serde_json::to_value(sample_prover_status()).unwrap();
2902        let obj = value.as_object().unwrap();
2903        let mut got: Vec<&str> = obj.keys().map(String::as_str).collect();
2904        got.sort_unstable();
2905
2906        let mut want = vec![
2907            "launcher_id",
2908            "store_id",
2909            "root",
2910            "prover_state",
2911            "prover_state_since",
2912            "last_cycle_started_at",
2913            "next_cycle_due_at",
2914            "last_entry_write_at",
2915            "consecutive_cycle_failures",
2916            "pending_entry_writes",
2917            "observed_at",
2918            "counters",
2919        ];
2920        // `last_cycle_completed_at` is `None` in the fixture and this type has
2921        // no `skip_serializing_if`, so it still serializes as `null` — include it.
2922        want.push("last_cycle_completed_at");
2923        want.sort_unstable();
2924        assert_eq!(got, want);
2925
2926        // Recurse into `counters` too — a smuggled health flag could hide one
2927        // level down, and a substring check on the serialized string would
2928        // miss it (and would also be actively wrong: `ProverState::Running`
2929        // legitimately serializes the *value* `"running"`, so a
2930        // `!s.contains("running")` assertion fails on honest input while
2931        // still passing a smuggled `isRunning` key).
2932        let counters_keys: Vec<&str> = value["counters"]
2933            .as_object()
2934            .unwrap()
2935            .keys()
2936            .map(String::as_str)
2937            .collect();
2938        let mut all_keys = got.clone();
2939        all_keys.extend(counters_keys);
2940
2941        for forbidden in [
2942            "healthy",
2943            "ok",
2944            "up",
2945            "running",
2946            "isRunning",
2947            "stale",
2948            "isStale",
2949            "staleness",
2950            "secondsSinceLastRun",
2951            "uptime",
2952            "alive",
2953            "live",
2954            "lastRunSecondsAgo",
2955        ] {
2956            assert!(
2957                !all_keys.contains(&forbidden),
2958                "RewardProverStatus (incl. counters) must not carry `{forbidden}` (SPEC §2.4)"
2959            );
2960        }
2961
2962        // Also assert directly against the Rust field list, independent of the
2963        // JSON round-trip, so a `#[serde(rename)]` cannot hide a violation.
2964        got.retain(|k| *k != "prover_state"); // enum-typed; checked separately below.
2965    }
2966
2967    /// **Proves:** `ProverState` deserializes each of the nine named SPEC §2.3
2968    /// variants and REJECTS an unknown string — fail-closed: no
2969    /// `#[serde(other)]`, no `Default`.
2970    #[test]
2971    fn prover_state_covers_the_closed_set_and_rejects_unknown() {
2972        let known = [
2973            ("idle", ProverState::Idle),
2974            ("running", ProverState::Running),
2975            ("localCopyMissing", ProverState::LocalCopyMissing),
2976            (
2977                "chainSourceUnavailable",
2978                ProverState::ChainSourceUnavailable,
2979            ),
2980            ("unfunded", ProverState::Unfunded),
2981            ("feeBudgetExhausted", ProverState::FeeBudgetExhausted),
2982            ("entrySetFull", ProverState::EntrySetFull),
2983            ("paused", ProverState::Paused),
2984            ("stopped", ProverState::Stopped),
2985        ];
2986        assert_eq!(known.len(), 9, "the SPEC §2.3 set has exactly nine members");
2987        for (wire, variant) in known {
2988            let got: ProverState = serde_json::from_value(json!(wire)).unwrap();
2989            assert_eq!(got, variant, "{wire}");
2990            assert_eq!(serde_json::to_value(variant).unwrap(), json!(wire));
2991        }
2992
2993        let err = serde_json::from_value::<ProverState>(json!("somethingElse"));
2994        assert!(
2995            err.is_err(),
2996            "an unknown ProverState string must be rejected"
2997        );
2998    }
2999
3000    /// **Proves:** `GetRewardProverStatusParams` / `GetRewardProverStatusResult`
3001    /// round-trip, and the statuses half is a `Half`.
3002    #[test]
3003    fn get_reward_prover_status_types_round_trip() {
3004        let params = GetRewardProverStatusParams {
3005            launcher_id: Some("ab".repeat(32)),
3006        };
3007        let back: GetRewardProverStatusParams =
3008            serde_json::from_str(&serde_json::to_string(&params).unwrap()).unwrap();
3009        assert_eq!(params, back);
3010
3011        let result = GetRewardProverStatusResult {
3012            statuses: Half::Consulted {
3013                observed_at: 1_700,
3014                items: vec![sample_prover_status()],
3015            },
3016        };
3017        let back: GetRewardProverStatusResult =
3018            serde_json::from_str(&serde_json::to_string(&result).unwrap()).unwrap();
3019        assert_eq!(result, back);
3020    }
3021
3022    /// **Proves:** "this node runs no prover loops" and "this node could not
3023    /// read its prover registry" are DIFFERENT wire payloads, each dated.
3024    /// **Catches:** `GetRewardProverStatusResult.statuses` reverting to a bare
3025    /// `Vec`, where an unreadable registry renders as a confident "none" on a
3026    /// reward surface.
3027    #[test]
3028    fn unconsulted_prover_registry_is_distinguishable_from_an_empty_one() {
3029        let not_consulted = GetRewardProverStatusResult {
3030            statuses: Half::NotConsulted { observed_at: 1_700 },
3031        };
3032        let consulted_and_empty = GetRewardProverStatusResult {
3033            statuses: Half::Consulted {
3034                observed_at: 1_700,
3035                items: vec![],
3036            },
3037        };
3038
3039        let a = serde_json::to_value(&not_consulted).unwrap();
3040        let b = serde_json::to_value(&consulted_and_empty).unwrap();
3041        assert_ne!(
3042            a, b,
3043            "an unread prover registry must not serialise as an empty one"
3044        );
3045        assert_eq!(a["statuses"]["outcome"], "not_consulted");
3046        assert_eq!(a["statuses"]["observed_at"], 1_700);
3047        assert_eq!(b["statuses"]["outcome"], "consulted");
3048        assert_eq!(b["statuses"]["observed_at"], 1_700);
3049
3050        let back: GetRewardProverStatusResult = serde_json::from_value(a).unwrap();
3051        assert_eq!(back, not_consulted);
3052        let back: GetRewardProverStatusResult = serde_json::from_value(b).unwrap();
3053        assert_eq!(back, consulted_and_empty);
3054    }
3055
3056    /// **Proves:** `ListRewardDistributorsResult` round-trips and its JSON keys
3057    /// are exactly `funded` / `claimable`, each a `Half` object carrying its own
3058    /// `outcome`, `observed_at` and `items` (SPEC §2.6, §12.5 clause 6).
3059    #[test]
3060    fn list_reward_distributors_result_round_trips_with_exact_keys() {
3061        let result = ListRewardDistributorsResult {
3062            funded: Half::Consulted {
3063                observed_at: 1_700,
3064                items: vec![RewardDistributorRef {
3065                    launcher_id: "11".repeat(32),
3066                    store_id: "22".repeat(32),
3067                    root: "33".repeat(32),
3068                }],
3069            },
3070            claimable: Half::Consulted {
3071                observed_at: 1_700,
3072                items: vec![],
3073            },
3074        };
3075        let value = serde_json::to_value(&result).unwrap();
3076        assert_eq!(sorted_keys(&value), vec!["claimable", "funded"]);
3077        assert_eq!(
3078            sorted_keys(&value["funded"]),
3079            vec!["items", "observed_at", "outcome"],
3080            "a half must carry its items INSIDE the tagged observation"
3081        );
3082
3083        let back: ListRewardDistributorsResult = serde_json::from_value(value).unwrap();
3084        assert_eq!(result, back);
3085    }
3086
3087    /// **Proves:** "the mirror-claim half was not consulted" and "it was
3088    /// consulted and is empty" are DIFFERENT wire payloads — the whole point of
3089    /// SPEC §12.5 clause 6. Both carry an `observed_at`, so neither is a bare,
3090    /// undated zero.
3091    /// **Catches:** the dated absence collapsing back into a plain empty vector,
3092    /// which is how a funder-only answer came to read as "you have no mirror
3093    /// claims".
3094    #[test]
3095    fn unconsulted_claimable_half_is_distinguishable_from_an_empty_one() {
3096        let not_consulted = ListRewardDistributorsResult {
3097            funded: Half::Consulted {
3098                observed_at: 1_700,
3099                items: vec![],
3100            },
3101            claimable: Half::NotConsulted { observed_at: 1_700 },
3102        };
3103        let consulted_and_empty = ListRewardDistributorsResult {
3104            claimable: Half::Consulted {
3105                observed_at: 1_700,
3106                items: vec![],
3107            },
3108            ..not_consulted.clone()
3109        };
3110
3111        let a = serde_json::to_value(&not_consulted).unwrap();
3112        let b = serde_json::to_value(&consulted_and_empty).unwrap();
3113        assert_ne!(
3114            a, b,
3115            "an unconsulted half must not serialise as an empty one"
3116        );
3117        assert_eq!(a["claimable"]["outcome"], "not_consulted");
3118        assert_eq!(a["claimable"]["observed_at"], 1_700);
3119        assert_eq!(b["claimable"]["outcome"], "consulted");
3120
3121        let back: ListRewardDistributorsResult = serde_json::from_value(a).unwrap();
3122        assert_eq!(back, not_consulted);
3123    }
3124
3125    /// **Proves:** "this node's funded half was not consulted" and "it was
3126    /// consulted and is empty" are DIFFERENT wire payloads, each dated, exactly
3127    /// as for the mirror-claim half (SPEC §12.5 clause 6).
3128    /// **Catches:** a node that cannot read its own funder registry -- not
3129    /// configured, persisted state corrupt, or the read failed -- reporting an
3130    /// empty funded list, which states that the operator funds nothing.
3131    #[test]
3132    fn unconsulted_funded_half_is_distinguishable_from_an_empty_one() {
3133        let not_consulted = ListRewardDistributorsResult {
3134            funded: Half::NotConsulted { observed_at: 1_700 },
3135            claimable: Half::Consulted {
3136                observed_at: 1_700,
3137                items: vec![],
3138            },
3139        };
3140        let consulted_and_empty = ListRewardDistributorsResult {
3141            funded: Half::Consulted {
3142                observed_at: 1_700,
3143                items: vec![],
3144            },
3145            ..not_consulted.clone()
3146        };
3147
3148        let a = serde_json::to_value(&not_consulted).unwrap();
3149        let b = serde_json::to_value(&consulted_and_empty).unwrap();
3150        assert_ne!(
3151            a, b,
3152            "an unconsulted funded half must not serialise as an empty one"
3153        );
3154        assert_eq!(a["funded"]["outcome"], "not_consulted");
3155        assert_eq!(a["funded"]["observed_at"], 1_700);
3156        assert_eq!(b["funded"]["outcome"], "consulted");
3157        assert_eq!(b["funded"]["observed_at"], 1_700);
3158
3159        let back: ListRewardDistributorsResult = serde_json::from_value(a).unwrap();
3160        assert_eq!(back, not_consulted);
3161        let back: ListRewardDistributorsResult = serde_json::from_value(b).unwrap();
3162        assert_eq!(back, consulted_and_empty);
3163    }
3164
3165    /// **Proves:** "nothing looked, and here are the results" is UNREPRESENTABLE
3166    /// — in Rust, because [`Half::NotConsulted`] has no `items` field at all; and
3167    /// on the wire, because the arm denies unknown fields, so a producer that
3168    /// emits both is a parse ERROR rather than having its items silently dropped.
3169    /// **Catches:** the observation drifting back out beside the collection,
3170    /// where only prose forbids the contradiction (dig_ecosystem#3269
3171    /// Condition 1).
3172    #[test]
3173    fn not_consulted_carrying_items_does_not_parse() {
3174        let contradiction = serde_json::json!({
3175            "outcome": "not_consulted",
3176            "observed_at": 1_700,
3177            "items": [{"launcher_id": "11".repeat(32),
3178                       "store_id": "22".repeat(32),
3179                       "root": "33".repeat(32)}],
3180        });
3181        assert!(
3182            serde_json::from_value::<Half<RewardDistributorRef>>(contradiction).is_err(),
3183            "\"not consulted, and here are the items\" must not parse"
3184        );
3185    }
3186
3187    /// **Proves:** `Half` is fail-closed — an unknown outcome, or one missing its
3188    /// `observed_at`, is a parse ERROR, never a silently coerced "consulted" (the
3189    /// `ProverState` discipline, SPEC §12.5 clause 6's ban on an undated absence).
3190    #[test]
3191    fn half_rejects_unknown_and_undated_outcomes() {
3192        let unknown = serde_json::json!({"outcome": "maybe", "observed_at": 1, "items": []});
3193        assert!(serde_json::from_value::<Half<RewardDistributorRef>>(unknown).is_err());
3194
3195        let undated = serde_json::json!({"outcome": "not_consulted"});
3196        assert!(serde_json::from_value::<Half<RewardDistributorRef>>(undated).is_err());
3197
3198        let undated_consulted = serde_json::json!({"outcome": "consulted", "items": []});
3199        assert!(serde_json::from_value::<Half<RewardDistributorRef>>(undated_consulted).is_err());
3200
3201        // A half that says it looked owes an answer, even an empty one.
3202        let itemless = serde_json::json!({"outcome": "consulted", "observed_at": 1});
3203        assert!(serde_json::from_value::<Half<RewardDistributorRef>>(itemless).is_err());
3204    }
3205
3206    /// **Proves:** [`Half::items`] distinguishes "consulted, none" (`Some(&[])`)
3207    /// from "not consulted" (`None`), and [`Half::observed_at`] answers in both
3208    /// arms — so a consumer never has to re-match to get a staleness anchor.
3209    #[test]
3210    fn half_accessors_keep_the_distinction() {
3211        let consulted: Half<RewardDistributorRef> = Half::Consulted {
3212            observed_at: 1_700,
3213            items: vec![],
3214        };
3215        let not_consulted: Half<RewardDistributorRef> = Half::NotConsulted { observed_at: 1_701 };
3216        assert_eq!(consulted.items().map(<[_]>::len), Some(0));
3217        assert!(not_consulted.items().is_none());
3218        assert_eq!(consulted.observed_at(), 1_700);
3219        assert_eq!(not_consulted.observed_at(), 1_701);
3220    }
3221
3222    /// A `ListRewardDistributorsResult` body with every key present, so a test
3223    /// can delete exactly one and assert the deletion is what broke it.
3224    fn full_list_reward_distributors_body() -> serde_json::Value {
3225        serde_json::json!({
3226            "funded": {"outcome": "consulted", "observed_at": 1_700, "items": []},
3227            "claimable": {"outcome": "consulted", "observed_at": 1_700, "items": []},
3228        })
3229    }
3230
3231    /// **Proves:** omitting `funded` -- or `claimable` -- is a PARSE ERROR, not a
3232    /// default, while the same body with both keys present parses. Neither half
3233    /// may be silently assumed consulted.
3234    /// **Catches:** `#[serde(default)]` (or a re-derived `Default`) being added
3235    /// as a compatibility convenience, which would make every legacy payload
3236    /// parse as "both halves consulted" -- the undated bare zero SPEC §12.5
3237    /// clause 6 forbids.
3238    #[test]
3239    fn omitting_either_half_is_a_parse_error() {
3240        serde_json::from_value::<ListRewardDistributorsResult>(full_list_reward_distributors_body())
3241            .expect("the complete body must parse, or the omission assertions prove nothing");
3242
3243        for omitted in ["funded", "claimable"] {
3244            let mut body = full_list_reward_distributors_body();
3245            body.as_object_mut().unwrap().remove(omitted).unwrap();
3246            assert!(
3247                serde_json::from_value::<ListRewardDistributorsResult>(body).is_err(),
3248                "omitting {omitted} must fail to parse, never default to consulted"
3249            );
3250        }
3251    }
3252
3253    /// **Proves:** `PayeeClaimStatus` carries the literal `"subject": "payee"`
3254    /// and a claim-log observation — and NO monetary field and no payout puzzle
3255    /// hash (dig_ecosystem#3269).
3256    /// **Catches:** an amount or a payment identity creeping back onto the payee
3257    /// payload, which is the shape that let a funder's total render as one
3258    /// operator's personal earnings.
3259    #[test]
3260    fn payee_claim_status_names_its_subject_and_carries_no_money() {
3261        let status = PayeeClaimStatus {
3262            subject: PayeeSubject::Payee,
3263            claim_log: ClaimLogObservation::Consulted {
3264                observed_at: 1_700,
3265                claims_submitted_count: 3,
3266            },
3267            claim_loop: ClaimLoopObservation::Consulted {
3268                observed_at: 1_690,
3269                distributors_known: 4,
3270                distributors_claimable: 2,
3271                state: ClaimLoopState::ClaimableButNotClaiming {
3272                    claimable: 2,
3273                    submitted: 0,
3274                },
3275            },
3276        };
3277        let value = serde_json::to_value(status).unwrap();
3278        assert_eq!(value["subject"], "payee");
3279        assert_eq!(
3280            sorted_keys(&value),
3281            vec!["claim_log", "claim_loop", "subject"],
3282            "PayeeClaimStatus gained a field -- if it names money or a payee's \
3283             payment identity, it must not ship"
3284        );
3285        assert_eq!(
3286            sorted_keys(&value["claim_log"]),
3287            vec!["claims_submitted_count", "observed_at", "outcome"],
3288            "the claim log gained a field -- the same money rule applies inside it"
3289        );
3290        assert_eq!(
3291            sorted_keys(&value["claim_loop"]),
3292            vec![
3293                "distributors_claimable",
3294                "distributors_known",
3295                "observed_at",
3296                "outcome",
3297                "state",
3298            ],
3299            "the claim loop's consulted arm gained a field -- the same money rule \
3300             applies inside it"
3301        );
3302
3303        let back: PayeeClaimStatus = serde_json::from_value(value).unwrap();
3304        assert_eq!(status, back);
3305    }
3306
3307    /// **Proves:** every field of `PayeeClaimStatus` is REQUIRED on the wire —
3308    /// nothing defaults, so a producer cannot omit the subject and have a
3309    /// consumer invent it.
3310    #[test]
3311    fn payee_claim_status_fields_do_not_default() {
3312        let full = || {
3313            serde_json::json!({
3314                "subject": "payee",
3315                "claim_log": {
3316                    "outcome": "consulted",
3317                    "observed_at": 1_700,
3318                    "claims_submitted_count": 3,
3319                },
3320                "claim_loop": {
3321                    "outcome": "consulted",
3322                    "observed_at": 1_690,
3323                    "distributors_known": 4,
3324                    "distributors_claimable": 2,
3325                    "state": { "kind": "nominal" },
3326                },
3327            })
3328        };
3329        serde_json::from_value::<PayeeClaimStatus>(full())
3330            .expect("the complete body must parse, or the omission assertions prove nothing");
3331
3332        for missing in ["subject", "claim_log", "claim_loop"] {
3333            let mut value = full();
3334            value.as_object_mut().unwrap().remove(missing).unwrap();
3335            assert!(
3336                serde_json::from_value::<PayeeClaimStatus>(value).is_err(),
3337                "{missing} must be required"
3338            );
3339        }
3340
3341        let wrong_subject = serde_json::json!({
3342            "subject": "funder",
3343            "claim_log": {
3344                "outcome": "consulted",
3345                "observed_at": 1_700,
3346                "claims_submitted_count": 3,
3347            },
3348            "claim_loop": {
3349                "outcome": "consulted",
3350                "observed_at": 1_690,
3351                "distributors_known": 4,
3352                "distributors_claimable": 2,
3353                "state": { "kind": "nominal" },
3354            },
3355        });
3356        assert!(
3357            serde_json::from_value::<PayeeClaimStatus>(wrong_subject).is_err(),
3358            "only the literal \"payee\" subject may parse into PayeeClaimStatus"
3359        );
3360    }
3361
3362    /// **Proves:** a node that never read its claim log cannot emit a count at
3363    /// all — there is no field for one in `not_consulted`, and a payload that
3364    /// carries one anyway is a parse ERROR. "Consulted and zero" and "never
3365    /// looked" are different, dated payloads.
3366    /// **Catches:** dig_ecosystem#3269 Condition 3 — a freshly dated
3367    /// `claims_submitted_count: 0` emitted by a node whose log was unreadable,
3368    /// which is SPEC §12.5 clause 6's reassuring zero with a timestamp on it.
3369    #[test]
3370    fn an_unread_claim_log_cannot_report_a_count() {
3371        let unread = PayeeClaimStatus {
3372            subject: PayeeSubject::Payee,
3373            claim_log: ClaimLogObservation::NotConsulted { observed_at: 1_700 },
3374            claim_loop: ClaimLoopObservation::NotConsulted { observed_at: 1_700 },
3375        };
3376        let read_and_zero = PayeeClaimStatus {
3377            subject: PayeeSubject::Payee,
3378            claim_log: ClaimLogObservation::Consulted {
3379                observed_at: 1_700,
3380                claims_submitted_count: 0,
3381            },
3382            claim_loop: ClaimLoopObservation::NotConsulted { observed_at: 1_700 },
3383        };
3384
3385        let a = serde_json::to_value(unread).unwrap();
3386        let b = serde_json::to_value(read_and_zero).unwrap();
3387        assert_ne!(a, b, "an unread claim log must not serialise as a zero one");
3388        assert_eq!(a["claim_log"]["outcome"], "not_consulted");
3389        assert_eq!(a["claim_log"]["observed_at"], 1_700);
3390        assert!(
3391            a["claim_log"].get("claims_submitted_count").is_none(),
3392            "an unread log has no count to give"
3393        );
3394        assert_eq!(b["claim_log"]["claims_submitted_count"], 0);
3395
3396        let contradiction = serde_json::json!({
3397            "outcome": "not_consulted",
3398            "observed_at": 1_700,
3399            "claims_submitted_count": 0,
3400        });
3401        assert!(
3402            serde_json::from_value::<ClaimLogObservation>(contradiction).is_err(),
3403            "\"never looked, and the count is zero\" must not parse"
3404        );
3405
3406        let undated = serde_json::json!({"outcome": "not_consulted"});
3407        assert!(serde_json::from_value::<ClaimLogObservation>(undated).is_err());
3408
3409        let countless = serde_json::json!({"outcome": "consulted", "observed_at": 1_700});
3410        assert!(
3411            serde_json::from_value::<ClaimLogObservation>(countless).is_err(),
3412            "a log that says it was read owes a count"
3413        );
3414    }
3415
3416    /// A `PayeeClaimStatus` carrying a fully populated `claim_loop` for use as
3417    /// a base by the new-test fixtures below.
3418    fn claim_loop_fixture() -> serde_json::Value {
3419        serde_json::json!({
3420            "subject": "payee",
3421            "claim_log": {
3422                "outcome": "not_consulted",
3423                "observed_at": 1_700,
3424            },
3425            "claim_loop": {
3426                "outcome": "consulted",
3427                "observed_at": 1_690,
3428                "distributors_known": 4,
3429                "distributors_claimable": 2,
3430                "state": { "kind": "nominal" },
3431            },
3432        })
3433    }
3434
3435    /// **Proves:** every one of the seven `ClaimLoopState` kinds round-trips
3436    /// through `PayeeClaimStatus` with exactly the keys SPEC §4.4.1 lists for
3437    /// its arm -- no more, no fewer. The two distributor counts parse from their
3438    /// correctly-named JSON keys: swapping `distributors_known` and
3439    /// `distributors_claimable` on the wire changes the parsed values.
3440    #[test]
3441    fn every_claim_loop_state_kind_round_trips_with_exact_keys() {
3442        let cases: Vec<(serde_json::Value, Vec<&str>)> = vec![
3443            (serde_json::json!({"kind": "idle"}), vec!["kind"]),
3444            (
3445                serde_json::json!({"kind": "chain_source_unavailable"}),
3446                vec!["kind"],
3447            ),
3448            (
3449                serde_json::json!({"kind": "persisted_state_corrupt"}),
3450                vec!["kind"],
3451            ),
3452            (
3453                serde_json::json!({"kind": "cadence_not_elapsed"}),
3454                vec!["kind"],
3455            ),
3456            (serde_json::json!({"kind": "nominal"}), vec!["kind"]),
3457            (
3458                serde_json::json!({"kind": "faulted", "cycles": 3}),
3459                vec!["cycles", "kind"],
3460            ),
3461            (
3462                serde_json::json!({
3463                    "kind": "claimable_but_not_claiming",
3464                    "claimable": 2,
3465                    "submitted": 0,
3466                }),
3467                vec!["claimable", "kind", "submitted"],
3468            ),
3469        ];
3470
3471        // Pin the wire names to their field bindings: a transposition on the
3472        // wire would silently invert the counts.
3473        let parsed: PayeeClaimStatus = serde_json::from_value(claim_loop_fixture()).unwrap();
3474        assert!(
3475            matches!(
3476                parsed.claim_loop,
3477                ClaimLoopObservation::Consulted { distributors_known: 4, distributors_claimable: 2, .. }
3478            ),
3479            "distributors_known must parse from the `distributors_known` key and \
3480             distributors_claimable from `distributors_claimable`; a swap must not parse into the same value"
3481        );
3482
3483        for (state, expected_keys) in cases {
3484            let mut body = claim_loop_fixture();
3485            body["claim_loop"]["state"] = state.clone();
3486            assert_eq!(
3487                sorted_keys(&state),
3488                expected_keys,
3489                "state {state:?} does not carry its SPEC section 4.4.1 key set"
3490            );
3491            let parsed: PayeeClaimStatus = serde_json::from_value(body).unwrap_or_else(|e| {
3492                panic!("state {state:?} must round-trip, got {e}");
3493            });
3494            let back = serde_json::to_value(parsed).unwrap();
3495            assert_eq!(
3496                sorted_keys(&back["claim_loop"]["state"]),
3497                expected_keys,
3498                "state {state:?} changed shape across a round trip"
3499            );
3500        }
3501    }
3502
3503    /// **Proves:** every one of `claim_loop.consulted`'s five keys is
3504    /// REQUIRED -- removing any one is a parse error, never a default.
3505    #[test]
3506    fn claim_loop_consulted_fields_do_not_default() {
3507        for missing in [
3508            "outcome",
3509            "observed_at",
3510            "distributors_known",
3511            "distributors_claimable",
3512            "state",
3513        ] {
3514            let mut body = claim_loop_fixture();
3515            body["claim_loop"].as_object_mut().unwrap().remove(missing);
3516            assert!(
3517                serde_json::from_value::<PayeeClaimStatus>(body).is_err(),
3518                "claim_loop.consulted must require {missing}"
3519            );
3520        }
3521    }
3522
3523    /// **Proves:** a `ClaimLoopState` payload is rejected for an unknown
3524    /// `kind`, for a bare-string `state`, for `faulted` missing `cycles`, for
3525    /// `claimable_but_not_claiming` missing `submitted`, and for an unknown
3526    /// key riding beside a `faulted` payload.
3527    #[test]
3528    fn claim_loop_state_rejects_malformed_and_unknown_payloads() {
3529        let reject = |state: serde_json::Value, why: &str| {
3530            let mut body = claim_loop_fixture();
3531            body["claim_loop"]["state"] = state;
3532            assert!(
3533                serde_json::from_value::<PayeeClaimStatus>(body).is_err(),
3534                "{why}"
3535            );
3536        };
3537
3538        reject(
3539            serde_json::json!({"kind": "not_a_real_state"}),
3540            "an unknown kind must not parse, never coerce to idle",
3541        );
3542        reject(
3543            serde_json::json!("idle"),
3544            "state must always be an object, never a bare string",
3545        );
3546        reject(
3547            serde_json::json!({"kind": "faulted"}),
3548            "faulted without cycles must not parse",
3549        );
3550        reject(
3551            serde_json::json!({"kind": "claimable_but_not_claiming", "claimable": 2}),
3552            "claimable_but_not_claiming without submitted must not parse",
3553        );
3554        reject(
3555            serde_json::json!({"kind": "faulted", "cycles": 3, "extra": 1}),
3556            "an unknown key beside a faulted payload must not parse",
3557        );
3558    }
3559
3560    /// **Proves:** `claim_loop.not_consulted` carries no count and no state --
3561    /// a count or a verdict cannot ride an arm that says nothing was read.
3562    #[test]
3563    fn claim_loop_not_consulted_rejects_every_count_and_state_key() {
3564        let base_not_consulted = || {
3565            serde_json::json!({
3566                "outcome": "not_consulted",
3567                "observed_at": 1_700,
3568            })
3569        };
3570
3571        for (key, value) in [
3572            ("distributors_known", serde_json::json!(0)),
3573            ("distributors_claimable", serde_json::json!(0)),
3574            ("state", serde_json::json!({"kind": "idle"})),
3575        ] {
3576            let mut claim_loop = base_not_consulted();
3577            claim_loop
3578                .as_object_mut()
3579                .unwrap()
3580                .insert(key.to_string(), value);
3581            let mut body = claim_loop_fixture();
3582            body["claim_loop"] = claim_loop;
3583            assert!(
3584                serde_json::from_value::<PayeeClaimStatus>(body).is_err(),
3585                "not_consulted must reject a stray {key}"
3586            );
3587        }
3588    }
3589
3590    /// **Proves:** the anti-silence rule (SPEC section 4.4.4) actually holds
3591    /// on the wire -- a consumer reading ONLY `claim_loop.state.kind` from
3592    /// the serialised JSON can tell "running, not paying everybody" apart
3593    /// from "nothing to do", without touching the Rust enum. Each distributor
3594    /// count rides under its own wire key: swapping `distributors_known`
3595    /// and `distributors_claimable` field names changes the JSON values.
3596    /// **Catches:** a producer that folds `claimable_but_not_claiming` into
3597    /// `nominal` or `idle`, which is the exact silence this method exists to
3598    /// remove.
3599    #[test]
3600    fn claimable_but_not_claiming_is_distinguishable_from_idle_by_json_alone() {
3601        let running_and_shortfalling = PayeeClaimStatus {
3602            subject: PayeeSubject::Payee,
3603            claim_log: ClaimLogObservation::NotConsulted { observed_at: 1_700 },
3604            claim_loop: ClaimLoopObservation::Consulted {
3605                observed_at: 1_690,
3606                distributors_known: 4,
3607                distributors_claimable: 2,
3608                state: ClaimLoopState::ClaimableButNotClaiming {
3609                    claimable: 2,
3610                    submitted: 0,
3611                },
3612            },
3613        };
3614        let nothing_to_do = PayeeClaimStatus {
3615            subject: PayeeSubject::Payee,
3616            claim_log: ClaimLogObservation::NotConsulted { observed_at: 1_700 },
3617            claim_loop: ClaimLoopObservation::Consulted {
3618                observed_at: 1_690,
3619                distributors_known: 0,
3620                distributors_claimable: 0,
3621                state: ClaimLoopState::Idle,
3622            },
3623        };
3624
3625        let a = serde_json::to_value(running_and_shortfalling).unwrap();
3626        let b = serde_json::to_value(nothing_to_do).unwrap();
3627
3628        // The two counts an operator compares must each ride under its OWN
3629        // key: a transposition on the wire would invert "known" and
3630        // "claimable" and every other assertion here would still pass.
3631        assert_eq!(a["claim_loop"]["distributors_known"], 4);
3632        assert_eq!(a["claim_loop"]["distributors_claimable"], 2);
3633
3634        let a_kind = a["claim_loop"]["state"]["kind"].as_str().unwrap();
3635        let b_kind = b["claim_loop"]["state"]["kind"].as_str().unwrap();
3636
3637        assert_ne!(
3638            a_kind, b_kind,
3639            "a loop that is running and shortfalling must not read the same as one \
3640             with nothing to do, from the JSON alone"
3641        );
3642        assert_eq!(a_kind, "claimable_but_not_claiming");
3643    }
3644
3645    /// **Proves:** an unknown top-level key on `PayeeClaimStatus` is a parse
3646    /// error, per `deny_unknown_fields` (new in 0.13.0).
3647    #[test]
3648    fn payee_claim_status_rejects_an_unknown_top_level_key() {
3649        let mut body = claim_loop_fixture();
3650        body.as_object_mut()
3651            .unwrap()
3652            .insert("amount".to_string(), serde_json::json!(5));
3653        assert!(
3654            serde_json::from_value::<PayeeClaimStatus>(body).is_err(),
3655            "an unknown top-level key must not parse"
3656        );
3657    }
3658
3659    /// **Proves:** `GetRewardDistributorResult` round-trips, and `entry_set_stale`
3660    /// IS present on this chain-derived type (contrast `RewardProverStatus`,
3661    /// which must never carry it — SPEC §12.4 vs §2.4).
3662    #[test]
3663    fn get_reward_distributor_result_round_trips_and_carries_entry_set_stale() {
3664        let params = GetRewardDistributorParams {
3665            launcher_id: "ab".repeat(32),
3666        };
3667        let back: GetRewardDistributorParams =
3668            serde_json::from_str(&serde_json::to_string(&params).unwrap()).unwrap();
3669        assert_eq!(params, back);
3670
3671        let result = GetRewardDistributorResult {
3672            launcher_id: "ab".repeat(32),
3673            store_id: "cd".repeat(32),
3674            root: "ef".repeat(32),
3675            epoch_seconds: 86_400,
3676            first_epoch_start: 1_000,
3677            payout_threshold: 500_000,
3678            fee_bps: 250,
3679            withdrawal_share_bps: 9_000,
3680            reserve_base_units: 1_000_000,
3681            entry_count: 42,
3682            current_distributor_epoch: 7,
3683            last_entry_write_at: Some(1_650),
3684            entry_set_stale: true,
3685            observed_at: 1_700,
3686            chain_peak_height: 12_345,
3687            chain_peak_timestamp: 1_690,
3688        };
3689        let value = serde_json::to_value(&result).unwrap();
3690        assert_eq!(value["entry_set_stale"], true);
3691        let back: GetRewardDistributorResult = serde_json::from_value(value).unwrap();
3692        assert_eq!(result, back);
3693    }
3694
3695    /// **Proves:** `entry_set_stale` is placed on exactly one of the two
3696    /// reward-distributor result types — present on the chain-derived
3697    /// `GetRewardDistributorResult`, absent from the self-reported
3698    /// `RewardProverStatus` — SPEC §12.4 vs §2.4.
3699    /// **Catches:** the staleness boolean migrating (or being copy-pasted)
3700    /// onto the self-reported type, which would let a wedged prover fake
3701    /// liveness by simply never flipping it.
3702    #[test]
3703    fn entry_set_stale_is_placed_on_the_chain_derived_result_only() {
3704        let prover_status = serde_json::to_value(sample_prover_status()).unwrap();
3705        assert!(
3706            !prover_status
3707                .as_object()
3708                .unwrap()
3709                .contains_key("entry_set_stale"),
3710            "RewardProverStatus must never carry entry_set_stale (SPEC §2.4)"
3711        );
3712
3713        let distributor_result = GetRewardDistributorResult {
3714            launcher_id: "ab".repeat(32),
3715            store_id: "cd".repeat(32),
3716            root: "ef".repeat(32),
3717            epoch_seconds: 86_400,
3718            first_epoch_start: 1_000,
3719            payout_threshold: 500_000,
3720            fee_bps: 250,
3721            withdrawal_share_bps: 9_000,
3722            reserve_base_units: 1_000_000,
3723            entry_count: 42,
3724            current_distributor_epoch: 7,
3725            last_entry_write_at: None,
3726            entry_set_stale: false,
3727            observed_at: 1_700,
3728            chain_peak_height: 12_345,
3729            chain_peak_timestamp: 1_690,
3730        };
3731        let value = serde_json::to_value(&distributor_result).unwrap();
3732        assert!(
3733            value.as_object().unwrap().contains_key("entry_set_stale"),
3734            "GetRewardDistributorResult must carry entry_set_stale (SPEC §12.4)"
3735        );
3736    }
3737
3738    /// **Proves:** `GetRewardProverStatusParams` with an absent `launcher_id`
3739    /// deserializes from an empty JSON object to `None` — the field is
3740    /// genuinely optional on the wire, not merely optional in Rust.
3741    #[test]
3742    fn get_reward_prover_status_params_absent_launcher_id_is_none() {
3743        let parsed: GetRewardProverStatusParams = serde_json::from_value(json!({})).unwrap();
3744        assert_eq!(parsed.launcher_id, None);
3745
3746        // And the round trip the other way: `Some` serializes the key back out.
3747        let with_id = GetRewardProverStatusParams {
3748            launcher_id: Some("ab".repeat(32)),
3749        };
3750        let value = serde_json::to_value(&with_id).unwrap();
3751        assert_eq!(value["launcher_id"], json!("ab".repeat(32)));
3752    }
3753
3754    /// **Proves:** `ListRewardDistributorCommitmentsResult` round-trips,
3755    /// including the legitimate EMPTY `commitments` case (a distributor
3756    /// funded only via `AddIncentives` has no clawback slots at all — an
3757    /// irrevocable donation, not an error) — SPEC §7.4 clause 5.
3758    #[test]
3759    fn list_reward_distributor_commitments_round_trips_with_empty_commitments() {
3760        let params = ListRewardDistributorCommitmentsParams {
3761            launcher_id: "ab".repeat(32),
3762        };
3763        let back: ListRewardDistributorCommitmentsParams =
3764            serde_json::from_str(&serde_json::to_string(&params).unwrap()).unwrap();
3765        assert_eq!(params, back);
3766
3767        let result = ListRewardDistributorCommitmentsResult {
3768            launcher_id: "ab".repeat(32),
3769            withdrawal_share_bps: 9_000,
3770            epoch_seconds: 86_400,
3771            commitments: vec![],
3772            observed_at: 1_700,
3773            chain_peak_height: 12_345,
3774            chain_peak_timestamp: 1_690,
3775        };
3776        let back: ListRewardDistributorCommitmentsResult =
3777            serde_json::from_str(&serde_json::to_string(&result).unwrap()).unwrap();
3778        assert_eq!(result, back);
3779        assert_eq!(back.epoch_seconds, 86_400);
3780    }
3781
3782    /// A full `GetRewardDistributorResult` body, as JSON, with the chain-view
3783    /// anchor fields included. Kept as one JSON literal (not a struct literal)
3784    /// so this helper compiles whether or not `chain_peak_height` /
3785    /// `chain_peak_timestamp` exist on the struct yet — the RED phase for
3786    /// ticket #3262 depends on that.
3787    fn get_reward_distributor_result_body_with_anchor() -> serde_json::Value {
3788        json!({
3789            "launcher_id": "ab".repeat(32),
3790            "store_id": "cd".repeat(32),
3791            "root": "ef".repeat(32),
3792            "epoch_seconds": 86_400,
3793            "first_epoch_start": 1_000,
3794            "payout_threshold": 500_000,
3795            "fee_bps": 250,
3796            "withdrawal_share_bps": 9_000,
3797            "reserve_base_units": 1_000_000,
3798            "entry_count": 42,
3799            "current_distributor_epoch": 7,
3800            "last_entry_write_at": 1_650,
3801            "entry_set_stale": true,
3802            "observed_at": 1_700,
3803            "chain_peak_height": 12_345,
3804            "chain_peak_timestamp": 1_690,
3805        })
3806    }
3807
3808    /// **Proves:** a `GetRewardDistributorResult` body without
3809    /// `chain_peak_height` / `chain_peak_timestamp` is a parse ERROR (the
3810    /// pre-0.14.0, 0.12.0-shaped wire body must not parse once the anchor is
3811    /// required), and the same body WITH both keys round-trips carrying
3812    /// exactly the expected key set — SPEC §4.5, dig_ecosystem#3262.
3813    /// **Catches:** `#[serde(default)]` / `Option` on either field, which
3814    /// would let the missing-key body parse silently.
3815    #[test]
3816    fn get_reward_distributor_result_refuses_a_body_without_its_chain_anchor() {
3817        let mut body = get_reward_distributor_result_body_with_anchor();
3818        body.as_object_mut()
3819            .unwrap()
3820            .remove("chain_peak_height")
3821            .unwrap();
3822        body.as_object_mut()
3823            .unwrap()
3824            .remove("chain_peak_timestamp")
3825            .unwrap();
3826        assert!(
3827            serde_json::from_value::<GetRewardDistributorResult>(body).is_err(),
3828            "a GetRewardDistributorResult body missing the chain-view anchor \
3829             must not parse -- SPEC §4.5 requires both fields"
3830        );
3831
3832        let full_body = get_reward_distributor_result_body_with_anchor();
3833        let parsed: GetRewardDistributorResult =
3834            serde_json::from_value(full_body).expect("a body carrying both anchor keys must parse");
3835        let value = serde_json::to_value(&parsed).unwrap();
3836        assert_eq!(
3837            sorted_keys(&value),
3838            vec![
3839                "chain_peak_height",
3840                "chain_peak_timestamp",
3841                "current_distributor_epoch",
3842                "entry_count",
3843                "entry_set_stale",
3844                "epoch_seconds",
3845                "fee_bps",
3846                "first_epoch_start",
3847                "last_entry_write_at",
3848                "launcher_id",
3849                "observed_at",
3850                "payout_threshold",
3851                "reserve_base_units",
3852                "root",
3853                "store_id",
3854                "withdrawal_share_bps",
3855            ],
3856            "GetRewardDistributorResult must carry exactly these keys, including \
3857             the chain-view anchor"
3858        );
3859    }
3860
3861    /// A full `ListRewardDistributorCommitmentsResult` body, as JSON, with the
3862    /// chain-view anchor fields included. See
3863    /// [`get_reward_distributor_result_body_with_anchor`] for why this is JSON
3864    /// rather than a struct literal.
3865    fn list_reward_distributor_commitments_result_body_with_anchor() -> serde_json::Value {
3866        json!({
3867            "launcher_id": "ab".repeat(32),
3868            "withdrawal_share_bps": 9_000,
3869            "epoch_seconds": 86_400,
3870            "commitments": [],
3871            "observed_at": 1_700,
3872            "chain_peak_height": 12_345,
3873            "chain_peak_timestamp": 1_690,
3874        })
3875    }
3876
3877    /// **Proves:** a `ListRewardDistributorCommitmentsResult` body without
3878    /// `chain_peak_height` / `chain_peak_timestamp` is a parse ERROR, and the
3879    /// same body WITH both keys round-trips carrying exactly the expected key
3880    /// set — SPEC §4.5, dig_ecosystem#3262 (sibling of the
3881    /// `GetRewardDistributorResult` test above).
3882    #[test]
3883    fn list_reward_distributor_commitments_result_refuses_a_body_without_its_chain_anchor() {
3884        let mut body = list_reward_distributor_commitments_result_body_with_anchor();
3885        body.as_object_mut()
3886            .unwrap()
3887            .remove("chain_peak_height")
3888            .unwrap();
3889        body.as_object_mut()
3890            .unwrap()
3891            .remove("chain_peak_timestamp")
3892            .unwrap();
3893        assert!(
3894            serde_json::from_value::<ListRewardDistributorCommitmentsResult>(body).is_err(),
3895            "a ListRewardDistributorCommitmentsResult body missing the chain-view \
3896             anchor must not parse -- SPEC §4.5 requires both fields"
3897        );
3898
3899        let full_body = list_reward_distributor_commitments_result_body_with_anchor();
3900        let parsed: ListRewardDistributorCommitmentsResult =
3901            serde_json::from_value(full_body).expect("a body carrying both anchor keys must parse");
3902        let value = serde_json::to_value(&parsed).unwrap();
3903        assert_eq!(
3904            sorted_keys(&value),
3905            vec![
3906                "chain_peak_height",
3907                "chain_peak_timestamp",
3908                "commitments",
3909                "epoch_seconds",
3910                "launcher_id",
3911                "observed_at",
3912                "withdrawal_share_bps",
3913            ],
3914            "ListRewardDistributorCommitmentsResult must carry exactly these keys, \
3915             including the chain-view anchor"
3916        );
3917    }
3918
3919    /// **Proves:** `observed_at` (wall clock of reply assembly) and
3920    /// `chain_peak_timestamp` (chain clock `entry_set_stale` is computed
3921    /// against) are DISTINCT fields that can hold different values — the
3922    /// whole point of the anchor is that one can be fresh while the other is
3923    /// stale (dig_ecosystem#3262).
3924    /// **Catches:** collapsing the two into one field, or deriving one from
3925    /// the other at serialization time.
3926    #[test]
3927    fn chain_peak_timestamp_and_observed_at_are_distinct_fields() {
3928        let mut body = get_reward_distributor_result_body_with_anchor();
3929        body["observed_at"] = json!(2_600u64);
3930        body["chain_peak_timestamp"] = json!(1_700u64);
3931        let parsed: GetRewardDistributorResult =
3932            serde_json::from_value(body).expect("full body must parse");
3933        let value = serde_json::to_value(&parsed).unwrap();
3934        assert_eq!(value["observed_at"], json!(2_600u64));
3935        assert_eq!(value["chain_peak_timestamp"], json!(1_700u64));
3936        assert_ne!(
3937            value["observed_at"], value["chain_peak_timestamp"],
3938            "observed_at (wall clock) and chain_peak_timestamp (chain clock) \
3939             must be able to disagree -- a stalled chain source freezes the \
3940             latter while the former keeps advancing"
3941        );
3942    }
3943
3944    /// **Proves:** `recoverable_base_units` is `rewards_base_units *
3945    /// withdrawal_share_bps / 10_000`, computed with integer arithmetic
3946    /// (multiply then divide) that TRUNCATES rather than rounds up — SPEC
3947    /// §7.4 clause 4 / §7.5.
3948    /// **Catches:** a rounded-up recoverable amount, which would promise
3949    /// money the chain will not return on clawback.
3950    #[test]
3951    fn commitment_recoverable_amount_truncates_and_never_exceeds_committed() {
3952        let withdrawal_share_bps: u64 = 9_000;
3953
3954        // Evenly divisible: 1_000 * 9000 / 10000 = 900.
3955        let even = RewardDistributorCommitment {
3956            epoch_start: 10,
3957            clawback_puzzle_hash: "aa".repeat(32),
3958            rewards_base_units: 1_000,
3959            recoverable_base_units: Some(1_000 * withdrawal_share_bps / 10_000),
3960        };
3961        assert_eq!(even.recoverable_base_units, Some(900));
3962
3963        // Not evenly divisible: 1_001 * 9000 / 10000 = 900.9 -> 900, not 901.
3964        let odd = RewardDistributorCommitment {
3965            epoch_start: 11,
3966            clawback_puzzle_hash: "bb".repeat(32),
3967            rewards_base_units: 1_001,
3968            recoverable_base_units: Some(1_001 * withdrawal_share_bps / 10_000),
3969        };
3970        assert_eq!(
3971            odd.recoverable_base_units,
3972            Some(900),
3973            "a non-evenly-divisible amount must truncate down, never round up"
3974        );
3975
3976        for commitment in [even, odd] {
3977            assert!(
3978                commitment.recoverable_base_units <= Some(commitment.rewards_base_units),
3979                "recoverable_base_units must never exceed rewards_base_units"
3980            );
3981        }
3982    }
3983
3984    /// A commitment row with a chosen recoverable figure, for the §4.6 wire tests.
3985    fn commitment_row(recoverable: Option<u64>) -> RewardDistributorCommitment {
3986        RewardDistributorCommitment {
3987            epoch_start: 7,
3988            clawback_puzzle_hash: "cc".repeat(32),
3989            rewards_base_units: 1_000,
3990            recoverable_base_units: recoverable,
3991        }
3992    }
3993
3994    /// **Proves:** a refused clawback (`None`) is the key PRESENT with JSON
3995    /// `null` — SPEC §4.6.
3996    /// **Catches:** `skip_serializing_if`, or a revert to `u64`. NB
3997    /// `v["k"].is_null()` is also true for a MISSING key, hence `.get()`.
3998    #[test]
3999    fn commitment_recoverable_none_serializes_key_present_as_null() {
4000        let value = serde_json::to_value(commitment_row(None)).unwrap();
4001        let obj = value.as_object().expect("a commitment row is an object");
4002        assert_eq!(
4003            obj.get("recoverable_base_units"),
4004            Some(&serde_json::Value::Null),
4005            "None must be the key present with null, never an omitted key"
4006        );
4007    }
4008
4009    /// **Proves:** `Some(0)` (a genuine zero the chain accepts) is the number
4010    /// `0` on the wire and `0` decodes back to `Some(0)`, never `None`.
4011    /// **Catches:** collapsing zero into absent, the #3439 failure.
4012    #[test]
4013    fn commitment_recoverable_some_zero_is_number_zero_and_roundtrips() {
4014        let value = serde_json::to_value(commitment_row(Some(0))).unwrap();
4015        assert_eq!(value.get("recoverable_base_units"), Some(&json!(0u64)));
4016        let parsed: RewardDistributorCommitment = serde_json::from_value(value).unwrap();
4017        assert_eq!(parsed.recoverable_base_units, Some(0));
4018    }
4019
4020    /// **Proves:** `null` and an omitted key both decode to `None`, never
4021    /// `Some(0)`.
4022    /// **Catches:** a decoder that defaults absence to zero.
4023    #[test]
4024    fn commitment_recoverable_null_or_omitted_decodes_to_none() {
4025        let base = json!({
4026            "epoch_start": 7,
4027            "clawback_puzzle_hash": "cc".repeat(32),
4028            "rewards_base_units": 1_000,
4029        });
4030        let mut with_null = base.clone();
4031        with_null["recoverable_base_units"] = serde_json::Value::Null;
4032        let from_null: RewardDistributorCommitment = serde_json::from_value(with_null).unwrap();
4033        assert_eq!(from_null.recoverable_base_units, None);
4034        let omitted: RewardDistributorCommitment = serde_json::from_value(base).unwrap();
4035        assert_eq!(omitted.recoverable_base_units, None);
4036    }
4037
4038    /// **Proves:** a 0.14.0-shaped row (a plain number) still decodes, to
4039    /// `Some(n)`.
4040    /// **Catches:** a change that breaks reading rows from an older responder.
4041    #[test]
4042    fn commitment_recoverable_0_14_0_numeric_row_decodes_to_some() {
4043        let old = json!({
4044            "epoch_start": 7,
4045            "clawback_puzzle_hash": "cc".repeat(32),
4046            "rewards_base_units": 1_000,
4047            "recoverable_base_units": 900,
4048        });
4049        let parsed: RewardDistributorCommitment = serde_json::from_value(old).unwrap();
4050        assert_eq!(parsed.recoverable_base_units, Some(900));
4051    }
4052
4053    /// **Proves:** SPEC §4.6 cl. 7 — a pre-0.15.0 reader (`u64`) FAILS to
4054    /// parse a `None` row rather than reading it as zero.
4055    /// **Catches:** an encoding of absence a legacy reader would silently
4056    /// accept as `0`.
4057    #[test]
4058    fn commitment_recoverable_none_row_fails_closed_for_a_u64_reader() {
4059        #[derive(Debug, Deserialize)]
4060        struct LegacyRow {
4061            #[allow(dead_code)]
4062            recoverable_base_units: u64,
4063        }
4064        let none_row = serde_json::to_value(commitment_row(None)).unwrap();
4065        assert!(
4066            serde_json::from_value::<LegacyRow>(none_row).is_err(),
4067            "a u64 reader must reject a None row, not coerce it"
4068        );
4069    }
4070}