Skip to content

fix(rpc): gate node-local reward reads, rate-bound open chain reads (#3352 #3355) - #621

Merged
MichaelTaylor3d merged 4 commits into
developfrom
fix/3352-reward-read-tier
Sep 24, 2026
Merged

MichaelTaylor3d merged 4 commits into
developfrom
fix/3352-reward-read-tier

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Token-gates the three NODE-LOCAL reward reads (dig.getRewardProverStatus, dig.listRewardDistributors, dig.getPayeeRewardClaimStatus) at the POST / 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-supplied launcher_id) with a second MissRateLimiter (AppState.reward_ingress, burst 32 / refill 8 per s), refusing -32034 REWARD_INGRESS_LIMITED at ingress before any chain work; loopback is exempt. The catalogue's requires_auth becomes the compiled statement of the HTTP token gate, pinned by an equality test against server::requires_http_token in tests/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_ingress limiter, unit tests), crates/dig-node-service/src/meta.rs (five reward MethodInfos, cache trio + chat pair flipped to requires_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 -32034 row).

Provenance

Salvaged from a lane that capped at 470a5c60 (anchor + partial), finished and rebased onto develop 2ca67dd8 by a second lane that died (rate limit) after pushing cc87ac7a with 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

@MichaelTaylor3d
MichaelTaylor3d force-pushed the fix/3352-reward-read-tier branch 2 times, most recently from 772ed78 to 90b8702 Compare September 24, 2026 18:18
@MichaelTaylor3d MichaelTaylor3d changed the title fix(rpc): token-gate node-local reward reads, rate-bound open chain reads (#3352 #3355) fix(rpc): gate node-local reward reads, rate-bound open chain reads (#3352 #3355) Sep 24, 2026
MichaelTaylor3d and others added 4 commits September 24, 2026 11:57
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>
@MichaelTaylor3d
MichaelTaylor3d force-pushed the fix/3352-reward-read-tier branch from cef7f2d to cc87ac7 Compare September 24, 2026 19:08

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.rs doc at GetRewardProverStatus/listRewardDistributors/getPayeeRewardClaimStatus now says token-GATED on POST / (not "loopback admin / in-process FFI ONLY"), names server.rs::is_node_local_reward_read.
  • GetRewardDistributor's doc names the -32034 bound; 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, ListRewardDistributorCommitments arm): this handler's doc comment still just says "same guard shape as GetRewardDistributor above... OPEN on POST /" without itself naming the -32034 bound the way GetRewardDistributor'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 (L1080), served-class local row, §7.2 (L1482), and the -32034 row (L~3332) all match the decider's verbatim text I have from the ticket comments.
  • -32034 maps to exactly one ErrorCode variant (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/UNAUTHORIZED with 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-loopback RequestorId).
  • 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 gate if would make node_local_reward_reads_require_the_control_token fail (calls would 200 with real payload instead of -32030) — the test is load-bearing, not decorative. Reverting the reward_ingress separate-bucket wiring back to sharing control_ingress would not be caught by any test here (no test proves the two buckets are actually distinct instances rather than the same Arc reused) — 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

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-decider — adversarial third leg: PASS

Head SHA read: cc87ac7a9820e292d4f758074737f44355752a1b (4 ahead / 0 behind 2ca67dd8). Worktree C:/worktrees/dig-node-3352, clean apart from untracked .claude/.

M1 — EXECUTED (the gate is load-bearing)

Mutation: crates/dig-node-service/src/server.rs:1298 || is_node_local_reward_read(&method) → || false. Warm target, cargo test -p dig-node-service --test server node_local_reward_reads_require_the_control_token (1m32s rebuild, 4.99s run):

test node_local_reward_reads_require_the_control_token ... FAILED
thread 'node_local_reward_reads_require_the_control_token' panicked at crates\dig-node-service\tests\server.rs:4022:9:
assertion `left == right` failed: dig.getRewardProverStatus without a token must be -32030, got Object {"jsonrpc": String("2.0"), "id": Number(9), "result": Object {"statuses": Object {"outcome": String("consulted"), "observed_at": Number(1790284716), "items": Array []}}}
  left: Null
 right: Number(-32030)
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 77 filtered out

Restored with git checkout -- crates/dig-node-service/src/server.rs; git status --short afterwards → ?? .claude/ only. Cargo/rustc processes reaped.

Red for the RIGHT reason: the failure payload is the leaked /result/statuses body itself, not a compile error, port bind or mock-upstream failure.

A1 — does the fixture reach the defect?

Yes. The mutated run above shows the tokenless call answering result.statuses.outcome = "consulted" — the exact field the test guards — so the ephemeral node does NOT refuse these three reads for another reason. The positive branch (tests/server.rs:4058-4098) asserts handler-specific shapes under the master token (statuses.outcome == consulted, funded.outcome == not_consulted + claimable.outcome == not_consulted, subject == "payee"), so dispatch — not a stub — answers the authorized calls, and the negative branch is meaningful.

A2 — M2–M4 judged from the diff

  • M2 (add dig.getRewardDistributor to is_node_local_reward_read): red three ways — reward_distributor_reads_answer_on_post_slash_without_a_token (tests/server.rs:3953 assert_ne -32030), the drift-guard equality (openrpc_drift_guard.rs:282 — catalogue meta.rs:644 carries requires_auth: false, predicate would say true), and the unit pin server.rs:3697 assert!(!is_node_local_reward_read("dig.getRewardDistributor")). Producible.
  • M3 (key the limiter on launcher_id inside rpc()): no test fails. open_reward_chain_reads_are_rate_bounded_per_source (server.rs:3643) drives control_ingress_admits(&limiter, &requestor) directly and cannot observe a key swap at the call site in rpc() (server.rs:1344); the integration test at tests/server.rs:4112 is loopback-only. M3 is covered only at the predicate level. Non-blocking (the call site is a two-line if whose argument is the requestor from requestor_for(&peer_addr), server.rs:1034), but this is the one mutation the suite would let through; noted for the record rather than as a change request.
  • M4 (flip cache.listCached back to requires_auth: false): red at openrpc_drift_guard.rs:282 AND the replacement equality in meta.rs:1627. Producible.

A3 — commit cc87ac7a

Replacement, not deletion: meta.rs:1618-1631 swaps the blanket !requires_auth for assert_eq!(m.requires_auth, crate::server::requires_http_token(m.name)) — a strictly stronger claim (it also pins the chat pair and cache trio, which the old assertion would now have contradicted). The same equality is independently pinned in openrpc_drift_guard.rs:275-285.

A4 — loopback exemption pin

Present: open_reward_chain_reads_never_limit_the_loopback_operator (tests/server.rs:4112) drives 40 dig.getRewardDistributor calls over real loopback TCP and asserts none is REWARD_INGRESS_LIMITED; requestor_for unchanged, Local derived from the peer socket (server.rs:957, unit pin 3582-3591).

A5 — beyond the diff

  • WS bypass of the new gate is already closed and pinned: reward_distributor_reads_are_not_routable_over_ws (tests/server.rs:1551) sends all five reward methods over /ws and ws_dispatch (server.rs:1653) only routes control.* and the wallet backend — no dig.* arm.
  • The -32030 error message string (server.rs:1318-1327) now enumerates six methods; fine, but any future addition to is_node_local_reward_read must edit this prose by hand — nothing pins it. Cosmetic.

Required CI contexts on cc87ac7a (re-read at verdict time)

Analyze (actions) SUCCESS · Analyze (javascript-typescript) SUCCESS · Analyze (rust) SUCCESS · Clippy SUCCESS · CodeQL SUCCESS · Lint commit messages SUCCESS · Release-script tests SUCCESS · Rustfmt SUCCESS · Test + coverage SUCCESS · build .deb linux-amd64/arm64 SUCCESS · build .msi SUCCESS · build .pkg SUCCESS · Attach packages SKIPPED (release-only). None UNRUN.

Verdict: PASS — mergeable from this leg. The one residual (M3 unobservable at the rpc() call site) is recorded above and does not block.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — adversarial audit, PR #621 @ cc87ac7a9820e292d4f758074737f44355752a1b

Verdict: PASS

S1 — Limiter key

requestor_for (server.rs:957-963) is unchanged by this PR: RequestorId::Local iff config::is_loopback_addr(&peer_addr.ip()) (the actual TCP peer socket address axum hands the handler), else Anonymous(peer_addr.ip().to_string()). Neither reward_ingress nor control_ingress ever key on a header or a request field, so X-Forwarded-For/Host cannot forge Local — there is no proxy trust in this path (a reverse proxy in front of DIG_NODE_ALLOW_REMOTE=1 would need to be the thing terminating TCP, which is a deployment concern, not this diff). is_open_reward_chain_read bounds on RequestorId, never launcher_id; open_reward_chain_reads_are_rate_bounded_per_source (server.rs unit test) explicitly proves rotating launcher_id from the same source does not revive budget.

S2 — Placement

Both new checks (token gate server.rs:~1295-1330, reward-ingress bound ~1343-1358) run inside rpc() strictly before dispatch to Node::handle_rpc_as at server.rs:1395-1398 — a refused call never reaches the dispatcher, never touches RewardsChainPort. Checked ws_dispatch (server.rs:1653-1710) and ws_handle_text: neither routes any dig.getReward*/dig.listReward* method — control/pairing/chat arms don't match, and the fallthrough goes to state.wallet.dispatch, which doesn't implement these methods. This is asserted directly by the new test reward_distributor_reads_are_not_routable_over_ws (tests/server.rs:1551) driving all five reward methods over /ws and asserting none gets a real result. No batch/array JSON-RPC form or method-alias path found that bypasses is_open_reward_chain_read/is_node_local_reward_read — both are exact-string matches! on the JSON-RPC method field the same dispatcher consumes.

S3 — Gate correctness

Master compare uses control::ct_eq (constant-time, full-length byte scan, control.rs:692) — unchanged. The direct-compare block (master_ok/paired_ok, server.rs:1306-1311) is the SAME pattern the pre-existing cache.* trio already used (not introduced by this PR); presented_token filters blank tokens to None before this runs, so an empty configured state.control_token cannot be matched by an absent/blank presented token — fails closed in practice. params._control_token and the header are both read via the same presented_token helper for every gated method, reward reads included — identical handling. Rejected calls get a generic -32030 UNAUTHORIZED with no reward-state leak (no statuses/funded/subject fields, no differential error text per method).

S4 — Bucket isolation

reward_ingress is a distinct Arc<MissRateLimiter> field on AppState (server.rs:128), constructed separately in build_state (server.rs:621) from control_ingress. Burst 32.0 / refill 8.0/s (REWARD_INGRESS_BURST/REWARD_INGRESS_REFILL_PER_SEC, server.rs:161-165) matches the decided #3355 shape and CONTROL_INGRESS_BURST's build-time assert!(>= 12.0) sibling reasoning. open_reward_chain_reads_never_limit_the_loopback_operator (tests/server.rs:4112) proves the loopback exemption holds even past 40 calls; unit tests prove per-source isolation (source_a/source_b independent buckets).

S5 — Catalogue truth

Two independent equality pins, both replacing (not deleting) a prior assertion:

  • meta.rs internal test (mod tests, was !m.requires_auth for every non-control method, now assert_eq!(m.requires_auth, crate::server::requires_http_token(m.name))).
  • tests/openrpc_drift_guard.rs (served_classes_are_well_formed), same replacement against dig_node_service::server::requires_http_token.
    requires_http_token (server.rs:1532-1538) is exactly the union checked at the gate call site (server.rs:1295-1298) — cache trio ∨ chat pair ∨ is_node_local_reward_read. No assertion was deleted without a strictly-stronger replacement.

S6 — Regression on open reads

dig.getRewardDistributor/dig.listRewardDistributorCommitments keep requires_auth: false in meta.rs (confirmed in diff) and are absent from is_node_local_reward_read; only the new ingress bound was added, proven by reward_distributor_reads_answer_on_post_slash_without_a_token (tests/server.rs:3929). No other method's requires_auth/gate predicate changed in this diff.

CI (named contexts, re-read at cc87ac7a)

Lint commit messages — SUCCESS · Rustfmt — SUCCESS · Clippy — SUCCESS · Test + coverage — SUCCESS · Release-script tests — SUCCESS. (All five required contexts attached and green; no UNRUN.)

Findings

None LIVE. No defence-in-depth tickets raised — the one thing that looked worth a second glance (direct ct_eq compare bypassing is_authorized's explicit expected.is_empty() guard) is pre-existing behaviour reused unchanged from the cache.* gate, and is not reachable with an empty presented token because presented_token filters blanks before this code runs.

Scope audited: SPEC.md, crates/dig-node-core/src/seams/dig_rpc/dispatch.rs, crates/dig-node-service/src/{server.rs,meta.rs}, crates/dig-node-service/tests/{server.rs,openrpc_drift_guard.rs} at cc87ac7a9820e292d4f758074737f44355752a1b, plus crates/dig-node-service/src/control.rs (ct_eq/is_authorized/presented_token) read for context, unchanged by this diff.

Not covered: the FFI in-process reward-call path (out of scope — HTTP-only diff); dig-rewards-coin's own on-chain verification (this PR touches only the node-service gate, not RewardsChainPort's implementation); load-bearing correctness of MissRateLimiter's token-bucket math itself (reused, not modified by this PR).

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 24, 2026 21:23
@MichaelTaylor3d
MichaelTaylor3d merged commit af8b010 into develop Sep 24, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the fix/3352-reward-read-tier branch September 24, 2026 21:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant