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