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 c5ac95b5..6d6e1ded 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -907,7 +907,8 @@ impl RpcDispatch for Node { "count": set.len()}}); } // dig.getRewardProverStatus (dig_ecosystem#3269, dig-rewards-coin SPEC.md - // §2.3/§2.4) — CONTROL plane: loopback admin / in-process FFI ONLY, NEVER over the + // §2.3/§2.4) — CONTROL plane: loopback admin / in-process FFI ONLY (the token tier of + // this NODE-LOCAL read is dig_ecosystem#3352's decision, not #3351's), NEVER over the // mTLS peer surface (absent from `is_peer_reachable_method`; // `reward_methods_tier_guard.rs` fails closed on that). Reads the node's live // `reward_prover_statuses` registry (empty until dig_ecosystem#3265 spawns a prover @@ -997,12 +998,22 @@ impl RpcDispatch for Node { }; return json!({"jsonrpc":"2.0","id":id,"result": result}); } - // dig.getRewardDistributor (dig_ecosystem#3269 unit 2, SPEC §2.6/§12.4) — CONTROL - // plane: loopback admin / in-process FFI ONLY, absent from `is_peer_reachable_method` - // (`reward_methods_tier_guard.rs` fails closed on that). Chain-derived state ONLY — - // never the local prover loop's self-reported state (see `GetRewardProverStatus` - // above for that). Goes entirely through `rewards::port::RewardsChainPort`: this - // crate never calls `dig-rewards-coin` itself (dig_ecosystem#3269 unit 0). + // dig.getRewardDistributor (dig_ecosystem#3269 unit 2, SPEC §2.6/§12.4) — `Tier::Control` + // in dig-rpc-protocol's sense: served ONLY by the local `handle_rpc` dispatch (the + // service's `POST /` and the in-process FFI), NEVER over the mTLS peer surface (absent + // from `is_peer_reachable_method`; `reward_methods_tier_guard.rs` fails closed on that). + // NOT token-gated (dig_ecosystem#3351): an OPEN read of public on-chain state keyed by + // the caller's `launcher_id`, answered to any caller that reaches `POST /` with no token: + // only the `control.` prefix is token-gated (SPEC §7.2 `is_control_method`; §5.5 + // `requires_auth: false` for every non-`control.*` method), and the read passes §7.2's + // WHO-NAMES-THE-SUBJECT test the same way `control.wallet.balance` does (#1851, + // `control::is_open_control_read`): the subject arrives in the request, so the answer + // discloses no node-local association. + // Pinned by `reward_distributor_reads_answer_on_post_slash_without_a_token` in + // dig-node-service `tests/server.rs`. Chain-derived state ONLY — never the local prover + // loop's self-reported state (see `GetRewardProverStatus` above for that). Goes entirely + // through `rewards::port::RewardsChainPort`: this crate never calls `dig-rewards-coin` + // itself (dig_ecosystem#3269 unit 0). Some(Method::GetRewardDistributor) => { let params = req.get("params").cloned().unwrap_or(json!({})); let launcher_id = match parse_launcher_id_arg(¶ms) { @@ -1041,7 +1052,8 @@ impl RpcDispatch for Node { return json!({"jsonrpc":"2.0","id":id,"result": result}); } // dig.listRewardDistributorCommitments (dig_ecosystem#3269 unit 2, SPEC §7.4 clause 5) - // — CONTROL plane, same guard shape as `GetRewardDistributor` above. `commitments` + // — same guard shape as `GetRewardDistributor` above (`Tier::Control`, not token-gated, + // OPEN on `POST /`; dig_ecosystem#3351). `commitments` // empty is legitimate (a donation-only distributor); `recoverable_base_units` per slot // is ALWAYS the port's pre-computed figure -- this handler never recomputes it (see // `rewards::port::CommitmentSlot`'s doc for why that arithmetic never lives here). diff --git a/crates/dig-node-service/tests/server.rs b/crates/dig-node-service/tests/server.rs index d383f7af..92b63f44 100644 --- a/crates/dig-node-service/tests/server.rs +++ b/crates/dig-node-service/tests/server.rs @@ -1535,6 +1535,62 @@ async fn cache_list_cached_is_not_routable_over_ws() { ); } +/// **Proves (dig_ecosystem#3351, WS parity):** `dig.getRewardDistributor` and +/// `dig.listRewardDistributorCommitments` are OPEN reads on the HTTP transport (no token required), +/// but that openness must not accidentally widen into a SECOND, WS-reachable path. The `ws_dispatch` +/// fall-through routes an unrecognized method to `WalletBackend::dispatch`, whose match has no +/// `dig.*` arm, so both methods come back as an unknown-method error over `/ws` -- never as +/// `UNAUTHORIZED` (that would mean WS gates them where HTTP does not, which is its own bug) and +/// never as a real result (that would mean the reward-chain answer leaked over an unaudited +/// transport). +/// +/// **Catches:** a wallet-backend or `ws_dispatch` arm that starts routing `dig.*` reward reads over +/// `/ws` without the tier decision being revisited. +#[tokio::test] +async fn reward_distributor_reads_are_not_routable_over_ws() { + use tokio_tungstenite::tungstenite::Message; + let (upstream, _calls) = start_mock_upstream().await; + let (addr, _token, _backend, _hold) = start_node_wallet(&upstream).await; + + let (mut ws, _resp) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .expect("connect to /ws"); + let _ = next_ws_json(&mut ws).await; // drain the initial sync_status snapshot + + for (idx, method) in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] + .into_iter() + .enumerate() + { + // No token: these reads are OPEN on HTTP, but that has no bearing on WS routability. + ws.send(Message::Text( + json!({ "id": format!("rd{idx}"), "type": "request", "method": method }).to_string(), + )) + .await + .unwrap(); + let resp = next_ws_json(&mut ws).await; + assert_eq!(resp["id"], json!(format!("rd{idx}"))); + assert_eq!( + resp["ok"], + json!(false), + "{method} is not a WS method, got {resp:?}" + ); + // `ws_err` emits `error.code` as the NUMERIC control-plane code (`ErrorCode::code()`, + // -32030 for Unauthorized) while `ws_from_jsonrpc` surfaces the string name; a gate + // added on either path must trip this, so reject BOTH spellings. + let is_unauthorized = resp + .pointer("/error/code") + .is_some_and(|c| c == &json!("UNAUTHORIZED") || c == &json!(-32030)); + assert!( + !is_unauthorized, + "{method} over WS must fail as unknown-method, not UNAUTHORIZED -- \ + a WS gate would contradict the HTTP-side open-read decision, got {resp:?}" + ); + } +} + /// **A person can add, list and remove a trusted Chia peer, end to end over the REAL control plane.** /// /// The whole round trip through the real server, the real token gate, the real wallet backend and @@ -3853,3 +3909,56 @@ async fn a_client_can_register_and_deregister_the_addresses_the_node_follows() { "deregistering one key must stop following exactly it, and leave the other followed" ); } + +/// **Proves:** `dig.getRewardDistributor` and `dig.listRewardDistributorCommitments` are answered +/// on `POST /` with NO control token presented — `Tier::Control` in dig-rpc-protocol's sense means +/// "loopback / in-process dispatch only, never over the mTLS peer surface", NOT token-gated +/// (dig_ecosystem#3351). Both requests must pass every ingress gate (no `-32030`/`UNAUTHORIZED`) and +/// reach the reward handler itself, which then reports `REWARD_CHAIN_UNAVAILABLE` because this +/// ephemeral test node has no chain-read adapter wired — proving dispatch, not a passthrough relay +/// or a method-not-found stub, answered the call. +/// **Catches:** a future gate added at the `server.rs` ingress (e.g. folded into the cache-trio +/// token check) that silently demotes these reads to token-gated — breaking the anonymous callers +/// nobody can enumerate — and a doc claiming they are gated when the enforced behaviour is open. +#[tokio::test] +async fn reward_distributor_reads_answer_on_post_slash_without_a_token() { + let (addr, _hold) = start_node("").await; + let launcher_id = "11".repeat(32); + + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { + let resp: Value = client() + .post(format!("http://{addr}/")) + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": { "launcher_id": launcher_id } + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + assert_ne!( + resp["error"]["code"], + json!(-32030), + "{method} must not be Unauthorized when no token is presented: {resp}" + ); + assert_ne!( + resp["error"]["data"]["code"], + json!("UNAUTHORIZED"), + "{method} must not be gated by the control token: {resp}" + ); + assert_eq!( + resp["error"]["data"]["code"], + json!("REWARD_CHAIN_UNAVAILABLE"), + "{method} must reach the reward handler (no chain port wired on this ephemeral node), \ + not a passthrough or a method-not-found stub: {resp}" + ); + } +}