From c9343e02a00f68e81aaa0d2fc5dedc36186eb109 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 17 Sep 2026 11:03:00 -0700 Subject: [PATCH 1/7] docs(rewards): correct reward-distributor-read tier claim -- OPEN, not token-gated dig.getRewardDistributor / dig.listRewardDistributorCommitments are Tier::Control in dig-rpc-protocol's sense (loopback/in-process dispatch only, never over the mTLS peer surface) -- that does NOT mean token-gated. Both disclose only public on-chain state keyed by the caller's launcher_id, the same class control::is_open_control_read already treats as open (#1851). The prior comment read as operator/token-gated, which the enforced behaviour never was. Refs DIG-Network/dig_ecosystem#3351 Co-Authored-By: Claude Sonnet 5 --- .../src/seams/dig_rpc/dispatch.rs | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) 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..908ccfb7 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -997,12 +997,19 @@ 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 /` — anonymous + // remote callers included on a node bound with `DIG_NODE_ALLOW_REMOTE=1` — for the same + // reason `control.wallet.balance` is open (#1851, `control::is_open_control_read`). + // 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 +1048,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). From e21820e6e932d00001c6990c7c45dae04d40c179 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 17 Sep 2026 11:04:19 -0700 Subject: [PATCH 2/7] test(rewards): pin reward-distributor reads OPEN on POST / without a token Adds reward_distributor_reads_answer_on_post_slash_without_a_token: POSTs dig.getRewardDistributor and dig.listRewardDistributorCommitments with no X-Dig-Control-Token / _control_token and asserts the request passed every ingress gate (not -32030 / UNAUTHORIZED) and reached the reward handler itself (REWARD_CHAIN_UNAVAILABLE on this ephemeral node's unwired chain port), not a passthrough relay or a method-not-found stub. Refs DIG-Network/dig_ecosystem#3351 Co-Authored-By: Claude Sonnet 5 --- crates/dig-node-service/tests/server.rs | 50 +++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/dig-node-service/tests/server.rs b/crates/dig-node-service/tests/server.rs index d383f7af..1cf89434 100644 --- a/crates/dig-node-service/tests/server.rs +++ b/crates/dig-node-service/tests/server.rs @@ -3853,3 +3853,53 @@ 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}" + ); + } +} From dd0e501ca6db6ab42d2668434370dd1057999f1e Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 17 Sep 2026 14:09:21 -0700 Subject: [PATCH 3/7] style(rewards): rustfmt the #3351 open-read pin Co-Authored-By: Claude Sonnet 5 --- crates/dig-node-service/tests/server.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/dig-node-service/tests/server.rs b/crates/dig-node-service/tests/server.rs index 1cf89434..ce201f5f 100644 --- a/crates/dig-node-service/tests/server.rs +++ b/crates/dig-node-service/tests/server.rs @@ -3869,7 +3869,10 @@ 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"] { + for method in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + ] { let resp: Value = client() .post(format!("http://{addr}/")) .json(&json!({ From a04c114eb346d9aff79f26a68c7a6adaf7516420 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 17 Sep 2026 14:48:28 -0700 Subject: [PATCH 4/7] test(rewards): pin reward-distributor reads as not routable over /ws (dig_ecosystem#3351) The ws_dispatch fall-through routes an unrecognized method to WalletBackend::dispatch, whose match has no dig.* arm. Pins both dig.getRewardDistributor and dig.listRewardDistributorCommitments as unroutable over /ws so a future wallet-backend or ws_dispatch change cannot silently open a second, WS-reachable path to the reward-chain reads without the tier decision being revisited. Co-Authored-By: Claude Sonnet 5 --- crates/dig-node-service/tests/server.rs | 53 +++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/dig-node-service/tests/server.rs b/crates/dig-node-service/tests/server.rs index ce201f5f..ee99e2b4 100644 --- a/crates/dig-node-service/tests/server.rs +++ b/crates/dig-node-service/tests/server.rs @@ -1535,6 +1535,59 @@ 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:?}" + ); + let is_unauthorized = resp + .pointer("/error/code") + .is_some_and(|c| c == &json!("UNAUTHORIZED")); + 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 From 5efd8a9c1a5e4c60a659f71744498b2cc4db10df Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 17 Sep 2026 19:17:34 -0700 Subject: [PATCH 5/7] test(rewards): WS pin rejects the numeric Unauthorized code too (dig_ecosystem#3351) `ws_err` renders `error.code` as `ErrorCode::code()` (-32030), not the string name, so comparing only against "UNAUTHORIZED" could never fire for a gate added through `ws_err`. Reject both spellings. Refs DIG-Network/dig_ecosystem#3351 Co-Authored-By: Claude Haiku 4.5 --- crates/dig-node-service/tests/server.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/dig-node-service/tests/server.rs b/crates/dig-node-service/tests/server.rs index ee99e2b4..92b63f44 100644 --- a/crates/dig-node-service/tests/server.rs +++ b/crates/dig-node-service/tests/server.rs @@ -1577,9 +1577,12 @@ async fn reward_distributor_reads_are_not_routable_over_ws() { 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")); + .is_some_and(|c| c == &json!("UNAUTHORIZED") || c == &json!(-32030)); assert!( !is_unauthorized, "{method} over WS must fail as unknown-method, not UNAUTHORIZED -- \ From 2791bf43f0d3be862c5a943162a4748f4968701c Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 17 Sep 2026 19:31:36 -0700 Subject: [PATCH 6/7] docs(rewards): claim only the transport the pins exercise; point the prover-status tier at #3352 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The getRewardDistributor comment asserted that anonymous REMOTE callers are answered on a DIG_NODE_ALLOW_REMOTE=1 bind -- a path no test reaches (both pins post to 127.0.0.1), so an origin-scoped gate could falsify it while every test stayed green. State the tested fact and its SPEC §7.2 reason instead. The neighbouring getRewardProverStatus comment keeps its loopback-admin wording and now names dig_ecosystem#3352 as the ticket that decides that node-local read's token tier, so the two blocks stop reading as a contradiction. Refs DIG-Network/dig_ecosystem#3351 Co-Authored-By: Claude Haiku 4.5 --- crates/dig-node-core/src/seams/dig_rpc/dispatch.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 908ccfb7..0dd68e25 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 @@ -1002,9 +1003,9 @@ impl RpcDispatch for Node { // 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 /` — anonymous - // remote callers included on a node bound with `DIG_NODE_ALLOW_REMOTE=1` — for the same - // reason `control.wallet.balance` is open (#1851, `control::is_open_control_read`). + // the caller's `launcher_id`, answered to any caller that reaches `POST /` with no + // token — the disclosure rule SPEC §7.2 applies to every non-`control.*` method, the + // same reason `control.wallet.balance` is open (#1851, `control::is_open_control_read`). // 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 From 262551c68821ae1729285a149d284c6ea4fdaf0f Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 17 Sep 2026 19:37:56 -0700 Subject: [PATCH 7/7] =?UTF-8?q?docs(rewards):=20cite=20the=20rule=20SPEC?= =?UTF-8?q?=20=C2=A77.2=20actually=20states,=20not=20a=20"disclosure=20rul?= =?UTF-8?q?e"=20it=20does=20not=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous wording attributed a "disclosure rule" for every non-control.* method to SPEC §7.2. §7.2 states two things: the `control.` prefix is the only token gate (`is_control_method`; §5.5 `requires_auth: false` for the rest), and WHO-NAMES-THE-SUBJECT is the membership test for the open `control.wallet.*` reads. Say exactly that, and say why this read passes the subject test: the launcher_id arrives in the request, so the answer discloses no node-local association. Refs DIG-Network/dig_ecosystem#3351 Co-Authored-By: Claude Haiku 4.5 --- crates/dig-node-core/src/seams/dig_rpc/dispatch.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 0dd68e25..6d6e1ded 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -1003,9 +1003,12 @@ impl RpcDispatch for Node { // 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 — the disclosure rule SPEC §7.2 applies to every non-`control.*` method, the - // same reason `control.wallet.balance` is open (#1851, `control::is_open_control_read`). + // 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