Skip to main content

dig_node_control_interface/
results.rs

1//! Typed result payloads for the control methods.
2//!
3//! Each struct is field-for-field identical to what dig-node emits (snake_case wire fields), so a
4//! client deserializes the node's real response and re-serializes the same bytes — the property the
5//! conformance KATs pin. Genuinely open/proxied shapes (the updater beacon's status, the peer-pool
6//! snapshot, the pairing list) stay [`serde_json::Value`] on the call's `Output` rather than being
7//! frozen into a struct that would drift from the proxied source.
8
9use serde::{Deserialize, Serialize};
10
11/// The on-disk content-cache view (`control.cache.get`, and embedded in [`StatusResult`]).
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct CacheView {
14    /// The configured cache size cap, in bytes.
15    pub cap_bytes: u64,
16    /// Bytes currently used on disk.
17    pub used_bytes: u64,
18    /// The cache directory.
19    pub dir: String,
20    /// Whether the cache directory is the machine-wide shared cache.
21    pub shared: bool,
22}
23
24/// The §21 sync availability flag embedded in [`StatusResult`].
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct SyncAvailability {
27    /// Whether authenticated §21 whole-store sync is available on this node.
28    pub available: bool,
29}
30
31/// How much of its own build a node reveals when it advertises (dig_ecosystem#2215).
32///
33/// Advertising an exact build is a fingerprinting aid — it tells an observer precisely which peers
34/// run a version with a publicly disclosed defect. This is the operator's dial between that cost
35/// and the diagnostic value of knowing what the network is running.
36///
37/// It lives here, beside [`PeerSoftware`], because rendering and parsing are two halves of one
38/// format: a node that hand-rolled its own `product/version` string would be re-implementing half
39/// the contract, and the two halves would drift. A node picks a mode; this type renders it.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum SoftwareVersionDetail {
43    /// Advertise the exact build, e.g. `dig-node/0.99.1`. The default: the diagnostic value is why
44    /// the field exists, and an operator who disagrees opts down explicitly.
45    #[default]
46    Full,
47    /// Advertise only the major and minor level, e.g. `dig-node/0.99.0`. Hides the patch level, and
48    /// any pre-release or build metadata, while remaining READABLE at the far end.
49    Minor,
50    /// Advertise nothing. Indistinguishable from a peer built before this field existed, and reads
51    /// as [`PeerSoftware::Unknown`].
52    Off,
53}
54
55impl SoftwareVersionDetail {
56    /// Render the advertisement a node with this setting puts on its handshake.
57    ///
58    /// The result is ALWAYS either the empty string or a value [`PeerSoftware::parse`] reads back
59    /// as `Reported`. Coarsening reduces PRECISION; it never produces a value that reads as
60    /// Unknown while pretending to be a report. Two consequences follow, and both are tested:
61    ///
62    /// - [`Minor`](SoftwareVersionDetail::Minor) renders `MAJOR.MINOR.0`, never a bare
63    ///   `MAJOR.MINOR` — two-part versions are not valid semver, so that spelling would read as
64    ///   Unknown and become a second, confusing spelling of [`Off`](SoftwareVersionDetail::Off).
65    /// - `Minor` of a `0.0.x` build renders the EMPTY STRING, because its coarsening is version
66    ///   zero and version zero is the "unknown" sentinel. There is no coarser representable value,
67    ///   so it advertises nothing rather than advertising the sentinel as if it were a report.
68    ///
69    /// A coarsened `1.4.0` is indistinguishable from a genuine `1.4.0`. That is the point of
70    /// coarsening, not a defect in it.
71    pub fn render(self, product: &str, version: &semver::Version) -> String {
72        match self {
73            Self::Full => format!("{product}/{version}"),
74            // A pre-release identifier (`-nightly.20260805`) is more precisely identifying than the
75            // patch number beside it, so a "coarse" advertisement that kept it would coarsen
76            // nothing for exactly the builds that most want it. `Version::new` drops both it and
77            // any build metadata.
78            Self::Minor => {
79                let coarsened = semver::Version::new(version.major, version.minor, 0);
80                // Hiding the patch of a `0.0.x` build leaves version zero, which the wire reserves
81                // as the "unknown" sentinel. There is no coarser representable value, so advertise
82                // nothing rather than advertise the sentinel dressed up as a report. (This differs
83                // from the rejected two-part `MAJOR.MINOR` spelling: there a representable coarse
84                // value existed and the wrong one was chosen; here none exists.)
85                if is_version_zero(&coarsened) {
86                    return String::new();
87                }
88                format!("{product}/{coarsened}")
89            }
90            Self::Off => String::new(),
91        }
92    }
93}
94
95/// A peer's advertised SOFTWARE build, as read from the gossip handshake (dig_ecosystem#2215).
96///
97/// dig-gossip carries the peer's `Handshake.software_version` as an opaque sanitized string and
98/// deliberately does not interpret it. This type is where that string becomes meaning, once, at the
99/// control boundary — so the interpretation is defined in one place and every client agrees.
100///
101/// # This is NOT the protocol version
102///
103/// Wire compatibility is a separate field that dig-gossip gates connections on. Two peers can speak
104/// the same protocol while running builds months apart; this type reports the latter. It MUST NOT
105/// be used to decide whether to talk to a peer.
106///
107/// # Why there is no `Ord` and no `Default`
108///
109/// [`Unknown`](PeerSoftware::Unknown) has no position on a version line: it is the absence of a
110/// measurement, not a low value. Deriving `Ord` would place it somewhere — and every peer built
111/// before #2215 is Unknown, so "somewhere" would silently become a verdict about most of the live
112/// network. Comparison is therefore reachable only by destructuring
113/// [`Reported`](PeerSoftware::Reported), which forces the caller to say what Unknown means for
114/// their question. There is no `Default` for the same reason: a defaulted Unknown that appears from
115/// nowhere is a different fact from one that was measured, and the two must not be confusable.
116///
117/// # JSON
118///
119/// ```json
120/// {"kind": "unknown"}
121/// {"kind": "reported", "product": "dig-node", "version": "0.99.1", "raw": "dig-node/0.99.1"}
122/// ```
123///
124/// Unknown carries no `version` member at all — never version zero, never `""`, never `null` in a
125/// field a consumer might read as a version.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(tag = "kind", rename_all = "snake_case")]
128pub enum PeerSoftware {
129    /// The peer's build is not known: it advertised nothing, advertised VERSION ZERO — the legacy
130    /// sentinel, in any decoration (`0.0.0`, `0.0.0-rc.1`, `0.0.0+build`) — or advertised something
131    /// this contract cannot parse. See [`PeerSoftware::parse`] for why those three are one case.
132    Unknown,
133    /// The peer advertised a well-formed `product/semver` build.
134    Reported {
135        /// The product name, e.g. `dig-node`. Everything before the LAST `/`.
136        product: String,
137        /// The parsed semantic version, e.g. `0.99.1`. Serializes as its string form.
138        version: semver::Version,
139        /// Exactly what the peer advertised, after trimming.
140        ///
141        /// **Currently reconstructible, deliberately kept.** The grammar this parser accepts is
142        /// lossless — `semver::Version` re-renders every string it accepts byte-identically — so
143        /// today `raw` always equals `format!("{product}/{version}")`, and no test can distinguish
144        /// this field from that expression. It is retained as the honest source: the moment the
145        /// grammar accepts anything non-canonical (a `v` prefix, a two-part version, a vendor
146        /// suffix), a diagnostic reader must see what the peer actually sent rather than this
147        /// parser's opinion of it, and callers that already read `raw` will not need to change.
148        raw: String,
149    },
150}
151
152/// Is this VERSION ZERO — the legacy "no version" sentinel, whatever it is dressed in?
153///
154/// Three of dig-gossip's four handshake send sites hardcoded `"0.0.0"` before dig_ecosystem#2215,
155/// so version zero is not a hypothetical value: it is what the live fleet is sending right now. It
156/// means "this build predates the field", which is [`PeerSoftware::Unknown`]; mapping it to a
157/// *version* would make the whole existing network read as ancient.
158///
159/// The test is over the major/minor/patch TRIPLE, ignoring any pre-release or build metadata. A
160/// peer advertising `0.0.0-rc.1` is no more versioned than one advertising `0.0.0`, and matching
161/// the bare string would let the decorated forms through as real builds at version zero.
162fn is_version_zero(version: &semver::Version) -> bool {
163    version.major == 0 && version.minor == 0 && version.patch == 0
164}
165
166/// The separator between the product and the version in a `product/semver` advertisement.
167const PRODUCT_VERSION_SEPARATOR: char = '/';
168
169impl PeerSoftware {
170    /// Interpret a peer's advertised `software_version` string.
171    ///
172    /// Returns [`Unknown`](PeerSoftware::Unknown) for an empty or blank string, for anything that
173    /// is not `product/semver` with both parts non-empty and the version parsing as semver, and for
174    /// any advertisement whose version is VERSION ZERO.
175    ///
176    /// Version zero is the legacy sentinel and is matched as a CLASS, not as a string: the bare
177    /// `0.0.0`, a product-qualified `dig-node/0.0.0`, and every decorated form (`0.0.0-rc.1`,
178    /// `0.0.0+build`, `0.0.0-0`) all mean "unversioned". A peer advertising `0.0.0-rc.1` is no more
179    /// versioned than one advertising `0.0.0`.
180    ///
181    /// A product name may contain `/`; the split is at the LAST separator.
182    pub fn parse(advertised: &str) -> Self {
183        let raw = advertised.trim();
184
185        // No separator at all: an empty advertisement, a bare version, a product with no version,
186        // or a bare version-zero sentinel (`0.0.0`, `0.0.0-rc.1`) — none of which contain a `/`, so
187        // they land here rather than needing a clause of their own. None of them name a build.
188        let Some((product, version)) = raw.rsplit_once(PRODUCT_VERSION_SEPARATOR) else {
189            return Self::Unknown;
190        };
191        if product.is_empty() {
192            return Self::Unknown;
193        }
194        let Ok(version) = version.parse::<semver::Version>() else {
195            return Self::Unknown;
196        };
197        // The sentinel is VERSION ZERO, a class — not the three-character string. Comparing the
198        // PARSED version is what makes `0.0.0+build`, `0.0.0-rc.1`, and `0.0.0-0` Unknown too; a
199        // string comparison would report each of them as a real build at version zero.
200        if is_version_zero(&version) {
201            return Self::Unknown;
202        }
203
204        Self::Reported {
205            product: product.to_string(),
206            version,
207            raw: raw.to_string(),
208        }
209    }
210}
211
212/// `control.status` — a rich node status snapshot.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct StatusResult {
215    /// Always `true` for a responding node.
216    pub running: bool,
217    /// The service name (`"dig-node"`).
218    pub service: String,
219    /// The node binary's semantic version.
220    pub version: String,
221    /// The git commit the binary was built from (or `"unknown"`).
222    pub commit: String,
223    /// The DIG read protocol version the node speaks.
224    pub protocol: String,
225    /// Process uptime in seconds.
226    pub uptime_secs: u64,
227    /// The loopback `host:port` the node is bound to.
228    pub addr: String,
229    /// The upstream DIG RPC the node proxies/syncs to.
230    pub upstream: String,
231    /// The on-disk cache view.
232    pub cache: CacheView,
233    /// Distinct stores held (from the cache).
234    pub hosted_store_count: u64,
235    /// Cached capsule count.
236    pub cached_capsule_count: u64,
237    /// Pinned-store count.
238    pub pinned_store_count: u64,
239    /// §21 sync availability.
240    pub sync: SyncAvailability,
241}
242
243/// `control.config.get` — the node's effective configuration.
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct ConfigResult {
246    /// The bound `host:port`.
247    pub addr: String,
248    /// The bound port, as a string.
249    pub port: String,
250    /// The effective upstream DIG RPC.
251    pub upstream: String,
252    /// The persisted upstream override, or `null` when unset.
253    pub upstream_override: Option<String>,
254    /// The cache directory.
255    pub cache_dir: String,
256    /// Whether the cache is the machine-wide shared cache.
257    pub cache_shared: bool,
258    /// The node's config.json path.
259    pub config_path: String,
260    /// Whether authenticated §21 sync is available.
261    pub sync_available: bool,
262}
263
264/// `control.config.setUpstream` — the persisted override + a restart hint.
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266pub struct SetUpstreamResult {
267    /// The normalized upstream that was persisted.
268    pub upstream: String,
269    /// Always `true` — the change takes effect on next node start.
270    pub requires_restart: bool,
271}
272
273/// `control.log.setLevel` — the applied filter directive.
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct SetLevelResult {
276    /// The EnvFilter directive now in effect.
277    pub filter: String,
278}
279
280/// `control.cache.setCap` — the applied cap (after the 64 MiB floor).
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282pub struct SetCapResult {
283    /// The cache cap now in effect, in bytes.
284    pub cap_bytes: u64,
285}
286
287/// `control.cache.clear` — the clear acknowledgement.
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289pub struct CacheClearResult {
290    /// Always `true`.
291    pub cleared: bool,
292}
293
294/// One cached capsule of a store, as listed by the hosted-stores methods.
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub struct CapsuleEntry {
297    /// The capsule reference (`storeId:rootHash`).
298    pub capsule: String,
299    /// The capsule root hash.
300    pub root: String,
301    /// The capsule size on disk, in bytes.
302    pub size_bytes: u64,
303    /// When the capsule was last served, in unix milliseconds.
304    pub last_used_unix_ms: u64,
305}
306
307/// One hosted/pinned store (`control.hostedStores.list`).
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309pub struct HostedStore {
310    /// The canonical lowercase 64-hex store id.
311    pub store_id: String,
312    /// Whether the operator has pinned this store.
313    pub pinned: bool,
314    /// The number of cached capsules of this store.
315    pub capsule_count: u64,
316    /// The total cached bytes across this store's capsules.
317    pub total_bytes: u64,
318    /// The cached capsules of this store.
319    pub capsules: Vec<CapsuleEntry>,
320}
321
322/// `control.hostedStores.list` — every held/pinned store.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct HostedStoresListResult {
325    /// The stores, one entry per distinct store id.
326    pub stores: Vec<HostedStore>,
327}
328
329/// `control.hostedStores.pin` — the pin acknowledgement + the pre-fetch outcome.
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331pub struct PinResult {
332    /// The store id that was pinned.
333    pub store_id: String,
334    /// The pinned root, or `null` when pinned at store level.
335    pub root: Option<String>,
336    /// Always `true`.
337    pub pinned: bool,
338    /// The in-band pre-fetch outcome (`{status, …}`) — its shape varies with the fetch path.
339    pub fetch: serde_json::Value,
340}
341
342/// `control.hostedStores.unpin` — the unpin acknowledgement + eviction count.
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub struct UnpinResult {
345    /// The store id that was unpinned.
346    pub store_id: String,
347    /// Whether a pin registry entry was actually removed.
348    pub unpinned: bool,
349    /// How many cached capsules of the store were evicted.
350    pub evicted_capsules: u64,
351}
352
353/// `control.hostedStores.status` — per-store pinned flag + cached capsules.
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub struct HostedStoreStatusResult {
356    /// The store id queried.
357    pub store_id: String,
358    /// Whether the store is pinned.
359    pub pinned: bool,
360    /// The number of cached capsules.
361    pub capsule_count: u64,
362    /// The total cached bytes.
363    pub total_bytes: u64,
364    /// The cached capsules.
365    pub capsules: Vec<CapsuleEntry>,
366}
367
368/// `control.sync.status` — §21 sync availability + pin coverage.
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370pub struct SyncStatusResult {
371    /// Whether authenticated §21 whole-store sync is available.
372    pub available: bool,
373    /// The sync method name.
374    pub method: String,
375    /// The number of pinned stores.
376    pub pinned_total: u64,
377    /// How many pinned stores currently have a cached capsule.
378    pub pinned_synced: u64,
379    /// Whether whole-store (root-less) sync is supported by this build.
380    pub whole_store_trigger_supported: bool,
381}
382
383/// `control.sync.trigger` — the synced-capsule outcome.
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385pub struct SyncTriggerResult {
386    /// The store id synced.
387    pub store_id: String,
388    /// The capsule root synced.
389    pub root: String,
390    /// The outcome status (`"synced"`).
391    pub status: String,
392    /// The synced capsule size, in bytes.
393    pub size_bytes: u64,
394    /// The served root the node verified against.
395    pub served_root: String,
396}
397
398/// `control.pairing.approve` — the mint acknowledgement + the new token's id.
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400pub struct PairingApproveResult {
401    /// Always `true`.
402    pub approved: bool,
403    /// The requesting client's declared name.
404    pub client_name: String,
405    /// The short id of the minted paired token (used to revoke it).
406    pub token_id: String,
407}
408
409/// `control.pairing.revoke` — the revoke acknowledgement.
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
411pub struct PairingRevokeResult {
412    /// Whether a token was actually removed.
413    pub revoked: bool,
414    /// The token id that was targeted.
415    pub token_id: String,
416}
417
418/// `control.peerCounts` — how many peers this node holds on EACH network.
419///
420/// # Two networks, two numbers, and neither is "peers"
421///
422/// A DIG node is connected to two entirely separate networks at once: the DIG content/gossip
423/// network (port 9445), and the Chia full nodes its wallet chain sync talks to. The counts are
424/// unrelated and move independently — a node with many DIG peers and no Chia peer is serving content
425/// while its wallet is not syncing at all, and the reverse is equally possible.
426///
427/// So neither field is spelled `peers`, `connected_peers` or `peer_count`. A bare name forces a
428/// consumer to KNOW which network a number describes, and the failure when it guesses wrong is
429/// silent: a plausible integer in a right-looking place. This method exists so that one call answers
430/// for both networks and each answer names its own.
431///
432/// # `relay.peer_count` from `control.peerStatus` is NOT this
433///
434/// That field counts the peers connected to THE RELAY, not to this node, and it is frequently the
435/// only non-zero number on a node connected to nothing. It is never the answer to "how many peers
436/// does this node have"; [`dig_peer_count`](Self::dig_peer_count) is.
437///
438/// # `Some(0)` is measured; `null` is unknown
439///
440/// `0` means the node looked at that network and found nothing connected. `null` means it cannot
441/// observe the count at all — which is what a node whose peer network is not running reports, since
442/// a zero there would claim "nothing is connected" about a network it never asked.
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
444pub struct PeerCountsResult {
445    /// Peers on the DIG content/gossip network (port 9445) — dig-node-core's `connected_peers`, the
446    /// same figure `control.peerStatus` reports. `0` is an observed zero; `null` is unobservable.
447    pub dig_peer_count: Option<u32>,
448    /// CHIA full-node peers the wallet's chain sync holds. The SAME observation
449    /// [`WalletSyncStatusResult::chia_peer_count`] reports — a conforming node MUST serve both from
450    /// one source, and the two MUST agree within a single node's view.
451    pub chia_peer_count: Option<u32>,
452}
453
454/// `control.peers.connect` — the connected peer's id.
455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456pub struct PeersConnectResult {
457    /// Always `true` on success.
458    pub connected: bool,
459    /// The connected peer's id.
460    pub peer_id: String,
461}
462
463/// `control.peers.disconnect` — the dropped peer's id.
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
465pub struct PeersDisconnectResult {
466    /// Always `true` (idempotent — dropping an absent peer still succeeds).
467    pub disconnected: bool,
468    /// The peer id that was targeted (trimmed + lower-cased).
469    pub peer_id: String,
470}
471
472/// `control.subscribe` — the subscription acknowledgement.
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
474pub struct SubscribeResult {
475    /// Always `true`.
476    pub subscribed: bool,
477    /// Whether the store was newly added (vs already subscribed).
478    pub added: bool,
479    /// The canonical persisted store id (trimmed + lower-cased).
480    pub store_id: String,
481}
482
483/// `control.unsubscribe` — the unsubscription acknowledgement.
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
485pub struct UnsubscribeResult {
486    /// Always `false`.
487    pub subscribed: bool,
488    /// Whether the store was actually removed.
489    pub removed: bool,
490    /// The canonical store id.
491    pub store_id: String,
492}
493
494/// `control.listSubscriptions` — the node's persisted subscription set.
495#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
496pub struct ListSubscriptionsResult {
497    /// The subscribed store ids.
498    pub subscriptions: Vec<String>,
499    /// The subscription count.
500    pub count: u64,
501}
502
503/// `control.wallet.balance` — an address's balance for one asset, as the node's chain read saw it.
504///
505/// A READ-only result: this reports chain state, it never moves funds. It is a strict SUPERSET of
506/// dig-app's frozen `BalanceResponse { balance }` — the node emits the richer shape, and because
507/// dig-app's struct does not deny unknown fields it reads [`balance`](Self::balance) losslessly and
508/// ignores the rest. That superset relationship is the "no dig-app code change" guarantee, pinned by
509/// the conformance KAT.
510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
511pub struct WalletBalanceResult {
512    /// The CONFIRMED, spendable balance in the asset's base unit (mojos for XCH, base units for DIG).
513    /// The only field dig-app 3.x reads.
514    pub balance: u64,
515    /// Incoming funds seen but not yet confirmed (asset base units); not yet spendable.
516    pub pending: u64,
517    /// Which tier produced these figures, or `None` from a node too old to disclose it.
518    ///
519    /// See [`WalletReadSource`]. Absent (`null` / omitted) is a THIRD state, not a default tier:
520    /// it means the answering node predates tier disclosure, so the caller knows the tier is
521    /// unknown rather than being told a tier that was never reported.
522    ///
523    /// The [`Option`] carries the backwards compatibility on its own — serde treats a missing
524    /// `Option` field as `None` — so no `#[serde(default)]` is needed and none is written; a
525    /// REQUIRED field here would reject an older node's payload outright.
526    pub source: Option<WalletReadSource>,
527    /// Whether THESE figures reflect a caught-up local view. When `false`, they are STALE or came
528    /// from the fallback tier.
529    ///
530    /// This describes the ANSWER, not the node: a [`WalletReadSource::Fallback`] answer is always
531    /// `false`, however caught-up the node's own replica happens to be.
532    pub synced: bool,
533    /// The peak block height the reported figures reflect, or `null` when no height applies —
534    /// including every [`WalletReadSource::Fallback`] answer, whose figures came from the oracle's
535    /// chain view rather than the node's.
536    pub peak_height: Option<u32>,
537}
538
539/// Which tier answered a wallet read (dig_ecosystem#2233).
540///
541/// A node serves a wallet read either from its own chain replica or from a third-party HTTP
542/// oracle, and the two are not interchangeable to a caller: the oracle path is a network round
543/// trip that **discloses the queried address off-node**, which a user on a metered or private
544/// connection has a legitimate interest in knowing about. Reporting the tier is also what makes
545/// "the node answered from its own chain state" a falsifiable claim — a sync-progress flag is not,
546/// since a flag can flip while the oracle keeps answering.
547#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
548#[serde(rename_all = "lowercase")]
549pub enum WalletReadSource {
550    /// The node's own local chain replica. No third party was consulted.
551    Db,
552    /// A third-party coinset HTTP oracle. The queried value — an address, or a COIN ID on
553    /// `control.wallet.coinById` — was disclosed off-node.
554    ///
555    /// The coin-id case is the more sensitive of the two, and the less obvious: an address is
556    /// disclosed on every routine balance poll, whereas querying a freshly created coin id, from the
557    /// spender's IP, at the moment of the spend, hands the oracle a `{IP, timestamp, coin id}` tuple
558    /// that ties a network identity to a specific new on-chain identity.
559    Fallback,
560}
561
562/// One coin, as the node's chain read saw it (`control.wallet.coins` / `control.wallet.coinById`).
563///
564/// The first three fields are byte-identical to dig-app's frozen `CoinRecord`, so its
565/// `CoinsResponse` deserializes this losslessly and ignores the rest. The rest is what a spend
566/// actually needs: a coin cannot be spent from an id and an amount alone — the parent and the
567/// puzzle hash are what reconstruct the `Coin` — and the heights are how a caller tells a confirmed
568/// coin from one it only saw in the mempool.
569///
570/// ONE record type serves both reads deliberately. A second coin shape would be a second thing to
571/// keep in step with dig-app's frozen struct, and the two would drift byte-wise the first time only
572/// one of them was touched.
573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
574pub struct WalletCoinRecord {
575    /// The coin id, lowercase 64-hex, unprefixed.
576    pub coin_id: String,
577    /// The asset this coin is denominated in, or `null` when THIS READ DID NOT CLASSIFY THE COIN.
578    ///
579    /// `null` never means "no asset" and never means XCH by default. It means the answering read
580    /// had no basis to say: a singleton, a CAT and a plain XCH coin are indistinguishable from a
581    /// coin id alone — telling them apart requires inspecting the puzzle, and the node reads only
582    /// the coin record. So `control.wallet.coinById` MUST report `null` here — emitting a concrete
583    /// asset on an unclassified read would make the node assert a classification it never verified,
584    /// which a caller would then spend against.
585    ///
586    /// `control.wallet.coins` MUST report the concrete asset it was SCOPED to and MUST NOT emit
587    /// `null`. This field is optional only to serve the by-id read; the coins read has no
588    /// unclassified case, and dig-app's frozen `CoinRecord` requires a non-null asset there, so a
589    /// `null` breaks that read outright rather than degrading it. The type cannot enforce the split
590    /// because ONE record shape deliberately serves both reads (see the type docs), which is why the
591    /// rule is stated here and pinned by a KAT.
592    pub asset: Option<crate::params::Asset>,
593    /// The coin's amount, in the asset's base unit.
594    pub amount: u64,
595    /// The parent coin's id, lowercase 64-hex, unprefixed.
596    pub parent_coin_info: String,
597    /// The coin's puzzle hash, lowercase 64-hex, unprefixed.
598    pub puzzle_hash: String,
599    /// The height the coin was created at, or `null` while it is still only in the mempool.
600    pub created_height: Option<u32>,
601    /// The height the coin was spent at, or `null` when it is unspent.
602    pub spent_height: Option<u32>,
603}
604
605/// `control.wallet.coins` — an address's spendable coins for one asset.
606///
607/// # An empty list is an ANSWER, never a fallback
608///
609/// `coins: []` means the node consulted a chain and that address holds nothing. It is NEVER what a
610/// caller gets when the chain could not be reached: those are catalogued errors
611/// ([`crate::error::ControlErrorCode::WalletNoChainSource`] / `WalletNotSynced` /
612/// `WalletReadFailed` / `WalletRateLimited`). The distinction is the whole point of the method —
613/// a well-shaped empty result on an unreachable chain would tell somebody who holds funds that they
614/// hold nothing, and a spend built on that answer refuses with a shortfall that is not true.
615#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
616pub struct WalletCoinsResult {
617    /// The spendable coins found at the address, possibly empty (see the type docs).
618    pub coins: Vec<WalletCoinRecord>,
619    /// Which tier answered, or `None` from a node too old to disclose it. See [`WalletReadSource`].
620    pub source: Option<WalletReadSource>,
621    /// Whether THESE coins reflect a caught-up local view; always `false` for a fallback answer.
622    pub synced: bool,
623    /// The peak height these coins reflect, or `null` when none applies (every fallback answer).
624    pub peak_height: Option<u32>,
625}
626
627/// Deserialize an `Option<T>` that is nullable but NOT omittable.
628///
629/// Serde special-cases a missing field of type `Option<T>` into `None`, so a required-but-nullable
630/// field is not expressible by the derive alone. Naming a `deserialize_with` suppresses that
631/// special case: an absent key becomes a `missing field` error, while an explicit `null` still
632/// decodes to `None`.
633fn required_option<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
634where
635    D: serde::Deserializer<'de>,
636    T: Deserialize<'de>,
637{
638    Option::<T>::deserialize(deserializer)
639}
640
641/// `control.wallet.coinById` — ONE coin, named by its own id, spent or unspent.
642///
643/// # An absent coin is an ANSWER; an unreachable chain is an ERROR
644///
645/// `coin: null` means a chain WAS consulted and holds no such coin. It is NEVER what a caller gets
646/// when the chain could not be reached: those are the catalogued errors
647/// ([`crate::error::ControlErrorCode::WalletNoChainSource`] / `WalletReadFailed` /
648/// `WalletRateLimited`). Collapsing the two turns "your wifi dropped" into "your mint never
649/// happened", and the remedies are opposite: retry the read, versus stop waiting.
650///
651/// # Why this method exists — observing a mint
652///
653/// `control.wallet.broadcast`'s `accepted: true` reports mempool admission only; only a buried
654/// confirmation of the CREATED COIN is evidence that a mint happened. `control.wallet.coins`
655/// cannot supply it — it answers by ADDRESS and lists UNSPENT coins only, so it can see neither the
656/// created DID coin nor the funding coin the mint spent. This method is how that evidence is
657/// obtained: read the created coin's id for a `created_height`, and the funding coin's id for a
658/// [`spent_height`](WalletCoinRecord::spent_height). Without it a mint can be pushed, real XCH can
659/// leave the wallet, and the outcome stays permanently "pending".
660///
661/// # The freshness fields are honest, not decorative
662///
663/// [`source`](Self::source) discloses which tier answered, and every freshness field describes THAT
664/// tier — the same rule the by-address reads carry. A `fallback` answer MUST report
665/// [`synced`](Self::synced) `false` and [`peak_height`](Self::peak_height) `null` however caught-up
666/// the node's own replica is, because the oracle produced the figures and the replica neither
667/// produced them nor bounds their freshness. A `db` answer means the node's OWN replica answered, so
668/// it MUST report `synced: true` and the replica's peak.
669///
670/// # A negative answer requires a view that could have held the coin
671///
672/// `coin: null` is a VERDICT — it says stop waiting — so it MUST NOT be served from a view that
673/// could not have seen the coin in the first place. A node whose replica is still catching up, or
674/// whose local index is address-scoped rather than a full chain view, has NOT established that the
675/// coin is absent; it has only established that IT cannot see it. Such a node MUST return
676/// [`WalletNoChainSource`](crate::error::ControlErrorCode::WalletNoChainSource) or
677/// [`WalletReadFailed`](crate::error::ControlErrorCode::WalletReadFailed) and MUST NOT answer
678/// `coin: null`.
679///
680/// This matters precisely for the two coins this method exists to observe. A created coin sits at no
681/// wallet address and a spent funding coin is gone from every unspent list, so an address-scoped
682/// replica is guaranteed to miss both — and a `coin: null` from it would report a mint that DID
683/// happen as never-having-happened, with the funds already gone. `control.wallet.peak` is no escape
684/// hatch here: it reports that same replica's height, which can bound a positive confirmation but
685/// can never license a negative one.
686#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
687pub struct WalletCoinByIdResult {
688    /// The coin, or `null` when the consulted chain holds no coin with that id (see the type docs).
689    ///
690    /// The key MUST be present. `null` is a verdict here, so an ABSENT key must not decode into one:
691    /// serde's default treatment of `Option` makes a missing field indistinguishable from an
692    /// explicit `null`, which would let an unrelated or truncated payload — anything at all carrying
693    /// a `synced` field — decode into a confident "the chain holds no such coin". `deserialize_with`
694    /// suppresses that default so the field is genuinely required.
695    #[serde(deserialize_with = "required_option")]
696    pub coin: Option<WalletCoinRecord>,
697    /// Which tier answered, or `None` from a node too old to disclose it. See [`WalletReadSource`].
698    pub source: Option<WalletReadSource>,
699    /// Whether this answer reflects a caught-up local view; `false` for every fallback answer.
700    pub synced: bool,
701    /// The peak height this answer reflects, or `null` when none applies (every fallback answer).
702    pub peak_height: Option<u32>,
703}
704
705/// One coin's SPEND: the coin that was consumed, plus the two programs that consumed it.
706///
707/// This is the chia `CoinSpend` in the contract's own wire form — the puzzle reveal and the solution
708/// as lowercase hex of their serialized CLVM, beside the [`WalletCoinRecord`] for the spent coin.
709/// The coin is carried as the SAME record type the other reads use rather than a trimmed
710/// parent/puzzle-hash/amount triple, because a second coin shape is a second thing to keep in step
711/// with dig-app's frozen `CoinRecord` (see [`WalletCoinRecord`]).
712///
713/// # The reveal is checkable, and a conforming node MUST have checked it
714///
715/// A puzzle reveal is supplied by a peer, and a lying peer can supply a different program. The
716/// reveal's tree hash MUST equal the spent coin's own
717/// [`puzzle_hash`](WalletCoinRecord::puzzle_hash), which makes the claim self-checking, and a node
718/// MUST fail closed — a catalogued error, never a spend carrying an unverified reveal — when the
719/// hashes disagree or the reveal does not parse. A caller MAY re-derive the same check from the two
720/// fields it is handed; it never has to trust the node to have done it.
721///
722/// # `spent_height` is present on the coin, always
723///
724/// A spend exists only because the coin was spent, so
725/// [`spent_height`](WalletCoinRecord::spent_height) MUST be non-null here. A spend reporting an
726/// unspent coin is a contradiction the shape cannot forbid, so the contract forbids it instead.
727#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
728pub struct WalletCoinSpend {
729    /// The coin this spend consumed. Its `spent_height` MUST be non-null (see the type docs).
730    pub coin: WalletCoinRecord,
731    /// The puzzle reveal: lowercase hex of the serialized CLVM program. MUST tree-hash to
732    /// [`coin.puzzle_hash`](WalletCoinRecord::puzzle_hash).
733    pub puzzle_reveal: String,
734    /// The solution the puzzle was run with: lowercase hex of the serialized CLVM.
735    pub solution: String,
736}
737
738/// `control.wallet.coinSpend` — the spend that spent one coin, named by that coin's id.
739///
740/// # `spend: null` is an ANSWER with TWO honest causes; an unreachable chain is an ERROR
741///
742/// `null` means a chain WAS consulted and no spend of that coin exists there — either because the
743/// coin is UNSPENT, or because the chain holds no such coin at all. Both are legitimately "there is
744/// no spend", and the contract deliberately does not distinguish them here: a caller that needs to
745/// tell them apart asks [`WalletCoinByIdResult`], whose `coin: null` separates the two.
746///
747/// What `null` NEVER means is that the node could not answer. That is a catalogued error
748/// ([`WalletNoChainSource`](crate::error::ControlErrorCode::WalletNoChainSource) /
749/// [`WalletReadFailed`](crate::error::ControlErrorCode::WalletReadFailed) /
750/// [`WalletRateLimited`](crate::error::ControlErrorCode::WalletRateLimited)). The three-valued
751/// distinction is money-critical: a caller following a singleton forward reads "no spend" as *this
752/// is the current tip* and stops walking. Collapsing "could not answer" into it makes a stale coin
753/// look like the tip, and a spend built against a superseded singleton is invalid.
754///
755/// # A negative answer requires a view that could have held the spend
756///
757/// `spend: null` is a VERDICT, and the same rule [`WalletCoinByIdResult`] states applies unchanged: a
758/// node whose replica is still catching up, or whose index is address-scoped rather than a full
759/// chain view, has established only that IT cannot see the spend. Such a node MUST return
760/// `WalletNoChainSource` / `WalletReadFailed` and MUST NOT answer `null`.
761#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
762pub struct WalletCoinSpendResult {
763    /// The spend, or `null` when the consulted chain holds no spend of that coin (see the type docs).
764    ///
765    /// The key MUST be present. `null` is a verdict here, so an ABSENT key must not decode into one —
766    /// the same reason [`WalletCoinByIdResult::coin`] is required.
767    #[serde(deserialize_with = "required_option")]
768    pub spend: Option<WalletCoinSpend>,
769    /// Which tier answered, or `None` from a node too old to disclose it. See [`WalletReadSource`].
770    pub source: Option<WalletReadSource>,
771    /// Whether this answer reflects a caught-up local view; `false` for every fallback answer.
772    pub synced: bool,
773    /// The peak height this answer reflects, or `null` when none applies (every fallback answer).
774    pub peak_height: Option<u32>,
775}
776
777/// `control.wallet.coinsByParent` — the DIRECT children created by spending one coin.
778///
779/// # ONE hop, never a walk
780///
781/// The list is the coins the named parent's spend created, and nothing further. It is not a lineage,
782/// not a subtree, and not transitive: a grandchild appears only when the caller asks again with the
783/// child's id. A node MUST NOT recurse — an unbounded server-side walk over caller-supplied input is
784/// work the caller cannot bound, and a partial walk returned as if complete would be a lineage with
785/// a silent hole in it.
786///
787/// # A page, and it says so — the truncation rule
788///
789/// [`coins`](Self::coins) is ONE PAGE of the parent's children, bounded by
790/// [`COINS_BY_PARENT_MAX_LIMIT`](crate::params::COINS_BY_PARENT_MAX_LIMIT). Whether it is the WHOLE
791/// child set is stated by [`complete`](Self::complete) and never left to be inferred from the page's
792/// length.
793///
794/// This is the money-critical shape in this type. A caller walking a lineage reads "no more
795/// children" as *this branch ends here*, so a page that was truncated but looks whole terminates the
796/// walk early and presents a partial lineage as a complete one. Inferring completeness from
797/// `coins.len() < limit` is NOT equivalent and MUST NOT be done: a node is free to return a short
798/// page for its own reasons, and a child set that is an exact multiple of the page size makes the
799/// last full page indistinguishable from a truncated one.
800///
801/// # Resuming: the same lesson `control.wallet.arrivals` records
802///
803/// Resume from [`cursor`](Self::cursor) — the last child you were actually HANDED — by passing it
804/// as [`after_coin_id`](crate::params::WalletCoinsByParentParams::after_coin_id). There is
805/// deliberately no "where the chain got to" marker on this type to reach for instead; that is the
806/// distinction `WalletArrivalsResult::latest` exists to warn about, and the cheapest way not to lose
807/// a row to it is to give a caller nothing else to resume from.
808///
809/// # The order is part of the contract, because paging is meaningless without one
810///
811/// A node MUST return children in ASCENDING `coin_id` order, and MUST keep that order stable across
812/// the pages of one walk. `after_coin_id` means *strictly after this id in that order*. Without a
813/// fixed order a cursor names no position, and a walk would silently repeat some children and skip
814/// others. Coin ids are fixed-length lowercase hex, so ascending lexicographic order and ascending
815/// 32-byte numeric order are the SAME order — an implementation may use whichever it has, and the
816/// two can never disagree.
817///
818/// # An empty list is an ANSWER, never a fallback
819///
820/// `coins: []` means the node consulted a chain and that parent created no children it knows of —
821/// typically because the parent is unspent. It is NEVER what a caller gets when the chain could not
822/// be reached: those are the catalogued errors
823/// ([`WalletNoChainSource`](crate::error::ControlErrorCode::WalletNoChainSource) /
824/// [`WalletReadFailed`](crate::error::ControlErrorCode::WalletReadFailed) /
825/// [`WalletRateLimited`](crate::error::ControlErrorCode::WalletRateLimited)). The distinction is the
826/// same one every read in this family carries, and it matters most here: a caller walking a
827/// singleton forward reads an empty list as *this is the tip*.
828///
829/// # `asset` is `null` on every record
830///
831/// A child is named by its parent, not by an address and not by an asset, so this read classifies
832/// nothing — exactly like [`WalletCoinByIdResult`]. Every record MUST report
833/// [`asset`](WalletCoinRecord::asset) as `null` rather than assert a class the read never verified.
834#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
835pub struct WalletCoinsByParentResult {
836    /// One page of the parent's direct children, ascending by `coin_id`, possibly empty. One hop
837    /// only, and NOT necessarily the whole child set — see [`complete`](Self::complete).
838    pub coins: Vec<WalletCoinRecord>,
839    /// Is this page the WHOLE child set?
840    ///
841    /// `true` means every child the node knows of is in [`coins`](Self::coins) and the walk of this
842    /// hop is finished. `false` means the answer was TRUNCATED and more children exist — resume from
843    /// [`cursor`](Self::cursor).
844    ///
845    /// Required on the wire, and stated positively so that the reading a caller falls into when the
846    /// field is absent or defaulted is the SAFE one. A boolean spelled `truncated` would default to
847    /// `false`, i.e. to "this is everything", which is the claim that ends a lineage walk early;
848    /// `complete` defaults to "there may be more", which costs at worst one redundant request.
849    pub complete: bool,
850    /// The last child in this page — **the value to resume from** — or `null` for an empty page.
851    ///
852    /// It is the id the caller was HANDED, never a marker for where the chain got to. Pass it as
853    /// [`after_coin_id`](crate::params::WalletCoinsByParentParams::after_coin_id) to fetch the next
854    /// page.
855    ///
856    /// The key MUST be present. `null` is meaningful here — it says this page carried nothing — so
857    /// an ABSENT key must not decode into it: serde's default treatment of `Option` would let a
858    /// truncated or mis-routed payload decode into a confident "there was nothing to resume from".
859    #[serde(deserialize_with = "required_option")]
860    pub cursor: Option<String>,
861    /// Which tier answered, or `None` from a node too old to disclose it. See [`WalletReadSource`].
862    pub source: Option<WalletReadSource>,
863    /// Whether these children reflect a caught-up local view; `false` for every fallback answer.
864    pub synced: bool,
865    /// The peak height these children reflect, or `null` when none applies (every fallback answer).
866    pub peak_height: Option<u32>,
867}
868
869/// `control.wallet.peak` — the node's current chain peak height.
870///
871/// `peak_height: null` is an honest "this node tracks no height yet", not a zero. A caller bounding
872/// a claimed confirmation MUST treat it as unknown rather than as height 0, which every block is
873/// trivially above.
874///
875/// # This `synced` is the WEAKER of the contract's two same-named notions
876///
877/// [`synced`](Self::synced) here reports only that the replica's initial catch-up COMPLETED. It says
878/// nothing about whether the wallet is still connected to a Chia peer, so a node that caught up
879/// yesterday and has been offline since still reports `synced: true` beside a height that stopped
880/// moving. [`WalletSyncStatusResult::phase`] answers the stronger question — *is this being kept
881/// current?* — and `WalletSyncPhase::Synced` therefore IMPLIES this flag while this flag does not
882/// imply that phase. The two are stated in terms of each other on purpose: they carry the same word
883/// and would otherwise drift apart silently.
884///
885/// # The height is the last EXISTING block
886///
887/// It is the height of the last block the peer view reported, never a next-block height. A consumer
888/// computing confirmation depth must floor its own arithmetic rather than assume a convention — see
889/// [`WalletSyncStatusResult`], which records why.
890#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
891pub struct WalletPeakResult {
892    /// The peak block height the node's chain view has reached, or `null` when it has none.
893    pub peak_height: Option<u32>,
894    /// Whether the node's own chain replica COMPLETED its catch-up. Weaker than
895    /// [`WalletSyncPhase::Synced`] — see the type docs.
896    pub synced: bool,
897}
898
899/// How far the node's wallet chain replica has got — the states a background sync can be in.
900///
901/// Named states rather than a boolean, because "has never started" and "is caught up" are different
902/// facts and a `bool` can only carry one of them. Paired with a `peak_height` a boolean forces a
903/// never-started wallet to report some height, and 0 is the only one available — which reads as
904/// *synced to the genesis block*, a claim about the chain that is simply false.
905///
906/// # Nothing to watch is TWO states, not one
907///
908/// A sync with no addresses to follow is idle for one of two reasons, and they are different
909/// sentences to a user with different remedies. [`NoWalletEnrolled`](Self::NoWalletEnrolled) is the
910/// honest all-clear: there is no wallet, so watching nothing is correct and complete.
911/// [`WalletNotUnlocked`](Self::WalletNotUnlocked) is the opposite — a wallet EXISTS and is not being
912/// watched — and reporting it as the all-clear tells a user with real coins that their balance is
913/// fully accounted for while the node follows none of their addresses. Merging the two would put a
914/// money-lie behind a green tick, so the contract keeps them apart.
915///
916/// # An unrecognised token is a VALUE, not a parse failure
917///
918/// [`Unrecognized`](Self::Unrecognized) exists because this enum was once closed, and a node that
919/// grew a new phase took every consumer's whole response down with it — see the variant's own docs.
920/// Consumers MUST treat an unrecognised phase as *unknown*, never as progress.
921#[derive(Debug, Clone, PartialEq, Eq, Hash)]
922pub enum WalletSyncPhase {
923    /// No sync has begun: the wallet holds no replica of the chain and is not building one.
924    NotStarted,
925    /// A sync is running — either the initial catch-up, or the ongoing task that keeps the replica
926    /// current. A wallet whose catch-up finished but whose peer connections have all dropped is
927    /// `Syncing`, not [`Synced`](Self::Synced): it is trying to be current and is not.
928    Syncing,
929    /// The initial catch-up completed AND at least one Chia peer connection is currently live: the
930    /// replica is caught up and CONNECTED, so it is in a position to be kept current.
931    ///
932    /// That is what the predicate delivers, and no more. A live connection to a stalled or lagging
933    /// peer satisfies it while the replica quietly goes stale, so this phase MUST NOT be read as
934    /// proof that the data is FRESH — only that nothing is known to be preventing freshness.
935    Synced,
936    /// **The honest all-clear: no wallet is enrolled on this node**, so there are no addresses to
937    /// follow and a sync would have nothing to do. Not a degraded state and not an error — a node
938    /// that has never had a wallet is working exactly as intended.
939    ///
940    /// A consumer MAY present this as settled. It is the ONLY nothing-to-watch phase for which that
941    /// is true: [`WalletNotUnlocked`](Self::WalletNotUnlocked) looks identical from inside the sync
942    /// loop and means the opposite.
943    ///
944    /// [`watched_addresses`](WalletSyncStatusResult::watched_addresses) accompanying this phase is
945    /// `Some(0)` — an observed zero, and the zero that is genuinely fine.
946    NoWalletEnrolled,
947    /// **A wallet IS enrolled, but the node holds no addresses for it, so it is watching nothing.**
948    /// The user's coins are not being followed and their balance is not being maintained.
949    ///
950    /// This is the common state after every restart, because the address set is derived from key
951    /// material the node cannot reach until the wallet is unlocked, and nothing back-fills it while
952    /// locked. It is emphatically NOT [`NoWalletEnrolled`](Self::NoWalletEnrolled): the difference
953    /// between them is the difference between *nothing to do* and *something to do that is not being
954    /// done*.
955    ///
956    /// A consumer MUST NOT render this as synced, settled, or up to date, and MUST NOT present a
957    /// balance read under it as complete. The honest rendering names the wallet and the remedy —
958    /// *"locked, so it is not being watched yet"* — because unlocking is the action that resolves
959    /// it.
960    ///
961    /// The name says NOT UNLOCKED rather than *locked* on purpose. An empty address set is what the
962    /// node can observe; a lock is only the usual cause of it, and a manifest that never carried the
963    /// keys reaches the same state without anything having been locked. The phase claims the
964    /// observation, and leaves the cause to whatever the node can actually establish.
965    WalletNotUnlocked,
966    /// **A phase token this build does not know**, carried verbatim.
967    ///
968    /// # Why this variant exists
969    ///
970    /// The enum shipped closed. dig-node then grew a phase, and because serde rejects an unknown
971    /// variant, the unknown token did not degrade one field — it aborted the entire
972    /// [`WalletSyncStatusResult`]. dig-app's sync read became `Err`, its chain-sync state collapsed
973    /// to unknown, and the surface rendered nothing at all (dig_ecosystem#2609). Every consumer
974    /// built against an older contract than the node it talks to hit it at once.
975    ///
976    /// # It is deliberately NOT silent
977    ///
978    /// The token is preserved rather than discarded so the state is *observable*: a consumer can say
979    /// which token it failed to understand, and a developer can read it out of a log instead of
980    /// reaching for a packet capture. This incident stayed invisible until somebody built a probe
981    /// against the published crate; the variant that replaces it should not need one.
982    ///
983    /// Mapping an unknown token onto [`Synced`](Self::Synced) or [`Syncing`](Self::Syncing) would be
984    /// far worse than the parse error it replaces. A parse error is loud and obviously wrong; a
985    /// coerced phase is a confident, plausible statement about the user's money that the node never
986    /// made. Consumers MUST render this as unknown and MUST NOT infer progress, completion, or a
987    /// trustworthy balance from it.
988    ///
989    /// # The payload is untrusted text
990    ///
991    /// It is whatever the node sent. A consumer that displays it MUST escape and bound it like any
992    /// other foreign string rather than splicing it into a message unchecked. `Debug` escapes it, as
993    /// `String`'s always has; [`as_wire`](Self::as_wire) deliberately does not, because a relay must
994    /// be able to hand on the exact bytes.
995    ///
996    /// # Not the same idea as [`PeerSoftware::Unknown`]
997    ///
998    /// The two look alike and are not. `PeerSoftware::Unknown` is the ABSENCE of a report — the peer
999    /// said nothing, or said something unparseable, and there is no datum to keep. Here the node DID
1000    /// report, and the token it used is a real observation this build cannot interpret. That is why
1001    /// this variant carries a payload and that one does not, and why the names differ: calling it
1002    /// `Unknown` would suggest nothing was said.
1003    Unrecognized(UnknownPhaseToken),
1004}
1005
1006/// A phase token this build does not recognise, held so it cannot be confused with one it does.
1007///
1008/// # Why the payload is a type and not a bare `String`
1009///
1010/// [`WalletSyncPhase::Unrecognized`] serializes whatever it holds. With a public `String` inside,
1011/// `Unrecognized("synced".to_owned())` was constructible by any consumer, reported
1012/// `is_recognized() == false` locally, went onto the wire as the bare token `"synced"`, and arrived
1013/// at the far side as a confident [`WalletSyncPhase::Synced`] — a value that claims the wallet is
1014/// caught up while calling itself unrecognised. It was also the one value in the type that did not
1015/// round-trip, contradicting the verbatim-carriage guarantee the variant exists to provide.
1016///
1017/// The field is private and this type has no public constructor, so the only way to reach
1018/// `Unrecognized` from outside the crate is [`WalletSyncPhase::from`], which is TOTAL: hand it a
1019/// known spelling and it returns that known variant instead. The dishonest value is therefore not
1020/// merely discouraged — it cannot be built.
1021///
1022/// This is deliberately a type-level guard rather than a documented rule. The whole family exists
1023/// because a wire-level mismatch went unnoticed until someone built a probe, and a rule that only a
1024/// doc comment enforces is the same shape of mistake one layer up.
1025///
1026/// # The seal is guarded by a test that can actually see it removed
1027///
1028/// The ordinary unit tests cannot. They reach `Unrecognized` only through
1029/// [`WalletSyncPhase::from`], and the seal is precisely what determines which values that route can
1030/// produce — so making this field `pub` again leaves every one of them green while the forged
1031/// value becomes constructible. Measured: the whole suite passed with the field public.
1032///
1033/// A doctest is the instrument that works, because doctests compile as a SEPARATE CRATE and
1034/// therefore see this type exactly as a consumer does. The one below must FAIL to compile; if the
1035/// field is ever made public it starts compiling, and `cargo test` reports the doctest as failed.
1036///
1037/// ```compile_fail
1038/// use dig_node_control_interface::results::{UnknownPhaseToken, WalletSyncPhase};
1039/// // A value that calls itself unrecognised while spelling itself `synced` on the wire.
1040/// let forged = WalletSyncPhase::Unrecognized(UnknownPhaseToken("synced".to_owned()));
1041/// ```
1042///
1043/// The honest route returns the KNOWN variant instead, which is the whole point:
1044///
1045/// ```
1046/// use dig_node_control_interface::results::WalletSyncPhase;
1047/// assert_eq!(WalletSyncPhase::from("synced"), WalletSyncPhase::Synced);
1048/// assert!(WalletSyncPhase::from("synced").is_recognized());
1049/// ```
1050#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1051pub struct UnknownPhaseToken(String);
1052
1053impl UnknownPhaseToken {
1054    /// The token's RAW bytes, exactly as the node sent them — the relay path.
1055    ///
1056    /// This is the escape hatch, not the default. It exists so a proxy can hand the token on
1057    /// byte-identically, and it is the ONE accessor that returns unescaped node-supplied text. Do
1058    /// not route it to a terminal, a log line, or a UI: use [`Display`](Self#impl-Display) or
1059    /// [`display_bounded`](Self::display_bounded), which escape.
1060    ///
1061    /// ```
1062    /// use dig_node_control_interface::results::WalletSyncPhase;
1063    /// let phase = WalletSyncPhase::from("a_newer_token");
1064    /// assert_eq!(phase.unrecognized_token(), Some("a_newer_token"));
1065    /// ```
1066    pub fn as_str(&self) -> &str {
1067        &self.0
1068    }
1069
1070    /// The token escaped for display and truncated to `max_len` bytes of escaped output.
1071    ///
1072    /// What [`Display`](Self#impl-Display) does, plus a length bound — for a log line or a UI label
1073    /// that must not be handed an unbounded string. Nothing bounds a token's length on the wire (the
1074    /// contract is transport-agnostic, and rejecting an over-long token would reintroduce the
1075    /// fail-closed parse this type exists to remove), so the bound belongs at the point of display.
1076    ///
1077    /// The escaped content is at most `max_len` bytes. A single `…` is appended when anything was
1078    /// dropped, so a truncated rendering is never mistaken for the whole token.
1079    ///
1080    /// ```
1081    /// use dig_node_control_interface::results::WalletSyncPhase;
1082    /// let phase = WalletSyncPhase::from("a_very_long_token_from_a_newer_node");
1083    /// let token = phase.unrecognized_token_value().unwrap();
1084    /// assert_eq!(token.display_bounded(10), "a_very_lon…");
1085    /// ```
1086    pub fn display_bounded(&self, max_len: usize) -> String {
1087        let mut rendered = String::new();
1088        let mut dropped = false;
1089
1090        for character in self.0.chars() {
1091            let escaped: String = character.escape_debug().collect();
1092            if rendered.len() + escaped.len() > max_len {
1093                dropped = true;
1094                break;
1095            }
1096            rendered.push_str(&escaped);
1097        }
1098        if dropped {
1099            rendered.push('…');
1100        }
1101        rendered
1102    }
1103}
1104
1105impl std::fmt::Display for UnknownPhaseToken {
1106    /// The token ESCAPED — the safe default, because this is the accessor a log line reaches for.
1107    ///
1108    /// # Why the default escapes rather than the opposite
1109    ///
1110    /// The raw token is attacker-influenced text that is designed to be logged, and a node emitting
1111    /// `"\u{1b}[2K\rsynced"` turns `format!("unknown phase: {token}")` into a terminal line reading
1112    /// `synced` — the erase-line and carriage-return wipe the prefix that said it was unknown. A
1113    /// right-to-left override does the same to a UI label. Making the ergonomic path raw and the
1114    /// safe path opt-in gets that backwards: every consumer would have to remember, and one
1115    /// forgetting reproduces the exact false-reassurance this family exists to prevent.
1116    ///
1117    /// `char::escape_debug` is the escaper because it is the standard library's own, covering C0/C1
1118    /// controls, `DEL`, and the format characters that carry bidi overrides. A hand-rolled table
1119    /// here would be a second implementation of a security-relevant rule, and would drift.
1120    ///
1121    /// [`as_str`](Self::as_str) remains raw for relaying; [`display_bounded`](Self::display_bounded)
1122    /// adds a length bound.
1123    ///
1124    /// ```
1125    /// use dig_node_control_interface::results::WalletSyncPhase;
1126    /// let phase = WalletSyncPhase::from("\u{1b}[2K\rsynced");
1127    /// let token = phase.unrecognized_token_value().unwrap();
1128    /// assert_eq!(token.to_string(), "\\u{1b}[2K\\rsynced");
1129    /// ```
1130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1131        for character in self.0.chars() {
1132            write!(f, "{}", character.escape_debug())?;
1133        }
1134        Ok(())
1135    }
1136}
1137
1138impl WalletSyncPhase {
1139    /// Every phase this build KNOWS, in progress order — the enumeration a machine reads, and the
1140    /// anchor the conformance KATs pin the wire tokens against.
1141    ///
1142    /// [`Unrecognized`](Self::Unrecognized) is absent by definition: it is the absence of a known
1143    /// token rather than one of them, and it has no fixed wire spelling to pin. A node MUST NOT emit
1144    /// anything outside this list; a consumer that meets something outside it gets `Unrecognized`
1145    /// instead of a failed response.
1146    pub const ALL: &'static [WalletSyncPhase] = &[
1147        WalletSyncPhase::NotStarted,
1148        WalletSyncPhase::Syncing,
1149        WalletSyncPhase::Synced,
1150        WalletSyncPhase::NoWalletEnrolled,
1151        WalletSyncPhase::WalletNotUnlocked,
1152    ];
1153
1154    /// This phase's exact wire spelling, or the verbatim token for
1155    /// [`Unrecognized`](Self::Unrecognized).
1156    ///
1157    /// The one place a phase becomes a string, so serialization and any display path cannot drift
1158    /// into two different spellings of the same state.
1159    pub fn as_wire(&self) -> &str {
1160        match self {
1161            WalletSyncPhase::NotStarted => "not_started",
1162            WalletSyncPhase::Syncing => "syncing",
1163            WalletSyncPhase::Synced => "synced",
1164            WalletSyncPhase::NoWalletEnrolled => "no_wallet_enrolled",
1165            WalletSyncPhase::WalletNotUnlocked => "wallet_not_unlocked",
1166            WalletSyncPhase::Unrecognized(token) => token.as_str(),
1167        }
1168    }
1169
1170    /// The token a build does not understand, or `None` for every phase it does.
1171    ///
1172    /// Lets a consumer log or surface the exact unrecognised spelling without matching the variant
1173    /// open-coded, which is how the two spellings drift apart.
1174    pub fn unrecognized_token(&self) -> Option<&str> {
1175        match self {
1176            WalletSyncPhase::Unrecognized(token) => Some(token.as_str()),
1177            _ => None,
1178        }
1179    }
1180
1181    /// Whether this build understands the phase at all.
1182    ///
1183    /// The predicate a consumer branches its *"your node may be newer than this app"* path on.
1184    pub fn is_recognized(&self) -> bool {
1185        !matches!(self, WalletSyncPhase::Unrecognized(_))
1186    }
1187
1188    /// The unrecognised token as its own type, giving access to the escaped renderings.
1189    ///
1190    /// [`unrecognized_token`](Self::unrecognized_token) hands back a raw `&str`; this hands back the
1191    /// [`UnknownPhaseToken`], whose `Display` escapes and whose
1192    /// [`display_bounded`](UnknownPhaseToken::display_bounded) also truncates.
1193    pub fn unrecognized_token_value(&self) -> Option<&UnknownPhaseToken> {
1194        match self {
1195            WalletSyncPhase::Unrecognized(token) => Some(token),
1196            _ => None,
1197        }
1198    }
1199
1200    /// Whether a consumer may present this phase as SETTLED — nothing outstanding, nothing to do.
1201    ///
1202    /// # Why this is a method and not a rule in the docs
1203    ///
1204    /// Two phases mean "the sync is idle" and only one of them is good news.
1205    /// [`NoWalletEnrolled`](Self::NoWalletEnrolled) is complete and correct;
1206    /// [`WalletNotUnlocked`](Self::WalletNotUnlocked) is a wallet whose coins nobody is following.
1207    /// Rendering the second as settled is the money-lie this family exists to prevent, and it is one
1208    /// mistaken `||` away in every consumer that writes the rule itself.
1209    ///
1210    /// Stating it once here makes it a compiler-checked fact rather than a paragraph each consumer
1211    /// re-derives — a second implementation of a rule like this is a drift bug waiting to happen.
1212    /// An unrecognised phase is never settled: this build cannot know what the node meant.
1213    ///
1214    /// ```
1215    /// use dig_node_control_interface::results::WalletSyncPhase;
1216    /// assert!(WalletSyncPhase::Synced.may_render_as_settled());
1217    /// assert!(WalletSyncPhase::NoWalletEnrolled.may_render_as_settled());
1218    /// // A wallet exists and nothing is watching it — never settled.
1219    /// assert!(!WalletSyncPhase::WalletNotUnlocked.may_render_as_settled());
1220    /// assert!(!WalletSyncPhase::from("a_newer_token").may_render_as_settled());
1221    /// ```
1222    pub fn may_render_as_settled(&self) -> bool {
1223        // An exhaustive match, not a `matches!`: a phase added later must be classified here
1224        // deliberately, and the compiler is what forces that rather than a reviewer noticing.
1225        match self {
1226            WalletSyncPhase::Synced | WalletSyncPhase::NoWalletEnrolled => true,
1227            WalletSyncPhase::NotStarted
1228            | WalletSyncPhase::Syncing
1229            | WalletSyncPhase::WalletNotUnlocked
1230            | WalletSyncPhase::Unrecognized(_) => false,
1231        }
1232    }
1233}
1234
1235impl From<&str> for WalletSyncPhase {
1236    /// Every token maps to a phase — an unknown one to
1237    /// [`Unrecognized`](WalletSyncPhase::Unrecognized). Total by construction, so no caller can
1238    /// reintroduce the fail-closed behaviour this type exists to remove.
1239    fn from(token: &str) -> Self {
1240        match token {
1241            "not_started" => WalletSyncPhase::NotStarted,
1242            "syncing" => WalletSyncPhase::Syncing,
1243            "synced" => WalletSyncPhase::Synced,
1244            "no_wallet_enrolled" => WalletSyncPhase::NoWalletEnrolled,
1245            "wallet_not_unlocked" => WalletSyncPhase::WalletNotUnlocked,
1246            other => WalletSyncPhase::Unrecognized(UnknownPhaseToken(other.to_owned())),
1247        }
1248    }
1249}
1250
1251impl Serialize for WalletSyncPhase {
1252    /// A bare JSON string, exactly as the derived `rename_all = "snake_case"` produced before this
1253    /// type grew an unrecognised arm — so an [`Unrecognized`](WalletSyncPhase::Unrecognized) token
1254    /// round-trips back out byte-identical rather than being rewritten or dropped by a relay.
1255    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1256        serializer.serialize_str(self.as_wire())
1257    }
1258}
1259
1260impl<'de> Deserialize<'de> for WalletSyncPhase {
1261    /// Accepts ANY string. A non-string is still a type error — a number or an object where a phase
1262    /// belongs is a malformed response, not a newer node.
1263    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1264        let token = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
1265        Ok(WalletSyncPhase::from(token.as_ref()))
1266    }
1267}
1268
1269/// `control.wallet.syncStatus` — is the wallet's chain replica being kept current, how far has it
1270/// got, and how many Chia peers is it using?
1271///
1272/// # `Synced` means CAUGHT UP AND CONNECTED, not ONCE CAUGHT UP
1273///
1274/// [`phase`](Self::phase) is [`WalletSyncPhase::Synced`] only when the initial catch-up completed
1275/// AND at least one Chia peer connection is live right now. A wallet that caught up yesterday and
1276/// has been offline since MUST report [`Syncing`](WalletSyncPhase::Syncing). This makes `phase ==
1277/// Synced` STRICTLY STRONGER than [`WalletPeakResult::synced`], which reflects only the
1278/// completed-catch-up flag: `Synced` implies that flag, the flag does not imply `Synced`. Both types
1279/// say so, because the two notions share a word and nothing but the docs would keep them aligned.
1280///
1281/// This is the whole reason the method exists. A surface asking *does my wallet stay synced?* cannot
1282/// be answered by a flag that a disconnected wallet still sets.
1283///
1284/// **`Synced` is nevertheless not a freshness guarantee.** Being connected is not being up to date:
1285/// a live connection to a stalled or lagging peer satisfies the predicate while the replica goes
1286/// stale. The phase reports that catch-up finished and a peer is attached — that nothing KNOWN is
1287/// preventing the replica from being kept current — and a consumer needing actual freshness must
1288/// compare [`peak_height`](Self::peak_height) against something, not read this phase. Stating the
1289/// limit is the point: this family exists because a surface asserted more than it knew.
1290///
1291/// # The height NEVER comes from a third-party oracle
1292///
1293/// [`peak_height`](Self::peak_height) is the node's OWN replica's height or `null`. It MUST NOT fall
1294/// back to the coinset oracle. `control.wallet.peak` deliberately does fall back, because it answers
1295/// a different question — *what height is the chain at?* — whereas this field answers *how far has
1296/// this replica got?* An oracle's height here would report a caller's own sync progress using a
1297/// number the replica never reached, which is precisely the reading a progress display makes.
1298///
1299/// # `chia_peer_count: 0` is a disambiguator, not a phase
1300///
1301/// A sync that is running while connected to nothing reports `Syncing` with a count of `0`, and a
1302/// consumer SHOULD render the count alongside the phase for exactly that reason: "syncing — no
1303/// peers" is honest where a bare "syncing" implies progress that is not happening. `null` means the
1304/// node cannot observe the count at all and licenses no claim about connectivity either way.
1305///
1306/// # `watched_addresses` is what makes an idle sync readable
1307///
1308/// A sync following nothing is idle, and the phase alone does not say whether that is correct. The
1309/// count is the second fact that settles it: `0` beside [`WalletSyncPhase::NoWalletEnrolled`] is a
1310/// complete and honest picture, while `0` beside [`WalletSyncPhase::WalletNotUnlocked`] is a wallet
1311/// whose coins nobody is following. A consumer SHOULD render the two together for the same reason it
1312/// renders the peer count beside `Syncing`.
1313///
1314/// `Some(0)` is an OBSERVED zero — the node looked and is following no addresses. `None` means the
1315/// node did not report the number, which is not the same claim and MUST NOT be rendered as zero: a
1316/// node that cannot say how many addresses it follows has not told you that it follows none.
1317///
1318/// A `Synced` phase with `watched_addresses: Some(0)` is a contradiction a conforming node MUST NOT
1319/// emit — a sync following no addresses has not caught anything up. A consumer meeting it SHOULD
1320/// trust the count over the phase, because the count is the narrower claim.
1321///
1322/// # An older node's payload still parses
1323///
1324/// A node that predates `watched_addresses` omits the key, and it deserializes to `None` — *the node
1325/// did not report it*. That tolerance is required, not incidental: a mandatory new field would make
1326/// every older node unreadable to a client that has it, which is dig_ecosystem#2609 in mirror image
1327/// — the same fail-closed break with the old and new sides swapped. A contract that tolerates a
1328/// token from the future must equally tolerate a payload from the past.
1329///
1330/// **Every `Option` field here behaves this way**, because serde decodes a missing `Option` to
1331/// `None`. So `peak_height` and `chia_peer_count` are absent-tolerant too, and have been since this
1332/// type shipped. Only [`phase`](Self::phase) is structurally mandatory. A conforming node MUST still
1333/// emit all four keys — absence is a compatibility allowance for older builds, never a licence to
1334/// omit an observation — and a consumer MUST read an absent count as unreported rather than zero.
1335///
1336/// # These are CHIA peers, not DIG peers
1337///
1338/// [`chia_peer_count`](Self::chia_peer_count) counts CHIA FULL-NODE peers the wallet's chain sync is
1339/// connected to. It is NOT the DIG gossip/content peer count from `control.peerStatus`
1340/// (`connected_peers` / `relay_peer_count`); the two are unrelated numbers that move independently.
1341/// A surface that placed one of them beside a wallet sync status under a bare label of "peers" would
1342/// assert something false — a node with many DIG peers and no Chia peer is a wallet that is not
1343/// syncing at all. A caller that wants BOTH networks' counts reads [`PeerCountsResult`], which is
1344/// the one call that answers for each network by name.
1345///
1346/// # The duplicated field is ONE observation
1347///
1348/// [`chia_peer_count`](Self::chia_peer_count) also appears on [`PeerCountsResult`], and the two are
1349/// the SAME observation: a conforming node MUST serve them from one source, and they MUST agree
1350/// within a single node's view. The field is duplicated rather than moved because it is load-bearing
1351/// HERE — `chia_peer_count: 0` beside `Syncing` is the honest "syncing — no peers" state, and a
1352/// phase separated from its count reads as a contradiction. A DIG content-network count, by
1353/// contrast, is not a wallet fact and does not vary with wallet state, which is why it is absent
1354/// from this type rather than added for symmetry.
1355///
1356/// # Which field combinations are meaningful
1357///
1358/// `{phase: Synced, peak_height: null}` MUST NOT be emitted. A node records its peak BEFORE it marks
1359/// the initial catch-up complete, so a completed catch-up always has a height behind it; a `Synced`
1360/// with no height describes a state a conforming node cannot be in, and a consumer has no honest
1361/// reading for it.
1362///
1363/// `{phase: NotStarted, peak_height: <some height>}` is the opposite case, and is EXPLICITLY
1364/// LEGITIMATE — it is not a contradiction and MUST NOT be "fixed". The height is persisted in the
1365/// wallet database, while the phase describes whether a sync is running IN THIS PROCESS. A node that
1366/// synced yesterday and has just restarted reports exactly this, and reports it truthfully: *here is
1367/// the height I reached, and no sync is running right now.* Forbidding the pair would force a
1368/// conforming node to either fabricate a phase it is not in or discard a height it genuinely has —
1369/// which is the dishonesty this method was created to prevent. `peak_height: null` alongside
1370/// `NotStarted` is equally legitimate and means a wallet that has never synced at all.
1371///
1372/// # No confirmation-depth arithmetic happens here
1373///
1374/// The height recorded is the height of the LAST EXISTING block the peer view reported
1375/// (`NewPeakWallet.height` / `RespondPuzzleState.height` from a real full node). This surface
1376/// performs no depth arithmetic. dig_ecosystem#2483 records that `peak_height`'s meaning differs
1377/// between a simulator (the NEXT height) and a full node (the last existing one), so a consumer
1378/// computing depth must floor its own input rather than assume a convention.
1379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1380pub struct WalletSyncStatusResult {
1381    /// Which state the wallet's chain sync is in. See [`WalletSyncPhase`].
1382    pub phase: WalletSyncPhase,
1383    /// The replica's own peak height, or `null` when it has none — never height 0 as a stand-in for
1384    /// unknown, and never an oracle's height. See the type docs.
1385    pub peak_height: Option<u32>,
1386    /// How many CHIA full-node peers the sync is connected to. `0` is an observed zero; `null` means
1387    /// the node cannot observe the count. Not the DIG peer count — see the type docs.
1388    pub chia_peer_count: Option<u32>,
1389    /// How many addresses the wallet sync is actually following. `Some(0)` is an observed zero;
1390    /// `None` means the node did not report the number at all — including because it predates the
1391    /// field. See the type docs for why that distinction is load-bearing.
1392    pub watched_addresses: Option<u32>,
1393}
1394
1395/// `control.wallet.broadcast` — the outcome of pushing an already-signed bundle.
1396///
1397/// # A rejection is a VALUE; an unreachable network is an ERROR
1398///
1399/// A mempool that looked at the bundle and said no is a successful call with `accepted: false` and
1400/// a [`rejection`](Self::rejection) reason — the bundle was seen and judged. Failing to REACH a
1401/// mempool is a catalogued error instead. Collapsing the two turns "your wifi dropped" into "your
1402/// mint failed", and the remedies are opposite: retry the same bundle, versus build a new one.
1403///
1404/// # Accepted is not confirmed
1405///
1406/// `accepted: true` says the mempool took the bundle. It is not evidence that anything reached a
1407/// block, and a caller must never record an outcome from it — only a buried confirmation of the
1408/// created coin is evidence.
1409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1410pub struct WalletBroadcastResult {
1411    /// Whether the network accepted the bundle into its mempool.
1412    pub accepted: bool,
1413    /// The transaction id (the spend bundle's name), lowercase 64-hex, when accepted.
1414    pub transaction_id: Option<String>,
1415    /// Why the mempool refused, when it refused. `null` on acceptance.
1416    pub rejection: Option<String>,
1417}
1418
1419/// `pairing.request` — the pairing handshake bootstrap (OPEN, no token).
1420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1421pub struct PairingRequestResult {
1422    /// The opaque pairing id to poll with.
1423    pub pairing_id: String,
1424    /// A short numeric code the operator compares before approving.
1425    pub pairing_code: String,
1426    /// When the pending pairing expires, in unix milliseconds.
1427    pub expires_ms: u64,
1428}
1429
1430/// `pairing.poll` — the pairing poll outcome (OPEN, no token).
1431#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1432pub struct PairingPollResult {
1433    /// The pairing status (`"pending"` / `"approved"` / …).
1434    pub status: String,
1435    /// The minted scoped token, present exactly once after approval.
1436    #[serde(skip_serializing_if = "Option::is_none", default)]
1437    pub token: Option<String>,
1438}
1439
1440/// One confirmed incoming payment, as the node's arrival ledger recorded it.
1441///
1442/// Every field is a public chain fact about an address this node already watches. There is
1443/// deliberately no ticker and no formatted amount: naming an asset the node did not attribute, or
1444/// choosing a divisor for it, would be a claim about WHICH money arrived that the node cannot
1445/// support.
1446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1447pub struct WalletArrivalRecord {
1448    /// This arrival's monotonic ledger position. Strictly increasing and never reused, so a stored
1449    /// position cannot come to mean a different arrival after a reorg.
1450    pub seq: u64,
1451    /// The coin that arrived (lowercase hex).
1452    pub coin_id: String,
1453    /// The watched puzzle hash it arrived at (lowercase hex).
1454    pub puzzle_hash: String,
1455    /// The amount in the asset's own base unit, as a DECIMAL STRING.
1456    ///
1457    /// A string because the ledger stores the full `u64` range and a JSON number does not carry it
1458    /// losslessly — a large mojo amount silently rounds through an f64 parser, which is a wrong
1459    /// figure about somebody's money.
1460    pub amount: String,
1461    /// The CAT asset id (hex TAIL), or `None` for native XCH.
1462    pub asset_id: Option<String>,
1463    /// The height the coin was CONFIRMED at. Never optional: an arrival with no confirmed height is
1464    /// not an arrival, and a node MUST NOT emit a mempool sighting here.
1465    pub confirmed_height: u32,
1466}
1467
1468/// One page of the arrival ledger (`control.wallet.arrivals`).
1469///
1470/// An empty [`arrivals`](Self::arrivals) list is an ANSWER — the node consulted its own replica and
1471/// nothing has arrived since the cursor. It is NOT a claim that the replica is current: a node that
1472/// has never completed a catch-up has no arrival baseline and reports an empty page forever, which
1473/// is the honest answer to "what arrived?" from a wallet that cannot tell history from news. A
1474/// caller that needs to know whether the replica is current asks `control.wallet.syncStatus`.
1475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1476pub struct WalletArrivalsResult {
1477    /// The page, oldest first.
1478    pub arrivals: Vec<WalletArrivalRecord>,
1479    /// Where the CLIENT got to: the position of the last row in this page, or the caller's own
1480    /// `after_seq` when the page is empty. **This is the value to resume from.**
1481    pub cursor: u64,
1482    /// Where the LEDGER got to when this answer was assembled.
1483    ///
1484    /// Read AFTER the page, so an arrival recorded in between sits above the page and below this
1485    /// value — which is exactly why resuming from it would step straight over that arrival and lose
1486    /// a notification silently. It exists for ONE question [`cursor`](Self::cursor) cannot answer: a
1487    /// first-run client passes it back as `after_seq` to start from NOW instead of replaying the
1488    /// whole ledger as a burst of toasts.
1489    pub latest: u64,
1490}
1491
1492#[cfg(test)]
1493mod tests {
1494    use super::*;
1495    use serde_json::json;
1496
1497    #[test]
1498    fn status_result_round_trips_the_node_shape() {
1499        let v = json!({
1500            "running": true, "service": "dig-node", "version": "0.30.0", "commit": "abc",
1501            "protocol": "21", "uptime_secs": 5, "addr": "127.0.0.1:9256", "upstream": "https://rpc.dig.net",
1502            "cache": {"cap_bytes": 1024, "used_bytes": 10, "dir": "/c", "shared": false},
1503            "hosted_store_count": 2, "cached_capsule_count": 3, "pinned_store_count": 1,
1504            "sync": {"available": true}
1505        });
1506        let parsed: StatusResult = serde_json::from_value(v.clone()).unwrap();
1507        assert_eq!(serde_json::to_value(&parsed).unwrap(), v);
1508    }
1509
1510    #[test]
1511    fn config_result_keeps_upstream_override_null_when_unset() {
1512        let parsed = ConfigResult {
1513            addr: "127.0.0.1:9256".into(),
1514            port: "9256".into(),
1515            upstream: "https://rpc.dig.net".into(),
1516            upstream_override: None,
1517            cache_dir: "/c".into(),
1518            cache_shared: false,
1519            config_path: "/c/config.json".into(),
1520            sync_available: true,
1521        };
1522        let v = serde_json::to_value(&parsed).unwrap();
1523        assert_eq!(v["upstream_override"], json!(null));
1524        assert!(v.as_object().unwrap().contains_key("upstream_override"));
1525    }
1526
1527    #[test]
1528    fn pairing_poll_omits_token_until_approved() {
1529        let pending = PairingPollResult {
1530            status: "pending".into(),
1531            token: None,
1532        };
1533        let v = serde_json::to_value(&pending).unwrap();
1534        assert_eq!(v, json!({"status": "pending"}));
1535        let approved = PairingPollResult {
1536            status: "approved".into(),
1537            token: Some("deadbeef".into()),
1538        };
1539        assert_eq!(
1540            serde_json::to_value(&approved).unwrap(),
1541            json!({"status": "approved", "token": "deadbeef"})
1542        );
1543    }
1544
1545    // ---- PeerSoftware (dig_ecosystem#2215) ----
1546
1547    /// The mapping that matters most. Every peer built before #2215 advertises the LITERAL
1548    /// `"0.0.0"` — three of dig-gossip's four handshake send sites hardcoded it. A parser that
1549    /// maps only `""` to Unknown would therefore read the entire live fleet as "software version
1550    /// 0.0.0", which any later `>=` comparison treats as ancient. `""`, `"0.0.0"`, and anything
1551    /// unparseable must all be Unknown, and this test is that mapping's guard.
1552    #[test]
1553    fn unknown_covers_empty_the_legacy_sentinel_and_garbage() {
1554        for raw in [
1555            "",                       // a peer advertising nothing, or `off` coarsening
1556            "0.0.0",                  // the pre-#2215 legacy sentinel
1557            "   ",                    // whitespace only
1558            "dig-node",               // no version part
1559            "dig-node/",              // empty version part
1560            "dig-node/not-a-version", // unparseable version
1561            "/1.2.3",                 // empty product part
1562            "1.2.3",                  // bare version, no product
1563            "dig-node/0.0.0",         // the sentinel, however it is dressed up
1564        ] {
1565            assert_eq!(
1566                PeerSoftware::parse(raw),
1567                PeerSoftware::Unknown,
1568                "{raw:?} must map to Unknown"
1569            );
1570        }
1571    }
1572
1573    /// A well-formed `product/semver` advertisement is reported with all three parts, and `raw`
1574    /// preserves exactly what the peer sent so a diagnostic reader is never shown a value the peer
1575    /// did not actually advertise.
1576    #[test]
1577    fn reported_carries_product_version_and_the_raw_advertisement() {
1578        let parsed = PeerSoftware::parse("dig-node/0.99.1");
1579        let PeerSoftware::Reported {
1580            product,
1581            version,
1582            raw,
1583        } = parsed
1584        else {
1585            panic!("a well-formed advertisement must be Reported");
1586        };
1587        assert_eq!(product, "dig-node");
1588        assert_eq!(version, semver::Version::new(0, 99, 1));
1589        assert_eq!(raw, "dig-node/0.99.1");
1590    }
1591
1592    /// A product name may itself contain a `/`; only the LAST separator splits product from
1593    /// version. Pinning this stops a future reader from switching to a first-separator split,
1594    /// which would silently reclassify such a peer as Unknown.
1595    #[test]
1596    fn product_is_split_at_the_last_separator() {
1597        let PeerSoftware::Reported {
1598            product, version, ..
1599        } = PeerSoftware::parse("acme/dig-node/1.2.3")
1600        else {
1601            panic!("expected Reported");
1602        };
1603        assert_eq!(product, "acme/dig-node");
1604        assert_eq!(version, semver::Version::new(1, 2, 3));
1605    }
1606
1607    /// Surrounding whitespace is trimmed before parsing, and `raw` records the TRIMMED
1608    /// advertisement. CON-008 sanitization strips Unicode Cc/Cf from the wire value but not
1609    /// spaces, so a padded advertisement reaches this parser intact and must not be classified as
1610    /// unparseable merely for having been padded.
1611    #[test]
1612    fn surrounding_whitespace_is_trimmed_before_parsing() {
1613        let PeerSoftware::Reported {
1614            product,
1615            version,
1616            raw,
1617        } = PeerSoftware::parse("  dig-node/1.2.3	")
1618        else {
1619            panic!("a padded advertisement must still be Reported");
1620        };
1621        assert_eq!(product, "dig-node");
1622        assert_eq!(version, semver::Version::new(1, 2, 3));
1623        assert_eq!(raw, "dig-node/1.2.3", "raw must record the trimmed value");
1624    }
1625
1626    /// A pre-release/build-metadata semver survives intact, because that is what a nightly build
1627    /// advertises and dropping it would make every nightly indistinguishable from its release.
1628    #[test]
1629    fn prerelease_versions_are_preserved() {
1630        let PeerSoftware::Reported { version, raw, .. } =
1631            PeerSoftware::parse("dig-node/1.0.0-nightly.20260805")
1632        else {
1633            panic!("expected Reported");
1634        };
1635        assert_eq!(version.to_string(), "1.0.0-nightly.20260805");
1636        assert_eq!(raw, "dig-node/1.0.0-nightly.20260805");
1637    }
1638
1639    /// Unknown's JSON is a tagged object — never `"0.0.0"`, never `""`, never a null sitting in a
1640    /// version field where a consumer might read it as a number.
1641    #[test]
1642    fn unknown_serializes_as_a_tagged_object_with_no_version_field() {
1643        let v = serde_json::to_value(PeerSoftware::Unknown).unwrap();
1644        assert_eq!(v, json!({"kind": "unknown"}));
1645        assert!(
1646            v.get("version").is_none(),
1647            "Unknown must not carry a version field at all"
1648        );
1649    }
1650
1651    /// Both variants round-trip byte-identically, which is what lets a client re-encode a node's
1652    /// response unchanged.
1653    #[test]
1654    fn both_variants_round_trip_byte_identically() {
1655        for wire in [
1656            json!({"kind": "unknown"}),
1657            json!({
1658                "kind": "reported",
1659                "product": "dig-node",
1660                "version": "0.99.1",
1661                "raw": "dig-node/0.99.1"
1662            }),
1663        ] {
1664            let parsed: PeerSoftware = serde_json::from_value(wire.clone()).unwrap();
1665            assert_eq!(serde_json::to_value(&parsed).unwrap(), wire);
1666        }
1667    }
1668
1669    /// Parsing a wire string and serializing the result produces the documented JSON, so the two
1670    /// halves of the contract cannot drift from each other.
1671    #[test]
1672    fn parse_then_serialize_matches_the_documented_json() {
1673        assert_eq!(
1674            serde_json::to_value(PeerSoftware::parse("dig-node/0.99.1")).unwrap(),
1675            json!({
1676                "kind": "reported",
1677                "product": "dig-node",
1678                "version": "0.99.1",
1679                "raw": "dig-node/0.99.1"
1680            })
1681        );
1682        assert_eq!(
1683            serde_json::to_value(PeerSoftware::parse("0.0.0")).unwrap(),
1684            json!({"kind": "unknown"})
1685        );
1686    }
1687
1688    // ---- Trait-absence probes (dig_ecosystem#2215) ----
1689    //
1690    // `PeerSoftware` deriving `Ord` or `Default` would be a silent correctness regression rather
1691    // than a compile error anywhere, so it is pinned here. The probe exploits inherent-impl
1692    // precedence: `Probe::<T>::has_it()` resolves to the inherent impl (returning `true`) only when
1693    // `T` satisfies the bound, and otherwise falls back to the blanket trait impl (`false`).
1694    //
1695    // Each probe carries a CONTROL on a type that DOES implement the trait. Without the control, a
1696    // probe broken so that it always answers `false` would pass while proving nothing.
1697
1698    struct Probe<T>(core::marker::PhantomData<T>);
1699
1700    trait ProbeFallback {
1701        fn is_ord() -> bool {
1702            false
1703        }
1704    }
1705    impl<T> ProbeFallback for Probe<T> {}
1706
1707    impl<T: Ord> Probe<T> {
1708        fn is_ord() -> bool {
1709            true
1710        }
1711    }
1712
1713    struct PartialOrdProbe<T>(core::marker::PhantomData<T>);
1714    trait PartialOrdFallback {
1715        fn is_partial_ord() -> bool {
1716            false
1717        }
1718    }
1719    impl<T> PartialOrdFallback for PartialOrdProbe<T> {}
1720    impl<T: PartialOrd> PartialOrdProbe<T> {
1721        fn is_partial_ord() -> bool {
1722            true
1723        }
1724    }
1725
1726    /// A version comparison must be unreachable without first destructuring `Reported`, so that a
1727    /// caller cannot order `Unknown` against a real version — which, since every pre-#2215 peer is
1728    /// Unknown, would quietly become a verdict about most of the live network.
1729    #[test]
1730    fn peer_software_is_not_ordered() {
1731        assert!(
1732            Probe::<u32>::is_ord(),
1733            "control: the probe must detect a type that IS Ord, or it proves nothing"
1734        );
1735        assert!(
1736            !Probe::<PeerSoftware>::is_ord(),
1737            "PeerSoftware must not implement Ord — comparison belongs after destructuring Reported"
1738        );
1739    }
1740
1741    /// A defaulted `Unknown` appearing from nowhere is a different fact from a measured one, and
1742    /// `Default` would make the two indistinguishable at the point of construction.
1743    #[test]
1744    fn peer_software_has_no_default() {
1745        struct DefaultProbe<T>(core::marker::PhantomData<T>);
1746        trait DefaultFallback {
1747            fn is_default() -> bool {
1748                false
1749            }
1750        }
1751        impl<T> DefaultFallback for DefaultProbe<T> {}
1752        impl<T: Default> DefaultProbe<T> {
1753            fn is_default() -> bool {
1754                true
1755            }
1756        }
1757
1758        assert!(
1759            DefaultProbe::<String>::is_default(),
1760            "control: the probe must detect a type that IS Default, or it proves nothing"
1761        );
1762        assert!(
1763            !DefaultProbe::<PeerSoftware>::is_default(),
1764            "PeerSoftware must not implement Default"
1765        );
1766    }
1767
1768    // ---- SoftwareVersionDetail (dig_ecosystem#2215) ----
1769
1770    /// Each mode renders a value the PARSER reads back at the intended level of detail.
1771    ///
1772    /// The fixture uses a version with a non-zero minor AND a non-zero patch, because that is the
1773    /// only shape where `Full` and `Minor` differ — a `1.0.0` fixture would let a renderer that
1774    /// ignores the mode entirely pass.
1775    #[test]
1776    fn each_detail_mode_round_trips_to_the_intended_precision() {
1777        let v = semver::Version::new(0, 99, 1);
1778
1779        let full = SoftwareVersionDetail::Full.render("dig-node", &v);
1780        assert_eq!(full, "dig-node/0.99.1");
1781        assert_eq!(
1782            PeerSoftware::parse(&full),
1783            PeerSoftware::parse("dig-node/0.99.1")
1784        );
1785
1786        let minor = SoftwareVersionDetail::Minor.render("dig-node", &v);
1787        assert_ne!(minor, full, "Minor must actually coarsen");
1788        let PeerSoftware::Reported { version, .. } = PeerSoftware::parse(&minor) else {
1789            panic!("a coarsened advertisement must still be READABLE, not Unknown");
1790        };
1791        assert_eq!(version.major, 0);
1792        assert_eq!(version.minor, 99);
1793        assert_eq!(version.patch, 0, "the patch level is what Minor hides");
1794
1795        let off = SoftwareVersionDetail::Off.render("dig-node", &v);
1796        assert_eq!(off, "");
1797        assert_eq!(PeerSoftware::parse(&off), PeerSoftware::Unknown);
1798    }
1799
1800    /// `Minor` renders `MAJOR.MINOR.0`, NOT `MAJOR.MINOR`.
1801    ///
1802    /// A bare two-part `0.99` is not valid semver, so the parser would classify a peer that
1803    /// coarsened its build as Unknown — turning "tell them less" into "tell them nothing", which
1804    /// is what `Off` is for. This test is the guard on that distinction.
1805    #[test]
1806    fn minor_mode_stays_valid_semver_rather_than_collapsing_to_unknown() {
1807        let rendered =
1808            SoftwareVersionDetail::Minor.render("dig-node", &semver::Version::new(1, 4, 7));
1809        assert_eq!(rendered, "dig-node/1.4.0");
1810        assert_ne!(
1811            PeerSoftware::parse(&rendered),
1812            PeerSoftware::Unknown,
1813            "a coarsened build must remain readable; `product/1.4` would not be"
1814        );
1815    }
1816
1817    /// Coarsening strips pre-release and build metadata. A nightly's identifier is more precisely
1818    /// identifying than the patch number it accompanies, so leaving it in place would make `Minor`
1819    /// coarsen nothing at all for exactly the builds that most want it.
1820    #[test]
1821    fn minor_mode_strips_prerelease_and_build_metadata() {
1822        let v: semver::Version = "1.0.0-nightly.20260805+sha.abc123".parse().unwrap();
1823        let rendered = SoftwareVersionDetail::Minor.render("dig-node", &v);
1824        assert_eq!(rendered, "dig-node/1.0.0");
1825        assert!(
1826            !rendered.contains("nightly"),
1827            "the nightly identifier must not survive coarsening"
1828        );
1829        assert!(
1830            !rendered.contains("abc123"),
1831            "build metadata must not survive coarsening"
1832        );
1833    }
1834
1835    /// `Off` renders the empty string for ANY version, which is what makes it indistinguishable
1836    /// from a peer built before the field existed.
1837    #[test]
1838    fn off_mode_reveals_nothing_for_any_version() {
1839        for v in ["0.0.1", "1.2.3", "99.99.99-rc.1"] {
1840            let rendered = SoftwareVersionDetail::Off.render("dig-node", &v.parse().unwrap());
1841            assert_eq!(
1842                rendered, "",
1843                "Off must reveal nothing, including the product name"
1844            );
1845        }
1846    }
1847
1848    /// The default is the most informative setting: the diagnostic value is the reason the field
1849    /// exists, and an operator who disagrees opts down explicitly.
1850    #[test]
1851    fn detail_defaults_to_full() {
1852        assert_eq!(
1853            SoftwareVersionDetail::default(),
1854            SoftwareVersionDetail::Full
1855        );
1856    }
1857
1858    /// The wire tokens are the lowercase words an operator writes in a config file, and they are a
1859    /// published contract once a config carries them.
1860    #[test]
1861    fn detail_uses_lowercase_wire_tokens() {
1862        for (mode, token) in [
1863            (SoftwareVersionDetail::Full, "\"full\""),
1864            (SoftwareVersionDetail::Minor, "\"minor\""),
1865            (SoftwareVersionDetail::Off, "\"off\""),
1866        ] {
1867            assert_eq!(serde_json::to_string(&mode).unwrap(), token);
1868            assert_eq!(
1869                serde_json::from_str::<SoftwareVersionDetail>(token).unwrap(),
1870                mode
1871            );
1872        }
1873    }
1874
1875    // ---- Gate round 1 regressions (dig_ecosystem#2215) ----
1876
1877    /// **`PartialOrd` is the hazard the `Ord` probe misses.** `Ord: PartialOrd`, so a type can
1878    /// derive only `PartialOrd` — satisfying an `Ord`-only probe — while `Unknown < Reported(..)`
1879    /// still compiles and evaluates. That one-word derive would sort `Unknown` below every real
1880    /// version, which is the verdict-about-the-live-network the contract forbids. SPEC §4.1 names
1881    /// all three traits; this pins the weakest of them, which subsumes `Ord`.
1882    #[test]
1883    fn peer_software_is_not_partially_ordered_either() {
1884        assert!(
1885            PartialOrdProbe::<f64>::is_partial_ord(),
1886            "control: the probe must detect a type that IS PartialOrd but NOT Ord, or it proves              nothing about the gap between the two"
1887        );
1888        assert!(
1889            PartialOrdProbe::<u32>::is_partial_ord(),
1890            "control: a fully-ordered type must also be detected"
1891        );
1892        assert!(
1893            !PartialOrdProbe::<PeerSoftware>::is_partial_ord(),
1894            "PeerSoftware must implement neither PartialOrd nor Ord"
1895        );
1896    }
1897
1898    /// **The sentinel is VERSION ZERO, a class — not the three-character string `\"0.0.0\"`.**
1899    /// The constant's doc, `parse`'s doc, and SPEC §4.1 all state the rule over the class, so a
1900    /// string comparison lets `0.0.0+build`, `0.0.0-rc.1`, and `0.0.0-0` through as a *reported*
1901    /// version zero — the exact reading every one of those three prose statements forbids.
1902    #[test]
1903    fn version_zero_is_unknown_however_it_is_decorated() {
1904        for raw in [
1905            "dig-node/0.0.0",
1906            "dig-node/0.0.0+build",
1907            "dig-node/0.0.0-rc.1",
1908            "x/0.0.0-0",
1909            "dig-node/0.0.0-alpha+sha.abc123",
1910        ] {
1911            assert_eq!(
1912                PeerSoftware::parse(raw),
1913                PeerSoftware::Unknown,
1914                "{raw:?} is version zero and must be Unknown"
1915            );
1916        }
1917    }
1918
1919    /// A version that is merely CLOSE to zero is still a real build and must be reported — without
1920    /// this, a parser that mapped everything below `0.1.0` to Unknown would pass the test above.
1921    #[test]
1922    fn a_nonzero_version_near_zero_is_still_reported() {
1923        for raw in ["dig-node/0.0.1", "dig-node/0.1.0", "dig-node/0.0.1-rc.1"] {
1924            assert_ne!(
1925                PeerSoftware::parse(raw),
1926                PeerSoftware::Unknown,
1927                "{raw:?} is a real build, not the sentinel"
1928            );
1929        }
1930    }
1931
1932    /// **`render`'s stated invariant, tested over the class it is stated over.**
1933    ///
1934    /// The doc promises: every rendering is either the empty string or a value `parse` reads back
1935    /// as `Reported`. A `1.4.7` fixture cannot see the case that breaks it — a `0.0.x` build, whose
1936    /// `MAJOR.MINOR.0` coarsening IS version zero and therefore reads as Unknown. That is the same
1937    /// Minor-collapses-into-Off defect as the two-part spelling, arriving through the other door.
1938    #[test]
1939    fn every_rendering_is_empty_or_readable() {
1940        let versions = [
1941            "0.0.1",
1942            "0.0.7",
1943            "0.0.99", // the class the 1.4.7 fixture cannot see
1944            "0.1.0",
1945            "0.99.1",
1946            "1.0.0",
1947            "1.4.7",
1948            "10.20.30",
1949            "1.0.0-nightly.20260805+sha.abc123",
1950            "0.0.1-rc.1",
1951        ];
1952        for mode in [
1953            SoftwareVersionDetail::Full,
1954            SoftwareVersionDetail::Minor,
1955            SoftwareVersionDetail::Off,
1956        ] {
1957            for v in versions {
1958                let rendered = mode.render("dig-node", &v.parse().unwrap());
1959                if rendered.is_empty() {
1960                    continue;
1961                }
1962                assert_ne!(
1963                    PeerSoftware::parse(&rendered),
1964                    PeerSoftware::Unknown,
1965                    "{mode:?} rendered {rendered:?} for {v}, which reads back as Unknown — a                      non-empty rendering must always be readable"
1966                );
1967            }
1968        }
1969    }
1970
1971    /// `Minor` on a `0.0.x` build advertises NOTHING, deliberately.
1972    ///
1973    /// Hiding the patch of a `0.0.x` version leaves only version zero, which the wire reserves as
1974    /// the "unknown" sentinel. There is no coarser representable value, so the honest rendering is
1975    /// the empty string rather than the sentinel dressed up as a report. This differs from the
1976    /// `0.99` case: there a representable coarse value existed and the wrong spelling was chosen;
1977    /// here none exists.
1978    #[test]
1979    fn minor_of_a_zero_zero_build_advertises_nothing_rather_than_the_sentinel() {
1980        let rendered = SoftwareVersionDetail::Minor.render("dig-node", &"0.0.7".parse().unwrap());
1981        assert_eq!(rendered, "");
1982        assert_ne!(
1983            rendered, "dig-node/0.0.0",
1984            "the sentinel must never be ADVERTISED; it is only ever received from a legacy peer"
1985        );
1986    }
1987
1988    /// **Tripwire, not a guard.** `raw` is reconstructible from `product` + `version` for every
1989    /// string the current grammar accepts, because `semver::Version` re-renders losslessly. This
1990    /// asserts that equivalence deliberately.
1991    ///
1992    /// **When this test FAILS, `raw` has become load-bearing** — the grammar has started accepting
1993    /// something non-canonical (a `v` prefix, a two-part version, a vendor suffix) and `raw` is now
1994    /// the only record of what the peer actually sent. Do not "fix" it by deleting the field;
1995    /// replace this test with real assertions on the divergent inputs.
1996    #[test]
1997    fn raw_is_still_reconstructible_from_the_parsed_parts() {
1998        for advertised in [
1999            "dig-node/0.0.1",
2000            "dig-node/0.99.1",
2001            "dig-node/1.0.0-nightly.20260805",
2002            "dig-node/1.0.0+sha.abc123",
2003            "dig-node/1.0.0-rc.1+build.7",
2004            "acme/dig-node/1.2.3",
2005        ] {
2006            let PeerSoftware::Reported {
2007                product,
2008                version,
2009                raw,
2010            } = PeerSoftware::parse(advertised)
2011            else {
2012                panic!("{advertised:?} must be Reported");
2013            };
2014            assert_eq!(
2015                raw,
2016                format!("{product}/{version}"),
2017                "raw diverged from the parsed parts for {advertised:?} — `raw` is now                  load-bearing; see this test's doc comment before changing anything"
2018            );
2019        }
2020    }
2021}