Skip to content

fix(rewards): reward-distributor reads are OPEN on POST /, doc + pin (dig_ecosystem#3351) - #619

Merged
MichaelTaylor3d merged 7 commits into
developfrom
fix/3351-reward-read-tier
Sep 18, 2026
Merged

MichaelTaylor3d merged 7 commits into
developfrom
fix/3351-reward-read-tier

Conversation

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor

Refs DIG-Network/dig_ecosystem#3351

Decision (option 2 of the ticket)

Tier::Control in dig-rpc-protocol's sense means "loopback / in-process
dispatch only, never reachable over the mTLS peer surface" -- it does NOT
mean token-gated. rpc.discover and cache.getConfig are also
Tier::Control and are open to any caller of POST /. Token gating in
this repo is a separate axis: control.* (minus
control::is_open_control_read), wallet_authz::requires_authorization,
the cache trio (server.rs:1262), and chat.send/poll.

dig.getRewardDistributor and dig.listRewardDistributorCommitments
disclose only public on-chain state keyed by a caller-supplied
launcher_id -- the same class control::is_open_control_read already
treats as open (#1851, "reads of PUBLIC chain state ... are exposed like
the other reads rather than behind the control-token gate"). No live RPC
caller depends on either being gated: dig-app's only RewardsClient impl
is a test fake, and the peer claim loop uses the in-process
ClaimChainPort, never RPC. Contract + precedent -> the enforced tier is
OPEN; the doc that read "CONTROL plane: loopback admin / in-process FFI
ONLY" was the thing that was wrong, since POST / answers anonymous
remote callers on a node bound with DIG_NODE_ALLOW_REMOTE=1.

Changes

  • dispatch.rs: corrected the comment on both handlers to state the
    enforced tier accurately, pointing at the new pinning test.
  • port.rs: no tier claim found there at this tip (grep below) -- no
    edit made, reported rather than invented.
  • tests/server.rs: new integration test
    reward_distributor_reads_answer_on_post_slash_without_a_token --
    POSTs both methods with no control token and asserts the request
    passed every ingress gate (not -32030/UNAUTHORIZED) and reached the
    reward handler (REWARD_CHAIN_UNAVAILABLE, not a passthrough or
    method-not-found stub).
  • WS parity unit test (brief's Unit 4): not attempted -- the unit-test
    module in server.rs never constructs an AppState and ws_dispatch
    is a private fn unreachable from tests/server.rs; per the brief, no
    new fixture was built for this.

crates/dig-node-core/tests/reward_methods_tier_guard.rs is unchanged --
it polices the peer-surface half (Tier::Control + not peer-reachable),
which this PR does not touch.

Zero behaviour change

No edits to any code path in server.rs or dispatch.rs outside
comments; the new test is additive.

🤖 Generated with Claude Code

MichaelTaylor3d and others added 2 commits September 17, 2026 11:03
…t 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
@MichaelTaylor3d MichaelTaylor3d changed the title fix(rewards): reward-distributor reads are OPEN on POST / -- say so, and pin it (dig_ecosystem#3351) fix(rewards): reward-distributor reads are OPEN on POST /: say so, and pin it (dig_ecosystem#3351) Sep 17, 2026
MichaelTaylor3d and others added 2 commits September 17, 2026 14:09
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…(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 <noreply@anthropic.com>
@MichaelTaylor3d MichaelTaylor3d changed the title fix(rewards): reward-distributor reads are OPEN on POST /: say so, and pin it (dig_ecosystem#3351) fix(rewards): say reward-distributor reads are OPEN on POST /, and pin it (dig_ecosystem#3351) Sep 18, 2026
@MichaelTaylor3d MichaelTaylor3d changed the title fix(rewards): say reward-distributor reads are OPEN on POST /, and pin it (dig_ecosystem#3351) fix(rewards): reward-distributor reads are OPEN on POST /, doc + pin (dig_ecosystem#3351) Sep 18, 2026
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Mutation proof for the two guards this PR pins (head a04c114e, run locally in D:/worktrees/dig-node-3351, each mutation reverted, tree clean after)

M1 — "is the open read silently demoted to token-gated?" Mutation: in crates/dig-node-service/src/server.rs, the cache-trio gate condition became if method == "cache.fetchAndCache" || method == "dig.getRewardDistributor".
Result: reward_distributor_reads_answer_on_post_slash_without_a_token RED at tests/server.rs:3944assertion left != right failed: dig.getRewardDistributor must not be Unauthorized when no token is presented (left: -32030, right: -32030; the response carried data.code: "UNAUTHORIZED", origin: "shell"). Exit 101.

M2 — "does dispatch still reach the reward handler, rather than a stub or relay?" Mutation: in rpc() just before let original = req.clone();, an early return (StatusCode::OK, Json(rpc_error(id, ErrorCode::MethodNotFound, "mutated"))) for dig.listRewardDistributorCommitments.
Result: same test RED at tests/server.rs:3954dig.listRewardDistributorCommitments must reach the reward handler …: left: "METHOD_NOT_FOUND", right: "REWARD_CHAIN_UNAVAILABLE". Exit 101.

Unmutated, both new tests PASS in CI on this head (Test + coverage run 35278713056: 2534/3435 and 2535/3435). The peer-surface half of the tier is unchanged and still pinned by dig-node-core/tests/reward_methods_tier_guard.rs.

Found while auditing the WS pin: ws_err emits error.code as a NUMBER (code.code(), i.e. -32030), while reward_distributor_reads_are_not_routable_over_ws compared /error/code to the STRING "UNAUTHORIZED" — a WS gate added via ws_err(.., ErrorCode::Unauthorized, ..) would have passed that assertion. Fixed in the follow-up commit to reject either form.

…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 <noreply@anthropic.com>

@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

Reviewed at head 5efd8a9c1a5e4c60a659f71744498b2cc4db10df (confirmed current via gh pr view --json headRefOid; matches the brief's SHA).

Q1 — Newly-false neighbour claims

Checked every sentence in crates/dig-node-core/src/seams/dig_rpc/dispatch.rs L995-1062, kept and new:

  • control::is_open_control_read exists (crates/dig-node-service/src/control.rs:149) and its doc explicitly documents control.wallet.balance as an open read gated by the same predicate. The new comment uses it as an analogy ("for the same reason ... is open"), not a claim that reward reads are literally added to that function's match arm (they aren't — different namespace, different dispatch path via RpcDispatch::dispatch, not control::dispatch_control). Accurate as written.
  • reward_distributor_reads_answer_on_post_slash_without_a_token exists verbatim in crates/dig-node-service/tests/server.rs:3931. Name matches exactly.
  • "same guard shape as GetRewardDistributor above" for ListRewardDistributorCommitments — both handlers use the identical reward_chain_port_absent_response fallback and same Tier::Control/open-read framing; still true.
  • No sentence found that claims behavior the code doesn't have.

Q2 — Test honesty

  • Verified ErrorCode::Unauthorized.code() == -32030 and .name() == "UNAUTHORIZED" (dig-rpc-protocol meta, asserted at its own test site, line ~1571-1572).
  • Verified REWARD_CHAIN_UNAVAILABLE is emitted only from reward_chain_port_absent_response (dispatch.rs:116-121), called from both handlers' let Some(port) = … else arms (dispatch.rs:1020, 1063) — reaching it does prove the reward handler ran past dispatch/param-parsing, not a passthrough or method-not-found stub.
  • WS test: confirmed ws_dispatch (server.rs:1549) has no dig.* arm — control::is_control_method only matches control.*, is_gated_chat_method only chat.send/chat.poll, wallet_authz::requires_authorization only matches retired (wallet./auth. prefixes), gated mutations, or master-tier aliases — none match dig.getRewardDistributor/dig.listRewardDistributorCommitments. Falls through to state.wallet.dispatch()WalletBackend::dispatch_inner (dig-wallet/sage/rpc.rs), whose match has no dig.* arm and returns Error::not_found on the other => branch, surfaced as {"ok":false,"error":{"code":<numeric HTTP status>,...}} — never the string "UNAUTHORIZED" nor -32030. The test's dual-spelling check is not vacuous: mutation proof (PR comment 5724073551) shows the author caught and fixed a real gap (the original WS test only checked the string form; a ws_err(.., ErrorCode::Unauthorized, ..)-shaped regression would have passed it) — commit 5efd8a9c is exactly that fix.
  • start_node_wallet (tests/server.rs:274) and next_ws_json (tests/server.rs:3649) exist and are used as described; the test drains one initial sync-status frame before sending, consistent with existing WS test patterns elsewhere in the file.
  • POST / test: -32030 confirmed to be ErrorCode::Unauthorized's wire code; the mutation proof (M1: cache-trio gate condition mutated to also catch dig.getRewardDistributor) reproduces the exact assertion failure at tests/server.rs:3944, and M2 (early MethodNotFound stub) fails at line 3954 — both proofs are RED on the named lines with the fix reverted, satisfying the test-vacuity property for "would this test catch a silent re-gate" and "would this test catch a stub/relay answering instead of the real handler."

Q3 — rustdoc / doc lint

No new [...]-bracket intra-doc links added — all references in the new/edited comments use plain backticks (`control::is_open_control_read`, `reward_distributor_reads_answer_on_post_slash_without_a_token`, etc.), which rustdoc does not attempt to resolve as links. No cargo doc -D warnings regression risk. Clippy and Rustfmt CI checks both pass on this head.

Q4 — Commit messages

All 5 commits checked (git log origin/develop..5efd8a9c): types are docs, test (x3), style — all in the allowed set. Header lengths: 82, 74, 47, 89, 84 chars — all ≤ 100. Lint commit messages CI check passes.

Q5 — No server.rs code-path change

git diff origin/develop..5efd8a9c -- crates/dig-node-service/src/server.rs is empty. Confirmed zero production code-path change; the diff is doc-only in dispatch.rs plus two new tests in tests/server.rs.

Not run

Did not run the test suite myself (read-only worktree per brief; relied on the CI Test + coverage run cited in the mutation-proof comment, run 35278713056, 2534-2535/3435, plus the current run's other green checks). Did not build.

No blocking findings. No inline threads opened — zero to drive to zero.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-decider — adversarial third leg: CHANGES-REQUIRED (option 2 stands; two doc-line fixes before merge)

Head judged: 5efd8a9c1a5e4c60a659f71744498b2cc4db10df (dig-node PR #619, fix/3351-reward-read-tier). Read-only.

Decision: Option 2 (OPEN read, never peer-reachable, not token-gated) is the correct call and is consistent with dig-node's own normative SPEC. Two lines of the PR's doc over-claim / leave the file self-contradictory; fix them, then merge. Option 1 is rejected.

Q1 — Is "Tier::Control ≠ token-gated" defensible? YES, and it is the SPEC's own rule, not a convenience.

  • dig-rpc-protocol-0.12.0/src/tier.rs:11-12,34Control = "loopback / in-process only ... NEVER reachable over the peer surface". Transport class; no token concept anywhere in the crate.
  • dig-node SPEC.md:1080-1081 (normative): "Every non-control.* method MUST have requires_auth: false; every control.* method MUST have served: "control" and requires_auth: true." dig.getRewardDistributor is non-control.* → the SPEC REQUIRES it not to be token-gated. Option 1 would contradict a normative clause; option 2 satisfies it.
  • SPEC.md §7.2:1471-1483: "Exactly the control. method prefix is gated", and the open-read membership rule is "WHO NAMES THE SUBJECT ... the answer discloses no node-to-address association." Both reads take a caller-supplied launcher_id; the real adapter (dig-node-service/src/rewards/mod.rs:102-121, build_report(source, launcher_id)) reads the chain for ANY launcher id — there is no local-registry lookup, so success/Unavailable is not an oracle for "this node funds X". The rule is satisfied.
  • The L1's rationale is right but its examples were cherry-picked: cache.fetchAndCache and chat.send/poll share Tier::Control and ARE token-gated at ingress (SPEC.md:1274-1282,1333). The honest statement is "tier decides transport; the per-method disclosure rule (§7.2) decides the token" — and under that rule these two reads are open.
  • dig-rewards-coin SPEC.md §2.6 (~L440-462): says all four are Control "because the prover surface reveals which stores the operator funds" and that listRewardDistributorCommitments "MUST NOT be promoted" to PublicRead/peer. The PR does NOT promote (tier and allowlist untouched). But §2.6's confidentiality rationale assumes Control ⇒ operator-only, which the enforced behaviour never delivered for a caller-named chain read. Follow-up (not a blocker, READ-ONLY here): one clarifying sentence in §2.6 — "Control governs transport (never peer); a caller-named public-chain read is answered without a token per dig-node SPEC §7.2." Its "argue on the record" escape clause is met by the L1's comment on #3351.

Q2 — Does the correction falsify a neighbour? YES, mildly: fix with one line.

dispatch.rs:909-910 (GetRewardProverStatus) still reads "CONTROL plane: loopback admin / in-process FFI ONLY" while L998-1011, 90 lines below, explains that the same Tier::Control idiom means "not token-gated, open on POST /". server.rs has NO ingress gate for any dig.*Reward* method at this head, so the prover-status comment is false today in exactly the way #3351 fixed. Leaving it byte-identical for #3352 is fine for the fix, but silence leaves the file asserting two meanings of one idiom. Required: append one line to the L909 comment: // Tier wording here is under review in dig_ecosystem#3352 (node-local reads); see GetRewardDistributor below for what Tier::Control does and does not mean.

Q3 — Is the caller measurement sound? Sound enough; one gap noted, needs no anonymity change.

  • control_cli.rs verb table: zero reward verbs (git grep at head) — the dign CLI cannot call them.
  • /:method (wallet_rpc, server.rs:275,1467) and /ws route unknown methods to WalletBackend::dispatch, which has no dig.* arm — pinned by the new WS test. No hidden path.
  • meta.rs (meta::methods(), the rpc.discover//openrpc.json catalogue) contains NO reward methods at all — so no machine-readable requires_auth flag contradicts the doc, but it also means these shipped methods are undiscoverable via rpc.discover. Follow-up ticket (catalogue gap; SPEC:1040 says the catalogue is the single source of truth).
  • A caller could still hide in docs.dig.net curl examples or a dynamic method string; the L1's grep was gitignore-aware across modules/. Any such caller would be anonymous by construction and is the reason NOT to demote. No change.

Q4 — A third mutation both tests miss? YES — and the doc claims it.

Mutation M3: gate the two methods only when the requestor is non-loopback (a DIG_NODE_ALLOW_REMOTE=1 remote path). Both tests run against 127.0.0.1 → stay green, while the new doc at dispatch.rs:1003-1004 states "anonymous remote callers included on a node bound with DIG_NODE_ALLOW_REMOTE=1" — a contract nothing pins. Required (in scope, one line): drop the remote-callers clause, or reword to what is tested: "answered to any caller that reaches POST / (pinned on the loopback bind; a remote-bind-only gate would be a separate, recorded decision)". Do not add a remote-bind test in this PR.
Other mutations: a gate placed after parse_launcher_id_arg for valid ids → caught (test uses a valid id, asserts REWARD_CHAIN_UNAVAILABLE); a gate on /:method → irrelevant (not routed there, WS test pins); a gate keyed on a locally-known launcher id → impossible, the adapter has no local registry.

Q5 — Irreversibility: gate now, open later? Argued both ways; NO.

  • FOR gating now: rewards-coin §2.6 and reward_methods_tier_guard.rs:17-19 adopt the asymmetry ("promotion additive, demotion breaks anonymous callers"); with zero known callers, the cost of gating is zero and opening later is additive.
  • AGAINST: (a) the ENFORCED behaviour has already shipped open (dispatch landed in #3269 and is in released dig-node); "no caller in modules/" measures the monorepo, not the world — gating now IS the demotion the asymmetry argument forbids. (b) dig-node SPEC:1080 and §7.2 REQUIRE it open; gating would need a SPEC amendment carving a third class. (c) The rationale is now on the record (#3351 comment), which is the exact condition §2.6 sets for departing from the Control-default.
  • Decision: option 2. Failure direction of the wrong call: option 1 breaks an unenumerable caller and contradicts SPEC:1080; option 2's only exposure is a public-chain read anyone with a full node already has.

Required before merge (both in crates/dig-node-core/src/seams/dig_rpc/dispatch.rs, doc-only, haiku tweak)

  1. L1003-1004: remove/reword the DIG_NODE_ALLOW_REMOTE=1 remote-callers claim (Q4).
  2. L909-910: one-line cross-reference to dig_ecosystem#3352 (Q2).
    Follow-up tickets (not blockers): rewards-coin SPEC §2.6 clarifying sentence; reward methods absent from meta::methods().

@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.

loop-security verdict: PASS

Head SHA judged: 5efd8a9c1a5e4c60a659f71744498b2cc4db10df (matches gh pr view --json headRefOid).

Scope confirmed: diff touches only (1) doc comments in crates/dig-node-core/src/seams/dig_rpc/dispatch.rs around the GetRewardDistributor / ListRewardDistributorCommitments arms, and (2) two new tests in crates/dig-node-service/tests/server.rs. No handler logic, no gate, no dispatch-table entry changed. The "zero production code-path change" claim holds.

Q1 — Is OPEN the right tier for exactly these two?

Yes for both. Read GetRewardDistributorResult / ListRewardDistributorCommitmentsResult / RewardDistributorCommitment in dig-rpc-protocol-0.12.0/src/types.rs (L1752-1889): every field (launcher_id, store_id, root, epoch_seconds, first_epoch_start, payout_threshold, fee_bps, withdrawal_share_bps, reserve_base_units, entry_count, current_distributor_epoch, last_entry_write_at, entry_set_stale, and the commitment rows' clawback_puzzle_hash/rewards_base_units/recoverable_base_units) is chain-derived. entry_set_stale's own doc comment says it explicitly: "computed at read time from the singleton's own on-chain spend history — it is not self-reported." last_entry_write_at is likewise "the most recent entry-set write on chain." observed_at is only the local wall-clock time the response was assembled, not sensitive.

Cross-checked against the actual handler (dispatch.rs, Method::GetRewardDistributor / Method::ListRewardDistributorCommitments arms): every wire field is copied 1:1 from report: DistributorReport returned by node.reward_chain_port().distributor_report(launcher_id) — no local registry, no config, no operator identity feeds either result. Nothing here originates from this node's own state. The OPEN decision is correct for exactly these two methods, and the sibling ticket #3352's three node-local reads (prover status, funded registry, payee claim status) are rightly left CONTROL/gated — GetRewardProverStatusResult and GetPayeeRewardClaimStatus are self-reported/local, visibly different in kind from these two.

Q2 — Resource cost as a DoS primitive

No per-call bound found on this path. crate::rate_limit::RequestorId is threaded through RpcDispatch::dispatch's signature but the GetRewardDistributor/ListRewardDistributorCommitments arms never call try_acquire or consult it — unlike the miss→DHT-lookup path (rate_limit.rs's own doc header scopes that limiter explicitly to dig.getContent/fetchRange/getAvailability, nothing reward-related). distributor_report is a real chain-backed read (currently Unavailable pending #3310's production adapter over dig-rewards-coin); once wired, an anonymous caller can drive one chain read per POST / call with no bucket in front of it. This is pre-existing — the code path is unchanged by this PR (comment/test-only diff) — so it does not block this PR, but it is a live gap: recommend a ticket ("bound anonymous callers on dig.getRewardDistributor/dig.listRewardDistributorCommitments the same way the miss-lookup path is bounded") rather than folding it into #3352, since #3352's three methods are a different set with different local-state costs.

Q3 — Does the new test create a false sense of a guard?

No, both new tests are load-bearing, not decorative:

  • reward_distributor_reads_answer_on_post_slash_without_a_token: asserts the response is neither -32030 nor data.code == "UNAUTHORIZED", AND asserts it reaches REWARD_CHAIN_UNAVAILABLE (proving the reward handler itself ran, not a passthrough/method-not-found stub). This can't pass under a hidden gate — a gate at the ingress would short-circuit before REWARD_CHAIN_UNAVAILABLE is producible.
  • reward_distributor_reads_are_not_routable_over_ws: traced ws_dispatch (server.rs L1549+) — dig.getRewardDistributor/dig.listRewardDistributorCommitments are not control.*, not chat, not wallet-authz-gated, so they fall through to state.wallet.dispatch(method, body) (dig-wallet/src/sage/rpc.rs's dispatch_inner), whose default arm returns Error::not_found → HTTP-shaped status (404-class), not 401. The test's numeric-vs-string dual check (-32030 or "UNAUTHORIZED") is real: a future gate wired through either ws_err's numeric ErrorCode::code() or ws_from_jsonrpc's string path would trip it. Could not construct a scenario where a future WS-gated-and-routable change would pass both tests simultaneously — the openness test is HTTP-scoped and the WS test asserts ok:false specifically, so a WS path that became both routable and gated would flip ok in a way the malformed-frame case doesn't (a malformed frame errors before method dispatch, different code path/message shape, not conflated here).

Q4 — Doc/enforcement agreement

Checked every remaining "loopback admin" hit at head SHA (git grep): all other dispatch.rs hits (L830, 850, 871, 910) are control.*/cache methods, and L1176 is dig.getPayeeRewardClaimStatus (#3352's, correctly still CONTROL/loopback-only, untouched here as the brief expects). Neither ecosystem SPEC.md nor crates/dig-node-core/SPEC.md mentions RewardDistributor at all (checked via grep -n RewardDistributor), so there is no stale SPEC clause asserting admin-only for these two methods to contradict the new doc. No remaining sentence anywhere in scope still claims admin/loopback-only for GetRewardDistributor/ListRewardDistributorCommitments specifically.

Q5 — DIG_NODE_ALLOW_REMOTE=1 framing

Accurate. dig-node-service/src/config.rs (L1539+, #1662) refuses a non-loopback DIG_NODE_HOST unless DIG_NODE_ALLOW_REMOTE=1; control.rs L133/L2740 and server.rs L904/L953/L965 independently describe that escape hatch as making "this open read network-reachable and unauthenticated." The new comment's claim — that on such a bind, anonymous remote callers are included among those who can reach POST / — matches existing, unrelated code documented the same way elsewhere in the repo. Not a new claim invented for this PR.

Pre-existing issue to file separately (not this PR's scope)

Ticket content: "dig.getRewardDistributor / dig.listRewardDistributorCommitments have no per-requestor rate limit once #3310 wires the production chain-read adapter — an anonymous caller can drive one chain read per POST / call unbounded. Extend the existing miss-lookup TokenBucket (dig-node-core/src/rate_limit.rs) to cover these two methods (or a dedicated bucket), keyed on the same non-spoofable RequestorId already threaded through RpcDispatch::dispatch." This is defense-in-depth against a cost-amplification vector, not a live break introduced by this PR — does not gate.

KG: NONE (gate)

…prover-status tier at #3352

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 <noreply@anthropic.com>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-decider RE-GATE — CHANGES-REQUIRED (one comment line)

head read: 2791bf43f0d3be862c5a943162a4748f4968701c · delta gated: 5efd8a9c..2791bf43 (1 file, +5/-4, crates/dig-node-core/src/seams/dig_rpc/dispatch.rs, comment lines only) · prior leg: PR comment 5724215114

Q1 — dig.getRewardDistributor (L1005-1008): over-claims. The new sentence says "the disclosure rule SPEC §7.2 applies to every non-control.* method". Read against git show 2791bf43:SPEC.md §7.2 (L1461-1486) and §5.5 (L1080-1081):

  • §7.2 names no "disclosure rule". Its rule is WHO NAMES THE SUBJECT (L1480), and it is the membership rule for the open control.wallet.* exceptions (is_open_control_read) — it says nothing about non-control.* methods.
  • What actually applies to every non-control.* method is the prefix gate — §7.2 "Exactly the control. method prefix is gated (is_control_method)" (L1468) and §5.5 "Every non-control.* method MUST have requires_auth: false" (L1080).
  • The over-claim is refuted by the neighbouring block: dig.getRewardProverStatus is non-control.* and NODE-LOCAL (fails the subject test) yet is ungated — which is exactly why #3352 exists. So "the disclosure rule applies to every non-control method" is false as written.

The analogy itself is sound (launcher_id arrives in the request → answer discloses no node-local association → same profile as control.wallet.balance); only the attribution is wrong. Required wording (replace the three lines "answered to any caller … 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.

Q2 — #3352 cross-ref (L909-911): PASS. "the token tier of this NODE-LOCAL read is dig_ecosystem#3352's decision, not #3351's" removes the contradiction (this PR no longer implies #3351 settled the prover-status tier) and asserts nothing about #3352's outcome — #3352 is OPEN and its body confirms that is its scope. The surviving "loopback admin / in-process FFI ONLY" is #3352's enforcement target, correctly left alone here.

Q3 — new false claims in the 9 lines: one, the Q1 mis-attribution above. "with no token" is pinned (reward_distributor_reads_answer_on_post_slash_without_a_token). Nothing else changed.

Re-gate scope after the fix: the replaced lines only. Read-only leg; no build run.

@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.

loop-security delta re-gate — PASS

Head audited: 2791bf43f0d3be862c5a943162a4748f4968701c

Scope: git diff 5efd8a9c..2791bf43 — one file, crates/dig-node-core/src/seams/dig_rpc/dispatch.rs, +5/-4, comment-only.

  1. Byte-identical security surface confirmed. git diff 5efd8a9c..2791bf43 | grep '^[-+]' | grep -v '^[-+][-+]' | grep -v '^[-+]\s*//' returns empty — every changed line is a // comment. No code, no test, no config changed since the PASSed 5efd8a9c.

  2. Doc-honesty call on dropping the DIG_NODE_ALLOW_REMOTE=1 clause: honest, not a hide. The old wording ("anonymous remote callers included on a node bound with DIG_NODE_ALLOW_REMOTE=1") named one specific transport condition under which the open read is reachable remotely, which reads as scoping the exposure to that bind mode — implying a default/loopback-only bind is safe from it. That implication was the actual risk: the code makes dig.getRewardDistributor open (requires_auth: false) unconditionally for any POST / caller, regardless of bind mode; DIG_NODE_ALLOW_REMOTE controls whether the socket accepts remote connections at all, not whether this specific method is gated once it does. The new wording — "answered to any caller that reaches POST / with no token ... the disclosure rule SPEC §7.2 applies to every non-control.* method" — states the true, unconditional scope and points to the actual authorization rule rather than a single environment variable that could be read as a boundary. This is the more honest formulation: it removes a clause that could mislead an operator into thinking the exposure is conditional, without removing any information about who can reach the read (the reachability is still fully governed by ordinary network exposure of POST /, which the comment still calls out).

  3. SPEC §7.2 cross-reference is accurate. SPEC.md line 1080 (as of 2791bf43) reads verbatim: "Every non-control.* method MUST have requires_auth: false; every control.* method MUST have served: "control" and requires_auth: true." This is the general disclosure rule the comment cites, and dig.getRewardDistributor is a non-control.* method, so the citation is correct. §7.2 itself ("Authorization model — loopback + local capability token", line 1461) is the section that defines the control-token gating this open read is explicitly exempted from. The #3352 cross-ref added to the dig.getRewardProverStatus comment is a forward pointer to a separate open decision (token tier of a CONTROL-plane, not open, method) and makes no claim this diff needs to substantiate.

No non-comment lines, no behavior change, no new exposure. Comment wording is more precise than before and matches SPEC.md.

@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.

PASS -- delta re-gate at 2791bf43f0d3be862c5a943162a4748f4968701c

Scope confirmed: git diff 5efd8a9c..2791bf43 --stat is exactly one file
(crates/dig-node-core/src/seams/dig_rpc/dispatch.rs, +5/-4), and every changed line is a //
comment line -- no code, no test, no config changed.

  1. Comment accuracy vs SPEC/code:
    • dig.getRewardDistributor comment now reads "the disclosure rule SPEC SS7.2 applies to every
      non-control.* method" (dropping the DIG_NODE_ALLOW_REMOTE=1 clause). SPEC SS7.2
      ("Authorization model -- loopback + local capability token") states "Exactly the control.
      method prefix is gated (is_control_method)" -- i.e. every non-control.* method is
      unauthenticated by construction. That is a correct paraphrase of SS7.2's rule (SS5.5 line 1080
      restates the same rule for the OpenRPC catalogue: "Every non-control.* method MUST have
      requires_auth: false" -- consistent, not contradictory).
    • control::is_open_control_read still exists, dig-node-service/src/control.rs:149, unchanged.
    • dig.getRewardProverStatus comment gains "(the token tier of this NODE-LOCAL read is
      dig_ecosystem#3352's decision, not #3351's)" -- verified against gh issue view 3352: OPEN,
      and its body is exactly about the three NODE-LOCAL reward reads (getRewardProverStatus
      included) answering anonymously, explicitly distinguishing itself from #3351's public-state
      reads. Accurate.
  2. Encoding: em dashes in both changed comment blocks render as U+2014 (-- glyph), confirmed
    via raw byte inspection -- no cp1252/mangled-escape damage.
  3. Commit header: docs(rewards): claim only the transport the pins exercise; point the prover-status tier at #3352 -- 96 chars (<=100), type docs is in the allowed set.
  4. Blast radius: git diff 5efd8a9c..2791bf43 --stat is exactly the one file; the
    code-line-only diff (excluding // lines) is empty -- nothing outside comments changed.

Zero threads opened -- delta is comment-only and every claim checks out against current SPEC/code/issue
state.

Head: 2791bf43f0d3be862c5a943162a4748f4968701c

…sure rule" it does not name

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 <noreply@anthropic.com>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-decider RE-GATE — PASS

head read: 262551c68821ae1729285a149d284c6ea4fdaf0f · delta gated: 2791bf43..262551c6 (1 file, +6/-3, crates/dig-node-core/src/seams/dig_rpc/dispatch.rs, comment lines only) · prior leg: PR comment 5724296746

Q1 — verbatim? Yes. The six lines at L1007-1012 are byte-for-byte the replacement wording in comment 5724296746; the surrounding line L1006 (// the caller's launcher_id, answered … with no token:) carries the colon exactly as specified.

Q2 — whole block (L1000-1017) as a cold reader — every sentence true at this head:

  • Tier::Control / never over the mTLS peer surface: is_peer_reachable_method lives in crates/dig-node-core/src/peer.rs; dig.getRewardDistributor is enumerated in crates/dig-node-core/tests/reward_methods_tier_guard.rs:56. True.
  • Prefix gate: is_control_method at crates/dig-node-service/src/control.rs:99; SPEC §7.2 L1472 "Exactly the control. method prefix is gated (is_control_method)". True.
  • §5.5 is the right number: ### 5.5. Method catalogue (SPEC L1039), L1080 "Every non-control.* method MUST have requires_auth: false". True.
  • WHO-NAMES-THE-SUBJECT: SPEC §7.2 L1480; is_open_control_read at control.rs:149. True.
  • "Pinned by …": async fn reward_distributor_reads_answer_on_post_slash_without_a_token at crates/dig-node-service/tests/server.rs:3924. True.
  • "Chain-derived state ONLY … see GetRewardProverStatus above": that arm is at L922, above. True.
  • "Goes entirely through rewards::port::RewardsChainPort … never calls dig-rewards-coin itself": pub trait RewardsChainPort at rewards/port.rs:262; dig-rewards-coin is absent from crates/dig-node-core/Cargo.toml; the only dig_rewards_coin token in core is a doc-comment reference (port.rs:249). True.

Q3 — new false claims introduced by 262551c: none.

Read-only leg; no build run (comment-only delta).

@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.

PASS — delta re-gate on 262551c68821ae1729285a149d284c6ea4fdaf0f.

Scope: crates/dig-node-core/src/seams/dig_rpc/dispatch.rs, comment-only, +6/-3, per git diff 2791bf43..262551c6.

Check 1 — SPEC attribution (read from git show 262551c6:SPEC.md, not memory):

  • (a) "only the control. prefix is token-gated (SPEC §7.2 is_control_method)" — matches SPEC.md:1472, "Exactly the control. method prefix is gated (is_control_method)".
  • (b) "§5.5 requires_auth: false for every non-control.* method" — matches SPEC.md:1080 under the confirmed ### 5.5. Method catalogue heading (SPEC.md:1039), verbatim: "Every non-control.* method MUST have requires_auth: false".
  • (c) "§7.2's WHO-NAMES-THE-SUBJECT test … the subject arrives in the request, so the answer discloses no node-local association" — matches SPEC.md:1480, "WHO NAMES THE SUBJECT: an open read's subject arrives in the request as a public address or coin id, so the answer discloses no node-to-address association" (paraphrase preserves the claim, not a fabrication). Cross-checked against crates/dig-node-service/src/control.rs doc comment on is_open_control_read (~L149), which states the same test for control.wallet.balance and named siblings.

The prior gates' defect — attributing a "disclosure rule for every non-control.* method" to §7.2, which names no such rule — is gone; the new wording cites §7.2 only for the prefix-gating and WHO-NAMES-THE-SUBJECT claims it actually makes, and cites §5.5 (not §7.2) for the requires_auth: false claim.

Check 2 — comment-only diff: git diff 2791bf43..262551c6 | grep '^[-+]' | grep -v '^[-+][-+]' | grep -v '^[-+]\s*//' returns empty (exit 1, no match). Em dashes elsewhere in the file intact (135 occurrences of U+2014 in the post-commit file).

Check 3 — commit: header docs(rewards): cite the rule SPEC §7.2 actually states, not a "disclosure rule" it does not name is 97 chars (≤100), type docs is valid conventional-commit type, scope rewards reasonable for the touched code.

No code semantics changed; nothing else in scope for this delta.

@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.

loop-security RE-GATE — PASS

head audited: 262551c68821ae1729285a149d284c6ea4fdaf0f · delta: 2791bf43..262551c6 (1 file, +6/-3, crates/dig-node-core/src/seams/dig_rpc/dispatch.rs, comment lines only)

1. Byte-identical security surface. git diff 2791bf43..262551c6 -- crates/dig-node-core/src/seams/dig_rpc/dispatch.rs | grep '^[-+]' | grep -v '^[-+][-+]' | grep -vE '^[-+]\s*//' is empty — every changed line is a // comment line. Confirmed.

2. The disclosure claim is TRUE for both methods at this head. dig.getRewardDistributor and dig.listRewardDistributorCommitments (dispatch.rs L1012/L1057 handlers) both go through port.distributor_report(launcher_id)RealRewardsChainPort::distributor_reportbuild_report (crates/dig-node-service/src/rewards/chain_port.rs L100-178). That path consults only ChainSource reads keyed on the caller-supplied launcher_id: read_distributor_guarded, read_launch_comment, read_launch_constants — all chain-derived. No node-local registry, config, funder-ownership table, or prover state is touched. Notably funded_distributors (the one method shaped like a node-local registry lookup) is hard-coded Err(ChainPortError::Unavailable) in this same impl (L79-84) — the registry the sentence would need to worry about doesn't exist yet. So "the subject arrives in the request, so the answer discloses no node-local association" holds.

3. §7.2 does state WHO-NAMES-THE-SUBJECT, and applying it here is the fair analogy, not a category error. SPEC.md §7.2 (L1478-1483, git show 262551c6:SPEC.md) defines the test as the membership rule for the is_open_control_read exceptions among control.* methods (control.wallet.balance etc.). dig.getRewardDistributor/ListRewardDistributorCommitments are non-control.* methods, already open by construction (§7.2 "Exactly the control. prefix is gated"; §5.5 requires_auth: false) — the new wording correctly does NOT claim §7.2 gates or exempts them. It uses the test only as an independent disclosure-safety argument, by analogy, for why being open is safe, not as the reason it is open. This is exactly the wording the adversarial gate (PR comment 5724296746) prescribed verbatim after flagging the prior sentence's mis-attribution ("the disclosure rule SPEC §7.2 applies to every non-control.* method" — false, refuted by GetRewardProverStatus sitting node-local and ungated, #3352). The new commit's added lines match that prescribed replacement byte-for-byte.

No live finding. Scope audited: crates/dig-node-core/src/seams/dig_rpc/dispatch.rs diff 2791bf43..262551c6, crates/dig-node-service/src/rewards/chain_port.rs L1-240, SPEC.md §7.2 at 262551c6. Not re-covered: everything already PASSed at 5efd8a9c and 2791bf43 (no code changed since).

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 18, 2026 02:56
@MichaelTaylor3d
MichaelTaylor3d merged commit 7c9f46b into develop Sep 18, 2026
14 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the fix/3351-reward-read-tier branch September 18, 2026 02:56
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