diff --git a/CHANGELOG.md b/CHANGELOG.md index 46528fdc..1a40ce87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ All notable changes to this project are documented here. This project adheres to [Semantic Versioning](https://semver.org) and [Conventional Commits](https://www.conventionalcommits.org). +## [0.261.0] - 2026-09-24 + +### Reward RPC ingress: token gate and rate bound +- Token-gate the three NODE-LOCAL reward reads on `POST /` (`dig.getRewardProverStatus`, + `dig.listRewardDistributors`, `dig.getPayeeRewardClaimStatus`): a master control token or a valid + paired token is now required, `-32030` otherwise. They were already `Tier::Control` (local + dispatch only, never the mTLS peer surface); what changed is that the anonymous `POST /` path no + longer volunteers node-local state, so the served catalogue's `requires_auth` and the enforced + predicate agree (#3352) +- Rate-bound the two OPEN chain-keyed reward reads per SOURCE at HTTP ingress + (`dig.getRewardDistributor`, `dig.listRewardDistributorCommitments`), `-32034 + REWARD_INGRESS_LIMITED`, so an anonymous caller can no longer drive an unbounded number of + upstream chain reads. The limiter is keyed on the source, never on the caller-supplied + `launcher_id` (#3355) +- The served OpenRPC catalogue now reports `requires_auth: true` for five methods that previously + advertised `false` while already being enforced as authenticated: `cache.listCached`, + `cache.fetchAndCache`, `cache.pushCapsule`, `chat.send` and `chat.poll`. No enforcement changed + -- the catalogue was describing these methods wrongly. Every `rpc.discover` consumer that reads + `requires_auth` will see the flip (#3352) + +### Reward claim port hardening +- Ban `RewardDistributor::created_slot_value_to_slot` from production code via a workspace + `disallowed-methods` clippy lint (phantom `LineageProof` on a chain-rebuilt distributor); allow + the one legitimate in-process test-fixture use. This is the dig-node HALF of #3357 only -- + the ticket also has dig-account and dig-app halves and remains OPEN (#3357) +- Refuse, by name, a distributor requiring payout approval in `submit_initiate_payout` before + building or broadcasting anything (#3362) +- Fix a claim-port regression test to use a non-empty launcher index so it actually exercises the + failing chain source's discovery/submit paths (#3363) + ## [0.255.0] - 2026-09-07 ### Chores diff --git a/Cargo.lock b/Cargo.lock index 788ff692..7f57fc7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3041,7 +3041,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.260.0" +version = "0.261.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 31e48aa5..5d41b816 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ edition = "2021" # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.260.0" +version = "0.261.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over # it, so silent wrapping in release would turn a length bug into a memory/logic hazard. diff --git a/SPEC.md b/SPEC.md index 96046691..6d7d6ace 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1044,7 +1044,7 @@ MUST NOT re-declare method names. Each entry carries a `served` class and `requi | `served` | Meaning | |---|---| -| `local` | Resolved by the node library (`handle_rpc`). | +| `local` | Resolved by the node library (`handle_rpc`). `requires_auth: false` except the HTTP-token-gated methods named below, which are `requires_auth: true`. | | `passthrough` | Read path returns `-32601`; relayed verbatim to the upstream WHEN one is configured (§5.4), else returned to the caller as `-32601`. | | `shell` | Answered by this service itself (`rpc.discover`). | | `control` | The gated control plane (§7); always `requires_auth: true`. | @@ -1056,8 +1056,11 @@ For the current node library (§2.2) the catalogue is: `dig.getCollection`, `dig.listCollectionItems`, the L7 peer surface (`dig.getNetworkInfo`, `dig.getPeers`, `dig.announce`, `dig.getAvailability`, `dig.listInventory`, `dig.fetchRange`), all `cache.*` (`cache.getConfig`, `cache.setCapBytes`, `cache.clear`, `cache.listCached`, - `cache.removeCached`, `cache.fetchAndCache`, `cache.pushCapsule` — §5.5.3), and the chat subsystem - `chat.send` / `chat.poll` (§5.5.2). + `cache.removeCached`, `cache.fetchAndCache`, `cache.pushCapsule` — §5.5.3), the chat subsystem + `chat.send` / `chat.poll` (§5.5.2), and the reward reads `dig.getRewardProverStatus`, + `dig.listRewardDistributors`, `dig.getPayeeRewardClaimStatus`, `dig.getRewardDistributor`, + `dig.listRewardDistributorCommitments` (dig_ecosystem#3352 / #3351 / #3355 — see the + `requires_auth` clause below). - **passthrough**: `dig.listCapsules` (needs a chain generation walk this node does not perform) and `dig.getProofStatus` (polls an execution-proof JOB this node does not run — inventing a status would be the fabrication the anti-fabrication rule forbids: an absent attestation is @@ -1077,8 +1080,18 @@ Param/result schemas for the `dig.*`/`cache.*` methods are owned by the digstore published on docs.dig.net (Protocol → the L7 read/RPC pages); this repo's OpenRPC document is a method + error **discovery** catalogue with intentionally permissive schemas. -Every non-`control.*` method MUST have `requires_auth: false`; every `control.*` method MUST have -`served: "control"` and `requires_auth: true`. +Every `control.*` method MUST have `served: "control"` and `requires_auth: true`. A non-`control.*` +method MUST have `requires_auth: false` UNLESS the HTTP surface token-gates it — today the +holder-/holdings-revealing `cache.fetchAndCache` / `cache.pushCapsule` / `cache.listCached` (§14.3, +#2108), the node-identity chat pair `chat.send` / `chat.poll` (#1946), and the NODE-LOCAL reward reads +`dig.getRewardProverStatus` / `dig.listRewardDistributors` / `dig.getPayeeRewardClaimStatus` +(dig_ecosystem#3352: each volunteers this node's own prover inventory, funded-distributor set or +payee claim state, failing §7.2's WHO-NAMES-THE-SUBJECT test). Those keep their `served` class and +carry `requires_auth: true`. `requires_auth` is the COMPILED statement of the HTTP token gate: the set +of catalogued methods with `requires_auth: true` MUST equal the set `server.rs` refuses `-32030 +UNAUTHORIZED` without a master or paired token (`requires_http_token`), and a test pins the equality. +The two chain-keyed reward reads `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments` +are OPEN (dig_ecosystem#3351) and rate-bounded per source (§10, `-32034`). #### 5.5.0. `dig.getContent` — the window envelope (#2071) @@ -1469,8 +1482,9 @@ Two layers, both REQUIRED: mismatched credential is answered `UNAUTHORIZED` (`-32030`, §10). Token comparison MUST be constant-time (`ct_eq`) so verification cannot be probed via a timing oracle. -Exactly the `control.` method prefix is gated (`is_control_method`); unknown `control.*` methods -still pass the auth gate first, then yield `METHOD_NOT_FOUND`. The pairing-administration methods +The `control.` method prefix is token-gated as a class (`is_control_method`); the HTTP surface +additionally token-gates the non-`control.*` methods §5.5 enumerates (`requires_http_token`); +unknown `control.*` methods still pass the auth gate first, then yield `METHOD_NOT_FOUND`. The pairing-administration methods (`control.pairing.list`/`approve`/`revoke`, §7.11) require the MASTER token specifically — a paired token is NOT accepted for them. The exceptions are the wallet CHAIN READS — `control.wallet.balance`, `control.wallet.coins`, `control.wallet.coinById`, `control.wallet.coinSpend`, `control.wallet.coinsByParent`, @@ -3315,6 +3329,7 @@ method runs, and it MUST NOT be conflated with the wallet's own `-32043` egress | -32031 | `NOT_SUPPORTED` | shell | A control operation this build/pin cannot perform (e.g. §21 sync without an identity). | | -32032 | `CONTROL_ERROR` | shell | A control operation failed at runtime (distinct from bad input / absent capability). | | -32033 | `CONTROL_INGRESS_LIMITED` | shell | An OPEN, token-less `control.*` read was refused AT INGRESS, before the request reached the dispatcher and before any DB work was done for it: this SOURCE's request bound is exhausted. The open reads present no credential, so without this bound an unauthenticated caller can drive unbounded SQLite work (`.coinById`/`.coinSpend` each run up to two lookups plus an LRU `UPDATE`) simply by asking repeatedly. The bound is PER SOURCE — one flooding source MUST NOT refuse another — and the node's OWN loopback operator is EXEMPT, so this code is only ever seen by a non-loopback caller (i.e. under `DIG_NODE_ALLOW_REMOTE=1`). It MUST stay DISTINCT from `-32043 WALLET_RATE_LIMITED`: that bound is on chain EGRESS and protects the third-party oracle, this one is on REQUESTS and protects this process. They fire for different reasons and have different remedies, so collapsing them would leave a caller unable to tell which bound it hit. Back off and retry. | +| -32034 | `REWARD_INGRESS_LIMITED` | shell | An OPEN reward chain read (`dig.getRewardDistributor` / `dig.listRewardDistributorCommitments`) was refused AT INGRESS: this SOURCE's request bound is exhausted. Each call is one upstream chain read for any caller-supplied launcher_id, so without this bound an anonymous caller drives unbounded upstream work. The bound is PER SOURCE (`RequestorId` — never the launcher id, which the caller controls); the loopback operator is EXEMPT, so only a `DIG_NODE_ALLOW_REMOTE=1` caller ever sees it. Distinct from -32033 (control-read ingress) and -32043 (wallet chain egress). Back off and retry. | | -32040 | `WALLET_NO_CHAIN_SOURCE` | node | a wallet chain read (`control.wallet.balance`/`.coins`/`.coinById`/`.coinSpend`/`.coinsByParent`/`.peak`) or `control.wallet.broadcast` had NO live chain source able to answer an arbitrary (non-wallet) address. Distinct from a truthful `0`. A read the node can answer WITHOUT a chain source MUST NOT be refused with this code: the replica fast path and the node own chain-read cache both answer from bytes already in hand, so on `.coinById`/`.coinSpend` liveness is consulted only on a cache MISS. Refusing a cached answer because a third party is momentarily unreachable gives availability away for nothing on exactly the rows a lineage walk re-reads (a spent coin record is immutable), and the refusal then cascades into the retries that exhaust the `-32043` bound. The refusal MUST stay for a miss, and `.coinSpend` MUST treat a PARTIAL cache hit (spend cached, coin record not) as a miss, because the heights come from the record. | | -32041 | `WALLET_NOT_SYNCED` | node | `control.wallet.balance` of the wallet's OWN address while the local DB is still syncing and no live fallback is attached (nothing can answer yet). | | -32042 | `WALLET_READ_FAILED` | node | `control.wallet.balance`/`.coins`/`.coinById`/`.coinSpend`/`.coinsByParent`/`.peak` failed at the underlying DB / chain-source layer. On `.coinById` this INCLUDES a chain source that answered with a record for a DIFFERENT coin than the id asked for: a coin id is self-certifying (`SHA256(parent ‖ puzzle_hash ‖ amount)`), so a substituted record is a failed READ -- never that coin's record, and never `coin: null`. On `.coinSpend` it likewise INCLUDES a source that answered with another coin's spend, a puzzle reveal that does not tree-hash to the spent coin's own `puzzle_hash` (or will not parse), and a spend the coin record contradicts (no record, or a record calling the coin unspent) -- each fails CLOSED rather than being served unverified. On `.coinsByParent` it INCLUDES a source that returned a child naming a different parent, which fails the WHOLE page rather than being silently filtered (a filtered page is a lineage with an invisible hole). Distinct from `WALLET_NO_CHAIN_SOURCE` and `WALLET_NOT_SYNCED`. | diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000..696a4e1d --- /dev/null +++ b/clippy.toml @@ -0,0 +1,18 @@ +# DIG-Network/dig_ecosystem#3357: bans calling `RewardDistributor::created_slot_value_to_slot` +# from production code anywhere in this workspace. +# +# That method derives a `LineageProof` from the coin it is called ON. For a distributor rebuilt +# from chain (every production read path), that coin is the TIP -- so a `LineageProof` it derives +# for a slot an EARLIER generation created is a well-formed but PHANTOM proof. The entry slot for a +# real spend must come from a fresh, authenticated chain walk instead (see +# `dig_rewards_coin::ChainEntrySlotSource`, or `DistributorSnapshot::entry_slot` / +# `commitment_slots` / `reward_slots` for a read). +# +# This lint bans the CALL, not the receiver class: clippy cannot tell a chain-rebuilt receiver from +# an in-process one (a distributor built fresh in the same process this same generation, e.g. a +# test fixture reading back the reward slots ITS OWN spend just created -- that use is legitimate). +# Every `#[allow(clippy::disallowed_methods)]` against this entry must carry a comment stating WHY +# its receiver is in-process this generation, not chain-rebuilt. +disallowed-methods = [ + { path = "chia_sdk_driver::RewardDistributor::created_slot_value_to_slot", reason = "derives a LineageProof from the coin it is called on; on a distributor rebuilt from chain that coin is the TIP, so any earlier generation's slot is a PHANTOM (dig_ecosystem#3357). Use DistributorSnapshot::entry_slot / commitment_slots / reward_slots or ChainEntrySlotSource. This lint bans the CALL, not the receiver class: it cannot tell a chain-rebuilt receiver from an in-process one. Every #[allow] must state in a comment WHY its receiver is in-process." }, +] 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 6d6e1ded..256d08f2 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -907,10 +907,11 @@ 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 (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 + // §2.3/§2.4) — `Tier::Control` = local dispatch only, never the mTLS peer surface + // (`reward_methods_tier_guard.rs`); token-GATED on `POST /` at + // `server.rs::is_node_local_reward_read` (master or paired token, `-32030`; + // dig_ecosystem#3352) because it volunteers node-local state; the in-process FFI path + // stays open like `cache.*`. Reads the node's live // `reward_prover_statuses` registry (empty until dig_ecosystem#3265 spawns a prover // loop) — a REAL read of a real, currently-empty registry, so // `{"statuses":{"outcome":"consulted","observed_at":N,"items":[]}}` means "this node @@ -1013,7 +1014,11 @@ impl RpcDispatch for Node { // 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). + // itself (dig_ecosystem#3269 unit 0). Token-LESS does not mean UNBOUNDED: staying open + // per dig_ecosystem#3351 above, `POST /` also rate-bounds this read PER SOURCE at + // ingress (`-32034 REWARD_INGRESS_LIMITED`, dig_ecosystem#3355, + // `server.rs::is_open_reward_chain_read`) — a caller-supplied `launcher_id` is never + // the limiter's key, only the source is. Some(Method::GetRewardDistributor) => { let params = req.get("params").cloned().unwrap_or(json!({})); let launcher_id = match parse_launcher_id_arg(¶ms) { @@ -1095,8 +1100,11 @@ impl RpcDispatch for Node { }; return json!({"jsonrpc":"2.0","id":id,"result": result}); } - // dig.listRewardDistributors (dig_ecosystem#3269 unit 2, SPEC §2.6) — CONTROL plane, - // same guard shape as the other reward handlers above. Two independently-consulted + // dig.listRewardDistributors (dig_ecosystem#3269 unit 2, SPEC §2.6) — `Tier::Control` + // = local dispatch only, never the mTLS peer surface (`reward_methods_tier_guard.rs`); + // token-GATED on `POST /` at `server.rs::is_node_local_reward_read` (master or paired + // token, `-32030`; dig_ecosystem#3352) because it volunteers node-local state; the + // in-process FFI path stays open like `cache.*`. Two independently-consulted // halves (`funded` / `claimable`), each a `Half` — SPEC §12.5 // clause 6's "reassuring zero" rule applies to EACH half separately. // @@ -1177,8 +1185,11 @@ impl RpcDispatch for Node { return json!({"jsonrpc":"2.0","id":id,"result": result}); } // dig.getPayeeRewardClaimStatus (dig_ecosystem#3268/#3269 unit 3, SPEC §12.5) — - // CONTROL plane: loopback admin / in-process FFI ONLY, absent from - // `is_peer_reachable_method` (`reward_methods_tier_guard.rs` fails closed on that). + // `Tier::Control` = local dispatch only, never the mTLS peer surface + // (`reward_methods_tier_guard.rs`); token-GATED on `POST /` at + // `server.rs::is_node_local_reward_read` (master or paired token, `-32030`; + // dig_ecosystem#3352) because it volunteers node-local state; the in-process FFI path + // stays open like `cache.*`. // Dispatched through `Method::from_name(..)` like every other reward method — never // the string pre-match above the enum, which bypasses this tier guard entirely // (dig_ecosystem#3261: a reward RPC reachable by a peer is a money hole). diff --git a/crates/dig-node-service/src/meta.rs b/crates/dig-node-service/src/meta.rs index 8e432c48..7dce128a 100644 --- a/crates/dig-node-service/src/meta.rs +++ b/crates/dig-node-service/src/meta.rs @@ -154,7 +154,7 @@ pub fn methods() -> &'static [MethodInfo] { name: "cache.listCached", served: "local", summary: "List cached capsules (storeId:rootHash).", - requires_auth: false, + requires_auth: true, }, MethodInfo { name: "cache.removeCached", @@ -166,7 +166,7 @@ pub fn methods() -> &'static [MethodInfo] { name: "cache.fetchAndCache", served: "local", summary: "Pre-fetch and cache a capsule.", - requires_auth: false, + requires_auth: true, }, MethodInfo { // #1476: the publish→seed push. Local-only by default; the HTTP surface adds a control-token @@ -181,7 +181,7 @@ pub fn methods() -> &'static [MethodInfo] { summary: "Push a freshly-committed capsule's bytes to seed this node as a holder \ (control-token gated over loopback; §21.9 authorized-writer signature when \ DIG_NODE_PUSH_OPEN=true).", - requires_auth: false, + requires_auth: true, }, MethodInfo { name: "cache.stats", @@ -601,7 +601,7 @@ pub fn methods() -> &'static [MethodInfo] { (base64 48-byte BLS G1 sealing key), peer_id (64-hex gossip target), \ envelope (base64 opaque DIGCHAT1) }; result { message_id (64-hex) }. \ recipient_pub + peer_id are app-supplied pending the key directory.", - requires_auth: false, + requires_auth: true, }, MethodInfo { name: "chat.poll", @@ -609,6 +609,53 @@ pub fn methods() -> &'static [MethodInfo] { summary: "Drain the node's inbound chat inbox. No params; result { messages: \ [{ sender_did (64-hex), message_id (64-hex), envelope (base64 opaque \ DIGCHAT1) }] } in arrival order.", + requires_auth: true, + }, + // -- reward reads (dig_ecosystem#3352 / #3355) — the three NODE-LOCAL reads volunteer this + // node's own prover inventory, funded-distributor set or payee claim state and are + // token-gated (`requires_http_token`); the two chain-keyed reads are OPEN (#3351) and + // rate-bounded per source at ingress instead (`-32034`). ------------------------------ + MethodInfo { + name: "dig.getRewardProverStatus", + served: "local", + summary: "This node's own reward-prover-loop status registry: { statuses }. \ + NODE-LOCAL — volunteers this node's own prover inventory — so it requires \ + the local control token or a paired token (dig_ecosystem#3352).", + requires_auth: true, + }, + MethodInfo { + name: "dig.listRewardDistributors", + served: "local", + summary: "This node's own funded/claimable reward-distributor identity sets: \ + { funded, claimable }. NODE-LOCAL — volunteers this node's own funded-\ + distributor set — so it requires the local control token or a paired token \ + (dig_ecosystem#3352).", + requires_auth: true, + }, + MethodInfo { + name: "dig.getPayeeRewardClaimStatus", + served: "local", + summary: "This node's own payee-side reward claim status. NODE-LOCAL — volunteers this \ + node's own claim state — so it requires the local control token or a paired \ + token (dig_ecosystem#3352).", + requires_auth: true, + }, + MethodInfo { + name: "dig.getRewardDistributor", + served: "local", + summary: "One reward distributor's chain-derived report for a caller-supplied \ + launcher_id. OPEN (dig_ecosystem#3351): the subject arrives in the request, \ + so no node-local association is disclosed. Rate-bounded per source at \ + ingress (`-32034 REWARD_INGRESS_LIMITED`, dig_ecosystem#3355).", + requires_auth: false, + }, + MethodInfo { + name: "dig.listRewardDistributorCommitments", + served: "local", + summary: "One reward distributor's clawback commitment slots for a caller-supplied \ + launcher_id. OPEN (dig_ecosystem#3351), same guard shape as \ + dig.getRewardDistributor: rate-bounded per source at ingress \ + (`-32034 REWARD_INGRESS_LIMITED`, dig_ecosystem#3355).", requires_auth: false, }, ] @@ -789,6 +836,16 @@ pub enum ErrorCode { /// leave the next person debugging a refusal unable to tell which bound they hit. Retriable: /// the caller should back off. Shell error (minted before dispatch). (Control range `-3203x`.) ControlIngressLimited, + /// `-32034` -- an OPEN reward chain read (`dig.getRewardDistributor` / + /// `dig.listRewardDistributorCommitments`) was refused AT INGRESS: this source's request + /// bound is exhausted (dig_ecosystem#3355). Each call is one upstream chain read for any + /// caller-supplied `launcher_id`, so without this bound an anonymous caller drives unbounded + /// upstream work. The bound is PER SOURCE (`RequestorId` -- never the launcher id, which the + /// caller controls); the loopback operator is EXEMPT, so only a `DIG_NODE_ALLOW_REMOTE=1` + /// caller ever sees it. Distinct from `-32033` (control-read ingress) and `-32043` + /// (wallet chain egress). Retriable: the caller should back off. Shell error (minted before + /// dispatch). (Control range `-3203x`.) + RewardIngressLimited, } /// The numeric code the shared wire contract assigns, widened to the `i64` the JSON-RPC @@ -838,6 +895,7 @@ impl ErrorCode { ErrorCode::PeerPingRefused => -32060, ErrorCode::PushPendingLimited => -32016, ErrorCode::ControlIngressLimited => -32033, + ErrorCode::RewardIngressLimited => -32034, } } @@ -872,6 +930,7 @@ impl ErrorCode { ErrorCode::PeerPingRefused => "PEER_PING_REFUSED", ErrorCode::PushPendingLimited => "PUSH_PENDING_LIMITED", ErrorCode::ControlIngressLimited => "CONTROL_INGRESS_LIMITED", + ErrorCode::RewardIngressLimited => "REWARD_INGRESS_LIMITED", } } @@ -889,6 +948,7 @@ impl ErrorCode { | ErrorCode::ControlError // Minted by the control SERVER at ingress, before the request reaches the node. | ErrorCode::ControlIngressLimited + | ErrorCode::RewardIngressLimited // The audit record is a node-private FILE read by the shell, not by the node. | ErrorCode::SpendAuditUnreadable // Minted by the shell's dispatch gate itself, before the read path is ever asked. @@ -991,6 +1051,11 @@ impl ErrorCode { "An open, token-less control read was refused at ingress: this source's request \ bound is exhausted. Distinct from WALLET_RATE_LIMITED, which bounds chain egress." } + ErrorCode::RewardIngressLimited => { + "An open reward chain read was refused at ingress: this source's request bound is \ + exhausted. Distinct from CONTROL_INGRESS_LIMITED (control reads) and \ + WALLET_RATE_LIMITED (chain egress)." + } } } @@ -1019,6 +1084,7 @@ impl ErrorCode { ErrorCode::PeerPingRefused, ErrorCode::PushPendingLimited, ErrorCode::ControlIngressLimited, + ErrorCode::RewardIngressLimited, ] } } @@ -1552,9 +1618,16 @@ mod tests { ); assert_eq!(m.served, "control", "{} must be served=control", m.name); } else { - assert!( - !m.requires_auth, - "non-control method {} must NOT require auth", + // Not every non-`control.*` method is a public read: the cache trio, + // the chat pair and the three node-local reward reads are gated on + // `POST /` (dig_ecosystem#3352, SPEC §5.5) though they carry no + // `control.` prefix. `requires_http_token` is the compiled predicate + // that actually enforces the gate, so the catalogue must equal it + // exactly rather than assume every non-control method is open. + assert_eq!( + m.requires_auth, + crate::server::requires_http_token(m.name), + "{} catalogued requires_auth must equal requires_http_token", m.name ); } diff --git a/crates/dig-node-service/src/rewards_claim/chain_port.rs b/crates/dig-node-service/src/rewards_claim/chain_port.rs index e5bb7aff..c3f56266 100644 --- a/crates/dig-node-service/src/rewards_claim/chain_port.rs +++ b/crates/dig-node-service/src/rewards_claim/chain_port.rs @@ -17,9 +17,13 @@ //! well-formed but PHANTOM `LineageProof` for a slot an earlier generation created //! (DIG-Network/dig_ecosystem#3357). `initiate_payout`'s returned `conditions` are a CALLER-SIDE //! assertion for a coin the caller would add to the same bundle; this adapter adds no coin of its -//! own (no fee coin, no key, nothing to sign -- `required_fee_mojos` is `0`), so it drops them -- -//! the simulator acceptance test in `tests/rewards_claim_chain_port_3347.rs` is the proof the -//! resulting bundle is accepted without them. +//! own (no fee coin, no key, nothing to sign -- `required_fee_mojos` is `0`), so it drops them when +//! it proceeds -- the simulator acceptance test in `tests/rewards_claim_chain_port_3347.rs` is the +//! proof the resulting bundle is accepted without them for `require_payout_approval = false`. When +//! the chain-curried `require_payout_approval` is `true` instead, dropping `conditions` would be +//! dropping the manager's approval assertion, not a no-op -- [`RealClaimChainPort::submit_initiate_payout`] +//! REFUSES by name in that case, before building anything, rather than broadcasting a bundle this +//! adapter cannot honestly satisfy (DIG-Network/dig_ecosystem#3362). //! //! A silent no-op would be the exact defect this ticket exists to prevent -- a refused method //! reports a NAMED [`ClaimPortError`], never a fabricated success. @@ -38,7 +42,7 @@ use dig_wallet::sage::spend::Broadcaster; use crate::rewards::chain_source::{read_distributor_guarded, GuardedReadError}; use super::port::{ClaimChainPort, ClaimPortError}; -use super::types::{DiscoveredDistributor, OwnEntry}; +use super::types::{DiscoveredDistributor, Discovery, OwnEntry}; /// The longest a chain port's own error text is allowed to carry before it is truncated -- the /// same 200-char discipline [`super::types::ClaimOutcome::Faulted`]'s `reason` field documents, @@ -190,7 +194,7 @@ where "real-corroborated" } - async fn discover_distributors(&self) -> Result, ClaimPortError> { + async fn discover_distributors(&self) -> Result { let candidate_ids = self.index.launcher_ids().await?; let source = Arc::clone(&self.source); @@ -207,7 +211,9 @@ where Err(other) => return Err(other), } } - Ok(discovered) + Ok(Discovery { + distributors: discovered, + }) }) .await .map_err(|join_error| { @@ -336,6 +342,26 @@ where ClaimPortError::Other(bounded("not a distributor: launcher coin unspent")) })?; + // DIG-Network/dig_ecosystem#3362: this distributor curries `require_payout_approval = + // true`, meaning `InitiatePayout` needs a manager-signed approval assertion in the same + // bundle. This adapter has no such assertion to attach and, per this module's doc, + // DROPS `initiate_payout`'s returned `conditions` unconditionally -- proceeding here + // would build a bundle the chain rejects, but only AFTER this adapter's caller had + // already reported `Paid` to whatever recorded the attempt. Refuse by name instead, + // before any spend is built. + if snapshot + .distributor() + .info + .constants + .require_payout_approval + { + return Err(ClaimPortError::Other(bounded( + "refused: distributor curries require_payout_approval = true; this adapter \ + carries no approval message (it drops initiate_payout's returned conditions), \ + so the bundle it would build is one the chain rejects after reporting Paid", + ))); + } + // NEVER `snapshot.distributor().created_slot_value_to_slot(..)` -- that derives a // well-formed but PHANTOM `LineageProof` for a slot an earlier generation created // (DIG-Network/dig_ecosystem#3357, this module's doc). The entry slot for THIS spend diff --git a/crates/dig-node-service/src/rewards_claim/driver.rs b/crates/dig-node-service/src/rewards_claim/driver.rs index a62eb998..c23543bf 100644 --- a/crates/dig-node-service/src/rewards_claim/driver.rs +++ b/crates/dig-node-service/src/rewards_claim/driver.rs @@ -715,7 +715,7 @@ mod tests { use super::super::cadence::CLAIM_JITTER_SECONDS_DEFAULT; use super::super::port::ClaimPortError; - use super::super::types::{DiscoveredDistributor, OwnEntry}; + use super::super::types::{DiscoveredDistributor, Discovery, OwnEntry}; // ---- decide_claim_driver / spawn_claim_driver_if (A3) ---------------------------------- @@ -988,10 +988,8 @@ mod tests { #[async_trait] impl ClaimChainPort for EmptyPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { - Ok(Vec::new()) + async fn discover_distributors(&self) -> Result { + Ok(Discovery::default()) } async fn resolve_launch_comment( &self, @@ -1138,14 +1136,14 @@ mod tests { #[async_trait] impl ClaimChainPort for OneDistributorPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { - Ok(vec![DiscoveredDistributor { - launcher_id: Bytes32::from([9u8; 32]), - store_id: Bytes32::from([0u8; 32]), - root: Bytes32::from([0u8; 32]), - }]) + async fn discover_distributors(&self) -> Result { + Ok(Discovery { + distributors: vec![DiscoveredDistributor { + launcher_id: Bytes32::from([9u8; 32]), + store_id: Bytes32::from([0u8; 32]), + root: Bytes32::from([0u8; 32]), + }], + }) } async fn resolve_launch_comment( &self, diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs index 9aea1184..56cbc05c 100644 --- a/crates/dig-node-service/src/rewards_claim/engine.rs +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -9,7 +9,7 @@ use chia_protocol::Bytes32; use super::config::RewardsClaimConfig; use super::hints::DistributorHintSource; use super::port::{ClaimChainPort, ClaimPortError}; -use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus}; +use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus, Discovery}; /// The two cadences [`ClaimEngine::with_persisted_fee_window`] needs, DERIVED together from the /// single raw configured value they both come from. @@ -481,14 +481,18 @@ impl ClaimEngine { // timestamp going stale to notice a wedged discovery path. self.status.fault_reported = true; discovery_failed = true; - Vec::new() + Discovery::default() } }; if !discovery_failed { self.status.last_discovery_at = Some(now); } - let mut candidates: Vec = discovered.iter().map(|d| d.launcher_id).collect(); + let mut candidates: Vec = discovered + .distributors + .iter() + .map(|d| d.launcher_id) + .collect(); // F4: a real adapter can plausibly return the same launcher id twice (one distributor // reachable via two of the §1.3 launch comments this node scans, across the // `(store_id, root)` pairs it mirrors). Without this, phase 2 would evaluate it twice and @@ -967,7 +971,7 @@ mod tests { use super::*; use crate::rewards_claim::hints::{DistributorHint, NoHintSource}; use crate::rewards_claim::parser::parse_launch_comment; - use crate::rewards_claim::types::DiscoveredDistributor; + use crate::rewards_claim::types::{DiscoveredDistributor, Discovery}; const DIG_ASSET_ID: Bytes32 = Bytes32::new([9u8; 32]); const OUR_PAYOUT_PUZZLE_HASH: Bytes32 = Bytes32::new([1u8; 32]); @@ -1046,20 +1050,20 @@ mod tests { #[async_trait] impl ClaimChainPort for FakeChainPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { - Ok(self - .distributors - .lock() - .unwrap() - .values() - .map(|d| DiscoveredDistributor { - launcher_id: d.launcher_id, - store_id: d.store_id, - root: d.root, - }) - .collect()) + async fn discover_distributors(&self) -> Result { + Ok(Discovery { + distributors: self + .distributors + .lock() + .unwrap() + .values() + .map(|d| DiscoveredDistributor { + launcher_id: d.launcher_id, + store_id: d.store_id, + root: d.root, + }) + .collect(), + }) } async fn resolve_launch_comment( @@ -1429,10 +1433,8 @@ mod tests { struct HintOnlyPort(FakeChainPort); #[async_trait] impl ClaimChainPort for HintOnlyPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { - Ok(Vec::new()) + async fn discover_distributors(&self) -> Result { + Ok(Discovery::default()) } async fn resolve_launch_comment( &self, @@ -1518,9 +1520,7 @@ mod tests { struct AlwaysFaultingDiscoveryPort; #[async_trait] impl ClaimChainPort for AlwaysFaultingDiscoveryPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { + async fn discover_distributors(&self) -> Result { Err(ClaimPortError::Other("simulated chain fault".into())) } async fn resolve_launch_comment( @@ -2829,9 +2829,7 @@ mod tests { #[async_trait] impl ClaimChainPort for FlakyThenHealthyPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { + async fn discover_distributors(&self) -> Result { let call_number = self.calls.fetch_add(1, Ordering::SeqCst) + 1; if call_number == 1 { return Err(ClaimPortError::Unavailable); @@ -2938,9 +2936,7 @@ mod tests { #[async_trait] impl ClaimChainPort for HealthyThenUnavailablePort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { + async fn discover_distributors(&self) -> Result { let call_number = self.calls.fetch_add(1, Ordering::SeqCst) + 1; if call_number == 1 { return self.inner.discover_distributors().await; @@ -3062,13 +3058,11 @@ mod tests { #[async_trait] impl ClaimChainPort for DuplicatingDiscoveryPort { - async fn discover_distributors( - &self, - ) -> Result, ClaimPortError> { - let mut v = self.0.discover_distributors().await?; - let doubled = v.clone(); - v.extend(doubled); - Ok(v) + async fn discover_distributors(&self) -> Result { + let mut discovery = self.0.discover_distributors().await?; + let doubled = discovery.distributors.clone(); + discovery.distributors.extend(doubled); + Ok(discovery) } async fn resolve_launch_comment( &self, diff --git a/crates/dig-node-service/src/rewards_claim/mod.rs b/crates/dig-node-service/src/rewards_claim/mod.rs index d4714c58..6146f540 100644 --- a/crates/dig-node-service/src/rewards_claim/mod.rs +++ b/crates/dig-node-service/src/rewards_claim/mod.rs @@ -70,7 +70,9 @@ pub use engine::ClaimEngine; pub use hints::{DistributorHint, DistributorHintSource, NoHintSource}; pub use parser::parse_launch_comment; pub use port::{ClaimChainPort, ClaimPortError, UnavailableClaimChainPort}; -pub use types::{ClaimLoopState, ClaimOutcome, ClaimStatus, DiscoveredDistributor, OwnEntry}; +pub use types::{ + ClaimLoopState, ClaimOutcome, ClaimStatus, DiscoveredDistributor, Discovery, OwnEntry, +}; #[cfg(test)] mod tests { diff --git a/crates/dig-node-service/src/rewards_claim/port.rs b/crates/dig-node-service/src/rewards_claim/port.rs index 9a4c57a3..36f02f16 100644 --- a/crates/dig-node-service/src/rewards_claim/port.rs +++ b/crates/dig-node-service/src/rewards_claim/port.rs @@ -8,7 +8,7 @@ use async_trait::async_trait; use chia_protocol::Bytes32; -use super::types::{DiscoveredDistributor, OwnEntry}; +use super::types::{DiscoveredDistributor, Discovery, OwnEntry}; /// Why a claim-chain call could not complete. #[derive(Debug, Clone, PartialEq, Eq)] @@ -27,7 +27,7 @@ pub enum ClaimPortError { pub trait ClaimChainPort: Send + Sync { /// SPEC §13.1: every CHIP-0051 distributor on chain whose launch comment parses per §1.3 — /// before the §9.3 reserve-asset filter, which the engine applies via [`Self::reserve_asset_id`]. - async fn discover_distributors(&self) -> Result, ClaimPortError>; + async fn discover_distributors(&self) -> Result; /// Re-derive one launcher id's launch comment from chain (SPEC §13.2 clause 1: a gossip hint is /// untrusted, so it is verified through this same on-chain path, never trusted directly). @@ -82,7 +82,7 @@ pub struct UnavailableClaimChainPort; #[async_trait] impl ClaimChainPort for UnavailableClaimChainPort { - async fn discover_distributors(&self) -> Result, ClaimPortError> { + async fn discover_distributors(&self) -> Result { Err(ClaimPortError::Unavailable) } diff --git a/crates/dig-node-service/src/rewards_claim/types.rs b/crates/dig-node-service/src/rewards_claim/types.rs index dfe22737..78b0487b 100644 --- a/crates/dig-node-service/src/rewards_claim/types.rs +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -13,6 +13,13 @@ pub struct DiscoveredDistributor { pub root: Bytes32, } +/// One `discover_distributors` call's result: every distributor the port decoded and verified. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Discovery { + /// Every distributor whose candidate id was actually decoded and verified this call. + pub distributors: Vec, +} + /// This node's own entry slot on one distributor (SPEC §10.2): keyed by a payout PUZZLE HASH, never /// a pubkey, re-read fresh before every claim (SPEC §12.5 clause 3) and never cached across cycles. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 8a291270..e8543ba3 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -119,6 +119,13 @@ pub struct AppState { /// re-implemented — it is already a per-[`RequestorId`] token-bucket registry with the /// identity-cycling table bound this needs. control_ingress: Arc, + /// The per-source INGRESS bound on the two OPEN, chain-keyed reward reads + /// (dig_ecosystem#3355): `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments`. + /// + /// A SEPARATE bucket from [`AppState::control_ingress`] (see `is_open_reward_chain_read`'s + /// call site): sharing one bucket would let one client's rewards-pane polling refuse its own + /// unrelated lineage-walk reads (or vice versa) under a code that names the wrong bound. + reward_ingress: Arc, /// §25.8's bond observation, as the last mirror pass published it (dig-node#412 step 7). /// /// Held on the shared state rather than rebuilt per request precisely so the control surface @@ -148,6 +155,18 @@ const CONTROL_INGRESS_REFILL_PER_SEC: f64 = 8.0; /// lowering the burst should fail the BUILD, not wait for someone to run the right test. const _: () = assert!(CONTROL_INGRESS_BURST >= 12.0); +/// Per-source burst for the two OPEN, chain-keyed reward reads (dig_ecosystem#3355): +/// `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments`. Sized identically to +/// [`CONTROL_INGRESS_BURST`] for the same reason — "one rewards pane, a handful of reads" — +/// on its OWN bucket (`AppState::reward_ingress`) so it cannot refuse or be refused by the +/// unrelated control-read bound. +const REWARD_INGRESS_BURST: f64 = 32.0; + +/// Sustained per-source rate for OPEN reward chain reads once the burst is spent. Matches +/// [`CONTROL_INGRESS_REFILL_PER_SEC`]: comfortably above a human-driven refresh, far below what +/// makes the upstream chain work matter. +const REWARD_INGRESS_REFILL_PER_SEC: f64 = 8.0; + /// dig-node's "method not found" error code. `handle_rpc` resolves only /// `dig.getContent` / `dig.getAnchoredRoot` / `cache.*` and returns this for /// anything else; this service treats that as the cue to blind-passthrough the @@ -599,6 +618,10 @@ pub async fn build_state(config: &Config) -> AppState { CONTROL_INGRESS_BURST, CONTROL_INGRESS_REFILL_PER_SEC, )), + reward_ingress: Arc::new(dig_node_core::rate_limit::MissRateLimiter::new( + REWARD_INGRESS_BURST, + REWARD_INGRESS_REFILL_PER_SEC, + )), } } @@ -1259,9 +1282,20 @@ async fn rpc( // it never reaches this HTTP `rpc` handler. Anonymous public CONTENT reads remain ungated; only // these holder-/holdings-revealing methods are gated. (WS parity: `cache.*` is not routable over // `/ws` — the wallet-backend fall-through has no `cache.*` arm — asserted in the server tests.) + // + // FOLDED IN (dig_ecosystem#3352): the three NODE-LOCAL reward reads (`is_node_local_reward_read`) + // — `dig.getRewardProverStatus` (this node's own prover-inventory registry), `dig.listRewardDistributors` + // (this node's own funded-distributor identity set), and `dig.getPayeeRewardClaimStatus` (this + // node's own payee claim state) — join the SAME gate rather than duplicating the token-extraction + // block, because they fail the same WHO-NAMES-THE-SUBJECT test §7.2 already applies here: each + // volunteers a node-to-launcher-id or node-to-inventory ASSOCIATION nobody supplied, exactly the + // shape `cache.listCached` is gated for. `requires_http_token` is the single pure predicate this + // union compiles to; `openrpc_drift_guard`'s `served_classes_are_well_formed` pins the catalogue's + // `requires_auth` to it BY EQUALITY. if method == "cache.fetchAndCache" || method == "cache.pushCapsule" || method == "cache.listCached" + || is_node_local_reward_read(&method) { let header_tok = headers .get(control::CONTROL_TOKEN_HEADER) @@ -1281,17 +1315,46 @@ async fn rpc( Json(rpc_error( id, ErrorCode::Unauthorized, - "cache.fetchAndCache / cache.pushCapsule / cache.listCached require the local \ - control token (X-Dig-Control-Token header or params._control_token) or a paired \ - controller token (see `dig-node pair`): fetchAndCache/pushCapsule make this node \ - a durable DHT holder of the requested capsule, and listCached enumerates the \ - operator's cached-capsule inventory (deanonymizing consumed content) — none is a \ - public read", + "cache.fetchAndCache / cache.pushCapsule / cache.listCached / \ + dig.getRewardProverStatus / dig.listRewardDistributors / \ + dig.getPayeeRewardClaimStatus require the local control token \ + (X-Dig-Control-Token header or params._control_token) or a paired controller \ + token (see `dig-node pair`): fetchAndCache/pushCapsule make this node a durable \ + DHT holder of the requested capsule, listCached enumerates the operator's \ + cached-capsule inventory (deanonymizing consumed content), and the three reward \ + reads volunteer a node-to-launcher-id/inventory association nobody supplied — \ + none is a public read", )), ); } } + // OPEN reward-chain INGRESS bound (dig_ecosystem#3355): `dig.getRewardDistributor` and + // `dig.listRewardDistributorCommitments` stay OPEN (dig_ecosystem#3351) — each is one chain read + // for ANY caller-supplied `launcher_id` — so, exactly like the token-less `control.*` reads above, + // an anonymous caller could otherwise drive unbounded upstream chain work simply by asking, + // repeatedly, for free. Bounded PER SOURCE (`RequestorId`, never `launcher_id` — the caller + // supplies and controls that value, so a limiter keyed on it is a DoS primitive an attacker + // rotates around) on a SEPARATE bucket from `control_ingress`: sized for "one rewards pane, a + // handful of reads", not shared with the lineage-walk bound, so one client's rewards polling can + // never refuse its own unrelated wallet reads with a code that says the wrong bound fired. The + // loopback operator is exempt, identically to `control_ingress` (`control_ingress_admits` is + // already generic over the limiter). + if is_open_reward_chain_read(&method) + && !control_ingress_admits(&state.reward_ingress, &requestor) + { + return ( + StatusCode::OK, + Json(rpc_error( + id, + ErrorCode::RewardIngressLimited, + "open reward chain reads are rate-limited per source; back off and retry. This is \ + the INGRESS bound on requests to this node (per-source), distinct from \ + CONTROL_INGRESS_LIMITED (open control reads) and WALLET_RATE_LIMITED (chain-egress).", + )), + ); + } + // CHAT gate (F1, #1946): `chat.send` seals + BLS-signs a directed message as this node's OWN // 0x0010 identity, and `chat.poll` DRAINS the inbound inbox — both wield node-owned crypto/state, // so they require the control token exactly like `control.*` mutations. A loopback address alone @@ -1433,6 +1496,47 @@ fn is_gated_chat_method(method: &str) -> bool { matches!(method, "chat.send" | "chat.poll") } +/// The three NODE-LOCAL reward reads token-gated at HTTP ingress (dig_ecosystem#3352): each +/// volunteers a node-to-launcher-id or node-to-inventory ASSOCIATION nobody supplied — +/// `dig.getRewardProverStatus` (this node's own `reward_prover_statuses` registry), +/// `dig.listRewardDistributors` (this node's own `FundedDistributorRegistry`), and +/// `dig.getPayeeRewardClaimStatus` (this node's own payee-side claim state) — failing the same +/// WHO-NAMES-THE-SUBJECT test §7.2 already applies (SPEC §5.5). PURE. +fn is_node_local_reward_read(method: &str) -> bool { + matches!( + method, + "dig.getRewardProverStatus" + | "dig.listRewardDistributors" + | "dig.getPayeeRewardClaimStatus" + ) +} + +/// The two OPEN, chain-keyed reward reads rate-bounded per source at HTTP ingress +/// (dig_ecosystem#3355): `dig.getRewardDistributor` and `dig.listRewardDistributorCommitments`, each +/// one `RewardsChainPort::distributor_report` call for any caller-supplied `launcher_id`. Does NOT +/// include `dig.listRewardDistributors` — that read does N chain reads too, but it is token-GATED +/// per dig_ecosystem#3352, so a presented credential is already accountable for it, same as every +/// `control.*` method. PURE. +fn is_open_reward_chain_read(method: &str) -> bool { + matches!( + method, + "dig.getRewardDistributor" | "dig.listRewardDistributorCommitments" + ) +} + +/// Whether `method` requires the HTTP token gate (master control token OR a valid paired token) at +/// this `POST /` ingress — the COMPILED statement the catalogue's `MethodInfo::requires_auth` must +/// equal (`openrpc_drift_guard::served_classes_are_well_formed` pins the equality; see SPEC §5.5). +/// The union: the holder-/holdings-revealing `cache.*` landing trio, the node-owned-identity chat +/// pair, and the three node-local reward reads (dig_ecosystem#3352). PURE. +pub fn requires_http_token(method: &str) -> bool { + method == "cache.fetchAndCache" + || method == "cache.pushCapsule" + || method == "cache.listCached" + || is_gated_chat_method(method) + || is_node_local_reward_read(method) +} + /// Whether `token` authorizes a gated chat call (F1, #1946): the master control token (constant-time) /// OR a valid paired controller token — the same master-or-paired policy that gates `control.*` and /// the wallet surface. Fails CLOSED on an empty master (the in-memory CSPRNG-failure sentinel) so a @@ -3379,8 +3483,9 @@ fn spawn_collateral_census(chain: Arc) mod tests { use super::{ chat_call_authorized, control_ingress_admits, is_app_origin, is_gated_chat_method, - is_local_origin, peer_tier_status, provenance_for, read_origin_for, reflects_origin, - requestor_for, served_response, ws_token, ServeProvenance, StorePath, APP_ORIGINS_ENV, + is_local_origin, is_node_local_reward_read, is_open_reward_chain_read, peer_tier_status, + provenance_for, read_origin_for, reflects_origin, requestor_for, requires_http_token, + served_response, ws_token, ServeProvenance, StorePath, APP_ORIGINS_ENV, EXPOSED_DIG_HEADERS, }; use axum::http::{HeaderMap, Method}; @@ -3520,6 +3625,118 @@ mod tests { ); } + /// **Proves (dig_ecosystem#3355):** the two OPEN chain-keyed reward reads + /// (`dig.getRewardDistributor`, `dig.listRewardDistributorCommitments`) are rate-bounded PER + /// SOURCE, never per `launcher_id` — the caller supplies and controls `launcher_id`, so a + /// limiter keyed on it would be a DoS primitive an attacker rotates around for free. + /// + /// Drives the predicate/limiter pair directly, exactly as + /// `an_anonymous_flood_is_refused_at_ingress_once_its_burst_is_spent` does for the sibling + /// `control_ingress` bound: a real HTTP test can't drive a non-loopback `RequestorId` (the test + /// client IS the loopback operator), so the meaningful assertion lives here, at the pair the + /// HTTP gate calls. + /// + /// **Catches (M3):** a limiter keyed on `launcher_id` instead of `RequestorId` — the same + /// requestor rotating which `launcher_id` it names in `params` must stay refused once its own + /// budget is spent; a different SOURCE must never be touched by another source's burst. + #[test] + fn open_reward_chain_reads_are_rate_bounded_per_source() { + let limiter = MissRateLimiter::new(32.0, 8.0); + let source_a = anon("198.51.100.7"); + let source_b = anon("198.51.100.8"); + + for i in 0..32 { + assert!( + control_ingress_admits(&limiter, &source_a), + "call {i} is within the 32-burst and must be admitted" + ); + } + assert!( + !control_ingress_admits(&limiter, &source_a), + "the 33rd call from the same source must be refused: its burst is spent" + ); + assert!( + control_ingress_admits(&limiter, &source_b), + "a different source draws from its own bucket and is untouched by source_a's burst" + ); + // M3: the SAME requestor rotating which `launcher_id` it names must stay refused — the + // limiter is keyed on the connection's `RequestorId`, never on caller-supplied request + // content, so nothing about the (unmodelled here) launcher_id can revive its budget. + assert!( + !control_ingress_admits(&limiter, &source_a), + "the same source must stay refused regardless of what launcher_id it names in params" + ); + } + + /// Pins the two OPEN reward reads and no others into `is_open_reward_chain_read` — the + /// predicate the ingress bound gates on. `dig.listRewardDistributors` does N chain reads too, + /// but it is token-GATED per dig_ecosystem#3352 (a presented credential is accountable, same as + /// every `control.*` method), so it must NOT be in this OPEN, per-source-bounded set. + #[test] + fn is_open_reward_chain_read_is_exactly_the_two_open_reads() { + assert!(is_open_reward_chain_read("dig.getRewardDistributor")); + assert!(is_open_reward_chain_read( + "dig.listRewardDistributorCommitments" + )); + assert!( + !is_open_reward_chain_read("dig.listRewardDistributors"), + "listRewardDistributors is token-gated per #3352, not ingress-bounded" + ); + assert!(!is_open_reward_chain_read("dig.getRewardProverStatus")); + assert!(!is_open_reward_chain_read("dig.getPayeeRewardClaimStatus")); + assert!(!is_open_reward_chain_read("dig.getContent")); + } + + /// Pins the three NODE-LOCAL reward reads (and no others) into `is_node_local_reward_read` — + /// the predicate the HTTP token gate folds in beside the `cache.*` trio and the chat pair. + #[test] + fn is_node_local_reward_read_is_exactly_the_three_gated_reads() { + assert!(is_node_local_reward_read("dig.getRewardProverStatus")); + assert!(is_node_local_reward_read("dig.listRewardDistributors")); + assert!(is_node_local_reward_read("dig.getPayeeRewardClaimStatus")); + assert!(!is_node_local_reward_read("dig.getRewardDistributor")); + assert!(!is_node_local_reward_read( + "dig.listRewardDistributorCommitments" + )); + assert!(!is_node_local_reward_read("dig.getContent")); + } + + /// **Proves:** `requires_http_token` is exactly the union of the cache-trio landing gate, the + /// gated chat pair, and the three node-local reward reads — the single pure predicate the + /// `openrpc_drift_guard` equality test (`served_classes_are_well_formed`) pins the catalogue + /// against. + /// + /// **Catches (M1):** dropping `is_node_local_reward_read` from the union — the three reward + /// reads would stop requiring a token here while the catalogue still says they do, and the + /// drift-guard equality test (not this one) is what actually trips on that; this test pins the + /// union's OWN membership so a future edit to the fold does not silently narrow it. + #[test] + fn requires_http_token_is_the_gate_plus_reward_reads_union() { + for m in [ + "cache.fetchAndCache", + "cache.pushCapsule", + "cache.listCached", + "chat.send", + "chat.poll", + "dig.getRewardProverStatus", + "dig.listRewardDistributors", + "dig.getPayeeRewardClaimStatus", + ] { + assert!(requires_http_token(m), "{m} must require the HTTP token"); + } + for m in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + "dig.getContent", + "control.status", + ] { + assert!( + !requires_http_token(m), + "{m} must not require the HTTP token via this predicate" + ); + } + } + /// **Regression (#1763):** the `X-Dig-Peer-Tier` wire value for BOTH tiers, asserted on the real /// response builder rather than on the enum alone. /// diff --git a/crates/dig-node-service/tests/common/rewards_fixture.rs b/crates/dig-node-service/tests/common/rewards_fixture.rs index 964bbb66..b8fcd0ff 100644 --- a/crates/dig-node-service/tests/common/rewards_fixture.rs +++ b/crates/dig-node-service/tests/common/rewards_fixture.rs @@ -66,6 +66,16 @@ pub struct LaunchedFixture { /// `launch_dig_distributor` against a fresh `Simulator` — trimmed from /// `dig-rewards-coin::tests::simulator::launch_harness_with_constants_builder`. pub fn launch_fixture() -> Result> { + launch_fixture_with_approval(false) +} + +/// Same as [`launch_fixture`], but with an explicit `require_payout_approval` -- DIG-Network/dig_ecosystem#3362 +/// needs a REAL simulator launch with the flag curried `true` (a fixture starting where production +/// cannot hides the bug -- a struct literal would never prove the chain-curried value is what the +/// adapter actually reads). +pub fn launch_fixture_with_approval( + require_payout_approval: bool, +) -> Result> { let ctx = &mut SpendContext::new(); let mut sim = Simulator::new(); @@ -163,7 +173,7 @@ pub fn launch_fixture() -> Result> { // from the default, or a port that ignores the chain and returns the default constant // reads as correct by coincidence. See `reserve_asset_id_and_payout_threshold_are_read_from_chain`. PAYOUT_THRESHOLD_BASE_UNITS.saturating_add(1_000_000), - false, + require_payout_approval, 0, WITHDRAWAL_SHARE_BPS, source_cat.info.asset_id, @@ -457,6 +467,18 @@ pub struct FundedFixture { #[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs pub fn launch_funded_admitted_fixture( payout_puzzle_hash: Bytes32, +) -> Result> { + launch_funded_admitted_fixture_with_approval(payout_puzzle_hash, false) +} + +/// Same as [`launch_funded_admitted_fixture`], but with an explicit `require_payout_approval` -- +/// DIG-Network/dig_ecosystem#3362 needs a REAL simulator launch (funded, admitted, above +/// threshold) with the flag curried `true`, not a struct literal a production read path could +/// never actually produce. +#[allow(dead_code)] +pub fn launch_funded_admitted_fixture_with_approval( + payout_puzzle_hash: Bytes32, + require_payout_approval: bool, ) -> Result> { let ctx = &mut SpendContext::new(); let mut sim = Simulator::new(); @@ -553,7 +575,7 @@ pub fn launch_funded_admitted_fixture( u64::MAX, MAX_SECONDS_OFFSET, PAYOUT_THRESHOLD_BASE_UNITS, - false, + require_payout_approval, 0, WITHDRAWAL_SHARE_BPS, source_cat.info.asset_id, @@ -618,6 +640,12 @@ pub fn launch_funded_admitted_fixture( )?, ); + // DIG-Network/dig_ecosystem#3357: safe here ONLY because `distributor` is this fixture's own + // freshly-built IN-PROCESS value -- these slots come from ITS OWN `pending_spend` this same + // generation, never from a distributor rebuilt from chain (where this call would derive a + // PHANTOM `LineageProof` for an earlier generation's slot). Production code must never call + // this; see `clippy.toml`'s `disallowed-methods` entry for the ban. + #[allow(clippy::disallowed_methods)] let reward_slots: Vec<_> = distributor .pending_spend .created_reward_slots diff --git a/crates/dig-node-service/tests/openrpc_drift_guard.rs b/crates/dig-node-service/tests/openrpc_drift_guard.rs index 5aa95e60..7412ddf4 100644 --- a/crates/dig-node-service/tests/openrpc_drift_guard.rs +++ b/crates/dig-node-service/tests/openrpc_drift_guard.rs @@ -272,9 +272,16 @@ fn control_peers_connect_disconnect_are_catalogued_and_not_peer_reachable() { fn served_classes_are_well_formed() { for m in meta::methods() { match m.served { - "local" | "passthrough" | "shell" => assert!( - !m.requires_auth, - "{} is a read/discovery method and must not require auth", + // `requires_auth` is the COMPILED statement of the HTTP token gate (SPEC §5.5): the + // set of catalogued methods with `requires_auth: true` MUST equal the set + // `server::requires_http_token` gates at `POST /` — not merely "false for every + // read", since dig_ecosystem#3352 token-gates a handful of non-`control.*` reads + // (the cache trio, the chat pair, the three node-local reward reads) that volunteer + // node-local state. + "local" | "passthrough" | "shell" => assert_eq!( + m.requires_auth, + dig_node_service::server::requires_http_token(m.name), + "{}: catalogued requires_auth must equal requires_http_token", m.name ), "control" => { diff --git a/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs b/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs index 89da668f..260df97f 100644 --- a/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs +++ b/crates/dig-node-service/tests/rewards_claim_chain_port_3347.rs @@ -26,8 +26,8 @@ use dig_rewards_coin::constants::PAYOUT_THRESHOLD_BASE_UNITS; use dig_wallet::sage::spend::MockBroadcaster; use common::rewards_fixture::{ - launch_fixture, launch_funded_admitted_fixture, mock_chain_source, - mock_chain_source_for_funded_fixture, + launch_fixture, launch_funded_admitted_fixture, launch_funded_admitted_fixture_with_approval, + mock_chain_source, mock_chain_source_for_funded_fixture, }; /// An index that proposes exactly the ids it is built with -- no re-verification of its own; that @@ -54,15 +54,18 @@ async fn discover_distributors_returns_exactly_the_real_launch() { Arc::new(MockBroadcaster::default()), ); - let discovered = port + let discovery = port .discover_distributors() .await .expect("a real launched distributor must discover"); - assert_eq!(discovered.len(), 1); - assert_eq!(discovered[0].launcher_id, fixture.launcher_id); - assert_eq!(discovered[0].store_id, fixture.launch_comment.store_id); - assert_eq!(discovered[0].root, fixture.launch_comment.root); + assert_eq!(discovery.distributors.len(), 1); + assert_eq!(discovery.distributors[0].launcher_id, fixture.launcher_id); + assert_eq!( + discovery.distributors[0].store_id, + fixture.launch_comment.store_id + ); + assert_eq!(discovery.distributors[0].root, fixture.launch_comment.root); } /// SPEC 13.1 clause 2: an index only PROPOSES. A bogus id mixed in with the real one must be @@ -79,17 +82,41 @@ async fn a_bogus_index_entry_is_dropped_not_echoed() { Arc::new(MockBroadcaster::default()), ); - let discovered = port + let discovery = port .discover_distributors() .await .expect("a bogus id must be dropped, not fail the whole discovery"); assert_eq!( - discovered.len(), + discovery.distributors.len(), 1, "an index lie must yield nothing for that id, and never overrule the real one" ); - assert_eq!(discovered[0].launcher_id, fixture.launcher_id); + assert_eq!(discovery.distributors[0].launcher_id, fixture.launcher_id); +} + +/// Discovery decodes EVERY candidate the index proposes -- there is no per-cycle bound, so a real +/// launcher is never pushed out of a cycle by the number (or transport order) of the candidates +/// around it. The companion to `a_bogus_index_entry_is_dropped_not_echoed`, with the real id +/// FIRST, so neither order can be the only one that works. +#[tokio::test(flavor = "multi_thread")] +async fn every_candidate_the_index_proposes_is_decoded() { + let fixture = launch_fixture().expect("a real distributor launches cleanly in the simulator"); + let source = mock_chain_source(&fixture); + let bogus_id = Bytes32::from([0xEE; 32]); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id, bogus_id]), + Arc::new(MockBroadcaster::default()), + ); + + let discovery = port + .discover_distributors() + .await + .expect("a real launched distributor must discover"); + + assert_eq!(discovery.distributors.len(), 1); + assert_eq!(discovery.distributors[0].launcher_id, fixture.launcher_id); } /// `reserve_asset_id` and `payout_threshold` are real chain-curried reads, not the crate's own @@ -171,13 +198,25 @@ async fn a_failing_source_reports_unavailable_everywhere() { MockChainSource::new().fail_with(dig_chainsource_interface::ChainSourceError::Transport( "simulated transport failure".into(), )); + let launcher_id = Bytes32::from([1u8; 32]); + // DIG-Network/dig_ecosystem#3363: a NON-empty index -- discovery must reach the failing + // source's own `Unavailable` answer, not stop short on an empty candidate list (which would + // pass this assertion for the wrong reason, without ever driving the source at all). let port = RealClaimChainPort::new( Arc::new(source), - FixtureLauncherIndex(vec![]), + FixtureLauncherIndex(vec![launcher_id]), Arc::new(MockBroadcaster::default()), ); - let launcher_id = Bytes32::from([1u8; 32]); + assert_eq!( + port.discover_distributors().await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.submit_initiate_payout(launcher_id, Bytes32::from([2u8; 32]), 0) + .await, + Err(ClaimPortError::Unavailable) + ); assert_eq!( port.reserve_asset_id(launcher_id).await, Err(ClaimPortError::Unavailable) @@ -376,6 +415,60 @@ async fn submit_initiate_payout_builds_a_bundle_the_simulator_accepts_and_pays_t ); } +/// DIG-Network/dig_ecosystem#3362: a distributor that curries `require_payout_approval = true` +/// must be REFUSED, by name, before any bundle is built or broadcast -- this adapter drops +/// `initiate_payout`'s returned `conditions` unconditionally (see `chain_port.rs`'s module doc), so +/// proceeding here would build a bundle the chain would reject anyway, but only after this +/// adapter's caller believed the payout had been submitted. A REAL simulator launch with the flag +/// curried true (never a struct literal -- a fixture starting where production cannot reach hides +/// the bug), funded and admitted so the refusal is proven against an entry that would otherwise be +/// perfectly payable. +#[tokio::test(flavor = "multi_thread")] +async fn a_distributor_requiring_payout_approval_is_refused_by_name_before_any_broadcast() { + let payout_puzzle_hash = Bytes32::from([0x55; 32]); + let fixture = launch_funded_admitted_fixture_with_approval(payout_puzzle_hash, true).expect( + "a funded, admitted distributor with require_payout_approval=true must launch cleanly", + ); + let source = mock_chain_source_for_funded_fixture(&fixture); + let broadcaster = Arc::new(MockBroadcaster::default()); + let port = RealClaimChainPort::new( + Arc::new(source), + FixtureLauncherIndex(vec![fixture.launcher_id]), + broadcaster.clone(), + ); + + let entry = port + .own_entry(fixture.launcher_id, payout_puzzle_hash) + .await + .expect("the admitted entry must read") + .expect("the fixture admitted exactly this payout puzzle hash"); + assert!( + entry.accrued_base_units >= fixture.constants.payout_threshold, + "the entry must clear its own threshold -- proving the refusal fires on a distributor that \ + would otherwise be perfectly payable, not merely an ineligible one" + ); + + let result = port + .submit_initiate_payout(fixture.launcher_id, payout_puzzle_hash, 0) + .await; + match result { + Err(ClaimPortError::Other(msg)) => { + assert!( + msg.contains("require_payout_approval"), + "the refusal must name the reason: {msg}" + ); + } + other => panic!("expected a named refusal, got {other:?}"), + } + + let sent = broadcaster.sent.lock().expect("the broadcaster's own lock"); + assert_eq!( + sent.len(), + 0, + "a require_payout_approval=true distributor must never reach the broadcaster" + ); +} + /// DIG-Network/dig_ecosystem#3347's CLOSURE ARTIFACT: drives the whole PRODUCTION BODY /// (`run_claim_driver_in`, the same function `run_claim_driver` calls in production, over a real /// `RealClaimChainPort`) against a real, funded, admitted distributor -- and asserts the payout @@ -412,12 +505,12 @@ async fn a_driven_cycle_over_a_funded_admitted_distributor_pays_this_peer() { "half an epoch with one entry must have accrued something" ); - let discovered = port + let discovery = port .discover_distributors() .await .expect("discovery must not error"); assert_eq!( - discovered.len(), + discovery.distributors.len(), 1, "discovery must find the one real distributor this fixture launched" ); diff --git a/crates/dig-node-service/tests/server.rs b/crates/dig-node-service/tests/server.rs index 92b63f44..7846a755 100644 --- a/crates/dig-node-service/tests/server.rs +++ b/crates/dig-node-service/tests/server.rs @@ -1535,17 +1535,18 @@ 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). +/// **Proves (dig_ecosystem#3351/#3352, WS parity):** ALL FIVE reward reads -- the two OPEN, +/// chain-keyed reads (`dig.getRewardDistributor`, `dig.listRewardDistributorCommitments`) and the +/// three HTTP-token-gated, node-local reads (`dig.getRewardProverStatus`, +/// `dig.listRewardDistributors`, `dig.getPayeeRewardClaimStatus`) -- have no WS-reachable path at +/// all, regardless of which HTTP tier each carries. The `ws_dispatch` fall-through routes an +/// unrecognized method to `WalletBackend::dispatch`, whose match has no `dig.*` arm, so every one +/// comes back as an unknown-method error over `/ws` -- never as `UNAUTHORIZED` (that would mean WS +/// gates a method where HTTP does not, or vice versa, either of which is its own bug) and never as +/// a real result (that would mean a reward 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. +/// **Catches:** a wallet-backend or `ws_dispatch` arm that starts routing any `dig.*` reward read +/// 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; @@ -1560,11 +1561,15 @@ async fn reward_distributor_reads_are_not_routable_over_ws() { for (idx, method) in [ "dig.getRewardDistributor", "dig.listRewardDistributorCommitments", + "dig.getRewardProverStatus", + "dig.listRewardDistributors", + "dig.getPayeeRewardClaimStatus", ] .into_iter() .enumerate() { - // No token: these reads are OPEN on HTTP, but that has no bearing on WS routability. + // No token: these reads (OPEN or HTTP-token-gated) have no bearing on WS routability -- + // the WS transport simply never dispatches ANY reward method (dig_ecosystem#3352/#3355). ws.send(Message::Text( json!({ "id": format!("rd{idx}"), "type": "request", "method": method }).to_string(), )) @@ -3962,3 +3967,164 @@ async fn reward_distributor_reads_answer_on_post_slash_without_a_token() { ); } } + +/// Proves (dig_ecosystem#3352): the three NODE-LOCAL reward reads (`dig.getRewardProverStatus`, +/// `dig.listRewardDistributors`, `dig.getPayeeRewardClaimStatus`) are token-gated at the HTTP +/// `POST /` ingress exactly like the `cache.*` landing trio: an untokened call is `-32030 +/// UNAUTHORIZED` and the response body carries NONE of the fields the handler would otherwise +/// return (`statuses`/`funded`/`subject`) -- a demoted gate that still leaked the payload alongside +/// the error would defeat the whole point. The master control token AND a genuine paired token each +/// clear the gate and reach the real handler (proven by the handler-specific shape each answers +/// with on this ephemeral, chain-portless node). +/// +/// Catches: any of the three reads left OPEN (or newly gated but leaking a body on refusal), and a +/// paired-token caller wrongly excluded from a gate that (per #3352, unlike wallet mutations) is +/// master-OR-paired, not master-only. +#[tokio::test] +async fn node_local_reward_reads_require_the_control_token() { + let (upstream, _calls) = start_mock_upstream().await; + let (addr, master, _hold) = start_node_full(&upstream).await; + + // A genuine paired token, obtained exactly as the extension does (see + // `a_paired_token_cannot_grant_itself_a_trusted_chia_peer`). + let req = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 1, "method": "pairing.request", + "params": { "client_name": "DIG Chrome Extension" } }), + None, + ) + .await; + let pairing_id = req["result"]["pairing_id"].as_str().unwrap().to_string(); + let approve = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 2, "method": "control.pairing.approve", + "params": { "pairing_id": pairing_id } }), + Some(&master), + ) + .await; + assert_eq!(approve["result"]["approved"], json!(true)); + let paired = poll_pairing(&addr, &pairing_id).await["result"]["token"] + .as_str() + .unwrap() + .to_string(); + + let cases: &[(&str, &str)] = &[ + ("dig.getRewardProverStatus", "statuses"), + ("dig.listRewardDistributors", "funded"), + ("dig.getPayeeRewardClaimStatus", "subject"), + ]; + + for (method, leaked_field) in cases { + let body = json!({ "jsonrpc": "2.0", "id": 9, "method": method }); + + // No token: UNAUTHORIZED, and the payload never leaks alongside the refusal. + let rejected = post_rpc(&addr, body.clone(), None).await; + assert_eq!( + rejected["error"]["code"], + json!(-32030), + "{method} without a token must be -32030, got {rejected:?}" + ); + assert_eq!( + rejected["error"]["data"]["code"], + json!("UNAUTHORIZED"), + "{method} without a token must be UNAUTHORIZED, got {rejected:?}" + ); + assert!( + rejected + .pointer(&format!("/result/{leaked_field}")) + .is_none(), + "{method} must never leak /result/{leaked_field} on a rejected call, got {rejected:?}" + ); + + // Master control token: clears the gate, reaches the real handler. + let via_master = post_rpc(&addr, body.clone(), Some(&master)).await; + assert_ne!( + via_master["error"]["data"]["code"], + json!("UNAUTHORIZED"), + "{method} with the master control token must clear the gate, got {via_master:?}" + ); + + // Paired token: clears the gate too (master-OR-paired, same as the cache trio). + let via_paired = post_rpc(&addr, body, Some(&paired)).await; + assert_ne!( + via_paired["error"]["data"]["code"], + json!("UNAUTHORIZED"), + "{method} with a paired token must clear the gate, got {via_paired:?}" + ); + } + + // Handler-specific shape, proving dispatch (not a stub) answered each authorized call. + let prover = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 10, "method": "dig.getRewardProverStatus" }), + Some(&master), + ) + .await; + assert_eq!( + prover["result"]["statuses"]["outcome"], + json!("consulted"), + "got {prover:?}" + ); + + let listed = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 11, "method": "dig.listRewardDistributors" }), + Some(&master), + ) + .await; + // This ephemeral node never writes a funded-distributor registry record, so the honest + // answer is "nothing looked" (`not_consulted`), NEVER a "consulted, found nothing" that + // would be SPEC §12.5 clause 6's forbidden reassuring zero (`FundedDistributorsRead:: + // NotConfigured`, dig_ecosystem#3269 unit 2). This still proves dispatch reached the real + // handler (a stub or a gate leak would answer neither `funded` nor `claimable` at all). + assert_eq!( + listed["result"]["funded"]["outcome"], + json!("not_consulted"), + "got {listed:?}" + ); + assert_eq!( + listed["result"]["claimable"]["outcome"], + json!("not_consulted"), + "got {listed:?}" + ); + + let payee = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 12, "method": "dig.getPayeeRewardClaimStatus" }), + Some(&master), + ) + .await; + assert_eq!(payee["result"]["subject"], json!("payee"), "got {payee:?}"); +} + +/// Proves (dig_ecosystem#3355): the loopback operator, driving the two OPEN chain-keyed reward +/// reads (`dig.getRewardDistributor`) well past the 32-call burst that bounds a per-source flood, is +/// NEVER refused with `REWARD_INGRESS_LIMITED` -- the ingress bound exempts `RequestorId::Local` +/// exactly as the existing `control_ingress` bound does (`control_ingress_admits`). The +/// non-exempt/anonymous side of this bound cannot be driven over a real loopback TCP connection (the +/// test client IS the operator), so it is pinned at the predicate/limiter level instead -- +/// `open_reward_chain_reads_are_rate_bounded_per_source` in `server.rs`'s own unit tests, beside the +/// sibling `control_ingress` tests it mirrors. +/// +/// Catches: an ingress bound wired onto the OPEN reward reads that forgets the loopback exemption, +/// reproducing the #3051 failure (a polling operator refused its own reads) one bound over. +#[tokio::test] +async fn open_reward_chain_reads_never_limit_the_loopback_operator() { + let (upstream, _calls) = start_mock_upstream().await; + let (addr, _hold) = start_node(&upstream).await; + let launcher_id = "11".repeat(32); + for i in 0..40 { + let resp = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": i, "method": "dig.getRewardDistributor", + "params": { "launcher_id": launcher_id } }), + None, + ) + .await; + assert_ne!( + resp["error"]["data"]["code"], + json!("REWARD_INGRESS_LIMITED"), + "call {i} from the loopback operator must never be -32034, got {resp:?}" + ); + } +}