Skip to main content

dig_node_control_interface/
results.rs

1//! Typed result payloads for the control methods.
2//!
3//! Each struct is field-for-field identical to what dig-node emits (snake_case wire fields), so a
4//! client deserializes the node's real response and re-serializes the same bytes — the property the
5//! conformance KATs pin. Genuinely open/proxied shapes (the updater beacon's status, the peer-pool
6//! snapshot, the pairing list) stay [`serde_json::Value`] on the call's `Output` rather than being
7//! frozen into a struct that would drift from the proxied source.
8
9use serde::{Deserialize, Serialize};
10
11/// The on-disk content-cache view (`control.cache.get`, and embedded in [`StatusResult`]).
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct CacheView {
14    /// The configured cache size cap, in bytes.
15    pub cap_bytes: u64,
16    /// Bytes currently used on disk.
17    pub used_bytes: u64,
18    /// The cache directory.
19    pub dir: String,
20    /// Whether the cache directory is the machine-wide shared cache.
21    pub shared: bool,
22}
23
24/// The §21 sync availability flag embedded in [`StatusResult`].
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct SyncAvailability {
27    /// Whether authenticated §21 whole-store sync is available on this node.
28    pub available: bool,
29}
30
31/// How much of its own build a node reveals when it advertises (dig_ecosystem#2215).
32///
33/// Advertising an exact build is a fingerprinting aid — it tells an observer precisely which peers
34/// run a version with a publicly disclosed defect. This is the operator's dial between that cost
35/// and the diagnostic value of knowing what the network is running.
36///
37/// It lives here, beside [`PeerSoftware`], because rendering and parsing are two halves of one
38/// format: a node that hand-rolled its own `product/version` string would be re-implementing half
39/// the contract, and the two halves would drift. A node picks a mode; this type renders it.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum SoftwareVersionDetail {
43    /// Advertise the exact build, e.g. `dig-node/0.99.1`. The default: the diagnostic value is why
44    /// the field exists, and an operator who disagrees opts down explicitly.
45    #[default]
46    Full,
47    /// Advertise only the major and minor level, e.g. `dig-node/0.99.0`. Hides the patch level, and
48    /// any pre-release or build metadata, while remaining READABLE at the far end.
49    Minor,
50    /// Advertise nothing. Indistinguishable from a peer built before this field existed, and reads
51    /// as [`PeerSoftware::Unknown`].
52    Off,
53}
54
55impl SoftwareVersionDetail {
56    /// Render the advertisement a node with this setting puts on its handshake.
57    ///
58    /// The result is ALWAYS either the empty string or a value [`PeerSoftware::parse`] reads back
59    /// as `Reported`. Coarsening reduces PRECISION; it never produces a value that reads as
60    /// Unknown while pretending to be a report. Two consequences follow, and both are tested:
61    ///
62    /// - [`Minor`](SoftwareVersionDetail::Minor) renders `MAJOR.MINOR.0`, never a bare
63    ///   `MAJOR.MINOR` — two-part versions are not valid semver, so that spelling would read as
64    ///   Unknown and become a second, confusing spelling of [`Off`](SoftwareVersionDetail::Off).
65    /// - `Minor` of a `0.0.x` build renders the EMPTY STRING, because its coarsening is version
66    ///   zero and version zero is the "unknown" sentinel. There is no coarser representable value,
67    ///   so it advertises nothing rather than advertising the sentinel as if it were a report.
68    ///
69    /// A coarsened `1.4.0` is indistinguishable from a genuine `1.4.0`. That is the point of
70    /// coarsening, not a defect in it.
71    pub fn render(self, product: &str, version: &semver::Version) -> String {
72        match self {
73            Self::Full => format!("{product}/{version}"),
74            // A pre-release identifier (`-nightly.20260805`) is more precisely identifying than the
75            // patch number beside it, so a "coarse" advertisement that kept it would coarsen
76            // nothing for exactly the builds that most want it. `Version::new` drops both it and
77            // any build metadata.
78            Self::Minor => {
79                let coarsened = semver::Version::new(version.major, version.minor, 0);
80                // Hiding the patch of a `0.0.x` build leaves version zero, which the wire reserves
81                // as the "unknown" sentinel. There is no coarser representable value, so advertise
82                // nothing rather than advertise the sentinel dressed up as a report. (This differs
83                // from the rejected two-part `MAJOR.MINOR` spelling: there a representable coarse
84                // value existed and the wrong one was chosen; here none exists.)
85                if is_version_zero(&coarsened) {
86                    return String::new();
87                }
88                format!("{product}/{coarsened}")
89            }
90            Self::Off => String::new(),
91        }
92    }
93}
94
95/// A peer's advertised SOFTWARE build, as read from the gossip handshake (dig_ecosystem#2215).
96///
97/// dig-gossip carries the peer's `Handshake.software_version` as an opaque sanitized string and
98/// deliberately does not interpret it. This type is where that string becomes meaning, once, at the
99/// control boundary — so the interpretation is defined in one place and every client agrees.
100///
101/// # This is NOT the protocol version
102///
103/// Wire compatibility is a separate field that dig-gossip gates connections on. Two peers can speak
104/// the same protocol while running builds months apart; this type reports the latter. It MUST NOT
105/// be used to decide whether to talk to a peer.
106///
107/// # Why there is no `Ord` and no `Default`
108///
109/// [`Unknown`](PeerSoftware::Unknown) has no position on a version line: it is the absence of a
110/// measurement, not a low value. Deriving `Ord` would place it somewhere — and every peer built
111/// before #2215 is Unknown, so "somewhere" would silently become a verdict about most of the live
112/// network. Comparison is therefore reachable only by destructuring
113/// [`Reported`](PeerSoftware::Reported), which forces the caller to say what Unknown means for
114/// their question. There is no `Default` for the same reason: a defaulted Unknown that appears from
115/// nowhere is a different fact from one that was measured, and the two must not be confusable.
116///
117/// # JSON
118///
119/// ```json
120/// {"kind": "unknown"}
121/// {"kind": "reported", "product": "dig-node", "version": "0.99.1", "raw": "dig-node/0.99.1"}
122/// ```
123///
124/// Unknown carries no `version` member at all — never version zero, never `""`, never `null` in a
125/// field a consumer might read as a version.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(tag = "kind", rename_all = "snake_case")]
128pub enum PeerSoftware {
129    /// The peer's build is not known: it advertised nothing, advertised VERSION ZERO — the legacy
130    /// sentinel, in any decoration (`0.0.0`, `0.0.0-rc.1`, `0.0.0+build`) — or advertised something
131    /// this contract cannot parse. See [`PeerSoftware::parse`] for why those three are one case.
132    Unknown,
133    /// The peer advertised a well-formed `product/semver` build.
134    Reported {
135        /// The product name, e.g. `dig-node`. Everything before the LAST `/`.
136        product: String,
137        /// The parsed semantic version, e.g. `0.99.1`. Serializes as its string form.
138        version: semver::Version,
139        /// Exactly what the peer advertised, after trimming.
140        ///
141        /// **Currently reconstructible, deliberately kept.** The grammar this parser accepts is
142        /// lossless — `semver::Version` re-renders every string it accepts byte-identically — so
143        /// today `raw` always equals `format!("{product}/{version}")`, and no test can distinguish
144        /// this field from that expression. It is retained as the honest source: the moment the
145        /// grammar accepts anything non-canonical (a `v` prefix, a two-part version, a vendor
146        /// suffix), a diagnostic reader must see what the peer actually sent rather than this
147        /// parser's opinion of it, and callers that already read `raw` will not need to change.
148        raw: String,
149    },
150}
151
152/// Is this VERSION ZERO — the legacy "no version" sentinel, whatever it is dressed in?
153///
154/// Three of dig-gossip's four handshake send sites hardcoded `"0.0.0"` before dig_ecosystem#2215,
155/// so version zero is not a hypothetical value: it is what the live fleet is sending right now. It
156/// means "this build predates the field", which is [`PeerSoftware::Unknown`]; mapping it to a
157/// *version* would make the whole existing network read as ancient.
158///
159/// The test is over the major/minor/patch TRIPLE, ignoring any pre-release or build metadata. A
160/// peer advertising `0.0.0-rc.1` is no more versioned than one advertising `0.0.0`, and matching
161/// the bare string would let the decorated forms through as real builds at version zero.
162fn is_version_zero(version: &semver::Version) -> bool {
163    version.major == 0 && version.minor == 0 && version.patch == 0
164}
165
166/// The separator between the product and the version in a `product/semver` advertisement.
167const PRODUCT_VERSION_SEPARATOR: char = '/';
168
169impl PeerSoftware {
170    /// Interpret a peer's advertised `software_version` string.
171    ///
172    /// Returns [`Unknown`](PeerSoftware::Unknown) for an empty or blank string, for anything that
173    /// is not `product/semver` with both parts non-empty and the version parsing as semver, and for
174    /// any advertisement whose version is VERSION ZERO.
175    ///
176    /// Version zero is the legacy sentinel and is matched as a CLASS, not as a string: the bare
177    /// `0.0.0`, a product-qualified `dig-node/0.0.0`, and every decorated form (`0.0.0-rc.1`,
178    /// `0.0.0+build`, `0.0.0-0`) all mean "unversioned". A peer advertising `0.0.0-rc.1` is no more
179    /// versioned than one advertising `0.0.0`.
180    ///
181    /// A product name may contain `/`; the split is at the LAST separator.
182    pub fn parse(advertised: &str) -> Self {
183        let raw = advertised.trim();
184
185        // No separator at all: an empty advertisement, a bare version, a product with no version,
186        // or a bare version-zero sentinel (`0.0.0`, `0.0.0-rc.1`) — none of which contain a `/`, so
187        // they land here rather than needing a clause of their own. None of them name a build.
188        let Some((product, version)) = raw.rsplit_once(PRODUCT_VERSION_SEPARATOR) else {
189            return Self::Unknown;
190        };
191        if product.is_empty() {
192            return Self::Unknown;
193        }
194        let Ok(version) = version.parse::<semver::Version>() else {
195            return Self::Unknown;
196        };
197        // The sentinel is VERSION ZERO, a class — not the three-character string. Comparing the
198        // PARSED version is what makes `0.0.0+build`, `0.0.0-rc.1`, and `0.0.0-0` Unknown too; a
199        // string comparison would report each of them as a real build at version zero.
200        if is_version_zero(&version) {
201            return Self::Unknown;
202        }
203
204        Self::Reported {
205            product: product.to_string(),
206            version,
207            raw: raw.to_string(),
208        }
209    }
210}
211
212/// `control.status` — a rich node status snapshot.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct StatusResult {
215    /// Always `true` for a responding node.
216    pub running: bool,
217    /// The service name (`"dig-node"`).
218    pub service: String,
219    /// The node binary's semantic version.
220    pub version: String,
221    /// The git commit the binary was built from (or `"unknown"`).
222    pub commit: String,
223    /// The DIG read protocol version the node speaks.
224    pub protocol: String,
225    /// Process uptime in seconds.
226    pub uptime_secs: u64,
227    /// The loopback `host:port` the node is bound to.
228    pub addr: String,
229    /// The upstream DIG RPC the node proxies/syncs to.
230    pub upstream: String,
231    /// The on-disk cache view.
232    pub cache: CacheView,
233    /// Distinct stores held (from the cache).
234    pub hosted_store_count: u64,
235    /// Cached capsule count.
236    pub cached_capsule_count: u64,
237    /// Pinned-store count.
238    pub pinned_store_count: u64,
239    /// §21 sync availability.
240    pub sync: SyncAvailability,
241}
242
243/// `control.config.get` — the node's effective configuration.
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct ConfigResult {
246    /// The bound `host:port`.
247    pub addr: String,
248    /// The bound port, as a string.
249    pub port: String,
250    /// The effective upstream DIG RPC.
251    pub upstream: String,
252    /// The persisted upstream override, or `null` when unset.
253    pub upstream_override: Option<String>,
254    /// The cache directory.
255    pub cache_dir: String,
256    /// Whether the cache is the machine-wide shared cache.
257    pub cache_shared: bool,
258    /// The node's config.json path.
259    pub config_path: String,
260    /// Whether authenticated §21 sync is available.
261    pub sync_available: bool,
262}
263
264/// `control.config.setUpstream` — the persisted override + a restart hint.
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266pub struct SetUpstreamResult {
267    /// The normalized upstream that was persisted.
268    pub upstream: String,
269    /// Always `true` — the change takes effect on next node start.
270    pub requires_restart: bool,
271}
272
273/// `control.log.setLevel` — the applied filter directive.
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct SetLevelResult {
276    /// The EnvFilter directive now in effect.
277    pub filter: String,
278}
279
280/// `control.cache.setCap` — the applied cap (after the 64 MiB floor).
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282pub struct SetCapResult {
283    /// The cache cap now in effect, in bytes.
284    pub cap_bytes: u64,
285}
286
287/// `control.cache.clear` — the clear acknowledgement.
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289pub struct CacheClearResult {
290    /// Always `true`.
291    pub cleared: bool,
292}
293
294/// One cached capsule of a store, as listed by the hosted-stores methods.
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub struct CapsuleEntry {
297    /// The capsule reference (`storeId:rootHash`).
298    pub capsule: String,
299    /// The capsule root hash.
300    pub root: String,
301    /// The capsule size on disk, in bytes.
302    pub size_bytes: u64,
303    /// When the capsule was last served, in unix milliseconds.
304    pub last_used_unix_ms: u64,
305}
306
307/// One hosted/pinned store (`control.hostedStores.list`).
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309pub struct HostedStore {
310    /// The canonical lowercase 64-hex store id.
311    pub store_id: String,
312    /// Whether the operator has pinned this store.
313    pub pinned: bool,
314    /// The number of cached capsules of this store.
315    pub capsule_count: u64,
316    /// The total cached bytes across this store's capsules.
317    pub total_bytes: u64,
318    /// The cached capsules of this store.
319    pub capsules: Vec<CapsuleEntry>,
320}
321
322/// `control.hostedStores.list` — every held/pinned store.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct HostedStoresListResult {
325    /// The stores, one entry per distinct store id.
326    pub stores: Vec<HostedStore>,
327}
328
329/// `control.hostedStores.pin` — the pin acknowledgement + the pre-fetch outcome.
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331pub struct PinResult {
332    /// The store id that was pinned.
333    pub store_id: String,
334    /// The pinned root, or `null` when pinned at store level.
335    pub root: Option<String>,
336    /// Always `true`.
337    pub pinned: bool,
338    /// The in-band pre-fetch outcome (`{status, …}`) — its shape varies with the fetch path.
339    pub fetch: serde_json::Value,
340}
341
342/// `control.hostedStores.unpin` — the unpin acknowledgement + eviction count.
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub struct UnpinResult {
345    /// The store id that was unpinned.
346    pub store_id: String,
347    /// Whether a pin registry entry was actually removed.
348    pub unpinned: bool,
349    /// How many cached capsules of the store were evicted.
350    pub evicted_capsules: u64,
351}
352
353/// `control.hostedStores.status` — per-store pinned flag + cached capsules.
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub struct HostedStoreStatusResult {
356    /// The store id queried.
357    pub store_id: String,
358    /// Whether the store is pinned.
359    pub pinned: bool,
360    /// The number of cached capsules.
361    pub capsule_count: u64,
362    /// The total cached bytes.
363    pub total_bytes: u64,
364    /// The cached capsules.
365    pub capsules: Vec<CapsuleEntry>,
366}
367
368/// `control.capsule.fetch` — the P2P pull acknowledgement.
369///
370/// This is a STARTED/ALREADY-CACHED acknowledgement, not a completion report: unlike
371/// `control.sync.trigger`'s §21 HTTP fetch (synchronous, single hop), a P2P pull recursively
372/// discovers a holder and may stream through several onion hops, so it can take arbitrarily
373/// long. A caller wanting to know when the bytes actually land polls `control.hostedStores.status`
374/// for the store, the same way `control.hostedStores.pin`'s pre-fetch is observed today.
375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
376pub struct CapsuleFetchResult {
377    /// The store id the fetch was requested for.
378    pub store: String,
379    /// The capsule root requested.
380    pub root: String,
381    /// The outcome: `"started"` (a P2P pull was launched), `"already_cached"` (the capsule was
382    /// already on disk and no pull was needed), or `"unavailable"` (recursive discovery found no
383    /// holder to pull from right now — the caller may retry later).
384    pub status: String,
385}
386
387/// `control.sync.status` — §21 sync availability + pin coverage.
388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389pub struct SyncStatusResult {
390    /// Whether authenticated §21 whole-store sync is available.
391    pub available: bool,
392    /// The sync method name.
393    pub method: String,
394    /// The number of pinned stores.
395    pub pinned_total: u64,
396    /// How many pinned stores currently have a cached capsule.
397    pub pinned_synced: u64,
398    /// Whether whole-store (root-less) sync is supported by this build.
399    pub whole_store_trigger_supported: bool,
400}
401
402/// `control.sync.trigger` — the synced-capsule outcome.
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404pub struct SyncTriggerResult {
405    /// The store id synced.
406    pub store_id: String,
407    /// The capsule root synced.
408    pub root: String,
409    /// The outcome status (`"synced"`).
410    pub status: String,
411    /// The synced capsule size, in bytes.
412    pub size_bytes: u64,
413    /// The served root the node verified against.
414    pub served_root: String,
415}
416
417/// `control.pairing.approve` — the mint acknowledgement + the new token's id.
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419pub struct PairingApproveResult {
420    /// Always `true`.
421    pub approved: bool,
422    /// The requesting client's declared name.
423    pub client_name: String,
424    /// The short id of the minted paired token (used to revoke it).
425    pub token_id: String,
426}
427
428/// `control.pairing.revoke` — the revoke acknowledgement.
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
430pub struct PairingRevokeResult {
431    /// Whether a token was actually removed.
432    pub revoked: bool,
433    /// The token id that was targeted.
434    pub token_id: String,
435}
436
437/// `control.peerCounts` — how many peers this node holds on EACH network.
438///
439/// # Two networks, two numbers, and neither is "peers"
440///
441/// A DIG node is connected to two entirely separate networks at once: the DIG content/gossip
442/// network (port 9445), and the Chia full nodes its wallet chain sync talks to. The counts are
443/// unrelated and move independently — a node with many DIG peers and no Chia peer is serving content
444/// while its wallet is not syncing at all, and the reverse is equally possible.
445///
446/// So neither field is spelled `peers`, `connected_peers` or `peer_count`. A bare name forces a
447/// consumer to KNOW which network a number describes, and the failure when it guesses wrong is
448/// silent: a plausible integer in a right-looking place. This method exists so that one call answers
449/// for both networks and each answer names its own.
450///
451/// # `relay.peer_count` from `control.peerStatus` is NOT this
452///
453/// That field counts the peers connected to THE RELAY, not to this node, and it is frequently the
454/// only non-zero number on a node connected to nothing. It is never the answer to "how many peers
455/// does this node have"; [`dig_peer_count`](Self::dig_peer_count) is.
456///
457/// # `Some(0)` is measured; `null` is unknown
458///
459/// `0` means the node looked at that network and found nothing connected. `null` means it cannot
460/// observe the count at all — which is what a node whose peer network is not running reports, since
461/// a zero there would claim "nothing is connected" about a network it never asked.
462///
463/// # Connected is not the same question as known
464///
465/// [`known_dig_peer_count`](PeerCountsResult::known_dig_peer_count) answers a THIRD question —
466/// how many DIG peers this node has heard of, connected or not — so that a lonely node can say
467/// which of the two ways it is lonely. Every count here is one node's local view; none of them is
468/// the size of the network.
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
470pub struct PeerCountsResult {
471    /// Peers on the DIG content/gossip network (port 9445) — dig-node-core's `connected_peers`, the
472    /// same figure `control.peerStatus` reports. `0` is an observed zero; `null` is unobservable.
473    pub dig_peer_count: Option<u32>,
474    /// CHIA full-node peers the wallet's chain sync holds. The SAME observation
475    /// [`WalletSyncStatusResult::chia_peer_count`] reports — a conforming node MUST serve both from
476    /// one source, and the two MUST agree within a single node's view.
477    pub chia_peer_count: Option<u32>,
478    /// DIG peers this node has LEARNED OF but is not necessarily connected to — the size of its own
479    /// discovered-peer address book (dig_ecosystem#2570).
480    ///
481    /// This exists so a client can distinguish "this node is connected to nobody" from "there is
482    /// nobody to connect to", which [`dig_peer_count`](Self::dig_peer_count) alone cannot tell
483    /// apart. A node reporting `dig_peer_count: 0` alongside a known count of 40 has a reachability
484    /// problem; one reporting `0` alongside `0` has a discovery problem. Those are different faults
485    /// with different remedies, and until this field existed both rendered as the same zero.
486    ///
487    /// # What it does NOT count
488    ///
489    /// **It is not the size of the DIG network, and no field on this interface is.** It is ONE
490    /// node's local view and therefore a LOWER BOUND: it omits every peer this node has not been
491    /// introduced to, every peer behind a relay it does not use, every peer that entered the
492    /// network after this node's last discovery pass, and every entry its address book evicted
493    /// under its bucket limits. Two healthy nodes on the same network will report different numbers
494    /// and neither is wrong. A client MUST label it as discovered/known peers — rendering it as
495    /// "total peers" or "network size" asserts global knowledge that nothing here has.
496    ///
497    /// It is also NOT `control.peerStatus`'s `relay.peer_count`, which counts peers registered with
498    /// THE RELAY — a different party's view, scoped to that one relay.
499    ///
500    /// # Relationship to [`dig_peer_count`](Self::dig_peer_count)
501    ///
502    /// Normally `known_dig_peer_count >= dig_peer_count`, since a connected peer is a peer this node
503    /// knows of. A client MUST NOT rely on that ordering as an invariant: the two are sampled from
504    /// separate structures and a transient inversion during churn is not a protocol violation.
505    ///
506    /// # `Some(0)` is measured; `null` is unknown
507    ///
508    /// `0` means the node consulted its address book and found it empty. `null` means it could not
509    /// consult it at all — which is what a node whose peer network is not running reports, and what
510    /// a node too old to have this field reports by omitting it. Serde treats the missing field as
511    /// `None`, so an older node's payload decodes here as "unknown" rather than being rejected, and
512    /// an older CLIENT ignores the extra field: the addition is compatible in both directions.
513    pub known_dig_peer_count: Option<u32>,
514}
515
516/// `control.peers.connect` — the connected peer's id.
517#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
518pub struct PeersConnectResult {
519    /// Always `true` on success.
520    pub connected: bool,
521    /// The connected peer's id.
522    pub peer_id: String,
523}
524
525/// `control.peers.disconnect` — the dropped peer's id.
526#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
527pub struct PeersDisconnectResult {
528    /// Always `true` (idempotent — dropping an absent peer still succeeds).
529    pub disconnected: bool,
530    /// The peer id that was targeted (trimmed + lower-cased).
531    pub peer_id: String,
532}
533
534/// One tracked Chia full-node peer.
535#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
536pub struct ChiaPeerEntry {
537    /// The peer's IP address, in the canonical form defined by
538    /// [`crate::params::canonical_peer_ip`] — a bare literal, never bracketed and never carrying a
539    /// port.
540    pub ip: String,
541    /// The peer's port (the standard full-node port unless the entry says otherwise).
542    pub port: u16,
543    /// The peak height this peer last reported, or `null` where the node has NO telemetry for it
544    /// yet.
545    ///
546    /// `null` means UNOBSERVABLE, never zero — the convention `control.peerCounts` and
547    /// `control.wallet.peak` already use, and it matters more here: this is the one signal an
548    /// operator has for judging whether a peer they trust WITHOUT corroboration is current or
549    /// stuck, and a peer nobody has polled must not read as a peer stalled at genesis.
550    ///
551    /// A reported height is that peer's CLAIM, never a fact this node verified — never a
552    /// fabricated height, and never to be aggregated into a chain position (NC-12: a maximum over
553    /// claimed peaks is whatever the most dishonest peer says).
554    pub peak_height: Option<u32>,
555    /// TRUE where a person added this peer by hand, which is exactly the set that is trusted
556    /// WITHOUT corroboration. Discovered peers are `false` and stay subject to agreement.
557    pub user_managed: bool,
558    /// TRUE where this entry is BANNED — kept so discovery cannot re-add it, and excluded from
559    /// every chain read.
560    ///
561    /// Banned entries appear in this list because it is the ONLY enumeration of the banned set,
562    /// and a blocklist a person cannot read is a blocklist they cannot correct.
563    pub banned: bool,
564}
565
566/// `control.chiaPeers.list` — every tracked Chia peer: trusted, discovered and banned alike.
567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568pub struct ChiaPeersListResult {
569    /// The tracked peers — read `user_managed` to tell the trusted set from the discovered one,
570    /// and `banned` to see the exclusions. A conforming node MUST NOT omit banned entries: this
571    /// list is the only way to enumerate them.
572    pub peers: Vec<ChiaPeerEntry>,
573}
574
575/// `control.chiaPeers.add` — the acknowledgement, including the cost that was paid.
576#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
577pub struct ChiaPeersAddResult {
578    /// Always `true` on success (idempotent — re-adding a known peer succeeds and un-bans it).
579    pub added: bool,
580    /// The peer's IP address as stored, in the canonical form defined by
581    /// [`crate::params::canonical_peer_ip`].
582    pub ip: String,
583    /// The port the entry was stored at.
584    pub port: u16,
585    /// Whether this peer is NOW believed without corroboration — the RESULTING trust state, not a
586    /// restatement of what was asked for.
587    ///
588    /// `true` in the ordinary case, and a conforming node MUST report `false` where the entry did
589    /// not end up trusted, however that came about (an upsert that touches other columns and
590    /// leaves the trusted flag alone is how it happens in practice). Reported honestly, this is
591    /// the only way an operator learns that the node they believe they configured is still subject
592    /// to corroboration; reported as a constant, it is a claim about custody-grade authority that
593    /// nothing checks.
594    pub corroboration_bypassed: bool,
595    /// The human-readable warning the node authored for this call, to be rendered VERBATIM to the
596    /// person who made it.
597    ///
598    /// This is the field a client quotes instead of restating the cost locally and drifting from
599    /// the node's wording. It MUST be non-empty and MUST name the corroboration bypass; a client
600    /// MUST NOT paraphrase, truncate or suppress it.
601    pub notice: String,
602}
603
604/// What `control.chiaPeers.remove` actually DID.
605///
606/// An enum rather than a boolean, and deliberately with no always-true companion field, because
607/// `remove` is the ONLY way to un-trust a peer holding unbounded authority over the money-bearing
608/// wallet replica. A remedy that cannot report its own failure is worse than no remedy: the
609/// operator believes they revoked custody-grade trust and they did not. A consumer has to MATCH on
610/// this, so it cannot render "nothing was there" as "it is gone".
611#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
612#[serde(rename_all = "snake_case")]
613pub enum ChiaPeerRemovalOutcome {
614    /// A matching entry existed and is gone — or, with `ban`, is now banned.
615    Removed,
616    /// NOTHING matched the address given. The trusted set is unchanged, so any peer the caller
617    /// meant to un-trust is STILL trusted — most often because the address was spelled differently
618    /// from the stored entry. A client MUST surface this as a failure to act, never as success.
619    NoSuchPeer,
620}
621
622/// `control.chiaPeers.remove` — the acknowledgement.
623#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
624pub struct ChiaPeersRemoveResult {
625    /// What happened — see [`ChiaPeerRemovalOutcome`]. There is no `removed: true` here, on
626    /// purpose.
627    pub outcome: ChiaPeerRemovalOutcome,
628    /// The peer's IP address as targeted, in the canonical form defined by
629    /// [`crate::params::canonical_peer_ip`].
630    pub ip: String,
631    /// Whether the peer is now BANNED rather than merely forgotten.
632    pub banned: bool,
633}
634
635/// `control.subscribe` — the subscription acknowledgement.
636#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
637pub struct SubscribeResult {
638    /// Always `true`.
639    pub subscribed: bool,
640    /// Whether the store was newly added (vs already subscribed).
641    pub added: bool,
642    /// The canonical persisted store id (trimmed + lower-cased).
643    pub store_id: String,
644    /// What the node recorded the subscription as following. OMITTED means
645    /// [`SubscriptionKind::Capsule`](crate::params::SubscriptionKind::Capsule), so a node build
646    /// that predates the field still parses here rather than failing the whole response.
647    #[serde(default)]
648    pub kind: crate::params::SubscriptionKind,
649}
650
651/// `control.unsubscribe` — the unsubscription acknowledgement.
652#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
653pub struct UnsubscribeResult {
654    /// Always `false`.
655    pub subscribed: bool,
656    /// Whether the store was actually removed.
657    pub removed: bool,
658    /// The canonical store id.
659    pub store_id: String,
660}
661
662/// `control.listSubscriptions` — the node's persisted subscription set.
663#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
664pub struct ListSubscriptionsResult {
665    /// The subscribed store ids.
666    pub subscriptions: Vec<String>,
667    /// The subscription count.
668    pub count: u64,
669}
670
671/// `control.wallet.balance` — an address's balance for one asset, as the node's chain read saw it.
672///
673/// A READ-only result: this reports chain state, it never moves funds. It is a strict SUPERSET of
674/// dig-app's frozen `BalanceResponse { balance }` — the node emits the richer shape, and because
675/// dig-app's struct does not deny unknown fields it reads [`balance`](Self::balance) losslessly and
676/// ignores the rest. That superset relationship is the "no dig-app code change" guarantee, pinned by
677/// the conformance KAT.
678#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
679pub struct WalletBalanceResult {
680    /// The CONFIRMED, spendable balance in the asset's base unit (mojos for XCH, base units for DIG).
681    /// The only field dig-app 3.x reads.
682    pub balance: u64,
683    /// Incoming funds seen but not yet confirmed (asset base units); not yet spendable.
684    pub pending: u64,
685    /// Which tier produced these figures, or `None` from a node too old to disclose it.
686    ///
687    /// See [`WalletReadSource`]. Absent (`null` / omitted) is a THIRD state, not a default tier:
688    /// it means the answering node predates tier disclosure, so the caller knows the tier is
689    /// unknown rather than being told a tier that was never reported.
690    ///
691    /// The [`Option`] carries the backwards compatibility on its own — serde treats a missing
692    /// `Option` field as `None` — so no `#[serde(default)]` is needed and none is written; a
693    /// REQUIRED field here would reject an older node's payload outright.
694    pub source: Option<WalletReadSource>,
695    /// Whether THESE figures reflect a caught-up local view. When `false`, they are STALE or came
696    /// from the fallback tier.
697    ///
698    /// This describes the ANSWER, not the node: a [`WalletReadSource::Fallback`] answer is always
699    /// `false`, however caught-up the node's own replica happens to be.
700    pub synced: bool,
701    /// The peak block height the reported figures reflect, or `null` when no height applies —
702    /// including every [`WalletReadSource::Fallback`] answer, whose figures came from the oracle's
703    /// chain view rather than the node's.
704    pub peak_height: Option<u32>,
705}
706
707/// Which tier answered a wallet read (dig_ecosystem#2233).
708///
709/// A node serves a wallet read either from its own chain replica or from a third-party HTTP
710/// oracle, and the two are not interchangeable to a caller: the oracle path is a network round
711/// trip that **discloses the queried address off-node**, which a user on a metered or private
712/// connection has a legitimate interest in knowing about. Reporting the tier is also what makes
713/// "the node answered from its own chain state" a falsifiable claim — a sync-progress flag is not,
714/// since a flag can flip while the oracle keeps answering.
715#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
716#[serde(rename_all = "lowercase")]
717pub enum WalletReadSource {
718    /// The node's own local chain replica. No third party was consulted.
719    Db,
720    /// A third-party coinset HTTP oracle. The queried value — an address, or a COIN ID on
721    /// `control.wallet.coinById` — was disclosed off-node.
722    ///
723    /// The coin-id case is the more sensitive of the two, and the less obvious: an address is
724    /// disclosed on every routine balance poll, whereas querying a freshly created coin id, from the
725    /// spender's IP, at the moment of the spend, hands the oracle a `{IP, timestamp, coin id}` tuple
726    /// that ties a network identity to a specific new on-chain identity.
727    Fallback,
728}
729
730/// One coin, as the node's chain read saw it (`control.wallet.coins` / `control.wallet.coinById`).
731///
732/// The first three fields are byte-identical to dig-app's frozen `CoinRecord`, so its
733/// `CoinsResponse` deserializes this losslessly and ignores the rest. The rest is what a spend
734/// actually needs: a coin cannot be spent from an id and an amount alone — the parent and the
735/// puzzle hash are what reconstruct the `Coin` — and the heights are how a caller tells a confirmed
736/// coin from one it only saw in the mempool.
737///
738/// ONE record type serves both reads deliberately. A second coin shape would be a second thing to
739/// keep in step with dig-app's frozen struct, and the two would drift byte-wise the first time only
740/// one of them was touched.
741#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
742pub struct WalletCoinRecord {
743    /// The coin id, lowercase 64-hex, unprefixed.
744    pub coin_id: String,
745    /// The asset this coin is denominated in, or `null` when THIS READ DID NOT CLASSIFY THE COIN.
746    ///
747    /// `null` never means "no asset" and never means XCH by default. It means the answering read
748    /// had no basis to say: a singleton, a CAT and a plain XCH coin are indistinguishable from a
749    /// coin id alone — telling them apart requires inspecting the puzzle, and the node reads only
750    /// the coin record. So `control.wallet.coinById` MUST report `null` here — emitting a concrete
751    /// asset on an unclassified read would make the node assert a classification it never verified,
752    /// which a caller would then spend against.
753    ///
754    /// `control.wallet.coins` MUST report the concrete asset it was SCOPED to and MUST NOT emit
755    /// `null`. This field is optional only to serve the by-id read; the coins read has no
756    /// unclassified case, and dig-app's frozen `CoinRecord` requires a non-null asset there, so a
757    /// `null` breaks that read outright rather than degrading it. The type cannot enforce the split
758    /// because ONE record shape deliberately serves both reads (see the type docs), which is why the
759    /// rule is stated here and pinned by a KAT.
760    pub asset: Option<crate::params::Asset>,
761    /// The coin's amount, in the asset's base unit.
762    pub amount: u64,
763    /// The parent coin's id, lowercase 64-hex, unprefixed.
764    pub parent_coin_info: String,
765    /// The coin's puzzle hash, lowercase 64-hex, unprefixed.
766    pub puzzle_hash: String,
767    /// The height the coin was created at, or `null` while it is still only in the mempool.
768    pub created_height: Option<u32>,
769    /// The height the coin was spent at, or `null` when it is unspent.
770    pub spent_height: Option<u32>,
771}
772
773/// `control.wallet.coins` — an address's spendable coins for one asset.
774///
775/// # An empty list is an ANSWER, never a fallback
776///
777/// `coins: []` means the node consulted a chain and that address holds nothing. It is NEVER what a
778/// caller gets when the chain could not be reached: those are catalogued errors
779/// ([`crate::error::ControlErrorCode::WalletNoChainSource`] / `WalletNotSynced` /
780/// `WalletReadFailed` / `WalletRateLimited`). The distinction is the whole point of the method —
781/// a well-shaped empty result on an unreachable chain would tell somebody who holds funds that they
782/// hold nothing, and a spend built on that answer refuses with a shortfall that is not true.
783#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
784pub struct WalletCoinsResult {
785    /// The spendable coins found at the address, possibly empty (see the type docs).
786    pub coins: Vec<WalletCoinRecord>,
787    /// Which tier answered, or `None` from a node too old to disclose it. See [`WalletReadSource`].
788    pub source: Option<WalletReadSource>,
789    /// Whether THESE coins reflect a caught-up local view; always `false` for a fallback answer.
790    pub synced: bool,
791    /// The peak height these coins reflect, or `null` when none applies (every fallback answer).
792    pub peak_height: Option<u32>,
793}
794
795/// Deserialize an `Option<T>` that is nullable but NOT omittable.
796///
797/// Serde special-cases a missing field of type `Option<T>` into `None`, so a required-but-nullable
798/// field is not expressible by the derive alone. Naming a `deserialize_with` suppresses that
799/// special case: an absent key becomes a `missing field` error, while an explicit `null` still
800/// decodes to `None`.
801fn required_option<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
802where
803    D: serde::Deserializer<'de>,
804    T: Deserialize<'de>,
805{
806    Option::<T>::deserialize(deserializer)
807}
808
809/// `control.wallet.coinById` — ONE coin, named by its own id, spent or unspent.
810///
811/// # An absent coin is an ANSWER; an unreachable chain is an ERROR
812///
813/// `coin: null` means a chain WAS consulted and holds no such coin. It is NEVER what a caller gets
814/// when the chain could not be reached: those are the catalogued errors
815/// ([`crate::error::ControlErrorCode::WalletNoChainSource`] / `WalletReadFailed` /
816/// `WalletRateLimited`). Collapsing the two turns "your wifi dropped" into "your mint never
817/// happened", and the remedies are opposite: retry the read, versus stop waiting.
818///
819/// # Why this method exists — observing a mint
820///
821/// `control.wallet.broadcast`'s `accepted: true` reports mempool admission only; only a buried
822/// confirmation of the CREATED COIN is evidence that a mint happened. `control.wallet.coins`
823/// cannot supply it — it answers by ADDRESS and lists UNSPENT coins only, so it can see neither the
824/// created DID coin nor the funding coin the mint spent. This method is how that evidence is
825/// obtained: read the created coin's id for a `created_height`, and the funding coin's id for a
826/// [`spent_height`](WalletCoinRecord::spent_height). Without it a mint can be pushed, real XCH can
827/// leave the wallet, and the outcome stays permanently "pending".
828///
829/// # The freshness fields are honest, not decorative
830///
831/// [`source`](Self::source) discloses which tier answered, and every freshness field describes THAT
832/// tier — the same rule the by-address reads carry. A `fallback` answer MUST report
833/// [`synced`](Self::synced) `false` and [`peak_height`](Self::peak_height) `null` however caught-up
834/// the node's own replica is, because the oracle produced the figures and the replica neither
835/// produced them nor bounds their freshness. A `db` answer means the node's OWN replica answered, so
836/// it MUST report `synced: true` and the replica's peak.
837///
838/// # A negative answer requires a view that could have held the coin
839///
840/// `coin: null` is a VERDICT — it says stop waiting — so it MUST NOT be served from a view that
841/// could not have seen the coin in the first place. A node whose replica is still catching up, or
842/// whose local index is address-scoped rather than a full chain view, has NOT established that the
843/// coin is absent; it has only established that IT cannot see it. Such a node MUST return
844/// [`WalletNoChainSource`](crate::error::ControlErrorCode::WalletNoChainSource) or
845/// [`WalletReadFailed`](crate::error::ControlErrorCode::WalletReadFailed) and MUST NOT answer
846/// `coin: null`.
847///
848/// This matters precisely for the two coins this method exists to observe. A created coin sits at no
849/// wallet address and a spent funding coin is gone from every unspent list, so an address-scoped
850/// replica is guaranteed to miss both — and a `coin: null` from it would report a mint that DID
851/// happen as never-having-happened, with the funds already gone. `control.wallet.peak` is no escape
852/// hatch here: it reports that same replica's height, which can bound a positive confirmation but
853/// can never license a negative one.
854#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
855pub struct WalletCoinByIdResult {
856    /// The coin, or `null` when the consulted chain holds no coin with that id (see the type docs).
857    ///
858    /// The key MUST be present. `null` is a verdict here, so an ABSENT key must not decode into one:
859    /// serde's default treatment of `Option` makes a missing field indistinguishable from an
860    /// explicit `null`, which would let an unrelated or truncated payload — anything at all carrying
861    /// a `synced` field — decode into a confident "the chain holds no such coin". `deserialize_with`
862    /// suppresses that default so the field is genuinely required.
863    #[serde(deserialize_with = "required_option")]
864    pub coin: Option<WalletCoinRecord>,
865    /// Which tier answered, or `None` from a node too old to disclose it. See [`WalletReadSource`].
866    pub source: Option<WalletReadSource>,
867    /// Whether this answer reflects a caught-up local view; `false` for every fallback answer.
868    pub synced: bool,
869    /// The peak height this answer reflects, or `null` when none applies (every fallback answer).
870    pub peak_height: Option<u32>,
871}
872
873/// One coin's SPEND: the coin that was consumed, plus the two programs that consumed it.
874///
875/// This is the chia `CoinSpend` in the contract's own wire form — the puzzle reveal and the solution
876/// as lowercase hex of their serialized CLVM, beside the [`WalletCoinRecord`] for the spent coin.
877/// The coin is carried as the SAME record type the other reads use rather than a trimmed
878/// parent/puzzle-hash/amount triple, because a second coin shape is a second thing to keep in step
879/// with dig-app's frozen `CoinRecord` (see [`WalletCoinRecord`]).
880///
881/// # The reveal is checkable, and a conforming node MUST have checked it
882///
883/// A puzzle reveal is supplied by a peer, and a lying peer can supply a different program. The
884/// reveal's tree hash MUST equal the spent coin's own
885/// [`puzzle_hash`](WalletCoinRecord::puzzle_hash), which makes the claim self-checking, and a node
886/// MUST fail closed — a catalogued error, never a spend carrying an unverified reveal — when the
887/// hashes disagree or the reveal does not parse. A caller MAY re-derive the same check from the two
888/// fields it is handed; it never has to trust the node to have done it.
889///
890/// # `spent_height` is present on the coin, always
891///
892/// A spend exists only because the coin was spent, so
893/// [`spent_height`](WalletCoinRecord::spent_height) MUST be non-null here. A spend reporting an
894/// unspent coin is a contradiction the shape cannot forbid, so the contract forbids it instead.
895#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
896pub struct WalletCoinSpend {
897    /// The coin this spend consumed. Its `spent_height` MUST be non-null (see the type docs).
898    pub coin: WalletCoinRecord,
899    /// The puzzle reveal: lowercase hex of the serialized CLVM program. MUST tree-hash to
900    /// [`coin.puzzle_hash`](WalletCoinRecord::puzzle_hash).
901    pub puzzle_reveal: String,
902    /// The solution the puzzle was run with: lowercase hex of the serialized CLVM.
903    pub solution: String,
904}
905
906/// `control.wallet.coinSpend` — the spend that spent one coin, named by that coin's id.
907///
908/// # `spend: null` is an ANSWER with TWO honest causes; an unreachable chain is an ERROR
909///
910/// `null` means a chain WAS consulted and no spend of that coin exists there — either because the
911/// coin is UNSPENT, or because the chain holds no such coin at all. Both are legitimately "there is
912/// no spend", and the contract deliberately does not distinguish them here: a caller that needs to
913/// tell them apart asks [`WalletCoinByIdResult`], whose `coin: null` separates the two.
914///
915/// What `null` NEVER means is that the node could not answer. That is a catalogued error
916/// ([`WalletNoChainSource`](crate::error::ControlErrorCode::WalletNoChainSource) /
917/// [`WalletReadFailed`](crate::error::ControlErrorCode::WalletReadFailed) /
918/// [`WalletRateLimited`](crate::error::ControlErrorCode::WalletRateLimited)). The three-valued
919/// distinction is money-critical: a caller following a singleton forward reads "no spend" as *this
920/// is the current tip* and stops walking. Collapsing "could not answer" into it makes a stale coin
921/// look like the tip, and a spend built against a superseded singleton is invalid.
922///
923/// # A negative answer requires a view that could have held the spend
924///
925/// `spend: null` is a VERDICT, and the same rule [`WalletCoinByIdResult`] states applies unchanged: a
926/// node whose replica is still catching up, or whose index is address-scoped rather than a full
927/// chain view, has established only that IT cannot see the spend. Such a node MUST return
928/// `WalletNoChainSource` / `WalletReadFailed` and MUST NOT answer `null`.
929#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
930pub struct WalletCoinSpendResult {
931    /// The spend, or `null` when the consulted chain holds no spend of that coin (see the type docs).
932    ///
933    /// The key MUST be present. `null` is a verdict here, so an ABSENT key must not decode into one —
934    /// the same reason [`WalletCoinByIdResult::coin`] is required.
935    #[serde(deserialize_with = "required_option")]
936    pub spend: Option<WalletCoinSpend>,
937    /// Which tier answered, or `None` from a node too old to disclose it. See [`WalletReadSource`].
938    pub source: Option<WalletReadSource>,
939    /// Whether this answer reflects a caught-up local view; `false` for every fallback answer.
940    pub synced: bool,
941    /// The peak height this answer reflects, or `null` when none applies (every fallback answer).
942    pub peak_height: Option<u32>,
943}
944
945/// `control.wallet.coinsByParent` — the DIRECT children created by spending one coin.
946///
947/// # ONE hop, never a walk
948///
949/// The list is the coins the named parent's spend created, and nothing further. It is not a lineage,
950/// not a subtree, and not transitive: a grandchild appears only when the caller asks again with the
951/// child's id. A node MUST NOT recurse — an unbounded server-side walk over caller-supplied input is
952/// work the caller cannot bound, and a partial walk returned as if complete would be a lineage with
953/// a silent hole in it.
954///
955/// # A page, and it says so — the truncation rule
956///
957/// [`coins`](Self::coins) is ONE PAGE of the parent's children, bounded by
958/// [`COINS_BY_PARENT_MAX_LIMIT`](crate::params::COINS_BY_PARENT_MAX_LIMIT). Whether it is the WHOLE
959/// child set is stated by [`complete`](Self::complete) and never left to be inferred from the page's
960/// length.
961///
962/// This is the money-critical shape in this type. A caller walking a lineage reads "no more
963/// children" as *this branch ends here*, so a page that was truncated but looks whole terminates the
964/// walk early and presents a partial lineage as a complete one. Inferring completeness from
965/// `coins.len() < limit` is NOT equivalent and MUST NOT be done: a node is free to return a short
966/// page for its own reasons, and a child set that is an exact multiple of the page size makes the
967/// last full page indistinguishable from a truncated one.
968///
969/// # Resuming: the same lesson `control.wallet.arrivals` records
970///
971/// Resume from [`cursor`](Self::cursor) — the last child you were actually HANDED — by passing it
972/// as [`after_coin_id`](crate::params::WalletCoinsByParentParams::after_coin_id). There is
973/// deliberately no "where the chain got to" marker on this type to reach for instead; that is the
974/// distinction `WalletArrivalsResult::latest` exists to warn about, and the cheapest way not to lose
975/// a row to it is to give a caller nothing else to resume from.
976///
977/// # The order is part of the contract, because paging is meaningless without one
978///
979/// A node MUST return children in ASCENDING `coin_id` order, and MUST keep that order stable across
980/// the pages of one walk. `after_coin_id` means *strictly after this id in that order*. Without a
981/// fixed order a cursor names no position, and a walk would silently repeat some children and skip
982/// others. Coin ids are fixed-length lowercase hex, so ascending lexicographic order and ascending
983/// 32-byte numeric order are the SAME order — an implementation may use whichever it has, and the
984/// two can never disagree.
985///
986/// # An empty list is an ANSWER, never a fallback
987///
988/// `coins: []` means the node consulted a chain and that parent created no children it knows of —
989/// typically because the parent is unspent. It is NEVER what a caller gets when the chain could not
990/// be reached: those are the catalogued errors
991/// ([`WalletNoChainSource`](crate::error::ControlErrorCode::WalletNoChainSource) /
992/// [`WalletReadFailed`](crate::error::ControlErrorCode::WalletReadFailed) /
993/// [`WalletRateLimited`](crate::error::ControlErrorCode::WalletRateLimited)). The distinction is the
994/// same one every read in this family carries, and it matters most here: a caller walking a
995/// singleton forward reads an empty list as *this is the tip*.
996///
997/// # `asset` is `null` on every record
998///
999/// A child is named by its parent, not by an address and not by an asset, so this read classifies
1000/// nothing — exactly like [`WalletCoinByIdResult`]. Every record MUST report
1001/// [`asset`](WalletCoinRecord::asset) as `null` rather than assert a class the read never verified.
1002#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1003pub struct WalletCoinsByParentResult {
1004    /// One page of the parent's direct children, ascending by `coin_id`, possibly empty. One hop
1005    /// only, and NOT necessarily the whole child set — see [`complete`](Self::complete).
1006    pub coins: Vec<WalletCoinRecord>,
1007    /// Is this page the WHOLE child set?
1008    ///
1009    /// `true` means every child the node knows of is in [`coins`](Self::coins) and the walk of this
1010    /// hop is finished. `false` means the answer was TRUNCATED and more children exist — resume from
1011    /// [`cursor`](Self::cursor).
1012    ///
1013    /// Required on the wire, and stated positively so that the reading a caller falls into when the
1014    /// field is absent or defaulted is the SAFE one. A boolean spelled `truncated` would default to
1015    /// `false`, i.e. to "this is everything", which is the claim that ends a lineage walk early;
1016    /// `complete` defaults to "there may be more", which costs at worst one redundant request.
1017    pub complete: bool,
1018    /// The last child in this page — **the value to resume from** — or `null` for an empty page.
1019    ///
1020    /// It is the id the caller was HANDED, never a marker for where the chain got to. Pass it as
1021    /// [`after_coin_id`](crate::params::WalletCoinsByParentParams::after_coin_id) to fetch the next
1022    /// page.
1023    ///
1024    /// The key MUST be present. `null` is meaningful here — it says this page carried nothing — so
1025    /// an ABSENT key must not decode into it: serde's default treatment of `Option` would let a
1026    /// truncated or mis-routed payload decode into a confident "there was nothing to resume from".
1027    #[serde(deserialize_with = "required_option")]
1028    pub cursor: Option<String>,
1029    /// Which tier answered, or `None` from a node too old to disclose it. See [`WalletReadSource`].
1030    pub source: Option<WalletReadSource>,
1031    /// Whether these children reflect a caught-up local view; `false` for every fallback answer.
1032    pub synced: bool,
1033    /// The peak height these children reflect, or `null` when none applies (every fallback answer).
1034    pub peak_height: Option<u32>,
1035}
1036
1037/// `control.wallet.peak` — the node's current chain peak height.
1038///
1039/// `peak_height: null` is an honest "this node tracks no height yet", not a zero. A caller bounding
1040/// a claimed confirmation MUST treat it as unknown rather than as height 0, which every block is
1041/// trivially above.
1042///
1043/// # This `synced` is the WEAKER of the contract's two same-named notions
1044///
1045/// [`synced`](Self::synced) here reports only that the replica's initial catch-up COMPLETED. It says
1046/// nothing about whether the wallet is still connected to a Chia peer, so a node that caught up
1047/// yesterday and has been offline since still reports `synced: true` beside a height that stopped
1048/// moving. [`WalletSyncStatusResult::phase`] answers the stronger question — *is this being kept
1049/// current?* — and `WalletSyncPhase::Synced` therefore IMPLIES this flag while this flag does not
1050/// imply that phase. The two are stated in terms of each other on purpose: they carry the same word
1051/// and would otherwise drift apart silently.
1052///
1053/// # The height is the last EXISTING block
1054///
1055/// It is the height of the last block the peer view reported, never a next-block height. A consumer
1056/// computing confirmation depth must floor its own arithmetic rather than assume a convention — see
1057/// [`WalletSyncStatusResult`], which records why.
1058#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1059pub struct WalletPeakResult {
1060    /// The peak block height the node's chain view has reached, or `null` when it has none.
1061    pub peak_height: Option<u32>,
1062    /// Whether the node's own chain replica COMPLETED its catch-up. Weaker than
1063    /// [`WalletSyncPhase::Synced`] — see the type docs.
1064    pub synced: bool,
1065}
1066
1067/// How far the node's wallet chain replica has got — the states a background sync can be in.
1068///
1069/// Named states rather than a boolean, because "has never started" and "is caught up" are different
1070/// facts and a `bool` can only carry one of them. Paired with a `peak_height` a boolean forces a
1071/// never-started wallet to report some height, and 0 is the only one available — which reads as
1072/// *synced to the genesis block*, a claim about the chain that is simply false.
1073///
1074/// # Nothing to watch is TWO states, not one
1075///
1076/// A sync with no addresses to follow is idle for one of two reasons, and they are different
1077/// sentences to a user with different remedies. [`NoWalletEnrolled`](Self::NoWalletEnrolled) is the
1078/// honest all-clear: there is no wallet, so watching nothing is correct and complete.
1079/// [`WalletNotUnlocked`](Self::WalletNotUnlocked) is the opposite — a wallet EXISTS and is not being
1080/// watched — and reporting it as the all-clear tells a user with real coins that their balance is
1081/// fully accounted for while the node follows none of their addresses. Merging the two would put a
1082/// money-lie behind a green tick, so the contract keeps them apart.
1083///
1084/// # An unrecognised token is a VALUE, not a parse failure
1085///
1086/// [`Unrecognized`](Self::Unrecognized) exists because this enum was once closed, and a node that
1087/// grew a new phase took every consumer's whole response down with it — see the variant's own docs.
1088/// Consumers MUST treat an unrecognised phase as *unknown*, never as progress.
1089#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1090pub enum WalletSyncPhase {
1091    /// No sync has begun: the wallet holds no replica of the chain and is not building one.
1092    NotStarted,
1093    /// A sync is running — either the initial catch-up, or the ongoing task that keeps the replica
1094    /// current. A wallet whose catch-up finished but whose peer connections have all dropped is
1095    /// `Syncing`, not [`Synced`](Self::Synced): it is trying to be current and is not.
1096    Syncing,
1097    /// The initial catch-up completed AND at least one Chia peer connection is currently live: the
1098    /// replica is caught up and CONNECTED, so it is in a position to be kept current.
1099    ///
1100    /// That is what the predicate delivers, and no more. A live connection to a stalled or lagging
1101    /// peer satisfies it while the replica quietly goes stale, so this phase MUST NOT be read as
1102    /// proof that the data is FRESH — only that nothing is known to be preventing freshness.
1103    Synced,
1104    /// **The honest all-clear: no wallet is enrolled on this node**, so there are no addresses to
1105    /// follow and a sync would have nothing to do. Not a degraded state and not an error — a node
1106    /// that has never had a wallet is working exactly as intended.
1107    ///
1108    /// A consumer MAY present this as settled. It is the ONLY nothing-to-watch phase for which that
1109    /// is true: [`WalletNotUnlocked`](Self::WalletNotUnlocked) looks identical from inside the sync
1110    /// loop and means the opposite.
1111    ///
1112    /// [`watched_addresses`](WalletSyncStatusResult::watched_addresses) accompanying this phase is
1113    /// `Some(0)` — an observed zero, and the zero that is genuinely fine.
1114    NoWalletEnrolled,
1115    /// **A wallet IS enrolled, but the node holds no addresses for it, so it is watching nothing.**
1116    /// The user's coins are not being followed and their balance is not being maintained.
1117    ///
1118    /// This is the common state after every restart, because the address set is derived from key
1119    /// material the node cannot reach until the wallet is unlocked, and nothing back-fills it while
1120    /// locked. It is emphatically NOT [`NoWalletEnrolled`](Self::NoWalletEnrolled): the difference
1121    /// between them is the difference between *nothing to do* and *something to do that is not being
1122    /// done*.
1123    ///
1124    /// A consumer MUST NOT render this as synced, settled, or up to date, and MUST NOT present a
1125    /// balance read under it as complete. The honest rendering names the wallet and the remedy —
1126    /// *"locked, so it is not being watched yet"* — because unlocking is the action that resolves
1127    /// it.
1128    ///
1129    /// The name says NOT UNLOCKED rather than *locked* on purpose. An empty address set is what the
1130    /// node can observe; a lock is only the usual cause of it, and a manifest that never carried the
1131    /// keys reaches the same state without anything having been locked. The phase claims the
1132    /// observation, and leaves the cause to whatever the node can actually establish.
1133    WalletNotUnlocked,
1134    /// **A phase token this build does not know**, carried verbatim.
1135    ///
1136    /// # Why this variant exists
1137    ///
1138    /// The enum shipped closed. dig-node then grew a phase, and because serde rejects an unknown
1139    /// variant, the unknown token did not degrade one field — it aborted the entire
1140    /// [`WalletSyncStatusResult`]. dig-app's sync read became `Err`, its chain-sync state collapsed
1141    /// to unknown, and the surface rendered nothing at all (dig_ecosystem#2609). Every consumer
1142    /// built against an older contract than the node it talks to hit it at once.
1143    ///
1144    /// # It is deliberately NOT silent
1145    ///
1146    /// The token is preserved rather than discarded so the state is *observable*: a consumer can say
1147    /// which token it failed to understand, and a developer can read it out of a log instead of
1148    /// reaching for a packet capture. This incident stayed invisible until somebody built a probe
1149    /// against the published crate; the variant that replaces it should not need one.
1150    ///
1151    /// Mapping an unknown token onto [`Synced`](Self::Synced) or [`Syncing`](Self::Syncing) would be
1152    /// far worse than the parse error it replaces. A parse error is loud and obviously wrong; a
1153    /// coerced phase is a confident, plausible statement about the user's money that the node never
1154    /// made. Consumers MUST render this as unknown and MUST NOT infer progress, completion, or a
1155    /// trustworthy balance from it.
1156    ///
1157    /// # The payload is untrusted text
1158    ///
1159    /// It is whatever the node sent. A consumer that displays it MUST escape and bound it like any
1160    /// other foreign string rather than splicing it into a message unchecked. `Debug` escapes it, as
1161    /// `String`'s always has; [`as_wire`](Self::as_wire) deliberately does not, because a relay must
1162    /// be able to hand on the exact bytes.
1163    ///
1164    /// # Not the same idea as [`PeerSoftware::Unknown`]
1165    ///
1166    /// The two look alike and are not. `PeerSoftware::Unknown` is the ABSENCE of a report — the peer
1167    /// said nothing, or said something unparseable, and there is no datum to keep. Here the node DID
1168    /// report, and the token it used is a real observation this build cannot interpret. That is why
1169    /// this variant carries a payload and that one does not, and why the names differ: calling it
1170    /// `Unknown` would suggest nothing was said.
1171    Unrecognized(UnknownPhaseToken),
1172}
1173
1174/// A phase token this build does not recognise, held so it cannot be confused with one it does.
1175///
1176/// # Why the payload is a type and not a bare `String`
1177///
1178/// [`WalletSyncPhase::Unrecognized`] serializes whatever it holds. With a public `String` inside,
1179/// `Unrecognized("synced".to_owned())` was constructible by any consumer, reported
1180/// `is_recognized() == false` locally, went onto the wire as the bare token `"synced"`, and arrived
1181/// at the far side as a confident [`WalletSyncPhase::Synced`] — a value that claims the wallet is
1182/// caught up while calling itself unrecognised. It was also the one value in the type that did not
1183/// round-trip, contradicting the verbatim-carriage guarantee the variant exists to provide.
1184///
1185/// The field is private and this type has no public constructor, so the only way to reach
1186/// `Unrecognized` from outside the crate is [`WalletSyncPhase::from`], which is TOTAL: hand it a
1187/// known spelling and it returns that known variant instead. The dishonest value is therefore not
1188/// merely discouraged — it cannot be built.
1189///
1190/// This is deliberately a type-level guard rather than a documented rule. The whole family exists
1191/// because a wire-level mismatch went unnoticed until someone built a probe, and a rule that only a
1192/// doc comment enforces is the same shape of mistake one layer up.
1193///
1194/// # The seal is guarded by a test that can actually see it removed
1195///
1196/// The ordinary unit tests cannot. They reach `Unrecognized` only through
1197/// [`WalletSyncPhase::from`], and the seal is precisely what determines which values that route can
1198/// produce — so making this field `pub` again leaves every one of them green while the forged
1199/// value becomes constructible. Measured: the whole suite passed with the field public.
1200///
1201/// A doctest is the instrument that works, because doctests compile as a SEPARATE CRATE and
1202/// therefore see this type exactly as a consumer does. The one below must FAIL to compile; if the
1203/// field is ever made public it starts compiling, and `cargo test` reports the doctest as failed.
1204///
1205/// ```compile_fail
1206/// use dig_node_control_interface::results::{UnknownPhaseToken, WalletSyncPhase};
1207/// // A value that calls itself unrecognised while spelling itself `synced` on the wire.
1208/// let forged = WalletSyncPhase::Unrecognized(UnknownPhaseToken("synced".to_owned()));
1209/// ```
1210///
1211/// The honest route returns the KNOWN variant instead, which is the whole point:
1212///
1213/// ```
1214/// use dig_node_control_interface::results::WalletSyncPhase;
1215/// assert_eq!(WalletSyncPhase::from("synced"), WalletSyncPhase::Synced);
1216/// assert!(WalletSyncPhase::from("synced").is_recognized());
1217/// ```
1218#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1219pub struct UnknownPhaseToken(String);
1220
1221impl UnknownPhaseToken {
1222    /// The token's RAW bytes, exactly as the node sent them — the relay path.
1223    ///
1224    /// This is the escape hatch, not the default. It exists so a proxy can hand the token on
1225    /// byte-identically, and it is the ONE accessor that returns unescaped node-supplied text. Do
1226    /// not route it to a terminal, a log line, or a UI: use [`Display`](Self#impl-Display) or
1227    /// [`display_bounded`](Self::display_bounded), which escape.
1228    ///
1229    /// ```
1230    /// use dig_node_control_interface::results::WalletSyncPhase;
1231    /// let phase = WalletSyncPhase::from("a_newer_token");
1232    /// assert_eq!(phase.unrecognized_token(), Some("a_newer_token"));
1233    /// ```
1234    pub fn as_str(&self) -> &str {
1235        &self.0
1236    }
1237
1238    /// The token escaped for display and truncated to `max_len` bytes of escaped output.
1239    ///
1240    /// What [`Display`](Self#impl-Display) does, plus a length bound — for a log line or a UI label
1241    /// that must not be handed an unbounded string. Nothing bounds a token's length on the wire (the
1242    /// contract is transport-agnostic, and rejecting an over-long token would reintroduce the
1243    /// fail-closed parse this type exists to remove), so the bound belongs at the point of display.
1244    ///
1245    /// The escaped content is at most `max_len` bytes. A single `…` is appended when anything was
1246    /// dropped, so a truncated rendering is never mistaken for the whole token.
1247    ///
1248    /// ```
1249    /// use dig_node_control_interface::results::WalletSyncPhase;
1250    /// let phase = WalletSyncPhase::from("a_very_long_token_from_a_newer_node");
1251    /// let token = phase.unrecognized_token_value().unwrap();
1252    /// assert_eq!(token.display_bounded(10), "a_very_lon…");
1253    /// ```
1254    pub fn display_bounded(&self, max_len: usize) -> String {
1255        let mut rendered = String::new();
1256        let mut dropped = false;
1257
1258        for character in self.0.chars() {
1259            let escaped: String = character.escape_debug().collect();
1260            if rendered.len() + escaped.len() > max_len {
1261                dropped = true;
1262                break;
1263            }
1264            rendered.push_str(&escaped);
1265        }
1266        if dropped {
1267            rendered.push('…');
1268        }
1269        rendered
1270    }
1271}
1272
1273impl std::fmt::Display for UnknownPhaseToken {
1274    /// The token ESCAPED — the safe default, because this is the accessor a log line reaches for.
1275    ///
1276    /// # Why the default escapes rather than the opposite
1277    ///
1278    /// The raw token is attacker-influenced text that is designed to be logged, and a node emitting
1279    /// `"\u{1b}[2K\rsynced"` turns `format!("unknown phase: {token}")` into a terminal line reading
1280    /// `synced` — the erase-line and carriage-return wipe the prefix that said it was unknown. A
1281    /// right-to-left override does the same to a UI label. Making the ergonomic path raw and the
1282    /// safe path opt-in gets that backwards: every consumer would have to remember, and one
1283    /// forgetting reproduces the exact false-reassurance this family exists to prevent.
1284    ///
1285    /// `char::escape_debug` is the escaper because it is the standard library's own, covering C0/C1
1286    /// controls, `DEL`, and the format characters that carry bidi overrides. A hand-rolled table
1287    /// here would be a second implementation of a security-relevant rule, and would drift.
1288    ///
1289    /// [`as_str`](Self::as_str) remains raw for relaying; [`display_bounded`](Self::display_bounded)
1290    /// adds a length bound.
1291    ///
1292    /// ```
1293    /// use dig_node_control_interface::results::WalletSyncPhase;
1294    /// let phase = WalletSyncPhase::from("\u{1b}[2K\rsynced");
1295    /// let token = phase.unrecognized_token_value().unwrap();
1296    /// assert_eq!(token.to_string(), "\\u{1b}[2K\\rsynced");
1297    /// ```
1298    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1299        for character in self.0.chars() {
1300            write!(f, "{}", character.escape_debug())?;
1301        }
1302        Ok(())
1303    }
1304}
1305
1306impl WalletSyncPhase {
1307    /// Every phase this build KNOWS, in progress order — the enumeration a machine reads, and the
1308    /// anchor the conformance KATs pin the wire tokens against.
1309    ///
1310    /// [`Unrecognized`](Self::Unrecognized) is absent by definition: it is the absence of a known
1311    /// token rather than one of them, and it has no fixed wire spelling to pin. A node MUST NOT emit
1312    /// anything outside this list; a consumer that meets something outside it gets `Unrecognized`
1313    /// instead of a failed response.
1314    pub const ALL: &'static [WalletSyncPhase] = &[
1315        WalletSyncPhase::NotStarted,
1316        WalletSyncPhase::Syncing,
1317        WalletSyncPhase::Synced,
1318        WalletSyncPhase::NoWalletEnrolled,
1319        WalletSyncPhase::WalletNotUnlocked,
1320    ];
1321
1322    /// This phase's exact wire spelling, or the verbatim token for
1323    /// [`Unrecognized`](Self::Unrecognized).
1324    ///
1325    /// The one place a phase becomes a string, so serialization and any display path cannot drift
1326    /// into two different spellings of the same state.
1327    pub fn as_wire(&self) -> &str {
1328        match self {
1329            WalletSyncPhase::NotStarted => "not_started",
1330            WalletSyncPhase::Syncing => "syncing",
1331            WalletSyncPhase::Synced => "synced",
1332            WalletSyncPhase::NoWalletEnrolled => "no_wallet_enrolled",
1333            WalletSyncPhase::WalletNotUnlocked => "wallet_not_unlocked",
1334            WalletSyncPhase::Unrecognized(token) => token.as_str(),
1335        }
1336    }
1337
1338    /// The token a build does not understand, or `None` for every phase it does.
1339    ///
1340    /// Lets a consumer log or surface the exact unrecognised spelling without matching the variant
1341    /// open-coded, which is how the two spellings drift apart.
1342    pub fn unrecognized_token(&self) -> Option<&str> {
1343        match self {
1344            WalletSyncPhase::Unrecognized(token) => Some(token.as_str()),
1345            _ => None,
1346        }
1347    }
1348
1349    /// Whether this build understands the phase at all.
1350    ///
1351    /// The predicate a consumer branches its *"your node may be newer than this app"* path on.
1352    pub fn is_recognized(&self) -> bool {
1353        !matches!(self, WalletSyncPhase::Unrecognized(_))
1354    }
1355
1356    /// The unrecognised token as its own type, giving access to the escaped renderings.
1357    ///
1358    /// [`unrecognized_token`](Self::unrecognized_token) hands back a raw `&str`; this hands back the
1359    /// [`UnknownPhaseToken`], whose `Display` escapes and whose
1360    /// [`display_bounded`](UnknownPhaseToken::display_bounded) also truncates.
1361    pub fn unrecognized_token_value(&self) -> Option<&UnknownPhaseToken> {
1362        match self {
1363            WalletSyncPhase::Unrecognized(token) => Some(token),
1364            _ => None,
1365        }
1366    }
1367
1368    /// Whether a consumer may present this phase as SETTLED — nothing outstanding, nothing to do.
1369    ///
1370    /// # Why this is a method and not a rule in the docs
1371    ///
1372    /// Two phases mean "the sync is idle" and only one of them is good news.
1373    /// [`NoWalletEnrolled`](Self::NoWalletEnrolled) is complete and correct;
1374    /// [`WalletNotUnlocked`](Self::WalletNotUnlocked) is a wallet whose coins nobody is following.
1375    /// Rendering the second as settled is the money-lie this family exists to prevent, and it is one
1376    /// mistaken `||` away in every consumer that writes the rule itself.
1377    ///
1378    /// Stating it once here makes it a compiler-checked fact rather than a paragraph each consumer
1379    /// re-derives — a second implementation of a rule like this is a drift bug waiting to happen.
1380    /// An unrecognised phase is never settled: this build cannot know what the node meant.
1381    ///
1382    /// ```
1383    /// use dig_node_control_interface::results::WalletSyncPhase;
1384    /// assert!(WalletSyncPhase::Synced.may_render_as_settled());
1385    /// assert!(WalletSyncPhase::NoWalletEnrolled.may_render_as_settled());
1386    /// // A wallet exists and nothing is watching it — never settled.
1387    /// assert!(!WalletSyncPhase::WalletNotUnlocked.may_render_as_settled());
1388    /// assert!(!WalletSyncPhase::from("a_newer_token").may_render_as_settled());
1389    /// ```
1390    pub fn may_render_as_settled(&self) -> bool {
1391        // An exhaustive match, not a `matches!`: a phase added later must be classified here
1392        // deliberately, and the compiler is what forces that rather than a reviewer noticing.
1393        match self {
1394            WalletSyncPhase::Synced | WalletSyncPhase::NoWalletEnrolled => true,
1395            WalletSyncPhase::NotStarted
1396            | WalletSyncPhase::Syncing
1397            | WalletSyncPhase::WalletNotUnlocked
1398            | WalletSyncPhase::Unrecognized(_) => false,
1399        }
1400    }
1401}
1402
1403impl From<&str> for WalletSyncPhase {
1404    /// Every token maps to a phase — an unknown one to
1405    /// [`Unrecognized`](WalletSyncPhase::Unrecognized). Total by construction, so no caller can
1406    /// reintroduce the fail-closed behaviour this type exists to remove.
1407    fn from(token: &str) -> Self {
1408        match token {
1409            "not_started" => WalletSyncPhase::NotStarted,
1410            "syncing" => WalletSyncPhase::Syncing,
1411            "synced" => WalletSyncPhase::Synced,
1412            "no_wallet_enrolled" => WalletSyncPhase::NoWalletEnrolled,
1413            "wallet_not_unlocked" => WalletSyncPhase::WalletNotUnlocked,
1414            other => WalletSyncPhase::Unrecognized(UnknownPhaseToken(other.to_owned())),
1415        }
1416    }
1417}
1418
1419impl Serialize for WalletSyncPhase {
1420    /// A bare JSON string, exactly as the derived `rename_all = "snake_case"` produced before this
1421    /// type grew an unrecognised arm — so an [`Unrecognized`](WalletSyncPhase::Unrecognized) token
1422    /// round-trips back out byte-identical rather than being rewritten or dropped by a relay.
1423    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1424        serializer.serialize_str(self.as_wire())
1425    }
1426}
1427
1428impl<'de> Deserialize<'de> for WalletSyncPhase {
1429    /// Accepts ANY string. A non-string is still a type error — a number or an object where a phase
1430    /// belongs is a malformed response, not a newer node.
1431    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1432        let token = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
1433        Ok(WalletSyncPhase::from(token.as_ref()))
1434    }
1435}
1436
1437/// `control.wallet.syncStatus` — is the wallet's chain replica being kept current, how far has it
1438/// got, and how many Chia peers is it using?
1439///
1440/// # `Synced` means CAUGHT UP AND CONNECTED, not ONCE CAUGHT UP
1441///
1442/// [`phase`](Self::phase) is [`WalletSyncPhase::Synced`] only when the initial catch-up completed
1443/// AND at least one Chia peer connection is live right now. A wallet that caught up yesterday and
1444/// has been offline since MUST report [`Syncing`](WalletSyncPhase::Syncing). This makes `phase ==
1445/// Synced` STRICTLY STRONGER than [`WalletPeakResult::synced`], which reflects only the
1446/// completed-catch-up flag: `Synced` implies that flag, the flag does not imply `Synced`. Both types
1447/// say so, because the two notions share a word and nothing but the docs would keep them aligned.
1448///
1449/// This is the whole reason the method exists. A surface asking *does my wallet stay synced?* cannot
1450/// be answered by a flag that a disconnected wallet still sets.
1451///
1452/// **`Synced` is nevertheless not a freshness guarantee.** Being connected is not being up to date:
1453/// a live connection to a stalled or lagging peer satisfies the predicate while the replica goes
1454/// stale. The phase reports that catch-up finished and a peer is attached — that nothing KNOWN is
1455/// preventing the replica from being kept current — and a consumer needing actual freshness must
1456/// compare [`peak_height`](Self::peak_height) against something, not read this phase. Stating the
1457/// limit is the point: this family exists because a surface asserted more than it knew.
1458///
1459/// # The height NEVER comes from a third-party oracle
1460///
1461/// [`peak_height`](Self::peak_height) is the node's OWN replica's height or `null`. It MUST NOT fall
1462/// back to the coinset oracle. `control.wallet.peak` deliberately does fall back, because it answers
1463/// a different question — *what height is the chain at?* — whereas this field answers *how far has
1464/// this replica got?* An oracle's height here would report a caller's own sync progress using a
1465/// number the replica never reached, which is precisely the reading a progress display makes.
1466///
1467/// # `chia_peer_count: 0` is a disambiguator, not a phase
1468///
1469/// A sync that is running while connected to nothing reports `Syncing` with a count of `0`, and a
1470/// consumer SHOULD render the count alongside the phase for exactly that reason: "syncing — no
1471/// peers" is honest where a bare "syncing" implies progress that is not happening. `null` means the
1472/// node cannot observe the count at all and licenses no claim about connectivity either way.
1473///
1474/// # `watched_addresses` is what makes an idle sync readable
1475///
1476/// A sync following nothing is idle, and the phase alone does not say whether that is correct. The
1477/// count is the second fact that settles it: `0` beside [`WalletSyncPhase::NoWalletEnrolled`] is a
1478/// complete and honest picture, while `0` beside [`WalletSyncPhase::WalletNotUnlocked`] is a wallet
1479/// whose coins nobody is following. A consumer SHOULD render the two together for the same reason it
1480/// renders the peer count beside `Syncing`.
1481///
1482/// `Some(0)` is an OBSERVED zero — the node looked and is following no addresses. `None` means the
1483/// node did not report the number, which is not the same claim and MUST NOT be rendered as zero: a
1484/// node that cannot say how many addresses it follows has not told you that it follows none.
1485///
1486/// A `Synced` phase with `watched_addresses: Some(0)` is a contradiction a conforming node MUST NOT
1487/// emit — a sync following no addresses has not caught anything up. A consumer meeting it SHOULD
1488/// trust the count over the phase, because the count is the narrower claim.
1489///
1490/// # An older node's payload still parses
1491///
1492/// A node that predates `watched_addresses` omits the key, and it deserializes to `None` — *the node
1493/// did not report it*. That tolerance is required, not incidental: a mandatory new field would make
1494/// every older node unreadable to a client that has it, which is dig_ecosystem#2609 in mirror image
1495/// — the same fail-closed break with the old and new sides swapped. A contract that tolerates a
1496/// token from the future must equally tolerate a payload from the past.
1497///
1498/// **Every `Option` field here behaves this way**, because serde decodes a missing `Option` to
1499/// `None`. So `peak_height` and `chia_peer_count` are absent-tolerant too, and have been since this
1500/// type shipped. Only [`phase`](Self::phase) is structurally mandatory. A conforming node MUST still
1501/// emit all four keys — absence is a compatibility allowance for older builds, never a licence to
1502/// omit an observation — and a consumer MUST read an absent count as unreported rather than zero.
1503///
1504/// # These are CHIA peers, not DIG peers
1505///
1506/// [`chia_peer_count`](Self::chia_peer_count) counts CHIA FULL-NODE peers the wallet's chain sync is
1507/// connected to. It is NOT the DIG gossip/content peer count from `control.peerStatus`
1508/// (`connected_peers` / `relay_peer_count`); the two are unrelated numbers that move independently.
1509/// A surface that placed one of them beside a wallet sync status under a bare label of "peers" would
1510/// assert something false — a node with many DIG peers and no Chia peer is a wallet that is not
1511/// syncing at all. A caller that wants BOTH networks' counts reads [`PeerCountsResult`], which is
1512/// the one call that answers for each network by name.
1513///
1514/// # The duplicated field is ONE observation
1515///
1516/// [`chia_peer_count`](Self::chia_peer_count) also appears on [`PeerCountsResult`], and the two are
1517/// the SAME observation: a conforming node MUST serve them from one source, and they MUST agree
1518/// within a single node's view. The field is duplicated rather than moved because it is load-bearing
1519/// HERE — `chia_peer_count: 0` beside `Syncing` is the honest "syncing — no peers" state, and a
1520/// phase separated from its count reads as a contradiction. A DIG content-network count, by
1521/// contrast, is not a wallet fact and does not vary with wallet state, which is why it is absent
1522/// from this type rather than added for symmetry.
1523///
1524/// # Which field combinations are meaningful
1525///
1526/// `{phase: Synced, peak_height: null}` MUST NOT be emitted. A node records its peak BEFORE it marks
1527/// the initial catch-up complete, so a completed catch-up always has a height behind it; a `Synced`
1528/// with no height describes a state a conforming node cannot be in, and a consumer has no honest
1529/// reading for it.
1530///
1531/// `{phase: NotStarted, peak_height: <some height>}` is the opposite case, and is EXPLICITLY
1532/// LEGITIMATE — it is not a contradiction and MUST NOT be "fixed". The height is persisted in the
1533/// wallet database, while the phase describes whether a sync is running IN THIS PROCESS. A node that
1534/// synced yesterday and has just restarted reports exactly this, and reports it truthfully: *here is
1535/// the height I reached, and no sync is running right now.* Forbidding the pair would force a
1536/// conforming node to either fabricate a phase it is not in or discard a height it genuinely has —
1537/// which is the dishonesty this method was created to prevent. `peak_height: null` alongside
1538/// `NotStarted` is equally legitimate and means a wallet that has never synced at all.
1539///
1540/// # No confirmation-depth arithmetic happens here
1541///
1542/// The height recorded is the height of the LAST EXISTING block the peer view reported
1543/// (`NewPeakWallet.height` / `RespondPuzzleState.height` from a real full node). This surface
1544/// performs no depth arithmetic. dig_ecosystem#2483 records that `peak_height`'s meaning differs
1545/// between a simulator (the NEXT height) and a full node (the last existing one), so a consumer
1546/// computing depth must floor its own input rather than assume a convention.
1547#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1548pub struct WalletSyncStatusResult {
1549    /// Which state the wallet's chain sync is in. See [`WalletSyncPhase`].
1550    pub phase: WalletSyncPhase,
1551    /// The replica's own peak height, or `null` when it has none — never height 0 as a stand-in for
1552    /// unknown, and never an oracle's height. See the type docs.
1553    pub peak_height: Option<u32>,
1554    /// How many CHIA full-node peers the sync is connected to. `0` is an observed zero; `null` means
1555    /// the node cannot observe the count. Not the DIG peer count — see the type docs.
1556    pub chia_peer_count: Option<u32>,
1557    /// How many addresses the wallet sync is actually following. `Some(0)` is an observed zero;
1558    /// `None` means the node did not report the number at all — including because it predates the
1559    /// field. See the type docs for why that distinction is load-bearing.
1560    pub watched_addresses: Option<u32>,
1561    /// How many peers the REPLICA's own subscription supervisor is writing through. The supervisor
1562    /// holds AT MOST ONE subscription peer by design, so this is a 0-or-1 fact about whether the
1563    /// replica is currently being kept fed — never a measure of network reach. `None` means no
1564    /// supervisor is attached at all, not that it counted zero.
1565    ///
1566    /// This is deliberately NOT [`chia_peer_count`](Self::chia_peer_count) and MUST NOT be summed
1567    /// with it. Before dig_ecosystem#2806 this crate's `chia_peer_count` carried this narrower
1568    /// number instead of the wallet's true peer count, so a node with five peers serving every read
1569    /// reported `chia_peer_count: 1` — the subscription supervisor's single writer standing in for
1570    /// the whole peer set. The two fields exist side by side so that confusion cannot recur: one
1571    /// counts what the replica is fed BY, the other counts what the wallet's sync is actually
1572    /// CONNECTED to.
1573    pub subscription_peer_count: Option<u32>,
1574    /// The peak height this node's OWN Chia peers have ANNOUNCED — not the replica's own progress
1575    /// (see [`peak_height`](Self::peak_height)) and not any oracle's reading. `None` until at least
1576    /// one peer has said something; never `0`, which every real block height is trivially above and
1577    /// so can never be an honest "unobserved" stand-in.
1578    ///
1579    /// A value here is evidence those peers are live and talking, independent of whether the
1580    /// replica itself has caught up to it.
1581    pub chia_peer_peak_height: Option<u32>,
1582}
1583
1584/// `control.wallet.broadcast` — the outcome of pushing an already-signed bundle.
1585///
1586/// # A rejection is a VALUE; an unreachable network is an ERROR
1587///
1588/// A mempool that looked at the bundle and said no is a successful call with `accepted: false` and
1589/// a [`rejection`](Self::rejection) reason — the bundle was seen and judged. Failing to REACH a
1590/// mempool is a catalogued error instead. Collapsing the two turns "your wifi dropped" into "your
1591/// mint failed", and the remedies are opposite: retry the same bundle, versus build a new one.
1592///
1593/// # Accepted is not confirmed
1594///
1595/// `accepted: true` says the mempool took the bundle. It is not evidence that anything reached a
1596/// block, and a caller must never record an outcome from it — only a buried confirmation of the
1597/// created coin is evidence.
1598#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1599pub struct WalletBroadcastResult {
1600    /// Whether the network accepted the bundle into its mempool.
1601    pub accepted: bool,
1602    /// The transaction id (the spend bundle's name), lowercase 64-hex, when accepted.
1603    pub transaction_id: Option<String>,
1604    /// Why the mempool refused, when it refused. `null` on acceptance.
1605    pub rejection: Option<String>,
1606}
1607
1608/// `control.wallet.watch` — the outcome of enrolling public keys.
1609///
1610/// # Two numbers, because idempotence is only observable with both
1611///
1612/// [`added`](Self::added) counts the keys this call newly enrolled; [`watched`](Self::watched) is the
1613/// size of the whole enrolled set afterwards. A re-enrolment of keys the node already follows is a
1614/// SUCCESS that reports `added: 0` with `watched` unchanged — which is how a client tells "already
1615/// done" from "nothing happened because the request was ignored". A single number could not: a
1616/// caller seeing only the total cannot distinguish its own duplicate call from another client's
1617/// concurrent enrolment.
1618#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1619pub struct WalletWatchResult {
1620    /// How many of the submitted keys were NOT already enrolled and are now.
1621    pub added: u32,
1622    /// How many keys the node follows in total after this call.
1623    pub watched: u32,
1624}
1625
1626/// `control.wallet.unwatch` — the outcome of deregistering public keys.
1627///
1628/// [`removed`](Self::removed) counts the submitted keys that were actually enrolled; a key that was
1629/// never enrolled is not an error, for the same reason a re-enrolment is not one — a client
1630/// reconciling its own state must be able to say "make sure these are gone" without first asking.
1631#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1632pub struct WalletUnwatchResult {
1633    /// How many of the submitted keys were enrolled and are no longer.
1634    pub removed: u32,
1635    /// How many keys the node follows in total after this call.
1636    pub watched: u32,
1637}
1638
1639/// `control.wallet.watched` — the public keys the node currently follows.
1640///
1641/// # No count field
1642///
1643/// The list is the answer, and its length is the count. A separate number could disagree with the
1644/// list it is printed beside, and a client that trusted the number over the rows would reconcile
1645/// against a set that was never sent.
1646#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1647pub struct WalletWatchedResult {
1648    /// The enrolled public keys, lowercase 96-hex and unprefixed — the same wire form
1649    /// [`WalletWatchParams`](crate::params::WalletWatchParams) accepts, so a client can compare what
1650    /// it sent against what came back without normalizing either side.
1651    pub public_keys: Vec<String>,
1652}
1653
1654/// One coin held by a live reservation, and when that hold lapses.
1655///
1656/// The expiry travels WITH the coin rather than being summarised once, because a client's honest
1657/// sentence is per-coin: "this coin is committed until 14:32". A single soonest-expiry figure would
1658/// be right about the set and wrong about every coin in it but one.
1659#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1660pub struct ReservedCoin {
1661    /// The held coin id, lowercase 64-hex and unprefixed.
1662    pub coin_id: String,
1663    /// The reservation holding it — the handle
1664    /// [`release`](crate::params::WalletReservationsReleaseParams) takes. OPAQUE; never parsed.
1665    pub reservation_id: String,
1666    /// Unix seconds after which this hold no longer applies, whether or not anyone releases it.
1667    ///
1668    /// Always present. A hold with no expiry is a permanent funds lockout, so the contract has no
1669    /// way to express one.
1670    pub expires_at_unix: u64,
1671}
1672
1673/// `control.wallet.reservations.held` — every coin currently committed to an in-flight spend.
1674///
1675/// # An empty list means EMPTY, and an error means UNKNOWN
1676///
1677/// `reserved: []` is a positive statement that nothing is held, and a caller may select freely on
1678/// it. A node that cannot read its reservation set answers
1679/// [`WalletReservationsUnavailable`](crate::error::ControlErrorCode::WalletReservationsUnavailable)
1680/// and NEVER an empty list — the two demand opposite actions, and collapsing them restores exactly
1681/// the cross-process double-select this method exists to prevent.
1682///
1683/// # This narrows SELECTION, never BALANCE
1684///
1685/// A reserved coin is still the user's money and still counts toward what they hold. Subtracting
1686/// these from a balance would report a shortfall the user does not have.
1687///
1688/// # No count field
1689///
1690/// The list is the answer and its length is the count. A separate number could disagree with the
1691/// rows printed beside it.
1692#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1693pub struct WalletReservationsHeldResult {
1694    /// The held coins, each with its holding reservation and expiry.
1695    pub reserved: Vec<ReservedCoin>,
1696    /// The node's OWN clock, in unix seconds, at the moment it answered.
1697    ///
1698    /// Reported so a client can measure skew against the `expires_at_unix` values it just received.
1699    /// The caller never supplies a time — see
1700    /// [`WalletReservationsHeldParams`](crate::params::WalletReservationsHeldParams).
1701    pub as_of_unix: u64,
1702}
1703
1704/// `control.wallet.reservations.reserve` — the handle for a hold that was taken in full.
1705///
1706/// Only ever returned when EVERY requested coin was taken. A conflict on any one of them is the
1707/// error [`WalletCoinsReserved`](crate::error::ControlErrorCode::WalletCoinsReserved) and reserves
1708/// nothing, so this type has deliberately no "partially reserved" shape to represent.
1709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1710pub struct WalletReservationsReserveResult {
1711    /// The handle to release with. OPAQUE — store it and send it back; never parse or derive one.
1712    pub reservation_id: String,
1713    /// The coins now held, lowercase 64-hex, echoed back so a client can compare what it asked for
1714    /// against what it got without re-normalizing either side.
1715    pub coin_ids: Vec<String>,
1716    /// Unix seconds after which this hold lapses on its own.
1717    pub expires_at_unix: u64,
1718    /// The lifetime the node ACTUALLY applied, in seconds — which may be shorter than the
1719    /// `ttl_secs` requested.
1720    ///
1721    /// Returned rather than assumed, because a caller that asked for an hour and silently got ten
1722    /// minutes would release far too late and believe its coins were still held long after they
1723    /// were selectable again.
1724    pub ttl_secs: u64,
1725}
1726
1727/// `control.wallet.reservations.release` — what a release actually freed.
1728///
1729/// # `released: false` is a SUCCESS
1730///
1731/// It means the handle named no live reservation: it lapsed on its TTL first, or was released
1732/// already. Both are the outcome the caller wanted, and reporting them as errors would push callers
1733/// toward ignoring the result — which is how the release path quietly stops being used and every
1734/// hold starts costing its full TTL.
1735#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1736pub struct WalletReservationsReleaseResult {
1737    /// Whether a live reservation was found and freed by THIS call.
1738    pub released: bool,
1739    /// The coins freed by this call — empty when `released` is false.
1740    pub coin_ids: Vec<String>,
1741}
1742
1743/// `pairing.request` — the pairing handshake bootstrap (OPEN, no token).
1744#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1745pub struct PairingRequestResult {
1746    /// The opaque pairing id to poll with.
1747    pub pairing_id: String,
1748    /// A short numeric code the operator compares before approving.
1749    pub pairing_code: String,
1750    /// When the pending pairing expires, in unix milliseconds.
1751    pub expires_ms: u64,
1752}
1753
1754/// `pairing.poll` — the pairing poll outcome (OPEN, no token).
1755#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1756pub struct PairingPollResult {
1757    /// The pairing status (`"pending"` / `"approved"` / …).
1758    pub status: String,
1759    /// The minted scoped token, present exactly once after approval.
1760    #[serde(skip_serializing_if = "Option::is_none", default)]
1761    pub token: Option<String>,
1762}
1763
1764/// One confirmed incoming payment, as the node's arrival ledger recorded it.
1765///
1766/// Every field is a public chain fact about an address this node already watches. There is
1767/// deliberately no ticker and no formatted amount: naming an asset the node did not attribute, or
1768/// choosing a divisor for it, would be a claim about WHICH money arrived that the node cannot
1769/// support.
1770#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1771pub struct WalletArrivalRecord {
1772    /// This arrival's monotonic ledger position. Strictly increasing and never reused, so a stored
1773    /// position cannot come to mean a different arrival after a reorg.
1774    pub seq: u64,
1775    /// The coin that arrived (lowercase hex).
1776    pub coin_id: String,
1777    /// The watched puzzle hash it arrived at (lowercase hex).
1778    pub puzzle_hash: String,
1779    /// The amount in the asset's own base unit, as a DECIMAL STRING.
1780    ///
1781    /// A string because the ledger stores the full `u64` range and a JSON number does not carry it
1782    /// losslessly — a large mojo amount silently rounds through an f64 parser, which is a wrong
1783    /// figure about somebody's money.
1784    pub amount: String,
1785    /// The CAT asset id (hex TAIL), or `None` for native XCH.
1786    pub asset_id: Option<String>,
1787    /// The height the coin was CONFIRMED at. Never optional: an arrival with no confirmed height is
1788    /// not an arrival, and a node MUST NOT emit a mempool sighting here.
1789    pub confirmed_height: u32,
1790}
1791
1792/// One page of the arrival ledger (`control.wallet.arrivals`).
1793///
1794/// An empty [`arrivals`](Self::arrivals) list is an ANSWER — the node consulted its own replica and
1795/// nothing has arrived since the cursor. It is NOT a claim that the replica is current: a node that
1796/// has never completed a catch-up has no arrival baseline and reports an empty page forever, which
1797/// is the honest answer to "what arrived?" from a wallet that cannot tell history from news. A
1798/// caller that needs to know whether the replica is current asks `control.wallet.syncStatus`.
1799#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1800pub struct WalletArrivalsResult {
1801    /// The page, oldest first.
1802    pub arrivals: Vec<WalletArrivalRecord>,
1803    /// Where the CLIENT got to: the position of the last row in this page, or the caller's own
1804    /// `after_seq` when the page is empty. **This is the value to resume from.**
1805    pub cursor: u64,
1806    /// Where the LEDGER got to when this answer was assembled.
1807    ///
1808    /// Read AFTER the page, so an arrival recorded in between sits above the page and below this
1809    /// value — which is exactly why resuming from it would step straight over that arrival and lose
1810    /// a notification silently. It exists for ONE question [`cursor`](Self::cursor) cannot answer: a
1811    /// first-run client passes it back as `after_seq` to start from NOW instead of replaying the
1812    /// whole ledger as a burst of toasts.
1813    pub latest: u64,
1814}
1815
1816/// `control.profile.putBody` — the acknowledgement that the node accepted and persisted a body.
1817///
1818/// Reaching this result at all means the node RESOLVED the root on chain and found it confirmed and
1819/// matching the supplied bytes. A refusal is an error, never a success carrying `stored: false` —
1820/// a caller that has to inspect a boolean to learn whether its profile published is a caller that
1821/// will forget to.
1822#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1823pub struct ProfilePutBodyResult {
1824    /// Always `true`: the body is persisted and this node will serve it to peers.
1825    pub stored: bool,
1826    /// The canonical store id the body was filed under (trimmed + lower-cased).
1827    pub store_id: String,
1828    /// The CONFIRMED chain root the node verified the body against — echoed so a caller can pin
1829    /// which root its bytes now stand behind.
1830    pub root: String,
1831    /// The DECODED body length in bytes, never above
1832    /// [`MAX_BODY_BYTES`](crate::params::MAX_BODY_BYTES).
1833    pub body_bytes: u64,
1834}
1835
1836/// `control.profile.getBody` — the body this node holds at a store id + root, if it holds one.
1837///
1838/// `body_b64: None` MUST mean "this node was consulted and holds no body at that root". It NEVER
1839/// means the body could not be read: a read that failed MUST return a catalogued error instead. A
1840/// caller that cannot tell those apart shows an empty profile for a profile that exists.
1841#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1842pub struct ProfileGetBodyResult {
1843    /// The canonical store id the read was scoped to.
1844    pub store_id: String,
1845    /// The root the read was scoped to — the SAME root the caller asked for, so a body for another
1846    /// root can never arrive here unnoticed.
1847    pub root: String,
1848    /// The body, standard base64 (padded) of its `DPB` serialization; `None` when this node holds
1849    /// no body at that root.
1850    pub body_b64: Option<String>,
1851    /// The DECODED body length in bytes; `0` when no body is held.
1852    pub body_bytes: u64,
1853}
1854
1855/// Which asset an automated spend moved.
1856///
1857/// Externally tagged on `asset` so a CAT carries its asset id in the same object rather than in a
1858/// sibling field that could go missing: `{"asset":"xch"}`, `{"asset":"dig"}`,
1859/// `{"asset":"cat","asset_id":"…"}`. An amount is never readable without its asset, so the two
1860/// travel together.
1861#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1862#[serde(tag = "asset", rename_all = "snake_case")]
1863pub enum SpendAsset {
1864    /// Chia itself.
1865    Xch,
1866    /// The $DIG CAT.
1867    Dig,
1868    /// Any other CAT, identified by its asset id.
1869    Cat {
1870        /// The CAT's asset id, lowercase 64-hex.
1871        asset_id: String,
1872    },
1873}
1874
1875/// ON WHOSE AUTHORITY the node signed without asking.
1876///
1877/// Two fields rather than one sentence, because a person auditing an unapproved spend asks two
1878/// separate questions: WHO holds the standing permission, and WHICH standing permission was used. A
1879/// prose sentence answers neither in a form a filter — or a revocation — can act on.
1880#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1881pub struct SpendAuthority {
1882    /// The principal whose funds moved and whose consent was relied on: an account id, a profile id,
1883    /// or `"node"` for the node's own operating wallet.
1884    pub principal: String,
1885    /// The standing grant relied on, in a form the operator can go and revoke — a setting name, a
1886    /// policy id, a pairing token id.
1887    pub grant: String,
1888}
1889
1890/// Where an attempt died.
1891///
1892/// Coarse and stable on purpose: the point is which STEP failed, because that is what tells a person
1893/// whether their money is at risk. **This distinction is load-bearing and MUST NOT be flattened into
1894/// a bare "failed".** A client that collapses it is structurally unable to tell someone the truth
1895/// about their own money.
1896#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1897#[serde(rename_all = "snake_case")]
1898pub enum SpendFailureStage {
1899    /// The spend could not be built or signed. No signed bundle ever existed, so nothing could reach
1900    /// a mempool and nothing moved.
1901    Signing,
1902    /// A signed bundle was rejected by the mempool, **as far as this node saw**. The bundle may
1903    /// still have reached the network by another route, or been accepted after the rejection this
1904    /// node observed.
1905    Broadcast,
1906    /// The bundle went out and the chain then reported it could not succeed.
1907    Confirmation,
1908}
1909
1910impl SpendFailureStage {
1911    /// Could the money have moved anyway, despite the attempt failing at this stage?
1912    ///
1913    /// [`Signing`](Self::Signing) is the only stage that answers NO, and it answers structurally: no
1914    /// signed bundle existed, so there was nothing that could reach a mempool.
1915    /// [`Broadcast`](Self::Broadcast) and [`Confirmation`](Self::Confirmation) both happen AFTER a
1916    /// valid signed bundle exists, and neither observation proves absence — a rejection this node
1917    /// saw does not bind a network it does not fully observe.
1918    ///
1919    /// This is the ONE place the distinction is decided. Every consumer asks the stage rather than
1920    /// re-listing the variants, so the "it did not happen" claim cannot be re-attached to a stage
1921    /// that never earned it. Written as an exhaustive `match` so adding a stage is a compile error
1922    /// here, forcing whoever adds it to choose a side.
1923    pub fn money_may_have_moved(self) -> bool {
1924        match self {
1925            SpendFailureStage::Signing => false,
1926            SpendFailureStage::Broadcast | SpendFailureStage::Confirmation => true,
1927        }
1928    }
1929
1930    /// The stable lowercase wire token.
1931    pub const fn token(self) -> &'static str {
1932        match self {
1933            SpendFailureStage::Signing => "signing",
1934            SpendFailureStage::Broadcast => "broadcast",
1935            SpendFailureStage::Confirmation => "confirmation",
1936        }
1937    }
1938}
1939
1940/// Where one automated spend got to.
1941///
1942/// Internally tagged on `state`, so a row is `{"state":"confirmed","height":…,"coin_id":"…"}`.
1943///
1944/// # Two shape rules, each from a measured money-lie
1945///
1946/// 1. **[`Confirmed`](Self::Confirmed) carries its evidence inside the variant.** There is no
1947///    optional height field to fill in optimistically, so a row cannot hold a confirmation height
1948///    without a confirmation.
1949/// 2. **[`Unresolved`](Self::Unresolved) is NOT a kind of failure.** "The node signed and does not
1950///    know how it ended" is not "it did not happen": money may well have moved, and saying `failed`
1951///    about a spend that landed is the same class of lie as claiming an unconfirmed success. A
1952///    client that maps it onto `failed` to keep a two-state UI has chosen the wrong UI.
1953#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1954#[serde(tag = "state", rename_all = "snake_case")]
1955pub enum SpendOutcome {
1956    /// Recorded, not yet handed to the network. Written before the producer may sign.
1957    Pending,
1958    /// A signed bundle was accepted by the mempool. NOT a claim that it will confirm.
1959    Submitted,
1960    /// The chain shows the coin this spend created.
1961    Confirmed {
1962        /// The height the created coin was confirmed at.
1963        height: u32,
1964        /// The coin the spend CREATED — the reference a person can paste into an explorer.
1965        coin_id: String,
1966    },
1967    /// The attempt ended in a failure this node observed.
1968    ///
1969    /// **This is not uniformly a claim that the money stayed put.** Only
1970    /// [`SpendFailureStage::Signing`] carries that claim; at `Broadcast` and `Confirmation` a signed
1971    /// bundle already existed and the outcome is genuinely UNKNOWN. Ask
1972    /// [`SpendFailureStage::money_may_have_moved`] before rendering any `failed` row as settled.
1973    Failed {
1974        /// Which step failed — and, through [`SpendFailureStage::money_may_have_moved`], whether
1975        /// this row claims the money is untouched or merely records where the attempt died.
1976        stage: SpendFailureStage,
1977        /// One line a person can act on. "Insufficient funds" is the difference between a broken
1978        /// node and a wallet that needs topping up.
1979        reason: String,
1980    },
1981    /// The node signed and does not know how it ended — a timeout, a restart mid-flight, or a
1982    /// producer that dropped the spend.
1983    Unresolved {
1984        /// Why the outcome is unknown.
1985        reason: String,
1986    },
1987}
1988
1989impl SpendOutcome {
1990    /// The stable lowercase token, matching the `state` tag and the
1991    /// [`status`](crate::params::SpendsListParams::status) filter.
1992    pub const fn token(&self) -> &'static str {
1993        match self {
1994            SpendOutcome::Pending => "pending",
1995            SpendOutcome::Submitted => "submitted",
1996            SpendOutcome::Confirmed { .. } => "confirmed",
1997            SpendOutcome::Failed { .. } => "failed",
1998            SpendOutcome::Unresolved { .. } => "unresolved",
1999        }
2000    }
2001
2002    /// Is what happened to the money still UNKNOWN?
2003    ///
2004    /// True for [`Unresolved`](Self::Unresolved), and true for a [`Failed`](Self::Failed) row whose
2005    /// stage [may have moved money](SpendFailureStage::money_may_have_moved). Those two are the rows
2006    /// a person still has to chase, and a UI grouping them with settled failures hides exactly the
2007    /// spends worth looking at.
2008    ///
2009    /// `Pending` and `Submitted` are NOT unknown outcomes — they are outcomes that have not happened
2010    /// yet, and the node expects to learn them. Conflating "in flight" with "lost track of" would
2011    /// raise an alarm about every spend in progress.
2012    pub fn outcome_is_unknown(&self) -> bool {
2013        match self {
2014            SpendOutcome::Unresolved { .. } => true,
2015            SpendOutcome::Failed { stage, .. } => stage.money_may_have_moved(),
2016            SpendOutcome::Pending | SpendOutcome::Submitted | SpendOutcome::Confirmed { .. } => {
2017                false
2018            }
2019        }
2020    }
2021}
2022
2023/// A chain reference, paired with whether this node actually OBSERVED it.
2024///
2025/// The [`confirmed`](Self::confirmed) flag is not decoration. Before confirmation the node knows the
2026/// coin id it INTENDS to create, and rendering that bare id beside a confirmed one presents an
2027/// intention as a fact. The two travel together so a client can render "expected" differently from
2028/// "on chain" without re-deriving the distinction — which is the derivation it would get wrong.
2029#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2030pub struct SpendChainReference {
2031    /// The coin id to look up.
2032    pub coin_id: String,
2033    /// `true` when this node observed the coin on chain; `false` when it is only the intended result.
2034    pub confirmed: bool,
2035}
2036
2037/// One spend this node made WITHOUT per-transaction approval.
2038///
2039/// # Amounts are decimal STRINGS
2040///
2041/// `amount_mojos` and `fee_mojos` carry the full `u64` range, which a JSON number does not survive
2042/// through an f64 parser — and a silently rounded figure about somebody's money is exactly the lie
2043/// this record exists to prevent. Every money field in this crate is a string for that reason.
2044#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2045pub struct AutomatedSpend {
2046    /// The audit id — stable for the life of the spend, and the value
2047    /// [`after_id`](crate::params::SpendsListParams::after_id) resumes from.
2048    pub id: String,
2049    /// The revision of the record this row reflects. The audit trail is append-only and each entry
2050    /// is a snapshot; this row is the highest revision the node holds for this spend.
2051    pub revision: u32,
2052    /// What the spend was for, as the producer's stable token (`"mirror-coin"`, …).
2053    pub kind: String,
2054    /// One human sentence: why this happened without asking.
2055    pub purpose: String,
2056    /// Whose standing consent was relied on, and which grant.
2057    pub authority: SpendAuthority,
2058    /// Which asset moved.
2059    pub asset: SpendAsset,
2060    /// How much, in the asset's base units, as a decimal string.
2061    pub amount_mojos: String,
2062    /// The network fee in mojos of XCH, as a decimal string.
2063    pub fee_mojos: String,
2064    /// The store this spend serves, when it serves one.
2065    pub store_id: Option<String>,
2066    /// When the node decided to spend, unix ms. The field the ordering and the time filters use.
2067    pub initiated_ms: u64,
2068    /// When this revision was written, unix ms.
2069    pub updated_ms: u64,
2070    /// Where the spend got to.
2071    pub status: SpendOutcome,
2072    /// The coins this spend CONSUMED, once known.
2073    ///
2074    /// Never the confirmation evidence. The legacy implementation waited for a funding coin to be
2075    /// spent and called that confirmation, which a competing spend of the same coin satisfies
2076    /// identically while the intended coin never exists — so a client MUST NOT infer success from
2077    /// anything here. [`chain_reference`](Self::chain_reference) is the only reference that carries
2078    /// an observed/expected flag.
2079    pub funding_coin_ids: Vec<String>,
2080    /// The chain reference to show, or `null` when the node knows no coin id yet — which is honest:
2081    /// there is nothing to look up.
2082    ///
2083    /// The key MUST be present. `null` is meaningful, so an ABSENT key must not decode into it: a
2084    /// truncated or mis-routed payload would otherwise decode as a confident "there is nothing to
2085    /// look up".
2086    #[serde(deserialize_with = "required_option")]
2087    pub chain_reference: Option<SpendChainReference>,
2088}
2089
2090/// `control.spends.list` — one page of the automated-spend audit record.
2091///
2092/// # Why this method is the only sanctioned reader
2093///
2094/// The record is a node-private file (dig-node SPEC §23). Every other view — dig-app's Activity tab
2095/// included — reads it THROUGH the node, and this is that route. A second process parsing the file
2096/// would be a second implementation of a growing append-only format, which is how two views of "what
2097/// did the node spend" start disagreeing, on the one subject where disagreeing is least affordable.
2098///
2099/// # A page, and it says so
2100///
2101/// [`spends`](Self::spends) is bounded by
2102/// [`SPENDS_LIST_MAX_LIMIT`](crate::params::SPENDS_LIST_MAX_LIMIT). Whether it is the whole matching
2103/// set is stated by [`complete`](Self::complete) and never left to be inferred from the page's
2104/// length: a node may return a short page for its own reasons, and a matching set that is an exact
2105/// multiple of the page size makes the last full page indistinguishable from a truncated one.
2106/// Without an explicit flag a caller cannot tell "there are no more spends" from "we stopped telling
2107/// you" — and on an audit record those read the same and mean opposite things.
2108///
2109/// # The order is part of the contract
2110///
2111/// A node MUST return rows by DESCENDING [`initiated_ms`](AutomatedSpend::initiated_ms), breaking
2112/// ties by ASCENDING [`id`](AutomatedSpend::id), and MUST keep that order stable across the pages of
2113/// one walk. [`after_id`](crate::params::SpendsListParams::after_id) means *strictly after this row
2114/// in that order*. The tiebreak is required rather than incidental: automated spends are issued by a
2115/// cycle and several can share a millisecond, so a time-only order names no position and a walk
2116/// would repeat some rows and skip others.
2117///
2118/// # An empty page is an ANSWER, never a fallback
2119///
2120/// `spends: []` with `complete: true` means this node has moved no money unattended that matches the
2121/// filters. It is NEVER what a caller gets when the record could not be read: that is
2122/// [`SpendAuditUnreadable`](crate::error::ControlErrorCode::SpendAuditUnreadable). "Nothing to
2123/// report" and "I could not look" are different answers, and the first is the one a person stops
2124/// investigating on.
2125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2126pub struct SpendsListResult {
2127    /// One page of matching spends, newest-initiated first, possibly empty.
2128    pub spends: Vec<AutomatedSpend>,
2129    /// Is this page the WHOLE matching set?
2130    ///
2131    /// `true` means every matching spend the node holds is in [`spends`](Self::spends). `false`
2132    /// means the answer was TRUNCATED and more exist — resume from [`cursor`](Self::cursor).
2133    ///
2134    /// Required on the wire, and stated positively so the reading a caller falls into when the field
2135    /// is absent or defaulted is the SAFE one. A boolean spelled `truncated` would default to
2136    /// `false`, i.e. to "this is everything", which is the claim that ends a walk early; `complete`
2137    /// defaults to "there may be more", which costs at worst one redundant request.
2138    pub complete: bool,
2139    /// The id of the last row in this page — **the value to resume from** — or `null` for an empty
2140    /// page.
2141    ///
2142    /// It is the id the caller was HANDED, never a marker for where the record "got to". Pass it as
2143    /// [`after_id`](crate::params::SpendsListParams::after_id).
2144    ///
2145    /// The key MUST be present; `null` is meaningful and an absent key must not decode into it.
2146    #[serde(deserialize_with = "required_option")]
2147    pub cursor: Option<String>,
2148    /// How many entries in the record the node could NOT parse.
2149    ///
2150    /// Part of the answer rather than a log line, and a client MUST surface a non-zero value. An
2151    /// audit trail that lost entries to corruption and reads as a shorter, tidy list is
2152    /// indistinguishable from one where those spends never happened — which is the same lie as a
2153    /// missing entry, told more convincingly.
2154    ///
2155    /// It counts unreadable entries across the WHOLE record, not just this page: a corrupt entry has
2156    /// no parsed timestamp and no parsed id, so it cannot be attributed to a page or excluded by a
2157    /// filter. A caller therefore MUST NOT read it as "this many rows are missing from this page".
2158    pub unreadable_lines: u32,
2159}
2160
2161#[cfg(test)]
2162mod tests {
2163    use super::*;
2164    use serde_json::json;
2165
2166    #[test]
2167    fn status_result_round_trips_the_node_shape() {
2168        let v = json!({
2169            "running": true, "service": "dig-node", "version": "0.30.0", "commit": "abc",
2170            "protocol": "21", "uptime_secs": 5, "addr": "127.0.0.1:9256", "upstream": "https://rpc.dig.net",
2171            "cache": {"cap_bytes": 1024, "used_bytes": 10, "dir": "/c", "shared": false},
2172            "hosted_store_count": 2, "cached_capsule_count": 3, "pinned_store_count": 1,
2173            "sync": {"available": true}
2174        });
2175        let parsed: StatusResult = serde_json::from_value(v.clone()).unwrap();
2176        assert_eq!(serde_json::to_value(&parsed).unwrap(), v);
2177    }
2178
2179    #[test]
2180    fn config_result_keeps_upstream_override_null_when_unset() {
2181        let parsed = ConfigResult {
2182            addr: "127.0.0.1:9256".into(),
2183            port: "9256".into(),
2184            upstream: "https://rpc.dig.net".into(),
2185            upstream_override: None,
2186            cache_dir: "/c".into(),
2187            cache_shared: false,
2188            config_path: "/c/config.json".into(),
2189            sync_available: true,
2190        };
2191        let v = serde_json::to_value(&parsed).unwrap();
2192        assert_eq!(v["upstream_override"], json!(null));
2193        assert!(v.as_object().unwrap().contains_key("upstream_override"));
2194    }
2195
2196    #[test]
2197    fn pairing_poll_omits_token_until_approved() {
2198        let pending = PairingPollResult {
2199            status: "pending".into(),
2200            token: None,
2201        };
2202        let v = serde_json::to_value(&pending).unwrap();
2203        assert_eq!(v, json!({"status": "pending"}));
2204        let approved = PairingPollResult {
2205            status: "approved".into(),
2206            token: Some("deadbeef".into()),
2207        };
2208        assert_eq!(
2209            serde_json::to_value(&approved).unwrap(),
2210            json!({"status": "approved", "token": "deadbeef"})
2211        );
2212    }
2213
2214    // ---- PeerSoftware (dig_ecosystem#2215) ----
2215
2216    /// The mapping that matters most. Every peer built before #2215 advertises the LITERAL
2217    /// `"0.0.0"` — three of dig-gossip's four handshake send sites hardcoded it. A parser that
2218    /// maps only `""` to Unknown would therefore read the entire live fleet as "software version
2219    /// 0.0.0", which any later `>=` comparison treats as ancient. `""`, `"0.0.0"`, and anything
2220    /// unparseable must all be Unknown, and this test is that mapping's guard.
2221    #[test]
2222    fn unknown_covers_empty_the_legacy_sentinel_and_garbage() {
2223        for raw in [
2224            "",                       // a peer advertising nothing, or `off` coarsening
2225            "0.0.0",                  // the pre-#2215 legacy sentinel
2226            "   ",                    // whitespace only
2227            "dig-node",               // no version part
2228            "dig-node/",              // empty version part
2229            "dig-node/not-a-version", // unparseable version
2230            "/1.2.3",                 // empty product part
2231            "1.2.3",                  // bare version, no product
2232            "dig-node/0.0.0",         // the sentinel, however it is dressed up
2233        ] {
2234            assert_eq!(
2235                PeerSoftware::parse(raw),
2236                PeerSoftware::Unknown,
2237                "{raw:?} must map to Unknown"
2238            );
2239        }
2240    }
2241
2242    /// A well-formed `product/semver` advertisement is reported with all three parts, and `raw`
2243    /// preserves exactly what the peer sent so a diagnostic reader is never shown a value the peer
2244    /// did not actually advertise.
2245    #[test]
2246    fn reported_carries_product_version_and_the_raw_advertisement() {
2247        let parsed = PeerSoftware::parse("dig-node/0.99.1");
2248        let PeerSoftware::Reported {
2249            product,
2250            version,
2251            raw,
2252        } = parsed
2253        else {
2254            panic!("a well-formed advertisement must be Reported");
2255        };
2256        assert_eq!(product, "dig-node");
2257        assert_eq!(version, semver::Version::new(0, 99, 1));
2258        assert_eq!(raw, "dig-node/0.99.1");
2259    }
2260
2261    /// A product name may itself contain a `/`; only the LAST separator splits product from
2262    /// version. Pinning this stops a future reader from switching to a first-separator split,
2263    /// which would silently reclassify such a peer as Unknown.
2264    #[test]
2265    fn product_is_split_at_the_last_separator() {
2266        let PeerSoftware::Reported {
2267            product, version, ..
2268        } = PeerSoftware::parse("acme/dig-node/1.2.3")
2269        else {
2270            panic!("expected Reported");
2271        };
2272        assert_eq!(product, "acme/dig-node");
2273        assert_eq!(version, semver::Version::new(1, 2, 3));
2274    }
2275
2276    /// Surrounding whitespace is trimmed before parsing, and `raw` records the TRIMMED
2277    /// advertisement. CON-008 sanitization strips Unicode Cc/Cf from the wire value but not
2278    /// spaces, so a padded advertisement reaches this parser intact and must not be classified as
2279    /// unparseable merely for having been padded.
2280    #[test]
2281    fn surrounding_whitespace_is_trimmed_before_parsing() {
2282        let PeerSoftware::Reported {
2283            product,
2284            version,
2285            raw,
2286        } = PeerSoftware::parse("  dig-node/1.2.3	")
2287        else {
2288            panic!("a padded advertisement must still be Reported");
2289        };
2290        assert_eq!(product, "dig-node");
2291        assert_eq!(version, semver::Version::new(1, 2, 3));
2292        assert_eq!(raw, "dig-node/1.2.3", "raw must record the trimmed value");
2293    }
2294
2295    /// A pre-release/build-metadata semver survives intact, because that is what a nightly build
2296    /// advertises and dropping it would make every nightly indistinguishable from its release.
2297    #[test]
2298    fn prerelease_versions_are_preserved() {
2299        let PeerSoftware::Reported { version, raw, .. } =
2300            PeerSoftware::parse("dig-node/1.0.0-nightly.20260805")
2301        else {
2302            panic!("expected Reported");
2303        };
2304        assert_eq!(version.to_string(), "1.0.0-nightly.20260805");
2305        assert_eq!(raw, "dig-node/1.0.0-nightly.20260805");
2306    }
2307
2308    /// Unknown's JSON is a tagged object — never `"0.0.0"`, never `""`, never a null sitting in a
2309    /// version field where a consumer might read it as a number.
2310    #[test]
2311    fn unknown_serializes_as_a_tagged_object_with_no_version_field() {
2312        let v = serde_json::to_value(PeerSoftware::Unknown).unwrap();
2313        assert_eq!(v, json!({"kind": "unknown"}));
2314        assert!(
2315            v.get("version").is_none(),
2316            "Unknown must not carry a version field at all"
2317        );
2318    }
2319
2320    /// Both variants round-trip byte-identically, which is what lets a client re-encode a node's
2321    /// response unchanged.
2322    #[test]
2323    fn both_variants_round_trip_byte_identically() {
2324        for wire in [
2325            json!({"kind": "unknown"}),
2326            json!({
2327                "kind": "reported",
2328                "product": "dig-node",
2329                "version": "0.99.1",
2330                "raw": "dig-node/0.99.1"
2331            }),
2332        ] {
2333            let parsed: PeerSoftware = serde_json::from_value(wire.clone()).unwrap();
2334            assert_eq!(serde_json::to_value(&parsed).unwrap(), wire);
2335        }
2336    }
2337
2338    /// Parsing a wire string and serializing the result produces the documented JSON, so the two
2339    /// halves of the contract cannot drift from each other.
2340    #[test]
2341    fn parse_then_serialize_matches_the_documented_json() {
2342        assert_eq!(
2343            serde_json::to_value(PeerSoftware::parse("dig-node/0.99.1")).unwrap(),
2344            json!({
2345                "kind": "reported",
2346                "product": "dig-node",
2347                "version": "0.99.1",
2348                "raw": "dig-node/0.99.1"
2349            })
2350        );
2351        assert_eq!(
2352            serde_json::to_value(PeerSoftware::parse("0.0.0")).unwrap(),
2353            json!({"kind": "unknown"})
2354        );
2355    }
2356
2357    // ---- Trait-absence probes (dig_ecosystem#2215) ----
2358    //
2359    // `PeerSoftware` deriving `Ord` or `Default` would be a silent correctness regression rather
2360    // than a compile error anywhere, so it is pinned here. The probe exploits inherent-impl
2361    // precedence: `Probe::<T>::has_it()` resolves to the inherent impl (returning `true`) only when
2362    // `T` satisfies the bound, and otherwise falls back to the blanket trait impl (`false`).
2363    //
2364    // Each probe carries a CONTROL on a type that DOES implement the trait. Without the control, a
2365    // probe broken so that it always answers `false` would pass while proving nothing.
2366
2367    struct Probe<T>(core::marker::PhantomData<T>);
2368
2369    trait ProbeFallback {
2370        fn is_ord() -> bool {
2371            false
2372        }
2373    }
2374    impl<T> ProbeFallback for Probe<T> {}
2375
2376    impl<T: Ord> Probe<T> {
2377        fn is_ord() -> bool {
2378            true
2379        }
2380    }
2381
2382    struct PartialOrdProbe<T>(core::marker::PhantomData<T>);
2383    trait PartialOrdFallback {
2384        fn is_partial_ord() -> bool {
2385            false
2386        }
2387    }
2388    impl<T> PartialOrdFallback for PartialOrdProbe<T> {}
2389    impl<T: PartialOrd> PartialOrdProbe<T> {
2390        fn is_partial_ord() -> bool {
2391            true
2392        }
2393    }
2394
2395    /// A version comparison must be unreachable without first destructuring `Reported`, so that a
2396    /// caller cannot order `Unknown` against a real version — which, since every pre-#2215 peer is
2397    /// Unknown, would quietly become a verdict about most of the live network.
2398    #[test]
2399    fn peer_software_is_not_ordered() {
2400        assert!(
2401            Probe::<u32>::is_ord(),
2402            "control: the probe must detect a type that IS Ord, or it proves nothing"
2403        );
2404        assert!(
2405            !Probe::<PeerSoftware>::is_ord(),
2406            "PeerSoftware must not implement Ord — comparison belongs after destructuring Reported"
2407        );
2408    }
2409
2410    /// A defaulted `Unknown` appearing from nowhere is a different fact from a measured one, and
2411    /// `Default` would make the two indistinguishable at the point of construction.
2412    #[test]
2413    fn peer_software_has_no_default() {
2414        struct DefaultProbe<T>(core::marker::PhantomData<T>);
2415        trait DefaultFallback {
2416            fn is_default() -> bool {
2417                false
2418            }
2419        }
2420        impl<T> DefaultFallback for DefaultProbe<T> {}
2421        impl<T: Default> DefaultProbe<T> {
2422            fn is_default() -> bool {
2423                true
2424            }
2425        }
2426
2427        assert!(
2428            DefaultProbe::<String>::is_default(),
2429            "control: the probe must detect a type that IS Default, or it proves nothing"
2430        );
2431        assert!(
2432            !DefaultProbe::<PeerSoftware>::is_default(),
2433            "PeerSoftware must not implement Default"
2434        );
2435    }
2436
2437    // ---- SoftwareVersionDetail (dig_ecosystem#2215) ----
2438
2439    /// Each mode renders a value the PARSER reads back at the intended level of detail.
2440    ///
2441    /// The fixture uses a version with a non-zero minor AND a non-zero patch, because that is the
2442    /// only shape where `Full` and `Minor` differ — a `1.0.0` fixture would let a renderer that
2443    /// ignores the mode entirely pass.
2444    #[test]
2445    fn each_detail_mode_round_trips_to_the_intended_precision() {
2446        let v = semver::Version::new(0, 99, 1);
2447
2448        let full = SoftwareVersionDetail::Full.render("dig-node", &v);
2449        assert_eq!(full, "dig-node/0.99.1");
2450        assert_eq!(
2451            PeerSoftware::parse(&full),
2452            PeerSoftware::parse("dig-node/0.99.1")
2453        );
2454
2455        let minor = SoftwareVersionDetail::Minor.render("dig-node", &v);
2456        assert_ne!(minor, full, "Minor must actually coarsen");
2457        let PeerSoftware::Reported { version, .. } = PeerSoftware::parse(&minor) else {
2458            panic!("a coarsened advertisement must still be READABLE, not Unknown");
2459        };
2460        assert_eq!(version.major, 0);
2461        assert_eq!(version.minor, 99);
2462        assert_eq!(version.patch, 0, "the patch level is what Minor hides");
2463
2464        let off = SoftwareVersionDetail::Off.render("dig-node", &v);
2465        assert_eq!(off, "");
2466        assert_eq!(PeerSoftware::parse(&off), PeerSoftware::Unknown);
2467    }
2468
2469    /// `Minor` renders `MAJOR.MINOR.0`, NOT `MAJOR.MINOR`.
2470    ///
2471    /// A bare two-part `0.99` is not valid semver, so the parser would classify a peer that
2472    /// coarsened its build as Unknown — turning "tell them less" into "tell them nothing", which
2473    /// is what `Off` is for. This test is the guard on that distinction.
2474    #[test]
2475    fn minor_mode_stays_valid_semver_rather_than_collapsing_to_unknown() {
2476        let rendered =
2477            SoftwareVersionDetail::Minor.render("dig-node", &semver::Version::new(1, 4, 7));
2478        assert_eq!(rendered, "dig-node/1.4.0");
2479        assert_ne!(
2480            PeerSoftware::parse(&rendered),
2481            PeerSoftware::Unknown,
2482            "a coarsened build must remain readable; `product/1.4` would not be"
2483        );
2484    }
2485
2486    /// Coarsening strips pre-release and build metadata. A nightly's identifier is more precisely
2487    /// identifying than the patch number it accompanies, so leaving it in place would make `Minor`
2488    /// coarsen nothing at all for exactly the builds that most want it.
2489    #[test]
2490    fn minor_mode_strips_prerelease_and_build_metadata() {
2491        let v: semver::Version = "1.0.0-nightly.20260805+sha.abc123".parse().unwrap();
2492        let rendered = SoftwareVersionDetail::Minor.render("dig-node", &v);
2493        assert_eq!(rendered, "dig-node/1.0.0");
2494        assert!(
2495            !rendered.contains("nightly"),
2496            "the nightly identifier must not survive coarsening"
2497        );
2498        assert!(
2499            !rendered.contains("abc123"),
2500            "build metadata must not survive coarsening"
2501        );
2502    }
2503
2504    /// `Off` renders the empty string for ANY version, which is what makes it indistinguishable
2505    /// from a peer built before the field existed.
2506    #[test]
2507    fn off_mode_reveals_nothing_for_any_version() {
2508        for v in ["0.0.1", "1.2.3", "99.99.99-rc.1"] {
2509            let rendered = SoftwareVersionDetail::Off.render("dig-node", &v.parse().unwrap());
2510            assert_eq!(
2511                rendered, "",
2512                "Off must reveal nothing, including the product name"
2513            );
2514        }
2515    }
2516
2517    /// The default is the most informative setting: the diagnostic value is the reason the field
2518    /// exists, and an operator who disagrees opts down explicitly.
2519    #[test]
2520    fn detail_defaults_to_full() {
2521        assert_eq!(
2522            SoftwareVersionDetail::default(),
2523            SoftwareVersionDetail::Full
2524        );
2525    }
2526
2527    /// The wire tokens are the lowercase words an operator writes in a config file, and they are a
2528    /// published contract once a config carries them.
2529    #[test]
2530    fn detail_uses_lowercase_wire_tokens() {
2531        for (mode, token) in [
2532            (SoftwareVersionDetail::Full, "\"full\""),
2533            (SoftwareVersionDetail::Minor, "\"minor\""),
2534            (SoftwareVersionDetail::Off, "\"off\""),
2535        ] {
2536            assert_eq!(serde_json::to_string(&mode).unwrap(), token);
2537            assert_eq!(
2538                serde_json::from_str::<SoftwareVersionDetail>(token).unwrap(),
2539                mode
2540            );
2541        }
2542    }
2543
2544    // ---- Gate round 1 regressions (dig_ecosystem#2215) ----
2545
2546    /// **`PartialOrd` is the hazard the `Ord` probe misses.** `Ord: PartialOrd`, so a type can
2547    /// derive only `PartialOrd` — satisfying an `Ord`-only probe — while `Unknown < Reported(..)`
2548    /// still compiles and evaluates. That one-word derive would sort `Unknown` below every real
2549    /// version, which is the verdict-about-the-live-network the contract forbids. SPEC §4.1 names
2550    /// all three traits; this pins the weakest of them, which subsumes `Ord`.
2551    #[test]
2552    fn peer_software_is_not_partially_ordered_either() {
2553        assert!(
2554            PartialOrdProbe::<f64>::is_partial_ord(),
2555            "control: the probe must detect a type that IS PartialOrd but NOT Ord, or it proves              nothing about the gap between the two"
2556        );
2557        assert!(
2558            PartialOrdProbe::<u32>::is_partial_ord(),
2559            "control: a fully-ordered type must also be detected"
2560        );
2561        assert!(
2562            !PartialOrdProbe::<PeerSoftware>::is_partial_ord(),
2563            "PeerSoftware must implement neither PartialOrd nor Ord"
2564        );
2565    }
2566
2567    /// **The sentinel is VERSION ZERO, a class — not the three-character string `\"0.0.0\"`.**
2568    /// The constant's doc, `parse`'s doc, and SPEC §4.1 all state the rule over the class, so a
2569    /// string comparison lets `0.0.0+build`, `0.0.0-rc.1`, and `0.0.0-0` through as a *reported*
2570    /// version zero — the exact reading every one of those three prose statements forbids.
2571    #[test]
2572    fn version_zero_is_unknown_however_it_is_decorated() {
2573        for raw in [
2574            "dig-node/0.0.0",
2575            "dig-node/0.0.0+build",
2576            "dig-node/0.0.0-rc.1",
2577            "x/0.0.0-0",
2578            "dig-node/0.0.0-alpha+sha.abc123",
2579        ] {
2580            assert_eq!(
2581                PeerSoftware::parse(raw),
2582                PeerSoftware::Unknown,
2583                "{raw:?} is version zero and must be Unknown"
2584            );
2585        }
2586    }
2587
2588    /// A version that is merely CLOSE to zero is still a real build and must be reported — without
2589    /// this, a parser that mapped everything below `0.1.0` to Unknown would pass the test above.
2590    #[test]
2591    fn a_nonzero_version_near_zero_is_still_reported() {
2592        for raw in ["dig-node/0.0.1", "dig-node/0.1.0", "dig-node/0.0.1-rc.1"] {
2593            assert_ne!(
2594                PeerSoftware::parse(raw),
2595                PeerSoftware::Unknown,
2596                "{raw:?} is a real build, not the sentinel"
2597            );
2598        }
2599    }
2600
2601    /// **`render`'s stated invariant, tested over the class it is stated over.**
2602    ///
2603    /// The doc promises: every rendering is either the empty string or a value `parse` reads back
2604    /// as `Reported`. A `1.4.7` fixture cannot see the case that breaks it — a `0.0.x` build, whose
2605    /// `MAJOR.MINOR.0` coarsening IS version zero and therefore reads as Unknown. That is the same
2606    /// Minor-collapses-into-Off defect as the two-part spelling, arriving through the other door.
2607    #[test]
2608    fn every_rendering_is_empty_or_readable() {
2609        let versions = [
2610            "0.0.1",
2611            "0.0.7",
2612            "0.0.99", // the class the 1.4.7 fixture cannot see
2613            "0.1.0",
2614            "0.99.1",
2615            "1.0.0",
2616            "1.4.7",
2617            "10.20.30",
2618            "1.0.0-nightly.20260805+sha.abc123",
2619            "0.0.1-rc.1",
2620        ];
2621        for mode in [
2622            SoftwareVersionDetail::Full,
2623            SoftwareVersionDetail::Minor,
2624            SoftwareVersionDetail::Off,
2625        ] {
2626            for v in versions {
2627                let rendered = mode.render("dig-node", &v.parse().unwrap());
2628                if rendered.is_empty() {
2629                    continue;
2630                }
2631                assert_ne!(
2632                    PeerSoftware::parse(&rendered),
2633                    PeerSoftware::Unknown,
2634                    "{mode:?} rendered {rendered:?} for {v}, which reads back as Unknown — a                      non-empty rendering must always be readable"
2635                );
2636            }
2637        }
2638    }
2639
2640    /// `Minor` on a `0.0.x` build advertises NOTHING, deliberately.
2641    ///
2642    /// Hiding the patch of a `0.0.x` version leaves only version zero, which the wire reserves as
2643    /// the "unknown" sentinel. There is no coarser representable value, so the honest rendering is
2644    /// the empty string rather than the sentinel dressed up as a report. This differs from the
2645    /// `0.99` case: there a representable coarse value existed and the wrong spelling was chosen;
2646    /// here none exists.
2647    #[test]
2648    fn minor_of_a_zero_zero_build_advertises_nothing_rather_than_the_sentinel() {
2649        let rendered = SoftwareVersionDetail::Minor.render("dig-node", &"0.0.7".parse().unwrap());
2650        assert_eq!(rendered, "");
2651        assert_ne!(
2652            rendered, "dig-node/0.0.0",
2653            "the sentinel must never be ADVERTISED; it is only ever received from a legacy peer"
2654        );
2655    }
2656
2657    /// **Tripwire, not a guard.** `raw` is reconstructible from `product` + `version` for every
2658    /// string the current grammar accepts, because `semver::Version` re-renders losslessly. This
2659    /// asserts that equivalence deliberately.
2660    ///
2661    /// **When this test FAILS, `raw` has become load-bearing** — the grammar has started accepting
2662    /// something non-canonical (a `v` prefix, a two-part version, a vendor suffix) and `raw` is now
2663    /// the only record of what the peer actually sent. Do not "fix" it by deleting the field;
2664    /// replace this test with real assertions on the divergent inputs.
2665    #[test]
2666    fn raw_is_still_reconstructible_from_the_parsed_parts() {
2667        for advertised in [
2668            "dig-node/0.0.1",
2669            "dig-node/0.99.1",
2670            "dig-node/1.0.0-nightly.20260805",
2671            "dig-node/1.0.0+sha.abc123",
2672            "dig-node/1.0.0-rc.1+build.7",
2673            "acme/dig-node/1.2.3",
2674        ] {
2675            let PeerSoftware::Reported {
2676                product,
2677                version,
2678                raw,
2679            } = PeerSoftware::parse(advertised)
2680            else {
2681                panic!("{advertised:?} must be Reported");
2682            };
2683            assert_eq!(
2684                raw,
2685                format!("{product}/{version}"),
2686                "raw diverged from the parsed parts for {advertised:?} — `raw` is now                  load-bearing; see this test's doc comment before changing anything"
2687            );
2688        }
2689    }
2690
2691    /// **`remove` can report that it removed NOTHING, and the two answers are distinguishable on
2692    /// the wire.**
2693    ///
2694    /// The fixture varies ONE thing — the outcome — and holds `ip` and `banned` fixed, so the
2695    /// difference it detects can only be the outcome itself. A result type carrying `removed: true`
2696    /// unconditionally would make these two JSON documents identical, which is exactly the state
2697    /// where an operator reads "un-trusted" off a call that un-trusted nothing.
2698    #[test]
2699    fn a_removal_that_matched_nothing_is_not_serialised_as_a_removal() {
2700        let removed = ChiaPeersRemoveResult {
2701            outcome: ChiaPeerRemovalOutcome::Removed,
2702            ip: "203.0.113.7".into(),
2703            banned: false,
2704        };
2705        let missed = ChiaPeersRemoveResult {
2706            outcome: ChiaPeerRemovalOutcome::NoSuchPeer,
2707            ..removed.clone()
2708        };
2709
2710        let a = serde_json::to_value(&removed).unwrap();
2711        let b = serde_json::to_value(&missed).unwrap();
2712        assert_ne!(a, b, "the two outcomes must differ on the wire");
2713        assert_eq!(a["outcome"], "removed");
2714        assert_eq!(b["outcome"], "no_such_peer");
2715
2716        // No field of the miss may be a success flag a client could render as one. Every other
2717        // field is identical by construction, so this asserts the outcome is the ONLY signal.
2718        let miss_obj = b.as_object().unwrap();
2719        assert!(
2720            !miss_obj
2721                .values()
2722                .any(|v| v == &serde_json::Value::Bool(true)),
2723            "a miss must carry no `true` a client can mistake for success: {b}"
2724        );
2725
2726        // And it round-trips, so a consumer cannot lose the distinction by decoding.
2727        let back: ChiaPeersRemoveResult = serde_json::from_value(b).unwrap();
2728        assert_eq!(back.outcome, ChiaPeerRemovalOutcome::NoSuchPeer);
2729    }
2730
2731    /// **An unpolled peer serialises as `null`, never as height zero.**
2732    ///
2733    /// `peak_height` is the one signal for judging whether a peer trusted WITHOUT corroboration is
2734    /// current or stuck. The fixture holds a genuinely-observed `0` beside the unobserved peer,
2735    /// because a `u32` field collapses those two into the same byte and the collapse is the defect.
2736    #[test]
2737    fn an_unobserved_peak_is_null_and_an_observed_zero_is_not() {
2738        let entry = |peak| ChiaPeerEntry {
2739            ip: "203.0.113.7".into(),
2740            port: 8444,
2741            peak_height: peak,
2742            user_managed: true,
2743            banned: false,
2744        };
2745        let unobserved = serde_json::to_value(entry(None)).unwrap();
2746        let genesis = serde_json::to_value(entry(Some(0))).unwrap();
2747
2748        // Indexing a MISSING key also yields `Null`, so presence is asserted first — otherwise
2749        // an implementation that skipped the field entirely would pass this test while telling a
2750        // reader nothing at all about the peer.
2751        assert!(
2752            unobserved.get("peak_height").is_some(),
2753            "the key must be PRESENT and null, not omitted: {unobserved}"
2754        );
2755        assert_eq!(unobserved["peak_height"], serde_json::Value::Null);
2756        assert_eq!(genesis["peak_height"], 0);
2757        assert_ne!(
2758            unobserved["peak_height"], genesis["peak_height"],
2759            "unobservable and observed-zero must not render the same"
2760        );
2761    }
2762
2763    /// **A banned peer is enumerable — `list` is the only place the blocklist is visible.**
2764    #[test]
2765    fn the_peer_list_can_carry_a_banned_entry() {
2766        let listed = ChiaPeersListResult {
2767            peers: vec![ChiaPeerEntry {
2768                ip: "203.0.113.9".into(),
2769                port: 8444,
2770                peak_height: None,
2771                user_managed: false,
2772                banned: true,
2773            }],
2774        };
2775        let json = serde_json::to_value(&listed).unwrap();
2776        assert_eq!(json["peers"][0]["banned"], true);
2777        let back: ChiaPeersListResult = serde_json::from_value(json).unwrap();
2778        assert!(back.peers[0].banned);
2779    }
2780
2781    /// **The add result carries the warning TEXT, not only a flag saying a cost was paid.**
2782    ///
2783    /// The field exists so a client can quote the node's own sentence rather than restate it and
2784    /// drift. A boolean cannot be quoted, so the assertion is that a quotable, non-empty string
2785    /// naming the bypass reaches the wire under a stable key.
2786    #[test]
2787    fn the_add_result_carries_a_quotable_bypass_notice() {
2788        let json = serde_json::to_value(ChiaPeersAddResult {
2789            added: true,
2790            ip: "203.0.113.7".into(),
2791            port: 8444,
2792            corroboration_bypassed: true,
2793            notice: "believed WITHOUT corroboration".into(),
2794        })
2795        .unwrap();
2796
2797        let notice = json["notice"]
2798            .as_str()
2799            .expect("notice is a string on the wire");
2800        assert!(
2801            !notice.trim().is_empty(),
2802            "an empty notice discloses nothing"
2803        );
2804        assert!(
2805            notice.to_lowercase().contains("corroboration"),
2806            "the notice must name the cost it exists to disclose: {notice}"
2807        );
2808    }
2809}