# 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 three 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).
| `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, known_dig_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, kind?:"capsule"\|"profile"}` | `{subscribed, added, store_id, kind}` |
| `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:Asset}` | `{balance, pending, source, synced, peak_height}` |
| `control.wallet.coins` | no | delegated | `{address:string, asset:Asset}` | `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"\|"no_wallet_enrolled"\|"wallet_not_unlocked", peak_height:u32\|null, chia_peer_count:u32\|null, watched_addresses:u32\|null, subscription_peer_count:u32\|null, chia_peer_peak_height:u32\|null}` |
| `control.wallet.broadcast` | yes | delegated | `{signed_bundle_hex:string}` | `WalletBroadcastResult` |
| `control.wallet.watch` | yes | delegated | `{public_keys:[string]}` (each lowercase 96-hex, `0x` accepted) | `{added:u32, watched:u32}` |
| `control.wallet.unwatch` | yes | delegated | `{public_keys:[string]}` | `{removed:u32, watched:u32}` |
| `control.wallet.watched` | yes | delegated | — | `{public_keys:[string]}` |
| `control.profile.putBody` | yes | delegated | `{store_id:string, root:string, body_b64:string}` | `{stored, store_id, root, body_bytes}` |
| `control.profile.getBody` | yes | delegated | `{store_id:string, root:string}` | `{store_id, root, body_b64:string\|null, body_bytes}` |
| `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 three 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. Five wallet methods are token-gated.
`control.wallet.broadcast` puts bytes on the network. `control.wallet.watch` / `.unwatch` aim what
this node follows, so they are mutations. `control.wallet.watched` and `control.wallet.arrivals` are
reads and are gated all the same, because each takes nothing from the caller and so answers with this
node's OWN state — the enrolled keys, or the watched puzzle hashes and the receive history behind
them. `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.
- **`Asset`** — the asset a wallet read is denominated in. Exactly three wire forms are valid:
| Form | JSON | Meaning |
|---|---|---|
| XCH token | `"xch"` | Native Chia, denominated in mojos. |
| $DIG token | `"dig"` | The $DIG CAT, asset id `a406d3a9de984d03c9591c10d917593b434d5263cabe2b42f6b367df16832f81`. |
| CAT by asset id | `{"cat":"<64-hex>"}` | Any CAT, named by its asset id (TAIL hash). |
The asset id is lowercase 64-hex, unprefixed, on the wire. A `0x` prefix and uppercase digits MUST
be ACCEPTED on input and normalized away; neither MUST ever be emitted. Any other string, an object
with any other or additional key, and a hex string of any other length MUST be REJECTED — an
unrecognized asset MUST NOT be defaulted to XCH or to $DIG.
**$DIG has exactly one identity.** `"dig"` and `{"cat":"a406…2f81"}` MUST denote the SAME asset and
MUST compare equal. An implementation MUST NOT model $DIG as a value distinct from its own asset
id: were the two inequal, a balance or coin list filtered by one spelling would silently omit the
holdings carrying the other, reporting part of a balance as though it were all of it.
**$DIG MUST still be EMITTED as `"dig"`**, not as its tagged form. `"xch"` and `"dig"` are the only
forms a node or client built before asset ids were nameable understands, so emitting the tagged
form for $DIG would break that compatibility direction. The tagged form is for every OTHER CAT.
Consumers MUST model this as a two-case type — native XCH, or a CAT identified by asset id — with
$DIG a named constant of the CAT case rather than a third case.
- **`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:Asset|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,
known_dig_peer_count:u32|null}`. How many peers this node holds on EACH network, and how many DIG
peers it knows of. 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.
`known_dig_peer_count` MUST be the number of DIG peers this node has LEARNED OF — the size of its
own discovered-peer address book — whether or not it is connected to them. It exists so that a
client can tell a REACHABILITY fault (`dig_peer_count: 0` beside a large known count) apart from a
DISCOVERY fault (`0` beside `0`); those have different remedies and, before this field, rendered
as the same zero. It MUST NOT be derived from `dig_peer_count`.
`known_dig_peer_count` is ONE node's local view and is therefore a LOWER BOUND. It MUST NOT be
presented, by the node or by any client, as the size of the DIG network or as a total peer count:
it omits every peer this node has not been introduced to, every peer reachable only through a
relay it does not use, and every address book entry evicted under the node's own bucket limits.
Two healthy nodes on one network will report different values and neither is wrong. A client MUST
label it as known/discovered peers.
`known_dig_peer_count >= dig_peer_count` normally holds, but is NOT an invariant a client may rely
on: the two are sampled from separate structures and may invert transiently during pool churn.
The field is OPTIONAL on the wire. A node predating it omits it, and a consumer MUST decode that
omission as `null` (UNKNOWN) rather than rejecting the payload or defaulting it to `0`.
- **`WalletSyncStatusResult`**: `{phase:"not_started"|"syncing"|"synced"|"no_wallet_enrolled"|
"wallet_not_unlocked", peak_height:u32|null, chia_peer_count:u32|null, watched_addresses:u32|null,
subscription_peer_count:u32|null, chia_peer_peak_height:u32|null}`.
Whether the node's WALLET CHAIN replica is being kept current, how far it has got, how many CHIA
full-node peers its sync is using, and how many addresses it is actually following. This is NOT
`control.sync.status`, which reports §21 DIG store sync and is unrelated.
A conforming node MUST emit one of the five 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 named 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 itself a 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.
**The two idle phases.** A sync with no addresses to follow MUST report which of two situations it
is in, because they are opposite claims. `"no_wallet_enrolled"` MUST mean no wallet is enrolled on
the node: there is nothing to watch, the state is correct and complete, and a consumer MAY present
it as settled. `"wallet_not_unlocked"` MUST mean a wallet IS enrolled but the node holds no
addresses derived for it, so the user's coins are being followed by nothing; a consumer MUST NOT
render it as synced, settled, or up to date, and MUST NOT present a balance read under it as
complete. A node MUST NOT emit a single token covering both, and MUST NOT report either as
`"synced"`. The common cause of `"wallet_not_unlocked"` is that address derivation needs key
material unavailable while the wallet is locked and nothing back-fills it until unlock, which makes
it the ordinary state after a restart — the token names the OBSERVATION (no addresses held), not
the lock, because a manifest that never carried the keys reaches the same state unlocked.
`watched_addresses` MUST be the number of addresses the sync is actually following. `0` is an
OBSERVED zero; `null` MUST mean the node did not report the number and MUST NOT be rendered as
zero. It is the second fact that makes an idle sync readable: `0` beside `"no_wallet_enrolled"` is
a complete picture, `0` beside `"wallet_not_unlocked"` is a wallet nobody is following. A node MUST
NOT emit `{phase:"synced", watched_addresses:0}` — a sync following no addresses has caught nothing
up — and a consumer meeting that pair SHOULD trust the count, which is the narrower claim.
`subscription_peer_count` counts peers the REPLICA's own subscription supervisor is writing
through. The supervisor holds AT MOST ONE such peer by design, so this is a 0-or-1 fact about
whether the replica is currently being fed — NEVER a measure of network reach, and it MUST NOT be
summed with `chia_peer_count`. Before dig_ecosystem#2806, `chia_peer_count` on this method
incorrectly carried this narrower number, so a node with five wallet-sync peers reported
`chia_peer_count:1`; the two fields now exist side by side so a conforming node reports both
observations distinctly. `null` means no supervisor is attached at all.
`chia_peer_peak_height` is the peak height this node's OWN Chia peers have ANNOUNCED — distinct
from `peak_height` (the replica's own progress) and from any oracle reading. `null` MUST be used
until at least one peer has reported a height; a conforming node MUST NOT report `0` as a stand-in
for unobserved, since `0` is itself a real height a peer could announce.
Both fields are OPTIONAL on the wire. A node predating them omits both keys, and a consumer MUST
decode that omission as `null` rather than rejecting the payload or defaulting either to `0`.
**An UNKNOWN token MUST NOT fail the response.** A consumer MUST accept any string in `phase` and
represent one outside the five tokens as an explicit unrecognised value carrying the token
verbatim, never as a deserialization error and never coerced onto a known phase. A closed token set
is what turned one added phase into a total read failure in every consumer built against an older
contract (dig_ecosystem#2609); a coercion onto `"synced"` or `"syncing"` would be worse still,
converting that outage into a confident false statement about the user's funds. A consumer MUST
render an unrecognised phase as unknown and MUST NOT infer progress, completion, or a trustworthy
balance from it. Symmetrically, a consumer MUST tolerate a payload that OMITS any of the three
count fields, reading each absence as unreported rather than zero, so a node predating a field
stays readable.
A phase value MUST NOT report itself as unrecognised while spelling itself as a known token.
Decoding any phase's own wire spelling MUST return that same phase. An implementation that lets a
caller attach an arbitrary spelling to its unrecognised representation reintroduces the defect this
tolerance exists to remove — a value that calls itself unknown locally and arrives at the far side
as `"synced"` — and MUST make that state unconstructible rather than merely discouraged.
**Which phases may be presented as SETTLED.** Exactly two: `"synced"` and `"no_wallet_enrolled"`.
A consumer MUST NOT present `"not_started"`, `"syncing"`, `"wallet_not_unlocked"`, or an
unrecognised token as settled, complete, or nothing-to-do. `"wallet_not_unlocked"` is the one that
invites the mistake, because it is idle and looks like `"no_wallet_enrolled"` from inside the sync
loop while meaning the opposite.
**Rendering an unrecognised token.** The token is untrusted node-supplied text and MUST be escaped
before it reaches a terminal, a log, or a UI. A node emitting `"\u{1b}[2K\rsynced"` otherwise
turns a consumer's `unknown phase: <token>` line into one that reads `synced`, because the escape
sequence erases the prefix; a right-to-left override does the same to a label. A consumer MUST
bound the rendered length as well: nothing caps the token on the wire, because rejecting an
over-long one would reintroduce the fail-closed parse. The raw bytes remain available for RELAYING
a payload on unchanged, which is the only use that needs them.
**Emission ordering.** Tolerance protects consumers built against a LATER contract than the node;
it cannot protect one built against an EARLIER contract, because the tolerance lives in the
consumer. A node MUST NOT emit `"no_wallet_enrolled"` or `"wallet_not_unlocked"` until the
consumers it serves are known to be at this contract version or later. dig-node and its clients
update independently on a user's machine, so a node that adopts the new tokens ahead of its
installed clients reproduces the total-parse-failure this revision fixes, on every one of them.
The same rule binds every future token: **the contract may grow a phase at any time; a node may
only emit it once its consumer floor has caught up.**
`"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 an **`Asset`** (see "Asset" below). 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 a GATED method — `control.wallet.broadcast`, `.arrivals`, `.watch`, `.unwatch`, `.watched` — `-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.2a Wallet enrolment (`control.wallet.watch` / `.unwatch` / `.watched`)
Enrolment is how an authorized local client tells the node WHICH addresses to follow, so that a
wallet whose keys live outside the node can still be synced and read.
- The unit of enrolment is a **BLS G1 PUBLIC KEY**, 48 bytes, on the wire as lowercase 96-hex,
unprefixed; a `0x` prefix MUST be accepted on input and MUST NOT be emitted. A node MUST derive the
addresses from an enrolled key using the SAME derivation it applies to keys in its own custody, so
that exactly one key→address mapping exists in the ecosystem. Enrolling puzzle hashes instead would
require every client to re-derive independently, and a client with a narrower derivation window
would under-report the funds it owns.
- No private key material is ever carried: a public key is public, and enrolment confers no ability
to sign. §4.3 is unaffected.
- `control.wallet.watch` MUST be IDEMPOTENT. Re-enrolling an enrolled key is a SUCCESS reporting
`added: 0`; duplicates within one request count once. `control.wallet.unwatch` mirrors it: a key
that was never enrolled reports `removed: 0` and is not an error.
- A node MUST refuse the WHOLE request as `-32602 INVALID_PARAMS` when ANY submitted key is
malformed, never the well-formed subset — a partial enrolment leaves the node following fewer
addresses than the client believes it asked for, and the client's next balance read would report
the shortfall as though the money were absent.
- The enrolled set MUST PERSIST across node restarts, and `unwatch` MUST actually stop the following
— the addresses leave the replica's watched set, not merely the list the node reports.
- `control.wallet.watched` MUST return exactly the keys enrolment added, in the wire form they were
accepted in, and MUST NOT include the node's own custody keys: a caller reconciling against a
superset would unwatch keys it never watched.
- **Privacy.** Enrolling makes this node query its peers for those addresses, so its peers can
associate them with this machine. That is already true of the node's own custody keys; it becomes
true of the enrolling client's keys too, and a client SHOULD say so where a person can see it.
### 4.2b dig-profile bodies (`control.profile.putBody` / `.getBody`)
A dig-profile is a chain-anchored ROOT plus the body that root commits to. The root is written on
chain by whoever holds the key — never by the node (§4.3). These two methods move only the BODY.
- **`control.profile.putBody` is a trust boundary the node MUST NOT skip.** On every call the node
MUST independently resolve the profile's root on chain, recompute the root of the supplied body,
and REFUSE the call unless the two agree and that root is CONFIRMED. The caller's `root` is a
claim to be checked, never a fact to be trusted.
- **dig-app receives no exemption.** It signs and pushes the root, but the bytes it then hands over
reach the node exactly as a peer's bytes do, and the same rule binds both: a body is checked
against the on-chain root and anything that does not match is rejected. A node that stores what it
is handed can be made to serve arbitrary bytes to the network under another party's profile id.
- **Refusal is an ERROR, never a success.** A rejected body MUST NOT return `Ok` with `stored:
false`; reaching a successful result asserts both that the root was confirmed and that the body is
persisted and servable.
- **Bodies are bounded at `MAX_BODY_BYTES` = 4 MiB, on the DECODED bytes.** A larger body MUST be
refused as `-32602 INVALID_PARAMS` before it is persisted. The bound is half of dig-gossip's
`WS_MAX_MESSAGE_BYTES` (8 MiB), the frame ceiling the body must fit inside when this node serves
it to a peer over `PROFILE_BODY` (opcode 225); a body accepted here but unservable there would be
stored and permanently unsyncable. The contract states the cap so a client can check before it
sends rather than discovering it as a failed round trip.
- **`control.profile.getBody` answers at the root it was ASKED for.** A node MUST NOT substitute a
newer body it holds. `body_b64: null` MUST mean "this node was consulted and holds no body at that
root"; a read that FAILED MUST return a catalogued error instead.
### 4.2c Subscription `kind` is read tolerantly
`control.subscribe` takes an OPTIONAL `kind` of `"capsule"` or `"profile"`. A `profile` subscription
additionally follows the profile ROOT and syncs its body from peers.
- **An ABSENT `kind` MUST mean `"capsule"`,** on the wire and on disk. Every subscription written
before the field existed is untagged, and a node MUST keep reading those rows: a node that refuses
its own pre-existing `subscriptions.json` starts with an empty one, and the upgrade silently
unsubscribes the user from everything they had.
- The same tolerance binds the RESULT: a client MUST parse a `control.subscribe` acknowledgement
from a node build that predates the field, treating the absent `kind` as `"capsule"`.
- An UNRECOGNISED `kind` token is `-32602 INVALID_PARAMS`; absence is not.
### 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. `control.wallet.watch` carries PUBLIC keys, which confer no ability to sign. There
`control.profile.putBody` carries body bytes whose root somebody else already signed and confirmed on
chain. There
is no private 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.
| `-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.