dig-node-control-interface 0.10.0

Canonical client <-> dig-node CONTROL interface contract: the method catalog for controlling/querying a running dig-node (config, status, peers, subscriptions, cache, wallet), transport-agnostic. SSOT so client and node can't drift.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
# dig-node-control-interface — normative specification

This document is the authoritative contract for the client ⇄ dig-node **control interface**: how a
client (the CLI `dign`, the browser extension, dig-app, hub) controls and queries a running dig-node.
An independent reimplementation of either side MUST conform to this specification byte-for-byte. Where
this document and the code disagree, the conformance KATs in `src/kats.rs` are the tie-breaker.

Layering: this file is the repo's own contract; the cross-repo interaction map is the superproject
`SYSTEM.md`; the session/transport envelope this catalog rides over is `dig-ipc-protocol`'s `SPEC.md`;
the node↔node peer wire is `dig-rpc-protocol`'s `SPEC.md`. All MUST agree.

## 1. Scope and boundary

`dig-node-control-interface` owns the **client↔node CONTROL method catalog**: the method names,
parameter/result types, and error taxonomy for controlling and querying a running dig-node — status,
configuration, cache, hosted/pinned stores, §21 whole-store sync, the peer network, subscription
lifecycle, the auto-update beacon, live log level, and the control-token pairing handshake.

