Skip to content
28 changes: 20 additions & 8 deletions crates/dig-node-core/src/seams/dig_rpc/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(&params) {
Expand Down Expand Up @@ -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).
Expand Down
109 changes: 109 additions & 0 deletions crates/dig-node-service/tests/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}"
);
}
}
Loading