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    /// This node's mirror advertise-URL view (dig-node#562), or `None` from a node built before
263    /// this field existed.
264    ///
265    /// The [`Option`] carries the backwards compatibility on its own — serde treats a missing
266    /// `Option` field as `None` — so a caller built ahead of its node still parses the rest of
267    /// this response rather than failing the whole call. See [`WalletBalanceResult::source`] for
268    /// the same pattern and the same reason.
269    pub mirror_advertise: Option<MirrorAdvertiseView>,
270}
271
272/// `control.config.setUpstream` — the persisted override + a restart hint.
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274pub struct SetUpstreamResult {
275    /// The normalized upstream that was persisted.
276    pub upstream: String,
277    /// Always `true` — the change takes effect on next node start.
278    pub requires_restart: bool,
279}
280
281/// Which of dig-node#562's `AdvertiseState` outcomes produced a [`MirrorAdvertiseView`].
282///
283/// Restated here, under the SAME six spellings, so a client and the node cannot drift on what one
284/// of them means — see the conformance KAT that pins each literal wire token. Named states rather
285/// than a bare empty [`MirrorAdvertiseView::urls`] list, because the four ways of publishing
286/// nothing have four different remedies and an empty list alone cannot tell them apart: a UI that
287/// only sees `[]` cannot say "nothing configured", "switched off", "not discovered yet", "waiting
288/// on a second source" and "waiting on a relay" apart, and each of those calls for different
289/// operator action (or none).
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
291#[serde(rename_all = "snake_case")]
292pub enum MirrorAdvertiseState {
293    /// Publishing the operator's own override; their value always wins.
294    AdvertisingOverride,
295    /// Publishing this node's own reflexive peer address, because no operator override is set.
296    AdvertisingDerived,
297    /// The operator set an override and no entry in it is publishable.
298    Off,
299    /// No operator override, and this node does not yet know a public address it could publish.
300    NoPublicAddress,
301    /// Exactly one source has reported a public address for this node and nothing has confirmed
302    /// it yet — a single source can be wrong without erroring.
303    UncorroboratedAddress,
304    /// A public address is known, but no path to this node (a relay reservation or a confirmed
305    /// direct mapping) is currently held.
306    NoRelay,
307}
308
309impl MirrorAdvertiseState {
310    /// Every variant, so a completeness test walks the SET rather than a chosen example.
311    pub const ALL: [MirrorAdvertiseState; 6] = [
312        MirrorAdvertiseState::AdvertisingOverride,
313        MirrorAdvertiseState::AdvertisingDerived,
314        MirrorAdvertiseState::Off,
315        MirrorAdvertiseState::NoPublicAddress,
316        MirrorAdvertiseState::UncorroboratedAddress,
317        MirrorAdvertiseState::NoRelay,
318    ];
319}
320
321/// The mirror advertise-URL view: embedded in [`ConfigResult`], and embedded in turn in
322/// [`SetMirrorAdvertiseUrlsResult`].
323///
324/// Not the WHOLE answer to `control.config.setMirrorAdvertiseUrls` — see
325/// [`SetMirrorAdvertiseUrlsResult::requires_restart`] for the reason a bare view is not enough
326/// there.
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328pub struct MirrorAdvertiseView {
329    /// The URLs this node will actually publish in its NEXT mirror-coin advertisement. Empty in
330    /// every state but [`MirrorAdvertiseState::AdvertisingOverride`] and
331    /// [`MirrorAdvertiseState::AdvertisingDerived`] — see [`Self::state`] for why an empty list
332    /// alone never says why.
333    pub urls: Vec<String>,
334    /// The operator's own persisted override, verbatim, or `None` when unset. Answers "is this
335    /// node advertising something the operator chose, or something it derived" without a caller
336    /// having to infer it from [`Self::state`] alone: `Some` even in
337    /// [`MirrorAdvertiseState::Off`], where the override exists but nothing in it is publishable.
338    pub operator_override: Option<Vec<String>>,
339    /// Which of dig-node#562's six outcomes produced [`Self::urls`] this pass.
340    pub state: MirrorAdvertiseState,
341}
342
343/// `control.config.setMirrorAdvertiseUrls` — the persisted view, plus whether it IS the live
344/// answer yet.
345///
346/// This does NOT reuse [`MirrorAdvertiseView`] alone the way [`CollateralMarginResult`] is shared
347/// between `.get` and `.set`, because unlike a margin this override has a genuine "not yet live"
348/// state a bare view cannot express. [`ConfigSetUpstream`](crate::method::ControlMethod::ConfigSetUpstream)'s
349/// sibling result already carries exactly this shape (`requires_restart`) — restated here rather
350/// than reused because the TRUTH VALUE differs per method, not merely the field name.
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352pub struct SetMirrorAdvertiseUrlsResult {
353    /// What a follow-up `control.config.get` will show ONCE this takes effect. See
354    /// [`Self::requires_restart`] for whether that is now or after a restart.
355    pub mirror_advertise: MirrorAdvertiseView,
356    /// Whether dig-node must be RESTARTED before [`Self::mirror_advertise`] is the answer a LIVE
357    /// `config.get` would give.
358    ///
359    /// This is a report of what THIS node's implementation actually did, never a value this
360    /// contract fixes — unlike [`SetUpstreamResult::requires_restart`], which is unconditionally
361    /// `true` because that override feeds `Config::from_env` at process start and can never be
362    /// read any other way. This override is different in kind: dig-node#562's advertise decision
363    /// is recomputed every mirror-coin-creation pass rather than read once at start, so LIVE is a
364    /// real, reachable answer here.
365    ///
366    /// It is not, however, the answer TODAY. dig-node#562's recomputation reads
367    /// `DIG_MIRROR_ADVERTISE_URLS` from the OS ENVIRONMENT of the running process
368    /// (`std::env::var`), and no control call can change the environment of a process already
369    /// running — so until dig-node persists this override somewhere its own advertise pass
370    /// actually re-reads (a config-store write this call feeds, rather than an environment
371    /// variable), a conforming node MUST answer `true` here. It MUST answer `false` the moment it
372    /// does, and this field is what lets that improvement ship without a wire-contract change: a
373    /// client that renders "saved" only when this is `false` never tells an operator their node is
374    /// advertising a URL it is not.
375    pub requires_restart: bool,
376}
377
378/// `control.log.setLevel` — the applied filter directive.
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380pub struct SetLevelResult {
381    /// The EnvFilter directive now in effect.
382    pub filter: String,
383}
384
385/// `control.cache.setCap` — the applied cap (after the 64 MiB floor).
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387pub struct SetCapResult {
388    /// The cache cap now in effect, in bytes.
389    pub cap_bytes: u64,
390}
391
392/// `control.cache.clear` — the clear acknowledgement.
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
394pub struct CacheClearResult {
395    /// Always `true`.
396    pub cleared: bool,
397}
398
399/// One cached capsule of a store, as listed by the hosted-stores methods.
400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
401pub struct CapsuleEntry {
402    /// The capsule reference (`storeId:rootHash`).
403    pub capsule: String,
404    /// The capsule root hash.
405    pub root: String,
406    /// The capsule size on disk, in bytes.
407    pub size_bytes: u64,
408    /// When the capsule was last served, in unix milliseconds.
409    pub last_used_unix_ms: u64,
410}
411
412/// One hosted/pinned store (`control.hostedStores.list`).
413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
414pub struct HostedStore {
415    /// The canonical lowercase 64-hex store id.
416    pub store_id: String,
417    /// Whether the operator has pinned this store.
418    pub pinned: bool,
419    /// The number of cached capsules of this store.
420    pub capsule_count: u64,
421    /// The total cached bytes across this store's capsules.
422    pub total_bytes: u64,
423    /// The cached capsules of this store.
424    pub capsules: Vec<CapsuleEntry>,
425}
426
427/// `control.hostedStores.list` — every held/pinned store.
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
429pub struct HostedStoresListResult {
430    /// The stores, one entry per distinct store id.
431    pub stores: Vec<HostedStore>,
432}
433
434/// `control.hostedStores.pin` — the pin acknowledgement + the pre-fetch outcome.
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436pub struct PinResult {
437    /// The store id that was pinned.
438    pub store_id: String,
439    /// The pinned root, or `null` when pinned at store level.
440    pub root: Option<String>,
441    /// Always `true`.
442    pub pinned: bool,
443    /// The in-band pre-fetch outcome (`{status, …}`) — its shape varies with the fetch path.
444    pub fetch: serde_json::Value,
445}
446
447/// `control.hostedStores.unpin` — the unpin acknowledgement + eviction count.
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
449pub struct UnpinResult {
450    /// The store id that was unpinned.
451    pub store_id: String,
452    /// Whether a pin registry entry was actually removed.
453    pub unpinned: bool,
454    /// How many cached capsules of the store were evicted.
455    pub evicted_capsules: u64,
456}
457
458/// `control.hostedStores.status` — per-store pinned flag + cached capsules.
459#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
460pub struct HostedStoreStatusResult {
461    /// The store id queried.
462    pub store_id: String,
463    /// Whether the store is pinned.
464    pub pinned: bool,
465    /// The number of cached capsules.
466    pub capsule_count: u64,
467    /// The total cached bytes.
468    pub total_bytes: u64,
469    /// The cached capsules.
470    pub capsules: Vec<CapsuleEntry>,
471}
472
473/// `control.capsule.fetch` — the P2P pull acknowledgement.
474///
475/// This is a STARTED/ALREADY-CACHED acknowledgement, not a completion report: unlike
476/// `control.sync.trigger`'s §21 HTTP fetch (synchronous, single hop), a P2P pull recursively
477/// discovers a holder and may stream through several onion hops, so it can take arbitrarily
478/// long. A caller wanting to know when the bytes actually land polls `control.hostedStores.status`
479/// for the store, the same way `control.hostedStores.pin`'s pre-fetch is observed today.
480#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
481pub struct CapsuleFetchResult {
482    /// The store id the fetch was requested for.
483    pub store: String,
484    /// The capsule root requested.
485    pub root: String,
486    /// The outcome: `"started"` (a P2P pull was launched), `"already_cached"` (the capsule was
487    /// already on disk and no pull was needed), or `"unavailable"` (recursive discovery found no
488    /// holder to pull from right now — the caller may retry later).
489    pub status: String,
490}
491
492/// `control.sync.status` — §21 sync availability + pin coverage.
493#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
494pub struct SyncStatusResult {
495    /// Whether authenticated §21 whole-store sync is available.
496    pub available: bool,
497    /// The sync method name.
498    pub method: String,
499    /// The number of pinned stores.
500    pub pinned_total: u64,
501    /// How many pinned stores currently have a cached capsule.
502    pub pinned_synced: u64,
503    /// Whether whole-store (root-less) sync is supported by this build.
504    pub whole_store_trigger_supported: bool,
505}
506
507/// `control.sync.trigger` — the synced-capsule outcome.
508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
509pub struct SyncTriggerResult {
510    /// The store id synced.
511    pub store_id: String,
512    /// The capsule root synced.
513    pub root: String,
514    /// The outcome status (`"synced"`).
515    pub status: String,
516    /// The synced capsule size, in bytes.
517    pub size_bytes: u64,
518    /// The served root the node verified against.
519    pub served_root: String,
520}
521
522/// `control.pairing.approve` — the mint acknowledgement + the new token's id.
523#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
524pub struct PairingApproveResult {
525    /// Always `true`.
526    pub approved: bool,
527    /// The requesting client's declared name.
528    pub client_name: String,
529    /// The short id of the minted paired token (used to revoke it).
530    pub token_id: String,
531}
532
533/// `control.pairing.revoke` — the revoke acknowledgement.
534#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
535pub struct PairingRevokeResult {
536    /// Whether a token was actually removed.
537    pub revoked: bool,
538    /// The token id that was targeted.
539    pub token_id: String,
540}
541
542/// `control.peerCounts` — how many peers this node holds on EACH network.
543///
544/// # Two networks, two numbers, and neither is "peers"
545///
546/// A DIG node is connected to two entirely separate networks at once: the DIG content/gossip
547/// network (port 9445), and the Chia full nodes its wallet chain sync talks to. The counts are
548/// unrelated and move independently — a node with many DIG peers and no Chia peer is serving content
549/// while its wallet is not syncing at all, and the reverse is equally possible.
550///
551/// So neither field is spelled `peers`, `connected_peers` or `peer_count`. A bare name forces a
552/// consumer to KNOW which network a number describes, and the failure when it guesses wrong is
553/// silent: a plausible integer in a right-looking place. This method exists so that one call answers
554/// for both networks and each answer names its own.
555///
556/// # `relay.peer_count` from `control.peerStatus` is NOT this
557///
558/// That field counts the peers connected to THE RELAY, not to this node, and it is frequently the
559/// only non-zero number on a node connected to nothing. It is never the answer to "how many peers
560/// does this node have"; [`dig_peer_count`](Self::dig_peer_count) is.
561///
562/// # `Some(0)` is measured; `null` is unknown
563///
564/// `0` means the node looked at that network and found nothing connected. `null` means it cannot
565/// observe the count at all — which is what a node whose peer network is not running reports, since
566/// a zero there would claim "nothing is connected" about a network it never asked.
567///
568/// # Connected is not the same question as known
569///
570/// [`known_dig_peer_count`](PeerCountsResult::known_dig_peer_count) answers a THIRD question —
571/// how many DIG peers this node has heard of, connected or not — so that a lonely node can say
572/// which of the two ways it is lonely. Every count here is one node's local view; none of them is
573/// the size of the network.
574#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
575pub struct PeerCountsResult {
576    /// Peers on the DIG content/gossip network (port 9445) — dig-node-core's `connected_peers`, the
577    /// same figure `control.peerStatus` reports. `0` is an observed zero; `null` is unobservable.
578    pub dig_peer_count: Option<u32>,
579    /// CHIA full-node peers the wallet's chain sync holds. The SAME observation
580    /// [`WalletSyncStatusResult::chia_peer_count`] reports — a conforming node MUST serve both from
581    /// one source, and the two MUST agree within a single node's view.
582    pub chia_peer_count: Option<u32>,
583    /// DIG peers this node has LEARNED OF but is not necessarily connected to — the size of its own
584    /// discovered-peer address book (dig_ecosystem#2570).
585    ///
586    /// This exists so a client can distinguish "this node is connected to nobody" from "there is
587    /// nobody to connect to", which [`dig_peer_count`](Self::dig_peer_count) alone cannot tell
588    /// apart. A node reporting `dig_peer_count: 0` alongside a known count of 40 has a reachability
589    /// problem; one reporting `0` alongside `0` has a discovery problem. Those are different faults
590    /// with different remedies, and until this field existed both rendered as the same zero.
591    ///
592    /// # What it does NOT count
593    ///
594    /// **It is not the size of the DIG network, and no field on this interface is.** It is ONE
595    /// node's local view and therefore a LOWER BOUND: it omits every peer this node has not been
596    /// introduced to, every peer behind a relay it does not use, every peer that entered the
597    /// network after this node's last discovery pass, and every entry its address book evicted
598    /// under its bucket limits. Two healthy nodes on the same network will report different numbers
599    /// and neither is wrong. A client MUST label it as discovered/known peers — rendering it as
600    /// "total peers" or "network size" asserts global knowledge that nothing here has.
601    ///
602    /// It is also NOT `control.peerStatus`'s `relay.peer_count`, which counts peers registered with
603    /// THE RELAY — a different party's view, scoped to that one relay.
604    ///
605    /// # Relationship to [`dig_peer_count`](Self::dig_peer_count)
606    ///
607    /// Normally `known_dig_peer_count >= dig_peer_count`, since a connected peer is a peer this node
608    /// knows of. A client MUST NOT rely on that ordering as an invariant: the two are sampled from
609    /// separate structures and a transient inversion during churn is not a protocol violation.
610    ///
611    /// # `Some(0)` is measured; `null` is unknown
612    ///
613    /// `0` means the node consulted its address book and found it empty. `null` means it could not
614    /// consult it at all — which is what a node whose peer network is not running reports, and what
615    /// a node too old to have this field reports by omitting it. Serde treats the missing field as
616    /// `None`, so an older node's payload decodes here as "unknown" rather than being rejected, and
617    /// an older CLIENT ignores the extra field: the addition is compatible in both directions.
618    pub known_dig_peer_count: Option<u32>,
619}
620
621/// `control.peers.connect` — the connected peer's id.
622#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
623pub struct PeersConnectResult {
624    /// Always `true` on success.
625    pub connected: bool,
626    /// The connected peer's id.
627    pub peer_id: String,
628}
629
630/// `control.peers.disconnect` — the dropped peer's id.
631#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
632pub struct PeersDisconnectResult {
633    /// Always `true` (idempotent — dropping an absent peer still succeeds).
634    pub disconnected: bool,
635    /// The peer id that was targeted (trimmed + lower-cased).
636    pub peer_id: String,
637}
638
639/// One tracked Chia full-node peer.
640#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
641pub struct ChiaPeerEntry {
642    /// The peer's IP address, in the canonical form defined by
643    /// [`crate::params::canonical_peer_ip`] — a bare literal, never bracketed and never carrying a
644    /// port.
645    pub ip: String,
646    /// The peer's port (the standard full-node port unless the entry says otherwise).
647    pub port: u16,
648    /// The peak height this peer last reported, or `null` where the node has NO telemetry for it
649    /// yet.
650    ///
651    /// `null` means UNOBSERVABLE, never zero — the convention `control.peerCounts` and
652    /// `control.wallet.peak` already use, and it matters more here: this is the one signal an
653    /// operator has for judging whether a peer they trust WITHOUT corroboration is current or
654    /// stuck, and a peer nobody has polled must not read as a peer stalled at genesis.
655    ///
656    /// A reported height is that peer's CLAIM, never a fact this node verified — never a
657    /// fabricated height, and never to be aggregated into a chain position (NC-12: a maximum over
658    /// claimed peaks is whatever the most dishonest peer says).
659    pub peak_height: Option<u32>,
660    /// TRUE where a person added this peer by hand, which is exactly the set that is trusted
661    /// WITHOUT corroboration. Discovered peers are `false` and stay subject to agreement.
662    pub user_managed: bool,
663    /// TRUE where this entry is BANNED — kept so discovery cannot re-add it, and excluded from
664    /// every chain read.
665    ///
666    /// Banned entries appear in this list because it is the ONLY enumeration of the banned set,
667    /// and a blocklist a person cannot read is a blocklist they cannot correct.
668    pub banned: bool,
669}
670
671/// `control.chiaPeers.list` — every tracked Chia peer: trusted, discovered and banned alike.
672#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
673pub struct ChiaPeersListResult {
674    /// The tracked peers — read `user_managed` to tell the trusted set from the discovered one,
675    /// and `banned` to see the exclusions. A conforming node MUST NOT omit banned entries: this
676    /// list is the only way to enumerate them.
677    pub peers: Vec<ChiaPeerEntry>,
678}
679
680/// `control.chiaPeers.add` — the acknowledgement, including the cost that was paid.
681#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
682pub struct ChiaPeersAddResult {
683    /// Always `true` on success (idempotent — re-adding a known peer succeeds and un-bans it).
684    pub added: bool,
685    /// The peer's IP address as stored, in the canonical form defined by
686    /// [`crate::params::canonical_peer_ip`].
687    pub ip: String,
688    /// The port the entry was stored at.
689    pub port: u16,
690    /// Whether this peer is NOW believed without corroboration — the RESULTING trust state, not a
691    /// restatement of what was asked for.
692    ///
693    /// `true` in the ordinary case, and a conforming node MUST report `false` where the entry did
694    /// not end up trusted, however that came about (an upsert that touches other columns and
695    /// leaves the trusted flag alone is how it happens in practice). Reported honestly, this is
696    /// the only way an operator learns that the node they believe they configured is still subject
697    /// to corroboration; reported as a constant, it is a claim about custody-grade authority that
698    /// nothing checks.
699    pub corroboration_bypassed: bool,
700    /// The human-readable warning the node authored for this call, to be rendered VERBATIM to the
701    /// person who made it.
702    ///
703    /// This is the field a client quotes instead of restating the cost locally and drifting from
704    /// the node's wording. It MUST be non-empty and MUST name the corroboration bypass; a client
705    /// MUST NOT paraphrase, truncate or suppress it.
706    pub notice: String,
707}
708
709/// What `control.chiaPeers.remove` actually DID.
710///
711/// An enum rather than a boolean, and deliberately with no always-true companion field, because
712/// `remove` is the ONLY way to un-trust a peer holding unbounded authority over the money-bearing
713/// wallet replica. A remedy that cannot report its own failure is worse than no remedy: the
714/// operator believes they revoked custody-grade trust and they did not. A consumer has to MATCH on
715/// this, so it cannot render "nothing was there" as "it is gone".
716#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
717#[serde(rename_all = "snake_case")]
718pub enum ChiaPeerRemovalOutcome {
719    /// A matching entry existed and is gone — or, with `ban`, is now banned.
720    Removed,
721    /// NOTHING matched the address given. The trusted set is unchanged, so any peer the caller
722    /// meant to un-trust is STILL trusted — most often because the address was spelled differently
723    /// from the stored entry. A client MUST surface this as a failure to act, never as success.
724    NoSuchPeer,
725}
726
727/// `control.chiaPeers.remove` — the acknowledgement.
728#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
729pub struct ChiaPeersRemoveResult {
730    /// What happened — see [`ChiaPeerRemovalOutcome`]. There is no `removed: true` here, on
731    /// purpose.
732    pub outcome: ChiaPeerRemovalOutcome,
733    /// The peer's IP address as targeted, in the canonical form defined by
734    /// [`crate::params::canonical_peer_ip`].
735    pub ip: String,
736    /// Whether the peer is now BANNED rather than merely forgotten.
737    pub banned: bool,
738}
739
740/// `control.subscribe` — the subscription acknowledgement.
741#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
742pub struct SubscribeResult {
743    /// Always `true`.
744    pub subscribed: bool,
745    /// Whether the store was newly added (vs already subscribed).
746    pub added: bool,
747    /// The canonical persisted store id (trimmed + lower-cased).
748    pub store_id: String,
749    /// What the node recorded the subscription as following. OMITTED means
750    /// [`SubscriptionKind::Capsule`](crate::params::SubscriptionKind::Capsule), so a node build
751    /// that predates the field still parses here rather than failing the whole response.
752    #[serde(default)]
753    pub kind: crate::params::SubscriptionKind,
754}
755
756/// `control.unsubscribe` — the unsubscription acknowledgement.
757#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
758pub struct UnsubscribeResult {
759    /// Always `false`.
760    pub subscribed: bool,
761    /// Whether the store was actually removed.
762    pub removed: bool,
763    /// The canonical store id.
764    pub store_id: String,
765}
766
767/// `control.listSubscriptions` — the node's persisted subscription set.
768#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
769pub struct ListSubscriptionsResult {
770    /// The subscribed store ids.
771    pub subscriptions: Vec<String>,
772    /// The subscription count.
773    pub count: u64,
774}
775
776/// `control.wallet.balance` — an address's balance for one asset, as the node's chain read saw it.
777///
778/// A READ-only result: this reports chain state, it never moves funds. It is a strict SUPERSET of
779/// dig-app's frozen `BalanceResponse { balance }` — the node emits the richer shape, and because
780/// dig-app's struct does not deny unknown fields it reads [`balance`](Self::balance) losslessly and
781/// ignores the rest. That superset relationship is the "no dig-app code change" guarantee, pinned by
782/// the conformance KAT.
783#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
784pub struct WalletBalanceResult {
785    /// The CONFIRMED, spendable balance in the asset's base unit (mojos for XCH, base units for DIG).
786    /// The only field dig-app 3.x reads.
787    pub balance: u64,
788    /// Incoming funds seen but not yet confirmed (asset base units); not yet spendable.
789    pub pending: u64,
790    /// Which tier produced these figures, or `None` from a node too old to disclose it.
791    ///
792    /// See [`WalletReadSource`]. Absent (`null` / omitted) is a THIRD state, not a default tier:
793    /// it means the answering node predates tier disclosure, so the caller knows the tier is
794    /// unknown rather than being told a tier that was never reported.
795    ///
796    /// The [`Option`] carries the backwards compatibility on its own — serde treats a missing
797    /// `Option` field as `None` — so no `#[serde(default)]` is needed and none is written; a
798    /// REQUIRED field here would reject an older node's payload outright.
799    pub source: Option<WalletReadSource>,
800    /// Whether THESE figures reflect a caught-up view of the tier that ANSWERED, measured against
801    /// that tier's own peak. When `false`, they are STALE or drawn from a tier that tracks no peak.
802    ///
803    /// This describes the ANSWER, not the node: a [`WalletReadSource::Fallback`] answer is measured
804    /// against the ORACLE's peak, never against the node's own replica or its held peers, neither of
805    /// which produced the figures.
806    ///
807    /// `true` only when that peak came from the SAME read that produced these figures; a carried-over
808    /// peak means `false`.
809    pub synced: bool,
810    /// The peak block height of the tier that ANSWERED, or `null` when that tier tracks no peak.
811    ///
812    /// For a [`WalletReadSource::Fallback`] answer this is the oracle's own reported height, and for
813    /// a [`WalletReadSource::Db`] answer the replica's. It is never the node's replica height stamped
814    /// onto an oracle's figures, and never a CACHED row, which no live tier bounds.
815    ///
816    /// It MUST be the height that tier reported in the SAME read that produced the figures, or `null`.
817    pub peak_height: Option<u32>,
818}
819
820/// Which tier answered a wallet read (dig_ecosystem#2233).
821///
822/// A node serves a wallet read either from its own chain replica or from a third-party HTTP
823/// oracle, and the two are not interchangeable to a caller: the oracle path is a network round
824/// trip that **discloses the queried address off-node**, which a user on a metered or private
825/// connection has a legitimate interest in knowing about. Reporting the tier is also what makes
826/// "the node answered from its own chain state" a falsifiable claim — a sync-progress flag is not,
827/// since a flag can flip while the oracle keeps answering.
828#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
829#[serde(rename_all = "lowercase")]
830pub enum WalletReadSource {
831    /// The node's own local chain replica. No third party was consulted.
832    Db,
833    /// A third-party coinset HTTP oracle. The queried value — an address, or a COIN ID on
834    /// `control.wallet.coinById` — was disclosed off-node.
835    ///
836    /// The coin-id case is the more sensitive of the two, and the less obvious: an address is
837    /// disclosed on every routine balance poll, whereas querying a freshly created coin id, from the
838    /// spender's IP, at the moment of the spend, hands the oracle a `{IP, timestamp, coin id}` tuple
839    /// that ties a network identity to a specific new on-chain identity.
840    Fallback,
841}
842
843/// One coin, as the node's chain read saw it (`control.wallet.coins` / `control.wallet.coinById`).
844///
845/// The first three fields are byte-identical to dig-app's frozen `CoinRecord`, so its
846/// `CoinsResponse` deserializes this losslessly and ignores the rest. The rest is what a spend
847/// actually needs: a coin cannot be spent from an id and an amount alone — the parent and the
848/// puzzle hash are what reconstruct the `Coin` — and the heights are how a caller tells a confirmed
849/// coin from one it only saw in the mempool.
850///
851/// ONE record type serves both reads deliberately. A second coin shape would be a second thing to
852/// keep in step with dig-app's frozen struct, and the two would drift byte-wise the first time only
853/// one of them was touched.
854#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
855pub struct WalletCoinRecord {
856    /// The coin id, lowercase 64-hex, unprefixed.
857    pub coin_id: String,
858    /// The asset this coin is denominated in, or `null` when THIS READ DID NOT CLASSIFY THE COIN.
859    ///
860    /// `null` never means "no asset" and never means XCH by default. It means the answering read
861    /// had no basis to say: a singleton, a CAT and a plain XCH coin are indistinguishable from a
862    /// coin id alone — telling them apart requires inspecting the puzzle, and the node reads only
863    /// the coin record. So `control.wallet.coinById` MUST report `null` here — emitting a concrete
864    /// asset on an unclassified read would make the node assert a classification it never verified,
865    /// which a caller would then spend against.
866    ///
867    /// `control.wallet.coins` MUST report the concrete asset it was SCOPED to and MUST NOT emit
868    /// `null`. This field is optional only to serve the by-id read; the coins read has no
869    /// unclassified case, and dig-app's frozen `CoinRecord` requires a non-null asset there, so a
870    /// `null` breaks that read outright rather than degrading it. The type cannot enforce the split
871    /// because ONE record shape deliberately serves both reads (see the type docs), which is why the
872    /// rule is stated here and pinned by a KAT.
873    pub asset: Option<crate::params::Asset>,
874    /// The coin's amount, in the asset's base unit.
875    pub amount: u64,
876    /// The parent coin's id, lowercase 64-hex, unprefixed.
877    pub parent_coin_info: String,
878    /// The coin's puzzle hash, lowercase 64-hex, unprefixed.
879    pub puzzle_hash: String,
880    /// The height the coin was created at, or `null` while it is still only in the mempool.
881    pub created_height: Option<u32>,
882    /// The height the coin was spent at, or `null` when it is unspent.
883    pub spent_height: Option<u32>,
884}
885
886/// `control.wallet.coins` — an address's spendable coins for one asset.
887///
888/// # An empty list is an ANSWER, never a fallback
889///
890/// `coins: []` means the node consulted a chain and that address holds nothing. It is NEVER what a
891/// caller gets when the chain could not be reached: those are catalogued errors
892/// ([`crate::error::ControlErrorCode::WalletNoChainSource`] / `WalletNotSynced` /
893/// `WalletReadFailed` / `WalletRateLimited`). The distinction is the whole point of the method —
894/// a well-shaped empty result on an unreachable chain would tell somebody who holds funds that they
895/// hold nothing, and a spend built on that answer refuses with a shortfall that is not true.
896///
897/// # The order is part of the contract, because paging is meaningless without one
898///
899/// A node MUST return coins in ASCENDING `coin_id` order and MUST keep that order stable across the
900/// pages of one walk;
901/// [`after_coin_id`](crate::params::WalletCoinsParams::after_coin_id) means *strictly after this id
902/// in that order*. Coin ids are fixed-length lowercase hex, so ascending lexicographic order and
903/// ascending 32-byte numeric order are the SAME order and cannot disagree.
904///
905/// The order is what makes the boundary survive a CHANGING coin set, which is the case that matters
906/// here and does not arise for `coinsByParent`: a spent coin drops out of an address's unspent set
907/// between two pages. Against a cursor, the rows before the boundary are simply gone and every row
908/// after it still follows the cursor. Against an OFFSET, every remaining row shifts one position
909/// earlier and the next page silently begins one row late — a coin the caller never sees, which on
910/// this read means funds it cannot spend and a spend that refuses with an untrue shortfall.
911#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
912pub struct WalletCoinsResult {
913    /// One page of the address's spendable coins, ascending by `coin_id`, possibly empty (see the
914    /// type docs). NOT necessarily the whole set — see [`complete`](Self::complete).
915    pub coins: Vec<WalletCoinRecord>,
916    /// Is this page the WHOLE unspent set at this address for this asset?
917    ///
918    /// `Some(true)` means every coin the node knows of is in [`coins`](Self::coins). `Some(false)`
919    /// means the answer was TRUNCATED and more coins exist — resume from [`cursor`](Self::cursor).
920    ///
921    /// A node MUST derive this from whether rows remain BEYOND the page, never from whether the page
922    /// filled. The two differ exactly when the coin count is a multiple of the page size, where the
923    /// length-based reading declares a truncated page whole — so a caller summing a balance or
924    /// selecting coins for a spend stops early on a set it believes it saw all of.
925    ///
926    /// `None` means a node too old to disclose it (pre-0.25), which served this read UNPAGED and
927    /// whose answer is therefore the whole set already. It is distinct from `Some(false)` on
928    /// purpose: such a node also ignores `after_coin_id`, so a caller that read `None` as
929    /// "truncated" and resumed would be re-served page one forever.
930    #[serde(default)]
931    pub complete: Option<bool>,
932    /// The last coin in this page — **the value to resume from** — or `null` for an empty page, and
933    /// from a pre-0.25 node that never paged at all.
934    ///
935    /// It is the id the caller was HANDED, never a marker for where the chain got to. Pass it as
936    /// [`after_coin_id`](crate::params::WalletCoinsParams::after_coin_id) to fetch the next page.
937    ///
938    /// Unlike its `coinsByParent` twin the key is OMITTABLE, because this method predates paging and
939    /// an older node emits no such key. [`complete`](Self::complete) is what carries the
940    /// old-node case, and reading this field without it is what the doc above warns against.
941    #[serde(default)]
942    pub cursor: Option<String>,
943    /// Which tier answered, or `None` from a node too old to disclose it. See [`WalletReadSource`].
944    pub source: Option<WalletReadSource>,
945    /// Whether THESE coins reflect a caught-up view of the tier that ANSWERED, measured against that
946    /// tier's own peak — never against the node's replica or its held peers.
947    ///
948    /// `true` only when that peak came from the SAME read that produced these figures; a carried-over
949    /// peak means `false`.
950    pub synced: bool,
951    /// The peak height of the tier that ANSWERED, or `null` when that tier tracks no peak.
952    ///
953    /// It MUST be the height that tier reported in the SAME read that produced the figures, or `null`.
954    pub peak_height: Option<u32>,
955}
956
957/// Deserialize an `Option<T>` that is nullable but NOT omittable.
958///
959/// Serde special-cases a missing field of type `Option<T>` into `None`, so a required-but-nullable
960/// field is not expressible by the derive alone. Naming a `deserialize_with` suppresses that
961/// special case: an absent key becomes a `missing field` error, while an explicit `null` still
962/// decodes to `None`.
963fn required_option<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
964where
965    D: serde::Deserializer<'de>,
966    T: Deserialize<'de>,
967{
968    Option::<T>::deserialize(deserializer)
969}
970
971/// `control.wallet.coinById` — ONE coin, named by its own id, spent or unspent.
972///
973/// # An absent coin is an ANSWER; an unreachable chain is an ERROR
974///
975/// `coin: null` means a chain WAS consulted and holds no such coin. It is NEVER what a caller gets
976/// when the chain could not be reached: those are the catalogued errors
977/// ([`crate::error::ControlErrorCode::WalletNoChainSource`] / `WalletReadFailed` /
978/// `WalletRateLimited`). Collapsing the two turns "your wifi dropped" into "your mint never
979/// happened", and the remedies are opposite: retry the read, versus stop waiting.
980///
981/// # Why this method exists — observing a mint
982///
983/// `control.wallet.broadcast`'s `accepted: true` reports mempool admission only; only a buried
984/// confirmation of the CREATED COIN is evidence that a mint happened. `control.wallet.coins`
985/// cannot supply it — it answers by ADDRESS and lists UNSPENT coins only, so it can see neither the
986/// created DID coin nor the funding coin the mint spent. This method is how that evidence is
987/// obtained: read the created coin's id for a `created_height`, and the funding coin's id for a
988/// [`spent_height`](WalletCoinRecord::spent_height). Without it a mint can be pushed, real XCH can
989/// leave the wallet, and the outcome stays permanently "pending".
990///
991/// # The freshness fields are honest, not decorative
992///
993/// [`source`](Self::source) discloses which tier answered, and every freshness field describes THAT
994/// tier — the same rule the by-address reads carry. The bound MUST come from the party that PRODUCED
995/// the answer: a `fallback` answer MUST report the ORACLE's own peak as
996/// [`peak_height`](Self::peak_height), and a `db` answer the replica's peak. A node MUST NOT bound an
997/// answer by its own replica's peak, nor by the high-water mark of its held peers, when neither
998/// produced the answer — that stamps a freshness claim onto figures whose freshness it does not bound.
999///
1000/// [`synced`](Self::synced) is a CONCLUSION, and it is defined exactly: it is `true` if and only if
1001/// the reported [`peak_height`](Self::peak_height) is the height the tier that ANSWERED reported in
1002/// the SAME read that produced these figures, and that tier reports the figures complete as of it. A
1003/// peak carried over from an earlier read, or obtained from any other exchange, party or tier, does
1004/// NOT satisfy this.
1005///
1006/// `synced` `true` on a `fallback` answer therefore asserts only that ONE disclosed oracle answered
1007/// self-consistently. It is not corroboration by the network and MUST NOT be presented as
1008/// confirmation by it; a consumer that badges money as current from it MUST also surface the tier.
1009///
1010/// `false` and `null` are the honest answer in three cases, and a node MUST emit them there: an
1011/// answering tier that tracks no peak of its own; a CACHED row, which no live tier bounds; and a peak
1012/// the node cannot bind to the same read as the figures.
1013///
1014/// # A negative answer requires a view that could have held the coin
1015///
1016/// `coin: null` is a VERDICT — it says stop waiting — so it MUST NOT be served from a view that
1017/// could not have seen the coin in the first place. A node whose replica is still catching up, or
1018/// whose local index is address-scoped rather than a full chain view, has NOT established that the
1019/// coin is absent; it has only established that IT cannot see it. Such a node MUST return
1020/// [`WalletNoChainSource`](crate::error::ControlErrorCode::WalletNoChainSource) or
1021/// [`WalletReadFailed`](crate::error::ControlErrorCode::WalletReadFailed) and MUST NOT answer
1022/// `coin: null`.
1023///
1024/// This matters precisely for the two coins this method exists to observe. A created coin sits at no
1025/// wallet address and a spent funding coin is gone from every unspent list, so an address-scoped
1026/// replica is guaranteed to miss both — and a `coin: null` from it would report a mint that DID
1027/// happen as never-having-happened, with the funds already gone. `control.wallet.peak` is no escape
1028/// hatch here: it reports that same replica's height, which can bound a positive confirmation but
1029/// can never license a negative one.
1030#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1031pub struct WalletCoinByIdResult {
1032    /// The coin, or `null` when the consulted chain holds no coin with that id (see the type docs).
1033    ///
1034    /// The key MUST be present. `null` is a verdict here, so an ABSENT key must not decode into one:
1035    /// serde's default treatment of `Option` makes a missing field indistinguishable from an
1036    /// explicit `null`, which would let an unrelated or truncated payload — anything at all carrying
1037    /// a `synced` field — decode into a confident "the chain holds no such coin". `deserialize_with`
1038    /// suppresses that default so the field is genuinely required.
1039    #[serde(deserialize_with = "required_option")]
1040    pub coin: Option<WalletCoinRecord>,
1041    /// Which tier answered, or `None` from a node too old to disclose it. See [`WalletReadSource`].
1042    pub source: Option<WalletReadSource>,
1043    /// Whether this answer reflects a caught-up view of the tier that ANSWERED, measured against
1044    /// that tier's own peak — never against the node's replica or its held peers.
1045    ///
1046    /// `true` only when that peak came from the SAME read that produced these figures; a carried-over
1047    /// peak means `false`.
1048    pub synced: bool,
1049    /// The peak height of the tier that ANSWERED, or `null` when that tier tracks no peak.
1050    ///
1051    /// It MUST be the height that tier reported in the SAME read that produced the figures, or `null`.
1052    pub peak_height: Option<u32>,
1053}
1054
1055/// One coin's SPEND: the coin that was consumed, plus the two programs that consumed it.
1056///
1057/// This is the chia `CoinSpend` in the contract's own wire form — the puzzle reveal and the solution
1058/// as lowercase hex of their serialized CLVM, beside the [`WalletCoinRecord`] for the spent coin.
1059/// The coin is carried as the SAME record type the other reads use rather than a trimmed
1060/// parent/puzzle-hash/amount triple, because a second coin shape is a second thing to keep in step
1061/// with dig-app's frozen `CoinRecord` (see [`WalletCoinRecord`]).
1062///
1063/// # The reveal is checkable, and a conforming node MUST have checked it
1064///
1065/// A puzzle reveal is supplied by a peer, and a lying peer can supply a different program. The
1066/// reveal's tree hash MUST equal the spent coin's own
1067/// [`puzzle_hash`](WalletCoinRecord::puzzle_hash), which makes the claim self-checking, and a node
1068/// MUST fail closed — a catalogued error, never a spend carrying an unverified reveal — when the
1069/// hashes disagree or the reveal does not parse. A caller MAY re-derive the same check from the two
1070/// fields it is handed; it never has to trust the node to have done it.
1071///
1072/// # `spent_height` is present on the coin, always
1073///
1074/// A spend exists only because the coin was spent, so
1075/// [`spent_height`](WalletCoinRecord::spent_height) MUST be non-null here. A spend reporting an
1076/// unspent coin is a contradiction the shape cannot forbid, so the contract forbids it instead.
1077#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1078pub struct WalletCoinSpend {
1079    /// The coin this spend consumed. Its `spent_height` MUST be non-null (see the type docs).
1080    pub coin: WalletCoinRecord,
1081    /// The puzzle reveal: lowercase hex of the serialized CLVM program. MUST tree-hash to
1082    /// [`coin.puzzle_hash`](WalletCoinRecord::puzzle_hash).
1083    pub puzzle_reveal: String,
1084    /// The solution the puzzle was run with: lowercase hex of the serialized CLVM.
1085    pub solution: String,
1086}
1087
1088/// `control.wallet.coinSpend` — the spend that spent one coin, named by that coin's id.
1089///
1090/// # `spend: null` is an ANSWER with TWO honest causes; an unreachable chain is an ERROR
1091///
1092/// `null` means a chain WAS consulted and no spend of that coin exists there — either because the
1093/// coin is UNSPENT, or because the chain holds no such coin at all. Both are legitimately "there is
1094/// no spend", and the contract deliberately does not distinguish them here: a caller that needs to
1095/// tell them apart asks [`WalletCoinByIdResult`], whose `coin: null` separates the two.
1096///
1097/// What `null` NEVER means is that the node could not answer. That is a catalogued error
1098/// ([`WalletNoChainSource`](crate::error::ControlErrorCode::WalletNoChainSource) /
1099/// [`WalletReadFailed`](crate::error::ControlErrorCode::WalletReadFailed) /
1100/// [`WalletRateLimited`](crate::error::ControlErrorCode::WalletRateLimited)). The three-valued
1101/// distinction is money-critical: a caller following a singleton forward reads "no spend" as *this
1102/// is the current tip* and stops walking. Collapsing "could not answer" into it makes a stale coin
1103/// look like the tip, and a spend built against a superseded singleton is invalid.
1104///
1105/// # A negative answer requires a view that could have held the spend
1106///
1107/// `spend: null` is a VERDICT, and the same rule [`WalletCoinByIdResult`] states applies unchanged: a
1108/// node whose replica is still catching up, or whose index is address-scoped rather than a full
1109/// chain view, has established only that IT cannot see the spend. Such a node MUST return
1110/// `WalletNoChainSource` / `WalletReadFailed` and MUST NOT answer `null`.
1111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1112pub struct WalletCoinSpendResult {
1113    /// The spend, or `null` when the consulted chain holds no spend of that coin (see the type docs).
1114    ///
1115    /// The key MUST be present. `null` is a verdict here, so an ABSENT key must not decode into one —
1116    /// the same reason [`WalletCoinByIdResult::coin`] is required.
1117    #[serde(deserialize_with = "required_option")]
1118    pub spend: Option<WalletCoinSpend>,
1119    /// Which tier answered, or `None` from a node too old to disclose it. See [`WalletReadSource`].
1120    pub source: Option<WalletReadSource>,
1121    /// Whether this answer reflects a caught-up view of the tier that ANSWERED, measured against
1122    /// that tier's own peak — never against the node's replica or its held peers.
1123    ///
1124    /// `true` only when that peak came from the SAME read that produced these figures; a carried-over
1125    /// peak means `false`.
1126    pub synced: bool,
1127    /// The peak height of the tier that ANSWERED, or `null` when that tier tracks no peak.
1128    ///
1129    /// It MUST be the height that tier reported in the SAME read that produced the figures, or `null`.
1130    pub peak_height: Option<u32>,
1131}
1132
1133/// `control.wallet.coinsByParent` — the DIRECT children created by spending one coin.
1134///
1135/// # ONE hop, never a walk
1136///
1137/// The list is the coins the named parent's spend created, and nothing further. It is not a lineage,
1138/// not a subtree, and not transitive: a grandchild appears only when the caller asks again with the
1139/// child's id. A node MUST NOT recurse — an unbounded server-side walk over caller-supplied input is
1140/// work the caller cannot bound, and a partial walk returned as if complete would be a lineage with
1141/// a silent hole in it.
1142///
1143/// # A page, and it says so — the truncation rule
1144///
1145/// [`coins`](Self::coins) is ONE PAGE of the parent's children, bounded by
1146/// [`COINS_BY_PARENT_MAX_LIMIT`](crate::params::COINS_BY_PARENT_MAX_LIMIT). Whether it is the WHOLE
1147/// child set is stated by [`complete`](Self::complete) and never left to be inferred from the page's
1148/// length.
1149///
1150/// This is the money-critical shape in this type. A caller walking a lineage reads "no more
1151/// children" as *this branch ends here*, so a page that was truncated but looks whole terminates the
1152/// walk early and presents a partial lineage as a complete one. Inferring completeness from
1153/// `coins.len() < limit` is NOT equivalent and MUST NOT be done: a node is free to return a short
1154/// page for its own reasons, and a child set that is an exact multiple of the page size makes the
1155/// last full page indistinguishable from a truncated one.
1156///
1157/// # Resuming: the same lesson `control.wallet.arrivals` records
1158///
1159/// Resume from [`cursor`](Self::cursor) — the last child you were actually HANDED — by passing it
1160/// as [`after_coin_id`](crate::params::WalletCoinsByParentParams::after_coin_id). There is
1161/// deliberately no "where the chain got to" marker on this type to reach for instead; that is the
1162/// distinction `WalletArrivalsResult::latest` exists to warn about, and the cheapest way not to lose
1163/// a row to it is to give a caller nothing else to resume from.
1164///
1165/// # The order is part of the contract, because paging is meaningless without one
1166///
1167/// A node MUST return children in ASCENDING `coin_id` order, and MUST keep that order stable across
1168/// the pages of one walk. `after_coin_id` means *strictly after this id in that order*. Without a
1169/// fixed order a cursor names no position, and a walk would silently repeat some children and skip
1170/// others. Coin ids are fixed-length lowercase hex, so ascending lexicographic order and ascending
1171/// 32-byte numeric order are the SAME order — an implementation may use whichever it has, and the
1172/// two can never disagree.
1173///
1174/// # An empty list is an ANSWER, never a fallback
1175///
1176/// `coins: []` means the node consulted a chain and that parent created no children it knows of —
1177/// typically because the parent is unspent. It is NEVER what a caller gets when the chain could not
1178/// be reached: those are the catalogued errors
1179/// ([`WalletNoChainSource`](crate::error::ControlErrorCode::WalletNoChainSource) /
1180/// [`WalletReadFailed`](crate::error::ControlErrorCode::WalletReadFailed) /
1181/// [`WalletRateLimited`](crate::error::ControlErrorCode::WalletRateLimited)). The distinction is the
1182/// same one every read in this family carries, and it matters most here: a caller walking a
1183/// singleton forward reads an empty list as *this is the tip*.
1184///
1185/// # `asset` is `null` on every record
1186///
1187/// A child is named by its parent, not by an address and not by an asset, so this read classifies
1188/// nothing — exactly like [`WalletCoinByIdResult`]. Every record MUST report
1189/// [`asset`](WalletCoinRecord::asset) as `null` rather than assert a class the read never verified.
1190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1191pub struct WalletCoinsByParentResult {
1192    /// One page of the parent's direct children, ascending by `coin_id`, possibly empty. One hop
1193    /// only, and NOT necessarily the whole child set — see [`complete`](Self::complete).
1194    pub coins: Vec<WalletCoinRecord>,
1195    /// Is this page the WHOLE child set?
1196    ///
1197    /// `true` means every child the node knows of is in [`coins`](Self::coins) and the walk of this
1198    /// hop is finished. `false` means the answer was TRUNCATED and more children exist — resume from
1199    /// [`cursor`](Self::cursor).
1200    ///
1201    /// Required on the wire, and stated positively so that the reading a caller falls into when the
1202    /// field is absent or defaulted is the SAFE one. A boolean spelled `truncated` would default to
1203    /// `false`, i.e. to "this is everything", which is the claim that ends a lineage walk early;
1204    /// `complete` defaults to "there may be more", which costs at worst one redundant request.
1205    pub complete: bool,
1206    /// The last child in this page — **the value to resume from** — or `null` for an empty page.
1207    ///
1208    /// It is the id the caller was HANDED, never a marker for where the chain got to. Pass it as
1209    /// [`after_coin_id`](crate::params::WalletCoinsByParentParams::after_coin_id) to fetch the next
1210    /// page.
1211    ///
1212    /// The key MUST be present. `null` is meaningful here — it says this page carried nothing — so
1213    /// an ABSENT key must not decode into it: serde's default treatment of `Option` would let a
1214    /// truncated or mis-routed payload decode into a confident "there was nothing to resume from".
1215    #[serde(deserialize_with = "required_option")]
1216    pub cursor: Option<String>,
1217    /// Which tier answered, or `None` from a node too old to disclose it. See [`WalletReadSource`].
1218    pub source: Option<WalletReadSource>,
1219    /// Whether these children reflect a caught-up view of the tier that ANSWERED, measured against
1220    /// that tier's own peak — never against the node's replica or its held peers.
1221    ///
1222    /// `true` only when that peak came from the SAME read that produced these figures; a carried-over
1223    /// peak means `false`.
1224    pub synced: bool,
1225    /// The peak height of the tier that ANSWERED, or `null` when that tier tracks no peak.
1226    ///
1227    /// It MUST be the height that tier reported in the SAME read that produced the figures, or `null`.
1228    pub peak_height: Option<u32>,
1229}
1230
1231/// `control.wallet.peak` — the node's current chain peak height.
1232///
1233/// `peak_height: null` is an honest "this node tracks no height yet", not a zero. A caller bounding
1234/// a claimed confirmation MUST treat it as unknown rather than as height 0, which every block is
1235/// trivially above.
1236///
1237/// # This `synced` is the WEAKER of the contract's two same-named notions
1238///
1239/// [`synced`](Self::synced) here reports only that the replica's initial catch-up COMPLETED. It says
1240/// nothing about whether the wallet is still connected to a Chia peer, so a node that caught up
1241/// yesterday and has been offline since still reports `synced: true` beside a height that stopped
1242/// moving. [`WalletSyncStatusResult::phase`] answers the stronger question — *is this being kept
1243/// current?* — and `WalletSyncPhase::Synced` therefore IMPLIES this flag while this flag does not
1244/// imply that phase. The two are stated in terms of each other on purpose: they carry the same word
1245/// and would otherwise drift apart silently.
1246///
1247/// # The height is the last EXISTING block
1248///
1249/// It is the height of the last block the peer view reported, never a next-block height. A consumer
1250/// computing confirmation depth must floor its own arithmetic rather than assume a convention — see
1251/// [`WalletSyncStatusResult`], which records why.
1252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1253pub struct WalletPeakResult {
1254    /// The peak block height the node's chain view has reached, or `null` when it has none.
1255    pub peak_height: Option<u32>,
1256    /// Whether the node's own chain replica COMPLETED its catch-up. Weaker than
1257    /// [`WalletSyncPhase::Synced`] — see the type docs.
1258    pub synced: bool,
1259}
1260
1261/// Where the node's OWN operator wallet lives on chain — the MACHINE wallet, not the user's.
1262///
1263/// # Two wallets, and confusing them costs real money
1264///
1265/// A node has an operator wallet of its own, derived from a machine-custody autoseed. It is the
1266/// wallet that pays mirror-coin collateral, and it is NOT the wallet whose keys the user holds and
1267/// whose addresses they watch. Nothing on any surface named which wallet a mirror figure was about,
1268/// and the cost of that was measured: a node reported three mirror bonds `unfunded, short 1010`
1269/// while the operator's OWN wallet held 1,015,000 base units of $DIG. Both statements were true and
1270/// they were about different wallets. This method exists so a client can name the second one, and
1271/// so somebody wanting to fund the machine wallet can find out where to send the money.
1272///
1273/// # The custody boundary (§908) is the whole design of this result
1274///
1275/// It carries an ADDRESS and a PUZZLE HASH and nothing else, and it never may carry more. Both are
1276/// public values in exactly the sense a coin id or an amount is: they say WHERE money can be sent,
1277/// never HOW it can be spent. A seed, a mnemonic, a private key, an extended key, a derivation path
1278/// with an index, or any other material from which a spend could be authorised MUST NOT appear
1279/// here, in any form, however encoded. The node signs its own mirror spends and no key ever leaves
1280/// it; a method that exported one would move the node from machine custody to no custody at all.
1281///
1282/// # It is TOKEN-GATED and it is answered by THIS node
1283///
1284/// Gated rather than open because the address links this specific node to a chain identity, and a
1285/// stranger able to ask any node for that mapping learns something about its operator that no other
1286/// open read discloses. Owned rather than delegated because the question is *which wallet does THIS
1287/// node spend from* — forwarding it upstream would return a different machine's address, which is
1288/// precisely the confusion the method exists to end.
1289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1290#[serde(tag = "state", rename_all = "snake_case")]
1291pub enum WalletOperatorAddressResult {
1292    /// The node knows its operator wallet and reports where it is.
1293    Known {
1294        /// The bech32m address, `xch1…` on mainnet — the value a person pastes into a wallet to
1295        /// send this node money.
1296        address: String,
1297        /// The same destination as a puzzle hash: LOWERCASE 64-hex, unprefixed.
1298        ///
1299        /// Beside the address rather than instead of it, because a client that must match this
1300        /// wallet against a coin record is comparing puzzle hashes, and re-deriving one from an
1301        /// address is a bech32m decode a consumer should not have to reimplement to answer *is
1302        /// this coin the machine wallet's?*
1303        puzzle_hash: String,
1304    },
1305    /// The node cannot say where its operator wallet is.
1306    ///
1307    /// A DEFINITE statement that the answer is unavailable, with the reason — never an empty string
1308    /// or a placeholder address. An address a client renders is an address somebody may send money
1309    /// to, so a fabricated or blank one is a money statement of the worst kind.
1310    Unavailable {
1311        /// Why.
1312        reason: WalletOperatorAddressUnavailableReason,
1313    },
1314}
1315
1316/// Why a node cannot name its own operator wallet.
1317///
1318/// Two reasons, and they call for different responses: one is a node that has not finished setting
1319/// itself up, the other is a node whose machine custody is broken.
1320#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1321#[serde(rename_all = "snake_case")]
1322pub enum WalletOperatorAddressUnavailableReason {
1323    /// The operator wallet has not been created yet.
1324    ///
1325    /// Nothing is wrong. A node that has never run its autoseed setup has no operator wallet, and
1326    /// therefore no address; it will have one. A client MUST NOT present this as a fault.
1327    NotInitialized,
1328    /// The operator wallet exists but this node could not read it.
1329    ///
1330    /// A fault: the seed material is present and unreadable, or its unseal failed. The node cannot
1331    /// pay mirror collateral in this state either, so a client SHOULD surface it.
1332    Unreadable,
1333}
1334
1335/// How far the node's wallet chain replica has got — the states a background sync can be in.
1336///
1337/// Named states rather than a boolean, because "has never started" and "is caught up" are different
1338/// facts and a `bool` can only carry one of them. Paired with a `peak_height` a boolean forces a
1339/// never-started wallet to report some height, and 0 is the only one available — which reads as
1340/// *synced to the genesis block*, a claim about the chain that is simply false.
1341///
1342/// # Nothing to watch is TWO states, not one
1343///
1344/// A sync with no addresses to follow is idle for one of two reasons, and they are different
1345/// sentences to a user with different remedies. [`NoWalletEnrolled`](Self::NoWalletEnrolled) is the
1346/// honest all-clear: there is no wallet, so watching nothing is correct and complete.
1347/// [`WalletNotUnlocked`](Self::WalletNotUnlocked) is the opposite — a wallet EXISTS and is not being
1348/// watched — and reporting it as the all-clear tells a user with real coins that their balance is
1349/// fully accounted for while the node follows none of their addresses. Merging the two would put a
1350/// money-lie behind a green tick, so the contract keeps them apart.
1351///
1352/// # An unrecognised token is a VALUE, not a parse failure
1353///
1354/// [`Unrecognized`](Self::Unrecognized) exists because this enum was once closed, and a node that
1355/// grew a new phase took every consumer's whole response down with it — see the variant's own docs.
1356/// Consumers MUST treat an unrecognised phase as *unknown*, never as progress.
1357#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1358pub enum WalletSyncPhase {
1359    /// No sync has begun: the wallet holds no replica of the chain and is not building one.
1360    NotStarted,
1361    /// A sync is running — either the initial catch-up, or the ongoing task that keeps the replica
1362    /// current. A wallet whose catch-up finished but whose peer connections have all dropped is
1363    /// `Syncing`, not [`Synced`](Self::Synced): it is trying to be current and is not.
1364    Syncing,
1365    /// The initial catch-up completed AND at least one Chia peer connection is currently live: the
1366    /// replica is caught up and CONNECTED, so it is in a position to be kept current.
1367    ///
1368    /// That is what the predicate delivers, and no more. A live connection to a stalled or lagging
1369    /// peer satisfies it while the replica quietly goes stale, so this phase MUST NOT be read as
1370    /// proof that the data is FRESH — only that nothing is known to be preventing freshness.
1371    Synced,
1372    /// **The honest all-clear: no wallet is enrolled on this node**, so there are no addresses to
1373    /// follow and a sync would have nothing to do. Not a degraded state and not an error — a node
1374    /// that has never had a wallet is working exactly as intended.
1375    ///
1376    /// A consumer MAY present this as settled. It is the ONLY nothing-to-watch phase for which that
1377    /// is true: [`WalletNotUnlocked`](Self::WalletNotUnlocked) looks identical from inside the sync
1378    /// loop and means the opposite.
1379    ///
1380    /// [`watched_addresses`](WalletSyncStatusResult::watched_addresses) accompanying this phase is
1381    /// `Some(0)` — an observed zero, and the zero that is genuinely fine.
1382    NoWalletEnrolled,
1383    /// **A wallet IS enrolled, but the node holds no addresses for it, so it is watching nothing.**
1384    /// The user's coins are not being followed and their balance is not being maintained.
1385    ///
1386    /// This is the common state after every restart, because the address set is derived from key
1387    /// material the node cannot reach until the wallet is unlocked, and nothing back-fills it while
1388    /// locked. It is emphatically NOT [`NoWalletEnrolled`](Self::NoWalletEnrolled): the difference
1389    /// between them is the difference between *nothing to do* and *something to do that is not being
1390    /// done*.
1391    ///
1392    /// A consumer MUST NOT render this as synced, settled, or up to date, and MUST NOT present a
1393    /// balance read under it as complete. The honest rendering names the wallet and the remedy —
1394    /// *"locked, so it is not being watched yet"* — because unlocking is the action that resolves
1395    /// it.
1396    ///
1397    /// The name says NOT UNLOCKED rather than *locked* on purpose. An empty address set is what the
1398    /// node can observe; a lock is only the usual cause of it, and a manifest that never carried the
1399    /// keys reaches the same state without anything having been locked. The phase claims the
1400    /// observation, and leaves the cause to whatever the node can actually establish.
1401    WalletNotUnlocked,
1402    /// **A phase token this build does not know**, carried verbatim.
1403    ///
1404    /// # Why this variant exists
1405    ///
1406    /// The enum shipped closed. dig-node then grew a phase, and because serde rejects an unknown
1407    /// variant, the unknown token did not degrade one field — it aborted the entire
1408    /// [`WalletSyncStatusResult`]. dig-app's sync read became `Err`, its chain-sync state collapsed
1409    /// to unknown, and the surface rendered nothing at all (dig_ecosystem#2609). Every consumer
1410    /// built against an older contract than the node it talks to hit it at once.
1411    ///
1412    /// # It is deliberately NOT silent
1413    ///
1414    /// The token is preserved rather than discarded so the state is *observable*: a consumer can say
1415    /// which token it failed to understand, and a developer can read it out of a log instead of
1416    /// reaching for a packet capture. This incident stayed invisible until somebody built a probe
1417    /// against the published crate; the variant that replaces it should not need one.
1418    ///
1419    /// Mapping an unknown token onto [`Synced`](Self::Synced) or [`Syncing`](Self::Syncing) would be
1420    /// far worse than the parse error it replaces. A parse error is loud and obviously wrong; a
1421    /// coerced phase is a confident, plausible statement about the user's money that the node never
1422    /// made. Consumers MUST render this as unknown and MUST NOT infer progress, completion, or a
1423    /// trustworthy balance from it.
1424    ///
1425    /// # The payload is untrusted text
1426    ///
1427    /// It is whatever the node sent. A consumer that displays it MUST escape and bound it like any
1428    /// other foreign string rather than splicing it into a message unchecked. `Debug` escapes it, as
1429    /// `String`'s always has; [`as_wire`](Self::as_wire) deliberately does not, because a relay must
1430    /// be able to hand on the exact bytes.
1431    ///
1432    /// # Not the same idea as [`PeerSoftware::Unknown`]
1433    ///
1434    /// The two look alike and are not. `PeerSoftware::Unknown` is the ABSENCE of a report — the peer
1435    /// said nothing, or said something unparseable, and there is no datum to keep. Here the node DID
1436    /// report, and the token it used is a real observation this build cannot interpret. That is why
1437    /// this variant carries a payload and that one does not, and why the names differ: calling it
1438    /// `Unknown` would suggest nothing was said.
1439    Unrecognized(UnknownPhaseToken),
1440}
1441
1442/// A phase token this build does not recognise, held so it cannot be confused with one it does.
1443///
1444/// # Why the payload is a type and not a bare `String`
1445///
1446/// [`WalletSyncPhase::Unrecognized`] serializes whatever it holds. With a public `String` inside,
1447/// `Unrecognized("synced".to_owned())` was constructible by any consumer, reported
1448/// `is_recognized() == false` locally, went onto the wire as the bare token `"synced"`, and arrived
1449/// at the far side as a confident [`WalletSyncPhase::Synced`] — a value that claims the wallet is
1450/// caught up while calling itself unrecognised. It was also the one value in the type that did not
1451/// round-trip, contradicting the verbatim-carriage guarantee the variant exists to provide.
1452///
1453/// The field is private and this type has no public constructor, so the only way to reach
1454/// `Unrecognized` from outside the crate is [`WalletSyncPhase::from`], which is TOTAL: hand it a
1455/// known spelling and it returns that known variant instead. The dishonest value is therefore not
1456/// merely discouraged — it cannot be built.
1457///
1458/// This is deliberately a type-level guard rather than a documented rule. The whole family exists
1459/// because a wire-level mismatch went unnoticed until someone built a probe, and a rule that only a
1460/// doc comment enforces is the same shape of mistake one layer up.
1461///
1462/// # The seal is guarded by a test that can actually see it removed
1463///
1464/// The ordinary unit tests cannot. They reach `Unrecognized` only through
1465/// [`WalletSyncPhase::from`], and the seal is precisely what determines which values that route can
1466/// produce — so making this field `pub` again leaves every one of them green while the forged
1467/// value becomes constructible. Measured: the whole suite passed with the field public.
1468///
1469/// A doctest is the instrument that works, because doctests compile as a SEPARATE CRATE and
1470/// therefore see this type exactly as a consumer does. The one below must FAIL to compile; if the
1471/// field is ever made public it starts compiling, and `cargo test` reports the doctest as failed.
1472///
1473/// ```compile_fail
1474/// use dig_node_control_interface::results::{UnknownPhaseToken, WalletSyncPhase};
1475/// // A value that calls itself unrecognised while spelling itself `synced` on the wire.
1476/// let forged = WalletSyncPhase::Unrecognized(UnknownPhaseToken("synced".to_owned()));
1477/// ```
1478///
1479/// The honest route returns the KNOWN variant instead, which is the whole point:
1480///
1481/// ```
1482/// use dig_node_control_interface::results::WalletSyncPhase;
1483/// assert_eq!(WalletSyncPhase::from("synced"), WalletSyncPhase::Synced);
1484/// assert!(WalletSyncPhase::from("synced").is_recognized());
1485/// ```
1486#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1487pub struct UnknownPhaseToken(String);
1488
1489impl UnknownPhaseToken {
1490    /// The token's RAW bytes, exactly as the node sent them — the relay path.
1491    ///
1492    /// This is the escape hatch, not the default. It exists so a proxy can hand the token on
1493    /// byte-identically, and it is the ONE accessor that returns unescaped node-supplied text. Do
1494    /// not route it to a terminal, a log line, or a UI: use [`Display`](Self#impl-Display) or
1495    /// [`display_bounded`](Self::display_bounded), which escape.
1496    ///
1497    /// ```
1498    /// use dig_node_control_interface::results::WalletSyncPhase;
1499    /// let phase = WalletSyncPhase::from("a_newer_token");
1500    /// assert_eq!(phase.unrecognized_token(), Some("a_newer_token"));
1501    /// ```
1502    pub fn as_str(&self) -> &str {
1503        &self.0
1504    }
1505
1506    /// The token escaped for display and truncated to `max_len` bytes of escaped output.
1507    ///
1508    /// What [`Display`](Self#impl-Display) does, plus a length bound — for a log line or a UI label
1509    /// that must not be handed an unbounded string. Nothing bounds a token's length on the wire (the
1510    /// contract is transport-agnostic, and rejecting an over-long token would reintroduce the
1511    /// fail-closed parse this type exists to remove), so the bound belongs at the point of display.
1512    ///
1513    /// The escaped content is at most `max_len` bytes. A single `…` is appended when anything was
1514    /// dropped, so a truncated rendering is never mistaken for the whole token.
1515    ///
1516    /// ```
1517    /// use dig_node_control_interface::results::WalletSyncPhase;
1518    /// let phase = WalletSyncPhase::from("a_very_long_token_from_a_newer_node");
1519    /// let token = phase.unrecognized_token_value().unwrap();
1520    /// assert_eq!(token.display_bounded(10), "a_very_lon…");
1521    /// ```
1522    pub fn display_bounded(&self, max_len: usize) -> String {
1523        let mut rendered = String::new();
1524        let mut dropped = false;
1525
1526        for character in self.0.chars() {
1527            let escaped: String = character.escape_debug().collect();
1528            if rendered.len() + escaped.len() > max_len {
1529                dropped = true;
1530                break;
1531            }
1532            rendered.push_str(&escaped);
1533        }
1534        if dropped {
1535            rendered.push('…');
1536        }
1537        rendered
1538    }
1539}
1540
1541impl std::fmt::Display for UnknownPhaseToken {
1542    /// The token ESCAPED — the safe default, because this is the accessor a log line reaches for.
1543    ///
1544    /// # Why the default escapes rather than the opposite
1545    ///
1546    /// The raw token is attacker-influenced text that is designed to be logged, and a node emitting
1547    /// `"\u{1b}[2K\rsynced"` turns `format!("unknown phase: {token}")` into a terminal line reading
1548    /// `synced` — the erase-line and carriage-return wipe the prefix that said it was unknown. A
1549    /// right-to-left override does the same to a UI label. Making the ergonomic path raw and the
1550    /// safe path opt-in gets that backwards: every consumer would have to remember, and one
1551    /// forgetting reproduces the exact false-reassurance this family exists to prevent.
1552    ///
1553    /// `char::escape_debug` is the escaper because it is the standard library's own, covering C0/C1
1554    /// controls, `DEL`, and the format characters that carry bidi overrides. A hand-rolled table
1555    /// here would be a second implementation of a security-relevant rule, and would drift.
1556    ///
1557    /// [`as_str`](Self::as_str) remains raw for relaying; [`display_bounded`](Self::display_bounded)
1558    /// adds a length bound.
1559    ///
1560    /// ```
1561    /// use dig_node_control_interface::results::WalletSyncPhase;
1562    /// let phase = WalletSyncPhase::from("\u{1b}[2K\rsynced");
1563    /// let token = phase.unrecognized_token_value().unwrap();
1564    /// assert_eq!(token.to_string(), "\\u{1b}[2K\\rsynced");
1565    /// ```
1566    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1567        for character in self.0.chars() {
1568            write!(f, "{}", character.escape_debug())?;
1569        }
1570        Ok(())
1571    }
1572}
1573
1574impl WalletSyncPhase {
1575    /// Every phase this build KNOWS, in progress order — the enumeration a machine reads, and the
1576    /// anchor the conformance KATs pin the wire tokens against.
1577    ///
1578    /// [`Unrecognized`](Self::Unrecognized) is absent by definition: it is the absence of a known
1579    /// token rather than one of them, and it has no fixed wire spelling to pin. A node MUST NOT emit
1580    /// anything outside this list; a consumer that meets something outside it gets `Unrecognized`
1581    /// instead of a failed response.
1582    pub const ALL: &'static [WalletSyncPhase] = &[
1583        WalletSyncPhase::NotStarted,
1584        WalletSyncPhase::Syncing,
1585        WalletSyncPhase::Synced,
1586        WalletSyncPhase::NoWalletEnrolled,
1587        WalletSyncPhase::WalletNotUnlocked,
1588    ];
1589
1590    /// This phase's exact wire spelling, or the verbatim token for
1591    /// [`Unrecognized`](Self::Unrecognized).
1592    ///
1593    /// The one place a phase becomes a string, so serialization and any display path cannot drift
1594    /// into two different spellings of the same state.
1595    pub fn as_wire(&self) -> &str {
1596        match self {
1597            WalletSyncPhase::NotStarted => "not_started",
1598            WalletSyncPhase::Syncing => "syncing",
1599            WalletSyncPhase::Synced => "synced",
1600            WalletSyncPhase::NoWalletEnrolled => "no_wallet_enrolled",
1601            WalletSyncPhase::WalletNotUnlocked => "wallet_not_unlocked",
1602            WalletSyncPhase::Unrecognized(token) => token.as_str(),
1603        }
1604    }
1605
1606    /// The token a build does not understand, or `None` for every phase it does.
1607    ///
1608    /// Lets a consumer log or surface the exact unrecognised spelling without matching the variant
1609    /// open-coded, which is how the two spellings drift apart.
1610    pub fn unrecognized_token(&self) -> Option<&str> {
1611        match self {
1612            WalletSyncPhase::Unrecognized(token) => Some(token.as_str()),
1613            _ => None,
1614        }
1615    }
1616
1617    /// Whether this build understands the phase at all.
1618    ///
1619    /// The predicate a consumer branches its *"your node may be newer than this app"* path on.
1620    pub fn is_recognized(&self) -> bool {
1621        !matches!(self, WalletSyncPhase::Unrecognized(_))
1622    }
1623
1624    /// The unrecognised token as its own type, giving access to the escaped renderings.
1625    ///
1626    /// [`unrecognized_token`](Self::unrecognized_token) hands back a raw `&str`; this hands back the
1627    /// [`UnknownPhaseToken`], whose `Display` escapes and whose
1628    /// [`display_bounded`](UnknownPhaseToken::display_bounded) also truncates.
1629    pub fn unrecognized_token_value(&self) -> Option<&UnknownPhaseToken> {
1630        match self {
1631            WalletSyncPhase::Unrecognized(token) => Some(token),
1632            _ => None,
1633        }
1634    }
1635
1636    /// Whether a consumer may present this phase as SETTLED — nothing outstanding, nothing to do.
1637    ///
1638    /// # Why this is a method and not a rule in the docs
1639    ///
1640    /// Two phases mean "the sync is idle" and only one of them is good news.
1641    /// [`NoWalletEnrolled`](Self::NoWalletEnrolled) is complete and correct;
1642    /// [`WalletNotUnlocked`](Self::WalletNotUnlocked) is a wallet whose coins nobody is following.
1643    /// Rendering the second as settled is the money-lie this family exists to prevent, and it is one
1644    /// mistaken `||` away in every consumer that writes the rule itself.
1645    ///
1646    /// Stating it once here makes it a compiler-checked fact rather than a paragraph each consumer
1647    /// re-derives — a second implementation of a rule like this is a drift bug waiting to happen.
1648    /// An unrecognised phase is never settled: this build cannot know what the node meant.
1649    ///
1650    /// ```
1651    /// use dig_node_control_interface::results::WalletSyncPhase;
1652    /// assert!(WalletSyncPhase::Synced.may_render_as_settled());
1653    /// assert!(WalletSyncPhase::NoWalletEnrolled.may_render_as_settled());
1654    /// // A wallet exists and nothing is watching it — never settled.
1655    /// assert!(!WalletSyncPhase::WalletNotUnlocked.may_render_as_settled());
1656    /// assert!(!WalletSyncPhase::from("a_newer_token").may_render_as_settled());
1657    /// ```
1658    pub fn may_render_as_settled(&self) -> bool {
1659        // An exhaustive match, not a `matches!`: a phase added later must be classified here
1660        // deliberately, and the compiler is what forces that rather than a reviewer noticing.
1661        match self {
1662            WalletSyncPhase::Synced | WalletSyncPhase::NoWalletEnrolled => true,
1663            WalletSyncPhase::NotStarted
1664            | WalletSyncPhase::Syncing
1665            | WalletSyncPhase::WalletNotUnlocked
1666            | WalletSyncPhase::Unrecognized(_) => false,
1667        }
1668    }
1669}
1670
1671impl From<&str> for WalletSyncPhase {
1672    /// Every token maps to a phase — an unknown one to
1673    /// [`Unrecognized`](WalletSyncPhase::Unrecognized). Total by construction, so no caller can
1674    /// reintroduce the fail-closed behaviour this type exists to remove.
1675    fn from(token: &str) -> Self {
1676        match token {
1677            "not_started" => WalletSyncPhase::NotStarted,
1678            "syncing" => WalletSyncPhase::Syncing,
1679            "synced" => WalletSyncPhase::Synced,
1680            "no_wallet_enrolled" => WalletSyncPhase::NoWalletEnrolled,
1681            "wallet_not_unlocked" => WalletSyncPhase::WalletNotUnlocked,
1682            other => WalletSyncPhase::Unrecognized(UnknownPhaseToken(other.to_owned())),
1683        }
1684    }
1685}
1686
1687impl Serialize for WalletSyncPhase {
1688    /// A bare JSON string, exactly as the derived `rename_all = "snake_case"` produced before this
1689    /// type grew an unrecognised arm — so an [`Unrecognized`](WalletSyncPhase::Unrecognized) token
1690    /// round-trips back out byte-identical rather than being rewritten or dropped by a relay.
1691    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1692        serializer.serialize_str(self.as_wire())
1693    }
1694}
1695
1696impl<'de> Deserialize<'de> for WalletSyncPhase {
1697    /// Accepts ANY string. A non-string is still a type error — a number or an object where a phase
1698    /// belongs is a malformed response, not a newer node.
1699    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1700        let token = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
1701        Ok(WalletSyncPhase::from(token.as_ref()))
1702    }
1703}
1704
1705/// `control.wallet.syncStatus` — is the wallet's chain replica being kept current, how far has it
1706/// got, and how many Chia peers is it using?
1707///
1708/// # `Synced` means CAUGHT UP AND CONNECTED, not ONCE CAUGHT UP
1709///
1710/// [`phase`](Self::phase) is [`WalletSyncPhase::Synced`] only when the initial catch-up completed
1711/// AND at least one Chia peer connection is live right now. A wallet that caught up yesterday and
1712/// has been offline since MUST report [`Syncing`](WalletSyncPhase::Syncing). This makes `phase ==
1713/// Synced` STRICTLY STRONGER than [`WalletPeakResult::synced`], which reflects only the
1714/// completed-catch-up flag: `Synced` implies that flag, the flag does not imply `Synced`. Both types
1715/// say so, because the two notions share a word and nothing but the docs would keep them aligned.
1716///
1717/// This is the whole reason the method exists. A surface asking *does my wallet stay synced?* cannot
1718/// be answered by a flag that a disconnected wallet still sets.
1719///
1720/// **`Synced` is nevertheless not a freshness guarantee.** Being connected is not being up to date:
1721/// a live connection to a stalled or lagging peer satisfies the predicate while the replica goes
1722/// stale. The phase reports that catch-up finished and a peer is attached — that nothing KNOWN is
1723/// preventing the replica from being kept current — and a consumer needing actual freshness must
1724/// compare [`peak_height`](Self::peak_height) against something, not read this phase. Stating the
1725/// limit is the point: this family exists because a surface asserted more than it knew.
1726///
1727/// # The height NEVER comes from a third-party oracle
1728///
1729/// [`peak_height`](Self::peak_height) is the node's OWN replica's height or `null`. It MUST NOT fall
1730/// back to the coinset oracle. `control.wallet.peak` deliberately does fall back, because it answers
1731/// a different question — *what height is the chain at?* — whereas this field answers *how far has
1732/// this replica got?* An oracle's height here would report a caller's own sync progress using a
1733/// number the replica never reached, which is precisely the reading a progress display makes.
1734///
1735/// # `chia_peer_count: 0` is a disambiguator, not a phase
1736///
1737/// A sync that is running while connected to nothing reports `Syncing` with a count of `0`, and a
1738/// consumer SHOULD render the count alongside the phase for exactly that reason: "syncing — no
1739/// peers" is honest where a bare "syncing" implies progress that is not happening. `null` means the
1740/// node cannot observe the count at all and licenses no claim about connectivity either way.
1741///
1742/// # `watched_addresses` is what makes an idle sync readable
1743///
1744/// A sync following nothing is idle, and the phase alone does not say whether that is correct. The
1745/// count is the second fact that settles it: `0` beside [`WalletSyncPhase::NoWalletEnrolled`] is a
1746/// complete and honest picture, while `0` beside [`WalletSyncPhase::WalletNotUnlocked`] is a wallet
1747/// whose coins nobody is following. A consumer SHOULD render the two together for the same reason it
1748/// renders the peer count beside `Syncing`.
1749///
1750/// `Some(0)` is an OBSERVED zero — the node looked and is following no addresses. `None` means the
1751/// node did not report the number, which is not the same claim and MUST NOT be rendered as zero: a
1752/// node that cannot say how many addresses it follows has not told you that it follows none.
1753///
1754/// A `Synced` phase with `watched_addresses: Some(0)` is a contradiction a conforming node MUST NOT
1755/// emit — a sync following no addresses has not caught anything up. A consumer meeting it SHOULD
1756/// trust the count over the phase, because the count is the narrower claim.
1757///
1758/// # An older node's payload still parses
1759///
1760/// A node that predates `watched_addresses` omits the key, and it deserializes to `None` — *the node
1761/// did not report it*. That tolerance is required, not incidental: a mandatory new field would make
1762/// every older node unreadable to a client that has it, which is dig_ecosystem#2609 in mirror image
1763/// — the same fail-closed break with the old and new sides swapped. A contract that tolerates a
1764/// token from the future must equally tolerate a payload from the past.
1765///
1766/// **Every `Option` field here behaves this way**, because serde decodes a missing `Option` to
1767/// `None`. So `peak_height` and `chia_peer_count` are absent-tolerant too, and have been since this
1768/// type shipped. Only [`phase`](Self::phase) is structurally mandatory. A conforming node MUST still
1769/// emit all four keys — absence is a compatibility allowance for older builds, never a licence to
1770/// omit an observation — and a consumer MUST read an absent count as unreported rather than zero.
1771///
1772/// # These are CHIA peers, not DIG peers
1773///
1774/// [`chia_peer_count`](Self::chia_peer_count) counts CHIA FULL-NODE peers the wallet's chain sync is
1775/// connected to. It is NOT the DIG gossip/content peer count from `control.peerStatus`
1776/// (`connected_peers` / `relay_peer_count`); the two are unrelated numbers that move independently.
1777/// A surface that placed one of them beside a wallet sync status under a bare label of "peers" would
1778/// assert something false — a node with many DIG peers and no Chia peer is a wallet that is not
1779/// syncing at all. A caller that wants BOTH networks' counts reads [`PeerCountsResult`], which is
1780/// the one call that answers for each network by name.
1781///
1782/// # The duplicated field is ONE observation
1783///
1784/// [`chia_peer_count`](Self::chia_peer_count) also appears on [`PeerCountsResult`], and the two are
1785/// the SAME observation: a conforming node MUST serve them from one source, and they MUST agree
1786/// within a single node's view. The field is duplicated rather than moved because it is load-bearing
1787/// HERE — `chia_peer_count: 0` beside `Syncing` is the honest "syncing — no peers" state, and a
1788/// phase separated from its count reads as a contradiction. A DIG content-network count, by
1789/// contrast, is not a wallet fact and does not vary with wallet state, which is why it is absent
1790/// from this type rather than added for symmetry.
1791///
1792/// # Which field combinations are meaningful
1793///
1794/// `{phase: Synced, peak_height: null}` MUST NOT be emitted. A node records its peak BEFORE it marks
1795/// the initial catch-up complete, so a completed catch-up always has a height behind it; a `Synced`
1796/// with no height describes a state a conforming node cannot be in, and a consumer has no honest
1797/// reading for it.
1798///
1799/// `{phase: NotStarted, peak_height: <some height>}` is the opposite case, and is EXPLICITLY
1800/// LEGITIMATE — it is not a contradiction and MUST NOT be "fixed". The height is persisted in the
1801/// wallet database, while the phase describes whether a sync is running IN THIS PROCESS. A node that
1802/// synced yesterday and has just restarted reports exactly this, and reports it truthfully: *here is
1803/// the height I reached, and no sync is running right now.* Forbidding the pair would force a
1804/// conforming node to either fabricate a phase it is not in or discard a height it genuinely has —
1805/// which is the dishonesty this method was created to prevent. `peak_height: null` alongside
1806/// `NotStarted` is equally legitimate and means a wallet that has never synced at all.
1807///
1808/// # No confirmation-depth arithmetic happens here
1809///
1810/// The height recorded is the height of the LAST EXISTING block the peer view reported
1811/// (`NewPeakWallet.height` / `RespondPuzzleState.height` from a real full node). This surface
1812/// performs no depth arithmetic. dig_ecosystem#2483 records that `peak_height`'s meaning differs
1813/// between a simulator (the NEXT height) and a full node (the last existing one), so a consumer
1814/// computing depth must floor its own input rather than assume a convention.
1815#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1816pub struct WalletSyncStatusResult {
1817    /// Which state the wallet's chain sync is in. See [`WalletSyncPhase`].
1818    pub phase: WalletSyncPhase,
1819    /// The replica's own peak height, or `null` when it has none — never height 0 as a stand-in for
1820    /// unknown, and never an oracle's height. See the type docs.
1821    pub peak_height: Option<u32>,
1822    /// How many CHIA full-node peers the sync is connected to. `0` is an observed zero; `null` means
1823    /// the node cannot observe the count. Not the DIG peer count — see the type docs.
1824    pub chia_peer_count: Option<u32>,
1825    /// How many addresses the wallet sync is actually following. `Some(0)` is an observed zero;
1826    /// `None` means the node did not report the number at all — including because it predates the
1827    /// field. See the type docs for why that distinction is load-bearing.
1828    pub watched_addresses: Option<u32>,
1829    /// How many peers the REPLICA's own subscription supervisor is writing through. The supervisor
1830    /// holds AT MOST ONE subscription peer by design, so this is a 0-or-1 fact about whether the
1831    /// replica is currently being kept fed — never a measure of network reach. `None` means no
1832    /// supervisor is attached at all, not that it counted zero.
1833    ///
1834    /// This is deliberately NOT [`chia_peer_count`](Self::chia_peer_count) and MUST NOT be summed
1835    /// with it. Before dig_ecosystem#2806 this crate's `chia_peer_count` carried this narrower
1836    /// number instead of the wallet's true peer count, so a node with five peers serving every read
1837    /// reported `chia_peer_count: 1` — the subscription supervisor's single writer standing in for
1838    /// the whole peer set. The two fields exist side by side so that confusion cannot recur: one
1839    /// counts what the replica is fed BY, the other counts what the wallet's sync is actually
1840    /// CONNECTED to.
1841    pub subscription_peer_count: Option<u32>,
1842    /// The peak height this node's OWN Chia peers have ANNOUNCED — not the replica's own progress
1843    /// (see [`peak_height`](Self::peak_height)) and not any oracle's reading. `None` until at least
1844    /// one peer has said something; never `0`, which every real block height is trivially above and
1845    /// so can never be an honest "unobserved" stand-in.
1846    ///
1847    /// A value here is evidence those peers are live and talking, independent of whether the
1848    /// replica itself has caught up to it.
1849    pub chia_peer_peak_height: Option<u32>,
1850}
1851
1852/// `control.wallet.broadcast` — the outcome of pushing an already-signed bundle.
1853///
1854/// # A rejection is a VALUE; an unreachable network is an ERROR
1855///
1856/// A mempool that looked at the bundle and said no is a successful call with `accepted: false` and
1857/// a [`rejection`](Self::rejection) reason — the bundle was seen and judged. Failing to REACH a
1858/// mempool is a catalogued error instead. Collapsing the two turns "your wifi dropped" into "your
1859/// mint failed", and the remedies are opposite: retry the same bundle, versus build a new one.
1860///
1861/// # Accepted is not confirmed
1862///
1863/// `accepted: true` says the mempool took the bundle. It is not evidence that anything reached a
1864/// block, and a caller must never record an outcome from it — only a buried confirmation of the
1865/// created coin is evidence.
1866#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1867pub struct WalletBroadcastResult {
1868    /// Whether the network accepted the bundle into its mempool.
1869    pub accepted: bool,
1870    /// The transaction id (the spend bundle's name), lowercase 64-hex, when accepted.
1871    pub transaction_id: Option<String>,
1872    /// Why the mempool refused, when it refused. `null` on acceptance.
1873    pub rejection: Option<String>,
1874}
1875
1876/// `control.wallet.watch` — the outcome of enrolling public keys.
1877///
1878/// # Two numbers, because idempotence is only observable with both
1879///
1880/// [`added`](Self::added) counts the keys this call newly enrolled; [`watched`](Self::watched) is the
1881/// size of the whole enrolled set afterwards. A re-enrolment of keys the node already follows is a
1882/// SUCCESS that reports `added: 0` with `watched` unchanged — which is how a client tells "already
1883/// done" from "nothing happened because the request was ignored". A single number could not: a
1884/// caller seeing only the total cannot distinguish its own duplicate call from another client's
1885/// concurrent enrolment.
1886#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1887pub struct WalletWatchResult {
1888    /// How many of the submitted keys were NOT already enrolled and are now.
1889    pub added: u32,
1890    /// How many keys the node follows in total after this call.
1891    pub watched: u32,
1892}
1893
1894/// `control.wallet.unwatch` — the outcome of deregistering public keys.
1895///
1896/// [`removed`](Self::removed) counts the submitted keys that were actually enrolled; a key that was
1897/// never enrolled is not an error, for the same reason a re-enrolment is not one — a client
1898/// reconciling its own state must be able to say "make sure these are gone" without first asking.
1899#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1900pub struct WalletUnwatchResult {
1901    /// How many of the submitted keys were enrolled and are no longer.
1902    pub removed: u32,
1903    /// How many keys the node follows in total after this call.
1904    pub watched: u32,
1905}
1906
1907/// `control.wallet.watched` — the public keys the node currently follows.
1908///
1909/// # No count field
1910///
1911/// The list is the answer, and its length is the count. A separate number could disagree with the
1912/// list it is printed beside, and a client that trusted the number over the rows would reconcile
1913/// against a set that was never sent.
1914#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1915pub struct WalletWatchedResult {
1916    /// The enrolled public keys, lowercase 96-hex and unprefixed — the same wire form
1917    /// [`WalletWatchParams`](crate::params::WalletWatchParams) accepts, so a client can compare what
1918    /// it sent against what came back without normalizing either side.
1919    pub public_keys: Vec<String>,
1920}
1921
1922/// One coin held by a live reservation, and when that hold lapses.
1923///
1924/// The expiry travels WITH the coin rather than being summarised once, because a client's honest
1925/// sentence is per-coin: "this coin is committed until 14:32". A single soonest-expiry figure would
1926/// be right about the set and wrong about every coin in it but one.
1927#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1928pub struct ReservedCoin {
1929    /// The held coin id, lowercase 64-hex and unprefixed.
1930    pub coin_id: String,
1931    /// The reservation holding it — the handle
1932    /// [`release`](crate::params::WalletReservationsReleaseParams) takes. OPAQUE; never parsed.
1933    pub reservation_id: String,
1934    /// Unix seconds after which this hold no longer applies, whether or not anyone releases it.
1935    ///
1936    /// Always present. A hold with no expiry is a permanent funds lockout, so the contract has no
1937    /// way to express one.
1938    pub expires_at_unix: u64,
1939}
1940
1941/// `control.wallet.reservations.held` — every coin currently committed to an in-flight spend.
1942///
1943/// # An empty list means EMPTY, and an error means UNKNOWN
1944///
1945/// `reserved: []` is a positive statement that nothing is held, and a caller may select freely on
1946/// it. A node that cannot read its reservation set answers
1947/// [`WalletReservationsUnavailable`](crate::error::ControlErrorCode::WalletReservationsUnavailable)
1948/// and NEVER an empty list — the two demand opposite actions, and collapsing them restores exactly
1949/// the cross-process double-select this method exists to prevent.
1950///
1951/// # This narrows SELECTION, never BALANCE
1952///
1953/// A reserved coin is still the user's money and still counts toward what they hold. Subtracting
1954/// these from a balance would report a shortfall the user does not have.
1955///
1956/// # No count field
1957///
1958/// The list is the answer and its length is the count. A separate number could disagree with the
1959/// rows printed beside it.
1960#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1961pub struct WalletReservationsHeldResult {
1962    /// The held coins, each with its holding reservation and expiry.
1963    pub reserved: Vec<ReservedCoin>,
1964    /// The node's OWN clock, in unix seconds, at the moment it answered.
1965    ///
1966    /// Reported so a client can measure skew against the `expires_at_unix` values it just received.
1967    /// The caller never supplies a time — see
1968    /// [`WalletReservationsHeldParams`](crate::params::WalletReservationsHeldParams).
1969    pub as_of_unix: u64,
1970}
1971
1972/// `control.wallet.reservations.reserve` — the handle for a hold that was taken in full.
1973///
1974/// Only ever returned when EVERY requested coin was taken. A conflict on any one of them is the
1975/// error [`WalletCoinsReserved`](crate::error::ControlErrorCode::WalletCoinsReserved) and reserves
1976/// nothing, so this type has deliberately no "partially reserved" shape to represent.
1977#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1978pub struct WalletReservationsReserveResult {
1979    /// The handle to release with. OPAQUE — store it and send it back; never parse or derive one.
1980    pub reservation_id: String,
1981    /// The coins now held, lowercase 64-hex, echoed back so a client can compare what it asked for
1982    /// against what it got without re-normalizing either side.
1983    pub coin_ids: Vec<String>,
1984    /// Unix seconds after which this hold lapses on its own.
1985    pub expires_at_unix: u64,
1986    /// The lifetime the node ACTUALLY applied, in seconds — which may be shorter than the
1987    /// `ttl_secs` requested.
1988    ///
1989    /// Returned rather than assumed, because a caller that asked for an hour and silently got ten
1990    /// minutes would release far too late and believe its coins were still held long after they
1991    /// were selectable again.
1992    pub ttl_secs: u64,
1993}
1994
1995/// `control.wallet.reservations.release` — what a release actually freed.
1996///
1997/// # `released: false` is a SUCCESS
1998///
1999/// It means the handle named no live reservation: it lapsed on its TTL first, or was released
2000/// already. Both are the outcome the caller wanted, and reporting them as errors would push callers
2001/// toward ignoring the result — which is how the release path quietly stops being used and every
2002/// hold starts costing its full TTL.
2003#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2004pub struct WalletReservationsReleaseResult {
2005    /// Whether a live reservation was found and freed by THIS call.
2006    pub released: bool,
2007    /// The coins freed by this call — empty when `released` is false.
2008    pub coin_ids: Vec<String>,
2009}
2010
2011/// `control.wallet.resetCoinDb` — how much cache the reset actually discarded.
2012///
2013/// Both counts describe local, chain-derived cache rows dropped, never money lost — every one of
2014/// them is reproduced by the re-sync that follows. `coins_dropped == 0` on an already-empty cache is
2015/// a SUCCESS, not a sign the reset failed to run.
2016#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2017pub struct WalletResetCoinDbResult {
2018    /// Confirmed coin rows discarded from the cache.
2019    pub coins_dropped: u64,
2020    /// Staged (not-yet-confirmed) coin rows discarded from the cache.
2021    pub staged_dropped: u64,
2022}
2023
2024/// `pairing.request` — the pairing handshake bootstrap (OPEN, no token).
2025#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2026pub struct PairingRequestResult {
2027    /// The opaque pairing id to poll with.
2028    pub pairing_id: String,
2029    /// A short numeric code the operator compares before approving.
2030    pub pairing_code: String,
2031    /// When the pending pairing expires, in unix milliseconds.
2032    pub expires_ms: u64,
2033}
2034
2035/// `pairing.poll` — the pairing poll outcome (OPEN, no token).
2036#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2037pub struct PairingPollResult {
2038    /// The pairing status (`"pending"` / `"approved"` / …).
2039    pub status: String,
2040    /// The minted scoped token, present exactly once after approval.
2041    #[serde(skip_serializing_if = "Option::is_none", default)]
2042    pub token: Option<String>,
2043}
2044
2045/// One confirmed incoming payment, as the node's arrival ledger recorded it.
2046///
2047/// Every field is a public chain fact about an address this node already watches. There is
2048/// deliberately no ticker and no formatted amount: naming an asset the node did not attribute, or
2049/// choosing a divisor for it, would be a claim about WHICH money arrived that the node cannot
2050/// support.
2051#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2052pub struct WalletArrivalRecord {
2053    /// This arrival's monotonic ledger position. Strictly increasing and never reused, so a stored
2054    /// position cannot come to mean a different arrival after a reorg.
2055    pub seq: u64,
2056    /// The coin that arrived (lowercase hex).
2057    pub coin_id: String,
2058    /// The watched puzzle hash it arrived at (lowercase hex).
2059    pub puzzle_hash: String,
2060    /// The amount in the asset's own base unit, as a DECIMAL STRING.
2061    ///
2062    /// A string because the ledger stores the full `u64` range and a JSON number does not carry it
2063    /// losslessly — a large mojo amount silently rounds through an f64 parser, which is a wrong
2064    /// figure about somebody's money.
2065    pub amount: String,
2066    /// The CAT asset id (hex TAIL), or `None` for native XCH.
2067    pub asset_id: Option<String>,
2068    /// The height the coin was CONFIRMED at. Never optional: an arrival with no confirmed height is
2069    /// not an arrival, and a node MUST NOT emit a mempool sighting here.
2070    pub confirmed_height: u32,
2071}
2072
2073/// One page of the arrival ledger (`control.wallet.arrivals`).
2074///
2075/// An empty [`arrivals`](Self::arrivals) list is an ANSWER — the node consulted its own replica and
2076/// nothing has arrived since the cursor. It is NOT a claim that the replica is current: a node that
2077/// has never completed a catch-up has no arrival baseline and reports an empty page forever, which
2078/// is the honest answer to "what arrived?" from a wallet that cannot tell history from news. A
2079/// caller that needs to know whether the replica is current asks `control.wallet.syncStatus`.
2080#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2081pub struct WalletArrivalsResult {
2082    /// The page, oldest first.
2083    pub arrivals: Vec<WalletArrivalRecord>,
2084    /// Where the CLIENT got to: the position of the last row in this page, or the caller's own
2085    /// `after_seq` when the page is empty. **This is the value to resume from.**
2086    pub cursor: u64,
2087    /// Where the LEDGER got to when this answer was assembled.
2088    ///
2089    /// Read AFTER the page, so an arrival recorded in between sits above the page and below this
2090    /// value — which is exactly why resuming from it would step straight over that arrival and lose
2091    /// a notification silently. It exists for ONE question [`cursor`](Self::cursor) cannot answer: a
2092    /// first-run client passes it back as `after_seq` to start from NOW instead of replaying the
2093    /// whole ledger as a burst of toasts.
2094    pub latest: u64,
2095}
2096
2097/// `control.profile.putBody` — the acknowledgement that the node accepted and persisted a body.
2098///
2099/// Reaching this result at all means the node RESOLVED the root on chain and found it confirmed and
2100/// matching the supplied bytes. A refusal is an error, never a success carrying `stored: false` —
2101/// a caller that has to inspect a boolean to learn whether its profile published is a caller that
2102/// will forget to.
2103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2104pub struct ProfilePutBodyResult {
2105    /// Always `true`: the body is persisted and this node will serve it to peers.
2106    pub stored: bool,
2107    /// The canonical store id the body was filed under (trimmed + lower-cased).
2108    pub store_id: String,
2109    /// The CONFIRMED chain root the node verified the body against — echoed so a caller can pin
2110    /// which root its bytes now stand behind.
2111    pub root: String,
2112    /// The DECODED body length in bytes, never above
2113    /// [`MAX_BODY_BYTES`](crate::params::MAX_BODY_BYTES).
2114    pub body_bytes: u64,
2115}
2116
2117/// `control.profile.getBody` — the body this node holds at a store id + root, if it holds one.
2118///
2119/// `body_b64: None` MUST mean "this node was consulted and holds no body at that root". It NEVER
2120/// means the body could not be read: a read that failed MUST return a catalogued error instead. A
2121/// caller that cannot tell those apart shows an empty profile for a profile that exists.
2122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2123pub struct ProfileGetBodyResult {
2124    /// The canonical store id the read was scoped to.
2125    pub store_id: String,
2126    /// The root the read was scoped to — the SAME root the caller asked for, so a body for another
2127    /// root can never arrive here unnoticed.
2128    pub root: String,
2129    /// The body, standard base64 (padded) of its `DPB` serialization; `None` when this node holds
2130    /// no body at that root.
2131    pub body_b64: Option<String>,
2132    /// The DECODED body length in bytes; `0` when no body is held.
2133    pub body_bytes: u64,
2134}
2135
2136/// Which asset an automated spend moved.
2137///
2138/// Externally tagged on `asset` so a CAT carries its asset id in the same object rather than in a
2139/// sibling field that could go missing: `{"asset":"xch"}`, `{"asset":"dig"}`,
2140/// `{"asset":"cat","asset_id":"…"}`. An amount is never readable without its asset, so the two
2141/// travel together.
2142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2143#[serde(tag = "asset", rename_all = "snake_case")]
2144pub enum SpendAsset {
2145    /// Chia itself.
2146    Xch,
2147    /// The $DIG CAT.
2148    Dig,
2149    /// Any other CAT, identified by its asset id.
2150    Cat {
2151        /// The CAT's asset id, lowercase 64-hex.
2152        asset_id: String,
2153    },
2154}
2155
2156/// ON WHOSE AUTHORITY the node signed without asking.
2157///
2158/// Two fields rather than one sentence, because a person auditing an unapproved spend asks two
2159/// separate questions: WHO holds the standing permission, and WHICH standing permission was used. A
2160/// prose sentence answers neither in a form a filter — or a revocation — can act on.
2161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2162pub struct SpendAuthority {
2163    /// The principal whose funds moved and whose consent was relied on: an account id, a profile id,
2164    /// or `"node"` for the node's own operating wallet.
2165    pub principal: String,
2166    /// The standing grant relied on, in a form the operator can go and revoke — a setting name, a
2167    /// policy id, a pairing token id.
2168    pub grant: String,
2169}
2170
2171/// Where an attempt died.
2172///
2173/// Coarse and stable on purpose: the point is which STEP failed, because that is what tells a person
2174/// whether their money is at risk. **This distinction is load-bearing and MUST NOT be flattened into
2175/// a bare "failed".** A client that collapses it is structurally unable to tell someone the truth
2176/// about their own money.
2177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2178#[serde(rename_all = "snake_case")]
2179pub enum SpendFailureStage {
2180    /// The spend could not be built or signed. No signed bundle ever existed, so nothing could reach
2181    /// a mempool and nothing moved.
2182    Signing,
2183    /// A signed bundle was rejected by the mempool, **as far as this node saw**. The bundle may
2184    /// still have reached the network by another route, or been accepted after the rejection this
2185    /// node observed.
2186    Broadcast,
2187    /// The bundle went out and the chain then reported it could not succeed.
2188    Confirmation,
2189}
2190
2191impl SpendFailureStage {
2192    /// Could the money have moved anyway, despite the attempt failing at this stage?
2193    ///
2194    /// [`Signing`](Self::Signing) is the only stage that answers NO, and it answers structurally: no
2195    /// signed bundle existed, so there was nothing that could reach a mempool.
2196    /// [`Broadcast`](Self::Broadcast) and [`Confirmation`](Self::Confirmation) both happen AFTER a
2197    /// valid signed bundle exists, and neither observation proves absence — a rejection this node
2198    /// saw does not bind a network it does not fully observe.
2199    ///
2200    /// This is the ONE place the distinction is decided. Every consumer asks the stage rather than
2201    /// re-listing the variants, so the "it did not happen" claim cannot be re-attached to a stage
2202    /// that never earned it. Written as an exhaustive `match` so adding a stage is a compile error
2203    /// here, forcing whoever adds it to choose a side.
2204    pub fn money_may_have_moved(self) -> bool {
2205        match self {
2206            SpendFailureStage::Signing => false,
2207            SpendFailureStage::Broadcast | SpendFailureStage::Confirmation => true,
2208        }
2209    }
2210
2211    /// The stable lowercase wire token.
2212    pub const fn token(self) -> &'static str {
2213        match self {
2214            SpendFailureStage::Signing => "signing",
2215            SpendFailureStage::Broadcast => "broadcast",
2216            SpendFailureStage::Confirmation => "confirmation",
2217        }
2218    }
2219}
2220
2221/// Where one automated spend got to.
2222///
2223/// Internally tagged on `state`, so a row is `{"state":"confirmed","height":…,"coin_id":"…"}`.
2224///
2225/// # Two shape rules, each from a measured money-lie
2226///
2227/// 1. **[`Confirmed`](Self::Confirmed) carries its evidence inside the variant.** There is no
2228///    optional height field to fill in optimistically, so a row cannot hold a confirmation height
2229///    without a confirmation.
2230/// 2. **[`Unresolved`](Self::Unresolved) is NOT a kind of failure.** "The node signed and does not
2231///    know how it ended" is not "it did not happen": money may well have moved, and saying `failed`
2232///    about a spend that landed is the same class of lie as claiming an unconfirmed success. A
2233///    client that maps it onto `failed` to keep a two-state UI has chosen the wrong UI.
2234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2235#[serde(tag = "state", rename_all = "snake_case")]
2236pub enum SpendOutcome {
2237    /// Recorded, not yet handed to the network. Written before the producer may sign.
2238    Pending,
2239    /// A signed bundle was accepted by the mempool. NOT a claim that it will confirm.
2240    Submitted,
2241    /// The chain shows the coin this spend created.
2242    Confirmed {
2243        /// The height the created coin was confirmed at.
2244        height: u32,
2245        /// The coin the spend CREATED — the reference a person can paste into an explorer.
2246        coin_id: String,
2247    },
2248    /// The attempt ended in a failure this node observed.
2249    ///
2250    /// **This is not uniformly a claim that the money stayed put.** Only
2251    /// [`SpendFailureStage::Signing`] carries that claim; at `Broadcast` and `Confirmation` a signed
2252    /// bundle already existed and the outcome is genuinely UNKNOWN. Ask
2253    /// [`SpendFailureStage::money_may_have_moved`] before rendering any `failed` row as settled.
2254    Failed {
2255        /// Which step failed — and, through [`SpendFailureStage::money_may_have_moved`], whether
2256        /// this row claims the money is untouched or merely records where the attempt died.
2257        stage: SpendFailureStage,
2258        /// One line a person can act on. "Insufficient funds" is the difference between a broken
2259        /// node and a wallet that needs topping up.
2260        reason: String,
2261    },
2262    /// The node signed and does not know how it ended — a timeout, a restart mid-flight, or a
2263    /// producer that dropped the spend.
2264    Unresolved {
2265        /// Why the outcome is unknown.
2266        reason: String,
2267    },
2268}
2269
2270impl SpendOutcome {
2271    /// The stable lowercase token, matching the `state` tag and the
2272    /// [`status`](crate::params::SpendsListParams::status) filter.
2273    pub const fn token(&self) -> &'static str {
2274        match self {
2275            SpendOutcome::Pending => "pending",
2276            SpendOutcome::Submitted => "submitted",
2277            SpendOutcome::Confirmed { .. } => "confirmed",
2278            SpendOutcome::Failed { .. } => "failed",
2279            SpendOutcome::Unresolved { .. } => "unresolved",
2280        }
2281    }
2282
2283    /// Is what happened to the money still UNKNOWN?
2284    ///
2285    /// True for [`Unresolved`](Self::Unresolved), and true for a [`Failed`](Self::Failed) row whose
2286    /// stage [may have moved money](SpendFailureStage::money_may_have_moved). Those two are the rows
2287    /// a person still has to chase, and a UI grouping them with settled failures hides exactly the
2288    /// spends worth looking at.
2289    ///
2290    /// `Pending` and `Submitted` are NOT unknown outcomes — they are outcomes that have not happened
2291    /// yet, and the node expects to learn them. Conflating "in flight" with "lost track of" would
2292    /// raise an alarm about every spend in progress.
2293    pub fn outcome_is_unknown(&self) -> bool {
2294        match self {
2295            SpendOutcome::Unresolved { .. } => true,
2296            SpendOutcome::Failed { stage, .. } => stage.money_may_have_moved(),
2297            SpendOutcome::Pending | SpendOutcome::Submitted | SpendOutcome::Confirmed { .. } => {
2298                false
2299            }
2300        }
2301    }
2302}
2303
2304/// A chain reference, paired with whether this node actually OBSERVED it.
2305///
2306/// The [`confirmed`](Self::confirmed) flag is not decoration. Before confirmation the node knows the
2307/// coin id it INTENDS to create, and rendering that bare id beside a confirmed one presents an
2308/// intention as a fact. The two travel together so a client can render "expected" differently from
2309/// "on chain" without re-deriving the distinction — which is the derivation it would get wrong.
2310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2311pub struct SpendChainReference {
2312    /// The coin id to look up.
2313    pub coin_id: String,
2314    /// `true` when this node observed the coin on chain; `false` when it is only the intended result.
2315    pub confirmed: bool,
2316}
2317
2318/// One spend this node made WITHOUT per-transaction approval.
2319///
2320/// # Amounts are decimal STRINGS
2321///
2322/// `amount_mojos` and `fee_mojos` carry the full `u64` range, which a JSON number does not survive
2323/// through an f64 parser — and a silently rounded figure about somebody's money is exactly the lie
2324/// this record exists to prevent. Every money field in this crate is a string for that reason.
2325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2326pub struct AutomatedSpend {
2327    /// The audit id — stable for the life of the spend, and the value
2328    /// [`after_id`](crate::params::SpendsListParams::after_id) resumes from.
2329    pub id: String,
2330    /// The revision of the record this row reflects. The audit trail is append-only and each entry
2331    /// is a snapshot; this row is the highest revision the node holds for this spend.
2332    pub revision: u32,
2333    /// What the spend was for, as the producer's stable token (`"mirror-coin"`, …).
2334    pub kind: String,
2335    /// One human sentence: why this happened without asking.
2336    pub purpose: String,
2337    /// Whose standing consent was relied on, and which grant.
2338    pub authority: SpendAuthority,
2339    /// Which asset moved.
2340    pub asset: SpendAsset,
2341    /// How much, in the asset's base units, as a decimal string.
2342    pub amount_mojos: String,
2343    /// The network fee in mojos of XCH, as a decimal string.
2344    pub fee_mojos: String,
2345    /// The store this spend serves, when it serves one.
2346    pub store_id: Option<String>,
2347    /// When the node decided to spend, unix ms. The field the ordering and the time filters use.
2348    pub initiated_ms: u64,
2349    /// When this revision was written, unix ms.
2350    pub updated_ms: u64,
2351    /// Where the spend got to.
2352    pub status: SpendOutcome,
2353    /// The coins this spend CONSUMED, once known.
2354    ///
2355    /// Never the confirmation evidence. The legacy implementation waited for a funding coin to be
2356    /// spent and called that confirmation, which a competing spend of the same coin satisfies
2357    /// identically while the intended coin never exists — so a client MUST NOT infer success from
2358    /// anything here. [`chain_reference`](Self::chain_reference) is the only reference that carries
2359    /// an observed/expected flag.
2360    pub funding_coin_ids: Vec<String>,
2361    /// The chain reference to show, or `null` when the node knows no coin id yet — which is honest:
2362    /// there is nothing to look up.
2363    ///
2364    /// The key MUST be present. `null` is meaningful, so an ABSENT key must not decode into it: a
2365    /// truncated or mis-routed payload would otherwise decode as a confident "there is nothing to
2366    /// look up".
2367    #[serde(deserialize_with = "required_option")]
2368    pub chain_reference: Option<SpendChainReference>,
2369}
2370
2371/// `control.spends.list` — one page of the automated-spend audit record.
2372///
2373/// # Why this method is the only sanctioned reader
2374///
2375/// The record is a node-private file (dig-node SPEC §23). Every other view — dig-app's Activity tab
2376/// included — reads it THROUGH the node, and this is that route. A second process parsing the file
2377/// would be a second implementation of a growing append-only format, which is how two views of "what
2378/// did the node spend" start disagreeing, on the one subject where disagreeing is least affordable.
2379///
2380/// # A page, and it says so
2381///
2382/// [`spends`](Self::spends) is bounded by
2383/// [`SPENDS_LIST_MAX_LIMIT`](crate::params::SPENDS_LIST_MAX_LIMIT). Whether it is the whole matching
2384/// set is stated by [`complete`](Self::complete) and never left to be inferred from the page's
2385/// length: a node may return a short page for its own reasons, and a matching set that is an exact
2386/// multiple of the page size makes the last full page indistinguishable from a truncated one.
2387/// Without an explicit flag a caller cannot tell "there are no more spends" from "we stopped telling
2388/// you" — and on an audit record those read the same and mean opposite things.
2389///
2390/// # The order is part of the contract
2391///
2392/// A node MUST return rows by DESCENDING [`initiated_ms`](AutomatedSpend::initiated_ms), breaking
2393/// ties by ASCENDING [`id`](AutomatedSpend::id), and MUST keep that order stable across the pages of
2394/// one walk. [`after_id`](crate::params::SpendsListParams::after_id) means *strictly after this row
2395/// in that order*. The tiebreak is required rather than incidental: automated spends are issued by a
2396/// cycle and several can share a millisecond, so a time-only order names no position and a walk
2397/// would repeat some rows and skip others.
2398///
2399/// # An empty page is an ANSWER, never a fallback
2400///
2401/// `spends: []` with `complete: true` means this node has moved no money unattended that matches the
2402/// filters. It is NEVER what a caller gets when the record could not be read: that is
2403/// [`SpendAuditUnreadable`](crate::error::ControlErrorCode::SpendAuditUnreadable). "Nothing to
2404/// report" and "I could not look" are different answers, and the first is the one a person stops
2405/// investigating on.
2406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2407pub struct SpendsListResult {
2408    /// One page of matching spends, newest-initiated first, possibly empty.
2409    pub spends: Vec<AutomatedSpend>,
2410    /// Is this page the WHOLE matching set?
2411    ///
2412    /// `true` means every matching spend the node holds is in [`spends`](Self::spends). `false`
2413    /// means the answer was TRUNCATED and more exist — resume from [`cursor`](Self::cursor).
2414    ///
2415    /// Required on the wire, and stated positively so the reading a caller falls into when the field
2416    /// is absent or defaulted is the SAFE one. A boolean spelled `truncated` would default to
2417    /// `false`, i.e. to "this is everything", which is the claim that ends a walk early; `complete`
2418    /// defaults to "there may be more", which costs at worst one redundant request.
2419    pub complete: bool,
2420    /// The id of the last row in this page — **the value to resume from** — or `null` for an empty
2421    /// page.
2422    ///
2423    /// It is the id the caller was HANDED, never a marker for where the record "got to". Pass it as
2424    /// [`after_id`](crate::params::SpendsListParams::after_id).
2425    ///
2426    /// The key MUST be present; `null` is meaningful and an absent key must not decode into it.
2427    #[serde(deserialize_with = "required_option")]
2428    pub cursor: Option<String>,
2429    /// How many entries in the record the node could NOT parse.
2430    ///
2431    /// Part of the answer rather than a log line, and a client MUST surface a non-zero value. An
2432    /// audit trail that lost entries to corruption and reads as a shorter, tidy list is
2433    /// indistinguishable from one where those spends never happened — which is the same lie as a
2434    /// missing entry, told more convincingly.
2435    ///
2436    /// It counts unreadable entries across the WHOLE record, not just this page: a corrupt entry has
2437    /// no parsed timestamp and no parsed id, so it cannot be attributed to a page or excluded by a
2438    /// filter. A caller therefore MUST NOT read it as "this many rows are missing from this page".
2439    pub unreadable_lines: u32,
2440}
2441
2442// ---------------------------------------------------------------------------
2443// Mirror bonds: the per-`(store, root)` bond state surface (dig-node SPEC §25.8).
2444// ---------------------------------------------------------------------------
2445
2446/// What this node can say about ONE `(store, root)` bond right now.
2447///
2448/// # The whole point is that "no coin yet" is never one answer
2449///
2450/// Seven of these eight variants mean "there is no current-epoch coin", and every one of them calls
2451/// for a different response from a person: add funds, wait, do nothing, turn a switch back on,
2452/// publish an advertise URL, or nothing at all because the capsule was never this node's to
2453/// advertise. Collapsing any two of
2454/// them is what produces an hourly out-of-funds alarm about a perfectly healthy node
2455/// (dig-app#300), which is the defect this method exists to remove.
2456///
2457/// # Vocabulary: `withheld`, `disabled` and `reclaiming` are three different things
2458///
2459/// dig-node's internal `BondState` (`mirror/pass.rs`) used the single word `Withheld` for the
2460/// node-wide collateralisation switch being OFF, while dig-node `SPEC.md` §25.8 used the same word
2461/// for a capsule of `Relayed` provenance — one this node holds but deliberately never advertises.
2462/// They are not the same state. They differ in SCOPE (one switch for the node, versus one
2463/// capsule's provenance) and, more importantly, in REMEDY: an operator told "withheld" about a
2464/// disabled node goes looking at content, and one told "withheld" about a relayed capsule goes
2465/// looking for a switch. **This contract keeps them apart, and neither existing use survives
2466/// unchanged:**
2467///
2468/// - [`Withheld`](Self::Withheld) carries §25.8's meaning — `Relayed` provenance, per capsule.
2469/// - [`Disabled`](Self::Disabled) is the node-wide switch, which §25.8 could not express at all.
2470/// - [`Unadvertised`](Self::Unadvertised) is that switch being ON while the node still has nothing
2471///   publishable to advertise. It is node-wide like `disabled` and is deliberately NOT the same
2472///   value: `disabled` is the operator's own decision and MUST NOT be shown as a fault, so a node
2473///   served under it would oblige a conforming client to stay silent about a real failure.
2474/// - [`Reclaiming`](Self::Reclaiming) is §25.8's seventh state, which `BondState` had no variant
2475///   for even though the money is still locked while it lasts.
2476///
2477/// So dig-node MUST rename `BondState::Withheld` to `Disabled` and add `Withheld` + `Reclaiming`,
2478/// and §25.8 MUST gain `disabled`. Serving the old enum under §25.8's words would publish a
2479/// contract whose terms mean something else — the drift class this crate exists to prevent.
2480///
2481/// **[`Withheld`](Self::Withheld) is VACUOUS until dig-node's surface enumerates its SERVED set
2482/// rather than its `Held` set.** A relayed capsule is by construction absent from the desired-bond
2483/// set, so a derivation keyed on `Held` bonds can never emit this variant — it would silently
2484/// answer "no such row" where §25.8 promises "withheld on purpose". Declaring it here is correct;
2485/// a producer that cannot reach it MUST say so rather than report the state as satisfied.
2486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2487#[serde(tag = "bond_state", rename_all = "snake_case")]
2488pub enum MirrorBondState {
2489    /// A coin bonding this `(store, root)` for the CURRENT epoch is on chain.
2490    Bonded {
2491        /// The coin a person can look up, hex, no `0x`.
2492        coin_id: String,
2493        /// The epoch it bonds, one-based.
2494        epoch: u64,
2495        /// What the coin actually LOCKS, in DIG base units, read from the coin — never from this
2496        /// epoch's requirement. A coin created under a previous requirement locks the previous
2497        /// amount, and rendering today's price against yesterday's coin is a figure nobody holds.
2498        amount_dig_base_units: u64,
2499        /// The advertise URL(s) THIS COIN's memo carries — read at the moment it was created, not
2500        /// recomputed. A coin minted before the node's public address changed still carries its
2501        /// OLD address; this is how a client sees that without decoding the memo itself.
2502        urls: Vec<String>,
2503        /// Does [`urls`](Self::Bonded::urls) set-equal what this node advertises THIS PASS?
2504        ///
2505        /// The one field `control.mirror.reconcile` exists to fix when it goes `false`. Carried
2506        /// per row, alongside [`urls`](Self::Bonded::urls), because a client would otherwise need
2507        /// a SECOND call (`control.config.get`) and a set comparison of its own to answer "is this
2508        /// bond stale" — and would need to repeat that comparison for every row on every render.
2509        /// `false` here is not a fault by itself: it is exactly the condition
2510        /// `control.mirror.reconcile` reconciles away, and [`UrlReconcileStatus::stale_bonds`] is
2511        /// the count of this flag being `false` across the WHOLE bond set, not just this page.
2512        url_current: bool,
2513    },
2514    /// A create for this bond has been submitted and has not confirmed.
2515    ///
2516    /// Nothing is wrong and no money is missing. A client MUST NOT render this as a shortfall.
2517    Pending,
2518    /// The wallet cannot cover the create for this bond.
2519    ///
2520    /// The genuine out-of-funds state, and the ONLY one a client may raise a funding alarm on.
2521    Unfunded {
2522        /// How many more DIG base units THIS BOND alone needs.
2523        ///
2524        /// DIG base units — $DIG has 3 decimals, so one unit is `0.001 DIG`. It is NOT a mojo,
2525        /// which is XCH's `1e-12` unit, nine orders of magnitude away. A mirror amount is never
2526        /// quoted in mojos.
2527        ///
2528        /// Per bond, never a total. "How short is this node overall" is
2529        /// `control.collateral.buffer`'s question, and it answers it authoritatively.
2530        short_dig_base_units: u64,
2531    },
2532    /// The epoch's collateral requirement is not known, so no create can be PRICED.
2533    ///
2534    /// **NOT an out-of-funds state.** The wallet may be full. A client that renders this as a
2535    /// shortfall tells an operator to send money that would change nothing.
2536    Deferred {
2537        /// Why the requirement is unknown, in the SAME taxonomy
2538        /// [`CollateralRequirementResult::Unknown`] uses.
2539        ///
2540        /// Reused rather than restated: a second copy of that taxonomy here would drift from the
2541        /// original, and a client already renders these tokens for
2542        /// `control.collateral.requirement`.
2543        reason: CollateralUnknownReason,
2544    },
2545    /// This node holds the capsule with `Relayed` provenance: it does not claim to serve it, and
2546    /// deliberately never advertises it.
2547    ///
2548    /// §25.8's `withheld`. Nothing is wrong, nothing is owed, and there is no remedy — which is
2549    /// exactly why conflating it with [`Unfunded`](Self::Unfunded) is the dig-app#300 defect.
2550    Withheld,
2551    /// Collateralisation is switched OFF for this node, so no bond is advertised regardless of
2552    /// funds, provenance or price.
2553    ///
2554    /// Node-wide, not per capsule: every row reads `disabled` together. The remedy is a switch, and
2555    /// it is the operator's own earlier decision — a client MUST NOT present it as a fault.
2556    Disabled,
2557    /// This node has nothing publishable to advertise, so it advertises nothing and creates no
2558    /// mirror coin.
2559    ///
2560    /// The node holds the capsule, its own collateralisation switch is ON, its wallet may be full
2561    /// and the epoch's requirement may be perfectly well known. It simply has no advertise URL a
2562    /// peer could fetch from -- the list is empty, or every entry in it was rejected as
2563    /// non-absolute or reachable only from this machine -- and a mirror coin that advertised no
2564    /// URL would bond nothing.
2565    ///
2566    /// **A client MUST surface this as a fault.** That is the whole difference between it and
2567    /// [`Disabled`](Self::Disabled), which is also node-wide and also means "no coin", but is the
2568    /// operator's own earlier decision and MUST NOT be presented as one. Here the operator decided
2569    /// the opposite -- the switch is ON -- and the node is silently unable to honour it. The remedy
2570    /// is a publishable advertise URL, and it is the only remedy: sending $DIG changes nothing,
2571    /// which is why serving this as [`Unfunded`](Self::Unfunded) is a false statement about money.
2572    ///
2573    /// Node-wide like `disabled`: every row reads `unadvertised` together, because the URL list is
2574    /// one list for the node rather than a property of any capsule.
2575    Unadvertised,
2576    /// A live coin is being reclaimed: the bond is going away and the money is not back yet.
2577    ///
2578    /// Carries the coin because the funds are STILL LOCKED for the duration. A surface that showed
2579    /// this as unbonded-and-unlocked would report money as available that cannot be spent, and a
2580    /// reclaim that fails leaves the coin exactly where this says it is.
2581    Reclaiming {
2582        /// The coin being reclaimed, hex, no `0x`.
2583        coin_id: String,
2584        /// The epoch that coin bonds — frequently a PREVIOUS epoch, which is usually why it is
2585        /// being reclaimed.
2586        epoch: u64,
2587        /// What it still locks, in DIG base units, read from the coin.
2588        amount_dig_base_units: u64,
2589    },
2590}
2591
2592/// The `(store_id, root)` pair that identifies one mirror bond — the read's sort key and its cursor.
2593///
2594/// A composite key rather than an opaque string, because the order it names is the contract's own
2595/// (ascending `store_id`, then ascending `root`) and a caller resuming a walk can check its position
2596/// rather than trust an encoding it cannot read.
2597#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
2598pub struct MirrorBondKey {
2599    /// The store id — LOWERCASE 64-hex, unprefixed.
2600    ///
2601    /// The canonical form is part of the key's contract rather than a formatting preference: the
2602    /// order this key names is ascending over these STRINGS, and uppercase hex sorts differently
2603    /// from lowercase, so two producers spelling it differently would disagree on the order and
2604    /// `after` would mean two different positions. A `0x` prefix is TOLERATED on input to
2605    /// [`MirrorBondStatesParams`](crate::params::MirrorBondStatesParams) and normalized away; it
2606    /// is never emitted.
2607    pub store_id: String,
2608    /// The root — LOWERCASE 64-hex, unprefixed, on the same terms as [`store_id`](Self::store_id).
2609    pub root: String,
2610}
2611
2612/// One row of [`MirrorBondStatesResult`]: which bond, and what its state is.
2613///
2614/// The state is FLATTENED into the row, so a row is one object carrying `store_id`, `root`,
2615/// `bond_state` and that state's own payload — never a nested envelope a client has to unwrap
2616/// before it can branch.
2617#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2618pub struct MirrorBondEntry {
2619    /// The store id this bond is for, hex, no `0x`.
2620    pub store_id: String,
2621    /// The ROOT this bond is for, hex, no `0x`.
2622    ///
2623    /// Part of the key, never decoration. A publisher funds the latest root and may decline to fund
2624    /// older ones, so a coin bonds one `(store, root)` pair and a surface keyed on the store alone
2625    /// would merge a funded root with an unfunded one into a single misleading row.
2626    pub root: String,
2627    /// What this node can say about the bond.
2628    #[serde(flatten)]
2629    pub state: MirrorBondState,
2630}
2631
2632/// Why a node cannot state its bond states AT ALL.
2633///
2634/// The "cannot tell" axis, and it is deliberately separate from every per-bond state, all of which
2635/// are DEFINITE statements. A fact the node could not read makes the WHOLE answer
2636/// [`MirrorBondStatesResult::Unknown`] rather than degrading individual rows: a partial list is
2637/// indistinguishable from a complete one, and the rows a broken read would drop are exactly the
2638/// bonds nobody is then watching.
2639///
2640/// The epoch requirement being unknown is NOT a member — that is a definite per-bond state
2641/// ([`MirrorBondState::Deferred`]) and the node can still answer.
2642#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2643#[serde(rename_all = "snake_case")]
2644pub enum MirrorBondStatesUnknownReason {
2645    /// The node cannot enumerate the `(store, root)` pairs it holds, so it does not know which
2646    /// bonds exist to have a state. Nothing may be substituted: the census `stores` figure counts
2647    /// network-wide advertisements and is not this node's set.
2648    ServedSetUnknown,
2649    /// The node cannot read mirror coins from chain, so it cannot tell a bonded pair from an
2650    /// unfunded one. Answering `unfunded` here would be a fabricated shortfall.
2651    ChainUnreadable,
2652    /// The node cannot read its own in-flight creates, so it cannot tell
2653    /// [`Pending`](MirrorBondState::Pending) from [`Unfunded`](MirrorBondState::Unfunded) — a
2654    /// submitted create and no create at all look identical from chain alone during the gap.
2655    InFlightUnknown,
2656    /// The node can enumerate the `(store, root)` pairs it holds, but cannot determine their
2657    /// PROVENANCE, so it cannot tell a `Relayed` capsule apart from one that is simply absent.
2658    ///
2659    /// The one non-infrastructure reason, and it exists because the alternative is a lie. A
2660    /// derivation keyed on the desired-bond (`Held`) set enumerates perfectly well and yet can
2661    /// never emit [`Withheld`](MirrorBondState::Withheld), because a `Relayed` capsule is by
2662    /// construction absent from that set. Without this reason its only conforming-LOOKING answer
2663    /// is a `known` page with `complete: true` and every withheld row silently missing — the exact
2664    /// "no such row where the contract promises withheld on purpose" failure, wearing the shape of
2665    /// a complete answer. This reason is how such a node says so instead.
2666    ProvenanceUnknown,
2667}
2668
2669impl MirrorBondStatesUnknownReason {
2670    /// Every reason, for exhaustive rendering and for the wire-token uniqueness KAT.
2671    pub const ALL: &'static [MirrorBondStatesUnknownReason] = &[
2672        MirrorBondStatesUnknownReason::ServedSetUnknown,
2673        MirrorBondStatesUnknownReason::ChainUnreadable,
2674        MirrorBondStatesUnknownReason::InFlightUnknown,
2675        MirrorBondStatesUnknownReason::ProvenanceUnknown,
2676    ];
2677
2678    /// The stable snake_case wire token, matching the `reason` field.
2679    pub const fn as_wire(self) -> &'static str {
2680        match self {
2681            MirrorBondStatesUnknownReason::ServedSetUnknown => "served_set_unknown",
2682            MirrorBondStatesUnknownReason::ChainUnreadable => "chain_unreadable",
2683            MirrorBondStatesUnknownReason::InFlightUnknown => "in_flight_unknown",
2684            MirrorBondStatesUnknownReason::ProvenanceUnknown => "provenance_unknown",
2685        }
2686    }
2687}
2688
2689/// `control.mirror.bondStates` — the per-`(store, root)` state of every mirror bond this node
2690/// holds, and the $DIG those bonds have locked.
2691///
2692/// # "No bond" and "cannot tell" are different answers, at different levels
2693///
2694/// Every per-row [`MirrorBondState`] is a DEFINITE statement, including the six that mean "no coin
2695/// yet": each names WHY, and each has its own remedy. A node that could not read a fact it needs
2696/// does not report a row at all — it answers [`Unknown`](Self::Unknown) for the WHOLE call, with
2697/// the reason. There is deliberately no per-row "unknown" and no empty-list fallback: a short list
2698/// and a complete one look the same, and the rows a broken read would drop are precisely the bonds
2699/// an operator most needs to see.
2700///
2701/// `entries: []` with `complete: true` is therefore an ANSWER — this node holds no mirror bonds —
2702/// and it is never what a caller gets when something could not be read.
2703///
2704/// # The locked total is the node's, and a client MUST NOT sum the page
2705///
2706/// `locked_dig_base_units` covers the WHOLE bond set, not this page, and includes
2707/// [`Reclaiming`](MirrorBondState::Reclaiming) coins because their money is still locked. A client
2708/// that summed `entries` instead would under-report the locked total by exactly one page boundary
2709/// and would show money as available that cannot be spent — the money lie this method's paging
2710/// makes easiest to tell. It is what dig-app#289's locked-total surface reads.
2711///
2712/// # Every amount is DIG BASE UNITS
2713///
2714/// $DIG carries 3 decimals, so one base unit is `0.001 DIG`. It is NOT a mojo — XCH's `1e-12` base
2715/// unit, nine orders of magnitude away. A mirror amount is never quoted in mojos.
2716///
2717/// # A page, and it says so
2718///
2719/// Rows come in ASCENDING `(store_id, root)`, a total order over the LOWERCASE unprefixed hex
2720/// spelling of both halves, stable across the pages of one walk. `complete` states whether the page is the whole set and is never inferred from the
2721/// page's length: a node may return a short page for its own reasons, and a set that is an exact
2722/// multiple of the page size makes the last full page indistinguishable from a truncated one.
2723/// Resume from `cursor` — the key of the last row you were actually HANDED.
2724#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2725#[serde(tag = "state", rename_all = "snake_case")]
2726pub enum MirrorBondStatesResult {
2727    /// The node can state every bond's state.
2728    Known {
2729        /// One page of bonds, ascending by `(store_id, root)`, possibly empty.
2730        entries: Vec<MirrorBondEntry>,
2731        /// Is this page the WHOLE bond set?
2732        ///
2733        /// Required on the wire, and stated positively so the reading a caller falls into when the
2734        /// field is absent or defaulted is the SAFE one — `complete` defaults to "there may be
2735        /// more", which costs at worst one redundant request, whereas a `truncated` spelling would
2736        /// default to "this is everything" and end a walk early.
2737        complete: bool,
2738        /// The key of the LAST row in this page — the value to resume from — or `null` for an empty
2739        /// page.
2740        ///
2741        /// The key the caller was HANDED, never a position the node "got to". Pass it as
2742        /// [`MirrorBondStatesParams::after`](crate::params::MirrorBondStatesParams::after).
2743        ///
2744        /// The key MUST be present; `null` is meaningful and an absent key must NOT decode into it.
2745        #[serde(deserialize_with = "required_option")]
2746        cursor: Option<MirrorBondKey>,
2747        /// The $DIG this node has LOCKED in mirror coins across the whole bond set, in DIG base
2748        /// units.
2749        ///
2750        /// Authoritative and node-computed. Includes reclaiming coins. Spans every page.
2751        locked_dig_base_units: u64,
2752        /// The epoch in force when this answer was taken, one-based.
2753        ///
2754        /// Carried so a client can tell a bond at the current epoch from one it is reading across a
2755        /// rollover, without consulting a second method whose answer may have moved in between.
2756        epoch: u64,
2757        /// WHICH WALLET every figure on this page is about.
2758        ///
2759        /// # Why an answer about money must name the wallet
2760        ///
2761        /// Every amount here — each [`Unfunded`](MirrorBondState::Unfunded) shortfall,
2762        /// [`locked_dig_base_units`](Self::Known::locked_dig_base_units), each bonded amount — is a
2763        /// statement about the node's own MACHINE-custody operator wallet, not about the user's. A
2764        /// node reported three bonds `unfunded, short 1010` while its operator's own wallet held
2765        /// 1,015,000 base units of $DIG: both statements were true, each was about a different
2766        /// wallet, and the payload named an amount and no wallet, so nobody could tell. Naming it
2767        /// here is what makes the page self-describing rather than merely correct.
2768        ///
2769        /// # On the ANSWER, not on each row — and the distinction is not cosmetic
2770        ///
2771        /// The funding wallet is node-wide, so a per-row copy would be the same string repeated for
2772        /// every entry: a field that cannot vary, which reads as though it could. Worse, two rows
2773        /// of one answer could then be written to disagree, and a client would have to decide which
2774        /// to believe. One value per answer can be wrong; it cannot be inconsistent with itself.
2775        ///
2776        /// Carried rather than left to `control.wallet.operatorAddress` for the same reason
2777        /// [`epoch`](Self::Known::epoch) is carried: a second call is a second observation, and it
2778        /// may have moved. A page of amounts that requires a follow-up call to learn whose amounts
2779        /// they are can be rendered, screenshotted and acted on before that call returns.
2780        ///
2781        /// The same type `control.wallet.operatorAddress` returns, reused rather than restated, so
2782        /// the two surfaces cannot drift into two spellings of one fact — and so a node with no
2783        /// wallet yet says [`NotInitialized`](WalletOperatorAddressUnavailableReason::NotInitialized)
2784        /// here too, instead of a blank string a client might render as a destination.
2785        funding_wallet: WalletOperatorAddressResult,
2786        /// This node's URL-reconciliation standing: whether the daily pass is armed, when it next
2787        /// runs, what it last saw, and how many bonds are stale RIGHT NOW.
2788        ///
2789        /// Without this a client cannot decide whether to show the "reset mirrors" button as
2790        /// useful without a `dry_run` round trip on every render, and cannot tell whether a
2791        /// `submitted` reconcile it kicked off earlier has landed — it would have to re-walk the
2792        /// whole bond page and re-derive [`stale_bonds`](UrlReconcileStatus::stale_bonds) itself
2793        /// from [`MirrorBondState::Bonded::url_current`] on every entry, on every page.
2794        ///
2795        /// Boxed: [`UrlReconcileStatus`] carries two nested `Option`s the way its sibling
2796        /// [`Unknown`](Self::Unknown) variant carries only a bare reason, and inlining it here
2797        /// widens the WHOLE enum to fit its largest variant — `Box` costs one wire-invisible
2798        /// indirection (`serde` serializes through it transparently) rather than that.
2799        url_reconcile: Box<UrlReconcileStatus>,
2800    },
2801    /// The node cannot state the bond states, and names which fact is missing.
2802    Unknown {
2803        /// Which fact the node is missing.
2804        reason: MirrorBondStatesUnknownReason,
2805    },
2806}
2807
2808/// This node's URL-reconciliation standing — the automatic side of `control.mirror.reconcile`.
2809///
2810/// Embedded in [`MirrorBondStatesResult::Known`], beside `funding_wallet`, for the same reason
2811/// `epoch` is carried there rather than left to a second call: this is a second OBSERVATION, and a
2812/// page of bond rows can be rendered and acted on before a follow-up call would return.
2813#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2814pub struct UrlReconcileStatus {
2815    /// Is the automatic daily detector (dig-node#570) armed on this node?
2816    ///
2817    /// `false` on a node built before the daily detector existed, or one with it configured off —
2818    /// a client MUST NOT infer either reason from `false` alone; it means only that nothing will
2819    /// run reconcile without a manual `control.mirror.reconcile` call.
2820    pub auto_enabled: bool,
2821    /// This node's personal offset into its day, in seconds, that the daily detector's check time
2822    /// is jittered by — so a fleet of nodes does not all wake and reconcile at the same instant.
2823    ///
2824    /// `None` when [`auto_enabled`](Self::auto_enabled) is `false`: an offset with nothing to
2825    /// offset is not a fact about this node.
2826    pub personal_day_offset_secs: Option<u32>,
2827    /// When the daily detector will next run, Unix milliseconds — or `None` on the same terms as
2828    /// [`personal_day_offset_secs`](Self::personal_day_offset_secs).
2829    pub next_check_unix_ms: Option<u64>,
2830    /// What the daily detector (or the last manual call) most recently observed, or `None` if this
2831    /// node has never observed its advertise state at all — a node just started, for instance.
2832    pub last_observation: Option<UrlReconcileObservation>,
2833    /// How many bonds, across the WHOLE set (never just this page), currently read
2834    /// [`url_current: false`](MirrorBondState::Bonded::url_current) — the count a client needs to
2835    /// decide whether "reset mirrors" would do anything, without walking the whole
2836    /// [`MirrorBondStatesResult::Known::entries`] page by page first.
2837    pub stale_bonds: u32,
2838    /// Has the automatic pass already reconciled this node's bonds during the CURRENT epoch?
2839    ///
2840    /// Lets a client distinguish "nothing to do, autopilot already handled it" from "nothing to
2841    /// do, everything was already current" — both read as `stale_bonds: 0`, and only one of them
2842    /// means a manual reconcile this epoch would be redundant with what already ran.
2843    pub auto_reconciled_this_epoch: bool,
2844}
2845
2846/// One observation the daily detector (or a manual reconcile) made of this node's own advertise
2847/// state — embedded in [`UrlReconcileStatus::last_observation`].
2848#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2849pub struct UrlReconcileObservation {
2850    /// When this observation was taken, Unix milliseconds.
2851    pub at_unix_ms: u64,
2852    /// Did the observation reach a DEFINITE answer?
2853    ///
2854    /// `false` means the node could not tell what it was advertising at the time — a chain read
2855    /// failed, corroboration was mid-flight, or similar — and BOTH
2856    /// [`urls`](Self::urls) and [`state`](Self::state) are then `None`: an inconclusive
2857    /// observation has nothing to report, and reporting a guess under either field would assert a
2858    /// fact the node does not have.
2859    pub conclusive: bool,
2860    /// The URL(s) this node was actively advertising at observation time, when it was ([`Some`]
2861    /// only while [`conclusive`](Self::conclusive) is `true` and the node WAS publishing something
2862    /// — mutually exclusive with [`state`](Self::state), which covers the opposite case).
2863    pub urls: Option<Vec<String>>,
2864    /// Which of [`MirrorAdvertiseState`]'s non-publishing variants applied, when the node was NOT
2865    /// publishing anything at observation time (mutually exclusive with
2866    /// [`urls`](Self::urls) — see it for the same [`conclusive`](Self::conclusive) gating).
2867    pub state: Option<MirrorAdvertiseState>,
2868}
2869
2870/// Which spending capability `control.mirror.reconcile` needs and cannot use, and why.
2871///
2872/// SIGNING and BROADCASTING fail for two independent reasons with two independent remedies — a
2873/// wallet that cannot sign needs its autoseed repaired or created; a wallet with
2874/// `DIG_WALLET_ENABLE_LIVE_BROADCAST` off needs that flag, never more $DIG. Reporting either as
2875/// [`MirrorReconcileRefusal::InsufficientFunds`] would send an operator to fund a wallet that may
2876/// already be full.
2877#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2878#[serde(tag = "kind", rename_all = "snake_case")]
2879pub enum MirrorReconcileWalletCapability {
2880    /// The node has no usable operator wallet to sign a reclaim or a create with.
2881    ///
2882    /// The same taxonomy [`WalletOperatorAddressResult::Unavailable`] uses, reused rather than
2883    /// restated so the two surfaces cannot drift into two spellings of one fact.
2884    Signing {
2885        /// Why signing is unavailable.
2886        reason: WalletOperatorAddressUnavailableReason,
2887    },
2888    /// The node could sign, but `DIG_WALLET_ENABLE_LIVE_BROADCAST` is off — the same guard
2889    /// `-32044 WALLET_NODE_SPEND_DISABLED` reports elsewhere in this catalog. Retrying cannot
2890    /// help; the remedy is the flag, on the machine that owns it.
2891    Broadcasting,
2892}
2893
2894/// Why `control.mirror.reconcile` refused — and NOTHING was spent.
2895///
2896/// Every member is paired with the same guarantee (see [`MirrorReconcileResult::Refused`]): the
2897/// node found a reason it must not even START the reclaim-then-recreate sequence, per
2898/// dig_ecosystem#3203's central invariant — establish and validate the new URL BEFORE reclaiming
2899/// anything, and if it cannot be established, do nothing at all. A reset that half-executes is
2900/// worse than one that refuses, because the half that runs is the half that destroys value.
2901///
2902/// # Ten gates, and conflating any two sends an operator to the wrong remedy
2903///
2904/// `0.34.0` shipped six reasons and one of them, `advertise_off`, silently meant two different
2905/// things: dig-node SPEC §25.10's "nothing is publishable" and §25.7's node-wide
2906/// collateralisation switch. This version separates them ([`NotPublishing`](Self::NotPublishing)
2907/// and [`Disabled`](Self::Disabled) respectively) and adds the reasons `0.34.0` had no words for
2908/// at all — an unreadable chain, an unpriced epoch, a wallet that cannot spend, and funds this
2909/// node has not yet measured — each of which `0.34.0` would otherwise have mis-reported as
2910/// [`InsufficientFunds`](Self::InsufficientFunds), the EXACT conflation dig-node SPEC §25.8 exists
2911/// to forbid ("the wallet may be full").
2912#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2913#[serde(tag = "reason", rename_all = "snake_case")]
2914pub enum MirrorReconcileRefusal {
2915    /// This node is not currently publishing an advertise URL at all, for one of
2916    /// [`MirrorAdvertiseState`]'s four non-publishing reasons — the SAME enum
2917    /// `control.config.get` already serves, carried here rather than re-invented so a client
2918    /// rendering one renders the other with no new arm.
2919    ///
2920    /// [`state`](Self::NotPublishing::state) is never
2921    /// [`AdvertisingOverride`](MirrorAdvertiseState::AdvertisingOverride) or
2922    /// [`AdvertisingDerived`](MirrorAdvertiseState::AdvertisingDerived) here — either would mean
2923    /// the node IS publishing, and reconcile would not have refused this way.
2924    NotPublishing {
2925        /// Which of the four non-publishing states applies.
2926        state: MirrorAdvertiseState,
2927    },
2928    /// The URL this node would advertise next is IDENTICAL to the one its existing mirror coins
2929    /// already advertise. Reconciling would spend real $DIG to reach the state the node is
2930    /// already in, so the node refuses rather than charge the operator for a pure-cost no-op.
2931    UrlUnchanged,
2932    /// This node holds no mirror coins at all, so there is nothing to reclaim and no bond whose
2933    /// advertise URL could be stale.
2934    NoMirrorCoins,
2935    /// The operator wallet cannot afford ANY of the plan, not even its first step. See
2936    /// [`MirrorReconcileResult::Submitted`] for the case where it can afford a PREFIX instead.
2937    InsufficientFunds {
2938        /// The $DIG this node's operator wallet actually holds, in DIG base units.
2939        have_dig_base_units: u64,
2940        /// The $DIG the FIRST reclaim+create pair alone would need, in DIG base units — never the
2941        /// cost of the whole plan, which a wallet that fails at step one was never priced
2942        /// against.
2943        need_dig_base_units: u64,
2944    },
2945    /// A reconcile this node started earlier is still in flight. Reconciling is not idempotent
2946    /// mid-flight — a second call racing the first could reclaim a coin the first call is still
2947    /// waiting to recreate — so the node serializes reconciles rather than interleaving them.
2948    ReconcileInProgress,
2949    /// This node's mirror-collateral advertising is switched OFF node-wide (dig-node SPEC
2950    /// §25.7) — the operator's own earlier decision, not a fault. Distinct from
2951    /// [`NotPublishing`](Self::NotPublishing): that gate is about WHAT URL to advertise; this one
2952    /// is about whether the node bonds anything AT ALL, regardless of URL.
2953    Disabled,
2954    /// The node cannot read mirror coins from chain, so it cannot tell which of its coins are
2955    /// stale, or how many mirror coins it holds at all. Reconciling from an unreadable chain view
2956    /// risks reclaiming a coin the node has misjudged.
2957    ChainUnreadable,
2958    /// This epoch's collateral requirement is not
2959    /// [`Known`](CollateralRequirementResult::Known), so no create can be PRICED. **Not an
2960    /// out-of-funds state** — the wallet may be full; a client that renders this as
2961    /// [`InsufficientFunds`](Self::InsufficientFunds) tells an operator to send money that would
2962    /// price nothing.
2963    RequirementUnknown {
2964        /// Why the requirement is unknown, in the SAME taxonomy
2965        /// [`CollateralRequirementResult::Unknown`] uses — named `collateral_reason` rather than
2966        /// `reason` because this variant already sits inside a `reason`-tagged enum, and the two
2967        /// concepts (which REFUSAL this is, versus which COLLATERAL fact is missing) must not
2968        /// share one wire key.
2969        collateral_reason: CollateralUnknownReason,
2970    },
2971    /// The node cannot spend at all — it can neither sign nor broadcast — for a reason that is
2972    /// NOT a shortfall. See [`MirrorReconcileWalletCapability`] for which capability and why.
2973    WalletUnavailable {
2974        /// Which capability is missing.
2975        capability: MirrorReconcileWalletCapability,
2976    },
2977    /// The node has a wallet, but has not yet MEASURED what it holds (dig-node SPEC §25.12), so
2978    /// it cannot say whether the plan is affordable. This refusal states only that measurement,
2979    /// never affordability, is what is missing — quoting a figure here would be fabricated.
2980    FundsUnmeasured,
2981}
2982
2983impl MirrorReconcileRefusal {
2984    /// Every refusal reason, for exhaustive rendering and the wire-token uniqueness KAT.
2985    ///
2986    /// Each fixture value is representative rather than load-bearing here — `ALL` exists to walk
2987    /// the VARIANT set, not to pin any one payload; the golden-vector KAT pins payloads.
2988    pub const ALL: &'static [MirrorReconcileRefusal] = &[
2989        MirrorReconcileRefusal::NotPublishing {
2990            state: MirrorAdvertiseState::UncorroboratedAddress,
2991        },
2992        MirrorReconcileRefusal::UrlUnchanged,
2993        MirrorReconcileRefusal::NoMirrorCoins,
2994        MirrorReconcileRefusal::InsufficientFunds {
2995            have_dig_base_units: 0,
2996            need_dig_base_units: 0,
2997        },
2998        MirrorReconcileRefusal::ReconcileInProgress,
2999        MirrorReconcileRefusal::Disabled,
3000        MirrorReconcileRefusal::ChainUnreadable,
3001        MirrorReconcileRefusal::RequirementUnknown {
3002            collateral_reason: CollateralUnknownReason::NotCensused,
3003        },
3004        MirrorReconcileRefusal::WalletUnavailable {
3005            capability: MirrorReconcileWalletCapability::Broadcasting,
3006        },
3007        MirrorReconcileRefusal::FundsUnmeasured,
3008    ];
3009
3010    /// The stable snake_case wire token, matching the `reason` tag — independent of payload, so
3011    /// this stays a `const fn` despite most variants now carrying one.
3012    pub const fn as_wire(self) -> &'static str {
3013        match self {
3014            MirrorReconcileRefusal::NotPublishing { .. } => "not_publishing",
3015            MirrorReconcileRefusal::UrlUnchanged => "url_unchanged",
3016            MirrorReconcileRefusal::NoMirrorCoins => "no_mirror_coins",
3017            MirrorReconcileRefusal::InsufficientFunds { .. } => "insufficient_funds",
3018            MirrorReconcileRefusal::ReconcileInProgress => "reconcile_in_progress",
3019            MirrorReconcileRefusal::Disabled => "disabled",
3020            MirrorReconcileRefusal::ChainUnreadable => "chain_unreadable",
3021            MirrorReconcileRefusal::RequirementUnknown { .. } => "requirement_unknown",
3022            MirrorReconcileRefusal::WalletUnavailable { .. } => "wallet_unavailable",
3023            MirrorReconcileRefusal::FundsUnmeasured => "funds_unmeasured",
3024        }
3025    }
3026}
3027
3028/// `control.mirror.reconcile` — reconcile this node's mirror coins to its CURRENT advertise URL:
3029/// reclaim every coin advertising a stale URL, then recreate it advertising the URL this node
3030/// advertises TODAY. Shared by the manual "reset mirrors" action and the same primitive the
3031/// node's DAILY detector runs (dig-node#570) — ONE reconcile primitive, exposed two ways
3032/// (dig_ecosystem#3203).
3033///
3034/// # Three outcomes, and a client MUST be able to tell them apart
3035///
3036/// They mean different things about the user's money, which is why this is a tagged union rather
3037/// than a single struct with optional fields:
3038///
3039/// - [`Planned`](Self::Planned) — `dry_run: true` only. A PRICED PLAN; nothing was spent.
3040/// - [`Refused`](Self::Refused) — nothing was spent. See [`MirrorReconcileRefusal`] for why.
3041/// - [`Submitted`](Self::Submitted) — reclaims were SUBMITTED to the mempool; recreates are OWED
3042///   to a later pass. Neither is confirmed by the time this call returns.
3043///
3044/// # `completed`/`partial` do not exist, and cannot: a reclaim and a create are separate bundles
3045///
3046/// A create's $DIG is selected by a chain scan of CONFIRMED coins; a reclaim's collateral becomes
3047/// spendable only once the reclaim ITSELF confirms — a LATER pass, never inside this call. So at
3048/// return time the node can state how many reclaims the mempool accepted or rejected and how many
3049/// recreates are therefore owed, but it CANNOT state how many creates confirmed: that fact does
3050/// not exist yet. A `completed{created: 3}`-shaped answer would be a money statement about the
3051/// future reported as though it were the present — the exact class of lie dig-node SPEC §25.8
3052/// exists to remove. [`Submitted`](Self::Submitted)'s counts are the honest version of the same
3053/// information: what the node actually knows at the moment it answers.
3054///
3055/// # `Refused` MUST mean nothing was spent, unconditionally
3056///
3057/// A caller distinguishes `refused` from `submitted` precisely so it never has to guess whether a
3058/// "no" cost money. An implementation that spends anything under a `Refused` outcome breaks the
3059/// one guarantee this contract exists to give the operator.
3060///
3061/// # A dry run that would in fact refuse reports `Refused`, never `Planned`
3062///
3063/// `Planned` prices a plan the node believes it CAN execute — computed against the same
3064/// validation (corroborated URL, changed URL, affordability) a real run performs. A dry run whose
3065/// candidate URL is uncorroborated, unchanged, or otherwise refusable answers `Refused` exactly
3066/// like a real run would, so a caller previewing a reset sees the same refusal a real attempt
3067/// would hit rather than a plan for a call that cannot execute.
3068///
3069/// # The load-bearing invariant: K is sized BEFORE any reclaim, and exactly K are reclaimed
3070///
3071/// `Planned::capsules_affordable` and `Submitted`'s counts describe the SAME quantity, computed
3072/// the SAME way: the affordable prefix K, sized against the wallet's balance AUGMENTED by the K
3073/// coins' own collateral (since reclaiming them is what funds their matching recreate), decided
3074/// BEFORE any reclaim is attempted. An implementation MUST NOT reclaim more than it has already
3075/// decided it can recreate — reclaiming n and creating only K leaves the node holding
3076/// uncollateralised capsules it has stopped advertising, which is the half-run failure this whole
3077/// method exists to prevent. "No worse off" therefore means the bond COUNT is unchanged, never
3078/// merely that no XCH fee was wasted.
3079#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3080#[serde(tag = "outcome", rename_all = "snake_case")]
3081pub enum MirrorReconcileResult {
3082    /// `dry_run: true` only — a priced plan. Nothing was spent.
3083    Planned {
3084        /// How many `(store, root)` capsules this node holds whose bonded coin is stale (`n`).
3085        capsules_stale: u32,
3086        /// Of those `n`, how many the wallet can actually afford to reconcile right now (`K`) —
3087        /// sized the SAME way, and BEFORE the same event, that a real run's
3088        /// [`Submitted`](Self::Submitted) counts are: see "The load-bearing invariant" above.
3089        /// `K < capsules_stale` is a normal answer, not a warning sign; a client renders it as
3090        /// "will reconcile K of n" rather than treating it as a partial failure.
3091        capsules_affordable: u32,
3092        /// The $DIG this plan would FREE, summed over the K coins it would reclaim — read from
3093        /// those coins, never recomputed from today's requirement.
3094        collateral_reclaimed_dig_base_units: u64,
3095        /// The $DIG this plan would RE-LOCK, `K × this epoch's margined requirement`.
3096        ///
3097        /// Deliberately NOT combined with
3098        /// [`collateral_reclaimed_dig_base_units`](Self::Planned::collateral_reclaimed_dig_base_units)
3099        /// into one "cost" figure: collateral is reclaimed and relocked, not spent, and the net
3100        /// $DIG movement is `collateral_relocked_dig_base_units -
3101        /// collateral_reclaimed_dig_base_units` — zero in the common case where the requirement
3102        /// has not moved. Presenting the round-trip as a single cost teaches an operator their
3103        /// reset burns $DIG, which is the money-lie class in the reassuring-looking direction's
3104        /// opposite: it makes a free action look expensive rather than a costly one look free.
3105        collateral_relocked_dig_base_units: u64,
3106        /// The ACTUAL cost of this plan: the XCH network fee, in mojos (`1e-12` XCH). The one
3107        /// figure in this result that is genuinely spent rather than reclaimed-and-relocked.
3108        fee_estimate_mojos: u64,
3109        /// The DISTINCT URL set(s) the `n` stale coins currently advertise, bounded.
3110        ///
3111        /// A `Vec<Vec<String>>` rather than one flattened list: coins created at different past
3112        /// times can each carry a DIFFERENT advertise URL, so a single list would either merge
3113        /// them (implying they agree when they may not) or silently show only one coin's memo. A
3114        /// client renders each inner list as one distinct "was advertising" group.
3115        stale_url_sets: Vec<Vec<String>>,
3116        /// The URL(s) this node's mirror coins would advertise once the K creates confirm — this
3117        /// node's CURRENT target, i.e. what `control.config.get`'s `mirror_advertise` reports NOW.
3118        url_current: Vec<String>,
3119        /// How many DISTINCT advertise URLs this node has held in the last 7 days.
3120        ///
3121        /// dig-node SPEC §25.13.8's rotating-address warning: a node whose public address keeps
3122        /// changing pays reconcile's fee repeatedly for a problem reconcile cannot fix. A client
3123        /// SHOULD surface this when it is greater than one or two, rather than only after the
3124        /// operator has already paid for several resets.
3125        targets_seen_last_7_days: u32,
3126    },
3127    /// Refused. Nothing was spent — see [`MirrorReconcileRefusal`] for why.
3128    Refused {
3129        /// Which of the ten reasons this refusal is.
3130        #[serde(flatten)]
3131        reason: MirrorReconcileRefusal,
3132        /// The URL(s) this node's existing mirror coins advertise, where the node can state it.
3133        /// `None` when the refusal itself means the node cannot say — e.g. `no_mirror_coins`
3134        /// leaves no bond to read a URL from at all.
3135        url_current: Option<Vec<String>>,
3136    },
3137    /// Reclaims were SUBMITTED to the mempool; matching recreates are OWED to a later pass — the
3138    /// only outcome an implementation may report once anything has been spent, because a create's
3139    /// confirmation is never observable synchronously (see "`completed`/`partial` do not exist"
3140    /// above). A `left_unchanged` of zero means every stale capsule was affordable and submitted;
3141    /// greater than zero is a NORMAL outcome, not an error — the caller can tell because the
3142    /// counts say so, not because a tag says "partial".
3143    Submitted {
3144        /// How many of the K sized-and-attempted reclaims the mempool ACCEPTED.
3145        reclaims_submitted: u32,
3146        /// Of the SAME K attempted, how many the mempool REJECTED (e.g. a coin spent elsewhere in
3147        /// a race). Distinct from [`left_unchanged`](Self::Submitted::left_unchanged): a rejected
3148        /// reclaim was ATTEMPTED and failed at runtime, an unchanged capsule was never attempted
3149        /// because K was sized before it.
3150        reclaims_rejected: u32,
3151        /// How many recreates are now OWED to the ordinary create pass — always equal to
3152        /// [`reclaims_submitted`](Self::Submitted::reclaims_submitted): only an accepted reclaim's
3153        /// collateral will ever confirm and become available to fund its matching create. Never
3154        /// created inside this call; see the type-level doc for why.
3155        recreates_owed: u32,
3156        /// How many stale capsules (`n − K`) this call left exactly as they were, because sizing
3157        /// K happened BEFORE any reclaim was attempted.
3158        left_unchanged: u32,
3159        /// Why the plan stopped short of every stale capsule. `None` when
3160        /// [`left_unchanged`](Self::Submitted::left_unchanged) is zero — there is nothing to
3161        /// explain when nothing was left behind.
3162        left_reason: Option<String>,
3163        /// The URL(s) the K submitted recreates are targeting — this node's CURRENT target, on
3164        /// the same terms as [`Planned::url_current`](Self::Planned::url_current).
3165        url_current: Vec<String>,
3166    },
3167}
3168
3169#[cfg(test)]
3170mod tests {
3171    use super::*;
3172    use serde_json::json;
3173
3174    #[test]
3175    fn status_result_round_trips_the_node_shape() {
3176        let v = json!({
3177            "running": true, "service": "dig-node", "version": "0.30.0", "commit": "abc",
3178            "protocol": "21", "uptime_secs": 5, "addr": "127.0.0.1:9256", "upstream": "https://rpc.dig.net",
3179            "cache": {"cap_bytes": 1024, "used_bytes": 10, "dir": "/c", "shared": false},
3180            "hosted_store_count": 2, "cached_capsule_count": 3, "pinned_store_count": 1,
3181            "sync": {"available": true}
3182        });
3183        let parsed: StatusResult = serde_json::from_value(v.clone()).unwrap();
3184        assert_eq!(serde_json::to_value(&parsed).unwrap(), v);
3185    }
3186
3187    #[test]
3188    fn config_result_keeps_upstream_override_null_when_unset() {
3189        let parsed = ConfigResult {
3190            addr: "127.0.0.1:9256".into(),
3191            port: "9256".into(),
3192            upstream: "https://rpc.dig.net".into(),
3193            upstream_override: None,
3194            cache_dir: "/c".into(),
3195            cache_shared: false,
3196            config_path: "/c/config.json".into(),
3197            sync_available: true,
3198            mirror_advertise: None,
3199        };
3200        let v = serde_json::to_value(&parsed).unwrap();
3201        assert_eq!(v["upstream_override"], json!(null));
3202        assert!(v.as_object().unwrap().contains_key("upstream_override"));
3203    }
3204
3205    #[test]
3206    fn pairing_poll_omits_token_until_approved() {
3207        let pending = PairingPollResult {
3208            status: "pending".into(),
3209            token: None,
3210        };
3211        let v = serde_json::to_value(&pending).unwrap();
3212        assert_eq!(v, json!({"status": "pending"}));
3213        let approved = PairingPollResult {
3214            status: "approved".into(),
3215            token: Some("deadbeef".into()),
3216        };
3217        assert_eq!(
3218            serde_json::to_value(&approved).unwrap(),
3219            json!({"status": "approved", "token": "deadbeef"})
3220        );
3221    }
3222
3223    // ---- PeerSoftware (dig_ecosystem#2215) ----
3224
3225    /// The mapping that matters most. Every peer built before #2215 advertises the LITERAL
3226    /// `"0.0.0"` — three of dig-gossip's four handshake send sites hardcoded it. A parser that
3227    /// maps only `""` to Unknown would therefore read the entire live fleet as "software version
3228    /// 0.0.0", which any later `>=` comparison treats as ancient. `""`, `"0.0.0"`, and anything
3229    /// unparseable must all be Unknown, and this test is that mapping's guard.
3230    #[test]
3231    fn unknown_covers_empty_the_legacy_sentinel_and_garbage() {
3232        for raw in [
3233            "",                       // a peer advertising nothing, or `off` coarsening
3234            "0.0.0",                  // the pre-#2215 legacy sentinel
3235            "   ",                    // whitespace only
3236            "dig-node",               // no version part
3237            "dig-node/",              // empty version part
3238            "dig-node/not-a-version", // unparseable version
3239            "/1.2.3",                 // empty product part
3240            "1.2.3",                  // bare version, no product
3241            "dig-node/0.0.0",         // the sentinel, however it is dressed up
3242        ] {
3243            assert_eq!(
3244                PeerSoftware::parse(raw),
3245                PeerSoftware::Unknown,
3246                "{raw:?} must map to Unknown"
3247            );
3248        }
3249    }
3250
3251    /// A well-formed `product/semver` advertisement is reported with all three parts, and `raw`
3252    /// preserves exactly what the peer sent so a diagnostic reader is never shown a value the peer
3253    /// did not actually advertise.
3254    #[test]
3255    fn reported_carries_product_version_and_the_raw_advertisement() {
3256        let parsed = PeerSoftware::parse("dig-node/0.99.1");
3257        let PeerSoftware::Reported {
3258            product,
3259            version,
3260            raw,
3261        } = parsed
3262        else {
3263            panic!("a well-formed advertisement must be Reported");
3264        };
3265        assert_eq!(product, "dig-node");
3266        assert_eq!(version, semver::Version::new(0, 99, 1));
3267        assert_eq!(raw, "dig-node/0.99.1");
3268    }
3269
3270    /// A product name may itself contain a `/`; only the LAST separator splits product from
3271    /// version. Pinning this stops a future reader from switching to a first-separator split,
3272    /// which would silently reclassify such a peer as Unknown.
3273    #[test]
3274    fn product_is_split_at_the_last_separator() {
3275        let PeerSoftware::Reported {
3276            product, version, ..
3277        } = PeerSoftware::parse("acme/dig-node/1.2.3")
3278        else {
3279            panic!("expected Reported");
3280        };
3281        assert_eq!(product, "acme/dig-node");
3282        assert_eq!(version, semver::Version::new(1, 2, 3));
3283    }
3284
3285    /// Surrounding whitespace is trimmed before parsing, and `raw` records the TRIMMED
3286    /// advertisement. CON-008 sanitization strips Unicode Cc/Cf from the wire value but not
3287    /// spaces, so a padded advertisement reaches this parser intact and must not be classified as
3288    /// unparseable merely for having been padded.
3289    #[test]
3290    fn surrounding_whitespace_is_trimmed_before_parsing() {
3291        let PeerSoftware::Reported {
3292            product,
3293            version,
3294            raw,
3295        } = PeerSoftware::parse("  dig-node/1.2.3	")
3296        else {
3297            panic!("a padded advertisement must still be Reported");
3298        };
3299        assert_eq!(product, "dig-node");
3300        assert_eq!(version, semver::Version::new(1, 2, 3));
3301        assert_eq!(raw, "dig-node/1.2.3", "raw must record the trimmed value");
3302    }
3303
3304    /// A pre-release/build-metadata semver survives intact, because that is what a nightly build
3305    /// advertises and dropping it would make every nightly indistinguishable from its release.
3306    #[test]
3307    fn prerelease_versions_are_preserved() {
3308        let PeerSoftware::Reported { version, raw, .. } =
3309            PeerSoftware::parse("dig-node/1.0.0-nightly.20260805")
3310        else {
3311            panic!("expected Reported");
3312        };
3313        assert_eq!(version.to_string(), "1.0.0-nightly.20260805");
3314        assert_eq!(raw, "dig-node/1.0.0-nightly.20260805");
3315    }
3316
3317    /// Unknown's JSON is a tagged object — never `"0.0.0"`, never `""`, never a null sitting in a
3318    /// version field where a consumer might read it as a number.
3319    #[test]
3320    fn unknown_serializes_as_a_tagged_object_with_no_version_field() {
3321        let v = serde_json::to_value(PeerSoftware::Unknown).unwrap();
3322        assert_eq!(v, json!({"kind": "unknown"}));
3323        assert!(
3324            v.get("version").is_none(),
3325            "Unknown must not carry a version field at all"
3326        );
3327    }
3328
3329    /// Both variants round-trip byte-identically, which is what lets a client re-encode a node's
3330    /// response unchanged.
3331    #[test]
3332    fn both_variants_round_trip_byte_identically() {
3333        for wire in [
3334            json!({"kind": "unknown"}),
3335            json!({
3336                "kind": "reported",
3337                "product": "dig-node",
3338                "version": "0.99.1",
3339                "raw": "dig-node/0.99.1"
3340            }),
3341        ] {
3342            let parsed: PeerSoftware = serde_json::from_value(wire.clone()).unwrap();
3343            assert_eq!(serde_json::to_value(&parsed).unwrap(), wire);
3344        }
3345    }
3346
3347    /// Parsing a wire string and serializing the result produces the documented JSON, so the two
3348    /// halves of the contract cannot drift from each other.
3349    #[test]
3350    fn parse_then_serialize_matches_the_documented_json() {
3351        assert_eq!(
3352            serde_json::to_value(PeerSoftware::parse("dig-node/0.99.1")).unwrap(),
3353            json!({
3354                "kind": "reported",
3355                "product": "dig-node",
3356                "version": "0.99.1",
3357                "raw": "dig-node/0.99.1"
3358            })
3359        );
3360        assert_eq!(
3361            serde_json::to_value(PeerSoftware::parse("0.0.0")).unwrap(),
3362            json!({"kind": "unknown"})
3363        );
3364    }
3365
3366    // ---- Trait-absence probes (dig_ecosystem#2215) ----
3367    //
3368    // `PeerSoftware` deriving `Ord` or `Default` would be a silent correctness regression rather
3369    // than a compile error anywhere, so it is pinned here. The probe exploits inherent-impl
3370    // precedence: `Probe::<T>::has_it()` resolves to the inherent impl (returning `true`) only when
3371    // `T` satisfies the bound, and otherwise falls back to the blanket trait impl (`false`).
3372    //
3373    // Each probe carries a CONTROL on a type that DOES implement the trait. Without the control, a
3374    // probe broken so that it always answers `false` would pass while proving nothing.
3375
3376    struct Probe<T>(core::marker::PhantomData<T>);
3377
3378    trait ProbeFallback {
3379        fn is_ord() -> bool {
3380            false
3381        }
3382    }
3383    impl<T> ProbeFallback for Probe<T> {}
3384
3385    impl<T: Ord> Probe<T> {
3386        fn is_ord() -> bool {
3387            true
3388        }
3389    }
3390
3391    struct PartialOrdProbe<T>(core::marker::PhantomData<T>);
3392    trait PartialOrdFallback {
3393        fn is_partial_ord() -> bool {
3394            false
3395        }
3396    }
3397    impl<T> PartialOrdFallback for PartialOrdProbe<T> {}
3398    impl<T: PartialOrd> PartialOrdProbe<T> {
3399        fn is_partial_ord() -> bool {
3400            true
3401        }
3402    }
3403
3404    /// A version comparison must be unreachable without first destructuring `Reported`, so that a
3405    /// caller cannot order `Unknown` against a real version — which, since every pre-#2215 peer is
3406    /// Unknown, would quietly become a verdict about most of the live network.
3407    #[test]
3408    fn peer_software_is_not_ordered() {
3409        assert!(
3410            Probe::<u32>::is_ord(),
3411            "control: the probe must detect a type that IS Ord, or it proves nothing"
3412        );
3413        assert!(
3414            !Probe::<PeerSoftware>::is_ord(),
3415            "PeerSoftware must not implement Ord — comparison belongs after destructuring Reported"
3416        );
3417    }
3418
3419    /// A defaulted `Unknown` appearing from nowhere is a different fact from a measured one, and
3420    /// `Default` would make the two indistinguishable at the point of construction.
3421    #[test]
3422    fn peer_software_has_no_default() {
3423        struct DefaultProbe<T>(core::marker::PhantomData<T>);
3424        trait DefaultFallback {
3425            fn is_default() -> bool {
3426                false
3427            }
3428        }
3429        impl<T> DefaultFallback for DefaultProbe<T> {}
3430        impl<T: Default> DefaultProbe<T> {
3431            fn is_default() -> bool {
3432                true
3433            }
3434        }
3435
3436        assert!(
3437            DefaultProbe::<String>::is_default(),
3438            "control: the probe must detect a type that IS Default, or it proves nothing"
3439        );
3440        assert!(
3441            !DefaultProbe::<PeerSoftware>::is_default(),
3442            "PeerSoftware must not implement Default"
3443        );
3444    }
3445
3446    // ---- SoftwareVersionDetail (dig_ecosystem#2215) ----
3447
3448    /// Each mode renders a value the PARSER reads back at the intended level of detail.
3449    ///
3450    /// The fixture uses a version with a non-zero minor AND a non-zero patch, because that is the
3451    /// only shape where `Full` and `Minor` differ — a `1.0.0` fixture would let a renderer that
3452    /// ignores the mode entirely pass.
3453    #[test]
3454    fn each_detail_mode_round_trips_to_the_intended_precision() {
3455        let v = semver::Version::new(0, 99, 1);
3456
3457        let full = SoftwareVersionDetail::Full.render("dig-node", &v);
3458        assert_eq!(full, "dig-node/0.99.1");
3459        assert_eq!(
3460            PeerSoftware::parse(&full),
3461            PeerSoftware::parse("dig-node/0.99.1")
3462        );
3463
3464        let minor = SoftwareVersionDetail::Minor.render("dig-node", &v);
3465        assert_ne!(minor, full, "Minor must actually coarsen");
3466        let PeerSoftware::Reported { version, .. } = PeerSoftware::parse(&minor) else {
3467            panic!("a coarsened advertisement must still be READABLE, not Unknown");
3468        };
3469        assert_eq!(version.major, 0);
3470        assert_eq!(version.minor, 99);
3471        assert_eq!(version.patch, 0, "the patch level is what Minor hides");
3472
3473        let off = SoftwareVersionDetail::Off.render("dig-node", &v);
3474        assert_eq!(off, "");
3475        assert_eq!(PeerSoftware::parse(&off), PeerSoftware::Unknown);
3476    }
3477
3478    /// `Minor` renders `MAJOR.MINOR.0`, NOT `MAJOR.MINOR`.
3479    ///
3480    /// A bare two-part `0.99` is not valid semver, so the parser would classify a peer that
3481    /// coarsened its build as Unknown — turning "tell them less" into "tell them nothing", which
3482    /// is what `Off` is for. This test is the guard on that distinction.
3483    #[test]
3484    fn minor_mode_stays_valid_semver_rather_than_collapsing_to_unknown() {
3485        let rendered =
3486            SoftwareVersionDetail::Minor.render("dig-node", &semver::Version::new(1, 4, 7));
3487        assert_eq!(rendered, "dig-node/1.4.0");
3488        assert_ne!(
3489            PeerSoftware::parse(&rendered),
3490            PeerSoftware::Unknown,
3491            "a coarsened build must remain readable; `product/1.4` would not be"
3492        );
3493    }
3494
3495    /// Coarsening strips pre-release and build metadata. A nightly's identifier is more precisely
3496    /// identifying than the patch number it accompanies, so leaving it in place would make `Minor`
3497    /// coarsen nothing at all for exactly the builds that most want it.
3498    #[test]
3499    fn minor_mode_strips_prerelease_and_build_metadata() {
3500        let v: semver::Version = "1.0.0-nightly.20260805+sha.abc123".parse().unwrap();
3501        let rendered = SoftwareVersionDetail::Minor.render("dig-node", &v);
3502        assert_eq!(rendered, "dig-node/1.0.0");
3503        assert!(
3504            !rendered.contains("nightly"),
3505            "the nightly identifier must not survive coarsening"
3506        );
3507        assert!(
3508            !rendered.contains("abc123"),
3509            "build metadata must not survive coarsening"
3510        );
3511    }
3512
3513    /// `Off` renders the empty string for ANY version, which is what makes it indistinguishable
3514    /// from a peer built before the field existed.
3515    #[test]
3516    fn off_mode_reveals_nothing_for_any_version() {
3517        for v in ["0.0.1", "1.2.3", "99.99.99-rc.1"] {
3518            let rendered = SoftwareVersionDetail::Off.render("dig-node", &v.parse().unwrap());
3519            assert_eq!(
3520                rendered, "",
3521                "Off must reveal nothing, including the product name"
3522            );
3523        }
3524    }
3525
3526    /// The default is the most informative setting: the diagnostic value is the reason the field
3527    /// exists, and an operator who disagrees opts down explicitly.
3528    #[test]
3529    fn detail_defaults_to_full() {
3530        assert_eq!(
3531            SoftwareVersionDetail::default(),
3532            SoftwareVersionDetail::Full
3533        );
3534    }
3535
3536    /// The wire tokens are the lowercase words an operator writes in a config file, and they are a
3537    /// published contract once a config carries them.
3538    #[test]
3539    fn detail_uses_lowercase_wire_tokens() {
3540        for (mode, token) in [
3541            (SoftwareVersionDetail::Full, "\"full\""),
3542            (SoftwareVersionDetail::Minor, "\"minor\""),
3543            (SoftwareVersionDetail::Off, "\"off\""),
3544        ] {
3545            assert_eq!(serde_json::to_string(&mode).unwrap(), token);
3546            assert_eq!(
3547                serde_json::from_str::<SoftwareVersionDetail>(token).unwrap(),
3548                mode
3549            );
3550        }
3551    }
3552
3553    // ---- Gate round 1 regressions (dig_ecosystem#2215) ----
3554
3555    /// **`PartialOrd` is the hazard the `Ord` probe misses.** `Ord: PartialOrd`, so a type can
3556    /// derive only `PartialOrd` — satisfying an `Ord`-only probe — while `Unknown < Reported(..)`
3557    /// still compiles and evaluates. That one-word derive would sort `Unknown` below every real
3558    /// version, which is the verdict-about-the-live-network the contract forbids. SPEC §4.1 names
3559    /// all three traits; this pins the weakest of them, which subsumes `Ord`.
3560    #[test]
3561    fn peer_software_is_not_partially_ordered_either() {
3562        assert!(
3563            PartialOrdProbe::<f64>::is_partial_ord(),
3564            "control: the probe must detect a type that IS PartialOrd but NOT Ord, or it proves              nothing about the gap between the two"
3565        );
3566        assert!(
3567            PartialOrdProbe::<u32>::is_partial_ord(),
3568            "control: a fully-ordered type must also be detected"
3569        );
3570        assert!(
3571            !PartialOrdProbe::<PeerSoftware>::is_partial_ord(),
3572            "PeerSoftware must implement neither PartialOrd nor Ord"
3573        );
3574    }
3575
3576    /// **The sentinel is VERSION ZERO, a class — not the three-character string `\"0.0.0\"`.**
3577    /// The constant's doc, `parse`'s doc, and SPEC §4.1 all state the rule over the class, so a
3578    /// string comparison lets `0.0.0+build`, `0.0.0-rc.1`, and `0.0.0-0` through as a *reported*
3579    /// version zero — the exact reading every one of those three prose statements forbids.
3580    #[test]
3581    fn version_zero_is_unknown_however_it_is_decorated() {
3582        for raw in [
3583            "dig-node/0.0.0",
3584            "dig-node/0.0.0+build",
3585            "dig-node/0.0.0-rc.1",
3586            "x/0.0.0-0",
3587            "dig-node/0.0.0-alpha+sha.abc123",
3588        ] {
3589            assert_eq!(
3590                PeerSoftware::parse(raw),
3591                PeerSoftware::Unknown,
3592                "{raw:?} is version zero and must be Unknown"
3593            );
3594        }
3595    }
3596
3597    /// A version that is merely CLOSE to zero is still a real build and must be reported — without
3598    /// this, a parser that mapped everything below `0.1.0` to Unknown would pass the test above.
3599    #[test]
3600    fn a_nonzero_version_near_zero_is_still_reported() {
3601        for raw in ["dig-node/0.0.1", "dig-node/0.1.0", "dig-node/0.0.1-rc.1"] {
3602            assert_ne!(
3603                PeerSoftware::parse(raw),
3604                PeerSoftware::Unknown,
3605                "{raw:?} is a real build, not the sentinel"
3606            );
3607        }
3608    }
3609
3610    /// **`render`'s stated invariant, tested over the class it is stated over.**
3611    ///
3612    /// The doc promises: every rendering is either the empty string or a value `parse` reads back
3613    /// as `Reported`. A `1.4.7` fixture cannot see the case that breaks it — a `0.0.x` build, whose
3614    /// `MAJOR.MINOR.0` coarsening IS version zero and therefore reads as Unknown. That is the same
3615    /// Minor-collapses-into-Off defect as the two-part spelling, arriving through the other door.
3616    #[test]
3617    fn every_rendering_is_empty_or_readable() {
3618        let versions = [
3619            "0.0.1",
3620            "0.0.7",
3621            "0.0.99", // the class the 1.4.7 fixture cannot see
3622            "0.1.0",
3623            "0.99.1",
3624            "1.0.0",
3625            "1.4.7",
3626            "10.20.30",
3627            "1.0.0-nightly.20260805+sha.abc123",
3628            "0.0.1-rc.1",
3629        ];
3630        for mode in [
3631            SoftwareVersionDetail::Full,
3632            SoftwareVersionDetail::Minor,
3633            SoftwareVersionDetail::Off,
3634        ] {
3635            for v in versions {
3636                let rendered = mode.render("dig-node", &v.parse().unwrap());
3637                if rendered.is_empty() {
3638                    continue;
3639                }
3640                assert_ne!(
3641                    PeerSoftware::parse(&rendered),
3642                    PeerSoftware::Unknown,
3643                    "{mode:?} rendered {rendered:?} for {v}, which reads back as Unknown — a                      non-empty rendering must always be readable"
3644                );
3645            }
3646        }
3647    }
3648
3649    /// `Minor` on a `0.0.x` build advertises NOTHING, deliberately.
3650    ///
3651    /// Hiding the patch of a `0.0.x` version leaves only version zero, which the wire reserves as
3652    /// the "unknown" sentinel. There is no coarser representable value, so the honest rendering is
3653    /// the empty string rather than the sentinel dressed up as a report. This differs from the
3654    /// `0.99` case: there a representable coarse value existed and the wrong spelling was chosen;
3655    /// here none exists.
3656    #[test]
3657    fn minor_of_a_zero_zero_build_advertises_nothing_rather_than_the_sentinel() {
3658        let rendered = SoftwareVersionDetail::Minor.render("dig-node", &"0.0.7".parse().unwrap());
3659        assert_eq!(rendered, "");
3660        assert_ne!(
3661            rendered, "dig-node/0.0.0",
3662            "the sentinel must never be ADVERTISED; it is only ever received from a legacy peer"
3663        );
3664    }
3665
3666    /// **Tripwire, not a guard.** `raw` is reconstructible from `product` + `version` for every
3667    /// string the current grammar accepts, because `semver::Version` re-renders losslessly. This
3668    /// asserts that equivalence deliberately.
3669    ///
3670    /// **When this test FAILS, `raw` has become load-bearing** — the grammar has started accepting
3671    /// something non-canonical (a `v` prefix, a two-part version, a vendor suffix) and `raw` is now
3672    /// the only record of what the peer actually sent. Do not "fix" it by deleting the field;
3673    /// replace this test with real assertions on the divergent inputs.
3674    #[test]
3675    fn raw_is_still_reconstructible_from_the_parsed_parts() {
3676        for advertised in [
3677            "dig-node/0.0.1",
3678            "dig-node/0.99.1",
3679            "dig-node/1.0.0-nightly.20260805",
3680            "dig-node/1.0.0+sha.abc123",
3681            "dig-node/1.0.0-rc.1+build.7",
3682            "acme/dig-node/1.2.3",
3683        ] {
3684            let PeerSoftware::Reported {
3685                product,
3686                version,
3687                raw,
3688            } = PeerSoftware::parse(advertised)
3689            else {
3690                panic!("{advertised:?} must be Reported");
3691            };
3692            assert_eq!(
3693                raw,
3694                format!("{product}/{version}"),
3695                "raw diverged from the parsed parts for {advertised:?} — `raw` is now                  load-bearing; see this test's doc comment before changing anything"
3696            );
3697        }
3698    }
3699
3700    /// **`remove` can report that it removed NOTHING, and the two answers are distinguishable on
3701    /// the wire.**
3702    ///
3703    /// The fixture varies ONE thing — the outcome — and holds `ip` and `banned` fixed, so the
3704    /// difference it detects can only be the outcome itself. A result type carrying `removed: true`
3705    /// unconditionally would make these two JSON documents identical, which is exactly the state
3706    /// where an operator reads "un-trusted" off a call that un-trusted nothing.
3707    #[test]
3708    fn a_removal_that_matched_nothing_is_not_serialised_as_a_removal() {
3709        let removed = ChiaPeersRemoveResult {
3710            outcome: ChiaPeerRemovalOutcome::Removed,
3711            ip: "203.0.113.7".into(),
3712            banned: false,
3713        };
3714        let missed = ChiaPeersRemoveResult {
3715            outcome: ChiaPeerRemovalOutcome::NoSuchPeer,
3716            ..removed.clone()
3717        };
3718
3719        let a = serde_json::to_value(&removed).unwrap();
3720        let b = serde_json::to_value(&missed).unwrap();
3721        assert_ne!(a, b, "the two outcomes must differ on the wire");
3722        assert_eq!(a["outcome"], "removed");
3723        assert_eq!(b["outcome"], "no_such_peer");
3724
3725        // No field of the miss may be a success flag a client could render as one. Every other
3726        // field is identical by construction, so this asserts the outcome is the ONLY signal.
3727        let miss_obj = b.as_object().unwrap();
3728        assert!(
3729            !miss_obj
3730                .values()
3731                .any(|v| v == &serde_json::Value::Bool(true)),
3732            "a miss must carry no `true` a client can mistake for success: {b}"
3733        );
3734
3735        // And it round-trips, so a consumer cannot lose the distinction by decoding.
3736        let back: ChiaPeersRemoveResult = serde_json::from_value(b).unwrap();
3737        assert_eq!(back.outcome, ChiaPeerRemovalOutcome::NoSuchPeer);
3738    }
3739
3740    /// **An unpolled peer serialises as `null`, never as height zero.**
3741    ///
3742    /// `peak_height` is the one signal for judging whether a peer trusted WITHOUT corroboration is
3743    /// current or stuck. The fixture holds a genuinely-observed `0` beside the unobserved peer,
3744    /// because a `u32` field collapses those two into the same byte and the collapse is the defect.
3745    #[test]
3746    fn an_unobserved_peak_is_null_and_an_observed_zero_is_not() {
3747        let entry = |peak| ChiaPeerEntry {
3748            ip: "203.0.113.7".into(),
3749            port: 8444,
3750            peak_height: peak,
3751            user_managed: true,
3752            banned: false,
3753        };
3754        let unobserved = serde_json::to_value(entry(None)).unwrap();
3755        let genesis = serde_json::to_value(entry(Some(0))).unwrap();
3756
3757        // Indexing a MISSING key also yields `Null`, so presence is asserted first — otherwise
3758        // an implementation that skipped the field entirely would pass this test while telling a
3759        // reader nothing at all about the peer.
3760        assert!(
3761            unobserved.get("peak_height").is_some(),
3762            "the key must be PRESENT and null, not omitted: {unobserved}"
3763        );
3764        assert_eq!(unobserved["peak_height"], serde_json::Value::Null);
3765        assert_eq!(genesis["peak_height"], 0);
3766        assert_ne!(
3767            unobserved["peak_height"], genesis["peak_height"],
3768            "unobservable and observed-zero must not render the same"
3769        );
3770    }
3771
3772    /// **A banned peer is enumerable — `list` is the only place the blocklist is visible.**
3773    #[test]
3774    fn the_peer_list_can_carry_a_banned_entry() {
3775        let listed = ChiaPeersListResult {
3776            peers: vec![ChiaPeerEntry {
3777                ip: "203.0.113.9".into(),
3778                port: 8444,
3779                peak_height: None,
3780                user_managed: false,
3781                banned: true,
3782            }],
3783        };
3784        let json = serde_json::to_value(&listed).unwrap();
3785        assert_eq!(json["peers"][0]["banned"], true);
3786        let back: ChiaPeersListResult = serde_json::from_value(json).unwrap();
3787        assert!(back.peers[0].banned);
3788    }
3789
3790    /// **The add result carries the warning TEXT, not only a flag saying a cost was paid.**
3791    ///
3792    /// The field exists so a client can quote the node's own sentence rather than restate it and
3793    /// drift. A boolean cannot be quoted, so the assertion is that a quotable, non-empty string
3794    /// naming the bypass reaches the wire under a stable key.
3795    #[test]
3796    fn the_add_result_carries_a_quotable_bypass_notice() {
3797        let json = serde_json::to_value(ChiaPeersAddResult {
3798            added: true,
3799            ip: "203.0.113.7".into(),
3800            port: 8444,
3801            corroboration_bypassed: true,
3802            notice: "believed WITHOUT corroboration".into(),
3803        })
3804        .unwrap();
3805
3806        let notice = json["notice"]
3807            .as_str()
3808            .expect("notice is a string on the wire");
3809        assert!(
3810            !notice.trim().is_empty(),
3811            "an empty notice discloses nothing"
3812        );
3813        assert!(
3814            notice.to_lowercase().contains("corroboration"),
3815            "the notice must name the cost it exists to disclose: {notice}"
3816        );
3817    }
3818}
3819
3820/// Why a node cannot state this epoch's collateral requirement.
3821///
3822/// Each variant names a DIFFERENT missing fact, because the remedies differ: a node that has not
3823/// censused the epoch needs to run the census, whereas a node inside the finality depth needs only
3824/// to wait for the chain to settle. Collapsing them into one "unavailable" would hand every client
3825/// the same unactionable sentence.
3826#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3827#[serde(rename_all = "snake_case")]
3828pub enum CollateralUnknownReason {
3829    /// This node has not censused the epoch, so it holds no record to answer from.
3830    NotCensused,
3831    /// The epoch's census inputs are not yet final — the node is inside
3832    /// `CENSUS_FINALITY_DEPTH_BLOCKS` of the chain tip and any figure it derived could still move.
3833    BehindFinalityDepth,
3834    /// The node holds a record for the epoch but could not read it.
3835    RecordUnreadable,
3836    /// The node cannot see the chain at all, so it cannot know whether a record should exist.
3837    NoChainSource,
3838    /// The node can read the epoch's record, but cannot read its OWN $DIG balance, so it cannot
3839    /// tell whether it could fund what the record prices.
3840    ///
3841    /// The one WALLET-shaped reason, and it exists because every other reason in this enum points
3842    /// an operator at the census, the record, or the chain. A node whose census is healthy and
3843    /// whose wallet read failed, reported as [`RecordUnreadable`](Self::RecordUnreadable), tells
3844    /// that operator to repair a census that is working — the same remedy misdirection the
3845    /// `withheld`/`disabled`/`reclaiming` split exists to prevent.
3846    ///
3847    /// It is emphatically NOT a shortfall. Answering
3848    /// [`Unfunded`](crate::results::MirrorBondState::Unfunded) here would assert a gap the node has
3849    /// no evidence for, on the surface an operator uses to decide whether to alarm.
3850    BalanceUnreadable,
3851}
3852
3853impl CollateralUnknownReason {
3854    /// Every reason, for exhaustive rendering and for the wire-token uniqueness KAT.
3855    pub const ALL: &'static [CollateralUnknownReason] = &[
3856        CollateralUnknownReason::NotCensused,
3857        CollateralUnknownReason::BehindFinalityDepth,
3858        CollateralUnknownReason::RecordUnreadable,
3859        CollateralUnknownReason::NoChainSource,
3860        CollateralUnknownReason::BalanceUnreadable,
3861    ];
3862
3863    /// The stable snake_case wire token, matching the `reason` field.
3864    pub const fn as_wire(self) -> &'static str {
3865        match self {
3866            CollateralUnknownReason::NotCensused => "not_censused",
3867            CollateralUnknownReason::BehindFinalityDepth => "behind_finality_depth",
3868            CollateralUnknownReason::RecordUnreadable => "record_unreadable",
3869            CollateralUnknownReason::NoChainSource => "no_chain_source",
3870            CollateralUnknownReason::BalanceUnreadable => "balance_unreadable",
3871        }
3872    }
3873}
3874
3875/// `control.collateral.requirement` — this epoch's per-store collateral requirement, or a named
3876/// reason the node cannot state it.
3877///
3878/// **UNKNOWN is a first-class answer, not an error.** A node that has not censused the epoch, or
3879/// that is inside the census finality depth, is not broken; it simply does not know yet. Making
3880/// that a tagged variant rather than an optional number means there is no representable state in
3881/// which a client holds a figure it has not been given — which is what dig-app `SPEC.md` §3.7b
3882/// requires when it forbids any path that renders an absent requirement as a zero cost.
3883///
3884/// **The census inputs travel with the figure on purpose.** A client that can show only the number
3885/// can say the price moved; a client holding `stores`, `owners`, `multiplier_micros` and
3886/// `handicap_dig_base_units` can say WHY it moved. The per-epoch record already holds all four, so
3887/// carrying them costs the node nothing and is the difference between a figure an operator can
3888/// weigh and one they can only accept.
3889///
3890/// **The margin is deliberately absent here.** The requirement is a consensus-derived value every
3891/// node derives identically; the margin is a local operator preference that MUST NOT be a consensus
3892/// input. Returning them from one method would invite exactly the conflation dig-app `SPEC.md`
3893/// §3.7b forbids — read the margin from
3894/// [`CollateralMarginResult`] instead.
3895#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3896#[serde(tag = "state", rename_all = "snake_case")]
3897pub enum CollateralRequirementResult {
3898    /// The node holds a final record for the epoch and states its requirement.
3899    Known {
3900        /// The epoch this requirement governs, one-based.
3901        epoch: u64,
3902        /// The collateral protocol version that COMPUTED this epoch.
3903        ///
3904        /// Travels with the figure because the model is versioned and upgradable: a client that
3905        /// knows only the number cannot tell a disagreement from a rule change.
3906        protocol_version: u16,
3907        /// The per-store requirement, in DIG base units, BEFORE any local safety margin.
3908        required_per_store_dig_base_units: u64,
3909        /// Qualifying `(owner, store, root)` advertisements counted in the census.
3910        ///
3911        /// An advertisement count, never a node count: one owner publishing two roots for one store
3912        /// id contributes two.
3913        stores: u64,
3914        /// Distinct owner puzzle hashes across those advertisements.
3915        ///
3916        /// Not a node count and not an operator count. A surface displaying it MUST say
3917        /// "collateralised owners".
3918        owners: u64,
3919        /// The controller multiplier for the epoch, in millionths (`MULT_SCALE` = 1_000_000).
3920        multiplier_micros: u64,
3921        /// The small-network handicap applied for the epoch, in DIG base units.
3922        handicap_dig_base_units: u64,
3923    },
3924    /// The node cannot state the requirement, and names which fact is missing.
3925    Unknown {
3926        /// Which fact the node is missing.
3927        reason: CollateralUnknownReason,
3928    },
3929}
3930
3931/// The node's funding position against its own recommended $DIG buffer.
3932///
3933/// **The state is carried, never re-derived by each client.** Every field needed to compute it does
3934/// travel in [`CollateralBufferResult::Known`], so a client COULD compare numbers itself — and two
3935/// clients that did would pick their own thresholds and disagree. The one that disagreed about a
3936/// funding warning is the one an operator would act on, so which state this node is in is the
3937/// node's answer, not a rendering decision.
3938///
3939/// **Whether a state is worth interrupting somebody over is the CLIENT's decision; this enum states
3940/// only what is true.** [`is_shortfall`](CollateralFundingState::is_shortfall) names the two states
3941/// in which some epoch is not covered. [`BelowRecommendedBuffer`](Self::BelowRecommendedBuffer) is
3942/// deliberately not one of them: a healthy node sits there much of the time, and a client that
3943/// raised a recurring alert for it would teach an operator to dismiss the two that matter.
3944#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3945#[serde(rename_all = "snake_case")]
3946pub enum CollateralFundingState {
3947    /// Cannot cover the CURRENT epoch: stores this node serves are already going uncollateralised.
3948    ShortNow,
3949    /// Covers the current epoch but could not cover the NEXT one if the requirement rose to the
3950    /// escalation ceiling. The rise is a bound, not a prediction — see
3951    /// `escalation_ceiling_micros` — so this state says the node has no room for the worst case,
3952    /// not that the worst case is coming.
3953    DangerouslyLow,
3954    /// Covers several epochs at the ceiling but holds less than the recommended buffer: funded, with
3955    /// no cushion. A READOUT, never a notification.
3956    BelowRecommendedBuffer,
3957    /// Holds at least the recommended buffer over the stated horizon.
3958    Funded,
3959}
3960
3961impl CollateralFundingState {
3962    /// Every state, for exhaustive rendering and for the wire-token uniqueness KAT.
3963    pub const ALL: &'static [CollateralFundingState] = &[
3964        CollateralFundingState::ShortNow,
3965        CollateralFundingState::DangerouslyLow,
3966        CollateralFundingState::BelowRecommendedBuffer,
3967        CollateralFundingState::Funded,
3968    ];
3969
3970    /// The stable snake_case wire token, matching the `funding_state` field.
3971    pub const fn as_wire(self) -> &'static str {
3972        match self {
3973            CollateralFundingState::ShortNow => "short_now",
3974            CollateralFundingState::DangerouslyLow => "dangerously_low",
3975            CollateralFundingState::BelowRecommendedBuffer => "below_recommended_buffer",
3976            CollateralFundingState::Funded => "funded",
3977        }
3978    }
3979
3980    /// Is some epoch actually UNCOVERED — now, or next at the escalation ceiling?
3981    ///
3982    /// A statement about the world, not about a client's UI. It is the honest input to a client's
3983    /// own decision about what deserves an interruption, and it excludes
3984    /// [`BelowRecommendedBuffer`](Self::BelowRecommendedBuffer) because nothing is uncovered there.
3985    pub const fn is_shortfall(self) -> bool {
3986        matches!(
3987            self,
3988            CollateralFundingState::ShortNow | CollateralFundingState::DangerouslyLow
3989        )
3990    }
3991}
3992
3993/// Why a node cannot state its recommended buffer or its funding position.
3994///
3995/// Separate from [`CollateralUnknownReason`] because the buffer needs three facts the epoch
3996/// requirement does not, and each has a different remedy: a node missing its served set needs its
3997/// hosted-store view, one missing reclaim state needs its transition bookkeeping, and one missing
3998/// its balance needs a chain source. Collapsing them into the requirement's reasons would answer
3999/// every one of those with "not censused", which is both false and unactionable.
4000#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4001#[serde(rename_all = "snake_case")]
4002pub enum CollateralBufferUnknownReason {
4003    /// The epoch requirement itself is unknown, so every term scaled by it is unknown too. Call
4004    /// `control.collateral.requirement` for WHICH fact is missing — this variant deliberately does
4005    /// not restate that taxonomy, because a copy of it here would drift from the original.
4006    RequirementUnknown,
4007    /// The node cannot enumerate the `(owner, store, root)` pairs IT serves. Nothing may be
4008    /// substituted for this: the census `stores` figure counts network-wide advertisements and is
4009    /// not a count of this node's own set.
4010    ServedSetUnknown,
4011    /// The node cannot read how much collateral is still locked against positions it has not yet
4012    /// reclaimed, so the transition-overlap term is unknown.
4013    ReclaimStateUnknown,
4014    /// The node cannot read its own spendable $DIG, so it can state a buffer but not a position
4015    /// against it.
4016    BalanceUnknown,
4017}
4018
4019impl CollateralBufferUnknownReason {
4020    /// Every reason, for exhaustive rendering and for the wire-token uniqueness KAT.
4021    pub const ALL: &'static [CollateralBufferUnknownReason] = &[
4022        CollateralBufferUnknownReason::RequirementUnknown,
4023        CollateralBufferUnknownReason::ServedSetUnknown,
4024        CollateralBufferUnknownReason::ReclaimStateUnknown,
4025        CollateralBufferUnknownReason::BalanceUnknown,
4026    ];
4027
4028    /// The stable snake_case wire token, matching the `reason` field.
4029    pub const fn as_wire(self) -> &'static str {
4030        match self {
4031            CollateralBufferUnknownReason::RequirementUnknown => "requirement_unknown",
4032            CollateralBufferUnknownReason::ServedSetUnknown => "served_set_unknown",
4033            CollateralBufferUnknownReason::ReclaimStateUnknown => "reclaim_state_unknown",
4034            CollateralBufferUnknownReason::BalanceUnknown => "balance_unknown",
4035        }
4036    }
4037}
4038
4039/// `control.collateral.buffer` — the $DIG this node recommends holding, and where it stands against
4040/// that figure.
4041///
4042/// **Every amount here is in DIG BASE UNITS.** $DIG carries 3 decimals, so one base unit is
4043/// `0.001 DIG`. It is NOT a mojo: a mojo is XCH's base unit at `1e-12` XCH, nine orders of magnitude
4044/// away. `margin_bp` is the one field that is not an amount and is in BASIS POINTS (`100` is `+1%`),
4045/// the unit the collateral crate's own presets and rounding use, never converted.
4046///
4047/// **UNKNOWN is a first-class answer, and a zero here is the money lie in its purest form.** On
4048/// `control.collateral.requirement` a fabricated zero reads as a free requirement; here it reads as
4049/// *no buffer needed*, which is worse, because an operator acting on it would post nothing and lose
4050/// the epoch. A node that cannot enumerate the pairs it serves, cannot read its reclaim state, or
4051/// cannot see its balance says so WITH the reason — the tagged variant means there is no
4052/// representable state in which a client holds a figure it was never given.
4053///
4054/// **The horizon travels with the buffer, because a buffer without one is a magic number.** The
4055/// escalation of the per-store requirement is bounded at `+12.5%` per epoch and COMPOUNDS: about
4056/// x1.12 at one epoch, x1.60 at four, x4.62 at thirteen. Two nodes quoting a buffer over different
4057/// horizons are answering different questions, and neither figure can be checked without knowing
4058/// which. `escalation_ceiling_micros` states the multiplier this node assumed, and it is a WORST
4059/// CASE, not a forecast: inside the controller's dead band the multiplier does not move at all.
4060///
4061/// **The total is authoritative; the terms are the working.** `recommended_buffer_dig_base_units`
4062/// is the figure to hold and the figure `funding_state` was decided against. The other fields exist
4063/// so a client can show WHY that number is what it is — which is the difference between a figure an
4064/// operator can weigh and one they can only accept — and a client MUST NOT re-add them and prefer
4065/// its own sum, because rounding lives in the node's arithmetic, not the client's.
4066///
4067/// **Why this is not part of [`CollateralRequirementResult`].** The requirement is consensus-derived
4068/// and every node derives it identically; the buffer is LOCAL — it depends on the pairs this
4069/// particular node serves, on an operator preference (the margin), and on a horizon this node chose.
4070/// 0.23.0 kept the margin out of the requirement on exactly that grounds, and the same reasoning
4071/// binds harder here, because a buffer folded into the requirement's result would make one node's
4072/// preferences look like the network's price.
4073#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4074#[serde(tag = "state", rename_all = "snake_case")]
4075pub enum CollateralBufferResult {
4076    /// The node can state its buffer and its position against it.
4077    Known {
4078        /// The epoch the underlying requirement governs, one-based.
4079        epoch: u64,
4080        /// The collateral protocol version that COMPUTED that epoch, carried for the same reason
4081        /// [`CollateralRequirementResult::Known`] carries it: a client holding only numbers cannot
4082        /// tell a disagreement from a rule change.
4083        protocol_version: u16,
4084        /// Where this node stands against `recommended_buffer_dig_base_units`.
4085        funding_state: CollateralFundingState,
4086        /// The $DIG this node recommends holding, in DIG base units. The authoritative figure.
4087        recommended_buffer_dig_base_units: u64,
4088        /// The spendable $DIG the node compared against the buffer, in DIG base units.
4089        ///
4090        /// Carried so `funding_state` is checkable rather than merely assertive: a client can show
4091        /// the two numbers the verdict came from. It is what is SPENDABLE — collateral already
4092        /// locked is not in it.
4093        spendable_dig_base_units: u64,
4094        /// Qualifying `(owner, store, root)` pairs THIS NODE serves.
4095        ///
4096        /// This node's own set, never the census `stores` count, which is a network-wide
4097        /// advertisement count. Multiplying the census figure by the requirement is the confident,
4098        /// badly wrong number this field exists to prevent.
4099        pairs_served_by_this_node: u64,
4100        /// The epoch's per-store requirement, in DIG base units, BEFORE any local safety margin —
4101        /// the same value `control.collateral.requirement` returns.
4102        required_per_store_dig_base_units: u64,
4103        /// The local safety margin in force, in BASIS POINTS (`100` is `+1%`).
4104        margin_bp: u64,
4105        /// Collateral still locked against positions this node has not yet reclaimed, in DIG base
4106        /// units.
4107        ///
4108        /// A transition overlap: during the changeover the node must be able to cover the new
4109        /// epoch while the previous epoch's posting is not yet back. It is NOT derivable from any
4110        /// other field here, which is why a node that cannot read its reclaim state answers
4111        /// [`ReclaimStateUnknown`](CollateralBufferUnknownReason::ReclaimStateUnknown) rather than
4112        /// omitting the term.
4113        overlap_dig_base_units: u64,
4114        /// The headroom included for the requirement escalating over `horizon_epochs`, in DIG base
4115        /// units. Also not derivable client-side, because it depends on the horizon and ceiling
4116        /// this node chose.
4117        escalation_headroom_dig_base_units: u64,
4118        /// How many future epochs the headroom covers. Never implied, never defaulted by a reader:
4119        /// the same buffer over a different horizon is a different claim.
4120        horizon_epochs: u32,
4121        /// The compounded WORST-CASE escalation multiplier assumed over `horizon_epochs`, in
4122        /// millionths (`1_000_000` is x1.0).
4123        ///
4124        /// A ceiling, not a forecast. Escalation is capped at `+12.5%` per epoch, so four epochs
4125        /// bound at roughly `1_601_806` (x1.60); in the dead band the multiplier does not move at
4126        /// all and the realised figure is `1_000_000`. A surface presenting this as an expectation
4127        /// would tell an operator to hold money for a rise the controller may never make.
4128        escalation_ceiling_micros: u64,
4129    },
4130    /// The node cannot state the buffer, and names which fact is missing.
4131    Unknown {
4132        /// Which fact the node is missing.
4133        reason: CollateralBufferUnknownReason,
4134    },
4135}
4136
4137/// `control.collateral.margin.get` / `.set` — the node's LOCAL safety margin.
4138///
4139/// `.set` returns the margin now in force, so a caller never has to re-read to learn what was
4140/// applied.
4141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4142pub struct CollateralMarginResult {
4143    /// The margin in BASIS POINTS over the requirement (`100` is +1%).
4144    ///
4145    /// The unit is basis points and is never converted, because it is the unit
4146    /// `dig_mirror_collateral::apply_safety_margin` takes and the one dig-app `SPEC.md` §3.7b
4147    /// normatively fixes. A conversion performed independently by two surfaces is a money-path
4148    /// drift bug.
4149    pub margin_bp: u64,
4150}