It does **not** own:
- the node↔node peer wire (dig-rpc-protocol's PublicRead + Peer tiers), or
- the local session/signing handshake and transport a client authenticates over (dig-ipc-protocol).

This crate is transport-agnostic: the same method catalog is carried over the dig-ipc-protocol local
session, or over loopback-mTLS + a signed control token for clients reached via HTTP/WebSocket
(CLAUDE.md §5.3). Consumers select the transport; this crate defines only the payload contract that
rides over it.

## 2. Roles and trust boundary

- **Client:** a user-facing surface (CLI, extension, app, hub) that wants to control or query a
  running dig-node. Authenticates over whichever transport is in play before issuing control calls.
- **Node (server):** the identity-agnostic engine dispatching control calls against its live state
  (config, cache, peer table, subscriptions).

### 2.1 Authorization

- Every `control.*` method is **token-gated** EXCEPT the open surface, which is whatever the §4
  method table marks `no` in its Token column — that table is authoritative and this sentence must
  never restate it as a count. Today it is the pairing bootstrap (`pairing.request`,
  `pairing.poll`), the wallet chain reads (`control.wallet.balance` / `.coins` / `.coinById` / `.coinSpend` /
  `.coinsByParent` /
  `.peak` / `.syncStatus`), which need only public chain data, and `control.peerCounts`, which
  discloses two integers about this node's own connectivity. For every other method the caller MUST
  present the node's local control token
  as the `X-Dig-Control-Token` request header (preferred) or a `params._control_token` field. The
  token is a 64-hex value the node mints at first run into its machine-wide state dir with a
  restrictive ACL; possession of the on-disk token is authorization. A call without a valid token MUST
  be rejected with `UNAUTHORIZED` (-32030). Token comparison MUST be constant-time.
- The two **pairing-bootstrap** methods `pairing.request` / `pairing.poll` are **OPEN** (no token), so
  a token-less client (e.g. an MV3 extension that cannot read a local file) can obtain a scoped token
  after local operator approval.
- The three **pairing-administration** methods (`control.pairing.list` / `.approve` / `.revoke`)
  require the **MASTER** control token specifically, never a scoped paired token — a paired controller
  can drive ordinary mutations but MUST NOT mint more tokens or revoke itself.

## 3. Envelope

Requests and responses are JSON-RPC 2.0.

- **Request:** `{"jsonrpc":"2.0","id":<id>,"method":<name>,"params":<object>}`. `id` is a number,
  string, or null and MUST be echoed on the response. `params` is always an object (`{}` for a
  no-parameter method).
- **Success response:** `{"jsonrpc":"2.0","id":<id>,"result":<value>}` (no `error` key).
- **Error response:** `{"jsonrpc":"2.0","id":<id>,"error":{"code":<int>,"message":<str>,"data":{"code":<SYMBOL>,"origin":<str>}}}`
  (no `result` key). A client MUST branch on `error.data.code` (the stable UPPER_SNAKE symbol), never
  on the human `message`.

## 4. Method catalog

Method names are stable wire contract. `Auth` = requires the control token; `Master` = requires the
master token specifically; `Routing` = how the node resolves it (`owned` by the service shell,
`delegated` to the engine, `open` bootstrap).

| Method | Auth | Routing | Params | Result |
|---|---|---|---|---|
| `control.status` | yes | owned || `StatusResult` |
| `control.config.get` | yes | owned || `ConfigResult` |
| `control.config.setUpstream` | yes | owned | `{upstream:string}` | `{upstream, requires_restart}` |
| `control.log.setLevel` | yes | owned | `{filter:string}` | `{filter}` |
| `control.cache.get` | yes | owned || `CacheView` |
| `control.cache.setCap` | yes | owned | `{cap_bytes:u64}` | `{cap_bytes}` (floored 64 MiB) |
| `control.cache.clear` | yes | owned || `{cleared:true}` |
| `control.hostedStores.list` | yes | owned || `{stores:[HostedStore]}` |
| `control.hostedStores.pin` | yes | owned | `{store:"storeId[:root]"}` | `{store_id, root, pinned, fetch}` |
| `control.hostedStores.unpin` | yes | owned | `{store}` | `{store_id, unpinned, evicted_capsules}` |
| `control.hostedStores.status` | yes | owned | `{store}` | `{store_id, pinned, capsule_count, total_bytes, capsules}` |
| `control.sync.status` | yes | owned || `{available, method, pinned_total, pinned_synced, whole_store_trigger_supported}` |
| `control.sync.trigger` | yes | owned | `{store:"storeId:root"}` | `{store_id, root, status, size_bytes, served_root}` |
| `control.updater.status` | yes | owned || (proxied beacon status) |
| `control.updater.setChannel` | yes | owned | `{channel:string}` | (proxied) |
| `control.updater.pause` | yes | owned | `{until?:u64}` | (proxied) |
| `control.updater.resume` | yes | owned || (proxied) |
| `control.updater.checkNow` | yes | owned || (proxied) |
| `control.pairing.list` | master | owned || (pending + issued tokens) |
| `control.pairing.approve` | master | owned | `{pairing_id:string}` | `{approved, client_name, token_id}` |
| `control.pairing.revoke` | master | owned | `{token_id:string}` | `{revoked, token_id}` |
| `control.peerStatus` | yes | delegated || (peer-pool snapshot; each peer entry carries `software`) |
| `control.peerCounts` | no | delegated || `{dig_peer_count:u32\|null, chia_peer_count:u32\|null}` |
| `control.peers.connect` | yes | delegated | `{peer:string}` | `{connected, peer_id}` |
| `control.peers.disconnect` | yes | delegated | `{peer:string}` | `{disconnected, peer_id}` |
| `control.subscribe` | yes | delegated | `{store_id:string}` | `{subscribed, added, store_id}` |
| `control.unsubscribe` | yes | delegated | `{store_id:string}` | `{subscribed, removed, store_id}` |
| `control.listSubscriptions` | yes | delegated || `{subscriptions:[string], count}` |
| `control.wallet.balance` | no | delegated | `{address:string, asset:"xch"\|"dig"}` | `{balance, pending, source, synced, peak_height}` |
| `control.wallet.coins` | no | delegated | `{address:string, asset:"xch"\|"dig"}` | `WalletCoinsResult` |
| `control.wallet.coinById` | no | delegated | `{coin_id:string}` | `WalletCoinByIdResult` |
| `control.wallet.coinSpend` | no | delegated | `{coin_id:string}` | `WalletCoinSpendResult` |
| `control.wallet.coinsByParent` | no | delegated | `{parent_coin_id:string, after_coin_id?:string, limit?:u32}` | `WalletCoinsByParentResult` |
| `control.wallet.arrivals` | yes | delegated | `{after_seq:u64=0, limit?:u32}` | `WalletArrivalsResult` |
| `control.wallet.peak` | no | delegated || `{peak_height:u32\|null, synced:bool}` |
| `control.wallet.syncStatus` | no | delegated || `{phase:"not_started"\|"syncing"\|"synced", peak_height:u32\|null, chia_peer_count:u32\|null}` |
| `control.wallet.broadcast` | yes | delegated | `{signed_bundle_hex:string}` | `WalletBroadcastResult` |
| `pairing.request` | no | open | `{client_name:string}` | `{pairing_id, pairing_code, expires_ms}` |
| `pairing.poll` | no | open | `{pairing_id:string}` | `{status, token?}` |

The wallet CHAIN READS (`control.wallet.balance` / `.coins` / `.coinById` / `.coinSpend` /
`.coinsByParent` / `.peak` /
`.syncStatus`)
are served WITHOUT a control token, because each needs only public chain data — an address or a coin
id, never a seed, a key, or a signature. `control.peerCounts` is open for a second reason: it
discloses two integers about this node's own connectivity and no address, endpoint or secret. The
`Token` column above is authoritative; the open set is deliberately named here rather than counted,
so that adding a method cannot leave a stale number behind. Two wallet methods are token-gated.
`control.wallet.broadcast` puts bytes on the network. `control.wallet.arrivals` takes only a cursor,
so its answer names this node's OWN watched puzzle hashes and the receive history behind them: the
chain facts are public, the association between this node and those addresses is not. Membership of
the open set turns on WHO NAMES THE ADDRESS, not on whether the data is on chain. That difference is normative for clients, because the two refusals demand
opposite remedies — see §4.2.

### 4.1 Result field definitions

- **`StatusResult`**: `{running:bool, service:string, version:string, commit:string, protocol:string,
  uptime_secs:u64, addr:string, upstream:string, cache:CacheView, hosted_store_count:u64,
  cached_capsule_count:u64, pinned_store_count:u64, sync:{available:bool}}`.
- **`ConfigResult`**: `{addr:string, port:string, upstream:string, upstream_override:string|null,
  cache_dir:string, cache_shared:bool, config_path:string, sync_available:bool}`.
  `upstream_override` MUST be present as `null` when unset (never omitted).
- **`CacheView`**: `{cap_bytes:u64, used_bytes:u64, dir:string, shared:bool}`.
- **`HostedStore`**: `{store_id:string, pinned:bool, capsule_count:u64, total_bytes:u64,
  capsules:[CapsuleEntry]}`.
- **`CapsuleEntry`**: `{capsule:"storeId:root", root:string, size_bytes:u64, last_used_unix_ms:u64}`.
- **`pairing.poll` token**: the `token` field MUST be omitted while `status` is not `approved`, and
  present exactly once after approval.
- **`WalletCoinsResult`**: `{coins:[WalletCoinRecord], source:"db"|"fallback"|null, synced:bool,
  peak_height:u32|null}`. `source`/`synced`/`peak_height` carry exactly the meanings defined for
  `WalletBalanceResult` below. `coins` MUST list the address's spendable coins for the requested
  asset (XCH coins sit AT the puzzle hash; CAT coins are HINTED to it).

  `coins:[]` MUST mean the node consulted a chain and the address holds nothing. A node that could
  NOT consult a chain MUST return the matching §5 wallet error instead — never an empty list. This
  is normative and not a quality-of-implementation note: an empty list on an unreachable chain tells
  a holder of funds that they hold nothing, and a spend built on that answer refuses with a
  shortfall that is not true.
- **`WalletCoinRecord`**: `{coin_id:string, asset:"xch"|"dig"|null, amount:u64,
  parent_coin_info:string, puzzle_hash:string, created_height:u32|null, spent_height:u32|null}`. All
  hashes are lowercase 64-hex, unprefixed. `created_height:null` means the coin is known only from
  the mempool; `spent_height:null` means unspent. The first three fields are a strict SUPERSET of
  dig-app's frozen `CoinRecord`.

  `asset:null` MUST mean THE READ DID NOT CLASSIFY THE COIN. It MUST NOT be read as "no asset" and
  MUST NOT be defaulted to XCH. A singleton, a CAT and a plain XCH coin are indistinguishable from a
  coin id alone — telling them apart requires inspecting the puzzle, which a coin-record read does
  not do. `control.wallet.coins` MUST report the concrete asset it was SCOPED to and MUST NOT emit
  `asset:null`: dig-app's frozen `CoinRecord` requires a non-null asset there, so `null` is a hard
  deserialization failure, not a degraded read. `control.wallet.coinById` and `control.wallet.coinsByParent` MUST report `null`: neither a coin id
  nor a parent id scopes a read to an asset.
- **`WalletCoinByIdResult`**: `{coin:WalletCoinRecord|null, source:"db"|"fallback"|null, synced:bool,
  peak_height:u32|null}`. ONE coin, named by its own id, SPENT OR UNSPENT.

  `coin:null` MUST mean the node consulted a chain and it holds no coin with that id. A node that
  could NOT consult a chain MUST return the matching §5 wallet error instead — never `coin:null`.
  These MUST NOT be collapsed: the first says stop waiting, the second says retry the read. A caller
  that conflates them reports a mint whose coin does not exist as pending forever, with the funds
  already spent.

  The `coin` key MUST be present on every response; `null` is a verdict and MUST NOT be conveyed by
  omitting the field.

  `coin_id` MUST be lowercase 64-hex; a `0x` prefix MUST be accepted on input and MUST NOT be
  emitted. Any other value MUST be refused as `-32602 INVALID_PARAMS` BEFORE any chain is consulted,
  so that an unanswerable question never wears the shape of an answer. There is no `asset`
  parameter: a coin id is not asset-scoped.

  `source` names which tier answered, and every freshness field describes THAT tier, exactly as for
  `WalletBalanceResult` below: a `"fallback"` answer MUST report `synced:false` and
  `peak_height:null`; a `"db"` answer means the node's own replica answered and MUST report
  `synced:true` and that replica's peak. A caller needing a height to bound a confirmation against
  reads `control.wallet.peak`.

  **A node MUST NOT answer `coin:null` from a view that could not have held the coin.** A replica
  that is not caught up, or a local index that is address-scoped rather than a full chain view, has
  not established absence — only its own inability to see. Such a node MUST return `-32040
  WALLET_NO_CHAIN_SOURCE` or `-32042 WALLET_READ_FAILED`. This is normative and load-bearing: the two
  coins this method exists to observe are a created coin sitting at no wallet address and a funding
  coin already spent, both of which an address-scoped view is GUARANTEED to miss, so a `coin:null`
  from one would report a mint that really happened as never having happened, with the funds gone.
  `-32041 WALLET_NOT_SYNCED` is not used here — it names a wallet-scoped branch this method does not
  have — but a node that is not synced still MUST NOT manufacture a negative answer; it errors.

  This method is how a pushed spend becomes OBSERVABLE. `control.wallet.broadcast`'s `accepted:true`
  reports mempool admission only; only a buried confirmation of the created coin is evidence.
  `control.wallet.coins` cannot supply it — it answers by ADDRESS and lists UNSPENT coins only, so it
  sees neither a created coin sitting at no wallet address nor a funding coin the spend consumed.
- **`WalletCoinSpend`**: `{coin:WalletCoinRecord, puzzle_reveal:string, solution:string}`. The chia
  `CoinSpend` in this contract's wire form: the coin that was consumed plus the two programs that
  consumed it, each lowercase hex of its serialized CLVM.

  `puzzle_reveal` MUST tree-hash to `coin.puzzle_hash`. A reveal is supplied by a PEER and a peer can
  lie, so the claim is deliberately self-checking: a node MUST verify it and MUST fail closed with a
  §5 wallet error — never return a spend carrying an unverified reveal — when the hashes disagree or
  the reveal does not parse. A caller MAY re-derive the same check from the two fields it is handed.

  `coin.spent_height` MUST be non-null. A spend exists only because the coin was spent, so a spend
  reporting an unspent coin is a contradiction.
- **`WalletCoinSpendResult`**: `{spend:WalletCoinSpend|null, source:"db"|"fallback"|null,
  synced:bool, peak_height:u32|null}`. THE SPEND that spent one coin, named by that coin's own id.
  A spend has no id of its own on chain, so `coin_id` names the SPENT COIN and takes the identical
  form and validation `control.wallet.coinById` takes.

  `spend:null` MUST mean the node consulted a chain and it holds no spend of that coin. Absence has
  TWO legitimate causes — the coin is UNSPENT, or the chain holds no such coin — and this method
  deliberately does not distinguish them; a caller needing to MUST ask `control.wallet.coinById`,
  whose `coin:null` separates them.

  A node that could NOT consult a chain MUST return the matching §5 wallet error instead — never
  `spend:null`. This is the money-critical distinction in the whole family: a caller following a
  singleton forward reads "no spend" as *this is the current tip* and stops walking, so a failure
  disguised as absence makes a superseded coin look like the tip and the spend built against it is
  invalid. The rule barring a negative answer from a view that could not have held the subject
  (stated for `WalletCoinByIdResult` above) applies here unchanged.

  The `spend` key MUST be present on every response; `null` is a verdict and MUST NOT be conveyed by
  omitting the field.

  `source` and the freshness fields follow the same tier rule as every other wallet read.
- **`WalletCoinsByParentResult`**: `{coins:[WalletCoinRecord], complete:bool, cursor:string|null,
  source:"db"|"fallback"|null, synced:bool, peak_height:u32|null}`. One PAGE of the DIRECT children
  created by spending one coin, named by that parent's coin id.

  Exactly ONE HOP. The list is what the named parent's spend created and nothing further: not a
  lineage, not a subtree, not transitive. A node MUST NOT recurse — an unbounded server-side walk
  over caller-supplied input is work the caller cannot bound, and a partial walk returned as a
  complete one is a lineage with a silent hole in it. A caller composes a lineage from repeated
  single hops.

  `coins:[]` MUST mean the node consulted a chain and that parent created no children it knows of,
  typically because the parent is unspent. A node that could NOT consult a chain MUST return the
  matching §5 wallet error instead — never an empty list. A caller walking a singleton forward reads
  an empty list as *this is the tip*.

  Every record MUST report `asset:null`: naming a coin by its parent classifies nothing.

  **The answer is BOUNDED and PAGED.** This is the only open wallet read whose cardinality is
  unbounded — every other returns a single record or is already paged — and there is NO request rate
  limiting on the control plane, so this bound is the only limit on the work a token-less caller can
  ask for. On the fallback tier the node forwards the caller's identifier to a third-party coinset
  oracle, so an unbounded page is unbounded work against another party's service.

  `limit` MUST be between 1 and `COINS_BY_PARENT_MAX_LIMIT` (1000); an omitted `limit` means
  `COINS_BY_PARENT_DEFAULT_LIMIT` (100). An out-of-range or zero `limit` MUST be REFUSED as `-32602
  INVALID_PARAMS` and MUST NOT be clamped — a caller resumes from a page boundary, so a silently
  shrunk page returns a cursor for a position the caller never asked about. (This deliberately
  differs from `control.wallet.arrivals`, where a node MAY clamp: that read's cursor is a ledger
  position the node owns, whereas this one's is a row the caller was handed.) `0` is refused
  separately: a page that holds nothing never makes progress. The maximum is derived from
  dig-ipc-protocol's `MAX_FRAME_BYTES` (1 MiB) — a worst-case `WalletCoinRecord` is ~350 bytes, so
  1000 records is ~350 KB, about a third of the frame, leaving headroom for the envelope.

  `complete` MUST state whether the page carries the LAST child. A node MUST NOT report
  `complete:true` on a page it truncated. A caller MUST NOT infer completeness from the page length:
  a node may return a short page for its own reasons, and a child set that is an exact multiple of
  the page size makes the final full page indistinguishable from a truncated one. This is normative
  and load-bearing — a caller walking a lineage reads "no more children" as the end of a branch, so a
  truncated page that looks whole presents a partial lineage as a complete one.

  `cursor` MUST be the `coin_id` of the LAST record actually returned, or `null` for an empty page,
  and the key MUST always be present. A caller resumes by passing it as `after_coin_id`. There is
  deliberately no chain-head marker on this type to resume from by mistake.

  A node MUST return children in ASCENDING `coin_id` order and MUST keep that order stable across the
  pages of one walk; `after_coin_id` means strictly after that id in that order. Without a fixed
  order a cursor names no position and a walk silently repeats and skips children. Coin ids are
  fixed-length lowercase hex, so ascending lexicographic and ascending 32-byte numeric order are the
  same order.

  The parameter is spelled `parent_coin_id`, NOT `coin_id`. The coin named is the one being asked
  ABOUT and is never the coin returned; a shared field name would make a recursive reading of the
  method plausible from the request alone.
- **`WalletPeakResult`**: `{peak_height:u32|null, synced:bool}`. The node's chain peak, independent
  of any address. `peak_height:null` means the node tracks NO height — it MUST NOT be read as height
  zero, which every block is trivially above. A caller bounding a claimed confirmation MUST treat
  `null` as unknown and fail closed.

  `synced` here reports ONLY that the replica's initial catch-up completed; it MUST NOT be read as
  "the wallet is caught up AND connected", which is `WalletSyncStatusResult.phase == "synced"` and
  is strictly stronger. Neither is a freshness guarantee.
- **`PeerCountsResult`**: `{dig_peer_count:u32|null, chia_peer_count:u32|null}`. How many peers
  this node holds on EACH network. The two networks are unrelated and their counts move
  independently.

  `dig_peer_count` MUST be the count of peers on the DIG content/gossip network (port 9445) — the
  node's `connected_peers`, the same figure `control.peerStatus` reports. `chia_peer_count` MUST be
  the count of CHIA full-node peers the wallet's chain sync holds, and MUST be the SAME observation
  `WalletSyncStatusResult.chia_peer_count` reports: a conforming node MUST serve both from ONE
  source, and the two MUST agree within a single node's view.

  Neither field may be named `peers`, `connected_peers` or `peer_count`. A name that does not state
  its network forces a consumer to know which number it is holding, and a consumer that guesses
  wrong fails SILENTLY — a plausible integer in a right-looking place.

  For each count, `0` MUST mean the node observed that network and found nothing connected, and
  `null` MUST mean the node cannot observe the count. A network that is not running is UNKNOWN and
  MUST NOT be reported as `0`, which would assert that nothing is connected to a network never asked.

  `control.peerStatus`'s `relay.peer_count` counts peers connected to THE RELAY, not to this node.
  It is frequently the only non-zero number on a node connected to nothing, and it is NEVER the
  answer to "how many peers does this node have" — `dig_peer_count` is.
- **`WalletSyncStatusResult`**: `{phase:"not_started"|"syncing"|"synced", peak_height:u32|null,
  chia_peer_count:u32|null}`. Whether the node's WALLET CHAIN replica is being kept current, how far
  it has got, and how many CHIA full-node peers its sync is using. This is NOT `control.sync.status`,
  which reports §21 DIG store sync and is unrelated.

  `phase` MUST be one of the three tokens above, spelled exactly. `"synced"` MUST require BOTH that
  the initial catch-up completed AND that at least one Chia peer connection is live at the time of
  the read; a wallet that caught up earlier and has since lost every peer MUST report `"syncing"`.
  `"synced"` is therefore STRICTLY STRONGER than `WalletPeakResult.synced`, which reflects only the
  completed-catch-up flag: `"synced"` implies that flag, the flag does not imply `"synced"`. A
  boolean MUST NOT be substituted for the three phases — "never started" and "synced at height 0"
  are different facts and MUST NOT render the same.

  `peak_height` MUST be the node's OWN replica's height, or `null` when it has none. It MUST NOT
  fall back to the coinset oracle, which `control.wallet.peak` deliberately does: that method answers
  what height the chain is at, this field answers how far this replica has got, and an oracle's
  number here would report a caller's own progress using a height the replica never reached.
  `null` MUST NOT be read as height zero.

  `chia_peer_count` counts CHIA FULL-NODE peers the wallet's chain sync is connected to. `0` is an
  OBSERVED zero and is not a fourth phase: a running sync connected to nothing reports `"syncing"`
  with `0`, which a consumer SHOULD render as "syncing — no peers". `null` means the node cannot
  observe the count and licenses no claim about connectivity. This count is NOT the DIG
  gossip/content peer count from `control.peerStatus` (`connected_peers` / `relay_peer_count`); the
  two are unrelated numbers, and a surface labelling either one bare "peers" beside a wallet sync
  status asserts something false. A caller wanting both networks' counts reads
  `control.peerCounts`, whose `chia_peer_count` is this same observation under the same key and MUST
  agree with it. The field is duplicated across the two methods rather than moved, because
  `chia_peer_count:0` beside `"syncing"` is the honest "syncing — no peers" state and a phase
  separated from its count reads as a contradiction; a DIG content-network count, by contrast, is not
  a wallet fact and MUST NOT be added here for symmetry.

  `"synced"` MUST NOT be read as a guarantee that the replica's data is FRESH. A live connection to
  a stalled or lagging peer satisfies the predicate while the replica goes stale; the phase reports
  that catch-up finished and a peer is attached, i.e. that nothing KNOWN is preventing the replica
  from being kept current.

  **Field combinations.** `{phase:"synced", peak_height:null}` MUST NOT be emitted: a node records
  its peak before it marks the initial catch-up complete, so a completed catch-up always has a
  height behind it, and the pair describes a state no conforming node can be in.

  `{phase:"not_started", peak_height:<n>}` is NOT a contradiction and MUST be permitted. The height
  is persisted in the wallet database while the phase describes whether a sync is running IN THIS
  PROCESS, so a node that synced earlier and has just RESTARTED reports this pair truthfully: here
  is the height I reached, and no sync is running right now. A node MUST NOT suppress the height or
  fabricate a phase to avoid emitting it. `{phase:"not_started", peak_height:null}` is equally
  legitimate and means a wallet that has never synced.

  The height reported is the height of the LAST EXISTING block the peer view reported
  (`NewPeakWallet.height` / `RespondPuzzleState.height` from a real full node). This surface performs
  no confirmation-depth arithmetic; a consumer computing depth MUST floor its own input rather than
  assume a convention, because `peak_height` means the NEXT height on a simulator and the last
  existing block on a full node.
- **`WalletBroadcastResult`**: `{accepted:bool, transaction_id:string|null, rejection:string|null}`.
  The node pushes an ALREADY-SIGNED bundle; it MUST NOT sign, and MUST NOT accept any parameter it
  could sign with (§4.3).

  A mempool that examined the bundle and refused it is a SUCCESSFUL call reporting
  `accepted:false` with a `rejection` reason. Failing to REACH a mempool MUST be a §5 error instead.
  These MUST NOT be collapsed: the first says build a different bundle, the second says retry this
  one. `accepted:true` reports mempool admission ONLY — it is NOT evidence that anything reached a
  block, and a caller MUST NOT record an outcome from it. Only a buried confirmation of the created
  coin is evidence.
- **`WalletBalanceResult`**: `{balance:u64, pending:u64, source:"db"|"fallback"|null, synced:bool,
  peak_height:u32|null}`. A
  READ-only chain read over the loopback control plane — it reports state, never moves funds. `balance`
  is the CONFIRMED spendable amount in the asset's base unit (mojos for XCH, base units for DIG);
  `pending` is incoming-unconfirmed.

  `source` names the TIER that produced the figures, and every freshness field describes THAT tier:
  `"db"` is the node's own chain replica (`synced:true`, `peak_height` = the replica's peak);
  `"fallback"` is a third-party coinset HTTP oracle, which MUST report `synced:false` and
  `peak_height:null` however caught-up the node's own replica is, because the replica neither
  produced that figure nor bounds its freshness. A `"fallback"` answer also means the queried address
  WAS DISCLOSED off-node. `source` is ABSENT/`null` only from a node predating tier disclosure — a
  third state meaning "tier unknown", never a defaulted tier; consumers MUST NOT treat it as either.
  `synced:false` means the figures are STALE or fallback-served; `peak_height` is the block height
  the figures reflect (present as `null`, never omitted, when no height applies).
  The `asset` request field is the lowercase wire token `"xch"`/`"dig"`. This result is a strict
  SUPERSET of dig-app's `BalanceResponse {balance}`: a consumer reading only `{balance}` deserializes
  it losslessly (unknown fields ignored), which is the no-consumer-change guarantee pinned by a KAT.

Proxied results (`control.updater.*`, `control.pairing.list`, `control.peerStatus`) carry the
underlying source's shape verbatim and are modelled as an opaque JSON value; consumers MUST NOT freeze
a struct over them.

- **`PeerSoftware`** — a peer's advertised SOFTWARE build, the one member of the otherwise-proxied
  `control.peerStatus` snapshot whose shape this contract owns. Every entry of the snapshot's
  `connected` array MUST carry a `software` member; a peer entry that omits it is a serialization
  defect, NOT a peer of unknown build. Two forms, tagged by `kind`:

  ```json
  {"kind": "unknown"}
  {"kind": "reported", "product": "dig-node", "version": "0.99.1", "raw": "dig-node/0.99.1"}
  ```

  `unknown` MUST carry no `version` member — never `"0.0.0"`, never `""`, never `null`.

  The node derives it from the peer's gossip `Handshake.software_version` string. The mapping is
  normative:

  | Advertised string | Result |
  |---|---|
  | `product/semver`, both parts non-empty, version parsing as semver | `reported` |
  | `""` (the peer advertised nothing, or coarsened its build off) | `unknown` |
  | any advertisement whose version is VERSION ZERO — the LEGACY SENTINEL | `unknown` |
  | anything else unparseable | `unknown` |

  The product/version split is at the LAST `/`, so a product name may itself contain one.
  Surrounding whitespace is trimmed before parsing.

  **Version zero is a CLASS, not a string.** The rule MUST be applied to the parsed
  major/minor/patch triple, ignoring pre-release and build metadata: the bare `0.0.0`, a
  product-qualified `dig-node/0.0.0`, and every decorated form (`0.0.0-rc.1`, `0.0.0+build`,
  `0.0.0-0`) are all `unknown`. A string comparison would let the decorated forms through as real
  builds at version zero.

  **Why version zero is `unknown` and not a version.** Every dig-node built before this contract
  advertises the literal `"0.0.0"`: three of dig-gossip's four handshake send sites hardcoded it. A
  reader that treated it as a version would classify the entire live network as running software
  0.0.0, and any `>=` comparison would call all of it ancient.

  **Version zero MUST NEVER BE ADVERTISED.** It is a value received from a legacy peer, never one a
  conforming node sends — see `SoftwareVersionDetail` below for the one place that constraint
  binds.

  **`PeerSoftware` MUST NOT implement `Ord`, `PartialOrd`, or `Default`.** `unknown` has no position
  on a version line, and most peers are `unknown` today; a comparison is reachable only after
  destructuring `reported`, which forces a caller to decide what `unknown` means for its question.

  **Privacy.** Reporting a peer's exact build is a fingerprinting aid — it identifies which peers run
  a version with a publicly disclosed defect. Accepted for the diagnostic value on a pre-release
  network. A node that declines to advertise sends an empty string, which reads as `unknown` here and
  is indistinguishable from a build predating the field.

- **`SoftwareVersionDetail`** — how much of its own build a node reveals when it advertises. Wire
  tokens `"full"` (default) | `"minor"` | `"off"`, rendering:

  | Mode | Advertised for version `0.99.1` | Read back as |
  |---|---|---|
  | `full` | `dig-node/0.99.1` | `reported`, exact |
  | `minor` | `dig-node/0.99.0` | `reported`, patch level hidden |
  | `off` | `""` | `unknown` |
  | `minor` of a `0.0.x` build | `""` | `unknown` |

  `minor` MUST render `MAJOR.MINOR.0`, never a bare `MAJOR.MINOR`: a two-part version is not valid
  semver, so the coarse setting would be read as `unknown` and become a confusing second spelling of
  `off`. For the same reason, `minor` of a `0.0.x` build MUST render the EMPTY STRING: its
  coarsening is version zero, which is the `unknown` sentinel, and there is no coarser representable
  value — so it advertises nothing rather than advertising the sentinel as if it were a report.

  The binding invariant: **every rendering is either the empty string or a value that reads back as
  `reported`.** Coarsening reduces precision; it never yields a value that reads as `unknown` while
  looking like a report. `minor` MUST also strip pre-release and build metadata — a nightly identifier is more
  precisely identifying than the patch number beside it, so retaining it would coarsen nothing for
  exactly the builds that most want it. A coarsened `1.4.0` is indistinguishable from a genuine
  `1.4.0`; that is the purpose of coarsening, not a defect in it.

  Rendering is specified here, beside the parsing, because they are two halves of one format. A node
  MUST NOT hand-roll its own `product/version` string.

- **`StatusResult.version`** already reports THIS node's own build; there is no separate method for
  it, and `control.peerStatus` covers both the point lookup ("what is that peer running") and the
  census (a group-by over the returned array).

### 4.2 `UNAUTHORIZED` on an open read means an OLD NODE, not a permission problem

A client MUST branch on which method it called:

- On an OPEN read — every method the §4 table marks `no`, today `control.wallet.balance` / `.coins` /
  `.coinById` / `.coinSpend` / `.coinsByParent` / `.peak` / `.syncStatus` and `control.peerCounts``-32030 UNAUTHORIZED` can only
  come from a node build that predates the method and gates the whole `control.*` namespace. The
  truth is "this node cannot do that yet" and the remedy is an UPGRADE.
- On `control.wallet.broadcast`, `-32030 UNAUTHORIZED` means exactly what it says, and the remedy is
  the CONTROL TOKEN.

A client that maps both to the same outcome sends a person to fix the wrong thing. `-32601
METHOD_NOT_FOUND` always means the method is absent, on either.

### 4.3 The custody boundary (§908)

The node holds no user key and produces no signature. `control.wallet.broadcast` carries signed bytes
and nothing else: there is no key, seed, phrase, or unsigned-spend-plus-key parameter in this catalog,
and none may be added. The node's role on the money path is to read chain state and to push what
somebody else signed.

## 5. Error taxonomy

The numeric codes are a published wire contract and never change once assigned. `origin` classifies
where the error was minted.

| Code | Symbol | Origin | Meaning |
|---|---|---|---|
| `-32700` | `PARSE_ERROR` | shell | request body was not valid JSON |
| `-32600` | `INVALID_REQUEST` | shell | not a single JSON-RPC object |
| `-32601` | `METHOD_NOT_FOUND` | boundary | control method is not resolved |
| `-32602` | `INVALID_PARAMS` | node | missing/malformed params |
| `-32000` | `DISPATCH_FAILED` | shell | the node failed to dispatch a well-formed call |
| `-32030` | `UNAUTHORIZED` | shell | a `control.*` method called without a valid token |
| `-32031` | `NOT_SUPPORTED` | shell | control op unsupported on this build (e.g. §21 sync with no identity) |
| `-32032` | `CONTROL_ERROR` | shell | a control op failed at runtime |
| `-32040` | `WALLET_NO_CHAIN_SOURCE` | node | a wallet chain read had no live chain source to answer |
| `-32041` | `WALLET_NOT_SYNCED` | node | a wallet chain read of the wallet's own address is still syncing, with no fallback |
| `-32042` | `WALLET_READ_FAILED` | node | a wallet chain read failed at the DB / chain-source layer |
| `-32043` | `WALLET_RATE_LIMITED` | node | a wallet chain read was refused: the open fallback rate bound is spent |

The `-3204x` band is the wallet's. All four wallet codes mean the answer is UNKNOWN. A client MUST
NOT degrade any of them into an empty or zero result, and MUST NOT report a mint, a spend or a
balance as having failed on their strength alone — they say the node could not look, not that the
chain said no.

The `-32020..-32022` band is RESERVED for onion routing (dig-rpc-protocol); the control-plane errors
use `-32030..-32032`.

## 6. Conformance

The golden known-answer tests in `src/kats.rs` are normative: golden request vectors (typed call →
exact envelope), golden response vectors (node JSON decodes into the typed result and re-encodes
byte-identically), the golden error envelope, and an end-to-end route through the node-facing
`ControlHandler` dispatcher proving every method maps to its typed handler. The node side (T7) and
every client side (T8–T10) MUST pin against these vectors. A change that alters a wire shape MUST
fail a KAT.

## 7. Stability

1. `ControlMethod` and `ControlErrorCode` are `#[non_exhaustive]`; adding a method/code is an additive
   MINOR change.
2. Method wire names and error numeric values never change once assigned.
3. Result fields are additive: a new optional field is MINOR; removing/renaming/repurposing a field
   is a BREAKING change.
4. The catalog MUST mirror the live dig-node surface exactly (`dig-node-service/src/control.rs` owned
   methods + the `dig-node-core` delegated peer/subscription methods); a divergence is a drift bug.

## 8. wasm / JS byte-agreement

The catalog types are plain serde structs with no non-wasm dependencies, so a browser/extension client
(T5's `wasm-bindgen` binding) serializes them to identical JSON. The `serde_json::Value`-typed proxied
results and the `#[serde(untagged)]` `RequestId` are the only shapes needing a JS-side check; T5 adds a
Rust↔wasm/JS byte-identical KAT over the vectors in §6.