diff --git a/Cargo.lock b/Cargo.lock index a73da6e6..cf34e6bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2969,7 +2969,7 @@ dependencies = [ [[package]] name = "dig-node-core" -version = "0.67.0" +version = "0.68.0" dependencies = [ "async-trait", "axum", @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.253.0" +version = "0.253.2" dependencies = [ "async-trait", "axum", @@ -3196,7 +3196,7 @@ dependencies = [ [[package]] name = "dig-runtime" -version = "0.3.0" +version = "0.4.0" dependencies = [ "base64", "dig-node-core", diff --git a/Cargo.toml b/Cargo.toml index 89d87107..d19bdf36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.253.0" +version = "0.253.2" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/README.md b/README.md index 00880387..fbda7c22 100644 --- a/README.md +++ b/README.md @@ -356,7 +356,7 @@ distinguishing node-shell errors from upstream/boundary ones), beside the numeri | -32600 | `INVALID_REQUEST` | shell | Not a single JSON-RPC object (batch arrays unsupported). | | -32601 | `METHOD_NOT_FOUND` | boundary | Not resolved locally or by the upstream. | | -32602 | `INVALID_PARAMS` | upstream | Invalid or missing method parameters. | -| -32000 | `DISPATCH_FAILED` | shell | The node failed to dispatch the request. | +| -32000 | `SERVER_ERROR` | shell | The request failed on the way to, or inside, the read path. Minted by both the shell and the node library; `data.origin` says which. | | -32010 | `UPSTREAM_ERROR` | shell | The blind-passthrough relay to the upstream failed. | | -32030 | `UNAUTHORIZED` | shell | A `control.*` method was called without a valid local control token. | | -32031 | `NOT_SUPPORTED` | shell | A control op the node build can't perform (e.g. §21 sync with no identity). | diff --git a/SPEC.md b/SPEC.md index 96493d85..2698caeb 100644 --- a/SPEC.md +++ b/SPEC.md @@ -96,9 +96,7 @@ shell's HTTP control surface — pairing/control.status/hostedStores/updater — ties its node-surface method names + the peer-reachable set to the crate. The shell keeps its own `ErrorCode` enum, but every code it shares with the crate now SOURCES its number and machine string from `dig_rpc_protocol::ErrorCode` rather than restating them, and a guard asserts that equality -across the whole shared set. One shell-specific string remains: `DISPATCH_FAILED` at `-32000`, where -the crate says `SERVER_ERROR`. That name is minted and published by the shell alone, so reconciling -it is a separate, wire-visible decision. Not yet adopted: the crate's `RpcError` envelope type and +across the whole shared set, with no exclusions. Not yet adopted: the crate's `RpcError` envelope type and the `dig-rpc` server framework. The numeric error codes remain guaranteed identical by the conformance vectors. @@ -971,9 +969,12 @@ truth shared with `rpc.dig.net`. This node MUST NOT diverge from it. - **The shell (`dig-node-service`) sources every code it shares with the contract crate from `dig_rpc_protocol::ErrorCode` rather than restating it**, so a shell-catalogued number and its name cannot disagree with the crate either. `-32004` is `RESOURCE_UNAVAILABLE` on both sides and on every - frame the node emits. The single remaining shell-specific string is `DISPATCH_FAILED` at `-32000`, - which the shell alone mints and publishes; the crate's generic name for that number is - `SERVER_ERROR`, and reconciling the two is tracked separately. + frame the node emits, and `-32000` is `SERVER_ERROR` on both sides — the shell and the read path + both mint that number, and a client branching on `data.code` cannot see which layer answered, so + they publish ONE name. Which layer answered is carried by `data.origin`. + + `dig-node-control-interface` declares the same number as `DISPATCH_FAILED` for its control + surface, which dig-node does not mint. - **The ONE exception, stated rather than left silent:** a code this node emits that `dig-rpc-protocol` does not declare carries NO `data` object at all. Today that is `-32001` alone (the push-authority refusal, §21.9), which `SYSTEM.md` records as reserved-by-occupancy. @@ -990,7 +991,7 @@ For each request, in order: 1. `rpc.discover` → answered by the shell with the OpenRPC document (§6.3) as `result`. 2. `control.*` → the control plane (§7): authorization gate, then `dispatch_control`. 3. Everything else → normalized (§5.3), then dispatched to `dig_node_core::handle_rpc` on a - spawned task. A panicked/failed dispatch task yields `DISPATCH_FAILED` (`-32000`); the server + spawned task. A panicked/failed dispatch task yields `SERVER_ERROR` (`-32000`); the server MUST survive it. 4. If the read path returns `-32601` (method not found), the shell relays the **original, un-normalized** request to the upstream (§5.4). @@ -3274,7 +3275,7 @@ method runs, and it MUST NOT be conflated with the wallet's own `-32043` egress | -32600 | `INVALID_REQUEST` | shell | Not a single JSON-RPC object (batch arrays unsupported); also the 421 Host-rejection body. | | -32601 | `METHOD_NOT_FOUND` | boundary | Not resolved locally or by the upstream (internally: the passthrough cue). | | -32602 | `INVALID_PARAMS` | node | Invalid/missing method parameters (also minted by the control plane for bad control params). | -| -32000 | `DISPATCH_FAILED` | shell | The shell failed to dispatch the request to the read path. | +| -32000 | `SERVER_ERROR` | shell | The request failed on the way to, or inside, the read path: a failed dispatch task, an unclassified wallet-proxy failure, or a generic read-path failure. Minted by BOTH the shell and the node library; `data.origin` says which. | | -32004 | `RESOURCE_UNAVAILABLE` | node | Genuine content miss at the requested root; distinct from transport failure. Minted by the node library for a LOCAL miss — `dig.fetchRange` ("resource not held") and `dig.getManifest` ("capsule not held locally") — and relayed with `origin: upstream` when a passthrough upstream returns it. Never a fabricated result. | | -32005 | `ROOT_NOT_ANCHORED` | node | The node's mandatory read-path anchored-root pin (§14.4) fails closed: the requested root does not match the chain-anchored tip, the store has no confirmed on-chain generation, the chain is unreachable, or a rootless request cannot be resolved under enforcement. Minted by the node library on `dig.getContent`. | | -32008 | `CONTENT_REDIRECT` | node | The node does not (or, under §17's throttle, will not right now) serve the requested content itself, but the DHT located peer(s) that hold it — `error.data.redirect` names them (`content`, `providers[].peer_id`/`addresses`, `redirect_depth`, `max_redirects`) so the caller re-requests there. The candidate set is CAPPED at `MAX_REDIRECT_PROVIDERS` (= dig-dht's `MAX_ADDRESSES_PER_RECORD`): a redirect NAMES holders (the requestor dials them over its own §5.2 reachability ladder — this node does NOT dial/probe them), so a few candidates suffice and probing-on-miss would itself be an amplification vector. Minted on a content miss (`dig.getContent`/`dig.fetchRange`/the peer range-stream) and on outgoing-bandwidth saturation (§17), bounded by the same redirect-hop cap either way. | diff --git a/USER_JOURNEY.md b/USER_JOURNEY.md index 4bfd4b67..ba637f39 100644 --- a/USER_JOURNEY.md +++ b/USER_JOURNEY.md @@ -166,7 +166,7 @@ every subcommand (machine output to stdout, prose to stderr). `2 USAGE`, `3 PERMISSION_DENIED`, `4 SERVICE_FAILED`, `5 BIND_FAILED`, `6 IO_ERROR`, `12 NODE_UNREACHABLE`. - **Stable JSON-RPC error codes** (UPPER_SNAKE in `error.data.code`): `PARSE_ERROR` (-32700), `INVALID_REQUEST` (-32600), `METHOD_NOT_FOUND` (-32601), `INVALID_PARAMS` (-32602), - `DISPATCH_FAILED` (-32000, shell), `UPSTREAM_ERROR` (-32010, shell), and the control-plane codes + `SERVER_ERROR` (-32000, shell and node), `UPSTREAM_ERROR` (-32010, shell), and the control-plane codes `UNAUTHORIZED` (-32020), `NOT_SUPPORTED` (-32021), `CONTROL_ERROR` (-32022). The `data.origin` field distinguishes node-shell errors from upstream/boundary ones. - **`rpc.discover`**: returns the OpenRPC document over the wire, so an agent can introspect the diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 66e40940..d15bba1c 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -30,7 +30,7 @@ name = "dig-node-core" # dig-node#276/#296). Changing a public return type is BREAKING for an out-of-workspace implementor; # this crate is consumed in-workspace only and is pre-1.0, so it is a MINOR bump under SemVer's 0.x # rule -- recorded here rather than letting the number imply the locator surface held still. -version = "0.67.0" +version = "0.68.0" edition = "2021" license = "GPL-2.0-only" description = "The canonical DIG node ENGINE library (crate `dig_node_core`): the JSON-RPC dispatch (`handle_rpc`, the same contract as rpc.dig.net), local-first content serve/fetch/redirect from LOCAL .dig store modules (via digstore_host::serve_blind), chain-anchored-root resolution, chain-watch + subscriptions + generation gap-fill, the LRU cache, and the full P2P stack. Shared UNCHANGED by both host shells: the `dig-node` OS-service binary (dig-node-service) and the DIG Browser's in-process cdylib (dig-runtime). Native Rust so the compiled-module serve path works." diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index fd531753..e3009a0a 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -4574,7 +4574,11 @@ pub async fn handle_rpc_json( /// Every frame is minted through [`seams::dig_rpc::errors::error_frame`], so a declared code /// carries `data.code` + `data.origin` from `dig-rpc-protocol` by construction rather than by /// each call site remembering to add them (dig-node#340). -fn rpc_err(id: &Value, code: i64, message: &str) -> Value { +/// +/// PUBLIC so a consumer that also mints frames at the same numbers — the dig-node shell — can be +/// tested against the frame this crate actually emits, rather than against a restatement of it. +/// Two producers of one wire shape are one edit away from disagreeing (dig-node#496). +pub fn rpc_err(id: &Value, code: i64, message: &str) -> Value { crate::seams::dig_rpc::errors::error_frame(id, code, message) } @@ -5239,10 +5243,13 @@ mod tests { /// ([`RESOURCE_UNAVAILABLE`] and [`RESOURCE_NOT_AVAILABLE`]) are correctly read as one condition /// under two names rather than as a collision. /// - /// Deliberately NOT exhaustive yet: `content_serve::SERVE_UNREADABLE` (`-32000`) specialises the - /// canonical `SERVER_ERROR`, and the chat band (`-32050`..`-32052`) is undeclared upstream - /// entirely. Both are pre-existing and out of this change; adding them is a follow-up that has to + /// Deliberately NOT exhaustive yet: the chat band (`-32050`..`-32052`) is undeclared upstream + /// entirely. That is pre-existing and out of this change; adding it is a follow-up that has to /// resolve the condition, not the table. + /// + /// `content_serve::SERVE_UNREADABLE` used to be named here as a second `-32000` gap. It was not + /// one: its code field's only sink answered `502` from the message and never read the number, so + /// it was a dead const rather than a producer, and it has been deleted (dig-node#496). const LOCAL_WIRE_CODES: &[(i64, &str)] = &[ ( crate::download::CONTENT_MISS_RATE_LIMITED, diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index cfc8810d..1b4e0ec8 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -1282,8 +1282,14 @@ fn admission_refused( peer = %conn_key.get(..16).unwrap_or(conn_key), "peer serve: inbound work refused at admission" ); - json!({"jsonrpc":"2.0","id":id, - "error":{"code":-32000,"message":"request refused","data":{"reason":refusal.reason()}}}) + // Minted through the canonical envelope so the refusal carries `data.code` + + // `data.origin` like every other declared code, with `reason` ADDED to that object rather + // than replacing it — the same shape `relay_pending_object` uses to attach its progress + // field. A frame that carries only `reason` forces a client back to prose for the one + // question `data.code` exists to answer (dig-node#496). + let mut error = crate::seams::dig_rpc::errors::error_object(-32000, "request refused"); + error["data"]["reason"] = json!(refusal.reason()); + json!({"jsonrpc":"2.0","id":id,"error":error}) } /// Whether `method` may be answered over the **mTLS peer surface** (other DIG nodes). diff --git a/crates/dig-node-core/src/seams/content/content_serve.rs b/crates/dig-node-core/src/seams/content/content_serve.rs index 0b8c5932..22008bb0 100644 --- a/crates/dig-node-core/src/seams/content/content_serve.rs +++ b/crates/dig-node-core/src/seams/content/content_serve.rs @@ -30,9 +30,6 @@ use serde_json::{json, Value}; use crate::{decide_pin, pin_enforced, CapsuleStore, Node, PinDecision, ROOT_NOT_ANCHORED}; -/// JSON-RPC-style code for a serve that fetched bytes but could not verify/decrypt/reach them — -/// distinct from a clean content miss (`NotFound`) and from the anchored-root pin (`RootError`). -const SERVE_UNREADABLE: i64 = -32000; /// The upstream/peer "resource not available at this root" code — a genuine content miss (SPEC §10). const RESOURCE_UNAVAILABLE: i64 = -32004; @@ -138,11 +135,11 @@ pub enum PlaintextOutcome { InvalidParams { message: String }, /// Bytes were fetched but verification or decryption failed (tamper / wrong key / decode error), /// or the fetch itself errored at the transport level. Fail-closed — no plaintext is returned. - Unreadable { - code: i64, - message: String, - root_hex: String, - }, + /// + /// Carries NO numeric code: this outcome's only sink is the HTTP serve path, which answers + /// `502 BAD_GATEWAY` from the message alone. A `-32000` field here never reached any wire and + /// was therefore a dead producer rather than a taxonomy gap (dig-node#496). + Unreadable { message: String, root_hex: String }, } /// The canonical ROOTLESS resource URN whose SHA-256 is the retrieval key and whose bytes seed the AES @@ -992,7 +989,6 @@ impl Node { None } Err(ProxyMiss::Error(message)) => Some(PlaintextOutcome::Unreadable { - code: SERVE_UNREADABLE, message, root_hex: root_hex.to_string(), }), diff --git a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index 7698f0c8..ca58d63b 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -1038,8 +1038,12 @@ impl RpcDispatch for Node { } v } - Err(e) => json!({"jsonrpc":"2.0","id":id, - "error":{"code":-32000,"message":format!("upstream: {e}")}}), + // Through the canonical frame builder, so this failure carries `data.code` + + // `data.origin` like every other declared code rather than a bare {code,message} + // a client cannot branch on (dig-node#496). + Err(e) => { + crate::seams::dig_rpc::errors::error_frame(&id, -32000, &format!("upstream: {e}")) + } } } } diff --git a/crates/dig-node-service/src/meta.rs b/crates/dig-node-service/src/meta.rs index fa6735fd..680ef847 100644 --- a/crates/dig-node-service/src/meta.rs +++ b/crates/dig-node-service/src/meta.rs @@ -664,8 +664,16 @@ pub enum ErrorCode { MethodNotFound, /// `-32602` — invalid params (e.g. missing store_id / urn). From dig-node. InvalidParams, - /// `-32000` — the dig-node shell failed to dispatch the request to the node. - /// Dig-node-shell error. + /// `-32000` — a request failed on the way to, or inside, the read path: the shell + /// could not dispatch it (a panicked/failed spawn task, an unclassified wallet-proxy + /// failure), or the read path itself failed generically. + /// + /// ONE condition covering BOTH mints, which is why it carries one name: the shell mints + /// it at 2 sites and the embedded read path at 11, on the same port and at the same + /// number. Which layer answered is carried by `data.origin`, never by the name — so the + /// name is taken from the shared catalogue (`SERVER_ERROR`) rather than restated. The + /// Rust variant keeps its condition-describing spelling; that is internal identity, not + /// the wire (dig-node#496). DispatchFailed, /// `-32004` — the requested resource is not available at the requested root (a /// genuine content miss for that capsule, distinct from a transport failure). @@ -791,10 +799,7 @@ impl ErrorCode { ErrorCode::InvalidRequest => shared(dig_rpc_protocol::ErrorCode::InvalidRequest), ErrorCode::MethodNotFound => shared(dig_rpc_protocol::ErrorCode::MethodNotFound), ErrorCode::InvalidParams => shared(dig_rpc_protocol::ErrorCode::InvalidParams), - // NOT sourced: the shell mints -32000 under its own `DISPATCH_FAILED`, which the - // crate calls `SERVER_ERROR`. That name IS on the wire, so reconciling it is a - // shipped-name change of a different kind — tracked separately, never silently. - ErrorCode::DispatchFailed => -32000, + ErrorCode::DispatchFailed => shared(dig_rpc_protocol::ErrorCode::ServerError), ErrorCode::ResourceUnavailable => { shared(dig_rpc_protocol::ErrorCode::ResourceUnavailable) } @@ -835,8 +840,7 @@ impl ErrorCode { ErrorCode::InvalidRequest => dig_rpc_protocol::ErrorCode::InvalidRequest.machine_code(), ErrorCode::MethodNotFound => dig_rpc_protocol::ErrorCode::MethodNotFound.machine_code(), ErrorCode::InvalidParams => dig_rpc_protocol::ErrorCode::InvalidParams.machine_code(), - // See `code()` for why -32000 is deliberately not sourced. - ErrorCode::DispatchFailed => "DISPATCH_FAILED", + ErrorCode::DispatchFailed => dig_rpc_protocol::ErrorCode::ServerError.machine_code(), ErrorCode::ResourceUnavailable => { dig_rpc_protocol::ErrorCode::ResourceUnavailable.machine_code() } @@ -910,7 +914,12 @@ impl ErrorCode { } ErrorCode::MethodNotFound => "Method is not resolved locally or by the upstream.", ErrorCode::InvalidParams => "Invalid or missing method parameters.", - ErrorCode::DispatchFailed => "The node failed to dispatch the request.", + ErrorCode::DispatchFailed => { + concat!( + "A generic server error — the shell failed to dispatch the request, or ", + "the embedded read path failed internally, whichever layer answered.", + ) + } ErrorCode::ResourceUnavailable => { concat!( "The requested resource is not available at the requested root — ", diff --git a/crates/dig-node-service/tests/openrpc_drift_guard.rs b/crates/dig-node-service/tests/openrpc_drift_guard.rs index b0f9a9e2..5aa95e60 100644 --- a/crates/dig-node-service/tests/openrpc_drift_guard.rs +++ b/crates/dig-node-service/tests/openrpc_drift_guard.rs @@ -291,19 +291,15 @@ fn served_classes_are_well_formed() { /// crate's. A hard-coded assertion on one number only ever checks that number; this is /// the test that catches the NEXT drift, wherever it appears. /// -/// `DispatchFailed`/-32000 is the one deliberate exclusion: the shell mints it and -/// publishes it under its own name, so shell and wire AGREE there. Reconciling it with -/// the crate's generic `SERVER_ERROR` is a genuine shipped-name change with a different -/// risk profile, tracked separately. +/// There are NO exclusions: every code the shell shares with the crate is checked. The +/// set is DRAWN from `ErrorCode::all()` rather than hand-listed, so a gate over it cannot +/// be narrower than the enumeration it sweeps (dig-node#496). #[test] fn shell_error_names_match_the_shared_catalogue() { let mut checked = 0usize; let mut drifted: Vec = Vec::new(); for shell in ErrorCode::all() { - if *shell == ErrorCode::DispatchFailed { - continue; - } let Some(shared) = dig_rpc_protocol::ErrorCode::ALL .iter() .find(|c| i64::from(c.code()) == shell.code()) @@ -322,7 +318,7 @@ fn shell_error_names_match_the_shared_catalogue() { } assert!( - checked >= 7, + checked >= 8, "the shell shares fewer codes with dig-rpc-protocol than expected ({checked}) — \ this guard may be checking nothing" ); @@ -376,3 +372,41 @@ async fn the_catalogued_name_for_32004_is_the_name_the_node_emits() { "the discovery document names -32004 differently from the frame the node emits" ); } + +/// THE TWO FRAMES, not the two catalogues: `-32000` has TWO producers on one port — the +/// dig-node shell (`rpc::rpc_error(DispatchFailed, ..)`, 2 sites) and the embedded read path +/// (`dig_node_core::rpc_err(.., -32000, ..)`, 11 sites) — and a client branching on +/// `data.code` cannot see which layer answered. So the two frames MUST publish one name. +/// +/// Asserting either producer against the catalogue would pass on a catalogue that agrees +/// with itself; asserting the two EMITTED frames against EACH OTHER is what makes this a +/// decision test. The third assertion pins which name won, so a future edit cannot satisfy +/// the equality by reconciling both onto a third string nobody publishes (dig-node#496). +#[test] +fn the_shell_and_the_read_path_publish_one_name_for_32000() { + let shell = dig_node_service::rpc::rpc_error(json!(1), ErrorCode::DispatchFailed, "x"); + let read_path = dig_node_core::rpc_err(&json!(1), -32000, "x"); + + assert_eq!( + shell["error"]["code"], read_path["error"]["code"], + "the two producers must be compared at the SAME number" + ); + + let shell_name = shell["error"]["data"]["code"] + .as_str() + .expect("the shell frame carries a machine name in data.code"); + let read_path_name = read_path["error"]["data"]["code"] + .as_str() + .expect("the read-path frame carries a machine name in data.code"); + + assert_eq!( + shell_name, read_path_name, + "-32000 reaches one client under TWO machine names: the shell says {shell_name:?}, \ + the read path says {read_path_name:?}" + ); + assert_eq!( + shell_name, + dig_rpc_protocol::ErrorCode::ServerError.machine_code(), + "the reconciled name must be the one the shared catalogue declares at -32000" + ); +} diff --git a/crates/dig-runtime/Cargo.toml b/crates/dig-runtime/Cargo.toml index 8c43fb7b..fbd57bd5 100644 --- a/crates/dig-runtime/Cargo.toml +++ b/crates/dig-runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-runtime" -version = "0.3.0" +version = "0.4.0" edition = "2021" license = "GPL-2.0-only" description = "DIG browser native in-process runtime: a cdylib (dig_runtime.dll) the browser loads at startup. Exposes the built-in Chia wallet FFI (dig_wallet_rpc) and the digstore Rust read-crypto FFI (dig_read_verify_decrypt) — the SAME Rust the webpage wasm wraps — plus, for other consumers, the full node RPC (dig_rpc). The browser starts it WALLET-ONLY (no node engine); the read-crypto FFI needs no runtime at all." diff --git a/crates/dig-runtime/src/lib.rs b/crates/dig-runtime/src/lib.rs index d1fa9859..f6ceb60c 100644 --- a/crates/dig-runtime/src/lib.rs +++ b/crates/dig-runtime/src/lib.rs @@ -95,13 +95,25 @@ fn runtime() -> &'static DigRuntime { init_global(true) } +/// The message carried by the wallet-only guard frame, kept separate so the frame itself is +/// BUILT rather than written down. +const NODE_UNAVAILABLE_MESSAGE: &str = "node engine not available: dig-runtime started wallet-only"; + /// A JSON-RPC error returned by [`dig_rpc`] when the runtime was started /// WALLET-ONLY (the browser's mode) and therefore has no node engine. The DIG /// Browser never calls `dig_rpc` — it consumes an EXTERNAL node over RPC (#44) — /// so this only guards a misuse. -const NODE_UNAVAILABLE_JSONRPC: &str = r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"node engine not available: dig-runtime started wallet-only"}}"#; +/// +/// Built through `dig_node_core::rpc_err` rather than written as a string literal. A literal +/// frame is invisible to every guard that scans TYPES for taxonomy compliance, and this one had +/// consequently drifted to a bare `{code, message}` with no `data.code` for a client to branch +/// on. Deriving it means `-32000` carries the same `data.code` + `data.origin` here as on every +/// other frame the node emits (dig-node#496). +fn node_unavailable_jsonrpc() -> String { + dig_node_core::rpc_err(&serde_json::Value::Null, -32000, NODE_UNAVAILABLE_MESSAGE).to_string() +} -/// Dispatch one node JSON-RPC request, or return [`NODE_UNAVAILABLE_JSONRPC`] when +/// Dispatch one node JSON-RPC request, or return [`node_unavailable_jsonrpc`] when /// there is no node engine (wallet-only mode). Factored out of [`dig_rpc`] so the /// wallet-only guard is testable without the process-global runtime. fn dispatch_node_rpc(node: Option<&Node>, rt: &tokio::runtime::Runtime, req: &str) -> String { @@ -115,7 +127,7 @@ fn dispatch_node_rpc(node: Option<&Node>, rt: &tokio::runtime::Runtime, req: &st dig_node_core::download::ReadOrigin::Local, dig_node_core::download::RequestProvenance::FirstParty, )), - None => NODE_UNAVAILABLE_JSONRPC.to_string(), + None => node_unavailable_jsonrpc(), } }