fix(rpc): gate node-local reward reads, rate-bound open chain reads (#3352 #3355) - #621
Conversation
772ed78 to
90b8702
Compare
Salvage anchor for the dig_ecosystem#3352 / #3351 / #3355 read-surface unit. Refs DIG-Network/dig_ecosystem#3352 DIG-Network/dig_ecosystem#3355 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tests (#3352 #3355) Salvaged uncommitted work of the prior implementer lane (died ~14:27Z). DOES NOT COMPILE yet: references AppState.reward_ingress and ErrorCode::RewardIngressLimited, which the next unit adds (meta.rs, AppState init, drift guard, dispatch.rs docs, SPEC, CHANGELOG). Refs dig_ecosystem#3352 dig_ecosystem#3355 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Completes the salvaged partial: wires the missing AppState.reward_ingress bucket, catalogues the -32034 REWARD_INGRESS_LIMITED error code, adds the five reward methods to the meta.rs discovery catalogue (the three node-local reads requires_auth: true, the two open chain-keyed reads requires_auth: false), flips the cache landing trio + chat pair to requires_auth: true to match the HTTP gate they already sat behind, and pins the drift guard's served_classes_are_well_formed assertion to requires_http_token by equality instead of a blanket "never auth" rule. Extends dispatch.rs's reward-handler docs to name the actual HTTP tier each carries and the -32034 bound on the two open chain reads. Updates SPEC.md §5.5/§7.2/§10 per dig_ecosystem#3352's decider text. Fixes the salvaged node_local_reward_reads_require_the_control_token test, which asserted a "consulted" funded-distributor read on a node with no funded registry written -- SPEC §12.5's reassuring-zero rule says that answer must be not_consulted; extends the WS-non-routability pin to all five reward method names. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
control_methods_require_auth_and_read_methods_do_not blanket-asserted !requires_auth for anything without a control. prefix, which is stale now that the cache trio, chat pair and three node-local reward reads are gated on POST / without carrying that prefix (dig_ecosystem#3352, SPEC §5.5). Assert catalogue requires_auth equals the compiled requires_http_token predicate instead, matching the same fix already made in openrpc_drift_guard::served_classes_are_well_formed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cef7f2d to
cc87ac7
Compare
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
Verdict: PASS
Head SHA: cc87ac7a9820e292d4f758074737f44355752a1b
Independent read of the diff against dig_ecosystem#3352 (loop-decider tier decision) and #3355 (loop-decider bound shape). No build/test run per brief (adversarial leg owns M1); judged from diff + named CI contexts.
Q1 — Gate fold: YES
is_node_local_reward_read is folded into the SAME if as the existing cache-trio block (server.rs, one token-extraction site: header_tok → control::presented_token → master_ok/paired_ok), not a second gate. Same master-OR-paired policy: control::ct_eq constant-time compare against state.control_token, OR pairing::is_paired_token. The refusal message lists all six gated methods and explains each generically ("none is a public read") — honest, not copy-pasted misleadingly.
Q2 — Bound: YES
reward_ingress is a separate AppState field / separate MissRateLimiter::new(REWARD_INGRESS_BURST=32.0, REWARD_INGRESS_REFILL_PER_SEC=8.0), built beside (not sharing) control_ingress. Keyed via the existing requestor_for/control_ingress_admits(&state.reward_ingress, &requestor) — RequestorId, never launcher_id. Checked in the rpc handler before dispatch, before any chain work. is_open_reward_chain_read is exactly {dig.getRewardDistributor, dig.listRewardDistributorCommitments} (unit-pinned). Token holders calling the two OPEN reads are still bounded (correct — these are unauthenticated-by-design reads per #3351, so the ingress bound is the only accountability they get); loopback (RequestorId::Local) is exempt via the reused control_ingress_admits.
Q3 — Catalogue equality: YES
tests/openrpc_drift_guard.rs::served_classes_are_well_formed now does assert_eq!(m.requires_auth, server::requires_http_token(m.name)) for local|passthrough|shell — a real equality against the compiled predicate, not a second allowlist. The mirrored unit test inside meta.rs::mod tests was updated the same way (not left as a stale blanket assertion) — the "stop meta test asserting every non-control method is open" commit replaces the assertion, it does not delete it with nothing in its place. Five reward MethodInfos added, served: "local", three requires_auth: true / two false as specified. Cache trio (cache.fetchAndCache/pushCapsule/listCached) + chat.send/chat.poll flipped to true. cache.removeCached/cache.getConfig/cache.clear/cache.stats correctly left untouched (never in the token-gated set).
Q4 — Docs: YES, one non-blocking nit
dispatch.rsdoc atGetRewardProverStatus/listRewardDistributors/getPayeeRewardClaimStatusnow says token-GATED onPOST /(not "loopback admin / in-process FFI ONLY"), namesserver.rs::is_node_local_reward_read.GetRewardDistributor's doc names the-32034bound; the #3351 "chain-derived state ONLY" sentence is preserved and still true.- Nit (non-blocking,
crates/dig-node-core/src/seams/dig_rpc/dispatch.rs~L1058,ListRewardDistributorCommitmentsarm): this handler's doc comment still just says "same guard shape asGetRewardDistributorabove... OPEN onPOST /" without itself naming the-32034bound the wayGetRewardDistributor's own comment now does. It's still accurate (defers to "above"), just asymmetric — the one open read that got the explicit rate-bound callout is not the one whose comment a reader lands on first when reading top-to-bottom. Not a correctness defect; leaving it open as a follow-up note rather than a blocking thread since the referenced doc is itself correct and up to date. - SPEC.md §5.5 (L
1080), served-class1482), and thelocalrow, §7.2 (L-32034row (L~3332) all match the decider's verbatim text I have from the ticket comments. -32034maps to exactly oneErrorCodevariant (checked — no collision).- CHANGELOG.md is untouched (not in the 6-file diff) — non-blocking per your note; flagging so the orchestrator can decide whether to require it before squash.
Q5 — Tests: YES
node_local_reward_reads_require_the_control_token: three methods × {none, master, paired}, asserts-32030/UNAUTHORIZEDwith no/result/{statuses,funded,subject}leak on rejection, and both master and paired tokens clear the gate + reach real handler shapes.reward_distributor_reads_are_not_routable_over_ws: extended to all five reward method names.open_reward_chain_reads_are_rate_bounded_per_source: 32-burst admitted, 33rd refused, a second source untouched, first source stays refused after — drives the predicate/limiter pair directly (documented reason: a real HTTP test can't drive a non-loopbackRequestorId).reward_distributor_reads_answer_on_post_slash_without_a_token(existing #3351 pin) is untouched and still present.open_reward_chain_reads_never_limit_the_loopback_operator: real HTTP test, 40 calls over the loopback client, never-32034.- Vacuity check: reverting just the
|| is_node_local_reward_read(&method)addition to the gateifwould makenode_local_reward_reads_require_the_control_tokenfail (calls would 200 with real payload instead of-32030) — the test is load-bearing, not decorative. Reverting thereward_ingressseparate-bucket wiring back to sharingcontrol_ingresswould not be caught by any test here (no test proves the two buckets are actually distinct instances rather than the sameArcreused) — minor coverage gap, not blocking given the field/constructor diff is a 3-line mechanical add and the doc comment explicitly states the reason for separateness.
Q6 — Readability / dead code / commits: YES
No leftover MUTATION probes (only pre-existing unrelated // WALLET plane... MUTATION methods comment matched, not a probe). All 4 commit headers are conventional (chore:, fix(rpc): ×3).
Required contexts (by name, re-read at cc87ac7a)
Lint commit messages: SUCCESS · Rustfmt: SUCCESS · Clippy: SUCCESS · Test + coverage: SUCCESS · Release-script tests: SUCCESS. All 5 required contexts green; also CodeQL/Analyze all SUCCESS.
What I did not run
No build, no test execution (per brief) — judged from diff text and CI-by-name only. Did not verify the loop-decider ticket comments' verbatim text byte-for-byte beyond spot-checking the SPEC clauses named in the brief.
🤖 Generated with Claude Code
loop-decider — adversarial third leg: PASSHead SHA read: M1 — EXECUTED (the gate is load-bearing)Mutation: Restored with Red for the RIGHT reason: the failure payload is the leaked A1 — does the fixture reach the defect?Yes. The mutated run above shows the tokenless call answering A2 — M2–M4 judged from the diff
A3 — commit
|
loop-security — adversarial audit, PR #621 @
|
Summary
Token-gates the three NODE-LOCAL reward reads (
dig.getRewardProverStatus,dig.listRewardDistributors,dig.getPayeeRewardClaimStatus) at thePOST /ingress with the same master-or-paired-token policy as the cache trio (-32030 UNAUTHORIZED), and rate-bounds the two OPEN chain-keyed reads (dig.getRewardDistributor,dig.listRewardDistributorCommitments) per source (RequestorId, never the caller-suppliedlauncher_id) with a secondMissRateLimiter(AppState.reward_ingress, burst 32 / refill 8 per s), refusing-32034 REWARD_INGRESS_LIMITEDat ingress before any chain work; loopback is exempt. The catalogue'srequires_authbecomes the compiled statement of the HTTP token gate, pinned by an equality test againstserver::requires_http_tokenintests/openrpc_drift_guard.rs(both drift directions go red).Tier decision: https://github.com/DIG-Network/dig_ecosystem/issues/3352 (loop-decider, head 44230a7). Bound shape: https://github.com/DIG-Network/dig_ecosystem/issues/3355.
Refs DIG-Network/dig_ecosystem#3352 DIG-Network/dig_ecosystem#3351 DIG-Network/dig_ecosystem#3355
Files
crates/dig-node-service/src/server.rs(gate fold,is_node_local_reward_read,is_open_reward_chain_read,requires_http_token,reward_ingresslimiter, unit tests),crates/dig-node-service/src/meta.rs(five rewardMethodInfos, cache trio + chat pair flipped torequires_auth: true,ErrorCode::RewardIngressLimited),crates/dig-node-core/src/seams/dig_rpc/dispatch.rs(docs),crates/dig-node-service/tests/server.rs,crates/dig-node-service/tests/openrpc_drift_guard.rs(equality arm),SPEC.md(§5.5, §7.2, §10-32034row).Provenance
Salvaged from a lane that capped at
470a5c60(anchor + partial), finished and rebased ontodevelop2ca67dd8by a second lane that died (rate limit) after pushingcc87ac7awith all five required contexts green but before writing this body. Mutation proofs were therefore never posted by the implementer; the adversarial gate leg executes M1 on this head and posts the result as a PR comment, which is the executed proof of record.🤖 Generated with Claude Code