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/// `control.wallet.peak` — the node's current chain peak height.
706///
707/// `peak_height: null` is an honest "this node tracks no height yet", not a zero. A caller bounding
708/// a claimed confirmation MUST treat it as unknown rather than as height 0, which every block is
709/// trivially above.
710///
711/// # This `synced` is the WEAKER of the contract's two same-named notions
712///
713/// [`synced`](Self::synced) here reports only that the replica's initial catch-up COMPLETED. It says
714/// nothing about whether the wallet is still connected to a Chia peer, so a node that caught up
715/// yesterday and has been offline since still reports `synced: true` beside a height that stopped
716/// moving. [`WalletSyncStatusResult::phase`] answers the stronger question — *is this being kept
717/// current?* — and `WalletSyncPhase::Synced` therefore IMPLIES this flag while this flag does not
718/// imply that phase. The two are stated in terms of each other on purpose: they carry the same word
719/// and would otherwise drift apart silently.
720///
721/// # The height is the last EXISTING block
722///
723/// It is the height of the last block the peer view reported, never a next-block height. A consumer
724/// computing confirmation depth must floor its own arithmetic rather than assume a convention — see
725/// [`WalletSyncStatusResult`], which records why.
726#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
727pub struct WalletPeakResult {
728 /// The peak block height the node's chain view has reached, or `null` when it has none.
729 pub peak_height: Option<u32>,
730 /// Whether the node's own chain replica COMPLETED its catch-up. Weaker than
731 /// [`WalletSyncPhase::Synced`] — see the type docs.
732 pub synced: bool,
733}
734
735/// How far the node's wallet chain replica has got — the three states a background sync can be in.
736///
737/// Three named states rather than a boolean, because "has never started" and "is caught up" are
738/// different facts and a `bool` can only carry one of them. Paired with a `peak_height` a boolean
739/// forces a never-started wallet to report some height, and 0 is the only one available — which
740/// reads as *synced to the genesis block*, a claim about the chain that is simply false.
741#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
742#[serde(rename_all = "snake_case")]
743pub enum WalletSyncPhase {
744 /// No sync has begun: the wallet holds no replica of the chain and is not building one.
745 NotStarted,
746 /// A sync is running — either the initial catch-up, or the ongoing task that keeps the replica
747 /// current. A wallet whose catch-up finished but whose peer connections have all dropped is
748 /// `Syncing`, not [`Synced`](Self::Synced): it is trying to be current and is not.
749 Syncing,
750 /// The initial catch-up completed AND at least one Chia peer connection is currently live: the
751 /// replica is caught up and CONNECTED, so it is in a position to be kept current.
752 ///
753 /// That is what the predicate delivers, and no more. A live connection to a stalled or lagging
754 /// peer satisfies it while the replica quietly goes stale, so this phase MUST NOT be read as
755 /// proof that the data is FRESH — only that nothing is known to be preventing freshness.
756 Synced,
757}
758
759impl WalletSyncPhase {
760 /// Every phase, in progress order — the enumeration a machine reads, and the anchor the
761 /// conformance KATs pin the wire tokens against.
762 pub const ALL: &'static [WalletSyncPhase] = &[
763 WalletSyncPhase::NotStarted,
764 WalletSyncPhase::Syncing,
765 WalletSyncPhase::Synced,
766 ];
767}
768
769/// `control.wallet.syncStatus` — is the wallet's chain replica being kept current, how far has it
770/// got, and how many Chia peers is it using?
771///
772/// # `Synced` means CAUGHT UP AND CONNECTED, not ONCE CAUGHT UP
773///
774/// [`phase`](Self::phase) is [`WalletSyncPhase::Synced`] only when the initial catch-up completed
775/// AND at least one Chia peer connection is live right now. A wallet that caught up yesterday and
776/// has been offline since MUST report [`Syncing`](WalletSyncPhase::Syncing). This makes `phase ==
777/// Synced` STRICTLY STRONGER than [`WalletPeakResult::synced`], which reflects only the
778/// completed-catch-up flag: `Synced` implies that flag, the flag does not imply `Synced`. Both types
779/// say so, because the two notions share a word and nothing but the docs would keep them aligned.
780///
781/// This is the whole reason the method exists. A surface asking *does my wallet stay synced?* cannot
782/// be answered by a flag that a disconnected wallet still sets.
783///
784/// **`Synced` is nevertheless not a freshness guarantee.** Being connected is not being up to date:
785/// a live connection to a stalled or lagging peer satisfies the predicate while the replica goes
786/// stale. The phase reports that catch-up finished and a peer is attached — that nothing KNOWN is
787/// preventing the replica from being kept current — and a consumer needing actual freshness must
788/// compare [`peak_height`](Self::peak_height) against something, not read this phase. Stating the
789/// limit is the point: this family exists because a surface asserted more than it knew.
790///
791/// # The height NEVER comes from a third-party oracle
792///
793/// [`peak_height`](Self::peak_height) is the node's OWN replica's height or `null`. It MUST NOT fall
794/// back to the coinset oracle. `control.wallet.peak` deliberately does fall back, because it answers
795/// a different question — *what height is the chain at?* — whereas this field answers *how far has
796/// this replica got?* An oracle's height here would report a caller's own sync progress using a
797/// number the replica never reached, which is precisely the reading a progress display makes.
798///
799/// # `chia_peer_count: 0` is the disambiguator, not a fourth phase
800///
801/// A sync that is running while connected to nothing reports `Syncing` with a count of `0`, and a
802/// consumer SHOULD render the count alongside the phase for exactly that reason: "syncing — no
803/// peers" is honest where a bare "syncing" implies progress that is not happening. `null` means the
804/// node cannot observe the count at all and licenses no claim about connectivity either way.
805///
806/// # These are CHIA peers, not DIG peers
807///
808/// [`chia_peer_count`](Self::chia_peer_count) counts CHIA FULL-NODE peers the wallet's chain sync is
809/// connected to. It is NOT the DIG gossip/content peer count from `control.peerStatus`
810/// (`connected_peers` / `relay_peer_count`); the two are unrelated numbers that move independently.
811/// A surface that placed one of them beside a wallet sync status under a bare label of "peers" would
812/// assert something false — a node with many DIG peers and no Chia peer is a wallet that is not
813/// syncing at all. A caller that wants BOTH networks' counts reads [`PeerCountsResult`], which is
814/// the one call that answers for each network by name.
815///
816/// # The duplicated field is ONE observation
817///
818/// [`chia_peer_count`](Self::chia_peer_count) also appears on [`PeerCountsResult`], and the two are
819/// the SAME observation: a conforming node MUST serve them from one source, and they MUST agree
820/// within a single node's view. The field is duplicated rather than moved because it is load-bearing
821/// HERE — `chia_peer_count: 0` beside `Syncing` is the honest "syncing — no peers" state, and a
822/// phase separated from its count reads as a contradiction. A DIG content-network count, by
823/// contrast, is not a wallet fact and does not vary with wallet state, which is why it is absent
824/// from this type rather than added for symmetry.
825///
826/// # Which field combinations are meaningful
827///
828/// `{phase: Synced, peak_height: null}` MUST NOT be emitted. A node records its peak BEFORE it marks
829/// the initial catch-up complete, so a completed catch-up always has a height behind it; a `Synced`
830/// with no height describes a state a conforming node cannot be in, and a consumer has no honest
831/// reading for it.
832///
833/// `{phase: NotStarted, peak_height: <some height>}` is the opposite case, and is EXPLICITLY
834/// LEGITIMATE — it is not a contradiction and MUST NOT be "fixed". The height is persisted in the
835/// wallet database, while the phase describes whether a sync is running IN THIS PROCESS. A node that
836/// synced yesterday and has just restarted reports exactly this, and reports it truthfully: *here is
837/// the height I reached, and no sync is running right now.* Forbidding the pair would force a
838/// conforming node to either fabricate a phase it is not in or discard a height it genuinely has —
839/// which is the dishonesty this method was created to prevent. `peak_height: null` alongside
840/// `NotStarted` is equally legitimate and means a wallet that has never synced at all.
841///
842/// # No confirmation-depth arithmetic happens here
843///
844/// The height recorded is the height of the LAST EXISTING block the peer view reported
845/// (`NewPeakWallet.height` / `RespondPuzzleState.height` from a real full node). This surface
846/// performs no depth arithmetic. dig_ecosystem#2483 records that `peak_height`'s meaning differs
847/// between a simulator (the NEXT height) and a full node (the last existing one), so a consumer
848/// computing depth must floor its own input rather than assume a convention.
849#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
850pub struct WalletSyncStatusResult {
851 /// Which of the three states the wallet's chain sync is in. See [`WalletSyncPhase`].
852 pub phase: WalletSyncPhase,
853 /// The replica's own peak height, or `null` when it has none — never height 0 as a stand-in for
854 /// unknown, and never an oracle's height. See the type docs.
855 pub peak_height: Option<u32>,
856 /// How many CHIA full-node peers the sync is connected to. `0` is an observed zero; `null` means
857 /// the node cannot observe the count. Not the DIG peer count — see the type docs.
858 pub chia_peer_count: Option<u32>,
859}
860
861/// `control.wallet.broadcast` — the outcome of pushing an already-signed bundle.
862///
863/// # A rejection is a VALUE; an unreachable network is an ERROR
864///
865/// A mempool that looked at the bundle and said no is a successful call with `accepted: false` and
866/// a [`rejection`](Self::rejection) reason — the bundle was seen and judged. Failing to REACH a
867/// mempool is a catalogued error instead. Collapsing the two turns "your wifi dropped" into "your
868/// mint failed", and the remedies are opposite: retry the same bundle, versus build a new one.
869///
870/// # Accepted is not confirmed
871///
872/// `accepted: true` says the mempool took the bundle. It is not evidence that anything reached a
873/// block, and a caller must never record an outcome from it — only a buried confirmation of the
874/// created coin is evidence.
875#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
876pub struct WalletBroadcastResult {
877 /// Whether the network accepted the bundle into its mempool.
878 pub accepted: bool,
879 /// The transaction id (the spend bundle's name), lowercase 64-hex, when accepted.
880 pub transaction_id: Option<String>,
881 /// Why the mempool refused, when it refused. `null` on acceptance.
882 pub rejection: Option<String>,
883}
884
885/// `pairing.request` — the pairing handshake bootstrap (OPEN, no token).
886#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
887pub struct PairingRequestResult {
888 /// The opaque pairing id to poll with.
889 pub pairing_id: String,
890 /// A short numeric code the operator compares before approving.
891 pub pairing_code: String,
892 /// When the pending pairing expires, in unix milliseconds.
893 pub expires_ms: u64,
894}
895
896/// `pairing.poll` — the pairing poll outcome (OPEN, no token).
897#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
898pub struct PairingPollResult {
899 /// The pairing status (`"pending"` / `"approved"` / …).
900 pub status: String,
901 /// The minted scoped token, present exactly once after approval.
902 #[serde(skip_serializing_if = "Option::is_none", default)]
903 pub token: Option<String>,
904}
905
906/// One confirmed incoming payment, as the node's arrival ledger recorded it.
907///
908/// Every field is a public chain fact about an address this node already watches. There is
909/// deliberately no ticker and no formatted amount: naming an asset the node did not attribute, or
910/// choosing a divisor for it, would be a claim about WHICH money arrived that the node cannot
911/// support.
912#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
913pub struct WalletArrivalRecord {
914 /// This arrival's monotonic ledger position. Strictly increasing and never reused, so a stored
915 /// position cannot come to mean a different arrival after a reorg.
916 pub seq: u64,
917 /// The coin that arrived (lowercase hex).
918 pub coin_id: String,
919 /// The watched puzzle hash it arrived at (lowercase hex).
920 pub puzzle_hash: String,
921 /// The amount in the asset's own base unit, as a DECIMAL STRING.
922 ///
923 /// A string because the ledger stores the full `u64` range and a JSON number does not carry it
924 /// losslessly — a large mojo amount silently rounds through an f64 parser, which is a wrong
925 /// figure about somebody's money.
926 pub amount: String,
927 /// The CAT asset id (hex TAIL), or `None` for native XCH.
928 pub asset_id: Option<String>,
929 /// The height the coin was CONFIRMED at. Never optional: an arrival with no confirmed height is
930 /// not an arrival, and a node MUST NOT emit a mempool sighting here.
931 pub confirmed_height: u32,
932}
933
934/// One page of the arrival ledger (`control.wallet.arrivals`).
935///
936/// An empty [`arrivals`](Self::arrivals) list is an ANSWER — the node consulted its own replica and
937/// nothing has arrived since the cursor. It is NOT a claim that the replica is current: a node that
938/// has never completed a catch-up has no arrival baseline and reports an empty page forever, which
939/// is the honest answer to "what arrived?" from a wallet that cannot tell history from news. A
940/// caller that needs to know whether the replica is current asks `control.wallet.syncStatus`.
941#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
942pub struct WalletArrivalsResult {
943 /// The page, oldest first.
944 pub arrivals: Vec<WalletArrivalRecord>,
945 /// Where the CLIENT got to: the position of the last row in this page, or the caller's own
946 /// `after_seq` when the page is empty. **This is the value to resume from.**
947 pub cursor: u64,
948 /// Where the LEDGER got to when this answer was assembled.
949 ///
950 /// Read AFTER the page, so an arrival recorded in between sits above the page and below this
951 /// value — which is exactly why resuming from it would step straight over that arrival and lose
952 /// a notification silently. It exists for ONE question [`cursor`](Self::cursor) cannot answer: a
953 /// first-run client passes it back as `after_seq` to start from NOW instead of replaying the
954 /// whole ledger as a burst of toasts.
955 pub latest: u64,
956}
957
958#[cfg(test)]
959mod tests {
960 use super::*;
961 use serde_json::json;
962
963 #[test]
964 fn status_result_round_trips_the_node_shape() {
965 let v = json!({
966 "running": true, "service": "dig-node", "version": "0.30.0", "commit": "abc",
967 "protocol": "21", "uptime_secs": 5, "addr": "127.0.0.1:9256", "upstream": "https://rpc.dig.net",
968 "cache": {"cap_bytes": 1024, "used_bytes": 10, "dir": "/c", "shared": false},
969 "hosted_store_count": 2, "cached_capsule_count": 3, "pinned_store_count": 1,
970 "sync": {"available": true}
971 });
972 let parsed: StatusResult = serde_json::from_value(v.clone()).unwrap();
973 assert_eq!(serde_json::to_value(&parsed).unwrap(), v);
974 }
975
976 #[test]
977 fn config_result_keeps_upstream_override_null_when_unset() {
978 let parsed = ConfigResult {
979 addr: "127.0.0.1:9256".into(),
980 port: "9256".into(),
981 upstream: "https://rpc.dig.net".into(),
982 upstream_override: None,
983 cache_dir: "/c".into(),
984 cache_shared: false,
985 config_path: "/c/config.json".into(),
986 sync_available: true,
987 };
988 let v = serde_json::to_value(&parsed).unwrap();
989 assert_eq!(v["upstream_override"], json!(null));
990 assert!(v.as_object().unwrap().contains_key("upstream_override"));
991 }
992
993 #[test]
994 fn pairing_poll_omits_token_until_approved() {
995 let pending = PairingPollResult {
996 status: "pending".into(),
997 token: None,
998 };
999 let v = serde_json::to_value(&pending).unwrap();
1000 assert_eq!(v, json!({"status": "pending"}));
1001 let approved = PairingPollResult {
1002 status: "approved".into(),
1003 token: Some("deadbeef".into()),
1004 };
1005 assert_eq!(
1006 serde_json::to_value(&approved).unwrap(),
1007 json!({"status": "approved", "token": "deadbeef"})
1008 );
1009 }
1010
1011 // ---- PeerSoftware (dig_ecosystem#2215) ----
1012
1013 /// The mapping that matters most. Every peer built before #2215 advertises the LITERAL
1014 /// `"0.0.0"` — three of dig-gossip's four handshake send sites hardcoded it. A parser that
1015 /// maps only `""` to Unknown would therefore read the entire live fleet as "software version
1016 /// 0.0.0", which any later `>=` comparison treats as ancient. `""`, `"0.0.0"`, and anything
1017 /// unparseable must all be Unknown, and this test is that mapping's guard.
1018 #[test]
1019 fn unknown_covers_empty_the_legacy_sentinel_and_garbage() {
1020 for raw in [
1021 "", // a peer advertising nothing, or `off` coarsening
1022 "0.0.0", // the pre-#2215 legacy sentinel
1023 " ", // whitespace only
1024 "dig-node", // no version part
1025 "dig-node/", // empty version part
1026 "dig-node/not-a-version", // unparseable version
1027 "/1.2.3", // empty product part
1028 "1.2.3", // bare version, no product
1029 "dig-node/0.0.0", // the sentinel, however it is dressed up
1030 ] {
1031 assert_eq!(
1032 PeerSoftware::parse(raw),
1033 PeerSoftware::Unknown,
1034 "{raw:?} must map to Unknown"
1035 );
1036 }
1037 }
1038
1039 /// A well-formed `product/semver` advertisement is reported with all three parts, and `raw`
1040 /// preserves exactly what the peer sent so a diagnostic reader is never shown a value the peer
1041 /// did not actually advertise.
1042 #[test]
1043 fn reported_carries_product_version_and_the_raw_advertisement() {
1044 let parsed = PeerSoftware::parse("dig-node/0.99.1");
1045 let PeerSoftware::Reported {
1046 product,
1047 version,
1048 raw,
1049 } = parsed
1050 else {
1051 panic!("a well-formed advertisement must be Reported");
1052 };
1053 assert_eq!(product, "dig-node");
1054 assert_eq!(version, semver::Version::new(0, 99, 1));
1055 assert_eq!(raw, "dig-node/0.99.1");
1056 }
1057
1058 /// A product name may itself contain a `/`; only the LAST separator splits product from
1059 /// version. Pinning this stops a future reader from switching to a first-separator split,
1060 /// which would silently reclassify such a peer as Unknown.
1061 #[test]
1062 fn product_is_split_at_the_last_separator() {
1063 let PeerSoftware::Reported {
1064 product, version, ..
1065 } = PeerSoftware::parse("acme/dig-node/1.2.3")
1066 else {
1067 panic!("expected Reported");
1068 };
1069 assert_eq!(product, "acme/dig-node");
1070 assert_eq!(version, semver::Version::new(1, 2, 3));
1071 }
1072
1073 /// Surrounding whitespace is trimmed before parsing, and `raw` records the TRIMMED
1074 /// advertisement. CON-008 sanitization strips Unicode Cc/Cf from the wire value but not
1075 /// spaces, so a padded advertisement reaches this parser intact and must not be classified as
1076 /// unparseable merely for having been padded.
1077 #[test]
1078 fn surrounding_whitespace_is_trimmed_before_parsing() {
1079 let PeerSoftware::Reported {
1080 product,
1081 version,
1082 raw,
1083 } = PeerSoftware::parse(" dig-node/1.2.3 ")
1084 else {
1085 panic!("a padded advertisement must still be Reported");
1086 };
1087 assert_eq!(product, "dig-node");
1088 assert_eq!(version, semver::Version::new(1, 2, 3));
1089 assert_eq!(raw, "dig-node/1.2.3", "raw must record the trimmed value");
1090 }
1091
1092 /// A pre-release/build-metadata semver survives intact, because that is what a nightly build
1093 /// advertises and dropping it would make every nightly indistinguishable from its release.
1094 #[test]
1095 fn prerelease_versions_are_preserved() {
1096 let PeerSoftware::Reported { version, raw, .. } =
1097 PeerSoftware::parse("dig-node/1.0.0-nightly.20260805")
1098 else {
1099 panic!("expected Reported");
1100 };
1101 assert_eq!(version.to_string(), "1.0.0-nightly.20260805");
1102 assert_eq!(raw, "dig-node/1.0.0-nightly.20260805");
1103 }
1104
1105 /// Unknown's JSON is a tagged object — never `"0.0.0"`, never `""`, never a null sitting in a
1106 /// version field where a consumer might read it as a number.
1107 #[test]
1108 fn unknown_serializes_as_a_tagged_object_with_no_version_field() {
1109 let v = serde_json::to_value(PeerSoftware::Unknown).unwrap();
1110 assert_eq!(v, json!({"kind": "unknown"}));
1111 assert!(
1112 v.get("version").is_none(),
1113 "Unknown must not carry a version field at all"
1114 );
1115 }
1116
1117 /// Both variants round-trip byte-identically, which is what lets a client re-encode a node's
1118 /// response unchanged.
1119 #[test]
1120 fn both_variants_round_trip_byte_identically() {
1121 for wire in [
1122 json!({"kind": "unknown"}),
1123 json!({
1124 "kind": "reported",
1125 "product": "dig-node",
1126 "version": "0.99.1",
1127 "raw": "dig-node/0.99.1"
1128 }),
1129 ] {
1130 let parsed: PeerSoftware = serde_json::from_value(wire.clone()).unwrap();
1131 assert_eq!(serde_json::to_value(&parsed).unwrap(), wire);
1132 }
1133 }
1134
1135 /// Parsing a wire string and serializing the result produces the documented JSON, so the two
1136 /// halves of the contract cannot drift from each other.
1137 #[test]
1138 fn parse_then_serialize_matches_the_documented_json() {
1139 assert_eq!(
1140 serde_json::to_value(PeerSoftware::parse("dig-node/0.99.1")).unwrap(),
1141 json!({
1142 "kind": "reported",
1143 "product": "dig-node",
1144 "version": "0.99.1",
1145 "raw": "dig-node/0.99.1"
1146 })
1147 );
1148 assert_eq!(
1149 serde_json::to_value(PeerSoftware::parse("0.0.0")).unwrap(),
1150 json!({"kind": "unknown"})
1151 );
1152 }
1153
1154 // ---- Trait-absence probes (dig_ecosystem#2215) ----
1155 //
1156 // `PeerSoftware` deriving `Ord` or `Default` would be a silent correctness regression rather
1157 // than a compile error anywhere, so it is pinned here. The probe exploits inherent-impl
1158 // precedence: `Probe::<T>::has_it()` resolves to the inherent impl (returning `true`) only when
1159 // `T` satisfies the bound, and otherwise falls back to the blanket trait impl (`false`).
1160 //
1161 // Each probe carries a CONTROL on a type that DOES implement the trait. Without the control, a
1162 // probe broken so that it always answers `false` would pass while proving nothing.
1163
1164 struct Probe<T>(core::marker::PhantomData<T>);
1165
1166 trait ProbeFallback {
1167 fn is_ord() -> bool {
1168 false
1169 }
1170 }
1171 impl<T> ProbeFallback for Probe<T> {}
1172
1173 impl<T: Ord> Probe<T> {
1174 fn is_ord() -> bool {
1175 true
1176 }
1177 }
1178
1179 struct PartialOrdProbe<T>(core::marker::PhantomData<T>);
1180 trait PartialOrdFallback {
1181 fn is_partial_ord() -> bool {
1182 false
1183 }
1184 }
1185 impl<T> PartialOrdFallback for PartialOrdProbe<T> {}
1186 impl<T: PartialOrd> PartialOrdProbe<T> {
1187 fn is_partial_ord() -> bool {
1188 true
1189 }
1190 }
1191
1192 /// A version comparison must be unreachable without first destructuring `Reported`, so that a
1193 /// caller cannot order `Unknown` against a real version — which, since every pre-#2215 peer is
1194 /// Unknown, would quietly become a verdict about most of the live network.
1195 #[test]
1196 fn peer_software_is_not_ordered() {
1197 assert!(
1198 Probe::<u32>::is_ord(),
1199 "control: the probe must detect a type that IS Ord, or it proves nothing"
1200 );
1201 assert!(
1202 !Probe::<PeerSoftware>::is_ord(),
1203 "PeerSoftware must not implement Ord — comparison belongs after destructuring Reported"
1204 );
1205 }
1206
1207 /// A defaulted `Unknown` appearing from nowhere is a different fact from a measured one, and
1208 /// `Default` would make the two indistinguishable at the point of construction.
1209 #[test]
1210 fn peer_software_has_no_default() {
1211 struct DefaultProbe<T>(core::marker::PhantomData<T>);
1212 trait DefaultFallback {
1213 fn is_default() -> bool {
1214 false
1215 }
1216 }
1217 impl<T> DefaultFallback for DefaultProbe<T> {}
1218 impl<T: Default> DefaultProbe<T> {
1219 fn is_default() -> bool {
1220 true
1221 }
1222 }
1223
1224 assert!(
1225 DefaultProbe::<String>::is_default(),
1226 "control: the probe must detect a type that IS Default, or it proves nothing"
1227 );
1228 assert!(
1229 !DefaultProbe::<PeerSoftware>::is_default(),
1230 "PeerSoftware must not implement Default"
1231 );
1232 }
1233
1234 // ---- SoftwareVersionDetail (dig_ecosystem#2215) ----
1235
1236 /// Each mode renders a value the PARSER reads back at the intended level of detail.
1237 ///
1238 /// The fixture uses a version with a non-zero minor AND a non-zero patch, because that is the
1239 /// only shape where `Full` and `Minor` differ — a `1.0.0` fixture would let a renderer that
1240 /// ignores the mode entirely pass.
1241 #[test]
1242 fn each_detail_mode_round_trips_to_the_intended_precision() {
1243 let v = semver::Version::new(0, 99, 1);
1244
1245 let full = SoftwareVersionDetail::Full.render("dig-node", &v);
1246 assert_eq!(full, "dig-node/0.99.1");
1247 assert_eq!(
1248 PeerSoftware::parse(&full),
1249 PeerSoftware::parse("dig-node/0.99.1")
1250 );
1251
1252 let minor = SoftwareVersionDetail::Minor.render("dig-node", &v);
1253 assert_ne!(minor, full, "Minor must actually coarsen");
1254 let PeerSoftware::Reported { version, .. } = PeerSoftware::parse(&minor) else {
1255 panic!("a coarsened advertisement must still be READABLE, not Unknown");
1256 };
1257 assert_eq!(version.major, 0);
1258 assert_eq!(version.minor, 99);
1259 assert_eq!(version.patch, 0, "the patch level is what Minor hides");
1260
1261 let off = SoftwareVersionDetail::Off.render("dig-node", &v);
1262 assert_eq!(off, "");
1263 assert_eq!(PeerSoftware::parse(&off), PeerSoftware::Unknown);
1264 }
1265
1266 /// `Minor` renders `MAJOR.MINOR.0`, NOT `MAJOR.MINOR`.
1267 ///
1268 /// A bare two-part `0.99` is not valid semver, so the parser would classify a peer that
1269 /// coarsened its build as Unknown — turning "tell them less" into "tell them nothing", which
1270 /// is what `Off` is for. This test is the guard on that distinction.
1271 #[test]
1272 fn minor_mode_stays_valid_semver_rather_than_collapsing_to_unknown() {
1273 let rendered =
1274 SoftwareVersionDetail::Minor.render("dig-node", &semver::Version::new(1, 4, 7));
1275 assert_eq!(rendered, "dig-node/1.4.0");
1276 assert_ne!(
1277 PeerSoftware::parse(&rendered),
1278 PeerSoftware::Unknown,
1279 "a coarsened build must remain readable; `product/1.4` would not be"
1280 );
1281 }
1282
1283 /// Coarsening strips pre-release and build metadata. A nightly's identifier is more precisely
1284 /// identifying than the patch number it accompanies, so leaving it in place would make `Minor`
1285 /// coarsen nothing at all for exactly the builds that most want it.
1286 #[test]
1287 fn minor_mode_strips_prerelease_and_build_metadata() {
1288 let v: semver::Version = "1.0.0-nightly.20260805+sha.abc123".parse().unwrap();
1289 let rendered = SoftwareVersionDetail::Minor.render("dig-node", &v);
1290 assert_eq!(rendered, "dig-node/1.0.0");
1291 assert!(
1292 !rendered.contains("nightly"),
1293 "the nightly identifier must not survive coarsening"
1294 );
1295 assert!(
1296 !rendered.contains("abc123"),
1297 "build metadata must not survive coarsening"
1298 );
1299 }
1300
1301 /// `Off` renders the empty string for ANY version, which is what makes it indistinguishable
1302 /// from a peer built before the field existed.
1303 #[test]
1304 fn off_mode_reveals_nothing_for_any_version() {
1305 for v in ["0.0.1", "1.2.3", "99.99.99-rc.1"] {
1306 let rendered = SoftwareVersionDetail::Off.render("dig-node", &v.parse().unwrap());
1307 assert_eq!(
1308 rendered, "",
1309 "Off must reveal nothing, including the product name"
1310 );
1311 }
1312 }
1313
1314 /// The default is the most informative setting: the diagnostic value is the reason the field
1315 /// exists, and an operator who disagrees opts down explicitly.
1316 #[test]
1317 fn detail_defaults_to_full() {
1318 assert_eq!(
1319 SoftwareVersionDetail::default(),
1320 SoftwareVersionDetail::Full
1321 );
1322 }
1323
1324 /// The wire tokens are the lowercase words an operator writes in a config file, and they are a
1325 /// published contract once a config carries them.
1326 #[test]
1327 fn detail_uses_lowercase_wire_tokens() {
1328 for (mode, token) in [
1329 (SoftwareVersionDetail::Full, "\"full\""),
1330 (SoftwareVersionDetail::Minor, "\"minor\""),
1331 (SoftwareVersionDetail::Off, "\"off\""),
1332 ] {
1333 assert_eq!(serde_json::to_string(&mode).unwrap(), token);
1334 assert_eq!(
1335 serde_json::from_str::<SoftwareVersionDetail>(token).unwrap(),
1336 mode
1337 );
1338 }
1339 }
1340
1341 // ---- Gate round 1 regressions (dig_ecosystem#2215) ----
1342
1343 /// **`PartialOrd` is the hazard the `Ord` probe misses.** `Ord: PartialOrd`, so a type can
1344 /// derive only `PartialOrd` — satisfying an `Ord`-only probe — while `Unknown < Reported(..)`
1345 /// still compiles and evaluates. That one-word derive would sort `Unknown` below every real
1346 /// version, which is the verdict-about-the-live-network the contract forbids. SPEC §4.1 names
1347 /// all three traits; this pins the weakest of them, which subsumes `Ord`.
1348 #[test]
1349 fn peer_software_is_not_partially_ordered_either() {
1350 assert!(
1351 PartialOrdProbe::<f64>::is_partial_ord(),
1352 "control: the probe must detect a type that IS PartialOrd but NOT Ord, or it proves nothing about the gap between the two"
1353 );
1354 assert!(
1355 PartialOrdProbe::<u32>::is_partial_ord(),
1356 "control: a fully-ordered type must also be detected"
1357 );
1358 assert!(
1359 !PartialOrdProbe::<PeerSoftware>::is_partial_ord(),
1360 "PeerSoftware must implement neither PartialOrd nor Ord"
1361 );
1362 }
1363
1364 /// **The sentinel is VERSION ZERO, a class — not the three-character string `\"0.0.0\"`.**
1365 /// The constant's doc, `parse`'s doc, and SPEC §4.1 all state the rule over the class, so a
1366 /// string comparison lets `0.0.0+build`, `0.0.0-rc.1`, and `0.0.0-0` through as a *reported*
1367 /// version zero — the exact reading every one of those three prose statements forbids.
1368 #[test]
1369 fn version_zero_is_unknown_however_it_is_decorated() {
1370 for raw in [
1371 "dig-node/0.0.0",
1372 "dig-node/0.0.0+build",
1373 "dig-node/0.0.0-rc.1",
1374 "x/0.0.0-0",
1375 "dig-node/0.0.0-alpha+sha.abc123",
1376 ] {
1377 assert_eq!(
1378 PeerSoftware::parse(raw),
1379 PeerSoftware::Unknown,
1380 "{raw:?} is version zero and must be Unknown"
1381 );
1382 }
1383 }
1384
1385 /// A version that is merely CLOSE to zero is still a real build and must be reported — without
1386 /// this, a parser that mapped everything below `0.1.0` to Unknown would pass the test above.
1387 #[test]
1388 fn a_nonzero_version_near_zero_is_still_reported() {
1389 for raw in ["dig-node/0.0.1", "dig-node/0.1.0", "dig-node/0.0.1-rc.1"] {
1390 assert_ne!(
1391 PeerSoftware::parse(raw),
1392 PeerSoftware::Unknown,
1393 "{raw:?} is a real build, not the sentinel"
1394 );
1395 }
1396 }
1397
1398 /// **`render`'s stated invariant, tested over the class it is stated over.**
1399 ///
1400 /// The doc promises: every rendering is either the empty string or a value `parse` reads back
1401 /// as `Reported`. A `1.4.7` fixture cannot see the case that breaks it — a `0.0.x` build, whose
1402 /// `MAJOR.MINOR.0` coarsening IS version zero and therefore reads as Unknown. That is the same
1403 /// Minor-collapses-into-Off defect as the two-part spelling, arriving through the other door.
1404 #[test]
1405 fn every_rendering_is_empty_or_readable() {
1406 let versions = [
1407 "0.0.1",
1408 "0.0.7",
1409 "0.0.99", // the class the 1.4.7 fixture cannot see
1410 "0.1.0",
1411 "0.99.1",
1412 "1.0.0",
1413 "1.4.7",
1414 "10.20.30",
1415 "1.0.0-nightly.20260805+sha.abc123",
1416 "0.0.1-rc.1",
1417 ];
1418 for mode in [
1419 SoftwareVersionDetail::Full,
1420 SoftwareVersionDetail::Minor,
1421 SoftwareVersionDetail::Off,
1422 ] {
1423 for v in versions {
1424 let rendered = mode.render("dig-node", &v.parse().unwrap());
1425 if rendered.is_empty() {
1426 continue;
1427 }
1428 assert_ne!(
1429 PeerSoftware::parse(&rendered),
1430 PeerSoftware::Unknown,
1431 "{mode:?} rendered {rendered:?} for {v}, which reads back as Unknown — a non-empty rendering must always be readable"
1432 );
1433 }
1434 }
1435 }
1436
1437 /// `Minor` on a `0.0.x` build advertises NOTHING, deliberately.
1438 ///
1439 /// Hiding the patch of a `0.0.x` version leaves only version zero, which the wire reserves as
1440 /// the "unknown" sentinel. There is no coarser representable value, so the honest rendering is
1441 /// the empty string rather than the sentinel dressed up as a report. This differs from the
1442 /// `0.99` case: there a representable coarse value existed and the wrong spelling was chosen;
1443 /// here none exists.
1444 #[test]
1445 fn minor_of_a_zero_zero_build_advertises_nothing_rather_than_the_sentinel() {
1446 let rendered = SoftwareVersionDetail::Minor.render("dig-node", &"0.0.7".parse().unwrap());
1447 assert_eq!(rendered, "");
1448 assert_ne!(
1449 rendered, "dig-node/0.0.0",
1450 "the sentinel must never be ADVERTISED; it is only ever received from a legacy peer"
1451 );
1452 }
1453
1454 /// **Tripwire, not a guard.** `raw` is reconstructible from `product` + `version` for every
1455 /// string the current grammar accepts, because `semver::Version` re-renders losslessly. This
1456 /// asserts that equivalence deliberately.
1457 ///
1458 /// **When this test FAILS, `raw` has become load-bearing** — the grammar has started accepting
1459 /// something non-canonical (a `v` prefix, a two-part version, a vendor suffix) and `raw` is now
1460 /// the only record of what the peer actually sent. Do not "fix" it by deleting the field;
1461 /// replace this test with real assertions on the divergent inputs.
1462 #[test]
1463 fn raw_is_still_reconstructible_from_the_parsed_parts() {
1464 for advertised in [
1465 "dig-node/0.0.1",
1466 "dig-node/0.99.1",
1467 "dig-node/1.0.0-nightly.20260805",
1468 "dig-node/1.0.0+sha.abc123",
1469 "dig-node/1.0.0-rc.1+build.7",
1470 "acme/dig-node/1.2.3",
1471 ] {
1472 let PeerSoftware::Reported {
1473 product,
1474 version,
1475 raw,
1476 } = PeerSoftware::parse(advertised)
1477 else {
1478 panic!("{advertised:?} must be Reported");
1479 };
1480 assert_eq!(
1481 raw,
1482 format!("{product}/{version}"),
1483 "raw diverged from the parsed parts for {advertised:?} — `raw` is now load-bearing; see this test's doc comment before changing anything"
1484 );
1485 }
1486 }
1487}