diff --git a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md deleted file mode 100644 index c9e0af34..00000000 --- a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -name: gitnexus-cli -description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\"" ---- - -# GitNexus CLI Commands - -All commands work via `npx` — no global install required. - -## Commands - -### analyze — Build or refresh the index - -```bash -npx gitnexus analyze -``` - -Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files. - -| Flag | Effect | -| -------------- | ---------------------------------------------------------------- | -| `--force` | Force full re-index even if up to date | -| `--embeddings` | Enable embedding generation for semantic search (off by default) | - -**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated. - -### status — Check index freshness - -```bash -npx gitnexus status -``` - -Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed. - -### clean — Delete the index - -```bash -npx gitnexus clean -``` - -Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. - -| Flag | Effect | -| --------- | ------------------------------------------------- | -| `--force` | Skip confirmation prompt | -| `--all` | Clean all indexed repos, not just the current one | - -### wiki — Generate documentation from the graph - -```bash -npx gitnexus wiki -``` - -Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). - -| Flag | Effect | -| ------------------- | ----------------------------------------- | -| `--force` | Force full regeneration | -| `--model ` | LLM model (default: minimax/minimax-m2.5) | -| `--base-url ` | LLM API base URL | -| `--api-key ` | LLM API key | -| `--concurrency ` | Parallel LLM calls (default: 3) | -| `--gist` | Publish wiki as a public GitHub Gist | - -### list — Show all indexed repos - -```bash -npx gitnexus list -``` - -Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information. - -## After Indexing - -1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded -2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task - -## Troubleshooting - -- **"Not inside a git repository"**: Run from a directory inside a git repo -- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server -- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding diff --git a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md deleted file mode 100644 index 9510b97a..00000000 --- a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -name: gitnexus-debugging -description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\"" ---- - -# Debugging with GitNexus - -## When to Use - -- "Why is this function failing?" -- "Trace where this error comes from" -- "Who calls this method?" -- "This endpoint returns 500" -- Investigating bugs, errors, or unexpected behavior - -## Workflow - -``` -1. gitnexus_query({query: ""}) → Find related execution flows -2. gitnexus_context({name: ""}) → See callers/callees/processes -3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow -4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed -``` - -> If "Index is stale" → run `npx gitnexus analyze` in terminal. - -## Checklist - -``` -- [ ] Understand the symptom (error message, unexpected behavior) -- [ ] gitnexus_query for error text or related code -- [ ] Identify the suspect function from returned processes -- [ ] gitnexus_context to see callers and callees -- [ ] Trace execution flow via process resource if applicable -- [ ] gitnexus_cypher for custom call chain traces if needed -- [ ] Read source files to confirm root cause -``` - -## Debugging Patterns - -| Symptom | GitNexus Approach | -| -------------------- | ---------------------------------------------------------- | -| Error message | `gitnexus_query` for error text → `context` on throw sites | -| Wrong return value | `context` on the function → trace callees for data flow | -| Intermittent failure | `context` → look for external calls, async deps | -| Performance issue | `context` → find symbols with many callers (hot paths) | -| Recent regression | `detect_changes` to see what your changes affect | - -## Tools - -**gitnexus_query** — find code related to error: - -``` -gitnexus_query({query: "payment validation error"}) -→ Processes: CheckoutFlow, ErrorHandling -→ Symbols: validatePayment, handlePaymentError, PaymentException -``` - -**gitnexus_context** — full context for a suspect: - -``` -gitnexus_context({name: "validatePayment"}) -→ Incoming calls: processCheckout, webhookHandler -→ Outgoing calls: verifyCard, fetchRates (external API!) -→ Processes: CheckoutFlow (step 3/7) -``` - -**gitnexus_cypher** — custom call chain traces: - -```cypher -MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"}) -RETURN [n IN nodes(path) | n.name] AS chain -``` - -## Example: "Payment endpoint returns 500 intermittently" - -``` -1. gitnexus_query({query: "payment error handling"}) - → Processes: CheckoutFlow, ErrorHandling - → Symbols: validatePayment, handlePaymentError - -2. gitnexus_context({name: "validatePayment"}) - → Outgoing calls: verifyCard, fetchRates (external API!) - -3. READ gitnexus://repo/my-app/process/CheckoutFlow - → Step 3: validatePayment → calls fetchRates (external) - -4. Root cause: fetchRates calls external API without proper timeout -``` diff --git a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md deleted file mode 100644 index 927a4e4b..00000000 --- a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -name: gitnexus-exploring -description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\"" ---- - -# Exploring Codebases with GitNexus - -## When to Use - -- "How does authentication work?" -- "What's the project structure?" -- "Show me the main components" -- "Where is the database logic?" -- Understanding code you haven't seen before - -## Workflow - -``` -1. READ gitnexus://repos → Discover indexed repos -2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness -3. gitnexus_query({query: ""}) → Find related execution flows -4. gitnexus_context({name: ""}) → Deep dive on specific symbol -5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow -``` - -> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal. - -## Checklist - -``` -- [ ] READ gitnexus://repo/{name}/context -- [ ] gitnexus_query for the concept you want to understand -- [ ] Review returned processes (execution flows) -- [ ] gitnexus_context on key symbols for callers/callees -- [ ] READ process resource for full execution traces -- [ ] Read source files for implementation details -``` - -## Resources - -| Resource | What you get | -| --------------------------------------- | ------------------------------------------------------- | -| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) | -| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) | -| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) | -| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) | - -## Tools - -**gitnexus_query** — find execution flows related to a concept: - -``` -gitnexus_query({query: "payment processing"}) -→ Processes: CheckoutFlow, RefundFlow, WebhookHandler -→ Symbols grouped by flow with file locations -``` - -**gitnexus_context** — 360-degree view of a symbol: - -``` -gitnexus_context({name: "validateUser"}) -→ Incoming calls: loginHandler, apiMiddleware -→ Outgoing calls: checkToken, getUserById -→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3) -``` - -## Example: "How does payment processing work?" - -``` -1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes -2. gitnexus_query({query: "payment processing"}) - → CheckoutFlow: processPayment → validateCard → chargeStripe - → RefundFlow: initiateRefund → calculateRefund → processRefund -3. gitnexus_context({name: "processPayment"}) - → Incoming: checkoutHandler, webhookHandler - → Outgoing: validateCard, chargeStripe, saveTransaction -4. Read src/payments/processor.ts for implementation details -``` diff --git a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md deleted file mode 100644 index 937ac73d..00000000 --- a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -name: gitnexus-guide -description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\"" ---- - -# GitNexus Guide - -Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema. - -## Always Start Here - -For any task involving code understanding, debugging, impact analysis, or refactoring: - -1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness -2. **Match your task to a skill below** and **read that skill file** -3. **Follow the skill's workflow and checklist** - -> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first. - -## Skills - -| Task | Skill to read | -| -------------------------------------------- | ------------------- | -| Understand architecture / "How does X work?" | `gitnexus-exploring` | -| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` | -| Trace bugs / "Why is X failing?" | `gitnexus-debugging` | -| Rename / extract / split / refactor | `gitnexus-refactoring` | -| Tools, resources, schema reference | `gitnexus-guide` (this file) | -| Index, status, clean, wiki CLI commands | `gitnexus-cli` | - -## Tools Reference - -| Tool | What it gives you | -| ---------------- | ------------------------------------------------------------------------ | -| `query` | Process-grouped code intelligence — execution flows related to a concept | -| `context` | 360-degree symbol view — categorized refs, processes it participates in | -| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | -| `detect_changes` | Git-diff impact — what do your current changes affect | -| `rename` | Multi-file coordinated rename with confidence-tagged edits | -| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | -| `list_repos` | Discover indexed repos | - -## Resources Reference - -Lightweight reads (~100-500 tokens) for navigation: - -| Resource | Content | -| ---------------------------------------------- | ----------------------------------------- | -| `gitnexus://repo/{name}/context` | Stats, staleness check | -| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores | -| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members | -| `gitnexus://repo/{name}/processes` | All execution flows | -| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace | -| `gitnexus://repo/{name}/schema` | Graph schema for Cypher | - -## Graph Schema - -**Nodes:** File, Function, Class, Interface, Method, Community, Process -**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS - -```cypher -MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"}) -RETURN caller.name, caller.filePath -``` diff --git a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md deleted file mode 100644 index e19af280..00000000 --- a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -name: gitnexus-impact-analysis -description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\"" ---- - -# Impact Analysis with GitNexus - -## When to Use - -- "Is it safe to change this function?" -- "What will break if I modify X?" -- "Show me the blast radius" -- "Who uses this code?" -- Before making non-trivial code changes -- Before committing — to understand what your changes affect - -## Workflow - -``` -1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this -2. READ gitnexus://repo/{name}/processes → Check affected execution flows -3. gitnexus_detect_changes() → Map current git changes to affected flows -4. Assess risk and report to user -``` - -> If "Index is stale" → run `npx gitnexus analyze` in terminal. - -## Checklist - -``` -- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents -- [ ] Review d=1 items first (these WILL BREAK) -- [ ] Check high-confidence (>0.8) dependencies -- [ ] READ processes to check affected execution flows -- [ ] gitnexus_detect_changes() for pre-commit check -- [ ] Assess risk level and report to user -``` - -## Understanding Output - -| Depth | Risk Level | Meaning | -| ----- | ---------------- | ------------------------ | -| d=1 | **WILL BREAK** | Direct callers/importers | -| d=2 | LIKELY AFFECTED | Indirect dependencies | -| d=3 | MAY NEED TESTING | Transitive effects | - -## Risk Assessment - -| Affected | Risk | -| ------------------------------ | -------- | -| <5 symbols, few processes | LOW | -| 5-15 symbols, 2-5 processes | MEDIUM | -| >15 symbols or many processes | HIGH | -| Critical path (auth, payments) | CRITICAL | - -## Tools - -**gitnexus_impact** — the primary tool for symbol blast radius: - -``` -gitnexus_impact({ - target: "validateUser", - direction: "upstream", - minConfidence: 0.8, - maxDepth: 3 -}) - -→ d=1 (WILL BREAK): - - loginHandler (src/auth/login.ts:42) [CALLS, 100%] - - apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%] - -→ d=2 (LIKELY AFFECTED): - - authRouter (src/routes/auth.ts:22) [CALLS, 95%] -``` - -**gitnexus_detect_changes** — git-diff based impact analysis: - -``` -gitnexus_detect_changes({scope: "staged"}) - -→ Changed: 5 symbols in 3 files -→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline -→ Risk: MEDIUM -``` - -## Example: "What breaks if I change validateUser?" - -``` -1. gitnexus_impact({target: "validateUser", direction: "upstream"}) - → d=1: loginHandler, apiMiddleware (WILL BREAK) - → d=2: authRouter, sessionManager (LIKELY AFFECTED) - -2. READ gitnexus://repo/my-app/processes - → LoginFlow and TokenRefresh touch validateUser - -3. Risk: 2 direct callers, 2 processes = MEDIUM -``` diff --git a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md deleted file mode 100644 index f48cc01b..00000000 --- a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -name: gitnexus-refactoring -description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\"" ---- - -# Refactoring with GitNexus - -## When to Use - -- "Rename this function safely" -- "Extract this into a module" -- "Split this service" -- "Move this to a new file" -- Any task involving renaming, extracting, splitting, or restructuring code - -## Workflow - -``` -1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents -2. gitnexus_query({query: "X"}) → Find execution flows involving X -3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs -4. Plan update order: interfaces → implementations → callers → tests -``` - -> If "Index is stale" → run `npx gitnexus analyze` in terminal. - -## Checklists - -### Rename Symbol - -``` -- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits -- [ ] Review graph edits (high confidence) and ast_search edits (review carefully) -- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits -- [ ] gitnexus_detect_changes() — verify only expected files changed -- [ ] Run tests for affected processes -``` - -### Extract Module - -``` -- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs -- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers -- [ ] Define new module interface -- [ ] Extract code, update imports -- [ ] gitnexus_detect_changes() — verify affected scope -- [ ] Run tests for affected processes -``` - -### Split Function/Service - -``` -- [ ] gitnexus_context({name: target}) — understand all callees -- [ ] Group callees by responsibility -- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update -- [ ] Create new functions/services -- [ ] Update callers -- [ ] gitnexus_detect_changes() — verify affected scope -- [ ] Run tests for affected processes -``` - -## Tools - -**gitnexus_rename** — automated multi-file rename: - -``` -gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) -→ 12 edits across 8 files -→ 10 graph edits (high confidence), 2 ast_search edits (review) -→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] -``` - -**gitnexus_impact** — map all dependents first: - -``` -gitnexus_impact({target: "validateUser", direction: "upstream"}) -→ d=1: loginHandler, apiMiddleware, testUtils -→ Affected Processes: LoginFlow, TokenRefresh -``` - -**gitnexus_detect_changes** — verify your changes after refactoring: - -``` -gitnexus_detect_changes({scope: "all"}) -→ Changed: 8 files, 12 symbols -→ Affected processes: LoginFlow, TokenRefresh -→ Risk: MEDIUM -``` - -**gitnexus_cypher** — custom reference queries: - -```cypher -MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"}) -RETURN caller.name, caller.filePath ORDER BY caller.filePath -``` - -## Risk Rules - -| Risk Factor | Mitigation | -| ------------------- | ----------------------------------------- | -| Many callers (>5) | Use gitnexus_rename for automated updates | -| Cross-area refs | Use detect_changes after to verify scope | -| String/dynamic refs | gitnexus_query to find them | -| External/public API | Version and deprecate properly | - -## Example: Rename `validateUser` to `authenticateUser` - -``` -1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) - → 12 edits: 10 graph (safe), 2 ast_search (review) - → Files: validator.ts, login.ts, middleware.ts, config.json... - -2. Review ast_search edits (config.json: dynamic reference!) - -3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) - → Applied 12 edits across 8 files - -4. gitnexus_detect_changes({scope: "all"}) - → Affected: LoginFlow, TokenRefresh - → Risk: MEDIUM — run tests for these flows -``` diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 9f1d6884..2d0588a7 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -28,7 +28,20 @@ jobs: with: fetch-depth: 0 - - name: Lint PR commits + # A main-base PR is a develop -> main release cut: every commit in main..develop + # was already linted at its own PR, while that commit was still mutable and its + # author could still fix it. Re-linting the whole inherited range at cut time adds + # no information and cannot be satisfied (the commits are now immutable, pinned by + # gitlinks and rev-pinned deps). Lint only what THIS PR introduces: its own commit(s). + - name: Lint PR commits (release cut — this PR's own commits only) + if: github.base_ref == 'main' + uses: wagoid/commitlint-github-action@v6 + with: + configFile: commitlint.config.mjs + commitDepth: 1 + + - name: Lint PR commits (feature branch — full PR range) + if: github.base_ref != 'main' uses: wagoid/commitlint-github-action@v6 with: configFile: commitlint.config.mjs @@ -37,9 +50,13 @@ jobs: if: github.event_name == 'pull_request' env: PR_TITLE: ${{ github.event.pull_request.title }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -euo pipefail # Install both @commitlint/cli AND the extended shareable config so the # `extends: ['@commitlint/config-conventional']` in commitlint.config.mjs resolves. - echo "$PR_TITLE" | npx --yes -p @commitlint/cli -p @commitlint/config-conventional \ + # GitHub's squash merge lands "$PR_TITLE (#$PR_NUMBER)" as the commit subject — + # lint that exact string, not the title alone, or a title within the length + # limit can still produce an over-limit commit subject once merged. + printf '%s (#%s)\n' "$PR_TITLE" "$PR_NUMBER" | npx --yes -p @commitlint/cli -p @commitlint/config-conventional \ commitlint --config commitlint.config.mjs diff --git a/.gitignore b/.gitignore index 6b1fb310..6288fdef 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,12 @@ config.json # lane-local scratch (never committed) .lane/ + +# gitnexus writes agent-tooling files into the repository it indexes. They are +# development-loop private and must never be tracked here. +# Prefer `gitnexus analyze --skip-agents-md`. +/AGENTS.md +/CLAUDE.md +/.claude/skills/gitnexus/ +/.claude/skills/generated/ +/.gitnexus/ diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 11a1e554..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,101 +0,0 @@ - -# GitNexus — Code Intelligence - -This project is indexed by GitNexus as **dn-365-366** (11798 symbols, 32150 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. - -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. - -## Always Do - -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. - -## When Debugging - -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/dn-365-366/process/{processName}` — trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed - -## When Refactoring - -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. - -## Never Do - -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. - -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/dn-365-366/context` | Codebase overview, check index freshness | -| `gitnexus://repo/dn-365-366/clusters` | All functional areas | -| `gitnexus://repo/dn-365-366/processes` | All execution flows | -| `gitnexus://repo/dn-365-366/process/{name}` | Step-by-step execution trace | - -## Self-Check Before Finishing - -Before completing any code modification task, verify: -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -```bash -npx gitnexus analyze -``` - -If the index previously included embeddings, preserve them by adding `--embeddings`: - -```bash -npx gitnexus analyze --embeddings -``` - -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - -## CLI - -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | - - diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 11a1e554..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,101 +0,0 @@ - -# GitNexus — Code Intelligence - -This project is indexed by GitNexus as **dn-365-366** (11798 symbols, 32150 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. - -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. - -## Always Do - -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. - -## When Debugging - -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/dn-365-366/process/{processName}` — trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed - -## When Refactoring - -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. - -## Never Do - -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. - -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/dn-365-366/context` | Codebase overview, check index freshness | -| `gitnexus://repo/dn-365-366/clusters` | All functional areas | -| `gitnexus://repo/dn-365-366/processes` | All execution flows | -| `gitnexus://repo/dn-365-366/process/{name}` | Step-by-step execution trace | - -## Self-Check Before Finishing - -Before completing any code modification task, verify: -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -```bash -npx gitnexus analyze -``` - -If the index previously included embeddings, preserve them by adding `--embeddings`: - -```bash -npx gitnexus analyze --embeddings -``` - -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - -## CLI - -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | - - diff --git a/Cargo.lock b/Cargo.lock index 0a7478c6..0462e8dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,7 +139,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -150,7 +150,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1948,7 +1948,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -2689,9 +2689,9 @@ dependencies = [ [[package]] name = "dig-download" -version = "0.22.1" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a85a94865f946f608c06bf1b2259b894c4100f14cb75fa5f0065b8e439fc0f93" +checksum = "b1f9a6e23899a1a58ff8f070307897799b0142b3d9447652676ffae7c131da7c" dependencies = [ "async-trait", "dig-constants 0.11.2", @@ -3041,7 +3041,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.255.0" +version = "0.256.0" dependencies = [ "async-trait", "axum", @@ -3144,9 +3144,9 @@ dependencies = [ [[package]] name = "dig-peer" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be5cf9690e3e31508b092cfb72fc92b62f241b479920d7f830d514a5fd4e8cdc" +checksum = "d6d28173f5ac2fb725d70d81491918bb9dbdc1691745bf044524aee8484333ef" dependencies = [ "chia-protocol 0.36.1", "chia-traits 0.36.1", @@ -3184,9 +3184,9 @@ dependencies = [ [[package]] name = "dig-peer-selector" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "946dee72de59dbe5c9ac00e700899e1a0f258930080e0b22de6019732d0b7389" +checksum = "1ac1005c43d63ca61d3ca6391cf6d3ff08b7138bb22e237ae76c5157d7677652" dependencies = [ "dig-dht", "dig-nat", @@ -3210,9 +3210,9 @@ dependencies = [ [[package]] name = "dig-rpc-protocol" -version = "0.10.3" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66c46a32c3fc6203773b6f551b21e5475b81b60d8b2c72a3b71c74694b149ade" +checksum = "1f88c346aa9ed0cd82ed1bcc051a6b3511058cc01ce7204a8e5ca65829fb0775" dependencies = [ "serde", "serde_json", @@ -3752,7 +3752,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3900,7 +3900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4536,7 +4536,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -4787,7 +4787,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5169,7 +5169,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5780,7 +5780,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.3", "rustls", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.20", "tokio", "tracing", @@ -5818,9 +5818,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6570,7 +6570,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7020,7 +7020,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7282,7 +7282,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8416,7 +8416,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1d44df4e..a694e447 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.255.0" +version = "0.256.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/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index c7320e10..e4ad1616 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -191,7 +191,7 @@ serde_json = "1" # per-method tier) and the mTLS peer-reachability allowlist. dig-node-core reads its # method names + the peer allowlist from HERE (never hand-rolled) so the contract # cannot drift from the other node implementation or the discovery document (#1075). -dig-rpc-protocol = "0.10.2" +dig-rpc-protocol = "0.11.0" # The directed-message base protocol (epic #793/#796): the e2e seal/open pipeline + the typed envelope # the chat subsystem seals into. dig-node is the TRANSPORT — it seals an app-supplied opaque DIGCHAT1 # envelope to the recipient's 0x0010 BLS identity key and dig-gossip directed-sends the sealed bytes. @@ -457,7 +457,11 @@ dig-pex = "0.1.1" # checkpoint store (`download.rs::capturing_state_store_checkpoints_a_real_module_download_key`), # because dig-download's own suite missed it: every `module.rs` test used `InMemoryStateStore` (no # filename at all) and the one `FileStateStore` test used a 3-character key. -dig-download = "0.22" +# +# Moved to 0.23 (dig_ecosystem#3269): 0.23.0 is the release that re-exports `dig-rpc-protocol` 0.11's +# `ModuleInfo`, closing the two-shapes split this crate's own `dig-rpc-protocol = "0.11.0"` line above +# opened against dig-download's prior 0.22-line dependency on `dig-rpc-protocol` 0.10.3. +dig-download = "0.23" # -- The shared peer client (#1283/#1576) ------------------------------------------------------------- # `DigPeer` — the ONE DIG Network peer client: peer_id-pinned mTLS over the full NAT ladder plus typed # RPC. Depended on DIRECTLY (not only transitively through dig-download) because dig-node supplies the @@ -469,7 +473,10 @@ dig-download = "0.22" # module pull's trust boundary — on the fields that drive the whole pull plan. dig-download 0.8.1 is on # dig-peer 0.5 too, so exactly ONE dig-rpc-protocol + ONE dig-peer resolve here (asserted by # `crates/dig-node-core/tests/dependency_tree.rs`). -dig-peer = "0.13" +# +# Moved to 0.14 (dig_ecosystem#3269), alongside dig-download's move to 0.23 above, for the same +# reason: 0.14.0 is on `dig-rpc-protocol` 0.11, keeping exactly one version resolving. +dig-peer = "0.14" # -- Self-optimizing peer selection (#178) ------------------------------------------------------------ # The decision + learning layer between dig-dht discovery and dig-download execution: it ranks the # providers `find_providers` returns (learning throughput/rtt/reliability + a per-class saturation @@ -496,7 +503,13 @@ dig-peer = "0.13" # above (dig-node#422). Its predecessor 0.10.0 required `^0.13`, and because this crate passes # dig-dht values into the selector, that requirement is what held dig-dht at 0.13; see the dig-dht # entry above. -dig-peer-selector = "0.11" +# +# Moved to 0.12 (dig_ecosystem#3269): 0.12.0 is the release that moves onto `dig-peer ^0.14`, the +# last of the four links in the `dig-rpc-protocol` 0.11 cascade (dig-peer 0.14.0, dig-download +# 0.23.0, dig-peer-selector 0.12.0). Every prior `dig-peer-selector` release — through 0.11.1 — +# stayed on `dig-peer ^0.13`, which is what pinned this crate's `dig-peer` line above at 0.13 and +# kept two `dig-rpc-protocol` versions resolving simultaneously. +dig-peer-selector = "0.12" # The canonical DIG mTLS certificate crate (L00, crates.io). The node's PERSISTENT machine identity # is a CA-signed `dig_tls::NodeCert` minted from the node's own BLS identity key and persisted 0600 in # the data dir (#908 identity boundary: this is the MACHINE key, never a user key). Replaces the @@ -580,7 +593,7 @@ rcgen = "0.13" # # Pinned by the `the_fail_open_anchor_verifier_is_not_reachable_from_a_production_build` test, which # fails if `testkit` ever appears on the production entry. -dig-download = { version = "0.22", features = ["testkit"] } +dig-download = { version = "0.23", features = ["testkit"] } # Captures the peer-facing serve's real emitted tracing records into an in-memory buffer, so the # serve-observability tests (#1595) assert what an operator would actually see in the node log — # and that no payload byte or proof ever reaches it. diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 47010d7b..d582f173 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -60,6 +60,7 @@ pub mod mirror_bond; mod module_tier_tag; pub mod peer; pub mod rate_limit; +pub mod rewards; pub mod store_exchange; #[cfg(test)] @@ -567,6 +568,45 @@ pub struct Node { /// announces exactly as it always did, and a verifier that cannot fetch a pointer withholds /// credit rather than demoting the holder. mirror_pointers: OnceLock>, + /// Registry of this node's live reward-prover [`rewards::state::StatusHandle`]s, read by + /// `dig.getRewardProverStatus` (dig_ecosystem#3269). Nothing spawns a prover loop yet + /// (dig_ecosystem#3265, not landed), so this stays empty and the handler's + /// `{"statuses": []}` answer is a REAL, currently-empty read — SPEC §2.4 clause 1's + /// "not distributing" render — not a hardcoded stub. The day #3265 registers a handle via + /// [`Node::register_reward_prover_status`], the same read starts returning it with no + /// dispatch-side change. + reward_prover_statuses: Arc>>, +} + +impl Node { + /// Register a live reward-prover status handle (dig_ecosystem#3269/#3265) so + /// `dig.getRewardProverStatus` can read it. Additive — registering a second handle for the + /// same distributor is the registrar's mistake to avoid, not this method's to dedupe. + /// + /// Only called from tests today: #3265 (the always-on prover loop that would call this from + /// production bring-up) has not landed, so clippy's non-test lib target sees no production + /// caller yet. `allow(dead_code)` here is a stand-in for that missing caller, not a claim the + /// registry itself is unused — remove this attribute the moment #3265 lands and wires a real + /// call site. + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn register_reward_prover_status(&self, handle: rewards::state::StatusHandle) { + self.reward_prover_statuses + .write() + .expect("reward prover status registry lock poisoned") + .push(handle); + } + + /// Snapshot every registered reward-prover status, in registration order. Empty when nothing + /// has registered — a REAL read of a real (currently empty) registry, see the field doc on + /// `reward_prover_statuses`. + pub(crate) fn reward_prover_status_snapshots(&self) -> Vec { + self.reward_prover_statuses + .read() + .expect("reward prover status registry lock poisoned") + .iter() + .map(rewards::state::StatusHandle::snapshot) + .collect() + } } /// A boxed async hook that reconciles the node's DHT provider records with its current cache @@ -4814,6 +4854,7 @@ impl Node { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }) } @@ -5153,6 +5194,7 @@ pub(crate) mod test_support { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }; (Arc::new(node), td) } @@ -5948,6 +5990,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }; (node, td) } @@ -6082,6 +6125,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }; // Missing before the pull. @@ -6150,6 +6194,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }); // Build the loop's deps from the PRODUCTION seams, with a fixed one-store subscription set. @@ -6249,6 +6294,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -6326,6 +6372,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }); assert!(!module_exists(&node.cache_dir, &store_hex, &root.to_hex())); @@ -8994,6 +9041,414 @@ mod tests { } } + // -- dig.getRewardProverStatus (dig_ecosystem#3269, dig-rewards-coin SPEC.md §2.3/§2.4) ----- + + /// A populated SPEC §2.3 status record with every field a distinct, checkable value — + /// distinguishes a mapping bug (e.g. two fields swapped, or a widening dropped) from an + /// accidental match against a zeroed/default record. + fn sample_reward_prover_status( + launcher_id: [u8; 32], + ) -> crate::rewards::state::RewardProverStatus { + crate::rewards::state::RewardProverStatus { + launcher_id, + store_id: [0x22u8; 32], + root: [0x33u8; 32], + prover_state: crate::rewards::state::ProverState::ChainSourceUnavailable, + prover_state_since: 1_000, + last_cycle_started_at: Some(1_100), + last_cycle_completed_at: Some(1_200), + next_cycle_due_at: Some(1_300), + last_entry_write_at: Some(1_400), + consecutive_cycle_failures: 3, + pending_entry_writes: 5, + observed_at: 1_500, + counters: crate::rewards::state::ProverCounters { + mirrors_seen: 11, + challenges_issued: 22, + challenges_passed: 33, + challenges_failed: 44, + entries_added: 55, + entries_removed: 66, + // Deliberately in the upper half of `u32`'s range (> 2^31) — the internal field IS + // `u32`, so this cannot exceed `u32::MAX` (that would not compile), but a value + // this large would not survive a mistaken re-narrowing (e.g. an accidental + // `as u32 as u64` round-trip through a signed/other-width type) intact, unlike a + // small value that would pass such a bug undetected. + entry_count: 3_000_000_000, + reserve_base_units: 77, + total_paid_out_base_units: 88, + }, + } + } + + /// **Proves:** `dig.getRewardProverStatus` answers through the REAL dispatch entry point + /// (`handle_rpc` → `RpcDispatch::dispatch` → the `Method::GetRewardProverStatus` arm) with a + /// registered handle's values, asserted field-for-field on the SERIALIZED JSON body (snake_case + /// wire keys, camelCase `prover_state` enum value) — not a Rust struct, so a serde rename or a + /// dropped field would be caught. Also asserts the wire body's key set carries none of + /// `alive`/`healthy`/`ok`/`up`/`running` and no staleness field, by KEY SET rather than + /// substring (a substring check would pass under the defect it exists to catch). + /// **Catches:** a field swap, a dropped `entry_count` widening, a reintroduced health boolean. + #[test] + fn get_reward_prover_status_answers_a_real_request_with_real_values() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + let launcher_id = [0x11u8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status(launcher_id), + )); + + let resp = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + + let statuses = resp["result"]["statuses"] + .as_array() + .expect("result.statuses is an array"); + assert_eq!(statuses.len(), 1, "one registered handle: {resp}"); + let s = &statuses[0]; + + assert_eq!(s["launcher_id"], json!(hex::encode(launcher_id))); + assert_eq!(s["store_id"], json!(hex::encode([0x22u8; 32]))); + assert_eq!(s["root"], json!(hex::encode([0x33u8; 32]))); + // camelCase VALUE for the enum, on an otherwise snake_case-keyed wire struct (confirmed at + // v0.11.0: only `ProverState` carries `rename_all = "camelCase"`). + assert_eq!(s["prover_state"], json!("chainSourceUnavailable")); + assert_eq!(s["prover_state_since"], json!(1_000)); + assert_eq!(s["last_cycle_started_at"], json!(1_100)); + assert_eq!(s["last_cycle_completed_at"], json!(1_200)); + assert_eq!(s["next_cycle_due_at"], json!(1_300)); + assert_eq!(s["last_entry_write_at"], json!(1_400)); + assert_eq!(s["consecutive_cycle_failures"], json!(3)); + assert_eq!(s["pending_entry_writes"], json!(5)); + assert_eq!(s["observed_at"], json!(1_500)); + + let counters = &s["counters"]; + assert_eq!(counters["mirrors_seen"], json!(11)); + assert_eq!(counters["challenges_issued"], json!(22)); + assert_eq!(counters["challenges_passed"], json!(33)); + assert_eq!(counters["challenges_failed"], json!(44)); + assert_eq!(counters["entries_added"], json!(55)); + assert_eq!(counters["entries_removed"], json!(66)); + // The value proving the widening ran: > u32::MAX, so a truncating cast would not equal this. + assert_eq!(counters["entry_count"], json!(3_000_000_000u64)); + assert_eq!(counters["reserve_base_units"], json!(77)); + assert_eq!(counters["total_paid_out_base_units"], json!(88)); + + // No health boolean, no precomputed staleness (SPEC §2.4) — by KEY SET, not substring. + let keys: std::collections::BTreeSet<&str> = s + .as_object() + .expect("status is an object") + .keys() + .map(String::as_str) + .collect(); + for banned in [ + "alive", + "healthy", + "ok", + "up", + "running", + "stale", + "seconds_since_last_run", + ] { + assert!( + !keys.contains(banned), + "banned key {banned:?} present: {keys:?}" + ); + } + } + + /// **Proves:** with nothing registered, `dig.getRewardProverStatus` answers + /// `{"statuses": []}` — SPEC §2.4 clause 1's "not distributing" render — never blank, `null`, + /// or an omitted `result`. **Catches:** an absent-record case that renders as nothing rather + /// than an explicit empty list a UI can render deterministically. + #[test] + fn get_reward_prover_status_with_no_registered_handle_is_explicit_empty() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + + let resp = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + + assert_eq!( + resp["result"], + json!({"statuses": []}), + "explicit empty list: {resp}" + ); + } + + /// **Proves:** a zeroed `launcher_id` OR `store_id` — what an uninitialised/never-assigned + /// registry slot hex-encodes to — is never rendered as a real distributor with a + /// plausible-looking id, AND that dropping it is never silent: a `tracing::warn!` fires + /// naming the SPECIFIC zeroed field(s), so a registration bug is observable rather than + /// swallowed. This is the money-hole class the `dig-rewards-coin` driver's adversarial gates + /// found three times (an unset field that reads fine and costs the operator), plus the SPEC + /// §2.4 clause 1 defect a security + adversarial gate found in the first version of this + /// filter: an all-zero-`launcher_id`-only check that silently destroyed the evidence of a bad + /// registration, and never checked `store_id` at all. + /// + /// Distinguishes IDENTITY fields (`launcher_id`, `store_id` — a record missing either cannot + /// be attributed to any distributor, so it is EXCLUDED and logged at `WARN`) from the + /// OBSERVATION field (`root` — legitimately zero before a prover's first cycle, so it is + /// logged at `DEBUG`, never `WARN`, and never causes exclusion on its own; see the third case + /// below). The level split matters, not just the exclusion split: security measured that an + /// undifferentiated `warn!` for both cases turns steady-state log volume into (uncycled + /// provers) x (poll rate) lines an operator cannot distinguish from a real registration bug. + /// + /// **Catches:** (1) a boundary that lets an uninitialised slot answer as if it were a real + /// distributor; (2) a filter that only checks `launcher_id`, missing a registration bug that + /// zeroes `store_id` beside an otherwise-valid `launcher_id` (the exact gap security named); + /// (3) a fix that goes back to dropping the bad record with no log line at all; (4) a fix + /// that over-corrects by excluding on a zeroed `root` too, which would make a healthy, + /// just-not-yet-cycled prover invisible; (5) a fix that returns the zeroed-root record but logs + /// it at the SAME level (`warn!`) as a real identity fault, defeating the operator's ability to + /// tell the two apart; (6) a log assertion that only checks the field NAME `launcher_id` + /// appears somewhere in the log line — true unconditionally, since the log always includes + /// `launcher_id = %hex::encode(...)` as a structured field regardless of which field was + /// actually zero — rather than checking the `zeroed_fields=[...]` value AND the level. + #[test] + fn get_reward_prover_status_logs_and_excludes_a_zeroed_identity_field() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + + // Case 1: launcher_id itself is zeroed (the original, narrower gap). + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status([0u8; 32]), + )); + + // Case 2: launcher_id is VALID, but store_id is zeroed — the gap security named, which + // the launcher_id-only filter would have let straight through as a plausible record. + let valid_but_zeroed_store = [0xccu8; 32]; + let mut zeroed_store_status = sample_reward_prover_status(valid_but_zeroed_store); + zeroed_store_status.store_id = [0u8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + zeroed_store_status, + )); + + // Case 3: launcher_id AND store_id are both valid, but root is zeroed — a plausible + // "registered, not yet cycled" prover. Must still be RETURNED (root is not an identity + // field), and a DEBUG (never WARN) still fires naming `root` so the state stays + // observable without polluting warn-level volume with an ordinary, expected state. + let valid_but_zeroed_root = [0xbbu8; 32]; + let mut zeroed_root_status = sample_reward_prover_status(valid_but_zeroed_root); + zeroed_root_status.root = [0u8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + zeroed_root_status, + )); + + // A real, fully-valid entry alongside all three, to prove the guard is selective, not a + // by-product of the registry being otherwise empty. + let real_id = [0xaau8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status(real_id), + )); + + let (resp, logs) = rt.block_on(capture_sync_logs(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + ))); + + let statuses = resp["result"]["statuses"] + .as_array() + .expect("result.statuses is an array"); + assert_eq!( + statuses.len(), + 2, + "the fully-valid entry AND the zeroed-root-only entry are both returned; only the \ + zeroed-launcher_id and zeroed-store_id entries are excluded: {resp}" + ); + let returned_ids: std::collections::BTreeSet = statuses + .iter() + .map(|s| s["launcher_id"].as_str().unwrap().to_string()) + .collect(); + assert!(returned_ids.contains(&hex::encode(real_id))); + assert!(returned_ids.contains(&hex::encode(valid_but_zeroed_root))); + + // The observable signal: a warning naming the SPECIFIC zeroed field(s), for EACH bad + // registration — asserted on the actual `zeroed_fields=[...]` value, not merely on the + // field NAME `launcher_id` appearing somewhere (that would pass even for the store_id or + // root cases, since the warn always logs `launcher_id = ...` as a structured field + // regardless of which field was actually zero — the exact tautology a correctness gate + // found in an earlier version of this assertion). + assert!( + logs.contains("WARN") && logs.contains(r#"zeroed_fields=["launcher_id"]"#), + "expected a WARN naming exactly launcher_id as zeroed, got: {logs}" + ); + assert!( + logs.contains("WARN") && logs.contains(r#"zeroed_fields=["store_id"]"#), + "expected a WARN naming exactly store_id as zeroed, got: {logs}" + ); + // A zeroed root alone must be DEBUG, not WARN — it is an ordinary pre-first-cycle state, + // not a registration bug, and sharing warn-level volume with a real identity fault would + // make an operator polling this endpoint unable to tell them apart (the exact security + // finding that split these into two levels). + assert!( + logs.contains("DEBUG") && logs.contains(r#"zeroed_fields=["root"]"#), + "expected a DEBUG line naming exactly root as zeroed, distinct from the WARN level \ + used for a missing identity field, even though the record is still returned: {logs}" + ); + assert_eq!( + logs.matches("missing an identity field").count(), + 2, + "expected exactly one WARN per identity-missing registration (2 here: launcher_id, \ + store_id) — the zeroed-root-only case must never count as one: {logs}" + ); + assert_eq!( + logs.matches("zeroed root").count(), + 1, + "expected exactly one DEBUG for the zeroed-root-only registration: {logs}" + ); + } + + /// **Proves:** `dig.getRewardProverStatus` is NOT peer-reachable (CONTROL plane — loopback + /// admin / in-process FFI only), matching `reward_methods_tier_guard.rs`'s enumeration-based + /// guard with a direct, single-method assertion. + /// **Catches:** the method being accidentally allowlisted for the mTLS peer surface. + #[test] + fn get_reward_prover_status_is_not_peer_reachable() { + assert!(!peer::is_peer_reachable_method("dig.getRewardProverStatus")); + } + + /// **Proves:** `dig.getRewardProverStatus` goes through the `Method` enum match (`Tier::Control` + /// per dig-rpc-protocol 0.11), not the string pre-match ahead of it — calling it over the SAME + /// dispatch entry point with no special-casing still resolves to the handler, so a future + /// refactor that moved it back to the pre-match string block would be the only way to break + /// this test's premise, not silently bypass the tier guard. + /// **Catches:** a reintroduction of the method into the pre-`Method::from_name` string match. + #[test] + fn get_reward_prover_status_is_served_via_the_method_enum_not_the_string_prematch() { + use dig_rpc_protocol::Method; + assert_eq!( + Method::from_name("dig.getRewardProverStatus"), + Some(Method::GetRewardProverStatus) + ); + assert_eq!( + Method::GetRewardProverStatus.tier(), + dig_rpc_protocol::Tier::Control + ); + } + + /// **Proves:** `dig.getRewardProverStatus` restricts to the requested `launcher_id` when the + /// caller supplies one, and returns every registered status when it does not. + /// **Catches:** a filter that ignores the param, or one that requires it. + #[test] + fn get_reward_prover_status_filters_by_launcher_id_when_given() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + let a = [0xaau8; 32]; + let b = [0xbbu8; 32]; + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status(a), + )); + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status(b), + )); + + let filtered = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus", + "params":{"launcher_id": hex::encode(a)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + let filtered = filtered["result"]["statuses"].as_array().unwrap(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0]["launcher_id"], json!(hex::encode(a))); + + let all = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":2,"method":"dig.getRewardProverStatus"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!(all["result"]["statuses"].as_array().unwrap().len(), 2); + } + + /// **Proves:** `total_paid_out_base_units`/`reserve_base_units` stay attributed to the + /// `launcher_id` (distributor) that reported them — never summed across distributors, never + /// cross-attributed to the other one. **Catches:** the class of defect a sibling adversarial + /// gate found in dig-app#403's rewards pane (dig_ecosystem#3269): a per-distributor total + /// rendered/returned as if it were a single subject's (there, one mirror operator's personal + /// earnings), overstating by however many other mirrors that distributor pays. Proving the + /// VALUE survives the wire hop (`get_reward_prover_status_answers_a_real_request_with_real_values`) + /// does not prove whose money it describes — this test does, with two distributors carrying + /// deliberately different, distinguishable totals. + #[test] + fn get_reward_prover_status_attributes_payout_figures_to_their_own_distributor() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + let distributor_a = [0xaau8; 32]; + let distributor_b = [0xbbu8; 32]; + + let mut status_a = sample_reward_prover_status(distributor_a); + status_a.counters.reserve_base_units = 10_000; + status_a.counters.total_paid_out_base_units = 999_000; + + let mut status_b = sample_reward_prover_status(distributor_b); + status_b.counters.reserve_base_units = 42; + status_b.counters.total_paid_out_base_units = 7; + + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new(status_a)); + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new(status_b)); + + let resp = rt.block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + let statuses = resp["result"]["statuses"].as_array().unwrap(); + assert_eq!(statuses.len(), 2); + + let find = |launcher_id: [u8; 32]| { + statuses + .iter() + .find(|s| s["launcher_id"] == json!(hex::encode(launcher_id))) + .unwrap_or_else(|| panic!("no status for launcher_id {}", hex::encode(launcher_id))) + }; + let a = find(distributor_a); + let b = find(distributor_b); + + // Each distributor's own figures, untouched. + assert_eq!(a["counters"]["reserve_base_units"], json!(10_000)); + assert_eq!(a["counters"]["total_paid_out_base_units"], json!(999_000)); + assert_eq!(b["counters"]["reserve_base_units"], json!(42)); + assert_eq!(b["counters"]["total_paid_out_base_units"], json!(7)); + + // Never summed across distributors (999_000 + 7) and never cross-attributed (swapped). + let combined = 999_000 + 7; + assert_ne!(a["counters"]["total_paid_out_base_units"], json!(combined)); + assert_ne!(b["counters"]["total_paid_out_base_units"], json!(combined)); + assert_ne!( + a["counters"]["total_paid_out_base_units"], + b["counters"]["total_paid_out_base_units"] + ); + } + /// **Proves:** `gap_fill_generation` is a cheap no-op when the generation is already held (no /// network, `Ok(())`). **Catches:** a gap-fill that re-pulls an already-held generation. #[tokio::test] @@ -9398,6 +9853,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), }; let before = handle_rpc( @@ -16553,6 +17009,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; // A holder for this EXACT content is known via the DHT. @@ -16604,6 +17061,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; // A P2P engine is attached but the DHT knows of NO holder for this content — the graceful @@ -16654,6 +17112,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; @@ -16686,6 +17145,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -16727,6 +17187,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); @@ -16770,6 +17231,7 @@ mod tests { inbound_demand: Arc::new(inbound_demand::InboundDemand::new()), node_peer_id: OnceLock::new(), mirror_pointers: OnceLock::new(), + reward_prover_statuses: Arc::new(std::sync::RwLock::new(Vec::new())), ..node }; let cid = ContentId::resource(store.0, tip.0, rk); diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index e83127bc..f85f954b 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -5633,6 +5633,34 @@ pub(crate) mod tests { } } + /// **Proves** (dig_ecosystem#3269, binding #3261's rule node-side): every `Method` whose wire + /// name contains `Reward` is absent from THIS node's `is_peer_reachable_method` allowlist — + /// exercising the real `pub(crate)` function, not the crate-level `Method::is_peer_reachable` + /// it delegates to, so a future special-case added HERE (the way `dig.getProviderSnapshot` and + /// `cache.pushCapsule` are special-cased above) is caught too. + /// **Catches:** a reward method reaching a remote peer over mTLS — a money-adjacent read no + /// unauthenticated peer should get, regardless of whether the wrapper's crate-delegation path + /// or a local special-case is what would have let it through. + #[test] + fn reward_methods_are_absent_from_the_node_peer_allowlist() { + let reward_methods: Vec = dig_rpc_protocol::Method::ALL + .iter() + .copied() + .filter(|m| m.name().contains("Reward")) + .collect(); + assert!( + !reward_methods.is_empty(), + "expected at least one Reward-named method in Method::ALL; found none" + ); + for m in reward_methods { + assert!( + !is_peer_reachable_method(m.name()), + "{} must be absent from is_peer_reachable_method", + m.name() + ); + } + } + /// **Proves:** `dig.getProviderSnapshot` is peer-reachable as the ONE deliberate dig-node-LOCAL /// addition beyond the shared `dig-rpc-protocol` allowlist (epic #1934 child 4a) — it is not (yet) /// in that crate's set, so the wrapper allowlists it explicitly, and this test records that as an diff --git a/crates/dig-node-core/src/rewards/admission.rs b/crates/dig-node-core/src/rewards/admission.rs new file mode 100644 index 00000000..0e65d04d --- /dev/null +++ b/crates/dig-node-core/src/rewards/admission.rs @@ -0,0 +1,297 @@ +//! THE single admission point (SPEC §5.3). Every discovery path — the DHT walk, this node's +//! locally-held provider set, the discovered cache, and any manual/operator add — MUST route a +//! candidate through [`admit`] before it becomes an entry decision. There MUST NOT be a second +//! admission function anywhere in this module tree. +//! +//! DIG-Network/dig-node#261 is the analogous defect: an absolute SPEC self-exclusion honoured by +//! the DHT leg and bypassed by the forwarded leg. The lesson is the rule: an invariant enforced on +//! some paths is not an invariant, it is a habit. So this file is deliberately the ONLY place that +//! compares a candidate against this node's own identity, and every caller — regardless of which +//! path produced the candidate — MUST call through here rather than re-implement the comparison. + +use super::gate::{EpochContext, GateError, GateOutcome, MirrorCoinGatePort}; +use super::port::Bytes32; + +/// Which discovery path produced a candidate. Exists ONLY for logging/tests (SPEC §5.3 clause 4's +/// control needs to name the path a candidate arrived by) — it MUST NOT change the admission +/// decision, since that would be exactly the per-path habit §5.3 forbids. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiscoveryPath { + DhtWalk, + LocalProviderSet, + DiscoveredCache, + ManualAdd, +} + +/// A raw candidate as a discovery path hands it in, before the mirror-coin gate has run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { + pub peer_id: [u8; 32], + pub path: DiscoveryPath, +} + +/// This node's own identity, on both SPEC §5.2 coordinates. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwnIdentity { + pub peer_id: [u8; 32], + /// Every puzzle hash this node's own wallet controls. A `Vec` (not a single hash) because a + /// wallet may hold more than one payout address; SPEC §5.2 excludes on membership, not equality + /// to one distinguished value. + pub controlled_puzzle_hashes: Vec<[u8; 32]>, +} + +impl OwnIdentity { + fn controls(&self, puzzle_hash: &[u8; 32]) -> bool { + self.controlled_puzzle_hashes + .iter() + .any(|h| h == puzzle_hash) + } +} + +/// Proof that a candidate passed THE single admission point (SPEC §5.3). Fields are private and no +/// public constructor exists, so an `EntryAction::Add` cannot be built without one — a path that +/// skips `admit` fails to compile rather than silently writing an entry for this node itself. This +/// type's whole reason to exist is that privacy: a `pub` field or a `pub fn new` here reopens +/// exactly the per-path habit DIG-Network/dig-node#261 already cost a lane for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AdmittedPeer { + payout_puzzle_hash: Bytes32, + launcher_id: Bytes32, +} + +impl AdmittedPeer { + /// The payout puzzle hash the entry would use (SPEC §10.2). + pub fn payout_puzzle_hash(&self) -> Bytes32 { + self.payout_puzzle_hash + } + + /// Which distributor this admission was decided for. + pub fn launcher_id(&self) -> Bytes32 { + self.launcher_id + } + + /// Test-only escape hatch, `cfg(test)`-gated so it never ships: production code has no way to + /// mint an `AdmittedPeer` except through [`admit`]. + #[cfg(test)] + pub fn for_test(payout_puzzle_hash: Bytes32, launcher_id: Bytes32) -> Self { + Self { + payout_puzzle_hash, + launcher_id, + } + } +} + +/// What [`admit`] decided. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdmissionDecision { + /// Eligible on the chain gate AND not self. + Admit(AdmittedPeer), + /// Refused because the candidate is this node itself, on the peer_id coordinate, the puzzle_hash + /// coordinate, or both (SPEC §5.2). Refused at admission, never a display filter (§5.3.3) — the + /// caller MUST NOT write an entry for this candidate under any circumstance. + SelfExcluded, + /// The mirror-coin gate did not admit the candidate (SPEC §4, §10.3) — fail-closed + /// ineligibility, not an accusation. + GateIneligible, + /// D5: the gate could not evaluate this candidate at all because the mirror-collateral epoch + /// ordinal was not supplied — a PROVER-side configuration fault, never a peer-attributable + /// verdict. The caller MUST NOT treat this as `GateIneligible` and MUST NOT strike the peer for + /// it (SPEC §3.6 clause 4). + ChainSourceUnavailable, +} + +/// THE single admission point. Every discovery path calls this and nothing else decides +/// self-exclusion. +/// +/// Order matters and is deliberate: self-exclusion is checked FIRST, on the `peer_id` coordinate, +/// before any chain read — refusing this node's own peer id costs nothing and needs no gate result. +/// The `payout_puzzle_hash` coordinate can only be checked once the gate has produced one (SPEC §4.3 +/// `owner_puzzle_hash()`), so that half of self-exclusion runs after the gate call but BEFORE the +/// gate's eligibility is trusted — an eligible-but-self-owned candidate is still refused, never +/// admitted then filtered. +pub async fn admit( + candidate: &Candidate, + own: &OwnIdentity, + gate: &dyn MirrorCoinGatePort, + epoch_ctx: EpochContext, + launcher_id: Bytes32, +) -> AdmissionDecision { + if candidate.peer_id == own.peer_id { + return AdmissionDecision::SelfExcluded; + } + + match gate.evaluate(candidate.peer_id, epoch_ctx).await { + Err(GateError::EpochOrdinalUnavailable) => AdmissionDecision::ChainSourceUnavailable, + Ok(GateOutcome::Eligible { payout_puzzle_hash }) => { + if own.controls(&payout_puzzle_hash) { + AdmissionDecision::SelfExcluded + } else { + AdmissionDecision::Admit(AdmittedPeer { + payout_puzzle_hash, + launcher_id, + }) + } + } + Ok(GateOutcome::Ineligible(_)) => AdmissionDecision::GateIneligible, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Which distributor `admit` is deciding for in these tests — arbitrary, self-exclusion does + /// not depend on it. + const LAUNCHER: Bytes32 = [9; 32]; + use crate::rewards::gate::{GateIneligibleReason, MirrorCoinGatePort}; + use async_trait::async_trait; + + struct FakeGate { + /// peer_id -> (eligible?, payout_puzzle_hash) + eligible: std::collections::HashMap<[u8; 32], [u8; 32]>, + } + + #[async_trait] + impl MirrorCoinGatePort for FakeGate { + async fn evaluate( + &self, + peer_id: [u8; 32], + _ctx: EpochContext, + ) -> Result { + match self.eligible.get(&peer_id) { + Some(ph) => Ok(GateOutcome::Eligible { + payout_puzzle_hash: *ph, + }), + None => Ok(GateOutcome::Ineligible( + GateIneligibleReason::AbsentDeclaration, + )), + } + } + } + + struct UnavailableFakeGate; + + #[async_trait] + impl MirrorCoinGatePort for UnavailableFakeGate { + async fn evaluate( + &self, + _peer_id: [u8; 32], + _ctx: EpochContext, + ) -> Result { + Err(GateError::EpochOrdinalUnavailable) + } + } + + fn own() -> OwnIdentity { + OwnIdentity { + peer_id: [0xAA; 32], + controlled_puzzle_hashes: vec![[0xBB; 32]], + } + } + + fn ctx() -> EpochContext { + EpochContext { + current_epoch: Some(2), + epoch_rolled_over_at: None, + now: 0, + } + } + + /// SPEC §5.2 coordinate 1: own peer_id, foreign payout hash -> refused, on EVERY path. + #[tokio::test] + async fn own_peer_id_is_refused_on_every_discovery_path() { + let gate = FakeGate { + eligible: [([0xAA; 32], [0xCC; 32])].into_iter().collect(), + }; + let own = own(); + for path in [ + DiscoveryPath::DhtWalk, + DiscoveryPath::LocalProviderSet, + DiscoveryPath::DiscoveredCache, + DiscoveryPath::ManualAdd, + ] { + let candidate = Candidate { + peer_id: own.peer_id, + path, + }; + let decision = admit(&candidate, &own, &gate, ctx(), LAUNCHER).await; + assert_eq!(decision, AdmissionDecision::SelfExcluded, "path {path:?}"); + } + } + + /// SPEC §5.2 coordinate 2: foreign peer_id, but the gate resolves a payout hash this node's + /// wallet controls -> refused. + #[tokio::test] + async fn own_controlled_payout_hash_is_refused_even_with_a_foreign_peer_id() { + let own = own(); + let foreign_peer = [0x11; 32]; + let gate = FakeGate { + eligible: [(foreign_peer, own.controlled_puzzle_hashes[0])] + .into_iter() + .collect(), + }; + let candidate = Candidate { + peer_id: foreign_peer, + path: DiscoveryPath::DhtWalk, + }; + let decision = admit(&candidate, &own, &gate, ctx(), LAUNCHER).await; + assert_eq!(decision, AdmissionDecision::SelfExcluded); + } + + /// The §5.3.4 control: an otherwise-identical NON-self candidate on the same paths IS admitted. + /// This distinguishes "excluded self" from "dropped everything". + #[tokio::test] + async fn control_a_non_self_candidate_is_admitted_on_every_path() { + let own = own(); + let honest_peer = [0x22; 32]; + let honest_payout = [0xDD; 32]; + let gate = FakeGate { + eligible: [(honest_peer, honest_payout)].into_iter().collect(), + }; + for path in [ + DiscoveryPath::DhtWalk, + DiscoveryPath::LocalProviderSet, + DiscoveryPath::DiscoveredCache, + DiscoveryPath::ManualAdd, + ] { + let candidate = Candidate { + peer_id: honest_peer, + path, + }; + let decision = admit(&candidate, &own, &gate, ctx(), LAUNCHER).await; + assert_eq!( + decision, + AdmissionDecision::Admit(AdmittedPeer::for_test(honest_payout, LAUNCHER)), + "path {path:?}" + ); + } + } + + #[tokio::test] + async fn gate_ineligible_candidate_is_refused_but_not_marked_self() { + let own = own(); + let gate = FakeGate { + eligible: std::collections::HashMap::new(), + }; + let candidate = Candidate { + peer_id: [0x33; 32], + path: DiscoveryPath::DhtWalk, + }; + let decision = admit(&candidate, &own, &gate, ctx(), LAUNCHER).await; + assert_eq!(decision, AdmissionDecision::GateIneligible); + } + + /// D5: a gate error (absent epoch ordinal) MUST surface as `ChainSourceUnavailable`, never as + /// `GateIneligible` — a caller telling these apart is exactly what keeps this from striking a + /// peer for the operator's own configuration gap. + #[tokio::test] + async fn gate_error_surfaces_as_chain_source_unavailable_not_gate_ineligible() { + let own = own(); + let candidate = Candidate { + peer_id: [0x44; 32], + path: DiscoveryPath::DhtWalk, + }; + let decision = admit(&candidate, &own, &UnavailableFakeGate, ctx(), LAUNCHER).await; + assert_eq!(decision, AdmissionDecision::ChainSourceUnavailable); + } +} diff --git a/crates/dig-node-core/src/rewards/challenge.rs b/crates/dig-node-core/src/rewards/challenge.rs new file mode 100644 index 00000000..f8fb9667 --- /dev/null +++ b/crates/dig-node-core/src/rewards/challenge.rs @@ -0,0 +1,435 @@ +//! SPEC §3: possession-challenge window selection, the fail-closed pass/fail decision, and the +//! §3.6 strike accounting the decision feeds. +//! +//! **Scope cut, deliberate**: this module holds the SOUNDNESS logic — which windows to pick, and +//! whether a response is honest — behind the narrow [`ChallengeTransport`] seam, tested against an +//! in-memory fake. The concrete `dig.fetchRange` transport adapter (`skip_layout: true, +//! capsule: false`, the §3.7 deadlines) is a FOLLOW-UP, not built here. Soundness in, transport +//! out. + +use super::port::Bytes32; +use super::spec_constants::{ + CHALLENGE_NO_REPEAT_CYCLES, CHALLENGE_STRIKES_TO_EVICT, CHALLENGE_WINDOW_BYTES, +}; +use async_trait::async_trait; +use std::collections::HashMap; + +/// One resource this distributor's peer set is challenged over (SPEC §3.1): an id and its total +/// byte length, used only for the length-proportional pick below. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Resource { + pub id: Bytes32, + pub length: u64, +} + +/// A concrete window request: which resource, what byte range. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindowPlan { + pub resource_index: usize, + pub offset: u64, + pub length: u64, +} + +/// A CSPRNG-drawn `u64` in `[0, bound)`. SPEC §3.2 clause 4: MUST NOT be derived from a counter, a +/// timestamp, a peer id, a store id, a root, a cycle index, or any hash of those — `getrandom` +/// draws from the OS CSPRNG and touches none of those inputs. +/// +/// `% bound` is modulo-biased: outcomes below `u64::MAX % bound` are drawn very slightly more often +/// than the rest, by a factor bounded by `bound / 2^64`. At `bound` sizes realistic here (a +/// resource's byte length, at most a handful of GiB) that bias is on the order of 2^-20 or smaller +/// — the decider gate did not judge this in its first round on this ticket, adjudicating it only +/// afterward as inert (bias ≈ 2⁻³¹). If this ever needs to +/// tighten (e.g. `bound` grows close to `2^64`), switch to rejection sampling (redraw when +/// `raw >= bound * (u64::MAX / bound)`); it is a two-line change and this comment marks exactly +/// where. +fn csprng_u64_below(bound: u64) -> u64 { + if bound == 0 { + return 0; + } + let mut buf = [0u8; 8]; + getrandom::getrandom(&mut buf).expect("OS CSPRNG unavailable"); + u64::from_le_bytes(buf) % bound +} + +/// The `(peer_id, launcher_id)` pair a no-repeat rule is keyed on (SPEC §3.2 clause 5). +type ChallengeSubject = (Bytes32, Bytes32); +/// One issued window: `(cycle_index, resource_id, offset)`. +type IssuedWindow = (u32, Bytes32, u64); + +/// Remembers, per `(peer_id, launcher_id)`, the `(cycle_index, resource_id, offset)` windows +/// issued in the last [`CHALLENGE_NO_REPEAT_CYCLES`] cycles (SPEC §3.2 clause 5). +#[derive(Default)] +pub struct NoRepeatMemory { + recent: HashMap>, +} + +impl NoRepeatMemory { + pub fn new() -> Self { + Self::default() + } + + pub fn is_repeat( + &self, + peer_id: Bytes32, + launcher_id: Bytes32, + resource_id: Bytes32, + offset: u64, + cycle_index: u32, + ) -> bool { + self.recent + .get(&(peer_id, launcher_id)) + .is_some_and(|windows| { + windows.iter().any(|(cyc, rid, off)| { + *rid == resource_id + && *off == offset + && cycle_index.saturating_sub(*cyc) < CHALLENGE_NO_REPEAT_CYCLES + }) + }) + } + + /// Record a just-issued window, and prune everything older than + /// [`CHALLENGE_NO_REPEAT_CYCLES`] at the same time — both the per-subject window list AND any + /// `(peer_id, launcher_id)` key left with no window inside the horizon. `peer_id` is + /// peer-supplied, so without BOTH prunes this map is a memory-growth primitive: a peer that + /// keeps presenting fresh identities (a new key per cycle) would grow the outer map forever, + /// and even a stable peer's window list would grow forever without the inner prune. Neither + /// prune loses information [`Self::is_repeat`] could still use — the horizon it checks against + /// is exactly `CHALLENGE_NO_REPEAT_CYCLES`. + pub fn record( + &mut self, + peer_id: Bytes32, + launcher_id: Bytes32, + resource_id: Bytes32, + offset: u64, + cycle_index: u32, + ) { + self.recent.retain(|_, windows| { + windows.retain(|(cyc, _, _)| { + cycle_index.saturating_sub(*cyc) < CHALLENGE_NO_REPEAT_CYCLES + }); + !windows.is_empty() + }); + self.recent + .entry((peer_id, launcher_id)) + .or_default() + .push((cycle_index, resource_id, offset)); + } +} + +/// SPEC §3.2: pick ONE window — resource choice length-proportional (uniform-over-resources is +/// exploitable: a peer can discard the large resources, most of the bytes, and still pass), +/// offset uniform in `[0, total_length - length]`, length clamped down for a smaller resource, +/// and skipping any pick the no-repeat memory has already issued this peer within the window. +/// Returns `None` only when every resource is empty or repeats exhaust the retry budget. +pub fn select_window( + resources: &[Resource], + peer_id: Bytes32, + launcher_id: Bytes32, + cycle_index: u32, + memory: &mut NoRepeatMemory, +) -> Option { + let total_length: u64 = resources.iter().map(|r| r.length).sum(); + if resources.is_empty() || total_length == 0 { + return None; + } + + for _attempt in 0..16 { + let pick = csprng_u64_below(total_length); + let mut cumulative = 0u64; + let resource_index = resources + .iter() + .position(|r| { + cumulative += r.length; + pick < cumulative + }) + .unwrap_or(resources.len() - 1); + let resource = &resources[resource_index]; + let length = CHALLENGE_WINDOW_BYTES.min(resource.length); + let max_offset = resource.length - length; + let offset = csprng_u64_below(max_offset + 1); + + if !memory.is_repeat(peer_id, launcher_id, resource.id, offset, cycle_index) { + memory.record(peer_id, launcher_id, resource.id, offset, cycle_index); + return Some(WindowPlan { + resource_index, + offset, + length, + }); + } + } + None +} + +/// Why one challenge window failed (SPEC §3.5 — fail-closed on every one of these). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChallengeFailure { + Transport, + PeerIdMismatch, + Timeout, + RpcError(String), + FrameLengthMismatch, + OffsetMismatch, + LayoutMismatch, + DecodeError, +} + +/// One raw window response, before comparison against the locally-known bytes. +#[derive(Debug, Clone)] +pub struct ChallengeResponse { + pub bytes: Vec, +} + +/// The narrow transport seam this module drives — soundness only, no `dig.fetchRange` wiring here +/// (see module docs). +#[async_trait] +pub trait ChallengeTransport: Send + Sync { + async fn fetch_window( + &self, + peer_id: Bytes32, + resource_id: Bytes32, + offset: u64, + length: u64, + ) -> Result; +} + +/// SPEC §3.5: a single window passes only when the transport succeeds AND the returned bytes +/// match the locally-known bytes exactly. Every failure — transport, protocol, or a byte +/// difference — collapses to `false`; a valid-but-wrong-bytes response fails exactly as loudly as +/// no response (SPEC §3.4: a relayable inclusion proof MUST NOT be accepted as possession). +pub async fn run_window( + transport: &dyn ChallengeTransport, + peer_id: Bytes32, + resource_id: Bytes32, + offset: u64, + length: u64, + expected_bytes: &[u8], +) -> bool { + match transport + .fetch_window(peer_id, resource_id, offset, length) + .await + { + Ok(response) => response.bytes == expected_bytes, + Err(_) => false, + } +} + +/// SPEC §3.5: a cycle passes only if ALL windows match — no partial credit. +pub async fn run_cycle( + transport: &dyn ChallengeTransport, + peer_id: Bytes32, + windows: &[(Bytes32, u64, u64, Vec)], +) -> bool { + for (resource_id, offset, length, expected) in windows { + if !run_window(transport, peer_id, *resource_id, *offset, *length, expected).await { + return false; + } + } + true +} + +/// SPEC §3.6 per-`(peer_id, launcher_id)` strike accounting. A pass resets to zero; three +/// CONSECUTIVE genuine peer-caused failures schedule a `RemoveEntry`. Strikes reset entirely on +/// prover restart (§12.1). +#[derive(Default)] +pub struct StrikeTracker { + consecutive_failures: HashMap<(Bytes32, Bytes32), u32>, +} + +impl StrikeTracker { + pub fn new() -> Self { + Self::default() + } + + /// Record a genuinely peer-caused challenge-cycle outcome. Returns `true` when this outcome + /// crosses [`CHALLENGE_STRIKES_TO_EVICT`] and a `RemoveEntry` MUST now be scheduled. + /// + /// MUST NEVER be called for a cycle abandoned through the prover's own fault — see + /// [`Self::record_prover_fault`], which exists precisely so that path cannot reach this one. + pub fn record_peer_outcome( + &mut self, + peer_id: Bytes32, + launcher_id: Bytes32, + passed: bool, + ) -> bool { + let key = (peer_id, launcher_id); + if passed { + self.consecutive_failures.insert(key, 0); + false + } else { + let count = self.consecutive_failures.entry(key).or_insert(0); + *count += 1; + *count >= CHALLENGE_STRIKES_TO_EVICT + } + } + + /// SPEC §3.6 clause 4: `LocalCopyMissing`, `ChainSourceUnavailable`, the prover's own cycle + /// deadline, or a reorg — none of these is the peer's fault, so none of them may touch a + /// strike counter. This function is intentionally a no-op; it exists so a caller reaches for a + /// NAMED prover-fault path instead of `record_peer_outcome`, which is the mistake that would + /// strike every peer for one broken node. + pub fn record_prover_fault(&self, _peer_id: Bytes32, _launcher_id: Bytes32) {} + + pub fn consecutive_failures(&self, peer_id: Bytes32, launcher_id: Bytes32) -> u32 { + self.consecutive_failures + .get(&(peer_id, launcher_id)) + .copied() + .unwrap_or(0) + } + + /// SPEC §12.1 clause 3: strikes reset to zero on prover restart. + pub fn reset_all(&mut self) { + self.consecutive_failures.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const PEER: Bytes32 = [1; 32]; + const LAUNCHER: Bytes32 = [2; 32]; + const RESOURCE: Bytes32 = [3; 32]; + + struct FakeTransport { + bytes: Vec, + fail: Option, + } + + #[async_trait] + impl ChallengeTransport for FakeTransport { + async fn fetch_window( + &self, + _peer_id: Bytes32, + _resource_id: Bytes32, + _offset: u64, + _length: u64, + ) -> Result { + if let Some(f) = &self.fail { + return Err(f.clone()); + } + Ok(ChallengeResponse { + bytes: self.bytes.clone(), + }) + } + } + + #[tokio::test] + async fn matching_bytes_pass() { + let t = FakeTransport { + bytes: vec![1, 2, 3], + fail: None, + }; + assert!(run_window(&t, PEER, RESOURCE, 0, 3, &[1, 2, 3]).await); + } + + #[tokio::test] + async fn wrong_bytes_fail_as_loudly_as_no_response() { + let t = FakeTransport { + bytes: vec![9, 9, 9], + fail: None, + }; + assert!(!run_window(&t, PEER, RESOURCE, 0, 3, &[1, 2, 3]).await); + } + + #[tokio::test] + async fn transport_error_fails_closed() { + let t = FakeTransport { + bytes: vec![], + fail: Some(ChallengeFailure::Timeout), + }; + assert!(!run_window(&t, PEER, RESOURCE, 0, 3, &[1, 2, 3]).await); + } + + /// SPEC §3.5: no partial credit — one bad window fails the whole cycle. + #[tokio::test] + async fn one_mismatched_window_fails_the_whole_cycle() { + let t = FakeTransport { + bytes: vec![1, 2, 3], + fail: None, + }; + let windows = vec![ + (RESOURCE, 0, 3, vec![1, 2, 3]), + (RESOURCE, 3, 3, vec![9, 9, 9]), // this one will mismatch: transport always returns [1,2,3] + ]; + assert!(!run_cycle(&t, PEER, &windows).await); + } + + #[test] + fn window_offset_and_length_stay_within_the_resource() { + let resources = [Resource { + id: RESOURCE, + length: 10, + }]; + let mut memory = NoRepeatMemory::new(); + let plan = select_window(&resources, PEER, LAUNCHER, 0, &mut memory).expect("a window"); + assert_eq!(plan.resource_index, 0); + assert!(plan.length <= 10); + assert!(plan.offset + plan.length <= 10); + } + + #[test] + fn no_repeat_memory_blocks_the_same_window_within_the_bound() { + let mut memory = NoRepeatMemory::new(); + assert!(!memory.is_repeat(PEER, LAUNCHER, RESOURCE, 5, 0)); + memory.record(PEER, LAUNCHER, RESOURCE, 5, 0); + assert!(memory.is_repeat(PEER, LAUNCHER, RESOURCE, 5, CHALLENGE_NO_REPEAT_CYCLES - 1)); + assert!(!memory.is_repeat(PEER, LAUNCHER, RESOURCE, 5, CHALLENGE_NO_REPEAT_CYCLES)); + } + + /// `peer_id` is peer-supplied; without pruning, a peer presenting a fresh identity every cycle + /// (or one honest peer over many cycles) would grow `NoRepeatMemory` without bound. This drives + /// far more distinct peer ids and cycles than the horizon and asserts the map never exceeds a + /// small, horizon-bounded size. + #[test] + fn no_repeat_memory_does_not_grow_without_bound() { + let mut memory = NoRepeatMemory::new(); + for cycle in 0..2_000u32 { + let peer = { + let mut id = [0u8; 32]; + id[0..4].copy_from_slice(&cycle.to_le_bytes()); + id + }; + memory.record(peer, LAUNCHER, RESOURCE, cycle as u64, cycle); + // Only subjects whose most recent window is still inside the no-repeat horizon may + // remain — a distinct peer id every cycle means at most CHALLENGE_NO_REPEAT_CYCLES of + // them are ever live at once. + assert!( + memory.recent.len() <= CHALLENGE_NO_REPEAT_CYCLES as usize, + "NoRepeatMemory grew to {} entries at cycle {cycle}, unbounded", + memory.recent.len() + ); + } + } + + #[test] + fn three_consecutive_peer_failures_schedule_a_removal() { + let mut strikes = StrikeTracker::new(); + assert!(!strikes.record_peer_outcome(PEER, LAUNCHER, false)); + assert!(!strikes.record_peer_outcome(PEER, LAUNCHER, false)); + assert!(strikes.record_peer_outcome(PEER, LAUNCHER, false)); + assert_eq!(strikes.consecutive_failures(PEER, LAUNCHER), 3); + } + + #[test] + fn a_pass_resets_the_strike_count() { + let mut strikes = StrikeTracker::new(); + strikes.record_peer_outcome(PEER, LAUNCHER, false); + strikes.record_peer_outcome(PEER, LAUNCHER, false); + strikes.record_peer_outcome(PEER, LAUNCHER, true); + assert_eq!(strikes.consecutive_failures(PEER, LAUNCHER), 0); + } + + /// The prover-fault case (D-equivalent to §3.6 clause 4): a chain outage MUST NOT strike any + /// peer. Simulates the fault path failing to strike every peer in a set of several. + #[tokio::test] + async fn prover_fault_never_increments_any_peer_strike() { + let strikes = StrikeTracker::new(); + let peers: [Bytes32; 3] = [[10; 32], [11; 32], [12; 32]]; + for peer in peers { + strikes.record_prover_fault(peer, LAUNCHER); + } + for peer in peers { + assert_eq!(strikes.consecutive_failures(peer, LAUNCHER), 0); + } + } +} diff --git a/crates/dig-node-core/src/rewards/cycle.rs b/crates/dig-node-core/src/rewards/cycle.rs new file mode 100644 index 00000000..98ce6329 --- /dev/null +++ b/crates/dig-node-core/src/rewards/cycle.rs @@ -0,0 +1,238 @@ +//! SPEC §2.5: the always-on per-distributor prover cycle — and the honesty properties that keep +//! a wedged loop from looking healthy. +//! +//! Three mechanisms, each with its own test below because an always-on loop is trivially easy to +//! keep green while it never actually runs: +//! 1. [`run_cycle_with_deadline`] enforces the `PROVER_CYCLE_DEADLINE_SECONDS` hard deadline — +//! a cycle that never resolves is ABANDONED, counted as a failure, and reported; it never +//! silently advances `last_cycle_completed_at`. +//! 2. [`heartbeat_tick`] / [`heartbeat_loop`] refresh `observed_at` at least every +//! `PROVER_HEARTBEAT_SECONDS`, including while `Idle` — that is what makes "the process is +//! gone" distinguishable from "the process is between cycles" (§2.5 clause 1). +//! 3. [`is_wedged`] is the READER-side derivation a caller (e.g. the RPC handler) uses to detect a +//! stalled writer: it compares `observed_at` against the reader's OWN clock, never a flag the +//! writer set — a wedged writer cannot make this reassuring because it cannot touch it. + +use super::spec_constants::{ + PROVER_CYCLE_DEADLINE_SECONDS, PROVER_CYCLE_PERIOD_SECONDS, PROVER_HEARTBEAT_SECONDS, +}; +use super::state::{Clock, ProverState, StatusHandle}; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::watch; +use tokio::time::timeout; + +/// Run ONE cycle attempt against a hard deadline (SPEC §2.5 clause 2). `cycle_fn` is the actual +/// cycle work (chain reads, admission, challenges, writes) as a future; this wrapper enforces the +/// deadline and updates the status record honestly regardless of outcome — it does not know or +/// care what the work does. +/// +/// Returns `true` if the cycle completed within the deadline, `false` if it was abandoned. +pub async fn run_cycle_with_deadline( + status: &StatusHandle, + clock: &dyn Clock, + cycle_fn: F, +) -> bool +where + F: FnOnce() -> Fut, + Fut: Future, +{ + let started_at = clock.now_unix_seconds(); + status.update(|s| { + s.prover_state = ProverState::Running; + s.last_cycle_started_at = Some(started_at); + s.observed_at = started_at; + }); + + match timeout( + Duration::from_secs(PROVER_CYCLE_DEADLINE_SECONDS), + cycle_fn(), + ) + .await + { + Ok(()) => { + let completed_at = clock.now_unix_seconds(); + status.update(|s| { + s.prover_state = ProverState::Idle; + s.last_cycle_completed_at = Some(completed_at); + s.next_cycle_due_at = Some(completed_at + PROVER_CYCLE_PERIOD_SECONDS); + s.consecutive_cycle_failures = 0; + s.observed_at = completed_at; + }); + true + } + Err(_elapsed) => { + // SPEC §2.5 clause 2: abandon, count as a failure, report — never leave pending, and + // NEVER advance `last_cycle_completed_at`: this cycle did not complete. + let now = clock.now_unix_seconds(); + status.update(|s| { + s.prover_state = ProverState::Idle; + s.consecutive_cycle_failures += 1; + s.observed_at = now; + }); + false + } + } +} + +/// One heartbeat: refresh `observed_at` from the clock. Exposed separately from +/// [`heartbeat_loop`] so the "refreshed at least every `PROVER_HEARTBEAT_SECONDS`, including +/// while `Idle`" property (SPEC §2.5 clause 1) has a deterministic, non-timing-dependent test. +pub fn heartbeat_tick(status: &StatusHandle, clock: &dyn Clock) { + status.update(|s| s.observed_at = clock.now_unix_seconds()); +} + +/// The heartbeat loop: calls [`heartbeat_tick`] every `PROVER_HEARTBEAT_SECONDS` until `stop` +/// carries `true`. Runs independently of whether a cycle is in progress — SPEC §2.5 clause 1 is +/// explicit that this MUST fire "including while `Idle`". +pub async fn heartbeat_loop( + status: StatusHandle, + clock: Arc, + mut stop: watch::Receiver, +) { + loop { + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(PROVER_HEARTBEAT_SECONDS)) => { + heartbeat_tick(&status, clock.as_ref()); + } + _ = stop.changed() => { + if *stop.borrow() { + break; + } + } + } + } +} + +/// The READER-side wedge derivation (SPEC §2.4/§2.5): `observed_at` is the ONLY staleness signal +/// this engine exposes. A reader compares it against ITS OWN clock — never a writer-set flag, +/// which is exactly the honesty property §2.4 forbids violating. +pub fn is_wedged(observed_at: u64, reader_now: u64) -> bool { + reader_now.saturating_sub(observed_at) > PROVER_HEARTBEAT_SECONDS * 2 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rewards::state::{idle_status, TestClock}; + + fn status_at(now: u64) -> StatusHandle { + StatusHandle::new(idle_status([0; 32], [0; 32], [0; 32], now)) + } + + /// A cycle that blocks forever is abandoned at the deadline, counted as a failure, and MUST + /// NOT advance `last_cycle_completed_at`. Under `start_paused`, tokio auto-advances virtual + /// time to the timeout's own timer once nothing else can make progress — the wedged + /// `cycle_fn` (a `pending()` future) never does. + #[tokio::test(start_paused = true)] + async fn wedged_cycle_is_abandoned_at_the_deadline_and_does_not_fake_completion() { + let clock = TestClock::new(1_000); + let status = status_at(1_000); + + let completed = run_cycle_with_deadline(&status, &clock, std::future::pending::<()>).await; + + assert!( + !completed, + "a cycle that never resolves must be reported as abandoned" + ); + let snap = status.snapshot(); + assert_eq!(snap.consecutive_cycle_failures, 1); + assert_eq!( + snap.last_cycle_completed_at, None, + "an abandoned cycle must never advance last_cycle_completed_at" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_cycle_that_finishes_in_time_completes_normally() { + let clock = TestClock::new(1_000); + let status = status_at(1_000); + + let completed = run_cycle_with_deadline(&status, &clock, || async {}).await; + + assert!(completed); + let snap = status.snapshot(); + assert_eq!(snap.consecutive_cycle_failures, 0); + assert_eq!(snap.last_cycle_completed_at, Some(1_000)); + assert_eq!( + snap.next_cycle_due_at, + Some(1_000 + PROVER_CYCLE_PERIOD_SECONDS) + ); + } + + /// SPEC §2.5 clause 1: a heartbeat fires even while the prover is sitting `Idle` between + /// cycles — this is what distinguishes "gone" from "between cycles" (deterministic: drives + /// the tick directly rather than the timer). + #[test] + fn heartbeat_tick_advances_observed_at_while_idle() { + let clock = TestClock::new(1_000); + let status = status_at(1_000); + assert_eq!(status.snapshot().prover_state, ProverState::Idle); + + clock.advance(PROVER_HEARTBEAT_SECONDS); + heartbeat_tick(&status, &clock); + + let snap = status.snapshot(); + assert_eq!(snap.observed_at, 1_000 + PROVER_HEARTBEAT_SECONDS); + assert_eq!( + snap.prover_state, + ProverState::Idle, + "a heartbeat must not touch prover_state" + ); + } + + /// The wedged-loop reader-side property: `observed_at` stops advancing while a cycle is stuck + /// (started but never completed, and no heartbeat fired), so a reader comparing it against its + /// own clock detects the wedge WITHOUT any writer-set flag existing to lie about it. + #[test] + fn a_stalled_observed_at_is_detected_by_the_readers_own_clock() { + let clock = TestClock::new(1_000); + let status = status_at(1_000); + status.update(|s| { + s.prover_state = ProverState::Running; + s.observed_at = clock.now_unix_seconds(); + }); + + // The writer never ticks again (that IS the wedge). The reader's own notion of "now" + // keeps moving regardless. + let reader_now = 1_000 + PROVER_HEARTBEAT_SECONDS * 3; + assert!(is_wedged(status.snapshot().observed_at, reader_now)); + } + + #[test] + fn a_recently_heartbeat_status_is_not_wedged() { + let clock = TestClock::new(1_000); + let status = status_at(1_000); + heartbeat_tick(&status, &clock); + let reader_now = 1_000 + PROVER_HEARTBEAT_SECONDS; // within bound, one missed tick at most + assert!(!is_wedged(status.snapshot().observed_at, reader_now)); + } + + /// The real async heartbeat loop actually fires on its own timer, not only via the + /// deterministic direct-call test above. + #[tokio::test(start_paused = true)] + async fn heartbeat_loop_fires_on_its_own_timer() { + let clock = Arc::new(TestClock::new(1_000)); + let status = status_at(1_000); + let (tx, rx) = watch::channel(false); + + let loop_status = status.clone(); + let loop_clock: Arc = clock.clone(); + let handle = tokio::spawn(heartbeat_loop(loop_status, loop_clock, rx)); + + // Let the loop reach its `sleep` and REGISTER its timer before virtual time moves. Without + // this, `advance` jumps over a timer that does not exist yet and the loop then sleeps from the + // far side of the jump — the test would fail while the loop is behaving correctly. + tokio::task::yield_now().await; + + clock.advance(PROVER_HEARTBEAT_SECONDS); + tokio::time::advance(Duration::from_secs(PROVER_HEARTBEAT_SECONDS)).await; + tokio::task::yield_now().await; + + assert!(status.snapshot().observed_at >= 1_000 + PROVER_HEARTBEAT_SECONDS); + + tx.send(true).expect("stop channel open"); + handle.await.expect("heartbeat loop task"); + } +} diff --git a/crates/dig-node-core/src/rewards/gate.rs b/crates/dig-node-core/src/rewards/gate.rs new file mode 100644 index 00000000..f78f6121 --- /dev/null +++ b/crates/dig-node-core/src/rewards/gate.rs @@ -0,0 +1,443 @@ +//! The mirror-coin gate (SPEC §4, §10). A candidate is admitted only when all three §4.3 calls +//! agree: `advertises(store, root, census_epoch)` AND `declares_peer(peer_id)` -> +//! `owner_puzzle_hash()`. Fail-closed on every absence or mismatch (§4.2, §10.3) — ineligibility is +//! never an accusation, never a strike, never a blocklist entry. +//! +//! This module does not reimplement `MirrorCoin::advertises` / `declares_peer` / +//! `owner_puzzle_hash` (Appendix B hard rule — see `crate::mirror_bond` for the existing verified- +//! pointer pattern this follows). It defines [`MirrorCoinReader`], the narrow seam over those three +//! calls, and drives the SPEC's admission logic — including the §4.6 census offset and the §4.6.3 +//! grace window — against it. The host binary supplies the real reader (wired to `dig-mirror-coin`) +//! exactly the way `mirror_bond::MirrorBondVerifier` is wired today. + +use super::spec_constants::MIRROR_EPOCH_GRACE_SECONDS; +use async_trait::async_trait; + +/// One candidate's claimed mirror-coin pointer, exactly as a `ProviderRecord` carries it +// (`unverified_mirror_coin_id`, SPEC §4.1-§4.2) — a claim, proves nothing on its own. +pub type CoinIdHint = Option<[u8; 32]>; + +/// The mirror-collateral epoch context needed to evaluate one peer this cycle (SPEC §4.6). +/// +/// `current_epoch` is the mirror-collateral epoch ordinal currently open (`n`), supplied by the +/// caller as CONFIGURATION — this gate never computes or guesses it (SPEC §4.6 clause 2, +/// DIG-Network/dig_ecosystem#3259: nobody owns the calendar yet). Its absence is a PROVER-side +/// fault, not a peer-attributable one — see [`GateError::EpochOrdinalUnavailable`]. +#[derive(Debug, Clone, Copy)] +pub struct EpochContext { + pub current_epoch: Option, + /// Wall-clock unix seconds the CURRENT epoch rolled over at, if known. `None` = no rollover + /// tracked (e.g. first epoch observed), so no grace applies. + pub epoch_rolled_over_at: Option, + /// Now, from the caller's injected `Clock` — used only to decide whether we're still inside + /// the SPEC §4.6.3 grace window. + pub now: u64, +} + +impl EpochContext { + fn in_grace_window(&self) -> bool { + match self.epoch_rolled_over_at { + Some(rolled_at) => self.now.saturating_sub(rolled_at) < MIRROR_EPOCH_GRACE_SECONDS, + None => false, + } + } +} + +/// The three SPEC §4.3 calls, plus the §4.2 coin-validity checks, as one seam. An implementation +/// MUST perform every §4.2 check (puzzle hash, asset id, collateral, unspent) before answering +/// `advertises`/`declares_peer`/`owner_puzzle_hash` — this trait's contract is that a `true` / +/// `Some` answer already reflects all of them, so the gate above it does not need to re-derive +/// coin validity. +#[async_trait] +pub trait MirrorCoinReader: Send + Sync { + /// SPEC §4.2 + §4.3 row 1: fetch the coin at `coin_id` and confirm it advertises exactly + /// `(store_id, root, census_epoch)`. `false` for absent, unresolvable, invalid, spent, + /// under-collateralised, or non-advertising — every §4.2/§4.3.1 failure collapses to `false` + /// here because none of them distinguish for the caller (SPEC §4.2: "MUST NOT be treated as + /// evidence of bad faith"). + async fn advertises( + &self, + coin_id: [u8; 32], + store_id: [u8; 32], + root: [u8; 32], + census_epoch: u64, + ) -> bool; + + /// SPEC §4.3 row 2: does this coin declare `peer_id` as its owner-authenticated claimant. + async fn declares_peer(&self, coin_id: [u8; 32], peer_id: [u8; 32]) -> bool; + + /// SPEC §4.3 row 3 / §10.2: the payout puzzle hash the entry would carry, derived from the + /// coin's lineage proof. `None` if the coin cannot be resolved (fail-closed). + async fn owner_puzzle_hash(&self, coin_id: [u8; 32]) -> Option<[u8; 32]>; +} + +/// Why a candidate was refused. Carried for logging/tests only — SPEC §4.2/§10.3: none of these is +/// an accusation, so no variant here may become a strike or a blocklist entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GateIneligibleReason { + /// No `unverified_mirror_coin_id` hint on the candidate's provider record. + AbsentCoinIdHint, + /// The coin does not advertise this `(store, root, census_epoch)` at all — covers "spent", + /// "wrong epoch ordinal", and "not a mirror coin" alike (§4.2's collapse). + DoesNotAdvertise, + /// The coin advertises the content but does not declare this candidate's `peer_id`. + PeerNotDeclared, + /// The coin resolved but its lineage-derived owner puzzle hash could not be read. + AbsentDeclaration, +} + +/// What the gate decided for one candidate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GateOutcome { + Eligible { payout_puzzle_hash: [u8; 32] }, + Ineligible(GateIneligibleReason), +} + +/// Why the gate could not evaluate ANY candidate this cycle — a prover-side fault, never a +/// peer-attributable ineligibility. Deliberately NOT a `GateIneligibleReason` variant: a caller +/// that could construct this as ordinary ineligibility would strike the peer for a configuration +/// gap that is not its fault (SPEC §3.6 clause 4 / dig_ecosystem#3250 D5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GateError { + /// SPEC §4.6 clause 2 / dig_ecosystem#3259: the mirror-collateral epoch ordinal was not + /// supplied. The caller MUST map this to `ProverState::ChainSourceUnavailable`, abort the + /// cycle WITHOUT evaluating any candidate, and MUST NOT increment any peer's strike counter. + EpochOrdinalUnavailable, +} + +/// The mirror-coin gate contract [`admission::admit`](super::admission::admit) drives. +#[async_trait] +pub trait MirrorCoinGatePort: Send + Sync { + async fn evaluate( + &self, + peer_id: [u8; 32], + ctx: EpochContext, + ) -> Result; +} + +/// The SPEC §4-driven gate: takes a candidate's coin-id hint and a `MirrorCoinReader`, and decides +/// eligibility per §4.2-§4.6. +pub struct SpecMirrorCoinGate { + reader: R, + store_id: [u8; 32], + root: [u8; 32], + /// A peer_id -> coin-id-hint lookup: the gate itself does not own DHT candidate state, only the + /// mapping a discovery path already resolved for this peer this cycle. + coin_hint_for: std::collections::HashMap<[u8; 32], CoinIdHint>, +} + +impl SpecMirrorCoinGate { + pub fn new( + reader: R, + store_id: [u8; 32], + root: [u8; 32], + coin_hint_for: std::collections::HashMap<[u8; 32], CoinIdHint>, + ) -> Self { + Self { + reader, + store_id, + root, + coin_hint_for, + } + } + + /// SPEC §4.6.3: during the grace window after a rollover, the PREVIOUS census ordinal is also + /// accepted, and a rollover mismatch MUST NOT strike (the caller enforces the "no strike" half; + /// this function only decides eligibility). `census_epoch` is already the §4.6.1 offset + /// (`current_epoch - 1`) — see [`Self::census_epoch`]. + pub async fn advertises_current_or_previous( + &self, + coin_id: [u8; 32], + census_epoch: u64, + in_grace_window: bool, + ) -> bool { + if self + .reader + .advertises(coin_id, self.store_id, self.root, census_epoch) + .await + { + return true; + } + if in_grace_window && census_epoch > 0 { + return self + .reader + .advertises(coin_id, self.store_id, self.root, census_epoch - 1) + .await; + } + false + } +} + +#[async_trait] +impl MirrorCoinGatePort for SpecMirrorCoinGate { + async fn evaluate( + &self, + peer_id: [u8; 32], + ctx: EpochContext, + ) -> Result { + // SPEC §4.6 clause 2 / D5: the ordinal is an INPUT; its absence is a PROVER fault + // (ChainSourceUnavailable at the cycle layer), never guessed and never peer-attributable + // ineligibility (dig_ecosystem#3259, #3250 D5). + let Some(current_epoch) = ctx.current_epoch else { + return Err(GateError::EpochOrdinalUnavailable); + }; + + // SPEC §4.6.1: a coin qualifies for the census of epoch `n` only by declaring `n-1` + // EXACTLY. `n == 0` means no epoch has closed a census round yet — nothing can qualify. + let Some(census_epoch) = current_epoch.checked_sub(1) else { + return Ok(GateOutcome::Ineligible( + GateIneligibleReason::DoesNotAdvertise, + )); + }; + + let Some(hint) = self.coin_hint_for.get(&peer_id).copied().flatten() else { + return Ok(GateOutcome::Ineligible( + GateIneligibleReason::AbsentCoinIdHint, + )); + }; + + if !self + .advertises_current_or_previous(hint, census_epoch, ctx.in_grace_window()) + .await + { + return Ok(GateOutcome::Ineligible( + GateIneligibleReason::DoesNotAdvertise, + )); + } + + if !self.reader.declares_peer(hint, peer_id).await { + return Ok(GateOutcome::Ineligible( + GateIneligibleReason::PeerNotDeclared, + )); + } + + match self.reader.owner_puzzle_hash(hint).await { + Some(payout_puzzle_hash) => Ok(GateOutcome::Eligible { payout_puzzle_hash }), + None => Ok(GateOutcome::Ineligible( + GateIneligibleReason::AbsentDeclaration, + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[derive(Default)] + struct FakeReader { + advertising: HashMap<([u8; 32], u64), bool>, + declaring: HashMap<[u8; 32], [u8; 32]>, + owners: HashMap<[u8; 32], [u8; 32]>, + } + + #[async_trait] + impl MirrorCoinReader for FakeReader { + async fn advertises( + &self, + coin_id: [u8; 32], + _store_id: [u8; 32], + _root: [u8; 32], + epoch: u64, + ) -> bool { + self.advertising + .get(&(coin_id, epoch)) + .copied() + .unwrap_or(false) + } + async fn declares_peer(&self, coin_id: [u8; 32], peer_id: [u8; 32]) -> bool { + self.declaring.get(&coin_id) == Some(&peer_id) + } + async fn owner_puzzle_hash(&self, coin_id: [u8; 32]) -> Option<[u8; 32]> { + self.owners.get(&coin_id).copied() + } + } + + const STORE: [u8; 32] = [1; 32]; + const ROOT: [u8; 32] = [2; 32]; + const PEER: [u8; 32] = [3; 32]; + const COIN: [u8; 32] = [4; 32]; + const OWNER: [u8; 32] = [5; 32]; + + fn gate(reader: FakeReader, hint: CoinIdHint) -> SpecMirrorCoinGate { + SpecMirrorCoinGate::new(reader, STORE, ROOT, [(PEER, hint)].into_iter().collect()) + } + + fn ctx(current_epoch: Option) -> EpochContext { + EpochContext { + current_epoch, + epoch_rolled_over_at: None, + now: 0, + } + } + + #[tokio::test] + async fn absent_coin_id_hint_is_ineligible() { + let g = gate(FakeReader::default(), None); + assert_eq!( + g.evaluate(PEER, ctx(Some(2))).await, + Ok(GateOutcome::Ineligible( + GateIneligibleReason::AbsentCoinIdHint + )) + ); + } + + /// D5: an absent epoch ordinal is a PROVER-side fault, never `GateIneligibleReason` — it must + /// come back as `Err`, not as an eligibility verdict a caller could strike a peer over. + #[tokio::test] + async fn epoch_ordinal_absent_is_a_gate_error_not_an_ineligibility_verdict() { + let g = gate(FakeReader::default(), Some(COIN)); + assert_eq!( + g.evaluate(PEER, ctx(None)).await, + Err(GateError::EpochOrdinalUnavailable) + ); + } + + #[tokio::test] + async fn current_epoch_zero_has_no_closed_census_and_is_ineligible() { + let g = gate(FakeReader::default(), Some(COIN)); + assert_eq!( + g.evaluate(PEER, ctx(Some(0))).await, + Ok(GateOutcome::Ineligible( + GateIneligibleReason::DoesNotAdvertise + )) + ); + } + + #[tokio::test] + async fn spent_or_non_advertising_coin_is_ineligible() { + let reader = FakeReader::default(); // advertising map empty == coin doesn't advertise (covers spent/absent) + let g = gate(reader, Some(COIN)); + assert_eq!( + g.evaluate(PEER, ctx(Some(2))).await, + Ok(GateOutcome::Ineligible( + GateIneligibleReason::DoesNotAdvertise + )) + ); + } + + /// D1 regression: declaring the CURRENT epoch ordinal `n` directly must NOT qualify the + /// census of epoch `n` — only `n-1` does (SPEC §4.6.1). This fails on the pre-fix code, which + /// queried `advertises(.., current_epoch)` instead of `current_epoch - 1`. + #[tokio::test] + async fn census_epoch_is_n_minus_1_not_n() { + let mut reader = FakeReader::default(); + reader.advertising.insert((COIN, 5), true); // declares n=5 itself, not n-1=4 + reader.declaring.insert(COIN, PEER); + reader.owners.insert(COIN, OWNER); + let g = gate(reader, Some(COIN)); + assert_eq!( + g.evaluate(PEER, ctx(Some(5))).await, + Ok(GateOutcome::Ineligible( + GateIneligibleReason::DoesNotAdvertise + )), + "declaring n directly must NOT qualify the census of epoch n (SPEC §4.6.1)" + ); + } + + /// D1 positive: declaring exactly `n-1` for current epoch `n` DOES qualify. + #[tokio::test] + async fn census_epoch_n_minus_1_is_admitted() { + let mut reader = FakeReader::default(); + reader.advertising.insert((COIN, 4), true); // n-1 = 4 for current epoch n=5 + reader.declaring.insert(COIN, PEER); + reader.owners.insert(COIN, OWNER); + let g = gate(reader, Some(COIN)); + assert_eq!( + g.evaluate(PEER, ctx(Some(5))).await, + Ok(GateOutcome::Eligible { + payout_puzzle_hash: OWNER + }) + ); + } + + #[tokio::test] + async fn declares_peer_mismatch_is_ineligible() { + let mut reader = FakeReader::default(); + reader.advertising.insert((COIN, 1), true); // census epoch 1 (current epoch 2) + reader.declaring.insert(COIN, [0xEE; 32]); // declares someone else + let g = gate(reader, Some(COIN)); + assert_eq!( + g.evaluate(PEER, ctx(Some(2))).await, + Ok(GateOutcome::Ineligible( + GateIneligibleReason::PeerNotDeclared + )) + ); + } + + /// D3: exercises the `owner_puzzle_hash() == None` path, distinct from `PeerNotDeclared`. + #[tokio::test] + async fn absent_declaration_is_ineligible() { + let mut reader = FakeReader::default(); + reader.advertising.insert((COIN, 1), true); // census epoch 1 (current epoch 2) + reader.declaring.insert(COIN, PEER); + // owners map has no entry for COIN -> owner_puzzle_hash() resolves to None. + let g = gate(reader, Some(COIN)); + assert_eq!( + g.evaluate(PEER, ctx(Some(2))).await, + Ok(GateOutcome::Ineligible( + GateIneligibleReason::AbsentDeclaration + )) + ); + } + + #[tokio::test] + async fn all_three_calls_agreeing_is_eligible() { + let mut reader = FakeReader::default(); + reader.advertising.insert((COIN, 1), true); // census epoch 1 (current epoch 2) + reader.declaring.insert(COIN, PEER); + reader.owners.insert(COIN, OWNER); + let g = gate(reader, Some(COIN)); + assert_eq!( + g.evaluate(PEER, ctx(Some(2))).await, + Ok(GateOutcome::Eligible { + payout_puzzle_hash: OWNER + }) + ); + } + + /// SPEC §4.6.3: previous census ordinal accepted inside the grace window — reachable through + /// `evaluate`, not only through the private helper (D2 regression). + #[tokio::test] + async fn grace_window_makes_previous_census_ordinal_admissible_via_evaluate() { + let mut reader = FakeReader::default(); + reader.advertising.insert((COIN, 4), true); // pre-rollover census ordinal + reader.declaring.insert(COIN, PEER); + reader.owners.insert(COIN, OWNER); + let g = gate(reader, Some(COIN)); + let inside_grace = EpochContext { + current_epoch: Some(6), // census would need 5; coin still shows 4 + epoch_rolled_over_at: Some(1_000), + now: 1_000 + MIRROR_EPOCH_GRACE_SECONDS - 1, + }; + assert_eq!( + g.evaluate(PEER, inside_grace).await, + Ok(GateOutcome::Eligible { + payout_puzzle_hash: OWNER + }) + ); + } + + /// D2 regression: outside the grace window the same mismatch is simply ineligible (never a + /// strike — enforced at the cycle layer). + #[tokio::test] + async fn outside_grace_window_previous_census_ordinal_is_rejected() { + let mut reader = FakeReader::default(); + reader.advertising.insert((COIN, 4), true); + reader.declaring.insert(COIN, PEER); + reader.owners.insert(COIN, OWNER); + let g = gate(reader, Some(COIN)); + let outside_grace = EpochContext { + current_epoch: Some(6), + epoch_rolled_over_at: Some(1_000), + now: 1_000 + MIRROR_EPOCH_GRACE_SECONDS + 1, + }; + assert_eq!( + g.evaluate(PEER, outside_grace).await, + Ok(GateOutcome::Ineligible( + GateIneligibleReason::DoesNotAdvertise + )) + ); + } +} diff --git a/crates/dig-node-core/src/rewards/mod.rs b/crates/dig-node-core/src/rewards/mod.rs new file mode 100644 index 00000000..d80a21e7 --- /dev/null +++ b/crates/dig-node-core/src/rewards/mod.rs @@ -0,0 +1,49 @@ +//! The rewards prover engine (DIG-Network/dig_ecosystem#3250): the node-side half of +//! `dig-rewards-coin`'s reward-distributor loop. +//! +//! It runs an always-on per-distributor cycle ([`cycle`]), gates every discovered mirror +//! candidate through the SPEC §4 mirror-coin proof ([`gate`], [`admission`]), issues and grades +//! §3 possession challenges ([`challenge`]), decides and rate-limits §6.3 entry-set writes +//! ([`writes`]), and derives the §12.4 staleness bound from chain-observed state only +//! ([`staleness`]). +//! +//! The chain seam ([`port`]'s `RewardsChainPort`) is UNIMPLEMENTED pending +//! DIG-Network/dig_ecosystem#3249 — `dig-rewards-coin` is SPEC-only today (its `distributor` +//! module is an empty placeholder). The production adapter wired into this crate is +//! `port::UnavailableChainPort`, which runs no cycles and reports +//! `port::ChainPortError::Unavailable` rather than a silent no-op. Every value this engine +//! compares against the SPEC's numeric bounds lives in [`spec_constants`], tagged with its +//! clause, so #3249 landing its own constants is a single, deliberate migration rather than a +//! scattered one. +//! +//! # The worst-case spend, stated where a human reads it +//! +//! [`spec_constants::MAX_ENTRY_WRITES_PER_BUNDLE`] = 8 actions per bundle, at most one bundle per +//! [`spec_constants::ENTRY_WRITE_MIN_INTERVAL_SECONDS`] = 3,600 s → **24 bundles/day, 192 entry +//! actions/day**, per distributor this node funds. +//! +//! **Fee ceiling**: 24 × the operator's configured standard fee, per day, per distributor — +//! nominally ~0.00012 XCH/day at a typical ~0.000005 XCH fee, but **~0.24 XCH/day (≈88 XCH/year)** +//! at a congested 0.01 XCH fee. This bound is NOT independent of the rate bound above: 24 +//! bundles/day is simultaneously the rate limit and the fee ceiling, so [`writes::FeeBudget`] does +//! not add a second, separate protection on top of the rate bound — stated plainly here so nobody +//! reads this engine as having two independent spend controls when it has one. +//! +//! **Eviction**: if every bundle is all removals, the ceiling is **192 `Remove` actions/day** (24 +//! bundles × 8 actions each) — the same 192-action/day cap stated above, not a fraction of it. +//! **96/day is a different number: the evict-plus-re-add churn ceiling**, since each churn (evict +//! one entry, admit a replacement) costs one `Remove` and one `Add`, so 192 actions/day buy at +//! most 96 churns/day. SPEC §6.4: `RemoveEntry` settles the entry's full accrued balance, ignoring +//! `payout_threshold` — so sustained churn can flush an entire 250-entry set's accrued balance, +//! including sub-threshold dust that could never otherwise have been claimed, in **~2.6 days** +//! (250 entries / 96 churns-per-day). + +pub mod admission; +pub mod challenge; +pub mod cycle; +pub mod gate; +pub mod port; +pub mod spec_constants; +pub mod staleness; +pub mod state; +pub mod writes; diff --git a/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs new file mode 100644 index 00000000..d9844fe9 --- /dev/null +++ b/crates/dig-node-core/src/rewards/port.rs @@ -0,0 +1,169 @@ +//! The chain port — the seam this whole engine is built against instead of `dig-rewards-coin`. +//! +//! `dig-rewards-coin` is SPEC-only as of the tag this lane read: `src/lib.rs` is a documented +//! placeholder and `pub mod distributor {}` is empty. Implementing the driver is +//! DIG-Network/dig_ecosystem#3249, a sibling lane. So the prover engine is built COMPLETELY against +//! a narrow trait derived from the SPEC's own described surface (not from the driver's internals, +//! so it is stable across #3249 landing), tested with an in-memory fake, and the production +//! adapter — until #3249 ships — reports [`ChainPortError::Unavailable`] and runs no cycles. See +//! [`unavailable`] for that adapter. + +use super::admission::AdmittedPeer; +use async_trait::async_trait; + +/// A 32-byte chain identifier (launcher id, store id, root, puzzle hash — all the same shape). +pub type Bytes32 = [u8; 32]; + +/// One distributor this node funds, as SPEC §1.3 names it: the generation it rewards plus its +/// launcher id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DistributorRef { + pub launcher_id: Bytes32, + pub store_id: Bytes32, + pub root: Bytes32, +} + +/// One occupied entry slot, as SPEC §10.2 shapes it: keyed by a payout PUZZLE HASH, never a pubkey. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntrySlot { + pub payout_puzzle_hash: Bytes32, + pub counter: u64, + /// SPEC §11.1: always `1` in the MVP; carried here because the chain state reports what is + /// actually on the slot, not what this crate would choose to write. + pub shares: u64, +} + +/// One distributor's chain-derived state (SPEC §2.3 `counters`, §8, §12.4). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DistributorChainState { + pub reserve_base_units: u64, + pub entries: Vec, + /// The `RewardDistributorConstants::epoch_seconds` accrual window ordinal this distributor is + /// currently in. NOT the mirror-collateral epoch (SPEC §0.3) — an unrelated clock. + pub current_distributor_epoch: u64, + /// SPEC §12.4: derived from the singleton's own spend history, never a self-report. `None` + /// means the entry set has never been written to. + pub last_entry_write_at: Option, + pub total_paid_out_base_units: u64, +} + +/// One add/remove decision destined for a bundle (SPEC §6.3). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EntryAction { + /// Carries [`AdmittedPeer`] rather than loose fields: `AdmittedPeer` is mintable only by + /// `admission::admit`, so an `Add` cannot be constructed from a discovery path that skipped + /// admission — self-exclusion becomes a compile-time property of this type, not a convention + /// every future discovery path must remember to honour (SPEC §5.3; DIG-Network/dig-node#261). + Add(AdmittedPeer), + Remove { + payout_puzzle_hash: Bytes32, + launcher_id: Bytes32, + }, +} + +/// One distributor spend bundle: at most [`super::spec_constants::MAX_ENTRY_WRITES_PER_BUNDLE`] +/// actions, one fee (SPEC §6.3 clause 1). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntryWriteBundle { + pub launcher_id: Bytes32, + pub actions: Vec, + pub fee_mojos: u64, +} + +/// Why a chain port call could not complete. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChainPortError { + /// No chain source is wired yet — the [`unavailable`] adapter's only answer, and what any real + /// adapter should answer for an unreachable chain too (SPEC §12.2 clause 4). + Unavailable, + /// A chain answered but the call failed for a reason worth a message (bounded before logging — + /// SPEC §3.7 clause 4 applies to every attacker-adjacent string, and a chain error is not + /// exempt). + Other(String), +} + +/// Reads and the one write this engine needs from the reward-distributor chain state. Derived from +/// the SPEC's described surface (§1.3 reads, §6.3 write), not from `dig-rewards-coin`'s internals. +#[async_trait] +pub trait RewardsChainPort: Send + Sync { + /// SPEC §1.3: every distributor this node funds, with its `(store_id, root)`. + async fn funded_distributors(&self) -> Result, ChainPortError>; + + /// SPEC §2.3, §8, §12.4: one distributor's current chain-derived state. + async fn distributor_state( + &self, + launcher_id: Bytes32, + ) -> Result; + + /// SPEC §6.3: submit ONE bundle of at most `MAX_ENTRY_WRITES_PER_BUNDLE` actions with a fee. + async fn submit_entry_writes(&self, bundle: EntryWriteBundle) -> Result<(), ChainPortError>; + + /// SPEC §2.1: spend the distributor singleton's `NewEpoch` action when a synced state is + /// needed for an entry-set write (§8.2) and the epoch has rolled. Idempotent in effect — SPEC + /// §2.1 clause 3 names TWO willing spenders (this prover and #3251's claim loop) as correct, + /// not a conflict, and neither MUST treat a not-yet-rolled epoch as an error or assume the + /// other already did it. + async fn spend_new_epoch(&self, launcher_id: Bytes32) -> Result<(), ChainPortError>; +} + +/// The production adapter until DIG-Network/dig_ecosystem#3249 lands: reports +/// [`ChainPortError::Unavailable`] on every call and runs no cycles. +/// +/// This is the named state `ChainSourceUnavailable` (SPEC §2.3), not a silent no-op — a no-op that +/// reported progress would be the exact honesty violation §2.4 forbids. When #3249 ships, this +/// adapter is replaced with one that calls the real driver through this same trait; nothing above +/// this seam changes. +pub struct UnavailableChainPort; + +#[async_trait] +impl RewardsChainPort for UnavailableChainPort { + async fn funded_distributors(&self) -> Result, ChainPortError> { + Err(ChainPortError::Unavailable) + } + + async fn distributor_state( + &self, + _launcher_id: Bytes32, + ) -> Result { + Err(ChainPortError::Unavailable) + } + + async fn submit_entry_writes(&self, _bundle: EntryWriteBundle) -> Result<(), ChainPortError> { + Err(ChainPortError::Unavailable) + } + + async fn spend_new_epoch(&self, _launcher_id: Bytes32) -> Result<(), ChainPortError> { + Err(ChainPortError::Unavailable) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn unavailable_adapter_never_reports_a_cycle_ran() { + let port = UnavailableChainPort; + assert_eq!( + port.funded_distributors().await, + Err(ChainPortError::Unavailable) + ); + assert_eq!( + port.distributor_state([0u8; 32]).await, + Err(ChainPortError::Unavailable) + ); + assert_eq!( + port.submit_entry_writes(EntryWriteBundle { + launcher_id: [0u8; 32], + actions: vec![], + fee_mojos: 0, + }) + .await, + Err(ChainPortError::Unavailable) + ); + assert_eq!( + port.spend_new_epoch([0u8; 32]).await, + Err(ChainPortError::Unavailable) + ); + } +} diff --git a/crates/dig-node-core/src/rewards/spec_constants.rs b/crates/dig-node-core/src/rewards/spec_constants.rs new file mode 100644 index 00000000..69a60b3b --- /dev/null +++ b/crates/dig-node-core/src/rewards/spec_constants.rs @@ -0,0 +1,76 @@ +//! Constants transcribed from `dig-rewards-coin/SPEC.md` v0.1.1 (DIG-Network/dig_ecosystem#3250). +//! +//! # Byte-identical contract +//! +//! Every value below is copied verbatim from the normative spec, each tagged with the clause it +//! comes from. They live here — not scattered across the engine — because `dig-rewards-coin` is +//! still SPEC-only (`pub mod distributor {}`, DIG-Network/dig_ecosystem#3249): the moment #3249 +//! lands and publishes these as its own constants, this file MUST be deleted and every reference +//! MUST move to `dig_rewards_coin::*`. That migration is the parent's call, not this lane's — do +//! not relitigate it here and do not let a second copy of any of these numbers exist anywhere else +//! in this crate. +//! +//! `epoch_seconds`, `first_epoch_start` and `payout_threshold` are deliberately ABSENT: they are +//! per-distributor chain values (SPEC §8), never constants. + +/// SPEC §2.5: a prover MUST begin a new cycle per distributor once per period. +pub const PROVER_CYCLE_PERIOD_SECONDS: u64 = 3_600; + +/// SPEC §2.5 clause 1: `observed_at` MUST be refreshed at least this often, including while `Idle`. +pub const PROVER_HEARTBEAT_SECONDS: u64 = 60; + +/// SPEC §2.5 clause 2: a cycle exceeding this MUST be abandoned and counted as a prover-fault +/// failure — never a peer strike (clause 3 / §3.6.4). +pub const PROVER_CYCLE_DEADLINE_SECONDS: u64 = 900; + +/// SPEC §3.2: windows selected per candidate peer per cycle. +pub const CHALLENGE_WINDOWS_PER_CYCLE: u32 = 4; + +/// SPEC §3.2 clause 3: bytes per challenge window (64 KiB), clamped to `total_length` for a smaller +/// resource. +pub const CHALLENGE_WINDOW_BYTES: u64 = 65_536; + +/// SPEC §3.2 clause 5: a window MUST NOT repeat for the same `(peer_id, launcher_id)` within this +/// many cycles. +pub const CHALLENGE_NO_REPEAT_CYCLES: u32 = 8; + +/// SPEC §3.6 clause 3: consecutive challenge-cycle failures before a `RemoveEntry` is scheduled. +pub const CHALLENGE_STRIKES_TO_EVICT: u32 = 3; + +/// SPEC §3.7 clause 1: per-window deadline. +pub const CHALLENGE_DEADLINE_SECONDS: u64 = 30; + +/// SPEC §3.7 clause 1: deadline for a peer's four windows. +pub const CHALLENGE_PEER_DEADLINE_SECONDS: u64 = 120; + +/// SPEC §3.7 clause 2: minimum interval between challenges of the same peer, summed across every +/// distributor this node funds. +pub const CHALLENGE_MIN_INTERVAL_SECONDS: u64 = 900; + +/// SPEC §3.7 clause 3: peers challenged per cycle per distributor, at most. +pub const CHALLENGE_MAX_PEERS_PER_CYCLE: u32 = 64; + +/// SPEC §6.3 clause 1: add/remove actions per bundle, at most. +pub const MAX_ENTRY_WRITES_PER_BUNDLE: u32 = 8; + +/// SPEC §6.3 clause 2: minimum interval between entry-set write bundles for one distributor. +pub const ENTRY_WRITE_MIN_INTERVAL_SECONDS: u64 = 3_600; + +/// SPEC §6.3 clause 4: a removed entry MUST NOT be re-added within this window, keyed on +/// `(payout_puzzle_hash, launcher_id)` — never on `peer_id`. +pub const REENTRY_COOLDOWN_SECONDS: u64 = 21_600; + +/// SPEC §4.6 clause 3: grace window after a mirror-collateral epoch rollover during which the +/// PREVIOUS epoch ordinal is still accepted, and a rollover mismatch MUST NOT strike. +pub const MIRROR_EPOCH_GRACE_SECONDS: u64 = 21_600; + +/// SPEC §12.4: an entry set that has not changed in this long, with a non-zero reserve, MUST be +/// reported as stale (`entry_set_stale` on `dig.getRewardDistributor` only — never on the prover +/// status record, §2.4). +pub const STALE_ENTRY_SET_SECONDS: u64 = 172_800; + +/// SPEC §6.5: the entry set is capped at this many entries per distributor. +pub const MAX_ENTRIES_PER_DISTRIBUTOR: u32 = 250; + +/// SPEC §4.4 clause 1: at most this many free-memo URL terms are considered per candidate. +pub const MAX_MIRROR_URL_TERMS: u32 = 8; diff --git a/crates/dig-node-core/src/rewards/staleness.rs b/crates/dig-node-core/src/rewards/staleness.rs new file mode 100644 index 00000000..43bf7b49 --- /dev/null +++ b/crates/dig-node-core/src/rewards/staleness.rs @@ -0,0 +1,94 @@ +//! SPEC §12.4: entry-set staleness, derived ONLY from chain-observed state — never a prover +//! self-report. See [`is_entry_set_stale`]. +//! +//! This value MUST NOT appear on the prover status record (SPEC §2.4 — no precomputed staleness +//! anywhere on that record); it belongs only on the distributor's own chain read +//! (`dig.getRewardDistributor`'s `entry_set_stale`), derived fresh by the reader every time. + +use super::port::DistributorChainState; +use super::spec_constants::STALE_ENTRY_SET_SECONDS; + +/// SPEC §12.4: an entry set is stale when BOTH conjuncts hold: +/// 1. the distributor's reserve is non-zero (a zero reserve is `Unfunded` — a different report, +/// §6.5/§12.6 — and the entry set is kept regardless of staleness); and +/// 2. the last CHAIN-OBSERVED entry write (`DistributorChainState::last_entry_write_at`, the +/// singleton's own spend history) is at least `STALE_ENTRY_SET_SECONDS` old. +/// +/// `last_entry_write_at == None` means the entry set has never been written to. That is not +/// "unknown" — it is maximally stale the moment the distributor itself has existed at least the +/// bound: "never written" cannot be more current than "written a long time ago". +pub fn is_entry_set_stale( + state: &DistributorChainState, + now: u64, + distributor_created_at: u64, +) -> bool { + if state.reserve_base_units == 0 { + return false; + } + match state.last_entry_write_at { + Some(last_write) => now.saturating_sub(last_write) >= STALE_ENTRY_SET_SECONDS, + None => now.saturating_sub(distributor_created_at) >= STALE_ENTRY_SET_SECONDS, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rewards::port::EntrySlot; + + fn state(reserve: u64, last_entry_write_at: Option) -> DistributorChainState { + DistributorChainState { + reserve_base_units: reserve, + entries: Vec::::new(), + current_distributor_epoch: 0, + last_entry_write_at, + total_paid_out_base_units: 0, + } + } + + #[test] + fn zero_reserve_is_never_stale_regardless_of_write_age() { + let s = state(0, Some(0)); + assert!(!is_entry_set_stale(&s, STALE_ENTRY_SET_SECONDS * 10, 0)); + } + + #[test] + fn fresh_write_with_reserve_is_not_stale() { + let s = state(100, Some(1_000)); + assert!(!is_entry_set_stale( + &s, + 1_000 + STALE_ENTRY_SET_SECONDS - 1, + 0 + )); + } + + #[test] + fn write_older_than_bound_with_reserve_is_stale() { + let s = state(100, Some(1_000)); + assert!(is_entry_set_stale(&s, 1_000 + STALE_ENTRY_SET_SECONDS, 0)); + } + + /// SPEC §12.4: a distributor whose entry set was NEVER written, funded, and at least as old as + /// the bound is stale too — "never written" is maximally stale, not an unknown/false default. + #[test] + fn never_written_entry_set_with_reserve_and_old_enough_distributor_is_stale() { + let s = state(100, None); + let created_at = 500; + assert!(is_entry_set_stale( + &s, + created_at + STALE_ENTRY_SET_SECONDS, + created_at + )); + } + + #[test] + fn never_written_entry_set_but_distributor_still_young_is_not_stale() { + let s = state(100, None); + let created_at = 500; + assert!(!is_entry_set_stale( + &s, + created_at + STALE_ENTRY_SET_SECONDS - 1, + created_at + )); + } +} diff --git a/crates/dig-node-core/src/rewards/state.rs b/crates/dig-node-core/src/rewards/state.rs new file mode 100644 index 00000000..60204bc2 --- /dev/null +++ b/crates/dig-node-core/src/rewards/state.rs @@ -0,0 +1,243 @@ +//! The per-distributor status record (SPEC §2.3) and its closed state set (§2.3, §2.4). +//! +//! # No health boolean, ever +//! +//! SPEC §2.4: "An implementation MUST NOT expose a `healthy`, `ok`, `up`, or `running` boolean, and +//! MUST NOT expose a pre-computed staleness." A wedged loop cannot report its own wedging — whatever +//! it last wrote stays there, so any field a stalled writer could set to a reassuring value is a +//! lie waiting to happen. The reader derives liveness itself from `last_cycle_completed_at` against +//! `observed_at` and its own clock; nothing here does that derivation for it. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, RwLock}; + +/// The closed set of prover states (SPEC §2.3). An implementation MUST use exactly this set, MUST +/// NOT add a state without adding it here first, and MUST NOT collapse two of these into one +/// message. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ProverState { + Idle, + Running, + LocalCopyMissing, + ChainSourceUnavailable, + Unfunded, + FeeBudgetExhausted, + EntrySetFull, + Paused, + Stopped, +} + +/// SPEC §2.3 `counters`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProverCounters { + pub mirrors_seen: u64, + pub challenges_issued: u64, + pub challenges_passed: u64, + pub challenges_failed: u64, + pub entries_added: u64, + pub entries_removed: u64, + pub entry_count: u32, + pub reserve_base_units: u64, + pub total_paid_out_base_units: u64, +} + +/// The SPEC §2.3 status record, verbatim field-for-field. Deliberately carries no boolean and no +/// precomputed staleness (§2.4) — a `#[test]` below asserts the serialized form has none. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RewardProverStatus { + pub launcher_id: [u8; 32], + pub store_id: [u8; 32], + pub root: [u8; 32], + pub prover_state: ProverState, + pub prover_state_since: u64, + pub last_cycle_started_at: Option, + pub last_cycle_completed_at: Option, + pub next_cycle_due_at: Option, + pub last_entry_write_at: Option, + pub consecutive_cycle_failures: u32, + /// SPEC §6.3 clause 2: decisions withheld by the write-rate bound, not dropped. + pub pending_entry_writes: u32, + /// SPEC §2.3: "chain view this record reflects" — refreshed at least every + /// `PROVER_HEARTBEAT_SECONDS` (§2.5 clause 1). The reader's only staleness signal: compare this + /// against `last_cycle_completed_at` and the reader's own clock. + pub observed_at: u64, + pub counters: ProverCounters, +} + +/// A shared, mutable status record a loop writes to and a reader (e.g. the RPC handler) reads from +/// without racing it. Plain `RwLock` over the whole record: writes are infrequent (at most once per +/// heartbeat) and reads must never block a cycle, so a lock is simpler and just as sound as a channel +/// here. +#[derive(Clone)] +pub struct StatusHandle(Arc>); + +impl StatusHandle { + pub fn new(initial: RewardProverStatus) -> Self { + Self(Arc::new(RwLock::new(initial))) + } + + pub fn snapshot(&self) -> RewardProverStatus { + self.0.read().expect("status lock poisoned").clone() + } + + /// Apply an update. The closure receives `&mut RewardProverStatus` so a caller can update + /// several fields as one atomic step (e.g. `prover_state` and `prover_state_since` together). + pub fn update(&self, f: impl FnOnce(&mut RewardProverStatus)) { + let mut guard = self.0.write().expect("status lock poisoned"); + f(&mut guard); + } +} + +/// A monotonically-advancing clock the loop uses for `observed_at`. A trait rather than +/// `SystemTime::now()` directly so a test can drive it (or refuse to), which is exactly what +/// proves a wedged loop stops advancing it (see `cycle.rs`'s wedged-loop test). +pub trait Clock: Send + Sync { + fn now_unix_seconds(&self) -> u64; +} + +/// The real clock. +pub struct SystemClock; + +impl Clock for SystemClock { + fn now_unix_seconds(&self) -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock before epoch") + .as_secs() + } +} + +/// A clock a test can advance by hand, and — critically — can also NOT advance, to prove that a +/// stalled loop's `observed_at` truly stops. +#[derive(Clone)] +pub struct TestClock(Arc); + +impl TestClock { + pub fn new(start: u64) -> Self { + Self(Arc::new(AtomicU64::new(start))) + } + + pub fn advance(&self, seconds: u64) { + self.0.fetch_add(seconds, Ordering::SeqCst); + } +} + +impl Clock for TestClock { + fn now_unix_seconds(&self) -> u64 { + self.0.load(Ordering::SeqCst) + } +} + +fn new_status( + launcher_id: [u8; 32], + store_id: [u8; 32], + root: [u8; 32], + now: u64, +) -> RewardProverStatus { + RewardProverStatus { + launcher_id, + store_id, + root, + prover_state: ProverState::Idle, + prover_state_since: now, + last_cycle_started_at: None, + last_cycle_completed_at: None, + next_cycle_due_at: None, + last_entry_write_at: None, + consecutive_cycle_failures: 0, + pending_entry_writes: 0, + observed_at: now, + counters: ProverCounters::default(), + } +} + +/// Build a fresh `Idle` status record for a distributor, as SPEC §12.1 clause 3 requires on +/// restart, before the first cycle completes. +pub fn idle_status( + launcher_id: [u8; 32], + store_id: [u8; 32], + root: [u8; 32], + now: u64, +) -> RewardProverStatus { + new_status(launcher_id, store_id, root, now) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The closed set of keys SPEC §2.4 forbids anywhere in the record. This asserts over object + /// *keys*, never over substrings of the serialized string: `ProverState::Running` legitimately + /// serializes the *value* `"running"`, so a substring test would fail on honest input while + /// still passing a smuggled `isRunning` **key**. Keep it key-based; a "simplification" back to + /// a substring check both breaks honest serialization and stops catching the real defect. + const FORBIDDEN_HEALTH_KEYS: &[&str] = &[ + "healthy", + "ok", + "up", + "running", + "isRunning", + "stale", + "isStale", + "staleness", + "secondsSinceLastRun", + "lastRunSecondsAgo", + "uptime", + "alive", + "live", + ]; + + /// Walk a `serde_json::Value` depth-first, asserting no object at ANY depth carries a forbidden + /// key. A top-level-only check would miss a forbidden key smuggled into a nested struct (e.g. a + /// future field added inside `counters`) — this recurses through objects and arrays so a + /// smuggled key at any depth still fails the test. + fn assert_no_forbidden_health_keys(value: &serde_json::Value) { + match value { + serde_json::Value::Object(map) => { + for forbidden in FORBIDDEN_HEALTH_KEYS { + assert!( + !map.contains_key(*forbidden), + "status record must not carry a {forbidden:?} key at any depth (SPEC §2.4)" + ); + } + for nested in map.values() { + assert_no_forbidden_health_keys(nested); + } + } + serde_json::Value::Array(items) => { + for item in items { + assert_no_forbidden_health_keys(item); + } + } + _ => {} + } + } + + /// SPEC §2.4: no `healthy`/`ok`/`up`/`running`/... key, and no precomputed staleness field, + /// anywhere in the serialized record — recursively, not just at the top level. + #[test] + fn serialized_status_has_no_health_or_staleness_key() { + let status = idle_status([1; 32], [2; 32], [3; 32], 1000); + let json = serde_json::to_value(&status).unwrap(); + assert_no_forbidden_health_keys(&json); + } + + #[test] + fn status_handle_reads_do_not_mutate() { + let status = idle_status([0; 32], [0; 32], [0; 32], 5); + let handle = StatusHandle::new(status.clone()); + assert_eq!(handle.snapshot(), status); + handle.update(|s| s.observed_at = 6); + assert_eq!(handle.snapshot().observed_at, 6); + } + + #[test] + fn test_clock_that_is_never_advanced_never_advances() { + let clock = TestClock::new(42); + assert_eq!(clock.now_unix_seconds(), 42); + assert_eq!(clock.now_unix_seconds(), 42); + } +} diff --git a/crates/dig-node-core/src/rewards/writes.rs b/crates/dig-node-core/src/rewards/writes.rs new file mode 100644 index 00000000..7f5874eb --- /dev/null +++ b/crates/dig-node-core/src/rewards/writes.rs @@ -0,0 +1,769 @@ +//! SPEC §6.3 entry-set write bounds — this spends the funder's money, so every bound here is +//! enforced in code, never left to caller discipline. +//! +//! 1. **Batch**: at most one bundle per cycle, at most [`MAX_ENTRY_WRITES_PER_BUNDLE`] actions, +//! one fee. +//! 2. **Rate**: at most one bundle per distributor per [`ENTRY_WRITE_MIN_INTERVAL_SECONDS`]. A +//! decision reached sooner is WITHHELD, never dropped — it shows up in `pending_entry_writes`. +//! 3. **Cap**: a per-distributor daily fee budget ([`FeeBudget`]). On exhaustion: stop writing, +//! KEEP the decisions, report `FeeBudgetExhausted`. +//! 4. **Hysteresis**: a removal is not re-added for [`REENTRY_COOLDOWN_SECONDS`], keyed on +//! `(payout_puzzle_hash, launcher_id)` and NEVER on `peer_id` — the puzzle hash is what the +//! chain writes; a peer can present a fresh `peer_id` (e.g. a new TLS cert) for the same payout +//! address and MUST still be held. +//! +//! Also §6.5/§12.6: [`is_entry_set_full`] / [`is_unfunded`] name the two other terminal reports +//! (`EntrySetFull`, `Unfunded`) — on `Unfunded` the entry set is KEPT, never evicted, because +//! evicting 250 entries to punish an empty reserve costs 250 fees and punishes nobody. + +use super::port::{Bytes32, EntryAction, EntryWriteBundle}; +use super::spec_constants::{ + ENTRY_WRITE_MIN_INTERVAL_SECONDS, MAX_ENTRIES_PER_DISTRIBUTOR, MAX_ENTRY_WRITES_PER_BUNDLE, + REENTRY_COOLDOWN_SECONDS, +}; +use std::collections::HashMap; + +/// One day in seconds — the window every fee-budget rollover in this module is measured against. +const SECONDS_PER_DAY: u64 = 86_400; + +/// The most bundles the §6.3 clause 2 rate bound permits in a day (one per 3,600 s → 24), derived +/// rather than written as a literal so it cannot drift from the interval it comes from. The daily +/// fee ceiling is this many standard fees, which is why `mod.rs` states the rate bound and the fee +/// ceiling are ONE spend control and not two independent ones. +const MAX_BUNDLES_PER_DAY: u64 = SECONDS_PER_DAY / ENTRY_WRITE_MIN_INTERVAL_SECONDS; + +/// Reentry-cooldown key — deliberately `(payout_puzzle_hash, launcher_id)`, never `peer_id`. +pub type CooldownKey = (Bytes32, Bytes32); + +/// A per-distributor daily fee budget in XCH mojos. Default: 24 bundles' worth of the operator's +/// configured standard fee (SPEC §6.3 cap). +pub struct FeeBudget { + limit_mojos_per_day: u64, + spent_mojos_today: u64, + day_started_at: u64, +} + +impl FeeBudget { + pub fn new(standard_fee_mojos: u64, now: u64) -> Self { + Self { + limit_mojos_per_day: Self::daily_limit_for(standard_fee_mojos), + spent_mojos_today: 0, + day_started_at: now, + } + } + + /// The SPEC §6.3 daily fee ceiling for an operator's configured standard fee: + /// [`MAX_BUNDLES_PER_DAY`] fees' worth. + /// + /// THE single place this product is formed. A second copy is how one write path ends up + /// bounding spend 24× looser than the other while both look internally consistent — and a + /// ceiling that is silently 24× too high is indistinguishable from ordinary operation right up + /// to the point the operator's XCH is gone. [`PersistedEntryWriter::decide`] therefore takes + /// the ALREADY-DERIVED ceiling instead of re-deriving it from a fee: mistaking a fee for a + /// ceiling there would fail open, whereas mistaking a ceiling for a fee here fails closed + /// (the prover refuses to write, which is exactly what §6.3 clause 3 asks of it). + pub fn daily_limit_for(standard_fee_mojos: u64) -> u64 { + // `saturating_mul` saturates toward `u64::MAX`, which is the permissive direction for a + // spend ceiling (a `checked_mul` refusal, or a `min` against a sane maximum, would be the + // fail-closed direction instead). Left as-is: this fn returns `u64`, not `Result`, and + // every caller (`Self::new`, `PersistedEntryWriter::decide`'s `daily_limit_mojos` param) + // treats its output as an infallible bound, so making it fail closed ripples into a + // signature change here and at both call sites rather than staying a local fix. No + // realistic configured fee reaches this overflow (`standard_fee_mojos` would need to + // exceed ~u64::MAX / 24), so this is a direction note for the next person to touch this + // fn, not a live exploit. + standard_fee_mojos.saturating_mul(MAX_BUNDLES_PER_DAY) + } + + fn roll_if_new_day(&mut self, now: u64) { + if now.saturating_sub(self.day_started_at) >= SECONDS_PER_DAY { + self.spent_mojos_today = 0; + self.day_started_at = now; + } + } + + /// `true` if `fee_mojos` fits inside today's remaining budget, in which case it is charged. + pub fn try_spend(&mut self, fee_mojos: u64, now: u64) -> bool { + self.roll_if_new_day(now); + if self.spent_mojos_today.saturating_add(fee_mojos) > self.limit_mojos_per_day { + return false; + } + self.spent_mojos_today += fee_mojos; + true + } +} + +/// What a call to [`EntryWriteScheduler::decide`] produced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WriteOutcome { + /// A bundle ready to submit through `RewardsChainPort::submit_entry_writes`. + Bundle { + bundle: EntryWriteBundle, + still_pending: u32, + }, + /// Nothing submitted, `count` decisions withheld this cycle (rate-limited or none ready) — + /// they MUST still surface in `pending_entry_writes`, never silently dropped. + Pending { count: u32 }, + /// The fee budget is exhausted for today: stop writing, but the `count` decisions are KEPT, + /// not discarded. + FeeBudgetExhausted { count: u32 }, +} + +/// Tracks the per-distributor write-rate clock and the per-`(payout_puzzle_hash, launcher_id)` +/// reentry cooldown. One instance per running prover (not per cycle) so both bounds persist across +/// cycles. +#[derive(Default)] +pub struct EntryWriteScheduler { + last_bundle_sent_at: HashMap, + cooldown_until: HashMap, +} + +impl EntryWriteScheduler { + pub fn new() -> Self { + Self::default() + } + + pub fn is_rate_limited(&self, launcher_id: Bytes32, now: u64) -> bool { + match self.last_bundle_sent_at.get(&launcher_id) { + Some(&last) => now.saturating_sub(last) < ENTRY_WRITE_MIN_INTERVAL_SECONDS, + None => false, + } + } + + /// SPEC §6.3 clause 4 hysteresis check — keyed on the payout puzzle hash, never `peer_id`. + pub fn is_in_reentry_cooldown( + &self, + payout_puzzle_hash: Bytes32, + launcher_id: Bytes32, + now: u64, + ) -> bool { + match self.cooldown_until.get(&(payout_puzzle_hash, launcher_id)) { + Some(&until) => now < until, + None => false, + } + } + + fn record_removal(&mut self, payout_puzzle_hash: Bytes32, launcher_id: Bytes32, now: u64) { + self.cooldown_until.insert( + (payout_puzzle_hash, launcher_id), + now + REENTRY_COOLDOWN_SECONDS, + ); + } + + /// Decide this cycle's write for one distributor from a queue of pending decisions (already + /// hysteresis-filtered by the caller via [`Self::is_in_reentry_cooldown`] for adds). Enforces + /// the batch cap, the rate bound, and the fee budget, in that order of relevance to the + /// caller — but the RATE check runs first because a rate-limited distributor must not touch + /// the fee budget at all. + pub fn decide( + &mut self, + launcher_id: Bytes32, + decisions: Vec, + fee_mojos: u64, + budget: &mut FeeBudget, + now: u64, + ) -> WriteOutcome { + if decisions.is_empty() { + return WriteOutcome::Pending { count: 0 }; + } + if self.is_rate_limited(launcher_id, now) { + return WriteOutcome::Pending { + count: decisions.len() as u32, + }; + } + + let take = decisions.len().min(MAX_ENTRY_WRITES_PER_BUNDLE as usize); + let (bundle_actions, rest) = decisions.split_at(take); + + if !budget.try_spend(fee_mojos, now) { + return WriteOutcome::FeeBudgetExhausted { + count: decisions.len() as u32, + }; + } + + self.last_bundle_sent_at.insert(launcher_id, now); + + WriteOutcome::Bundle { + bundle: EntryWriteBundle { + launcher_id, + actions: bundle_actions.to_vec(), + fee_mojos, + }, + still_pending: rest.len() as u32, + } + } + + /// Record a bundle's removals as reentry-cooldown-blocked. Call this ONLY after + /// `RewardsChainPort::submit_entry_writes` has returned `Ok` for this exact bundle — recording + /// the cooldown before the chain confirms would hold an honest mirror out for the full + /// [`REENTRY_COOLDOWN_SECONDS`] window on a submit that never actually reached the chain (e.g. + /// a network error, a rejected spend). [`Self::decide`] deliberately does NOT do this itself. + pub fn record_submitted(&mut self, bundle: &EntryWriteBundle, now: u64) { + for action in &bundle.actions { + if let EntryAction::Remove { + payout_puzzle_hash, + launcher_id, + } = action + { + self.record_removal(*payout_puzzle_hash, *launcher_id, now); + } + } + } +} + +/// SPEC §12.1 clause 2: cooldowns and fee budgets MUST persist across a restart. Without this, a +/// restart loop resets `last_bundle_sent_at` to empty, `spent_mojos_today` to zero and +/// `cooldown_until` to empty — an unbounded per-restart spend of the operator's XCH and repeated +/// reserve settlements via re-eviction, invisible because it looks like ordinary bounded operation +/// each time. One `WriteBoundState` covers a single `launcher_id` (the caller keys storage by +/// distributor); `spent_mojos_today` carries the day it refers to so a loaded state past midnight +/// UTC-relative-to-`day_started_at` rolls over exactly like the in-memory [`FeeBudget`] does. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct WriteBoundState { + pub last_bundle_sent_at: Option, + pub spent_mojos_today: u64, + pub day_started_at: u64, + pub cooldown_until: HashMap, +} + +/// Why a [`WriteBoundStore`] call could not complete. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoreError(pub String); + +/// The persistence seam SPEC §12.1 clause 2 requires. Narrow on purpose — one `launcher_id` at a +/// time, load-then-save — so a real backend (a file, a small embedded DB) is a thin adapter, not a +/// redesign. +pub trait WriteBoundStore: Send + Sync { + fn load(&self, launcher_id: Bytes32) -> Result; + fn save(&self, launcher_id: Bytes32, state: &WriteBoundState) -> Result<(), StoreError>; +} + +/// The fail-closed default until a real backend is wired: every call errors, so +/// [`PersistedEntryWriter::decide`] refuses to submit anything rather than run the write bounds +/// unbounded across a restart. This is deliberately the production default TODAY — the chain port +/// itself is `UnavailableChainPort` until #3249 lands, so this adapter costs nothing operationally +/// yet and closes the money hole the moment either seam is wired. +pub struct NoPersistence; + +impl WriteBoundStore for NoPersistence { + fn load(&self, _launcher_id: Bytes32) -> Result { + Err(StoreError( + "no write-bound persistence backend configured".to_string(), + )) + } + + fn save(&self, _launcher_id: Bytes32, _state: &WriteBoundState) -> Result<(), StoreError> { + Err(StoreError( + "no write-bound persistence backend configured".to_string(), + )) + } +} + +/// What [`PersistedEntryWriter::decide`] produced, in place of [`WriteOutcome`] once persistence is +/// in the loop. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PersistedWriteOutcome { + Bundle { + bundle: EntryWriteBundle, + still_pending: u32, + }, + Pending { + count: u32, + }, + FeeBudgetExhausted { + count: u32, + }, + /// The write-bound store could not be loaded for this distributor. No bundle is computed or + /// returned — the caller MUST NOT submit anything this cycle and MUST report this + /// distributor's `ProverState` as `ChainSourceUnavailable`. + /// + /// `FeeBudgetExhausted` was considered and rejected: that state means "a real budget exists + /// and is spent," which asserts something this code does not know when the store itself is + /// unreachable. `ChainSourceUnavailable` already means "a dependency this decision needs is + /// not reachable, and this is a prover-side fault, never a peer-attributable one" (see + /// `admission.rs`'s D5 use of the same state for the analogous gate-unavailable case) — which + /// is exactly what an unreachable persistence backend is. Inventing a tenth `ProverState` + /// would need a SPEC amendment (§2.3 pins the set to nine); this does not. + PersistenceUnavailable, +} + +/// Wraps [`EntryWriteScheduler`]'s decision with the SPEC §12.1 clause 2 persistence gate: bounds +/// are loaded before deciding and persisted only after a caller-confirmed successful submit +/// ([`Self::commit`]) — never inside `decide` itself, for the same before/after-success reason +/// [`EntryWriteScheduler::record_submitted`] documents. +pub struct PersistedEntryWriter<'a> { + store: &'a dyn WriteBoundStore, + /// Set when [`Self::commit`] observes a `save` error. This is the enforcement of the + /// obligation this module's `commit` doc previously stated but never checked: a store where + /// `load` succeeds but `save` fails would otherwise keep returning pre-submit state forever, + /// so `spent_mojos_today` never accumulates and the daily ceiling silently becomes + /// `MAX_BUNDLES_PER_DAY × whatever fee the caller supplies` instead of `× standard_fee`. + /// `Cell`, not a plain `bool`, because [`Self::decide`] takes `&self`. There is deliberately + /// no clearing method: recovery is a fresh writer after the operator fixes the store — a + /// reset path is how a poison flag becomes decorative. + poisoned: std::cell::Cell, +} + +impl<'a> PersistedEntryWriter<'a> { + pub fn new(store: &'a dyn WriteBoundStore) -> Self { + Self { + store, + poisoned: std::cell::Cell::new(false), + } + } + + /// Load this distributor's persisted bounds, then decide this cycle's write. Returns the + /// updated (not-yet-persisted) state alongside every non-refusal outcome; the caller MUST + /// call [`Self::commit`] with that state after the chain confirms a `Bundle` outcome's submit + /// succeeded. Nothing here submits to the chain. + /// + /// `daily_limit_mojos` is TODAY'S WHOLE FEE CEILING in mojos, not a per-bundle fee — derive it + /// with [`FeeBudget::daily_limit_for`] so this path and the in-memory [`FeeBudget`] cannot + /// bound the same spend differently. `fee_mojos` is what THIS bundle would cost. + pub fn decide( + &self, + launcher_id: Bytes32, + decisions: Vec, + fee_mojos: u64, + daily_limit_mojos: u64, + now: u64, + ) -> (PersistedWriteOutcome, Option) { + if self.poisoned.get() { + return (PersistedWriteOutcome::PersistenceUnavailable, None); + } + + let mut state = match self.store.load(launcher_id) { + Ok(state) => state, + Err(_) => return (PersistedWriteOutcome::PersistenceUnavailable, None), + }; + + if now.saturating_sub(state.day_started_at) >= SECONDS_PER_DAY { + state.spent_mojos_today = 0; + state.day_started_at = now; + } + + if decisions.is_empty() { + return (PersistedWriteOutcome::Pending { count: 0 }, Some(state)); + } + + let rate_limited = state + .last_bundle_sent_at + .is_some_and(|last| now.saturating_sub(last) < ENTRY_WRITE_MIN_INTERVAL_SECONDS); + if rate_limited { + return ( + PersistedWriteOutcome::Pending { + count: decisions.len() as u32, + }, + Some(state), + ); + } + + if state.spent_mojos_today.saturating_add(fee_mojos) > daily_limit_mojos { + return ( + PersistedWriteOutcome::FeeBudgetExhausted { + count: decisions.len() as u32, + }, + Some(state), + ); + } + + let take = decisions.len().min(MAX_ENTRY_WRITES_PER_BUNDLE as usize); + let (bundle_actions, rest) = decisions.split_at(take); + + state.last_bundle_sent_at = Some(now); + state.spent_mojos_today += fee_mojos; + for action in bundle_actions { + if let EntryAction::Remove { + payout_puzzle_hash, + launcher_id: lid, + } = action + { + state + .cooldown_until + .insert((*payout_puzzle_hash, *lid), now + REENTRY_COOLDOWN_SECONDS); + } + } + + ( + PersistedWriteOutcome::Bundle { + bundle: EntryWriteBundle { + launcher_id, + actions: bundle_actions.to_vec(), + fee_mojos, + }, + still_pending: rest.len() as u32, + }, + Some(state), + ) + } + + /// Persist the state [`Self::decide`] returned, once the caller has confirmed the chain + /// accepted the bundle. On `Err`, this writer is POISONED for the rest of its lifetime: every + /// subsequent [`Self::decide`] call returns `PersistenceUnavailable` with no state, regardless + /// of what `load` would return — a save failure means the bounds this submit just advanced are + /// not durable, so trusting them in memory afterward would reopen the exact hole this seam + /// exists to close. There is no unpoison method; a fresh writer after the store is fixed is + /// the only recovery. + pub fn commit(&self, launcher_id: Bytes32, state: &WriteBoundState) -> Result<(), StoreError> { + let result = self.store.save(launcher_id, state); + if result.is_err() { + self.poisoned.set(true); + } + result + } +} + +/// SPEC §6.5: the entry set is capped at [`MAX_ENTRIES_PER_DISTRIBUTOR`] entries. +pub fn is_entry_set_full(current_entry_count: usize) -> bool { + current_entry_count >= MAX_ENTRIES_PER_DISTRIBUTOR as usize +} + +/// SPEC §12.6: a zero reserve is `Unfunded`; the caller MUST keep the entry set as-is. +pub fn is_unfunded(reserve_base_units: u64) -> bool { + reserve_base_units == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + const LAUNCHER: Bytes32 = [1; 32]; + const PAYOUT_A: Bytes32 = [2; 32]; + + fn add(payout_puzzle_hash: Bytes32, launcher_id: Bytes32) -> EntryAction { + EntryAction::Add(super::super::admission::AdmittedPeer::for_test( + payout_puzzle_hash, + launcher_id, + )) + } + + fn remove(payout_puzzle_hash: Bytes32, launcher_id: Bytes32) -> EntryAction { + EntryAction::Remove { + payout_puzzle_hash, + launcher_id, + } + } + + #[test] + fn batch_cap_leaves_the_rest_pending() { + let mut scheduler = EntryWriteScheduler::new(); + let mut budget = FeeBudget::new(1_000_000, 0); + let decisions: Vec = (0..(MAX_ENTRY_WRITES_PER_BUNDLE + 3)) + .map(|i| add([i as u8; 32], LAUNCHER)) + .collect(); + let outcome = scheduler.decide(LAUNCHER, decisions, 100, &mut budget, 0); + match outcome { + WriteOutcome::Bundle { + bundle, + still_pending, + } => { + assert_eq!(bundle.actions.len(), MAX_ENTRY_WRITES_PER_BUNDLE as usize); + assert_eq!(still_pending, 3); + } + other => panic!("expected Bundle, got {other:?}"), + } + } + + /// SPEC §6.3 clause 2: a decision reached sooner than the interval MUST be withheld and MUST + /// appear as pending — never dropped. + #[test] + fn rate_limit_withholds_rather_than_drops() { + let mut scheduler = EntryWriteScheduler::new(); + let mut budget = FeeBudget::new(1_000_000, 0); + let first = scheduler.decide(LAUNCHER, vec![add(PAYOUT_A, LAUNCHER)], 100, &mut budget, 0); + assert!(matches!(first, WriteOutcome::Bundle { .. })); + + let second = scheduler.decide( + LAUNCHER, + vec![add([9; 32], LAUNCHER)], + 100, + &mut budget, + ENTRY_WRITE_MIN_INTERVAL_SECONDS - 1, + ); + assert_eq!(second, WriteOutcome::Pending { count: 1 }); + } + + #[test] + fn fee_budget_exhaustion_keeps_decisions_and_stops_writing() { + let mut scheduler = EntryWriteScheduler::new(); + let mut budget = FeeBudget::new(10, 0); // 240 mojos/day + let fee = 1_000; // exceeds the whole day's budget on the first attempt + let outcome = + scheduler.decide(LAUNCHER, vec![add(PAYOUT_A, LAUNCHER)], fee, &mut budget, 0); + assert_eq!(outcome, WriteOutcome::FeeBudgetExhausted { count: 1 }); + } + + /// The named cooldown-bypass trap: cooldown is keyed on `(payout_puzzle_hash, launcher_id)` + /// only — `peer_id` never enters the key, so presenting a fresh TLS cert / `peer_id` for the + /// SAME payout address does not bypass the cooldown. + #[test] + fn reentry_cooldown_survives_a_fresh_peer_id_for_the_same_payout_hash() { + let mut scheduler = EntryWriteScheduler::new(); + let mut budget = FeeBudget::new(1_000_000, 0); + let outcome = scheduler.decide( + LAUNCHER, + vec![remove(PAYOUT_A, LAUNCHER)], + 100, + &mut budget, + 0, + ); + let bundle = match outcome { + WriteOutcome::Bundle { bundle, .. } => bundle, + other => panic!("expected Bundle, got {other:?}"), + }; + // Cooldown is recorded only once the chain confirms the submit — never inside `decide`. + scheduler.record_submitted(&bundle, 0); + + // A "fresh peer_id" is not even a parameter to this cooldown check — it is keyed purely on + // the payout puzzle hash, which is exactly what makes the bypass impossible: nothing about + // peer identity can change which key is consulted. + assert!(scheduler.is_in_reentry_cooldown( + PAYOUT_A, + LAUNCHER, + ENTRY_WRITE_MIN_INTERVAL_SECONDS + )); + assert!(scheduler.is_in_reentry_cooldown(PAYOUT_A, LAUNCHER, REENTRY_COOLDOWN_SECONDS - 1)); + assert!(!scheduler.is_in_reentry_cooldown(PAYOUT_A, LAUNCHER, REENTRY_COOLDOWN_SECONDS)); + } + + /// Regression for the "cooldown recorded before the submit is confirmed" defect: `decide` + /// alone MUST NOT hold the payout hash in cooldown — only `record_submitted` may. + #[test] + fn decide_alone_does_not_record_a_cooldown() { + let mut scheduler = EntryWriteScheduler::new(); + let mut budget = FeeBudget::new(1_000_000, 0); + let outcome = scheduler.decide( + LAUNCHER, + vec![remove(PAYOUT_A, LAUNCHER)], + 100, + &mut budget, + 0, + ); + assert!(matches!(outcome, WriteOutcome::Bundle { .. })); + assert!(!scheduler.is_in_reentry_cooldown(PAYOUT_A, LAUNCHER, 0)); + } + + #[test] + fn entry_set_full_and_unfunded_report_the_right_terminal_state() { + assert!(is_entry_set_full(MAX_ENTRIES_PER_DISTRIBUTOR as usize)); + assert!(!is_entry_set_full(MAX_ENTRIES_PER_DISTRIBUTOR as usize - 1)); + assert!(is_unfunded(0)); + assert!(!is_unfunded(1)); + } + + /// A trivial in-process store, standing in for a real backend (a file, an embedded DB) — the + /// point under test is `PersistedEntryWriter`'s contract, not any particular backend. + #[derive(Default)] + struct FakeStore { + states: std::sync::Mutex>, + } + + impl WriteBoundStore for FakeStore { + fn load(&self, launcher_id: Bytes32) -> Result { + Ok(self + .states + .lock() + .unwrap() + .get(&launcher_id) + .cloned() + .unwrap_or_default()) + } + + fn save(&self, launcher_id: Bytes32, state: &WriteBoundState) -> Result<(), StoreError> { + self.states + .lock() + .unwrap() + .insert(launcher_id, state.clone()); + Ok(()) + } + } + + /// SPEC §12.1 clause 2, fail-closed side: with no persistence backend wired, the writer MUST + /// submit zero bundles and report `ChainSourceUnavailable` — not run the write bounds + /// unbounded because nothing durable exists to bound them against. + #[test] + fn no_persistence_writer_submits_zero_bundles() { + let writer = PersistedEntryWriter::new(&NoPersistence); + let (outcome, state) = + writer.decide(LAUNCHER, vec![add(PAYOUT_A, LAUNCHER)], 100, 1_000_000, 0); + assert_eq!(outcome, PersistedWriteOutcome::PersistenceUnavailable); + assert!( + state.is_none(), + "no bundle-tracking state may be produced without a store" + ); + } + + /// THE money-bug regression: without persistence, restarting the process resets every bound to + /// its zero value, so a restart loop would write one bundle per restart with no interval, no + /// daily cap and no cooldown. This fails without `PersistedEntryWriter` reloading state from + /// the store on every `decide` call. + #[test] + fn restart_still_enforces_rate_daily_cap_and_cooldown_across_the_store() { + let store = FakeStore::default(); + + // Cycle 1 ("before restart"): first bundle for the day goes through and is persisted. + let writer = PersistedEntryWriter::new(&store); + let (outcome, state) = writer.decide( + LAUNCHER, + vec![remove(PAYOUT_A, LAUNCHER)], + 100, + 1_000_000, + 0, + ); + let bundle = match outcome { + PersistedWriteOutcome::Bundle { bundle, .. } => bundle, + other => panic!("expected Bundle, got {other:?}"), + }; + writer + .commit(LAUNCHER, &state.expect("decide returns state on success")) + .unwrap(); + + // "Restart": a brand-new `PersistedEntryWriter` (fresh in-memory scheduler state), backed + // by the SAME store — this is the whole point of the seam. + let writer_after_restart = PersistedEntryWriter::new(&store); + + // Rate bound survives the restart: a second attempt one second later is still withheld. + let (rate_outcome, _) = + writer_after_restart.decide(LAUNCHER, vec![add([9; 32], LAUNCHER)], 100, 1_000_000, 1); + assert_eq!(rate_outcome, PersistedWriteOutcome::Pending { count: 1 }); + + // Reentry cooldown survives the restart too: the just-removed payout hash is still held, + // even though the scheduler that decided the removal no longer exists in memory. + let post_restart_state = store.load(LAUNCHER).unwrap(); + assert!(post_restart_state + .cooldown_until + .contains_key(&(PAYOUT_A, LAUNCHER))); + assert_eq!(bundle.actions.len(), 1); + + // Daily cap survives the restart: jump past the rate window but stay inside the same day, + // with a fee that would exceed the remaining daily budget already spent pre-restart. + let writer_later = PersistedEntryWriter::new(&store); + let (cap_outcome, _) = writer_later.decide( + LAUNCHER, + vec![add([7; 32], LAUNCHER)], + 1_000_000, // exceeds the day's whole 1_000_000-mojo budget on top of the 100 already spent + 1_000_000, + ENTRY_WRITE_MIN_INTERVAL_SECONDS + 2, + ); + assert_eq!( + cap_outcome, + PersistedWriteOutcome::FeeBudgetExhausted { count: 1 } + ); + } + + /// The GENERAL form of the bug the restart test above catches one instance of: `decide` + + /// `commit` must round-trip EVERY field of [`WriteBoundState`], not only the fields whichever + /// scenario test happens to inspect. A seam that carries `last_bundle_sent_at` and + /// `cooldown_until` but silently drops `spent_mojos_today` passes every per-cycle check and + /// drains the operator's XCH one restart at a time. So: field by field, each with a distinct + /// non-zero value, so no dropped field can hide behind a plausible-looking zero. + #[test] + fn decide_then_commit_persists_every_write_bound_field() { + let store = FakeStore::default(); + let writer = PersistedEntryWriter::new(&store); + + // A clock exactly one day in rolls the loaded (default, all-zero) state's day window over, + // so `day_started_at` lands on a non-zero value of its own rather than staying at 0. + let now = SECONDS_PER_DAY; + let (outcome, state) = writer.decide( + LAUNCHER, + vec![remove(PAYOUT_A, LAUNCHER)], + 250, + 1_000_000, + now, + ); + assert!(matches!(outcome, PersistedWriteOutcome::Bundle { .. })); + writer + .commit(LAUNCHER, &state.expect("decide returns state on success")) + .unwrap(); + + let persisted = store.load(LAUNCHER).unwrap(); + assert_eq!( + persisted.last_bundle_sent_at, + Some(now), + "rate bound (§6.3 clause 2) must persist" + ); + assert_eq!( + persisted.spent_mojos_today, 250, + "fee budget (§6.3 clause 3) — the field a restart loop drains" + ); + assert_eq!( + persisted.day_started_at, now, + "the day window the spend is measured against must persist with it" + ); + assert_eq!( + persisted.cooldown_until.get(&(PAYOUT_A, LAUNCHER)), + Some(&(now + REENTRY_COOLDOWN_SECONDS)), + "reentry cooldown (§6.3 clause 4) must persist" + ); + } + + /// A store whose `load` always succeeds but whose `save` always fails — the save-failure hole + /// C6 closes: without the poison flag, `decide` would keep reloading the same never-advanced + /// state forever, so `spent_mojos_today` never accumulates and the daily ceiling silently + /// becomes `MAX_BUNDLES_PER_DAY × whatever fee the caller supplies`. + #[derive(Default)] + struct LoadOkSaveErrStore { + states: std::sync::Mutex>, + } + + impl WriteBoundStore for LoadOkSaveErrStore { + fn load(&self, launcher_id: Bytes32) -> Result { + Ok(self + .states + .lock() + .unwrap() + .get(&launcher_id) + .cloned() + .unwrap_or_default()) + } + + fn save(&self, _launcher_id: Bytes32, _state: &WriteBoundState) -> Result<(), StoreError> { + Err(StoreError("disk full".to_string())) + } + } + + /// C6: a `commit` failure poisons the writer for its whole lifetime — every subsequent + /// `decide` call returns `PersistenceUnavailable` with no state, across at least two + /// subsequent cycles (not just the one immediately after), so a single lucky follow-up call + /// cannot pass by accident through the rate bound. + #[test] + fn save_failure_poisons_the_writer_for_every_subsequent_cycle() { + let store = LoadOkSaveErrStore::default(); + let writer = PersistedEntryWriter::new(&store); + + // Cycle 1: load succeeds, decide produces a bundle, commit's save fails. + let (outcome, state) = writer.decide( + LAUNCHER, + vec![remove(PAYOUT_A, LAUNCHER)], + 100, + 1_000_000, + 0, + ); + assert!(matches!(outcome, PersistedWriteOutcome::Bundle { .. })); + let commit_result = writer.commit(LAUNCHER, &state.expect("decide returns state")); + assert!(commit_result.is_err(), "the fake store's save always fails"); + + // Cycle 2: poisoned — no load, no bundle, regardless of rate/cooldown state. + let (outcome_2, state_2) = writer.decide( + LAUNCHER, + vec![add([9; 32], LAUNCHER)], + 100, + 1_000_000, + ENTRY_WRITE_MIN_INTERVAL_SECONDS + 1, + ); + assert_eq!(outcome_2, PersistedWriteOutcome::PersistenceUnavailable); + assert!(state_2.is_none()); + + // Cycle 3: still poisoned — this is the assertion a single-follow-up-call test could miss. + let (outcome_3, state_3) = writer.decide( + LAUNCHER, + vec![add([8; 32], LAUNCHER)], + 100, + 1_000_000, + 2 * ENTRY_WRITE_MIN_INTERVAL_SECONDS + 2, + ); + assert_eq!(outcome_3, PersistedWriteOutcome::PersistenceUnavailable); + assert!(state_3.is_none()); + } +} 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 1c432b4d..85be4b2e 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -143,6 +143,117 @@ async fn resolve_enforced_pin( } } +/// Names which of a reward-prover status record's fields are all-zero, if any, across +/// `launcher_id`, `store_id` (both IDENTITY — an all-zero value is never a real distributor's or +/// module's id; it's what an unassigned/uninitialised registry slot hex-encodes to, which reads +/// exactly like a valid 64-hex id to every consumer, including the dig-app consumer in +/// dig-app#403 (unmerged)) and `root` (an OBSERVATION, not an identity — a registered prover that +/// has not completed its first cycle yet plausibly has no root, so a zeroed `root` alone is not a +/// registration bug the way a zeroed identity field is). +/// +/// Callers decide what to DO with a zeroed field; this only names which ones are zero, so the +/// same detection drives both the exclusion decision (identity fields only, see the +/// `GetRewardProverStatus` filter below) and the log level split there: a zeroed identity field +/// is a `tracing::warn!` (a real registration bug, record excluded), while a zeroed `root` alone +/// is a `tracing::debug!` (an ordinary pre-first-cycle state, record still returned) — the two +/// outcomes are opposite, so they must never share one undifferentiated log line or level. +/// +/// Isolated on purpose (dig_ecosystem#3269 security/adversarial gate): this is a +/// registration-bug DETECTOR that belongs, longer-term, at #3265's writer (the code that will +/// actually populate this registry) rather than woven into the wire mapping below — kept here, +/// small and easy to relocate, only because #3265 has not landed yet. +fn zeroed_fields(s: &crate::rewards::state::RewardProverStatus) -> Vec<&'static str> { + let mut zeroed = Vec::new(); + if s.launcher_id == [0u8; 32] { + zeroed.push("launcher_id"); + } + if s.store_id == [0u8; 32] { + zeroed.push("store_id"); + } + if s.root == [0u8; 32] { + zeroed.push("root"); + } + zeroed +} + +/// Whether a record's zeroed fields (from [`zeroed_fields`]) include an IDENTITY field +/// (`launcher_id` or `store_id`). A record failing this cannot be attributed to any distributor +/// or module, so it must never be presented as one — unlike a zeroed `root` alone, which is a +/// legitimate "no cycle observed yet" state for an otherwise-real, otherwise-attributable prover. +fn is_missing_identity(zeroed: &[&str]) -> bool { + zeroed.contains(&"launcher_id") || zeroed.contains(&"store_id") +} + +/// Map dig-node-core's internal (`camelCase`-tagged) reward-prover status onto +/// `dig-rpc-protocol` 0.11's wire type (snake_case-tagged struct; only its `ProverState` VALUE is +/// camelCase) — field by field, explicit and widening where the shapes differ, never a +/// same-name struct-to-struct copy. This subsystem has already shipped a 24x-too-high fee +/// ceiling and a 2x-understated eviction count that a correctness gate passed twice, so every +/// non-identical field below is called out rather than assumed. +fn reward_prover_status_to_wire( + s: crate::rewards::state::RewardProverStatus, +) -> dig_rpc_protocol::types::RewardProverStatus { + dig_rpc_protocol::types::RewardProverStatus { + launcher_id: hex::encode(s.launcher_id), + store_id: hex::encode(s.store_id), + root: hex::encode(s.root), + prover_state: reward_prover_state_to_wire(s.prover_state), + prover_state_since: s.prover_state_since, + last_cycle_started_at: s.last_cycle_started_at, + last_cycle_completed_at: s.last_cycle_completed_at, + next_cycle_due_at: s.next_cycle_due_at, + last_entry_write_at: s.last_entry_write_at, + consecutive_cycle_failures: s.consecutive_cycle_failures, + pending_entry_writes: s.pending_entry_writes, + observed_at: s.observed_at, + counters: dig_rpc_protocol::types::ProverCounters { + mirrors_seen: s.counters.mirrors_seen, + challenges_issued: s.counters.challenges_issued, + challenges_passed: s.counters.challenges_passed, + challenges_failed: s.counters.challenges_failed, + entries_added: s.counters.entries_added, + entries_removed: s.counters.entries_removed, + // Internal `entry_count` is `u32`; the wire field is `u64` — widen explicitly rather + // than a same-name copy, so a future wire narrowing fails to compile instead of + // silently truncating. + entry_count: u64::from(s.counters.entry_count), + // SUBJECT, not just value (dig_ecosystem#3269, found by a sibling adversarial gate on + // dig-app#403's rewards pane): `reserve_base_units` and `total_paid_out_base_units` are + // per-DISTRIBUTOR figures — this distributor's own reserve, and the total THIS + // distributor has paid out in total to ALL of its mirrors combined. Neither is the + // querying node's own earnings, and `total_paid_out_base_units` is never one mirror's + // share; a caller rendering either as "your earnings" for the operator running this + // node overstates by however many other mirrors this distributor pays (the dig-app + // pane rendered it as personal earnings and overstated by up to 250x). This function + // passes both through unmodified and unaggregated (SPEC §2.4) — it is the caller's job + // to label them as the distributor's totals, never the operator's. + reserve_base_units: s.counters.reserve_base_units, + total_paid_out_base_units: s.counters.total_paid_out_base_units, + }, + } +} + +/// The SPEC §2.3 nine-variant closed set is identical between the internal and wire +/// `ProverState`; mapped explicitly (never `transmute`d) so an internal-only variant added +/// without a matching wire variant is a compile error here, not a silent wire mismatch. +fn reward_prover_state_to_wire( + s: crate::rewards::state::ProverState, +) -> dig_rpc_protocol::types::ProverState { + use crate::rewards::state::ProverState as Internal; + use dig_rpc_protocol::types::ProverState as Wire; + match s { + Internal::Idle => Wire::Idle, + Internal::Running => Wire::Running, + Internal::LocalCopyMissing => Wire::LocalCopyMissing, + Internal::ChainSourceUnavailable => Wire::ChainSourceUnavailable, + Internal::Unfunded => Wire::Unfunded, + Internal::FeeBudgetExhausted => Wire::FeeBudgetExhausted, + Internal::EntrySetFull => Wire::EntrySetFull, + Internal::Paused => Wire::Paused, + Internal::Stopped => Wire::Stopped, + } +} + #[async_trait::async_trait] impl RpcDispatch for Node { async fn dispatch( @@ -659,6 +770,76 @@ impl RpcDispatch for Node { "subscriptions": set.stores(), "count": set.len()}}); } + // dig.getRewardProverStatus (dig_ecosystem#3269, dig-rewards-coin SPEC.md + // §2.3/§2.4) — CONTROL plane: loopback admin / in-process FFI ONLY, NEVER over the + // mTLS peer surface (absent from `is_peer_reachable_method`; + // `reward_methods_tier_guard.rs` fails closed on that). Reads the node's live + // `reward_prover_statuses` registry (empty until dig_ecosystem#3265 spawns a prover + // loop) — a REAL read of a real, currently-empty registry, so `{"statuses": []}` + // means "this node runs no prover loops" and stays true right up until #3265 + // registers one, at which point this same read starts returning it with no dispatch + // change. Never serializes the internal `rewards::state::RewardProverStatus` + // directly (it is `camelCase`-tagged; the wire struct is snake_case) — every field is + // mapped explicitly by `reward_prover_status_to_wire`. + Some(Method::GetRewardProverStatus) => { + let params = req.get("params").cloned().unwrap_or(json!({})); + let filter_launcher_id = params + .get("launcher_id") + .and_then(Value::as_str) + .map(str::to_ascii_lowercase); + let statuses: Vec = node + .reward_prover_status_snapshots() + .into_iter() + // A zeroed `launcher_id` or `store_id` is never a real distributor's or + // module's IDENTITY — see `is_missing_identity`/`zeroed_fields`. Excluding + // such a record rather than presenting it as a real one avoids the money-hole + // class the driver's gates found three times (an unset field that reads fine + // and costs the operator), BUT exclusion alone would silently destroy the + // evidence that a registration bug happened — the exact §2.4 clause 1 + // violation a security + adversarial gate found in the first version of this + // filter (dig-node#595 review round). So this is never a silent drop: a + // `tracing::warn!` fires naming which field(s) were zero, making a bad + // registration observable, and the record is excluded. + // + // A zeroed `root` alone is different: it is an OBSERVATION (the prover's most + // recent cycle), not an identity, and a freshly-registered prover that has not + // completed its first cycle plausibly has a zero `root` legitimately. Excluding + // it on that basis alone would make a healthy, just-not-yet-cycled prover + // invisible — worse than the defect this guard exists to prevent. So this case + // is `tracing::debug!`, not `warn!`: an ordinary, expected state rather than a + // fault, kept out of `warn!`-level volume so an operator polling this endpoint + // is never shown (uncycled provers) x (poll rate) lines indistinguishable from + // a real registration bug. The record is still returned either way. + .filter(|s| { + let zeroed = zeroed_fields(s); + if is_missing_identity(&zeroed) { + tracing::warn!( + launcher_id = %hex::encode(s.launcher_id), + store_id = %hex::encode(s.store_id), + root = %hex::encode(s.root), + zeroed_fields = ?zeroed, + "reward-prover status registration is missing an identity field; excluding it from dig.getRewardProverStatus rather than presenting it as a real distributor" + ); + } else if !zeroed.is_empty() { + tracing::debug!( + launcher_id = %hex::encode(s.launcher_id), + store_id = %hex::encode(s.store_id), + root = %hex::encode(s.root), + zeroed_fields = ?zeroed, + "reward-prover status has a zeroed root; likely no cycle observed yet, returning it anyway" + ); + } + !is_missing_identity(&zeroed) + }) + .filter(|s| match &filter_launcher_id { + Some(want) => hex::encode(s.launcher_id).eq_ignore_ascii_case(want), + None => true, + }) + .map(reward_prover_status_to_wire) + .collect(); + let result = dig_rpc_protocol::types::GetRewardProverStatusResult { statuses }; + return json!({"jsonrpc":"2.0","id":id,"result": result}); + } Some(Method::CacheSetCapBytes) => { let requested = req .get("params") diff --git a/crates/dig-node-core/tests/dependency_tree.rs b/crates/dig-node-core/tests/dependency_tree.rs index 82418b16..54b40044 100644 --- a/crates/dig-node-core/tests/dependency_tree.rs +++ b/crates/dig-node-core/tests/dependency_tree.rs @@ -96,10 +96,11 @@ fn locked_versions(crate_name: &str) -> Vec<&str> { .collect() } -/// **Proves:** exactly ONE `dig-rpc-protocol` resolves in the workspace, and it is the 0.10 line that -/// defines the module wire (`ModuleInfo` / `GetModuleInfoParams` / `FetchModuleRangeParams`) AND the +/// **Proves:** exactly ONE `dig-rpc-protocol` resolves in the workspace, and it is the 0.11 line that +/// defines the module wire (`ModuleInfo` / `GetModuleInfoParams` / `FetchModuleRangeParams`), the /// recursive-ask contract this node adopted (`GetAvailabilityParams::budget_ms` / `::ask_id`, -/// `AvailabilityAnswer::absence_established`, `ErrorCode::ContentMissInconclusive`). +/// `AvailabilityAnswer::absence_established`, `ErrorCode::ContentMissInconclusive`), AND (#3269) the +/// reward RPC surface (`Method::GetRewardProverStatus` et al., all `Tier::Control`). /// /// **Catches:** the obligation-8 skew directly. Before the #1576 cascade, dig-download consumed /// dig-rpc-protocol 0.5 while dig-peer 0.4 pulled 0.3.1, so a tree containing both held TWO `ModuleInfo` @@ -107,6 +108,12 @@ fn locked_versions(crate_name: &str) -> Vec<&str> { /// that drive the entire pull plan. Asserting the TRANSITIVE lock entry (not the caret dep in a manifest) /// is the point: a consumer's own lock can pin an old patch even when every caret dep and every /// higher-layer bump looks correct. +/// +/// **Cascade closed (#3269):** `dig-node-core` depends on 0.11.0 directly; `dig-peer` (0.14.0), +/// `dig-download` (0.23.0) and `dig-peer-selector` (0.12.0) all now resolve `dig-rpc-protocol` +/// 0.11 too, so `cargo metadata` resolves exactly one line. This assertion is deliberately left at +/// exactly-one/0.11 (never widened to accept a set — see #836/#1576); if a future dependency bump +/// reopens the split, this test goes red again on purpose. #[test] fn the_workspace_carries_exactly_one_module_wire_crate() { let versions = locked_versions("dig-rpc-protocol"); @@ -117,8 +124,10 @@ fn the_workspace_carries_exactly_one_module_wire_crate() { majors means two `ModuleInfo` shapes across the module pull's trust boundary" ); assert!( - versions[0].starts_with("0.10."), - "the availability contract this node adopted ships in dig-rpc-protocol 0.10; the workspace resolved {} — on an earlier line the canonical items simply do not exist and this node would be back to declaring its own", + versions[0].starts_with("0.11."), + "the availability contract plus the #3269 reward RPC surface this node adopted ship in \ + dig-rpc-protocol 0.11; the workspace resolved {} — on an earlier line the canonical items \ + simply do not exist and this node would be back to declaring its own", versions[0] ); } diff --git a/crates/dig-node-core/tests/reward_methods_tier_guard.rs b/crates/dig-node-core/tests/reward_methods_tier_guard.rs new file mode 100644 index 00000000..37393051 --- /dev/null +++ b/crates/dig-node-core/tests/reward_methods_tier_guard.rs @@ -0,0 +1,70 @@ +//! Fail-closed guard (dig_ecosystem#3269, binding #3261's rule node-side): every `Method` variant +//! whose wire name contains `Reward` MUST be `Tier::Control` and MUST NOT be peer-reachable. +//! +//! The companion check — absence from dig-node's OWN peer dispatch allowlist +//! (`is_peer_reachable_method`, `pub(crate)` in `src/peer.rs`, unreachable from an external +//! integration test) — is a sibling unit test inside `peer.rs`'s own `#[cfg(test)] mod tests`: +//! `reward_methods_are_absent_from_the_node_peer_allowlist`. +//! +//! #3261 (a `dig-rpc-protocol` ticket, not this crate's work) replaces that crate's four-member +//! reward-method enumeration with a prefix guard — but the enumeration it replaces lists exactly the +//! four methods that exist TODAY, so a FIFTH reward method added later would pass an enumeration test +//! simply by not being in the list: an enumeration test only proves the enumeration. This test proves +//! the RULE instead, over the live `Method::ALL` catalogue: it does not name any reward method, so a +//! reward method added after this test is written is caught automatically, at the wrong tier, the +//! moment it appears — rather than silently inheriting a wrong default. +//! +//! Promotion (widening a method's reach) is additive and reversible; demotion is breaking and breaks +//! exactly the anonymous callers nobody can enumerate. That asymmetry is why this fails closed: a +//! reward method that is NOT `Tier::Control`, or IS peer-reachable, fails loudly instead of quietly +//! granting a remote peer a money-adjacent read. + +use dig_rpc_protocol::{Method, Tier}; + +/// Every catalogue member whose wire name contains `"Reward"` (case-sensitive — the wire is +/// camelCase, e.g. `dig.getRewardProverStatus`). +fn reward_methods() -> Vec { + Method::ALL + .iter() + .copied() + .filter(|m| m.name().contains("Reward")) + .collect() +} + +#[test] +fn reward_methods_exist_and_are_found_by_the_prefix_scan() { + // A guard that silently matched zero methods would pass on a catalogue where every reward + // method had been renamed out of its `Reward` name, proving nothing. Assert the scan actually + // finds the surface it exists to police. + let methods = reward_methods(); + assert!( + !methods.is_empty(), + "expected at least one Reward-prefixed method in Method::ALL; found none — the prefix scan \ + itself may be broken, or the wire naming convention changed" + ); +} + +#[test] +fn every_reward_method_is_tier_control() { + for method in reward_methods() { + assert_eq!( + method.tier(), + Tier::Control, + "{} must be Tier::Control (dig_ecosystem#3269) — a reward RPC reachable at a lower tier \ + is a money hole", + method.name() + ); + } +} + +#[test] +fn no_reward_method_is_peer_reachable() { + for method in reward_methods() { + assert!( + !method.is_peer_reachable(), + "{} must NOT be peer-reachable — reachable ONLY from the loopback admin / in-process FFI \ + dispatch, never over the mTLS peer surface", + method.name() + ); + } +} diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 3666c163..e340a965 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -172,7 +172,11 @@ getrandom = "0.2" # longer has — the shell would omit `dig.getModuleInfo` / `dig.fetchModuleRange` while the engine served # them. Two majors in one workspace also duplicates the wire TYPES; pinned by # `dig-node-core/tests/dependency_tree.rs`. -dig-rpc-protocol = "0.10" +# +# Moved to 0.11 (dig_ecosystem#3269), matching `dig-node-core`'s move to 0.11.0 — the engine's +# `dig.getRewardProverStatus` handler needs the 0.11 line's reward types, and this line staying at +# 0.10 would be the exact drift the paragraph above warns against. +dig-rpc-protocol = "0.11" # The Sage-parity wallet engine (crate `dig_wallet`) — the node-custodied wallet DB + dual-transport # dispatch + seed custody. This shell WIRES it into bring-up (#368): it builds one live @@ -314,9 +318,9 @@ windows-sys = { version = "0.61", features = [ [dev-dependencies] # `openrpc_drift_guard.rs` compares the shell's error catalogue against the shared contract # crate name-for-name. Already a normal dependency above; restated here only so the -# integration-test crate can name it, and pinned to the SAME "0.10" line so the guard can +# integration-test crate can name it, and pinned to the SAME "0.11" line so the guard can # never compare against a different catalogue than the shell compiles against. -dig-rpc-protocol = "0.10" +dig-rpc-protocol = "0.11" # The `never_log` battery (#277) drives the real seed bootstrap against a temp layout so its # sentinels are the ACTUAL minted phrase and device key rather than invented strings. Already a # normal dependency above; restated here only so the integration-test crate can name it. diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index 906ff93d..7d240feb 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -103,6 +103,12 @@ pub mod peers; /// The passthrough relay guard (#1997): whether this node relays an unimplemented method to an /// upstream, and the bring-up probe that proves an upstream is not this node itself. See [`relay`]. pub mod relay; +/// The node's PEER-SIDE reward claim loop (DIG-Network/dig_ecosystem#3251): discovers the +/// reward distributors covering the `(store_id, root)`s this node mirrors and submits +/// `InitiatePayout` on a jittered cadence, default 24h. The other half of the reward-distributor +/// lifecycle from `dig_node_core::rewards` (#3250, the funder-side prover, a sibling lane). See +/// [`rewards_claim`]. +pub mod rewards_claim; pub mod rpc; /// The offline `wallet export-seed` rescue command: a local read of this node's /// encrypted seed file. Adds no network surface, and is removed with node-side custody. diff --git a/crates/dig-node-service/src/rewards_claim/cadence.rs b/crates/dig-node-service/src/rewards_claim/cadence.rs new file mode 100644 index 00000000..ec9cc540 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/cadence.rs @@ -0,0 +1,85 @@ +//! Claim cadence + jitter (SPEC §8.6): the peer's own setting, never curried on the distributor, +//! and jittered so a network of peers on the default does not converge on one minute of the day. +//! +//! The jitter SOURCE is injected — never a global RNG or clock read directly — so the schedule is +//! deterministic under test. + +/// SPEC §8.6: the minimum jitter spread every peer MUST apply. +pub const CLAIM_JITTER_SECONDS_DEFAULT: u64 = 3_600; + +/// Supplies the jitter offset for one scheduling decision. A production implementation draws from +/// the OS CSPRNG; tests inject a fixed or sequenced value. +pub trait JitterSource: Send + Sync { + /// An offset in `0..=bound` seconds. + fn jitter_seconds(&self, bound: u64) -> u64; +} + +/// A jitter source that always returns the same value — for deterministic tests. +pub struct FixedJitter(pub u64); + +impl JitterSource for FixedJitter { + fn jitter_seconds(&self, bound: u64) -> u64 { + self.0.min(bound) + } +} + +/// The next cadence interval, in seconds: `cadence_seconds + jitter`, where `jitter` is drawn from +/// `[0, jitter_seconds]` via the injected source (SPEC §8.6). `jitter_seconds` is a lower bound on +/// the SPREAD available to the source, not a fixed addition — a source that always returns `0` +/// still produces a schedule within the required bound, just at its floor. +#[must_use] +pub fn next_interval_seconds( + cadence_seconds: u64, + jitter_seconds: u64, + source: &dyn JitterSource, +) -> u64 { + let offset = source.jitter_seconds(jitter_seconds); + cadence_seconds.saturating_add(offset) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interval_is_cadence_plus_a_bounded_jitter() { + let cadence = 86_400; + let jitter_bound = 3_600; + + let at_floor = next_interval_seconds(cadence, jitter_bound, &FixedJitter(0)); + assert_eq!(at_floor, cadence); + + let at_ceiling = next_interval_seconds(cadence, jitter_bound, &FixedJitter(jitter_bound)); + assert_eq!(at_ceiling, cadence + jitter_bound); + + // A source that tries to exceed the bound is clamped by the source contract itself + // (FixedJitter here), and the composed interval never exceeds cadence + jitter_seconds. + let over = next_interval_seconds(cadence, jitter_bound, &FixedJitter(jitter_bound * 10)); + assert!(over <= cadence + jitter_bound); + assert!(over >= cadence); + } + + /// ACCEPTANCE 8 (part) — a config setting a cadence OTHER than the default is honoured by the + /// scheduling function, not silently overridden back to `CLAIM_CADENCE_SECONDS_DEFAULT`. + #[test] + fn a_non_default_configured_cadence_is_honoured() { + let cfg = super::super::config::RewardsClaimConfig { + enabled: true, + cadence_seconds: 12_000, + jitter_seconds: 500, + max_fee_mojos: 1, + max_cycle_fee_budget_mojos: 10, + rotation_cursor: None, + ..super::super::config::RewardsClaimConfig::default() + }; + let interval = + next_interval_seconds(cfg.cadence_seconds, cfg.jitter_seconds, &FixedJitter(0)); + assert_eq!( + interval, 12_000, + "configured cadence, not the 86_400 default" + ); + let interval_at_ceiling = + next_interval_seconds(cfg.cadence_seconds, cfg.jitter_seconds, &FixedJitter(500)); + assert_eq!(interval_at_ceiling, 12_500); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/config.rs b/crates/dig-node-service/src/rewards_claim/config.rs new file mode 100644 index 00000000..8e0a77bd --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/config.rs @@ -0,0 +1,490 @@ +//! This node's peer-side claim-loop preferences (requirement 5) — persisted the same way +//! `crate::collateral::CollateralConfig` is: a dedicated JSON file in the node's state dir, every +//! field `#[serde(default = "...")]` so a config written before a field existed loads that field's +//! DEFAULT, never a fabricated deliberate choice. + +use std::path::Path; + +use chia_protocol::Bytes32; +use serde::{Deserialize, Serialize}; + +use super::cadence::CLAIM_JITTER_SECONDS_DEFAULT; + +/// SPEC §8.6: the peer-side claim cadence default. +pub const CLAIM_CADENCE_SECONDS_DEFAULT: u64 = 86_400; + +/// The max fee ceiling this node will spend on ONE claim (requirement 2). Not a floor: a true +/// "net > 0" floor is not computable here — the fee is XCH mojos, the reward is $DIG base units, +/// and the node holds no exchange rate between them. SPEC §8.3 clause 2 already asserts +/// `payout_threshold` (1 $DIG) is "above any plausible fee", so the threshold IS the economic floor +/// by construction; this constant only caps what the node will pay to collect it. +/// +/// # Defect C1: the magnitude, not the reasoning, was wrong +/// This constant originally reused `crate::mirror::signer::MIRROR_SPEND_FEE_CEILING_MOJOS` +/// (1_000_000_000 mojos = 0.001 XCH) — a number sized for a mirror-coin spend, not a per-distributor +/// claim repeated daily. Against a routine Chia transaction fee of 5,000-100,000 mojos, that ceiling +/// was four to five orders of magnitude too loose to ever bind a real fee: a peer could still lose +/// money inside it whenever 1 $DIG is worth less than 0.001 XCH, and the ceiling would never notice. +/// 200,000 mojos is 2x the top of the observed routine-fee range — enough headroom to survive a +/// congested mempool without giving up the one computable control this loop has. +pub const CLAIM_FEE_CEILING_MOJOS_DEFAULT: u64 = 200_000; + +/// The per-cycle AGGREGATE fee budget (Defect C2): a cap on what this node will spend across ALL +/// claims in one cycle, independent of [`CLAIM_FEE_CEILING_MOJOS_DEFAULT`]'s per-claim cap. +/// +/// `required_fee_mojos(launcher_id)` is per-distributor state that anyone may create: a DIG-asset +/// distributor may be launched over any widely mirrored store. Without an aggregate cap, an +/// attacker funding K such distributors and getting a victim peer's payout puzzle hash admitted to +/// each could force that peer to spend up to `K * CLAIM_FEE_CEILING_MOJOS_DEFAULT` of its own XCH +/// per cycle, at a cost to the attacker of only K $DIG. Defaulting this to 10x the per-claim ceiling +/// bounds a single cycle to roughly 10 distributors' worth of fees before the loop stops claiming +/// for the rest of that cycle and reports it by name +/// (`ClaimOutcome::SkippedCycleBudgetExhausted`) — configurable for an operator who mirrors more +/// than that many distributors' worth of stores. +pub const CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT: u64 = CLAIM_FEE_CEILING_MOJOS_DEFAULT * 10; + +/// F10 (§8.6 floor): the lowest `cadence_seconds` this config will honour. SPEC §8.6 sets the +/// default at `86_400` but never floors an operator-supplied override, so an unvalidated `0` (or a +/// handful of seconds) would hot-loop `ClaimEngine::run_cycle` — a chain read on every tick with no +/// cadence protection at all, the same class of unbounded-work defect F7 closed for spend. One +/// minute is short enough to never bind a legitimate operator (SPEC's own default is a full day) +/// and long enough that a degenerate value cannot turn this loop into a busy-poll. +pub const CLAIM_CADENCE_FLOOR_SECONDS: u64 = 60; + +const REWARDS_CLAIM_CONFIG_FILE: &str = "rewards-claim.json"; + +/// This node's peer-side claim-loop preferences. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct RewardsClaimConfig { + /// Whether the claim loop runs at all. Default-on: a peer earning rewards and never claiming + /// them is the silent-failure case this ticket exists to prevent, so opting IN by default is + /// the honest posture — see [`crate::rewards_claim`]'s module doc. + /// + /// # R5: `true` here does not mean the loop is running yet + /// Nothing in this codebase constructs a [`super::ClaimEngine`] outside this module's own tests + /// (DIG-Network/dig_ecosystem#3268, not yet landed) — see [`crate::rewards_claim`]'s module doc, + /// "Not yet wired into node startup". An operator who reads their own `rewards-claim.json` and + /// sees `enabled: true` is exactly the person who needs to know that; the module doc alone does + /// not reach them. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// SPEC §8.6: base cadence between claim cycles, before jitter. + #[serde(default = "default_cadence_seconds")] + pub cadence_seconds: u64, + + /// SPEC §8.6: the jitter spread applied on top of `cadence_seconds` (see + /// [`super::cadence::next_interval_seconds`]). + #[serde(default = "default_jitter_seconds")] + pub jitter_seconds: u64, + + /// The PER-CLAIM fee ceiling (requirement 2) — see [`CLAIM_FEE_CEILING_MOJOS_DEFAULT`]'s doc for + /// why this is a ceiling, not a floor, and for the magnitude reasoning (Defect C1). + #[serde(default = "default_max_fee_mojos")] + pub max_fee_mojos: u64, + + /// The PER-CYCLE aggregate fee budget (Defect C2) — see + /// [`CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT`]'s doc for the attacker-cost reasoning. + #[serde(default = "default_max_cycle_fee_budget_mojos")] + pub max_cycle_fee_budget_mojos: u64, + + /// Defect B2: the tie-break cursor [`super::ClaimEngine::order_for_budget`] uses to rotate a + /// legitimately starved tail (a set of equal-accrual honest distributors whose combined fee + /// exceeds one cycle's budget every cycle) so the SAME distributors are not dropped every + /// cycle forever. Persisted here — not just held in the in-memory [`super::ClaimEngine`] — so + /// a node that restarts daily does not reset the rotation and starve the tail permanently. + /// `None` until the first cycle defers something; absent from a config written before this + /// field existed, which is the same as `None` (no rotation history yet). + #[serde(default)] + pub rotation_cursor: Option, + + /// F7: the start (unix seconds) of the CURRENT aggregate-fee-budget window. Read alongside + /// [`Self::fee_spent_in_window_mojos`] to decide, on each cycle, whether the window has rolled + /// over (`now - fee_window_start_unix >= cadence_seconds`) or whether spend must keep + /// accumulating into it. `None` until the first cycle ever runs; absent from a config written + /// before this field existed, which is the same as `None` (no window has started yet, so the + /// next cycle starts one fresh rather than reading a fabricated "already spent" history). + #[serde(default)] + pub fee_window_start_unix: Option, + + /// F7: fee mojos already spent inside [`Self::fee_window_start_unix`]'s window. This is the + /// field that actually bounds a crash-restart loop: without it, every fresh process starts + /// this at zero and re-grants a full [`Self::max_cycle_fee_budget_mojos`] on every restart, no + /// matter how many restarts happen inside one cadence period. Defaults to `0` — a config + /// written before this field existed had spent nothing in a window that did not exist either. + #[serde(default)] + pub fee_spent_in_window_mojos: u64, + + /// F7: the unix-second timestamp of the last cycle that ran to completion. The cadence gate + /// (`now - last_cycle_completed_at < cadence_seconds`) refuses to START a new cycle at all + /// until the cadence has genuinely elapsed since this time, so a crash-restart loop cannot + /// immediately re-run a cycle that already ran, independent of the fee-window check above. + /// `None` until the first cycle ever completes; absent from a config written before this field + /// existed is the same as `None` (no completed cycle on record, so the next cycle is allowed to + /// run immediately -- the honest reading for a node that has never run this loop before). + #[serde(default)] + pub last_cycle_completed_at: Option, + + /// F8: set by [`Self::load_from`] (never persisted, never read from the file itself) when the + /// file was present but unparsable, unreadable, or carried a `fee_spent_in_window_mojos` + /// exceeding its own `max_cycle_fee_budget_mojos` (F14) — corrupt state, not a fresh peer. + /// `ClaimEngine` reads this to fail CLOSED (treat the window as fully spent, submit nothing) + /// rather than the old behaviour of falling back to [`Self::default`], which re-granted a full + /// budget through the exact crash-restart loop F7 exists to bound. `#[serde(skip)]` because a + /// value read off disk can never itself declare "I am corrupt" — that fact lives only in + /// *how* the read failed, decided once, here, at load time. + #[serde(skip)] + pub corrupt: bool, +} + +fn default_enabled() -> bool { + true +} + +fn default_cadence_seconds() -> u64 { + CLAIM_CADENCE_SECONDS_DEFAULT +} + +fn default_jitter_seconds() -> u64 { + CLAIM_JITTER_SECONDS_DEFAULT +} + +fn default_max_fee_mojos() -> u64 { + CLAIM_FEE_CEILING_MOJOS_DEFAULT +} + +fn default_max_cycle_fee_budget_mojos() -> u64 { + CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT +} + +impl Default for RewardsClaimConfig { + fn default() -> Self { + RewardsClaimConfig { + enabled: default_enabled(), + cadence_seconds: default_cadence_seconds(), + jitter_seconds: default_jitter_seconds(), + max_fee_mojos: default_max_fee_mojos(), + max_cycle_fee_budget_mojos: default_max_cycle_fee_budget_mojos(), + rotation_cursor: None, + fee_window_start_unix: None, + fee_spent_in_window_mojos: 0, + last_cycle_completed_at: None, + corrupt: false, + } + } +} + +impl RewardsClaimConfig { + /// Load from the node's own machine-wide state directory (production entry point). + pub fn load() -> Self { + RewardsClaimConfig::load_from(&crate::state::state_dir()) + } + + /// Persist to the node's own machine-wide state directory. + pub fn save(&self) -> std::io::Result<()> { + self.save_to(&crate::state::state_dir()) + } + + /// F8: the fail-CLOSED reading for a file this process could not trust — present but + /// unparsable, unreadable, or carrying a spend that exceeds its own budget (F14). Deliberately + /// NOT [`Self::default`]: a missing file is a clean first run and defaults are the honest + /// reading for it, but a corrupt one must never be treated the same way, because `default()` + /// re-grants a full spend budget into exactly the crash-restart loop F7 exists to bound. + /// `corrupt: true` is the only signal a caller needs — every other field here is a placeholder + /// `ClaimEngine` must not act on, and [`Self::save_to`] must never be called with this value + /// (see [`super::engine::ClaimEngine::persist_fee_window`]'s corrupt-file guard). + fn poisoned() -> Self { + RewardsClaimConfig { + corrupt: true, + ..Self::default() + } + } + + /// Load from an explicit directory. + /// + /// A MISSING file is a clean first run: [`Self::default`] is the honest reading, because + /// nothing has ever been decided or spent yet. + /// + /// A file this process cannot trust — unreadable, unparsable, or (F14) carrying a persisted + /// spend larger than its own budget — is a DIFFERENT fact and must never share `default()`'s + /// code path (F8): it becomes [`Self::poisoned`], visibly logged, and never fatal to node + /// start over one preferences file, but never silently re-granting a budget either. + pub fn load_from(dir: &Path) -> Self { + let path = dir.join(REWARDS_CLAIM_CONFIG_FILE); + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(), + Err(e) => { + tracing::error!( + path = %path.display(), + error = %e, + "the rewards-claim preference file could not be read; failing closed, not \ + using defaults" + ); + return Self::poisoned(); + } + }; + match serde_json::from_str::(&text) { + Ok(mut cfg) => { + // F10 (§8.6 floor): an operator-supplied cadence below the floor is clamped, not + // corrupt -- see `CLAIM_CADENCE_FLOOR_SECONDS`'s doc for why this is the one F7 + // field that is safe to correct upward rather than fail closed over. + if cfg.cadence_seconds < CLAIM_CADENCE_FLOOR_SECONDS { + tracing::warn!( + path = %path.display(), + cadence_seconds = cfg.cadence_seconds, + floor = CLAIM_CADENCE_FLOOR_SECONDS, + "rewards-claim cadence_seconds below the §8.6 floor; clamping up" + ); + cfg.cadence_seconds = CLAIM_CADENCE_FLOOR_SECONDS; + } + // F14: a persisted spend exceeding the budget it is measured against is not a big + // number to clamp down -- clamping would hand back exactly the budget the + // corruption was hiding. It is corrupt state: fail closed instead. + if cfg.fee_spent_in_window_mojos > cfg.max_cycle_fee_budget_mojos { + tracing::error!( + path = %path.display(), + spent = cfg.fee_spent_in_window_mojos, + budget = cfg.max_cycle_fee_budget_mojos, + "persisted rewards-claim spend exceeds its own budget; failing closed, \ + not clamping" + ); + return Self::poisoned(); + } + cfg + } + Err(e) => { + tracing::error!( + path = %path.display(), + error = %e, + "the rewards-claim preference file could not be parsed; failing closed, not \ + using defaults" + ); + Self::poisoned() + } + } + } + + /// Persist to `dir`, ATOMICALLY: written to a temp file beside the real path, then renamed + /// over it — the same pattern `crate::mirror::reconcile_state::ReconcileState::save_to` uses + /// for the same class of state in this crate (F8). Without this, a crash mid-`write` can leave + /// a torn file that [`Self::load_from`] would previously have read as [`Self::default`] and + /// re-granted a full budget into — the exact restart-loop F7 was written to close, reopened + /// through F7's own persist path. A rename is atomic on the same filesystem, so the file this + /// process's crash leaves behind is always either the old complete contents or the new + /// complete contents, never a half-write. + pub fn save_to(&self, dir: &Path) -> std::io::Result<()> { + crate::state::ensure_dir_restricted(dir)?; + let path = dir.join(REWARDS_CLAIM_CONFIG_FILE); + let temp = path.with_extension("json.tmp"); + let body = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?; + std::fs::write(&temp, &body)?; + crate::control::restrict_permissions(&temp); + std::fs::rename(&temp, &path)?; + crate::control::restrict_permissions(&path); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_spec_8_6_and_the_fee_ceiling() { + let cfg = RewardsClaimConfig::default(); + assert!(cfg.enabled); + assert_eq!(cfg.cadence_seconds, 86_400); + assert_eq!(cfg.jitter_seconds, 3_600); + assert_eq!(cfg.max_fee_mojos, 200_000); + assert_eq!(cfg.max_cycle_fee_budget_mojos, 2_000_000); + } + + /// Defect C1 regression: the ceiling must actually bind a routine Chia fee — the old default + /// (1_000_000_000, transplanted from `MIRROR_SPEND_FEE_CEILING_MOJOS`) was 4-5 orders of + /// magnitude looser than the observed 5,000-100,000 mojo range and never rejected a real fee. + #[test] + fn the_default_per_claim_ceiling_actually_binds_a_routine_fee() { + let cfg = RewardsClaimConfig::default(); + assert!( + cfg.max_fee_mojos < 1_000_000, + "the default ceiling must be within striking distance of a routine fee, not 1e9" + ); + assert!( + cfg.max_fee_mojos >= 100_000, + "the default ceiling must not reject the top of the routine fee range outright" + ); + } + + #[test] + fn save_then_load_round_trips_and_survives_restart() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-test-") + .tempdir() + .expect("a scratch dir"); + + let cfg = RewardsClaimConfig { + enabled: false, + cadence_seconds: 43_200, + jitter_seconds: 1_800, + max_fee_mojos: 150_000, + max_cycle_fee_budget_mojos: 900_000, + rotation_cursor: None, + fee_window_start_unix: None, + fee_spent_in_window_mojos: 0, + last_cycle_completed_at: None, + corrupt: false, + }; + cfg.save_to(dir.path()).expect("save"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert_eq!(loaded, cfg); + } + + /// Defect B2: a rotation cursor left in memory only resets on every restart, which starves a + /// legitimately-tied honest tail forever on any node that restarts daily. It must round-trip + /// through save/load exactly like every other field. + #[test] + fn the_rotation_cursor_survives_a_save_load_round_trip() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-cursor-test-") + .tempdir() + .expect("a scratch dir"); + + let cursor = Bytes32::from([7u8; 32]); + let cfg = RewardsClaimConfig { + rotation_cursor: Some(cursor), + ..RewardsClaimConfig::default() + }; + cfg.save_to(dir.path()).expect("save"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert_eq!(loaded.rotation_cursor, Some(cursor)); + assert_eq!(loaded, cfg); + } + + /// F7: the persisted fee-window fields must round-trip through save/load exactly like every + /// other field -- this is the state a restart reads back to avoid re-granting a fresh budget. + #[test] + fn the_fee_window_fields_survive_a_save_load_round_trip() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-fee-window-test-") + .tempdir() + .expect("a scratch dir"); + + let cfg = RewardsClaimConfig { + fee_window_start_unix: Some(1_000), + fee_spent_in_window_mojos: 1_500_000, + last_cycle_completed_at: Some(1_000), + ..RewardsClaimConfig::default() + }; + cfg.save_to(dir.path()).expect("save"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert_eq!(loaded, cfg); + } + + #[test] + fn a_config_written_before_a_field_existed_loads_that_fields_default() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-legacy-test-") + .tempdir() + .expect("a scratch dir"); + std::fs::write(dir.path().join(REWARDS_CLAIM_CONFIG_FILE), b"{}").expect("write"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert_eq!(loaded, RewardsClaimConfig::default()); + } + + #[test] + fn a_missing_file_yields_the_default() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-missing-test-") + .tempdir() + .expect("a scratch dir"); + assert_eq!( + RewardsClaimConfig::load_from(dir.path()), + RewardsClaimConfig::default() + ); + } + + /// F8 regression: a present-but-unparsable file must NOT load as [`RewardsClaimConfig::default`] + /// — that is exactly the fail-OPEN bug (a torn write reads as a clean first run and re-grants a + /// full spend budget). Must go red with only the `Err(e) => ... Self::poisoned()` branch of + /// [`RewardsClaimConfig::load_from`]'s parse-failure arm reverted to `Self::default()`. + #[test] + fn a_corrupt_file_fails_closed_not_default() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-corrupt-test-") + .tempdir() + .expect("a scratch dir"); + std::fs::write( + dir.path().join(REWARDS_CLAIM_CONFIG_FILE), + b"{ this is not json, or a torn write mid-object", + ) + .expect("write garbage"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert!( + loaded.corrupt, + "a present-but-unparsable file must be reported as corrupt, never silently defaulted" + ); + assert_ne!( + loaded, + RewardsClaimConfig::default(), + "corrupt state must be distinguishable from a clean first run" + ); + } + + /// F8: a MISSING file is the opposite fact from a corrupt one -- still a clean first run. + #[test] + fn a_missing_file_is_not_corrupt() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-missing-not-corrupt-") + .tempdir() + .expect("a scratch dir"); + assert!(!RewardsClaimConfig::load_from(dir.path()).corrupt); + } + + /// F10 (§8.6 floor) regression: an operator (or corrupt/hostile) config with `cadence_seconds: + /// 0` must not be honoured verbatim -- it would hot-loop `run_cycle` with no cadence + /// protection at all. Must go red with only the floor-clamp removed from `load_from`. + #[test] + fn a_cadence_below_the_floor_is_clamped_up_on_load() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-cadence-floor-") + .tempdir() + .expect("a scratch dir"); + std::fs::write( + dir.path().join(REWARDS_CLAIM_CONFIG_FILE), + br#"{"cadence_seconds": 0}"#, + ) + .expect("write"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert_eq!(loaded.cadence_seconds, CLAIM_CADENCE_FLOOR_SECONDS); + assert!(!loaded.corrupt, "a low cadence is clamped, not corrupt"); + } + + /// F14 regression: a persisted spend larger than its own budget is corrupt state, not a large + /// number to clamp down -- clamping down would hand back exactly the budget the corruption was + /// hiding. Must go red with only that branch removed (i.e. the field loaded verbatim). + #[test] + fn a_spend_exceeding_its_own_budget_fails_closed() { + let dir = tempfile::Builder::new() + .prefix("dig-node-rewards-claim-spend-overflow-") + .tempdir() + .expect("a scratch dir"); + std::fs::write( + dir.path().join(REWARDS_CLAIM_CONFIG_FILE), + br#"{"fee_spent_in_window_mojos": 999999999999, "max_cycle_fee_budget_mojos": 2000000}"#, + ) + .expect("write"); + + let loaded = RewardsClaimConfig::load_from(dir.path()); + assert!( + loaded.corrupt, + "a spend exceeding its own budget must fail closed, never be clamped down" + ); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/engine.rs b/crates/dig-node-service/src/rewards_claim/engine.rs new file mode 100644 index 00000000..2d098da2 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/engine.rs @@ -0,0 +1,2946 @@ +//! The claim loop's one tick: discover, evaluate, claim — driven against [`ClaimChainPort`] and +//! [`DistributorHintSource`], never against a concrete chain client (see the module doc's "chain +//! seam" section). + +use std::path::{Path, PathBuf}; + +use chia_protocol::Bytes32; + +use super::config::RewardsClaimConfig; +use super::hints::DistributorHintSource; +use super::port::{ClaimChainPort, ClaimPortError}; +use super::types::{ClaimLoopState, ClaimOutcome, ClaimStatus}; + +/// Drives one claim cycle for this node against a [`ClaimChainPort`] + [`DistributorHintSource`] +/// and the anti-silence status surface across calls to [`Self::run_cycle`]. +/// +/// # No permanent "no entry slot" blacklist (Defect B) +/// An earlier version of this engine cached a launcher id in a process-lifetime `terminal_no_entry` +/// set the first time `own_entry` returned `None`, and never re-checked it. That is wrong in two +/// reachable cases: SPEC §12.5 clause 2's re-entry path (a peer evicted, re-challenged and +/// legitimately re-admitted would never claim again until the process restarted), and a peer that +/// discovers a distributor before the funder's `AddEntry` lands (blacklisted on its very first +/// cycle, never paid at all). SPEC §12.5 clause 3 — re-read the entry slot before every claim, +/// never cache one across cycles — argues directly against caching an absence forever too. The fix: +/// no blacklist at all. `own_entry` is a cheap chain READ, so it is re-issued every cycle for every +/// candidate; `ClaimOutcome::NoEntrySlot` stays the reported outcome (still non-error, still no +/// spend, still no chain fault), but it is now a per-cycle observation, not a lifetime sentence. +pub struct ClaimEngine { + port: P, + hints: H, + own_payout_puzzle_hash: Bytes32, + max_fee_mojos: u64, + /// Defect C2: the per-cycle aggregate fee budget — bounds what this node will spend across ALL + /// claims in one cycle, independent of the per-claim ceiling. See [`super::config`]'s module doc + /// for the attacker-cost reasoning that makes this necessary in addition to `max_fee_mojos`. + cycle_fee_budget_mojos: u64, + dig_asset_id: Bytes32, + status: ClaimStatus, + /// Defect B2: which launcher id the per-cycle budget cut off LAST, so the next cycle gives that + /// one first crack instead of it being permanently outranked. This only breaks TIES among + /// candidates with equal accrued value (see [`Self::order_for_budget`]) — it can never let a + /// lower-accrued distributor (an attacker's dust) jump ahead of a genuinely higher-earning one, + /// because accrued value is always the primary sort key. `None` until a cycle first defers + /// someone for budget. Persisted alongside [`super::config::RewardsClaimConfig`] (via + /// [`Self::with_rotation_cursor`] / [`Self::rotation_cursor`]) so a restart does not re-arm a + /// fresh queue and starve the tail forever. + rotation_cursor: Option, + + /// F7: when `Some`, this engine persists the aggregate-fee-budget window + /// (`fee_window_start_unix`, `fee_spent_in_window_mojos`, `last_cycle_completed_at`) into + /// [`RewardsClaimConfig`] in this directory -- see [`Self::with_persisted_fee_window`]. `None` + /// keeps the engine purely in-memory, the behaviour every test before F7 relies on. + /// + /// F18: the three fields above are DELIBERATELY NOT stored on this struct. Every one of them + /// is re-read fresh from [`RewardsClaimConfig::load_from`] at the top of every + /// [`Self::run_cycle`] regardless (see F16), so caching a copy on `Self` bought nothing and + /// cost exactly the class of defect F16 fixed: a value seeded once, at construction or at the + /// last successful read, going stale the moment an operator repairs the file underneath it. + /// With no field to go stale, there is nothing left to resynchronize -- `run_cycle`'s local + /// `CycleConditions` (built fresh from `cfg`, used, and dropped before the function returns) + /// is now the ONLY place any of these three values are held in memory, mirroring exactly how + /// [`Self::run_cycle`]'s doc already describes `fee_window_poisoned`'s removal: a future + /// `self.fee_window_start_unix = ...` outside `run_cycle`/`persist_fee_window` is now an + /// `E0609` compile error (no such field), not a convention to remember. + fee_window_state_dir: Option, + /// F7: the cadence length the persisted budget window and the cadence gate are measured + /// against. Deliberately a constructor argument of [`Self::with_persisted_fee_window`], never + /// read from [`RewardsClaimConfig::cadence_seconds`] directly -- the engine has no other + /// dependency on the rest of that config, and the caller (which already loaded it) is the one + /// place that should decide what "the cadence" means. + cadence_seconds: u64, + // F16: there used to be a `fee_window_poisoned: bool` field here, set `true` by a corrupt + // load or a future-dated clock and never cleared. That is the THIRD instance of one + // mechanism -- a per-cycle condition stored as process-lifetime state (pass 3: + // `ChainSourceUnavailable` latched forever; pass 4: a cadence gate's early return left a + // stale `state` standing) -- and it is the one place where latching was actively wrong: a + // future-dated clock is SELF-HEALING (`t > now` goes false the moment real time passes it), + // so ORing it into a field that is then set permanently `true` turned a transient RTC glitch + // into a permanent refusal to claim. The fix removes the field rather than the bug: with no + // `fee_window_poisoned` field on this struct, `self.fee_window_poisoned = true` is a COMPILE + // ERROR (E0609, no such field), not a convention a future pass has to remember. See + // [`Self::run_cycle`]'s `CycleConditions` -- built fresh at the top of every cycle from `now` + // plus a freshly reloaded [`RewardsClaimConfig`], used, and dropped before the function + // returns; there is nowhere on `Self` to write it back into. +} + +impl ClaimEngine { + pub fn new( + port: P, + hints: H, + own_payout_puzzle_hash: Bytes32, + max_fee_mojos: u64, + cycle_fee_budget_mojos: u64, + dig_asset_id: Bytes32, + ) -> Self { + ClaimEngine { + port, + hints, + own_payout_puzzle_hash, + max_fee_mojos, + cycle_fee_budget_mojos, + dig_asset_id, + status: ClaimStatus::default(), + rotation_cursor: None, + fee_window_state_dir: None, + cadence_seconds: 0, + } + } + + /// Restores the per-cycle budget rotation cursor (Defect B2) from persisted state — the + /// production wiring (DIG-Network/dig_ecosystem#3268) loads it from + /// [`super::config::RewardsClaimConfig`] alongside the rest of this loop's preferences. + #[must_use] + pub fn with_rotation_cursor(mut self, cursor: Option) -> Self { + self.rotation_cursor = cursor; + self + } + + /// The current budget rotation cursor (Defect B2) — persist this after every `run_cycle` so a + /// restart resumes the rotation instead of restarting it and re-starving the same tail. + #[must_use] + pub fn rotation_cursor(&self) -> Option { + self.rotation_cursor + } + + /// F7: restores the persisted aggregate-fee-budget window and cadence clock from `dir` and + /// arms this engine to keep persisting them there after every submission and every completed + /// cycle (never batched to cycle end — see [`Self::run_cycle`]'s "F7" doc section for why). + /// + /// `cadence_seconds` is both the window length and the cadence gate's threshold: the same + /// number [`super::config::RewardsClaimConfig::cadence_seconds`] carries, passed in explicitly + /// because this engine has no other dependency on the rest of that config. + /// + /// Without this call, the engine is exactly as it was before F7: a fresh + /// [`Self::cycle_fee_budget_mojos`] and no cadence gate on every construction. That is + /// deliberately still true for a caller that has not opted in (every pre-F7 test), but it is + /// also the defect this method exists to close for production use: nothing here is wired into + /// node startup yet (`crate::rewards_claim`'s module doc, "Not yet wired into node startup"), + /// so the production wiring (#3268) is the one place expected to call this. + /// F10 (§8.6 floor): also applied here, not just in [`RewardsClaimConfig::load_from`] -- + /// this is a constructor argument, independent of whatever the config file says, and the same + /// hot-loop hazard applies to whatever caller passes it a degenerate value directly. + /// + /// F16: this no longer latches `cfg.corrupt` into a field. [`Self::run_cycle`] re-reads + /// [`RewardsClaimConfig::load_from`] fresh at the top of every cycle instead, so a file an + /// operator fixes or removes between cycles is observed on the VERY NEXT cycle, not only on + /// the next process restart -- see that method's `CycleConditions`. + /// + /// F18: this no longer seeds `fee_window_start_unix` / `fee_spent_in_window_mojos` / + /// `last_cycle_completed_at` from a construction-time read either -- there is nowhere left on + /// `Self` to seed them into. [`Self::run_cycle`] reads [`RewardsClaimConfig`] fresh at the top + /// of every cycle unconditionally (its `CycleConditions`), so a construction-time copy was + /// pure overhead: it was never trusted past the first cycle anyway once F16 landed, and now it + /// is never even taken. + #[must_use] + pub fn with_persisted_fee_window(mut self, dir: &Path, cadence_seconds: u64) -> Self { + self.fee_window_state_dir = Some(dir.to_path_buf()); + self.cadence_seconds = cadence_seconds.max(super::config::CLAIM_CADENCE_FLOOR_SECONDS); + self + } + + /// F7: read-modify-write the fee-window fields into whatever `RewardsClaimConfig` currently + /// sits on disk at [`Self::fee_window_state_dir`], leaving every other field (including + /// [`Self::rotation_cursor`], which this engine does not own writing to disk for) exactly as + /// it was read. A failed write is logged, never fatal — the same survivable-degradation + /// posture [`super::config::RewardsClaimConfig::load_from`] already uses for a read. + /// + /// # F8: never overwrites a corrupt file with defaults + /// If the file on disk has gone corrupt SINCE this engine last read it (a concurrent write, or + /// disk damage between calls), the fresh `load_from` above returns [`RewardsClaimConfig`] with + /// `corrupt: true` -- writing our in-memory fee-window fields into that value and saving it + /// would silently paper over the corruption with a value that looks clean (defaulted `enabled`, + /// a dropped `rotation_cursor`, exactly the "worse" half of the F8 finding). Refuse instead: + /// leave the corrupt file exactly as it is on disk and let the NEXT `run_cycle` observe + /// `corrupt` itself and report [`ClaimLoopState::PersistedStateCorrupt`]. + /// + /// F18: `window` is the caller's in-flight view of the three fee-window values -- this engine + /// no longer holds them itself (see the `fee_window_state_dir` doc), so every caller ( + /// [`Self::run_cycle`], [`Self::evaluate_budget_phase`], [`Self::uncommit_fee`]) threads its + /// own local [`FeeWindowState`] through instead of reading `self`. + fn persist_fee_window(&self, window: &FeeWindowState) { + let Some(dir) = &self.fee_window_state_dir else { + return; + }; + let mut cfg = RewardsClaimConfig::load_from(dir); + if cfg.corrupt { + tracing::warn!( + path = %dir.display(), + "the rewards-claim preference file is corrupt on disk; refusing to overwrite it \ + with a fee-window update" + ); + return; + } + cfg.fee_window_start_unix = window.start_unix; + cfg.fee_spent_in_window_mojos = window.spent_mojos; + cfg.last_cycle_completed_at = window.last_completed_at; + if let Err(e) = cfg.save_to(dir) { + tracing::warn!( + path = %dir.display(), + error = %e, + "the rewards-claim fee-budget window could not be persisted" + ); + } + } + + #[must_use] + pub fn status(&self) -> ClaimStatus { + self.status + } + + /// Run one cycle: discover candidates (chain + re-derived hints), evaluate each against SPEC + /// §9.3/§8.3/§12.5, then claim from the above-threshold set in DESCENDING ACCRUED-VALUE ORDER + /// (Defect B2) within the per-claim ceiling AND the per-cycle aggregate fee budget. Returns + /// every outcome, one per evaluated distributor. + pub async fn run_cycle(&mut self, now: u64) -> Vec { + // Defect A1/A4/F1/F3: EVERY per-cycle field is reset here, at the TOP, before any early + // return — a fault, a claim count or a stale distributor tally from a PAST cycle must never + // leak into this cycle's reading, including on the `ChainUnavailable` early-return paths + // below that skip the end-of-function assignment block entirely (F3: those paths used to + // leave last cycle's `distributors_claimable` / `claims_submitted_this_cycle` / + // `distributors_faulted` / `no_entry_slot_this_cycle` sitting stale under this cycle's + // freshly-stamped `last_attempt_at`). + self.status.fault_reported = false; + self.status.chain_unavailable_this_cycle = false; + self.status.payout_hash_mismatches_this_cycle = 0; + self.status.distributors_known = 0; + self.status.distributors_with_own_entry = 0; + self.status.distributors_claimable = 0; + self.status.distributors_faulted = 0; + self.status.claims_submitted_this_cycle = 0; + self.status.no_entry_slot_this_cycle = 0; + self.status.last_attempt_at = Some(now); + + // F18: this engine's own view of the persisted fee window for this cycle -- there is no + // longer a field on `Self` to hold it across cycles; it lives here, for the lifetime of + // this call, and nowhere else. See `fee_window_state_dir`'s doc. + let mut window = FeeWindowState { + start_unix: None, + spent_mojos: 0, + last_completed_at: None, + }; + + // F7: the cadence gate and the persisted budget window -- both keyed off + // `self.fee_window_state_dir`, so a caller that never opted in via + // `with_persisted_fee_window` sees no change at all (every pre-F7 test). + if let Some(dir) = self.fee_window_state_dir.clone() { + // F16: `CycleConditions` is built HERE, at the top of this cycle, from `now` plus a + // freshly reloaded `RewardsClaimConfig` -- and dropped at the end of this `if let` + // block. It is never a field on `Self`, so there is nowhere to latch it: a + // future-dated clock is self-healing by construction (`t > now` goes false the + // moment real time passes it) and is now recomputed, never remembered, every cycle. + // An actually-corrupt file (unreadable, unparsable, or F14's spend-exceeds-budget) is + // re-read from disk on every cycle too, so a file an operator fixes or removes is + // observed on the VERY NEXT cycle rather than only after a process restart -- see + // `RewardsClaimConfig::load_from`'s doc for why re-reading here is cheap and safe + // (`persist_fee_window` below already re-reads the same file for the same reason). + // + // F16/F18 (stale-read fix): the fee-window values carried here come from THIS `cfg` + // -- the freshly reloaded value -- never from a cached copy on `Self` (there is none + // left to read; F18 deleted the fields entirely). `with_persisted_fee_window` no + // longer seeds anything at construction either, so a file that was corrupt then and + // gets repaired later is judged only against what is actually on disk now. + struct CycleConditions { + corrupt: bool, + future_dated_clock: bool, + fee_window_start_unix: Option, + fee_spent_in_window_mojos: u64, + last_cycle_completed_at: Option, + } + let conditions = { + let cfg = RewardsClaimConfig::load_from(&dir); + CycleConditions { + corrupt: cfg.corrupt, + future_dated_clock: cfg.last_cycle_completed_at.is_some_and(|t| t > now) + || cfg.fee_window_start_unix.is_some_and(|t| t > now), + fee_window_start_unix: cfg.fee_window_start_unix, + fee_spent_in_window_mojos: cfg.fee_spent_in_window_mojos, + last_cycle_completed_at: cfg.last_cycle_completed_at, + } + }; + // F8/F10: a corrupt persisted file, or either persisted clock reading AFTER `now` (a + // future-dated clock is corrupt state exactly the same way a torn write is -- an + // ordinary NTP step or clock glitch would otherwise freeze the window forever, F10), + // must never be treated as a fresh start. Fail CLOSED: submit nothing, report it by + // name, and -- critically -- return BEFORE the cadence gate and the window-roll logic + // below, which would otherwise happily manufacture a brand-new zeroed window out of + // untrustworthy state. Critically, disk is left UNTOUCHED here -- absent, corrupt and + // valid are three different facts, and only a valid read below is ever adopted into + // `window`, so a corrupt cycle can never write `poisoned()`'s placeholders over a + // still-good persisted value. + if conditions.corrupt || conditions.future_dated_clock { + self.status.state = ClaimLoopState::PersistedStateCorrupt; + return Vec::new(); + } + // F16/F18: THE fix -- adopt this cycle's fresh, valid, non-future-dated read into + // `window` before the cadence gate or the window-roll logic below ever consults it. A + // file repaired since the last cycle that read a valid, non-corrupt file is now + // observed from DISK, not from whatever a cache happened to hold going in -- closing + // both the stale-read defect (a corrupt-then-cleared file no longer resumes from + // `poisoned()`'s zeros) and Finding 2b (the clocks below now come from the same fresh + // `cfg` the corrupt/future-dated check just used). + window.start_unix = conditions.fee_window_start_unix; + window.spent_mojos = conditions.fee_spent_in_window_mojos; + window.last_completed_at = conditions.last_cycle_completed_at; + // Refuse to START a cycle until the cadence has elapsed since the last one that ran + // to completion -- stops a restart loop from immediately re-running a cycle that + // already ran, independent of whether the fee window below has room left. + // + // F9: this is a DELIBERATE skip, not a fault and not silence -- name it, so it can + // never read as "healthy and idle" (a stale `state` from whatever cycle last computed + // one would otherwise stand here forever, since this path never reaches + // `compute_state` below). + if let Some(last_completed) = window.last_completed_at { + if now.saturating_sub(last_completed) < self.cadence_seconds { + self.status.state = ClaimLoopState::CadenceNotElapsed; + return Vec::new(); + } + } + // The aggregate budget is enforced against this window, never a per-`run_cycle` + // local: roll a fresh window only once the cadence has elapsed since it opened, + // otherwise keep accumulating into what is already spent in it. + let window_still_open = window + .start_unix + .is_some_and(|start| now.saturating_sub(start) < self.cadence_seconds); + if !window_still_open { + window.start_unix = Some(now); + window.spent_mojos = 0; + self.persist_fee_window(&window); + } + } + let mut spent_this_cycle_mojos = if self.fee_window_state_dir.is_some() { + window.spent_mojos + } else { + 0 + }; + let mut budget_exhausted = false; + + let mut discovery_failed = false; + let discovered = match self.port.discover_distributors().await { + Ok(v) => v, + Err(ClaimPortError::Unavailable) => { + // F1: per-cycle only — never a latch. See `ClaimStatus::chain_unavailable_this_cycle`. + self.status.chain_unavailable_this_cycle = true; + self.status.state = ClaimLoopState::ChainSourceUnavailable; + return Vec::new(); + } + Err(ClaimPortError::Other(_)) => { + // Defect A4: do NOT stamp `last_discovery_at` here — a reader relies on this + // timestamp going stale to notice a wedged discovery path. + self.status.fault_reported = true; + discovery_failed = true; + Vec::new() + } + }; + if !discovery_failed { + self.status.last_discovery_at = Some(now); + } + + let mut candidates: Vec = discovered.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 + // submit `InitiatePayout` twice against one entry slot in one cycle -- the second spend is + // invalid (counter already incremented) but the fee is paid anyway, double-charging the + // cycle budget for a single distributor. + candidates.sort_unstable(); + candidates.dedup(); + + // SPEC §13.2: a hint only ADDS a candidate; every property is re-derived from chain before + // it counts, and a hint that fails re-derivation is dropped, never trusted. + for hint in self.hints.hints().await { + if candidates.contains(&hint.launcher_id) { + continue; + } + match self.port.resolve_launch_comment(hint.launcher_id).await { + Ok(Some(_)) => candidates.push(hint.launcher_id), + Ok(None) => {} + Err(ClaimPortError::Unavailable) => {} + Err(ClaimPortError::Other(_)) => self.status.fault_reported = true, + } + } + + self.status.distributors_known = candidates.len() as u32; + let any_candidates = !candidates.is_empty(); + + let mut outcomes = Vec::new(); + let mut with_entry = 0u32; + let mut faulted = 0u32; + let mut submitted_this_cycle = 0u64; + let mut no_entry_this_cycle = 0u32; + let mut eligible: Vec = Vec::new(); + + // Phase 1: everything up to (and including) the payout-threshold check, for every + // candidate — none of this touches the per-cycle budget. Above-threshold candidates become + // `Eligible` and move to phase 2 instead of being decided here. + for launcher_id in candidates { + // Defect B: no permanent blacklist skip here — every candidate is re-evaluated every + // cycle, including one that reported `NoEntrySlot` on a prior cycle. + match self.evaluate_pre_budget(launcher_id).await { + PreBudgetResult::Fault { reason } => { + faulted += 1; + outcomes.push(ClaimOutcome::Faulted { + launcher_id, + reversed_fee_mojos: None, + reason, + }); + } + PreBudgetResult::ChainUnavailable => { + self.status.chain_unavailable_this_cycle = true; + self.status.state = ClaimLoopState::ChainSourceUnavailable; + return outcomes; + } + PreBudgetResult::Eligible { + launcher_id, + accrued_base_units, + } => { + with_entry += 1; + eligible.push(EligibleClaim { + launcher_id, + accrued_base_units, + }); + } + PreBudgetResult::Outcome(outcome, entry_seen) => { + if entry_seen { + with_entry += 1; + } + if let ClaimOutcome::NoEntrySlot { .. } = &outcome { + no_entry_this_cycle += 1; + } + outcomes.push(outcome); + } + } + } + + // Defect B1/E: every `Eligible` candidate is claimable regardless of what phase 2 later + // decides for it (submitted, ceiling-skipped or budget-skipped all count) — matching what + // `distributors_claimable` always meant here. + let claimable = u32::try_from(eligible.len()).unwrap_or(u32::MAX); + + // Phase 2: order by accrued value DESCENDING (Defect B2) — an attacker's dust distributors + // (our own entry there accrues little to nothing) always sort behind a victim's genuine + // earnings, regardless of the fee the attacker sets. The persisted rotation cursor only + // breaks TIES within an accrued-value tier, so it can never let a lower-value distributor + // displace a higher-value one; see `Self::order_for_budget`. + let ordered = self.order_for_budget(eligible); + let mut first_deferred_this_cycle: Option = None; + for claim in &ordered { + match self + .evaluate_budget_phase( + claim, + &mut spent_this_cycle_mojos, + &mut budget_exhausted, + &mut window, + ) + .await + { + BudgetPhaseResult::Fault { + reason, + reversed_fee_mojos, + } => { + faulted += 1; + outcomes.push(ClaimOutcome::Faulted { + launcher_id: claim.launcher_id, + reversed_fee_mojos, + reason, + }); + } + BudgetPhaseResult::ChainUnavailable => { + self.status.chain_unavailable_this_cycle = true; + self.status.state = ClaimLoopState::ChainSourceUnavailable; + return outcomes; + } + BudgetPhaseResult::Outcome(outcome) => { + match &outcome { + ClaimOutcome::Submitted { .. } => submitted_this_cycle += 1, + ClaimOutcome::SkippedCycleBudgetExhausted { .. } + if first_deferred_this_cycle.is_none() => + { + first_deferred_this_cycle = Some(claim.launcher_id); + } + _ => {} + } + outcomes.push(outcome); + } + } + } + // Defect B2: advance the rotation cursor to whoever the budget cut off FIRST this cycle, so + // that one gets first crack next cycle instead of the same tail being dropped every time. + if let Some(deferred) = first_deferred_this_cycle { + self.rotation_cursor = Some(deferred); + } + + // Defect A4: an all-faulted cycle (candidates existed, discovery succeeded, but every one of + // them faulted) must not stamp `last_cycle_at` either — same staleness reasoning as above. + // + // F17: this used to be `outcomes.is_empty() && self.status.fault_reported` — a predicate + // over the OUTCOME STREAM's emptiness. The authorized `ClaimOutcome::Faulted` rework then + // started pushing an outcome at every one of the five per-candidate fault sites, so + // `outcomes` is never empty when a per-candidate fault occurs and this predicate silently + // went permanently false, letting `last_cycle_at` get stamped on a cycle where every + // candidate faulted and nothing was submitted. Never test a stream for emptiness to infer + // a property of its contents — ask what actually happened instead: no submissions this + // cycle, and at least one fault reported. + let all_faulted_cycle = + any_candidates && submitted_this_cycle == 0 && self.status.fault_reported; + + self.status.distributors_with_own_entry = with_entry; + self.status.distributors_claimable = claimable; + self.status.distributors_faulted = faulted; + self.status.claims_submitted += submitted_this_cycle; + self.status.claims_submitted_this_cycle = submitted_this_cycle; + self.status.no_entry_slot_this_cycle = no_entry_this_cycle; + self.status.consecutive_faulted_cycles = if self.status.fault_reported { + self.status.consecutive_faulted_cycles + 1 + } else { + 0 + }; + if !discovery_failed && !all_faulted_cycle { + self.status.last_cycle_at = Some(now); + } + // F7: this cycle ran to completion (every early return above -- ChainUnavailable -- skips + // this line, which is exactly right: those never reached the cadence gate's definition of + // "ran" -- F9: neither does the `CadenceNotElapsed` / `PersistedStateCorrupt` early + // returns above, for the same reason: none of these ever reached the point where a cycle + // is considered to have run). Stamp and persist unconditionally, including a fault-only or + // all-faulted cycle -- an operator restarting to work around a wedged cycle must still get + // the cadence gate's protection, not a loophole that lets a fault re-arm an immediate + // retry. + if self.fee_window_state_dir.is_some() { + window.last_completed_at = Some(now); + self.persist_fee_window(&window); + } + // F1: unconditional now -- `compute_state` reads `chain_unavailable_this_cycle` (reset at + // the top of this function), never `self.state`, so the old "don't overwrite a latch" guard + // is gone along with the latch itself. + self.status.state = self.status.compute_state(); + outcomes + } + + /// Everything up to and including the payout-threshold check (SPEC §9.3, §12.5, §8.6) — none of + /// it depends on, or affects, the per-cycle budget. An above-threshold, hash-matching entry + /// becomes `Eligible` and is decided in [`Self::evaluate_budget_phase`] instead. + async fn evaluate_pre_budget(&mut self, launcher_id: Bytes32) -> PreBudgetResult { + let asset = match self.port.reserve_asset_id(launcher_id).await { + Ok(a) => a, + Err(ClaimPortError::Unavailable) => return PreBudgetResult::ChainUnavailable, + Err(ClaimPortError::Other(message)) => { + self.status.fault_reported = true; + return PreBudgetResult::Fault { + reason: bound_port_error_text(&message), + }; + } + }; + if asset != self.dig_asset_id { + // SPEC §9.3: not ours, dropped — not counted as known/claimable. + return PreBudgetResult::Outcome(ClaimOutcome::NotOurs { launcher_id }, false); + } + + // SPEC §12.5 clause 3: re-read the entry slot fresh on EVERY call — never cached. + let entry = match self + .port + .own_entry(launcher_id, self.own_payout_puzzle_hash) + .await + { + Ok(Some(e)) => e, + Ok(None) => { + return PreBudgetResult::Outcome(ClaimOutcome::NoEntrySlot { launcher_id }, false); + } + Err(ClaimPortError::Unavailable) => return PreBudgetResult::ChainUnavailable, + Err(ClaimPortError::Other(message)) => { + self.status.fault_reported = true; + return PreBudgetResult::Fault { + reason: bound_port_error_text(&message), + }; + } + }; + + if entry.payout_puzzle_hash != self.own_payout_puzzle_hash { + // Defect E: the port handed back an entry for a puzzle hash that is not this node's own. + // Submitting against it would pay someone else. Refuse -- never substitute our own hash + // and proceed. + // + // Defect B3: this is a PER-DISTRIBUTOR problem, not a cycle-wide one -- it must never + // set `fault_reported` (that pins the whole surface at `Faulted`, permanently, since the + // refusal is deliberately non-terminal and recurs every cycle). Count it instead, both + // lifetime and per-cycle, and let `ClaimableButNotClaiming` (or `Nominal`, if everything + // else claimed) surface it. + self.status.claims_refused_payout_mismatch += 1; + self.status.payout_hash_mismatches_this_cycle += 1; + return PreBudgetResult::Outcome( + ClaimOutcome::PayoutPuzzleHashMismatch { launcher_id }, + true, + ); + } + + let threshold = match self.port.payout_threshold(launcher_id).await { + Ok(t) => t, + Err(ClaimPortError::Unavailable) => return PreBudgetResult::ChainUnavailable, + Err(ClaimPortError::Other(message)) => { + self.status.fault_reported = true; + return PreBudgetResult::Fault { + reason: bound_port_error_text(&message), + }; + } + }; + + if entry.accrued_base_units < threshold { + self.status.claims_skipped_below_threshold += 1; + return PreBudgetResult::Outcome( + ClaimOutcome::SkippedBelowThreshold { + launcher_id, + accrued: entry.accrued_base_units, + threshold, + }, + true, + ); + } + + PreBudgetResult::Eligible { + launcher_id, + accrued_base_units: entry.accrued_base_units, + } + } + + /// Orders the above-threshold candidates for the budget pass (Defect B2): primarily by accrued + /// value DESCENDING, so an attacker's dust distributors — where this node's own entry accrues + /// little to nothing — always sort behind a victim's genuine earnings no matter what fee the + /// attacker sets. The persisted [`Self::rotation_cursor`] only breaks ties WITHIN an equal-value + /// tier: it rebuilds a byte-order canonical ranking of the candidates present this cycle, then + /// rotates that ranking so the cursor's own launcher id sorts first — guaranteeing a genuinely + /// tied, budget-exceeding honest tail eventually reaches the front, without ever letting a + /// lower-value candidate outrank a higher-value one. + fn order_for_budget(&self, mut eligible: Vec) -> Vec { + let mut canonical: Vec = eligible.iter().map(|c| c.launcher_id).collect(); + canonical.sort(); + let cursor_index = self + .rotation_cursor + .and_then(|cursor| canonical.iter().position(|id| *id == cursor)) + .unwrap_or(0); + let len = canonical.len(); + let rotation_key = |id: &Bytes32| -> usize { + let pos = canonical.iter().position(|x| x == id).unwrap_or(0); + if len == 0 { + 0 + } else { + (pos + len - cursor_index) % len + } + }; + eligible.sort_by(|a, b| { + b.accrued_base_units + .cmp(&a.accrued_base_units) + .then_with(|| rotation_key(&a.launcher_id).cmp(&rotation_key(&b.launcher_id))) + }); + eligible + } + + /// The fee ceiling, per-cycle budget and submission for one already-`Eligible` candidate (SPEC + /// §8.3, Defect C1/C2). The payout puzzle hash is `self.own_payout_puzzle_hash` unconditionally + /// — [`Self::evaluate_pre_budget`] already refused any entry that diverged from it. + async fn evaluate_budget_phase( + &mut self, + claim: &EligibleClaim, + spent_this_cycle_mojos: &mut u64, + budget_exhausted: &mut bool, + window: &mut FeeWindowState, + ) -> BudgetPhaseResult { + let launcher_id = claim.launcher_id; + let fee = match self.port.required_fee_mojos(launcher_id).await { + Ok(f) => f, + Err(ClaimPortError::Unavailable) => return BudgetPhaseResult::ChainUnavailable, + Err(ClaimPortError::Other(message)) => { + self.status.fault_reported = true; + return BudgetPhaseResult::Fault { + reason: bound_port_error_text(&message), + reversed_fee_mojos: None, + }; + } + }; + + if fee > self.max_fee_mojos { + self.status.claims_skipped_fee_ceiling += 1; + return BudgetPhaseResult::Outcome(ClaimOutcome::SkippedFeeAboveCeiling { + launcher_id, + fee_mojos: fee, + ceiling_mojos: self.max_fee_mojos, + }); + } + + // Defect C2: the per-claim ceiling alone does not bound what K distributors can collectively + // force this node to spend in one cycle. Once the cycle budget is gone, every remaining + // candidate is skipped the same way, not spent past it. + // + // F14: `saturating_add`, never a bare `+` -- `spent_this_cycle_mojos` is seeded from a + // persisted value (`RewardsClaimConfig::fee_spent_in_window_mojos`) on the very first + // candidate of a cycle. `config::RewardsClaimConfig::load_from` now rejects a spend + // exceeding its own budget at load time (fails closed, see F8), but this comparison must + // not ALSO be able to panic on a `u64` overflow if that guard is ever bypassed -- the + // workspace enables `overflow-checks` in release, so an unchecked add here is a live + // panic-on-corrupt-input path, not just a debug-build lint. + if *budget_exhausted + || spent_this_cycle_mojos.saturating_add(fee) > self.cycle_fee_budget_mojos + { + *budget_exhausted = true; + self.status.claims_skipped_cycle_budget += 1; + return BudgetPhaseResult::Outcome(ClaimOutcome::SkippedCycleBudgetExhausted { + launcher_id, + fee_mojos: fee, + budget_mojos: self.cycle_fee_budget_mojos, + }); + } + + // F7: write-then-spend, never spend-then-write. If persistence is armed, the fee this + // submission is about to cost is committed to disk BEFORE the chain call, not after -- + // so a crash between "we decided to spend" and the chain call returning can never leave + // an unpersisted spend that a restart would repeat. This pre-commit is deliberately + // conservative: a genuine crash mid-`await` never returns to the `match` below at all, so + // the only way to protect against THAT case is to have already written the spend before + // making the call. + if self.fee_window_state_dir.is_some() { + window.spent_mojos = window.spent_mojos.saturating_add(fee); + self.persist_fee_window(window); + } + + match self + .port + .submit_initiate_payout(launcher_id, self.own_payout_puzzle_hash, fee) + .await + { + Ok(()) => { + *spent_this_cycle_mojos += fee; + BudgetPhaseResult::Outcome(ClaimOutcome::Submitted { launcher_id }) + } + // F12: the call HAS resolved here, with a definite answer -- unlike the crash case + // above, "no" means the fee was never broadcast (`ClaimPortError::Unavailable`: never + // even reached the network; `Other(_)`: the network is reachable but the submission + // was rejected). Charging the persisted window for a fee that never left would let an + // attacker exhaust this node's per-cycle budget for free with K always-failing + // submissions, suppressing a victim's real claims for the rest of the window at zero + // cost -- reverse the pre-commit now that we know it did not consume a fee. + Err(ClaimPortError::Unavailable) => { + self.uncommit_fee(fee, window); + BudgetPhaseResult::ChainUnavailable + } + Err(ClaimPortError::Other(message)) => { + self.uncommit_fee(fee, window); + self.status.fault_reported = true; + BudgetPhaseResult::Fault { + reason: bound_port_error_text(&message), + reversed_fee_mojos: Some(fee), + } + } + } + } + + /// F12: reverses a pre-committed persisted spend once [`Self::evaluate_budget_phase`]'s + /// submission call has DEFINITELY returned without broadcasting -- see that method's "F12" + /// doc comment for why the pre-commit itself must stay conservative for a genuine crash + /// mid-call, which never reaches this method at all. + fn uncommit_fee(&mut self, fee: u64, window: &mut FeeWindowState) { + if self.fee_window_state_dir.is_some() { + window.spent_mojos = window.spent_mojos.saturating_sub(fee); + self.persist_fee_window(window); + } + } +} + +/// An above-threshold, hash-matching candidate waiting for the budget pass (Defect B2). +struct EligibleClaim { + launcher_id: Bytes32, + accrued_base_units: u64, +} + +/// F18: [`ClaimEngine::run_cycle`]'s own, call-scoped view of the three persisted fee-window +/// values (`fee_window_start_unix`, `fee_spent_in_window_mojos`, `last_cycle_completed_at`). +/// These used to be cached fields on [`ClaimEngine`] itself; they are not any more (see that +/// struct's `fee_window_state_dir` doc) -- disk, via [`RewardsClaimConfig`], is the only place +/// they persist across cycles. This type exists only to thread the in-flight values through one +/// `run_cycle` call, into [`ClaimEngine::evaluate_budget_phase`] and +/// [`ClaimEngine::uncommit_fee`], and into [`ClaimEngine::persist_fee_window`]'s write. +struct FeeWindowState { + /// See [`super::config::RewardsClaimConfig::fee_window_start_unix`]. + start_unix: Option, + /// See [`super::config::RewardsClaimConfig::fee_spent_in_window_mojos`]. + spent_mojos: u64, + /// See [`super::config::RewardsClaimConfig::last_cycle_completed_at`]. + last_completed_at: Option, +} + +/// The outcome of [`ClaimEngine::evaluate_pre_budget`]. +enum PreBudgetResult { + /// `(outcome, entry_slot_was_present)`. + Outcome(ClaimOutcome, bool), + /// Above threshold, hash matches — proceeds to [`ClaimEngine::evaluate_budget_phase`]. + Eligible { + launcher_id: Bytes32, + accrued_base_units: u64, + }, + /// A chain read (`reserve_asset_id`, `own_entry` or `payout_threshold`) returned + /// `ClaimPortError::Other`. None of these ever reads a fee, so [`ClaimOutcome::Faulted`] built + /// from this is always `reversed_fee_mojos: None`. + Fault { + reason: String, + }, + ChainUnavailable, +} + +/// The outcome of [`ClaimEngine::evaluate_budget_phase`]. +enum BudgetPhaseResult { + Outcome(ClaimOutcome), + /// A chain call (`required_fee_mojos` or `submit_initiate_payout` itself) returned + /// `ClaimPortError::Other`. `reversed_fee_mojos` is `Some` only for the latter, where a fee was + /// already pre-committed and [`ClaimEngine::uncommit_fee`] has already reversed it. + Fault { + reason: String, + reversed_fee_mojos: Option, + }, + ChainUnavailable, +} + +/// Bounds a chain port's error text before it is carried into [`ClaimOutcome::Faulted`] or logged — +/// it originates from a chain port and is therefore attacker-adjacent, the same 200-char discipline +/// `service::summarize_stderr` applies to a spawned tool's own stderr. +fn bound_port_error_text(message: &str) -> String { + message.chars().take(200).collect() +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Mutex; + + use async_trait::async_trait; + + use super::*; + use crate::rewards_claim::hints::{DistributorHint, NoHintSource}; + use crate::rewards_claim::parser::parse_launch_comment; + use crate::rewards_claim::types::DiscoveredDistributor; + + const DIG_ASSET_ID: Bytes32 = Bytes32::new([9u8; 32]); + const OUR_PAYOUT_PUZZLE_HASH: Bytes32 = Bytes32::new([1u8; 32]); + const FEE_CEILING: u64 = 1_000_000_000; + const CYCLE_BUDGET: u64 = 1_000_000_000; + + #[derive(Clone)] + struct FakeDistributor { + launcher_id: Bytes32, + store_id: Bytes32, + root: Bytes32, + reserve_asset_id: Bytes32, + payout_threshold: u64, + entry: Option, + fee_mojos: u64, + } + + /// A full in-memory fake standing in for the real chain adapter (see the module doc's "chain + /// seam" section) — the ONLY thing #3249 landing changes is which struct implements this trait. + struct FakeChainPort { + distributors: Mutex>, + submitted: Mutex>, + own_entry_reads: Mutex, + /// F12: launcher ids whose `submit_initiate_payout` must return + /// `Err(ClaimPortError::Other(_))` -- simulates a submission that definitely never + /// broadcast. + fail_submit_for: Mutex>, + /// F17: launcher ids whose `reserve_asset_id` must return `Err(ClaimPortError::Other(_))` + /// -- simulates a per-candidate chain-read fault reached during discovery success, the + /// scenario `repeated_discovery_faults_never_read_as_nominal` does NOT cover (that test + /// fails discovery itself, a different and already-correct path). + fail_reserve_asset_for: Mutex>, + /// F15: when set, `submit_initiate_payout` snapshots the persisted spend at this + /// directory into `submit_snapshots` BEFORE returning -- proving the write already + /// landed on disk before the chain call resolves, not just before `run_cycle` returns. + submit_snapshot_dir: Mutex>, + submit_snapshots: Mutex>, + } + + impl FakeChainPort { + fn new(distributors: Vec) -> Self { + FakeChainPort { + distributors: Mutex::new( + distributors + .into_iter() + .map(|d| (d.launcher_id, d)) + .collect(), + ), + submitted: Mutex::new(Vec::new()), + own_entry_reads: Mutex::new(0), + fail_submit_for: Mutex::new(std::collections::HashSet::new()), + fail_reserve_asset_for: Mutex::new(std::collections::HashSet::new()), + submit_snapshot_dir: Mutex::new(None), + submit_snapshots: Mutex::new(Vec::new()), + } + } + + /// F12: makes `submit_initiate_payout` for `id` return `Err(Other(_))` instead of `Ok`. + fn fail_submit_for(&self, id: Bytes32) { + self.fail_submit_for.lock().unwrap().insert(id); + } + + /// F17: makes `reserve_asset_id` for `id` return `Err(Other(_))` instead of `Ok` -- `id` + /// still appears in `discover_distributors`' output (discovery itself succeeds), so this + /// simulates a per-candidate fault reached AFTER discovery, not a discovery failure. + fn fail_reserve_asset_for(&self, id: Bytes32) { + self.fail_reserve_asset_for.lock().unwrap().insert(id); + } + + /// F15: arms the pre-submit snapshot hook against `dir`. + fn arm_submit_snapshot(&self, dir: std::path::PathBuf) { + *self.submit_snapshot_dir.lock().unwrap() = Some(dir); + } + } + + #[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 resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + Ok(self + .distributors + .lock() + .unwrap() + .get(&launcher_id) + .map(|d| DiscoveredDistributor { + launcher_id: d.launcher_id, + store_id: d.store_id, + root: d.root, + })) + } + + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result { + if self + .fail_reserve_asset_for + .lock() + .unwrap() + .contains(&launcher_id) + { + return Err(ClaimPortError::Other( + "simulated reserve_asset_id fault".into(), + )); + } + self.distributors + .lock() + .unwrap() + .get(&launcher_id) + .map(|d| d.reserve_asset_id) + .ok_or(ClaimPortError::Other("unknown distributor".into())) + } + + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result { + self.distributors + .lock() + .unwrap() + .get(&launcher_id) + .map(|d| d.payout_threshold) + .ok_or(ClaimPortError::Other("unknown distributor".into())) + } + + async fn own_entry( + &self, + launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + *self.own_entry_reads.lock().unwrap() += 1; + self.distributors + .lock() + .unwrap() + .get(&launcher_id) + .map(|d| d.entry) + .ok_or(ClaimPortError::Other("unknown distributor".into())) + } + + async fn required_fee_mojos(&self, launcher_id: Bytes32) -> Result { + self.distributors + .lock() + .unwrap() + .get(&launcher_id) + .map(|d| d.fee_mojos) + .ok_or(ClaimPortError::Other("unknown distributor".into())) + } + + async fn submit_initiate_payout( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + if let Some(dir) = self.submit_snapshot_dir.lock().unwrap().clone() { + let snapshot = RewardsClaimConfig::load_from(&dir).fee_spent_in_window_mojos; + self.submit_snapshots.lock().unwrap().push(snapshot); + } + if self.fail_submit_for.lock().unwrap().contains(&launcher_id) { + return Err(ClaimPortError::Other("simulated submission failure".into())); + } + self.submitted + .lock() + .unwrap() + .push((launcher_id, payout_puzzle_hash, fee_mojos)); + Ok(()) + } + } + + fn one_distributor( + entry: Option, + payout_threshold: u64, + fee_mojos: u64, + ) -> FakeDistributor { + FakeDistributor { + launcher_id: Bytes32::new([2u8; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold, + entry, + fee_mojos, + } + } + + fn engine(port: FakeChainPort) -> ClaimEngine { + ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + } + + /// ACCEPTANCE 1 — the anti-green test. A loop that runs and claims nothing MUST fail this. + #[tokio::test] + async fn one_tick_submits_exactly_one_claim_for_an_above_threshold_entry() { + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let launcher_id = d.launcher_id; + let port = FakeChainPort::new(vec![d]); + let mut e = engine(port); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!(outcomes, vec![ClaimOutcome::Submitted { launcher_id }]); + assert_eq!(e.status().claims_submitted, 1); + assert_eq!(e.port.submitted.lock().unwrap().len(), 1); + let (submitted_launcher, submitted_ppz, _fee) = e.port.submitted.lock().unwrap()[0]; + assert_eq!(submitted_launcher, launcher_id); + assert_eq!(submitted_ppz, OUR_PAYOUT_PUZZLE_HASH); + } + + /// ACCEPTANCE 3 — below threshold is skipped, never an error, never a spend. + #[tokio::test] + async fn below_threshold_is_skipped_not_failed_and_spends_nothing() { + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 500, + }), + 1_000, + 10, + ); + let launcher_id = d.launcher_id; + let port = FakeChainPort::new(vec![d]); + let mut e = engine(port); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::SkippedBelowThreshold { + launcher_id, + accrued: 500, + threshold: 1_000, + }] + ); + assert_eq!(e.status().claims_submitted, 0); + assert_eq!(e.status().claims_skipped_below_threshold, 1); + assert!(e.port.submitted.lock().unwrap().is_empty()); + } + + /// ACCEPTANCE 4 — the threshold is read from chain, not hardcoded. + #[tokio::test] + async fn threshold_other_than_1000_is_honoured() { + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 2_500, + }), + 5_000, + 10, + ); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::SkippedBelowThreshold { + launcher_id, + accrued: 2_500, + threshold: 5_000, + }] + ); + } + + /// ACCEPTANCE 5 — a required fee above the ceiling is skipped, zero submissions. + #[tokio::test] + async fn fee_above_ceiling_is_skipped() { + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + FEE_CEILING + 1, + ); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::SkippedFeeAboveCeiling { + launcher_id, + fee_mojos: FEE_CEILING + 1, + ceiling_mojos: FEE_CEILING, + }] + ); + assert_eq!(e.status().claims_submitted, 0); + assert!(e.port.submitted.lock().unwrap().is_empty()); + } + + /// Defect B regression: `NoEntrySlot` is non-error and reports neither a chain fault nor a lost + /// payment, but it is NO LONGER a permanent blacklist — the second tick re-checks the same + /// distributor (SPEC §12.5 clause 3: re-read fresh before every claim, never cache). + #[tokio::test] + async fn no_entry_slot_is_non_terminal_and_re_checked_every_cycle() { + let d = one_distributor(None, 1_000, 10); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + + let first = e.run_cycle(1_000).await; + assert_eq!(first, vec![ClaimOutcome::NoEntrySlot { launcher_id }]); + assert_eq!(e.status().no_entry_slot_this_cycle, 1); + assert!(!e.status().fault_reported, "no chain fault reported"); + assert_eq!(e.status().claims_submitted, 0, "no lost payment claimed"); + + let reads_after_first = *e.port.own_entry_reads.lock().unwrap(); + let second = e.run_cycle(2_000).await; + assert_eq!( + second, + vec![ClaimOutcome::NoEntrySlot { launcher_id }], + "still no entry, so still reported -- but re-evaluated, not silently skipped" + ); + assert_eq!( + *e.port.own_entry_reads.lock().unwrap(), + reads_after_first + 1, + "the second tick re-reads the entry slot rather than trusting a cached absence" + ); + } + + /// Defect B — the fix's whole point: SPEC §12.5 clause 2's re-entry path. A distributor with no + /// entry slot on cycle 1 (never admitted yet, or evicted) that gains one before cycle 2 (legit + /// re-admission, or a discovery-vs-`AddEntry` race resolving) must produce a claim on cycle 2 — + /// the old process-lifetime blacklist made this permanently unreachable. + #[tokio::test] + async fn no_entry_slot_then_re_admitted_produces_a_claim_on_the_later_cycle() { + let d = one_distributor(None, 1_000, 10); + let launcher_id = d.launcher_id; + let port = FakeChainPort::new(vec![d]); + let mut e = engine(port); + + let first = e.run_cycle(1_000).await; + assert_eq!(first, vec![ClaimOutcome::NoEntrySlot { launcher_id }]); + + // The distributor admits our entry between cycle 1 and cycle 2. + e.port + .distributors + .lock() + .unwrap() + .get_mut(&launcher_id) + .unwrap() + .entry = Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }); + + let second = e.run_cycle(2_000).await; + assert_eq!(second, vec![ClaimOutcome::Submitted { launcher_id }]); + assert_eq!(e.status().claims_submitted, 1); + } + + /// ACCEPTANCE 7 — two consecutive ticks perform two fresh entry-slot reads; no cached slot. + #[tokio::test] + async fn consecutive_ticks_re_read_the_entry_slot_fresh() { + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 500, + }), + 1_000, + 10, + ); + let mut e = engine(FakeChainPort::new(vec![d])); + + e.run_cycle(1_000).await; + e.run_cycle(2_000).await; + + assert_eq!(*e.port.own_entry_reads.lock().unwrap(), 2); + } + + /// ACCEPTANCE 10 — SPEC §9.3: a distributor whose reserve asset is not DIG_ASSET_ID is dropped. + #[tokio::test] + async fn non_dig_reserve_asset_distributor_is_dropped() { + let mut d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + d.reserve_asset_id = Bytes32::new([0xFFu8; 32]); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!(outcomes, vec![ClaimOutcome::NotOurs { launcher_id }]); + assert_eq!(e.status().claims_submitted, 0); + assert!(e.port.submitted.lock().unwrap().is_empty()); + } + + /// ACCEPTANCE 11a/11c — a hint ADDS a candidate the chain sweep did not already return, and + /// `NoHintSource` changes no outcome versus the chain-only path (every other test here uses + /// `NoHintSource` already; this test is the direct A/B). + #[tokio::test] + async fn a_hint_adds_a_candidate_the_chain_sweep_alone_would_miss() { + struct OneHint(Bytes32); + #[async_trait] + impl DistributorHintSource for OneHint { + async fn hints(&self) -> Vec { + vec![DistributorHint { + launcher_id: self.0, + }] + } + } + + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let launcher_id = d.launcher_id; + + // Chain-only sweep never returns this distributor -- only resolve_launch_comment does, + // simulating "known to exist on chain but not enumerated by the discovery sweep yet". + struct HintOnlyPort(FakeChainPort); + #[async_trait] + impl ClaimChainPort for HintOnlyPort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + Ok(Vec::new()) + } + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + self.0.resolve_launch_comment(launcher_id).await + } + async fn reserve_asset_id(&self, l: Bytes32) -> Result { + self.0.reserve_asset_id(l).await + } + async fn payout_threshold(&self, l: Bytes32) -> Result { + self.0.payout_threshold(l).await + } + async fn own_entry( + &self, + l: Bytes32, + p: Bytes32, + ) -> Result, ClaimPortError> { + self.0.own_entry(l, p).await + } + async fn required_fee_mojos(&self, l: Bytes32) -> Result { + self.0.required_fee_mojos(l).await + } + async fn submit_initiate_payout( + &self, + l: Bytes32, + p: Bytes32, + f: u64, + ) -> Result<(), ClaimPortError> { + self.0.submit_initiate_payout(l, p, f).await + } + } + + let port = HintOnlyPort(FakeChainPort::new(vec![d])); + let mut e = ClaimEngine::new( + port, + OneHint(launcher_id), + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + assert_eq!(outcomes, vec![ClaimOutcome::Submitted { launcher_id }]); + } + + /// ACCEPTANCE 11b — a hint whose chain re-derivation fails (resolve_launch_comment -> None) is + /// dropped, never becomes a candidate, never a claim's authority. + #[tokio::test] + async fn a_hint_that_fails_chain_rederivation_is_dropped() { + struct BogusHint; + #[async_trait] + impl DistributorHintSource for BogusHint { + async fn hints(&self) -> Vec { + vec![DistributorHint { + launcher_id: Bytes32::new([0xEEu8; 32]), + }] + } + } + + let port = FakeChainPort::new(Vec::new()); + let mut e = ClaimEngine::new( + port, + BogusHint, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + assert!(outcomes.is_empty()); + assert_eq!(e.status().distributors_known, 0); + } + + /// A port whose discovery call always returns a real (non-`Unavailable`) chain fault, every + /// cycle -- the failure Defect A1 describes. + struct AlwaysFaultingDiscoveryPort; + #[async_trait] + impl ClaimChainPort for AlwaysFaultingDiscoveryPort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + Err(ClaimPortError::Other("simulated chain fault".into())) + } + async fn resolve_launch_comment( + &self, + _launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + Ok(None) + } + async fn reserve_asset_id(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Other("unreachable".into())) + } + async fn payout_threshold(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Other("unreachable".into())) + } + async fn own_entry( + &self, + _launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + Err(ClaimPortError::Other("unreachable".into())) + } + async fn required_fee_mojos(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Other("unreachable".into())) + } + async fn submit_initiate_payout( + &self, + _launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + _fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + Err(ClaimPortError::Other("unreachable".into())) + } + } + + /// Defect A1/A2 regression -- THE anti-green test for this defect: a port that errors on + /// discovery every cycle must NEVER read `Nominal`. Before the fix, `fault_reported` had no + /// fault-bearing state to fall through to and this laundered into `Nominal` forever. + #[tokio::test] + async fn repeated_discovery_faults_never_read_as_nominal() { + let mut e = ClaimEngine::new( + AlwaysFaultingDiscoveryPort, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + for cycle in 1..=3u32 { + e.run_cycle(u64::from(cycle) * 1_000).await; + assert_ne!( + e.status().state, + ClaimLoopState::Nominal, + "cycle {cycle}: a reported fault must never read as Nominal" + ); + assert_eq!( + e.status().state, + ClaimLoopState::Faulted { cycles: cycle }, + "cycle {cycle}: consecutive fault count must track the streak" + ); + } + } + + /// Finding 2 regression: discovery SUCCEEDS (unlike + /// `repeated_discovery_faults_never_read_as_nominal`, which fails discovery itself -- a + /// different and already-correct path), every candidate faults on a per-candidate chain read, + /// and `last_cycle_at` must NOT be stamped. Must go red with `all_faulted_cycle` restored to + /// its old `outcomes.is_empty() && self.status.fault_reported` proxy -- a `ClaimOutcome::Faulted` + /// IS an outcome, so `outcomes` is never empty here and the old proxy silently stamped + /// `last_cycle_at` on a cycle where nothing was actually claimed. + #[tokio::test] + async fn all_candidates_faulted_does_not_stamp_last_cycle_at() { + let launcher_id = Bytes32::new([2u8; 32]); + let port = FakeChainPort::new(vec![one_distributor(None, 1_000, 10)]); + port.fail_reserve_asset_for(launcher_id); + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::Faulted { + launcher_id, + reversed_fee_mojos: None, + reason: "simulated reserve_asset_id fault".to_string(), + }], + "discovery succeeded (the candidate was found) but its only chain read faulted" + ); + assert_eq!(e.status().claims_submitted_this_cycle, 0); + assert_eq!( + e.status().last_cycle_at, + None, + "an all-faulted cycle (candidates existed, discovery succeeded, nothing submitted) \ + must not stamp last_cycle_at -- never infer 'nothing happened' from outcomes being \ + empty, because a Faulted outcome is still an outcome" + ); + } + + /// Defect A4 regression: a failed discovery must leave `last_discovery_at` unchanged (a reader + /// depends on that timestamp going stale to notice a wedged discovery path). + #[tokio::test] + async fn failed_discovery_leaves_last_discovery_at_unchanged() { + let mut e = ClaimEngine::new( + AlwaysFaultingDiscoveryPort, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + e.run_cycle(1_000).await; + assert_eq!(e.status().last_discovery_at, None); + e.run_cycle(2_000).await; + assert_eq!( + e.status().last_discovery_at, + None, + "still unchanged after a second failed discovery" + ); + assert_eq!( + e.status().last_attempt_at, + Some(2_000), + "last_attempt_at still proves the loop is alive" + ); + } + + /// Defect C2 regression: K distributors each individually under the per-claim ceiling must NOT + /// collectively spend past the per-cycle aggregate budget. + #[tokio::test] + async fn distributors_each_under_ceiling_do_not_collectively_exceed_the_cycle_budget() { + const PER_CLAIM_FEE: u64 = 10; + const BUDGET: u64 = 25; // only 2 of 4 distributors can be paid out of this budget + let distributors: Vec = (0..4u8) + .map(|i| FakeDistributor { + launcher_id: Bytes32::new([i + 10; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + fee_mojos: PER_CLAIM_FEE, + }) + .collect(); + let mut e = ClaimEngine::new( + FakeChainPort::new(distributors), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, // each individual fee (10) is far under the per-claim ceiling + BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + + let submitted = outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::Submitted { .. })) + .count(); + let budget_skipped = outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::SkippedCycleBudgetExhausted { .. })) + .count(); + assert_eq!( + submitted, 2, + "only 2 claims fit inside the 25-mojo budget at 10 each" + ); + assert_eq!( + budget_skipped, 2, + "the remaining 2 are skipped, not spent past the budget" + ); + assert_eq!(e.status().claims_submitted, 2); + assert_eq!(e.status().claims_skipped_cycle_budget, 2); + } + + /// **Defect B2 (blocking) -- the anti-suppression test.** Ten attacker-funded dust distributors + /// (our own entry there accrues almost nothing, but each demands a fee big enough that ONE of + /// them alone exhausts the cycle budget) must NOT prevent a genuinely high-accrual distributor + /// from being claimed in the same cycle, no matter what order the chain sweep happens to return + /// them in (`FakeChainPort` stores candidates in a `HashMap`, so discovery order here is exactly + /// as arbitrary as a real chain sweep's). + #[tokio::test] + async fn dust_distributors_do_not_suppress_a_high_accrual_claim_in_the_same_cycle() { + const DUST_FEE: u64 = 100; + let victim = FakeDistributor { + launcher_id: Bytes32::new([0xFFu8; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 100_000, // genuinely high accrual + }), + fee_mojos: DUST_FEE, + }; + let victim_id = victim.launcher_id; + let mut distributors = vec![victim]; + for i in 0..10u8 { + distributors.push(FakeDistributor { + launcher_id: Bytes32::new([i; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 1_100, // just above threshold -- dust, not zero + }), + fee_mojos: DUST_FEE, // funder-controlled: attacker sets this at will + }); + } + // The budget fits exactly ONE distributor's fee -- first-come order would let any dust + // distributor that sorts ahead of the victim consume it entirely. + let mut e = ClaimEngine::new( + FakeChainPort::new(distributors), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + DUST_FEE, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::Submitted { launcher_id } if *launcher_id == victim_id)) + .count(), + 1, + "the high-accrual victim must be the one claimed, regardless of discovery order" + ); + assert_eq!( + e.status().claims_submitted, + 1, + "the budget fits exactly one claim" + ); + assert_eq!( + e.status().claims_skipped_cycle_budget, + 10, + "every dust distributor is deferred, never the victim" + ); + } + + /// **Defect B2 (blocking) -- the fairness half.** A persisted rotation cursor must advance + /// across cycles so a genuinely tied, budget-exceeding honest tail is not the same distributor + /// dropped every cycle forever. + #[tokio::test] + async fn the_rotation_cursor_advances_so_a_tied_starved_tail_is_eventually_served() { + const FEE: u64 = 10; + const BUDGET: u64 = 20; // only 2 of 3 equal-value distributors fit per cycle + let distributors: Vec = (0..3u8) + .map(|i| FakeDistributor { + launcher_id: Bytes32::new([i + 1; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, // EQUAL for all three -- a genuine tie + }), + fee_mojos: FEE, + }) + .collect(); + let mut e = ClaimEngine::new( + FakeChainPort::new(distributors), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + BUDGET, + DIG_ASSET_ID, + ); + + let mut deferred_across_cycles: std::collections::HashSet = + std::collections::HashSet::new(); + for cycle in 1..=3u32 { + let outcomes = e.run_cycle(u64::from(cycle) * 1_000).await; + for outcome in &outcomes { + if let ClaimOutcome::SkippedCycleBudgetExhausted { launcher_id, .. } = outcome { + deferred_across_cycles.insert(*launcher_id); + } + } + } + + assert!( + deferred_across_cycles.len() > 1, + "the same distributor must not be the only one ever deferred across cycles -- got {deferred_across_cycles:?}" + ); + assert!( + e.rotation_cursor().is_some(), + "the cursor must have advanced at least once" + ); + } + + /// Defect B2: `with_rotation_cursor` / `rotation_cursor` are the seam a persisted config uses + /// to survive a restart -- proves the getter reflects what the setter installed before any + /// cycle has run. + #[test] + fn rotation_cursor_round_trips_through_the_engine_accessors() { + let cursor = Bytes32::new([0x42u8; 32]); + let e = engine(FakeChainPort::new(Vec::new())).with_rotation_cursor(Some(cursor)); + assert_eq!(e.rotation_cursor(), Some(cursor)); + } + + /// F7: a distributor whose required fee alone equals the whole cycle budget, so ONE submitted + /// claim exhausts it completely -- makes every F7 test below unambiguous about whether a + /// SECOND full budget was granted. + fn budget_consuming_distributor(launcher_id: Bytes32, fee_mojos: u64) -> FakeDistributor { + FakeDistributor { + launcher_id, + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + fee_mojos, + } + } + + /// **F7 (blocking) — the restart reproducer.** Before the fix, a fresh [`ClaimEngine`] has an + /// empty in-memory budget and cadence clock no matter what a PRIOR process already spent, so + /// this must FAIL before the fix: the second engine submits its claim too, spending a second + /// full [`CYCLE_BUDGET`] inside the same window a prior process already exhausted. + #[tokio::test] + async fn f7_restart_reproducer_a_second_engine_from_the_same_directory_refuses_to_overspend() { + const CYCLE_BUDGET: u64 = 1_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f7-reproducer-") + .tempdir() + .expect("a scratch dir"); + + let mut first = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor( + Bytes32::new([0x10u8; 32]), + CYCLE_BUDGET, + )]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let first_outcomes = first.run_cycle(1_000).await; + assert_eq!( + first_outcomes, + vec![ClaimOutcome::Submitted { + launcher_id: Bytes32::new([0x10u8; 32]) + }], + "the first cycle must actually spend the whole budget, or this reproduces nothing" + ); + drop(first); + + // A NEW process, seconds later — nowhere near CADENCE_SECONDS away — reconstructs the + // engine from the SAME directory and faces a DIFFERENT distributor that also costs the + // whole budget. + let mut second = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor( + Bytes32::new([0x20u8; 32]), + CYCLE_BUDGET, + )]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let second_outcomes = second.run_cycle(1_010).await; + + let second_submitted = second_outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::Submitted { .. })) + .count(); + assert_eq!( + second_submitted, 0, + "a restart inside the same budget window must not be able to spend a second full \ + cycle budget -- a process restart is not a fresh peer" + ); + } + + /// F7: ten simulated restarts inside ONE window must not collectively exceed the aggregate + /// budget, however many of those restarts each try to spend a full budget's worth. + #[tokio::test] + async fn f7_ten_restarts_inside_one_window_never_collectively_exceed_the_budget() { + const CYCLE_BUDGET: u64 = 1_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f7-ten-restarts-") + .tempdir() + .expect("a scratch dir"); + + let mut total_submitted_mojos = 0u64; + for i in 0..10u8 { + let launcher_id = Bytes32::new([0x30 + i; 32]); + let mut e = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor( + launcher_id, + CYCLE_BUDGET, + )]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let outcomes = e.run_cycle(1_000 + u64::from(i)).await; + if outcomes + .iter() + .any(|o| matches!(o, ClaimOutcome::Submitted { .. })) + { + total_submitted_mojos += CYCLE_BUDGET; + } + } + + assert!( + total_submitted_mojos <= CYCLE_BUDGET, + "ten restarts inside one window spent {total_submitted_mojos} mojos, over the \ + {CYCLE_BUDGET}-mojo budget" + ); + } + + /// F7: once the window has genuinely elapsed, a restart MUST be allowed a fresh budget — the + /// fix bounds a crash-restart loop, it does not starve a node that legitimately restarts + /// between cadence periods. + #[tokio::test] + async fn f7_a_restart_after_the_window_elapsed_gets_a_fresh_budget() { + const CYCLE_BUDGET: u64 = 1_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f7-window-elapsed-") + .tempdir() + .expect("a scratch dir"); + + let mut first = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor( + Bytes32::new([0x40u8; 32]), + CYCLE_BUDGET, + )]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let first_outcomes = first.run_cycle(1_000).await; + assert_eq!( + first_outcomes, + vec![ClaimOutcome::Submitted { + launcher_id: Bytes32::new([0x40u8; 32]) + }] + ); + drop(first); + + // Well past both the window AND the cadence gate. + let later = 1_000 + CADENCE_SECONDS + 1; + let mut second = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor( + Bytes32::new([0x50u8; 32]), + CYCLE_BUDGET, + )]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let second_outcomes = second.run_cycle(later).await; + + assert_eq!( + second_outcomes, + vec![ClaimOutcome::Submitted { + launcher_id: Bytes32::new([0x50u8; 32]) + }], + "a restart after the window elapsed must be granted a fresh budget" + ); + } + + /// F7: a restart immediately after a completed cycle must not even START another cycle before + /// the cadence elapses — independent of the fee-window check, this stops a fast restart loop + /// from re-running full cycles (with their own chain reads) back to back. + #[tokio::test] + async fn f7_a_restart_immediately_after_a_completed_cycle_does_not_run_another() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f7-cadence-gate-") + .tempdir() + .expect("a scratch dir"); + let launcher_id = Bytes32::new([0x60u8; 32]); + + let mut first = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let first_outcomes = first.run_cycle(1_000).await; + assert_eq!( + first_outcomes, + vec![ClaimOutcome::Submitted { launcher_id }], + "the first cycle must complete normally, or this proves nothing about a restart" + ); + drop(first); + + let mut second = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let second_outcomes = second.run_cycle(1_050).await; + + assert_eq!( + second_outcomes, + Vec::new(), + "a restart 50 seconds after a completed cycle must not run another before the \ + 86,400-second cadence elapses" + ); + } + + /// F7: a crash after a submission but before the cycle finishes must still leave that spend + /// recorded on disk — proves the write happens PER SUBMISSION, never batched to cycle end. + /// Simulated by reading the persisted config directly after a cycle that submits more than one + /// claim, rather than waiting for `run_cycle` to return. + #[tokio::test] + async fn f7_a_spend_is_persisted_per_submission_not_batched_to_cycle_end() { + const CYCLE_BUDGET: u64 = 30; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f7-per-submission-") + .tempdir() + .expect("a scratch dir"); + + let distributors = vec![ + budget_consuming_distributor(Bytes32::new([0x70u8; 32]), 10), + budget_consuming_distributor(Bytes32::new([0x71u8; 32]), 10), + ]; + let mut e = ClaimEngine::new( + FakeChainPort::new(distributors), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + let outcomes = e.run_cycle(1_000).await; + let submitted: u64 = outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::Submitted { .. })) + .count() as u64 + * 10; + assert_eq!(submitted, 20, "both distributors must have been submitted"); + + // Read the file directly rather than through `e` -- proves the write already landed on + // disk, not just in the engine's own in-memory mirror. + let persisted = RewardsClaimConfig::load_from(dir.path()); + assert_eq!( + persisted.fee_spent_in_window_mojos, 20, + "each submission must persist its own spend immediately, not wait for cycle end" + ); + } + + /// F15: `f7_a_spend_is_persisted_per_submission_not_batched_to_cycle_end` above only reads the + /// file AFTER `run_cycle` returns, which a cycle-end-batched persist would also satisfy -- + /// exactly the vacuous-test class F11 named. This test snapshots the file DURING each + /// submission's own chain call, before that call (or `run_cycle`) has returned: the second + /// distributor's snapshot can only show the first distributor's 10-mojo spend already on disk + /// if persistence genuinely happens per submission. Must go red with the pre-commit in + /// `evaluate_budget_phase` moved to after the `.await` (or to cycle end). + #[tokio::test] + async fn f15_a_spend_is_visible_on_disk_before_the_submission_call_resolves() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f15-") + .tempdir() + .expect("a scratch dir"); + + let distributors = vec![ + budget_consuming_distributor(Bytes32::new([0x72u8; 32]), 10), + budget_consuming_distributor(Bytes32::new([0x73u8; 32]), 10), + ]; + let port = FakeChainPort::new(distributors); + port.arm_submit_snapshot(dir.path().to_path_buf()); + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + let outcomes = e.run_cycle(1_000).await; + assert_eq!( + outcomes + .iter() + .filter(|o| matches!(o, ClaimOutcome::Submitted { .. })) + .count(), + 2, + "both distributors must have been submitted, or this proves nothing" + ); + + let snapshots = e.port.submit_snapshots.lock().unwrap().clone(); + assert_eq!( + snapshots, + vec![10, 20], + "the first submission's own snapshot must already see ITS OWN pre-committed 10-mojo \ + spend (write-then-spend), and the second must see BOTH -- a cycle-end batch would \ + show 0 for both, since neither had landed on disk yet when these calls ran" + ); + } + + /// F11: the two restart tests above (`f7_restart_reproducer_...` and `f7_ten_restarts_...`) + /// advance the clock by ≤10s, so the CADENCE GATE alone makes them pass -- delete the window + /// enforcement entirely and they still go green. This test satisfies the gate (no prior + /// completed cycle at all, so it never even runs) and instead binds the window accumulator + /// directly: a cycle the gate permits, entering a window that already carries a full persisted + /// spend, must still be refused by the budget. Must go red with only the window-seeding line + /// in `with_persisted_fee_window` (`self.fee_spent_in_window_mojos = cfg.fee_spent_in_window_ + /// mojos`) reverted to always start at `0`. + #[tokio::test] + async fn f11_a_gate_permitted_cycle_is_still_refused_by_an_already_full_persisted_window() { + const CYCLE_BUDGET: u64 = 1_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f11-window-binds-") + .tempdir() + .expect("a scratch dir"); + + // Simulate a crash mid-window: a prior process opened this window and spent it in full, + // but never recorded a completed cycle (a real crash never gets that far either). + let seeded = RewardsClaimConfig { + fee_window_start_unix: Some(1_000), + fee_spent_in_window_mojos: CYCLE_BUDGET, + last_cycle_completed_at: None, // no completed cycle on record -- the gate is satisfied + ..RewardsClaimConfig::default() + }; + seeded.save_to(dir.path()).expect("seed the window"); + + let launcher_id = Bytes32::new([0x74u8; 32]); + let mut e = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + // Still well inside the seeded window (`1_000 + 5 - 1_000 = 5 < CADENCE_SECONDS`), so the + // gate cannot be what refuses this -- only the window accumulator can. + let outcomes = e.run_cycle(1_005).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::SkippedCycleBudgetExhausted { + launcher_id, + fee_mojos: 10, + budget_mojos: CYCLE_BUDGET, + }], + "a window seeded as already fully spent must refuse every claim, even though the \ + cadence gate itself was satisfied" + ); + } + + /// F9 regression: a deliberately-skipped cycle must report its OWN named condition, never a + /// stale reading left over from the last cycle that actually ran. Must go red with only the + /// `self.status.state = ClaimLoopState::CadenceNotElapsed;` assignment on the cadence-gate + /// early return removed. + #[tokio::test] + async fn f9_a_cadence_skipped_cycle_reports_its_own_state_not_a_stale_one() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f9-cadence-state-") + .tempdir() + .expect("a scratch dir"); + let launcher_id = Bytes32::new([0x75u8; 32]); + + let mut first = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let first_outcomes = first.run_cycle(1_000).await; + assert_eq!( + first_outcomes, + vec![ClaimOutcome::Submitted { launcher_id }], + "the first cycle must actually run and claim, or its state proves nothing to skip past" + ); + assert_eq!( + first.status().state, + ClaimLoopState::Nominal, + "sanity: the first cycle's OWN state must be something other than CadenceNotElapsed" + ); + drop(first); + + let mut second = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + let second_outcomes = second.run_cycle(1_010).await; + + assert_eq!( + second_outcomes, + Vec::new(), + "the cadence gate must still refuse to run" + ); + assert_eq!( + second.status().state, + ClaimLoopState::CadenceNotElapsed, + "a deliberately-skipped cycle must name itself, never read as the previous cycle's \ + Nominal (or any other stale) state" + ); + } + + /// F10/F16 regression -- THE anti-latch test for Finding 1. A future-dated + /// `last_cycle_completed_at` (an NTP step, a clock glitch) must (a) refuse cycle 1, reported as + /// `PersistedStateCorrupt`, never silent, and (b) — this is the part the ONE-cycle version of + /// this test could never prove — self-heal the moment real time catches up: cycle 2, run after + /// the clock has caught up AND the cadence has elapsed, MUST claim. A single-cycle version of + /// this test is green whether the latch bug is present or not, because it never gives the + /// latch a second cycle to prove it never clears. Must go red against the pre-F16 engine (the + /// `fee_window_poisoned` field latching `future_dated_clock` permanently `true`), and green + /// once that field is gone and `future_dated_clock` is recomputed fresh every cycle. + #[tokio::test] + async fn f10_a_future_dated_clock_refuses_then_self_heals_next_cycle() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f10-future-clock-") + .tempdir() + .expect("a scratch dir"); + + let far_future = 9_999_999_999u64; + let seeded = RewardsClaimConfig { + last_cycle_completed_at: Some(far_future), + ..RewardsClaimConfig::default() + }; + seeded + .save_to(dir.path()) + .expect("seed a future-dated clock"); + + let launcher_id = Bytes32::new([0x76u8; 32]); + let mut e = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + // Cycle 1: `now` (1_000) is nowhere near `far_future` -- the clock reads as future-dated, + // and must refuse. + let cycle1 = e.run_cycle(1_000).await; + assert_eq!( + cycle1, + Vec::new(), + "a future-dated clock must submit nothing this cycle" + ); + assert_eq!( + e.status().state, + ClaimLoopState::PersistedStateCorrupt, + "a future-dated clock must be its own reported condition, never silent, and never \ + read as CadenceNotElapsed (which is what the old unvalidated saturating_sub bug \ + would produce once F9 is fixed)" + ); + + // Cycle 2: real time has now passed `far_future` (self-healing the clock condition) AND + // the cadence has elapsed since `far_future` (satisfying the cadence gate too) -- a + // genuinely healthy cycle that a permanent latch would still refuse forever. + let caught_up = far_future + CADENCE_SECONDS + 1; + let cycle2 = e.run_cycle(caught_up).await; + assert_eq!( + cycle2, + vec![ClaimOutcome::Submitted { launcher_id }], + "once the clock has genuinely caught up, the next cycle MUST claim -- a latched \ + `fee_window_poisoned` would refuse this cycle forever, long after the glitch that \ + caused it stopped being true" + ); + assert_ne!( + e.status().state, + ClaimLoopState::PersistedStateCorrupt, + "a self-healed clock must not still read as corrupt" + ); + } + + /// F16 regression: a corrupt file, repaired mid-run to VALID values carrying a large + /// already-spent amount and a recent completed-cycle time, must resume from those DISK + /// values on the very next cycle -- never from `poisoned()`'s `None`/`0`/`None` placeholders + /// that `with_persisted_fee_window` copied into the engine while the file was still corrupt. + /// Cycle 2 must neither get a fresh budget (the repaired file says the window is already + /// fully spent) nor skip the cadence gate (the repaired file names a `last_cycle_completed_at` + /// only 10 seconds before cycle 2's `now`, far short of the cadence). Must go red against the + /// pre-fix engine, which loads the fee-window fields ONCE at construction + /// (`with_persisted_fee_window`) and never refreshes them from the freshly-reloaded `cfg` + /// inside `run_cycle`'s `CycleConditions` -- so cycle 2 sees its own construction-time `None`s + /// for `last_cycle_completed_at` and `fee_window_start_unix`, skips the cadence gate entirely, + /// rolls a brand-new zeroed window and submits. + #[tokio::test] + async fn f16_a_repaired_file_resumes_from_disk_values_not_placeholders() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f16-repair-mid-run-") + .tempdir() + .expect("a scratch dir"); + + std::fs::write( + dir.path().join("rewards-claim.json"), + b"{ this is not json, or a torn write mid-object", + ) + .expect("seed a corrupt file"); + + let launcher_id = Bytes32::new([0x79u8; 32]); + let mut e = ClaimEngine::new( + FakeChainPort::new(vec![budget_consuming_distributor(launcher_id, 10)]), + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + // Cycle 1: the file is corrupt -- must refuse, submit nothing. + let cycle1 = e.run_cycle(1_000).await; + assert_eq!( + cycle1, + Vec::new(), + "a corrupt file must submit nothing this cycle" + ); + assert_eq!(e.status().state, ClaimLoopState::PersistedStateCorrupt); + + // The operator's remedy: repair the file with VALID values -- a window already fully + // spent, and a completed cycle only 10 seconds ago. + let repaired = RewardsClaimConfig { + cadence_seconds: CADENCE_SECONDS, + max_cycle_fee_budget_mojos: CYCLE_BUDGET, + fee_window_start_unix: Some(1_000), + fee_spent_in_window_mojos: CYCLE_BUDGET, + last_cycle_completed_at: Some(1_000), + ..RewardsClaimConfig::default() + }; + repaired.save_to(dir.path()).expect("repair the file"); + + // Cycle 2, 10 seconds later -- far short of the 86_400s cadence, and the window the + // repaired file names is already fully spent. Must neither claim nor roll a fresh window. + let cycle2 = e.run_cycle(1_010).await; + assert_eq!( + cycle2, + Vec::new(), + "a just-repaired file must resume from its OWN disk values, not the placeholders \ + `with_persisted_fee_window` saw while the file was still corrupt -- a fresh budget \ + or a skipped cadence gate here is the F16 stale-read defect" + ); + assert_eq!( + e.status().state, + ClaimLoopState::CadenceNotElapsed, + "the repaired file's own last_cycle_completed_at must still gate this cycle" + ); + + let persisted = RewardsClaimConfig::load_from(dir.path()); + assert_eq!( + persisted.fee_spent_in_window_mojos, CYCLE_BUDGET, + "a cycle that never ran must never overwrite the repaired disk values with a fresh \ + zeroed window" + ); + } + + /// F12 regression: a submission that DEFINITELY failed (the call returned `Err`, so it never + /// broadcast) must not permanently inflate the persisted window -- that is free denial-of- + /// service for an attacker running K always-failing submissions. Must go red with the + /// `uncommit_fee` calls on the `Err` branches of `evaluate_budget_phase`'s `match` removed. + #[tokio::test] + async fn f12_a_failed_submission_does_not_inflate_the_persisted_window() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + let dir = tempfile::Builder::new() + .prefix("dig-node-f12-failed-submit-") + .tempdir() + .expect("a scratch dir"); + + let failing = Bytes32::new([0x78u8; 32]); + let d = budget_consuming_distributor(failing, 10); + let port = FakeChainPort::new(vec![d]); + port.fail_submit_for(failing); + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + let outcomes = e.run_cycle(1_000).await; + assert_eq!( + outcomes.len(), + 1, + "the one candidate must have been evaluated, or this proves nothing about its fee" + ); + assert!(!matches!( + outcomes[0], + ClaimOutcome::PayoutPuzzleHashMismatch { .. } + )); + + let persisted = RewardsClaimConfig::load_from(dir.path()); + assert_eq!( + persisted.fee_spent_in_window_mojos, 0, + "a submission that definitely never broadcast must leave the persisted window \ + exactly as it was, not charged for a fee that was never spent" + ); + } + + /// #3251 rework: a failed submission must produce `ClaimOutcome::Faulted`, not just increment + /// `distributors_faulted` and vanish from the outcome stream -- the exact silence this ticket + /// exists to close. Reuses F12's own fixture (a submission that DEFINITELY failed) so both + /// facts are proven from the SAME cycle: the outcome exists AND the fee it reversed is not + /// left charged against the persisted window. + #[tokio::test] + async fn a_failed_submission_produces_a_faulted_outcome_with_the_fee_it_reversed() { + const CYCLE_BUDGET: u64 = 1_000_000; + const CADENCE_SECONDS: u64 = 86_400; + const FEE_MOJOS: u64 = 10; + let dir = tempfile::Builder::new() + .prefix("dig-node-faulted-outcome-") + .tempdir() + .expect("a scratch dir"); + + let failing = Bytes32::new([0x79u8; 32]); + let d = budget_consuming_distributor(failing, FEE_MOJOS); + let port = FakeChainPort::new(vec![d]); + port.fail_submit_for(failing); + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ) + .with_persisted_fee_window(dir.path(), CADENCE_SECONDS); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::Faulted { + launcher_id: failing, + reversed_fee_mojos: Some(FEE_MOJOS), + reason: "simulated submission failure".to_string(), + }], + "a definitely-failed submission must be reported, not silently absorbed into the \ + `faulted` counter alone" + ); + assert_eq!( + e.status().distributors_faulted, + 1, + "the counter stays; it is not a substitute for the outcome" + ); + + let persisted = RewardsClaimConfig::load_from(dir.path()); + assert_eq!( + persisted.fee_spent_in_window_mojos, 0, + "the fee `Faulted` reports as reversed must actually be reversed in the persisted \ + window, not merely claimed reversed in the outcome" + ); + } + + /// F14 regression: the per-cycle budget comparison must never panic on a corrupted or + /// otherwise near-`u64::MAX` in-cycle spend total -- the workspace enables `overflow-checks` + /// in release, so a bare `+` here is a live panic-on-corrupt-input path, not just a debug + /// lint. Must go red (panic) with `saturating_add` reverted to a bare `+` in + /// `evaluate_budget_phase`'s budget comparison. + #[tokio::test] + async fn f14_a_near_max_spent_value_does_not_panic_the_budget_comparison() { + let d = budget_consuming_distributor(Bytes32::new([0x80u8; 32]), 10); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + let mut spent_this_cycle_mojos = u64::MAX - 5; + let mut budget_exhausted = false; + let claim = EligibleClaim { + launcher_id, + accrued_base_units: 5_000, + }; + let mut window = FeeWindowState { + start_unix: None, + spent_mojos: 0, + last_completed_at: None, + }; + + let result = e + .evaluate_budget_phase( + &claim, + &mut spent_this_cycle_mojos, + &mut budget_exhausted, + &mut window, + ) + .await; + + assert!( + matches!( + result, + BudgetPhaseResult::Outcome(ClaimOutcome::SkippedCycleBudgetExhausted { .. }) + ), + "a near-overflow spent value must read as budget-exhausted, never panic and never \ + submit" + ); + } + + /// Defect E regression: a port returning an entry whose `payout_puzzle_hash` diverges from this + /// node's own must produce ZERO submissions -- never pay whoever the port named instead -- + /// counted both lifetime and per-cycle. + /// + /// Defect B3 regression: this used to also assert `fault_reported`, which set the CYCLE-WIDE + /// `Faulted` state for a PER-DISTRIBUTOR problem -- see `a_payout_mismatch_never_sets_the_cycle_ + /// wide_fault_or_masks_other_distributors` below for the exploit this enabled. + #[tokio::test] + async fn entry_for_a_different_payout_puzzle_hash_is_refused_not_paid() { + let wrong_hash = Bytes32::new([0x77u8; 32]); + assert_ne!(wrong_hash, OUR_PAYOUT_PUZZLE_HASH); + let d = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: wrong_hash, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let launcher_id = d.launcher_id; + let mut e = engine(FakeChainPort::new(vec![d])); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::PayoutPuzzleHashMismatch { launcher_id }] + ); + assert_eq!(e.status().claims_submitted, 0, "never paid the wrong hash"); + assert!(e.port.submitted.lock().unwrap().is_empty()); + assert!( + !e.status().fault_reported, + "Defect B3: a per-distributor mismatch must never set the cycle-wide fault" + ); + assert_eq!(e.status().claims_refused_payout_mismatch, 1); + assert_eq!(e.status().payout_hash_mismatches_this_cycle, 1); + } + + /// **Defect B3 (blocking) -- the exploit the review found.** A single hostile/buggy entry row + /// (a payout-hash mismatch on one launcher) must NOT pin the whole surface at `Faulted` and + /// must NOT bury the `ClaimableButNotClaiming` signal for every OTHER, healthy distributor. + #[tokio::test] + async fn a_payout_mismatch_never_sets_the_cycle_wide_fault_or_masks_other_distributors() { + let wrong_hash = Bytes32::new([0x77u8; 32]); + let mismatched = FakeDistributor { + launcher_id: Bytes32::new([0xAAu8; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: wrong_hash, + counter: 0, + accrued_base_units: 5_000, + }), + fee_mojos: 10, + }; + // A second, healthy distributor whose claim would exceed the budget alongside the + // mismatched one's fee, so a fault-flag leak would be free to hide behind + // `ClaimableButNotClaiming` too -- proving the precedence fix, not just the flag. + let healthy = FakeDistributor { + launcher_id: Bytes32::new([0xBBu8; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + fee_mojos: 10, + }; + let mut e = engine(FakeChainPort::new(vec![mismatched, healthy])); + + for cycle in 1..=3u32 { + e.run_cycle(u64::from(cycle) * 1_000).await; + assert!( + !matches!(e.status().state, ClaimLoopState::Faulted { .. }), + "cycle {cycle}: a per-distributor mismatch must never read as the cycle-wide Faulted" + ); + } + // F2 inversion: this assertion used to read `ClaimLoopState::Nominal` (an A2-class test + // pinning the defect as intended behaviour). A live payout-hash mismatch is a real, + // per-cycle shortfall exactly like an unmet `claimable` -- the healthy distributor + // claiming does NOT make the surface healthy while the mismatched one is still refused + // every cycle. `distributors_claimable` counts only the healthy one (1); the mismatch + // never enters `eligible` so it is not in `claimable` either, but it IS folded into the + // shortfall predicate's denominator, so `submitted (1) < claimable (1) + mismatches (1)`. + // + // F13: the payload reports that same folded denominator (2), not the un-folded + // `distributors_claimable` (1) alone -- a state named `ClaimableButNotClaiming` whose + // numbers said "0 short" would contradict its own name. + assert_eq!( + e.status().state, + ClaimLoopState::ClaimableButNotClaiming { + claimable: 2, + submitted: 1 + }, + "an ongoing payout-hash mismatch is a real, per-cycle shortfall -- it must never read \ + as Nominal just because the OTHER distributor claimed" + ); + assert_eq!( + e.status().claims_submitted, + 3, + "the healthy one claimed all 3 cycles" + ); + assert_eq!(e.status().claims_refused_payout_mismatch, 3); + } + + /// **F2 -- all-K-distributors mismatching must read as a shortfall, never `Nominal`.** Before + /// the fix, a mismatch never entered `eligible`, so `claims_submitted_this_cycle` (0) and + /// `distributors_claimable` (0) were BOTH zero and the magnitude comparison read healthy -- + /// the exact case the F2 brief calls out: "what if every distributor refuses for the same + /// reason." This must be a shortfall (`ClaimableButNotClaiming`), and it must NOT reintroduce + /// Defect B3 by setting the cycle-wide `Faulted`. + #[tokio::test] + async fn all_distributors_mismatching_is_a_shortfall_not_nominal() { + let wrong_hash = Bytes32::new([0x77u8; 32]); + let mismatched = FakeDistributor { + launcher_id: Bytes32::new([0xAAu8; 32]), + store_id: Bytes32::new([3u8; 32]), + root: Bytes32::new([4u8; 32]), + reserve_asset_id: DIG_ASSET_ID, + payout_threshold: 1_000, + entry: Some(super::super::types::OwnEntry { + payout_puzzle_hash: wrong_hash, + counter: 0, + accrued_base_units: 5_000, + }), + fee_mojos: 10, + }; + let mut e = engine(FakeChainPort::new(vec![mismatched])); + + e.run_cycle(1_000).await; + + assert_eq!( + e.status().distributors_claimable, + 0, + "the mismatched distributor never enters eligible" + ); + assert_eq!(e.status().claims_submitted_this_cycle, 0); + assert!( + !matches!(e.status().state, ClaimLoopState::Faulted { .. }), + "a per-distributor mismatch must never set the cycle-wide Faulted (Defect B3)" + ); + // F13: `distributors_claimable` (the un-folded term) is 0, but the payload reports the + // folded shortfall denominator -- `distributors_claimable (0) + mismatches (1)` -- so an + // all-mismatching cycle carries a nonzero `claimable` instead of a reassuring zero. + assert_eq!( + e.status().state, + ClaimLoopState::ClaimableButNotClaiming { + claimable: 1, + submitted: 0 + }, + "all-K-distributors mismatching is a real, systemic shortfall -- it must never read \ + as Nominal just because nothing entered `eligible`" + ); + } + + /// ACCEPTANCE 12 — with `UnavailableClaimChainPort` wired, the engine reports the named state + /// `ChainSourceUnavailable` and runs zero cycles: no discovery outcome, no fault flag, no + /// claim, never a silent no-op (see the module doc's "chain seam" + "HONESTY" sections). + #[tokio::test] + async fn unavailable_port_reports_chain_source_unavailable_and_runs_zero_cycles() { + let mut e = ClaimEngine::new( + crate::rewards_claim::port::UnavailableClaimChainPort, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + + assert!(outcomes.is_empty(), "zero cycles ran"); + assert_eq!(e.status().state, ClaimLoopState::ChainSourceUnavailable); + assert_eq!(e.status().claims_submitted, 0); + assert_eq!(e.status().distributors_known, 0); + assert!(e.status().last_cycle_at.is_none(), "no cycle completed"); + } + + /// A discovery port that answers `Unavailable` on its FIRST call only, then delegates every + /// call (including later `discover_distributors` calls) to a healthy inner `FakeChainPort` -- + /// modelling a node still syncing, or one dropped connection, exactly as F1 describes. + struct FlakyThenHealthyPort { + // An atomic counter, not a `Mutex` -- a guard held across the `.await` below would + // make this port's future not `Send`, which `#[async_trait]`'s generated signature + // requires. Nothing here needs a lock: it is a single counter, never held past its own + // increment. + calls: AtomicU32, + inner: FakeChainPort, + } + + #[async_trait] + impl ClaimChainPort for FlakyThenHealthyPort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + let call_number = self.calls.fetch_add(1, Ordering::SeqCst) + 1; + if call_number == 1 { + return Err(ClaimPortError::Unavailable); + } + self.inner.discover_distributors().await + } + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + self.inner.resolve_launch_comment(launcher_id).await + } + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result { + self.inner.reserve_asset_id(launcher_id).await + } + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result { + self.inner.payout_threshold(launcher_id).await + } + async fn own_entry( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + self.inner.own_entry(launcher_id, payout_puzzle_hash).await + } + async fn required_fee_mojos(&self, launcher_id: Bytes32) -> Result { + self.inner.required_fee_mojos(launcher_id).await + } + async fn submit_initiate_payout( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + self.inner + .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) + .await + } + } + + /// **F1 regression -- the anti-latch test.** `ChainSourceUnavailable` must be a PER-CYCLE + /// reading, never a process-lifetime latch. Cycle 1 hits the transient `Unavailable` port path + /// and must report it honestly; cycle 2, once the chain answers again, MUST read `Nominal` -- + /// not the stale `ChainSourceUnavailable` from cycle 1 -- because a real claim submits. + #[tokio::test] + async fn a_transient_unavailable_cycle_does_not_latch_state_for_the_rest_of_the_process() { + let distributor = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let launcher_id = distributor.launcher_id; + let port = FlakyThenHealthyPort { + calls: AtomicU32::new(0), + inner: FakeChainPort::new(vec![distributor]), + }; + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + assert!(outcomes.is_empty(), "cycle 1: no chain, no outcomes"); + assert_eq!( + e.status().state, + ClaimLoopState::ChainSourceUnavailable, + "cycle 1: the transient unavailability must be reported honestly" + ); + + let outcomes = e.run_cycle(2_000).await; + assert_eq!( + outcomes, + vec![ClaimOutcome::Submitted { launcher_id }], + "cycle 2: the chain is healthy and a real claim is submitted" + ); + assert_eq!( + e.status().state, + ClaimLoopState::Nominal, + "cycle 2 MUST NOT still read ChainSourceUnavailable -- that is a process-lifetime \ + latch on the very state whose whole point is to be a live reading" + ); + } + + /// A discovery port that answers healthily on its FIRST call, then `Unavailable` on every call + /// after that -- the inverse of `FlakyThenHealthyPort`, for F3's staleness scenario. + struct HealthyThenUnavailablePort { + // Atomic, not `Mutex` -- see `FlakyThenHealthyPort`'s comment: a guard held across + // the `.await` below would make this port's future not `Send`. + calls: AtomicU32, + inner: FakeChainPort, + } + + #[async_trait] + impl ClaimChainPort for HealthyThenUnavailablePort { + async fn discover_distributors( + &self, + ) -> Result, ClaimPortError> { + let call_number = self.calls.fetch_add(1, Ordering::SeqCst) + 1; + if call_number == 1 { + return self.inner.discover_distributors().await; + } + Err(ClaimPortError::Unavailable) + } + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + self.inner.resolve_launch_comment(launcher_id).await + } + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result { + self.inner.reserve_asset_id(launcher_id).await + } + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result { + self.inner.payout_threshold(launcher_id).await + } + async fn own_entry( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + self.inner.own_entry(launcher_id, payout_puzzle_hash).await + } + async fn required_fee_mojos(&self, launcher_id: Bytes32) -> Result { + self.inner.required_fee_mojos(launcher_id).await + } + async fn submit_initiate_payout( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + self.inner + .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) + .await + } + } + + /// **F3 regression -- staleness under a fresh timestamp.** Cycle 1 is healthy and submits a + /// real claim (`distributors_claimable == 1`, `claims_submitted_this_cycle == 1`). Cycle 2 hits + /// the `ChainUnavailable` early-return path, which skips the end-of-function assignment block + /// entirely. Before the fix, cycle 1's counts stayed on `self.status` while `last_attempt_at` + /// was stamped fresh for cycle 2 -- exactly the stale-count-under-a-fresh-timestamp §2.4 + /// forbids. Every per-cycle counter must read as this cycle's true zero. + #[tokio::test] + async fn a_chain_unavailable_cycle_does_not_leave_prior_cycles_counters_stale() { + let distributor = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let port = HealthyThenUnavailablePort { + calls: AtomicU32::new(0), + inner: FakeChainPort::new(vec![distributor]), + }; + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + e.run_cycle(1_000).await; + assert_eq!( + e.status().distributors_claimable, + 1, + "cycle 1: healthy and claimable" + ); + assert_eq!( + e.status().claims_submitted_this_cycle, + 1, + "cycle 1: submitted" + ); + + e.run_cycle(2_000).await; + assert_eq!(e.status().state, ClaimLoopState::ChainSourceUnavailable); + assert_eq!( + e.status().distributors_claimable, + 0, + "F3: cycle 1's claimable count must not survive under cycle 2's fresh last_attempt_at" + ); + assert_eq!( + e.status().claims_submitted_this_cycle, + 0, + "F3: cycle 1's submission count must not survive into cycle 2" + ); + assert_eq!(e.status().distributors_faulted, 0); + assert_eq!(e.status().no_entry_slot_this_cycle, 0); + } + + /// The launch-comment parser wired end-to-end: what `resolve_launch_comment` would produce for + /// a real chain reply, confirming the two modules compose (not a duplicate of parser.rs's own + /// table-driven unit tests). + #[test] + fn parser_output_feeds_discovered_distributor_shape() { + let store = "a".repeat(64); + let root = "b".repeat(64); + let comment = format!("dig-rewards:v1:{store}:{root}"); + let d = parse_launch_comment(Bytes32::new([5u8; 32]), &comment).expect("parses"); + assert_eq!(d.launcher_id, Bytes32::new([5u8; 32])); + } + /// A discovery port that returns the SAME launcher id twice from one `discover_distributors` + /// call -- plausible for a real adapter scanning §1.3 launch comments across every + /// `(store_id, root)` pair this node mirrors, when one distributor is reachable via two of + /// them. + struct DuplicatingDiscoveryPort(FakeChainPort); + + #[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 resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + self.0.resolve_launch_comment(launcher_id).await + } + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result { + self.0.reserve_asset_id(launcher_id).await + } + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result { + self.0.payout_threshold(launcher_id).await + } + async fn own_entry( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + self.0.own_entry(launcher_id, payout_puzzle_hash).await + } + async fn required_fee_mojos(&self, launcher_id: Bytes32) -> Result { + self.0.required_fee_mojos(launcher_id).await + } + async fn submit_initiate_payout( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + self.0 + .submit_initiate_payout(launcher_id, payout_puzzle_hash, fee_mojos) + .await + } + } + + /// **F4 (non-blocking, cheap) -- a duplicated launcher id must submit EXACTLY ONCE.** Without + /// dedup, phase 2 evaluates the same candidate twice and pays the fee twice against one entry + /// slot in one cycle; the second spend is invalid (`counter` already incremented) but the fee + /// is spent anyway. + #[tokio::test] + async fn a_duplicated_launcher_id_submits_exactly_once() { + let distributor = one_distributor( + Some(super::super::types::OwnEntry { + payout_puzzle_hash: OUR_PAYOUT_PUZZLE_HASH, + counter: 0, + accrued_base_units: 5_000, + }), + 1_000, + 10, + ); + let launcher_id = distributor.launcher_id; + let port = DuplicatingDiscoveryPort(FakeChainPort::new(vec![distributor])); + let mut e = ClaimEngine::new( + port, + NoHintSource, + OUR_PAYOUT_PUZZLE_HASH, + FEE_CEILING, + CYCLE_BUDGET, + DIG_ASSET_ID, + ); + + let outcomes = e.run_cycle(1_000).await; + + assert_eq!( + outcomes, + vec![ClaimOutcome::Submitted { launcher_id }], + "exactly one submission for one distributor, even though discovery reported it twice" + ); + assert_eq!(e.status().claims_submitted, 1); + assert_eq!( + e.status().distributors_known, + 1, + "dedup collapses the duplicate" + ); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/hints.rs b/crates/dig-node-service/src/rewards_claim/hints.rs new file mode 100644 index 00000000..6a2a6a53 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/hints.rs @@ -0,0 +1,43 @@ +//! The DIG-Network/dig_ecosystem#3252 seam — defined here, wired to nothing (SPEC §13.2). + +use async_trait::async_trait; +use chia_protocol::Bytes32; + +/// An UNTRUSTED pointer to a distributor (SPEC §13.2 clause 1) — exactly like +/// `unverified_mirror_coin_id`. It MUST NOT admit an entry, MUST NOT rank a candidate and MUST NOT +/// be a claim's authority. Every property is re-derived from the chain via +/// [`super::port::ClaimChainPort::resolve_launch_comment`] before this hint's launcher id becomes a +/// candidate. DIG-Network/dig_ecosystem#3252 supplies the dig-gossip implementation by extending the +/// holdings-announce wire (opcode 222). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DistributorHint { + pub launcher_id: Bytes32, +} + +/// A source of untrusted distributor pointers (SPEC §13.2). +#[async_trait] +pub trait DistributorHintSource: Send + Sync { + async fn hints(&self) -> Vec; +} + +/// The MVP wiring: no hints. SPEC §13.2 clause 2 — a peer that never hears a hint MUST still find +/// and claim via §13.1, so this changes no outcome; it only removes a latency shortcut this lane +/// does not build. +pub struct NoHintSource; + +#[async_trait] +impl DistributorHintSource for NoHintSource { + async fn hints(&self) -> Vec { + Vec::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn no_hint_source_yields_nothing() { + assert!(NoHintSource.hints().await.is_empty()); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/mod.rs b/crates/dig-node-service/src/rewards_claim/mod.rs new file mode 100644 index 00000000..5d5d4a57 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/mod.rs @@ -0,0 +1,72 @@ +//! The node's PEER-SIDE reward claim loop (DIG-Network/dig_ecosystem#3251). +//! +//! This is the other half of the reward-distributor lifecycle from +//! `dig_node_core::rewards` (DIG-Network/dig_ecosystem#3250, a sibling lane): that crate proves a +//! FUNDER's distributors are honest and writes entries; this module discovers the distributors that +//! cover the `(store_id, root)`s THIS node mirrors, watches its own entry slot, and submits +//! `InitiatePayout` on a jittered cadence. It lives in `dig-node-service`, not `dig-node-core`, +//! because `dig-mirror-coin` (the on-chain peer<->payout binding this loop reuses, SPEC §10.1) is a +//! dependency of this crate and not of `dig-node-core`. +//! +//! # No rival copy of a shared type +//! +//! `chia_protocol::Bytes32` is the one canonical 32-byte type — never a locally declared +//! `type Bytes32 = [u8; 32]`. This module's own types (`DiscoveredDistributor`, `OwnEntry`, the +//! [`ClaimChainPort`] trait) are named differently from #3250's `port.rs` (`DistributorRef`, +//! `EntrySlot`, `RewardsChainPort`) because they carry different behaviour: #3250 reads the FUNDER's +//! whole entry set and writes entries; this module reads only THIS node's own entry slot and submits +//! payout claims. Same protocol, other side, not a duplicate. +//! +//! # The chain seam +//! +//! `dig-rewards-coin` is v0.1.3, published on crates.io, and still SPEC-only (`src/` is +//! `error.rs` + `lib.rs`); its driver is +//! DIG-Network/dig_ecosystem#3249, still open. So the whole engine here is built against the narrow +//! [`ClaimChainPort`] trait derived from the SPEC's described surface, tested with a full in-memory +//! fake, and the production adapter — until #3249 ships — is [`UnavailableClaimChainPort`], which +//! reports the named state `ChainSourceUnavailable` and runs zero cycles. This mirrors #3250's own +//! `UnavailableChainPort` exactly. When #3249 lands, one adapter is written against +//! `ClaimChainPort` and nothing above this seam changes. +//! +//! A silent no-op that reported progress instead would be the exact defect this ticket exists to +//! prevent (SPEC §2.4): with the unavailable adapter wired, zero claims IS the true state, so the +//! status surface must say so by name, not by omission. +//! +//! # Not yet wired into node startup (Defect D — stated, not fixed here) +//! Nothing in this codebase constructs a [`ClaimEngine`] outside this module's own tests: there is +//! no scheduler that drives [`ClaimEngine::run_cycle`] on a cadence, and no RPC method exposes +//! [`ClaimStatus`] to an operator, even though [`RewardsClaimConfig::enabled`] defaults to `true`. +//! Wiring this into node startup — picking a concrete [`ClaimChainPort`] adapter, starting the +//! cadence loop, and exposing `ClaimStatus` over RPC — is a separate unit of work with its own +//! review surface, deferred out of this PR on purpose: the only production adapter available today +//! is [`UnavailableClaimChainPort`], and the real one arrives with +//! DIG-Network/dig_ecosystem#3249. Until that wiring lands, this module compiles, is fully tested +//! against the fake chain port, and does nothing in a running node. + +mod cadence; +mod config; +mod engine; +mod hints; +mod parser; +mod port; +mod types; + +pub use cadence::{next_interval_seconds, FixedJitter, JitterSource, CLAIM_JITTER_SECONDS_DEFAULT}; +pub use config::{ + RewardsClaimConfig, CLAIM_CADENCE_SECONDS_DEFAULT, CLAIM_CYCLE_FEE_BUDGET_MOJOS_DEFAULT, + CLAIM_FEE_CEILING_MOJOS_DEFAULT, +}; +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}; + +#[cfg(test)] +mod tests { + #[test] + fn module_compiles_and_loads() { + // Skeleton checkpoint (kernel invariant 3): a compiling module with one passing test, + // pushed before any design work. Superseded by the real engine tests as they land. + } +} diff --git a/crates/dig-node-service/src/rewards_claim/parser.rs b/crates/dig-node-service/src/rewards_claim/parser.rs new file mode 100644 index 00000000..aebfaade --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/parser.rs @@ -0,0 +1,102 @@ +//! The launch-comment parser (SPEC §1.3) — the only place a distributor's launch spend is tied to +//! content, so a wrong parse here is a wrong claim everywhere downstream. +//! +//! `dig-rewards:v1::`, each half exactly 64 lowercase hex characters. A +//! writer MUST emit lowercase; a reader MUST accept either case and compare the 32 BYTES, never the +//! text (SPEC §1.3). A comment that does not parse is "not a DIG rewards distributor" — not an +//! error (SPEC §1.3 clause 3). + +use chia_protocol::Bytes32; + +use super::types::DiscoveredDistributor; + +const PREFIX: &str = "dig-rewards:v1:"; + +/// Parse a launch comment into the `(store_id, root)` it names, or `None` if it is not a DIG +/// rewards distributor's comment. `launcher_id` is threaded through unchanged — this function only +/// interprets the comment string. +#[must_use] +pub fn parse_launch_comment(launcher_id: Bytes32, comment: &str) -> Option { + let rest = comment.strip_prefix(PREFIX)?; + let (store_hex, root_hex) = rest.split_once(':')?; + let store_id = parse_hex32(store_hex)?; + let root = parse_hex32(root_hex)?; + Some(DiscoveredDistributor { + launcher_id, + store_id, + root, + }) +} + +/// Exactly 64 hex characters (either case), compared as the 32 bytes they denote — never as text. +fn parse_hex32(hex: &str) -> Option { + if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let mut bytes = [0u8; 32]; + hex::decode_to_slice(hex, &mut bytes).ok()?; + Some(Bytes32::from(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lid() -> Bytes32 { + Bytes32::from([7u8; 32]) + } + + #[test] + fn table_driven_launch_comment_parsing() { + let store = "a".repeat(64); + let root = "b".repeat(64); + let store_upper = "A".repeat(64); + + let cases: &[(&str, bool)] = &[ + ("valid lowercase", true), + ("valid uppercase halves", true), + ("wrong prefix", false), + ("wrong version", false), + ("short store half", false), + ("long store half", false), + ("non-hex store half", false), + ("empty comment", false), + ("empty halves", false), + ]; + + let comments: &[String] = &[ + format!("dig-rewards:v1:{store}:{root}"), + format!("dig-rewards:v1:{store_upper}:{root}"), + format!("dig-mirror:v1:{store}:{root}"), + format!("dig-rewards:v2:{store}:{root}"), + format!("dig-rewards:v1:{}:{root}", &store[..63]), + format!("dig-rewards:v1:{store}a:{root}"), + format!("dig-rewards:v1:{}:{root}", "z".repeat(64)), + String::new(), + "dig-rewards:v1::".to_string(), + ]; + + for ((name, expect_some), comment) in cases.iter().zip(comments.iter()) { + let got = parse_launch_comment(lid(), comment); + assert_eq!(got.is_some(), *expect_some, "case: {name} ({comment:?})"); + } + } + + #[test] + fn parse_compares_bytes_not_text_case() { + let store = "ab".repeat(32); + let root = "cd".repeat(32); + let lower = parse_launch_comment(lid(), &format!("dig-rewards:v1:{store}:{root}")).unwrap(); + let upper = parse_launch_comment( + lid(), + &format!( + "dig-rewards:v1:{}:{}", + store.to_uppercase(), + root.to_uppercase() + ), + ) + .unwrap(); + assert_eq!(lower.store_id, upper.store_id); + assert_eq!(lower.root, upper.root); + } +} diff --git a/crates/dig-node-service/src/rewards_claim/port.rs b/crates/dig-node-service/src/rewards_claim/port.rs new file mode 100644 index 00000000..f5988f52 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/port.rs @@ -0,0 +1,142 @@ +//! The claim-side chain port — the seam this engine is built against instead of +//! `dig-rewards-coin` (see the module doc's "chain seam" section for why). +//! +//! Deliberately a DIFFERENT trait from #3250's `RewardsChainPort`: that one reads a funder's whole +//! entry set and writes entries; this one reads only THIS node's own entry slot and submits its own +//! payout. + +use async_trait::async_trait; +use chia_protocol::Bytes32; + +use super::types::{DiscoveredDistributor, OwnEntry}; + +/// Why a claim-chain call could not complete. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClaimPortError { + /// No chain source is wired yet — [`UnavailableClaimChainPort`]'s only answer, and what any + /// real adapter should answer for an unreachable chain too. + Unavailable, + /// A chain answered but the call failed for a reason worth a message (bounded before logging). + Other(String), +} + +/// The narrow surface the claim engine needs from the reward-distributor chain state, derived from +/// SPEC's described surface (§1.3 discovery, §8.3/§9.3 evaluation, §10.2/§12.5 the peer's own entry, +/// §10.2 clause 3 the claim write) — not from `dig-rewards-coin`'s internals. +#[async_trait] +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>; + + /// 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). + /// `Ok(None)` means the comment does not parse — "not a DIG rewards distributor", not an error + /// (SPEC §1.3). + async fn resolve_launch_comment( + &self, + launcher_id: Bytes32, + ) -> Result, ClaimPortError>; + + /// SPEC §9.1/§9.3: the distributor's on-chain `reserve_asset_id`. + async fn reserve_asset_id(&self, launcher_id: Bytes32) -> Result; + + /// SPEC §8.3: the distributor's own chain-curried `payout_threshold` — never hardcoded here. + async fn payout_threshold(&self, launcher_id: Bytes32) -> Result; + + /// SPEC §10.2/§12.5: this node's own entry slot, re-read fresh on EVERY call, EVERY cycle — the + /// engine MUST NOT cache the result across cycles and MUST NOT treat one `Ok(None)` as + /// permanent (Defect B): SPEC §12.5 clause 2 describes a legitimate re-entry path (evicted, + /// re-challenged, re-admitted), and this call cannot tell "never admitted yet" apart from + /// "evicted" from the absence alone — nor does it need to, since SPEC §6.4 clause 1 means + /// nothing is owed either way. `Ok(None)` means only "no claim this cycle", never "no claim + /// ever again". + async fn own_entry( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError>; + + /// The network fee, in mojos, an `InitiatePayout` for this launcher id would need. + async fn required_fee_mojos(&self, launcher_id: Bytes32) -> Result; + + /// SPEC §10.2: submit ONE `InitiatePayout` for `payout_puzzle_hash` at `fee_mojos`. + async fn submit_initiate_payout( + &self, + launcher_id: Bytes32, + payout_puzzle_hash: Bytes32, + fee_mojos: u64, + ) -> Result<(), ClaimPortError>; +} + +/// The production adapter until DIG-Network/dig_ecosystem#3249 lands: reports +/// [`ClaimPortError::Unavailable`] on every call and runs zero cycles — the named state +/// `ChainSourceUnavailable` (see the module doc), never a silent no-op. +pub struct UnavailableClaimChainPort; + +#[async_trait] +impl ClaimChainPort for UnavailableClaimChainPort { + async fn discover_distributors(&self) -> Result, ClaimPortError> { + Err(ClaimPortError::Unavailable) + } + + async fn resolve_launch_comment( + &self, + _launcher_id: Bytes32, + ) -> Result, ClaimPortError> { + Err(ClaimPortError::Unavailable) + } + + async fn reserve_asset_id(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Unavailable) + } + + async fn payout_threshold(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Unavailable) + } + + async fn own_entry( + &self, + _launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + ) -> Result, ClaimPortError> { + Err(ClaimPortError::Unavailable) + } + + async fn required_fee_mojos(&self, _launcher_id: Bytes32) -> Result { + Err(ClaimPortError::Unavailable) + } + + async fn submit_initiate_payout( + &self, + _launcher_id: Bytes32, + _payout_puzzle_hash: Bytes32, + _fee_mojos: u64, + ) -> Result<(), ClaimPortError> { + Err(ClaimPortError::Unavailable) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn unavailable_adapter_never_reports_a_cycle_ran() { + let port = UnavailableClaimChainPort; + assert_eq!( + port.discover_distributors().await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.own_entry(Bytes32::from([0u8; 32]), Bytes32::from([0u8; 32])) + .await, + Err(ClaimPortError::Unavailable) + ); + assert_eq!( + port.submit_initiate_payout(Bytes32::from([0u8; 32]), Bytes32::from([0u8; 32]), 0) + .await, + 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 new file mode 100644 index 00000000..dfe22737 --- /dev/null +++ b/crates/dig-node-service/src/rewards_claim/types.rs @@ -0,0 +1,566 @@ +//! The data shapes the claim loop moves — deliberately named apart from #3250's `port.rs` +//! (`DistributorRef` / `EntrySlot`) because this side carries discovery provenance the funder side +//! has no concept of. + +use chia_protocol::Bytes32; + +/// A distributor this node has located on-chain and confirmed is ours (SPEC §1.3, §9.3): its launch +/// comment parsed and its reserve asset is `dig_constants::DIG_ASSET_ID`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DiscoveredDistributor { + pub launcher_id: Bytes32, + pub store_id: Bytes32, + pub root: Bytes32, +} + +/// 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)] +pub struct OwnEntry { + pub payout_puzzle_hash: Bytes32, + /// The slot's replay guard; `InitiatePayout` writes `counter + 1` (SPEC §10.2 clause 3). + pub counter: u64, + /// What this entry has accrued and not yet claimed, in $DIG base units. + pub accrued_base_units: u64, +} + +/// What one distributor's evaluation this cycle produced — never silently nothing. +/// +/// Not `Copy` since [`Self::Faulted`] carries a `String` (the chain port's own bounded error text). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClaimOutcome { + /// `InitiatePayout` was submitted for this launcher id. + Submitted { launcher_id: Bytes32 }, + /// SPEC §8.6 final sentence: skipped, not failed — no spend, no fee. + SkippedBelowThreshold { + launcher_id: Bytes32, + accrued: u64, + threshold: u64, + }, + /// The fee ceiling (`crate::mirror::signer::MIRROR_SPEND_FEE_CEILING_MOJOS`-derived, see + /// [`super::config`]) would be exceeded — skipped, not failed. + SkippedFeeAboveCeiling { + launcher_id: Bytes32, + fee_mojos: u64, + ceiling_mojos: u64, + }, + /// SPEC v0.1.3 §12.5: no entry slot for our puzzle hash this cycle — terminal for THIS claim + /// attempt only, never for the distributor. Eviction already settled everything owed (SPEC + /// §6.4), but §12.5 forbids caching an absence any more than a value and forbids a permanent + /// per-distributor exclusion set: the loop keeps observing this distributor on §8.6's cadence, + /// because a peer can re-enter after eviction (§12.5 clause 2's re-entry path). + NoEntrySlot { launcher_id: Bytes32 }, + /// SPEC §9.3: the distributor's reserve asset is not `DIG_ASSET_ID` — not ours, dropped. + NotOurs { launcher_id: Bytes32 }, + /// Defect C2: the per-cycle aggregate fee budget (`RewardsClaimConfig::max_cycle_fee_budget_mojos`) + /// is exhausted — skipped, not failed, and every later candidate this cycle is skipped the same + /// way rather than spent past the budget. Bounds what an attacker funding many distributors over + /// a widely mirrored store can force this node to spend in one cycle. + SkippedCycleBudgetExhausted { + launcher_id: Bytes32, + fee_mojos: u64, + budget_mojos: u64, + }, + /// Defect E: the port's `own_entry` returned an entry whose `payout_puzzle_hash` does not equal + /// THIS node's own (`ClaimEngine::own_payout_puzzle_hash`). Paying it would send funds to + /// somewhere that is not this node, so the claim is REFUSED — not corrected by substituting our + /// own hash and proceeding. A mismatch means the port is confused or hostile, so it counts as a + /// fault, never a routine skip. + PayoutPuzzleHashMismatch { launcher_id: Bytes32 }, + /// The seventh case, added because the other six could only say a peer was legitimately not + /// paid, never that something went wrong: a chain call for this launcher id returned + /// `ClaimPortError::Other(_)` this cycle -- the chain answered but the call itself failed. + /// Distinct from `ClaimPortError::Unavailable` (no chain reached at all -- a cycle-wide + /// condition, surfaced as [`ClaimLoopState::ChainSourceUnavailable`], never per-launcher). Every + /// one of `evaluate_pre_budget`'s three chain reads and `evaluate_budget_phase`'s two can + /// produce this outcome; only the last of those five (`submit_initiate_payout` itself) is a + /// genuine "we tried to pay you and the chain said no" -- the earlier four never got far enough + /// to read a fee or attempt a spend. For a peer's money this is still the one fact worth + /// reporting either way: nothing legitimate happened to this distributor this cycle, and unlike + /// every variant above, it is not a deliberate, correct non-payment. + /// + /// The current [`super::port::ClaimPortError`] shape cannot distinguish "definitely never + /// landed" from "landed, fate unknown" any further than this: `Other(_)` IS the chain giving a + /// resolved answer (see `evaluate_budget_phase`'s "F12" doc comment), so every site that + /// produces this outcome already knows the call did not succeed and, by construction, that no + /// fee is left committed for it (either none was ever read, or it was read, pre-committed to + /// the persisted window, and reversed by `ClaimEngine::uncommit_fee` before this outcome was + /// built). There is no "fate unknown" case reachable today; if one is ever added (e.g. a + /// request that times out with no chain answer at all), it needs its own variant rather than + /// being folded in here, because it could not carry the same "no money moved" guarantee. + Faulted { + launcher_id: Bytes32, + /// `Some(fee)` only when a fee was pre-committed to the persisted fee window and then + /// reversed before this outcome was produced (the `submit_initiate_payout` failure path) -- + /// proof the fee did not stay spent despite the pre-commit. `None` means no fee was ever + /// read for this attempt, so there was nothing to commit or reverse. Either way the + /// persisted window reflects zero net spend for this launcher id this cycle (see + /// `f12_a_failed_submission_does_not_inflate_the_persisted_window`). + reversed_fee_mojos: Option, + /// The chain port's own words for why (`ClaimPortError::Other`'s payload), bounded to 200 + /// chars before it is stored or logged -- it originates from a chain port and so is + /// attacker-adjacent, the same discipline `service::summarize_stderr` applies to a tool's + /// own stderr. + reason: String, + }, +} + +/// The closed set of states this loop can be in. Never a health boolean (SPEC §2.4) — each name +/// maps to a different fact an operator can act on. +/// +/// # Precedence: `ChainSourceUnavailable` > `Faulted` > `ClaimableButNotClaiming` > `Idle` > +/// `Nominal` (Defect A1, refined by Defect B3) +/// `ChainSourceUnavailable` outranks everything (no chain at all). Next, `Faulted` outranks +/// `Nominal` and `ClaimableButNotClaiming`: a cycle where a chain call returned +/// `ClaimPortError::Other(_)` is never allowed to read as healthy just because nothing else in +/// the cycle happened to be claimable. Only once no fault is live can `ClaimableButNotClaiming` +/// or `Nominal` apply. +/// +/// # F1: `ChainSourceUnavailable` is a per-cycle reading, never a latch +/// This used to be decided by comparing against `self.state` -- LAST cycle's computed reading -- +/// so once any cycle took an `Unavailable` port path, every later cycle's `compute_state` saw its +/// own prior verdict and re-asserted it forever, even after the chain came back and real claims +/// were submitting. [`ClaimStatus::chain_unavailable_this_cycle`] fixes this: reset to `false` at +/// the top of every `run_cycle`, set `true` only on a cycle that actually took the `Unavailable` +/// path this cycle. `compute_state` reads that flag, never `self.state`. +/// +/// # Defect B3: a per-distributor problem must never set the cycle-wide fault +/// `Faulted` used to also fire on [`ClaimOutcome::PayoutPuzzleHashMismatch`] — a single hostile or +/// buggy ENTRY ROW pinned the whole surface at `Faulted` indefinitely (non-terminal, so it recurred +/// every cycle) and buried the `ClaimableButNotClaiming` signal this ticket exists to produce. A +/// payout-hash mismatch is now a per-distributor COUNTED refusal (see +/// [`ClaimStatus::payout_hash_mismatches_this_cycle`] and +/// [`Self::claims_refused_payout_mismatch`]), never [`Self::fault_reported`]. `Faulted` is reserved +/// for a genuinely cycle-wide failure: discovery itself failing, or a chain-port call returning +/// `ClaimPortError::Other(_)`. +/// +/// # F8/F9/F10: `PersistedStateCorrupt` and `CadenceNotElapsed` are assigned DIRECTLY, never via +/// [`ClaimStatus::compute_state`] +/// Both are written by [`super::engine::ClaimEngine::run_cycle`] on an early return that happens +/// BEFORE any of this cycle's own numbers exist to compute a reading from — there is no +/// "claimable" or "faulted" count to rank against `compute_state`'s ladder, because no candidate +/// was ever evaluated. F9's finding was exactly this gap: an early return that assigned NEITHER a +/// direct state NOR fell through to `compute_state` left whatever `self.state` a PAST cycle +/// computed sitting there, stamped with a fresh `last_attempt_at` that made a deliberate skip read +/// as "healthy and idle". Every exit out of `run_cycle` now sets `state` one of these two ways — +/// directly here, or through `compute_state` at the bottom — never neither. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ClaimLoopState { + /// No cycle has ever been attempted yet. + #[default] + Idle, + /// The chain seam reported [`super::port::ClaimPortError::Unavailable`] — see the module doc's + /// "chain seam" section. Zero cycles ran; this is the true state, not a silent no-op. + ChainSourceUnavailable, + /// F8/F10/F16, fund-safety: the persisted rewards-claim state (`RewardsClaimConfig`) was + /// unreadable, unparsable, carried a spend exceeding its own budget (F14), or carried a + /// future-dated clock (F10) — corrupt state, not a fresh peer. The engine treats the window as + /// fully spent and submits nothing THIS CYCLE. What happens next differs by cause, and both are + /// re-checked fresh on every cycle (F16), never latched: + /// - an unreadable/unparsable file or an over-budget spend needs an operator to fix or remove + /// it, and stays `PersistedStateCorrupt` until they do; + /// - a future-dated clock is SELF-HEALING — `t > now` goes false the moment real time passes + /// the stored timestamp, so the very next cycle after catch-up reads as whatever + /// `compute_state` decides (typically `Nominal`), never stuck here. + /// This state exists so that refusal is visible rather than a silent, permanent freeze that + /// reads as `Nominal` (the pre-F9 shape of the F10 defect) — or, before F16, a permanent freeze + /// of its OWN under a different name once the clock had already caught up. + PersistedStateCorrupt, + /// F9: the cadence has not yet elapsed since the last cycle that ran to completion — a + /// DELIBERATE skip, its own named condition rather than the absence of one. Without this, the + /// gate's early return left a stale `self.state` from whatever a PAST cycle computed standing + /// under this cycle's freshly-stamped `last_attempt_at`, indistinguishable from a healthy idle + /// loop (the fourth relocation of this error class — see [`super::engine::ClaimEngine`]'s + /// module doc for the first three). + CadenceNotElapsed, + /// A chain call this cycle returned `ClaimPortError::Other(_)` — a real fault, distinct from + /// `ChainSourceUnavailable` (no chain at all). `cycles` is the number of CONSECUTIVE cycles a + /// fault has now been observed on, so an operator can tell a one-off blip from a wedged loop. + /// Defect A1: this state exists precisely so a reported fault can never be laundered into + /// `Nominal` for lack of anywhere else to fall through to. + Faulted { cycles: u32 }, + /// The silent-failure case this ticket exists to prevent: fewer distributors were claimed THIS + /// CYCLE than were claimable, and no fault is live. Carries both numbers so a reader sees the + /// SIZE of the gap, not just its existence. Computed, never asserted by a writer about itself — + /// see [`ClaimStatus::compute_state`]. + /// + /// # Defect B1: a zero-test masked a partial shortfall + /// This used to fire only when `claims_submitted_this_cycle == 0` — a magnitude comparison + /// disguised as an existence check. `claimable = 10, submitted_this_cycle = 1` read `Nominal`: + /// one submission (e.g. a distributor whose fee happened to sort first) masked nine same-cycle + /// skips. Reachable precisely because [`super::engine::ClaimEngine`]'s per-cycle budget + /// (Defect C2) is the first thing that can skip a claimable distributor while another one + /// submits in the same cycle. Fixed to a true magnitude comparison: fires whenever + /// `submitted < claimable`, whatever the non-zero submitted count is. + ClaimableButNotClaiming { claimable: u32, submitted: u32 }, + /// A cycle completed, nothing above is true. + Nominal, +} + +/// The anti-silence status surface (requirement 4): what an operator or a monitor reads to know +/// whether this loop is actually doing anything, never a boolean. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClaimStatus { + /// F1: set when THIS cycle actually took an `Unavailable` port path -- reset to `false` at the + /// top of every `run_cycle`, never latched. See [`ClaimLoopState`]'s "F1" doc section. + pub chain_unavailable_this_cycle: bool, + pub distributors_known: u32, + pub distributors_with_own_entry: u32, + /// Computed independently of whether a submission actually happened this cycle — an + /// entry that accrued at least `payout_threshold` with a fee at or under the ceiling. THIS + /// CYCLE's snapshot, overwritten every `run_cycle`, and compared against + /// [`Self::claims_submitted_this_cycle`] (also per-cycle) — never against the cumulative + /// [`Self::claims_submitted`], which only ever grows and would let one success in the process's + /// life mask every later broken cycle (Defect A3). + pub distributors_claimable: u32, + /// Defect C3/A3: distributors whose evaluation THIS cycle returned `ClaimPortError::Other(_)`. + /// A faulted distributor is not counted in [`Self::distributors_claimable`] — a fault must never + /// silently shrink that denominator into looking healthier than it is. + pub distributors_faulted: u32, + /// Only stamped on a discovery call that actually SUCCEEDED (Defect A4) — a reader uses this as + /// an independent staleness signal, so refreshing it on a failed discovery would destroy the one + /// reading that would have exposed the fault. See [`Self::last_attempt_at`] for "the loop is + /// still alive" instead. + pub last_discovery_at: Option, + /// Only stamped on a cycle that was not a failed discovery and not all-faulted (Defect A4) — same + /// reasoning as [`Self::last_discovery_at`]. + pub last_cycle_at: Option, + /// Stamped every time `run_cycle` is invoked, success or failure — proves the loop is still + /// running even across a run of all-faulted cycles, without polluting the staleness signal the + /// other two timestamps carry (Defect A4). + pub last_attempt_at: Option, + /// Lifetime total — a useful counter, kept cumulative on purpose. NOT the predicate for + /// [`ClaimLoopState::ClaimableButNotClaiming`]; see [`Self::claims_submitted_this_cycle`]. + pub claims_submitted: u64, + /// THIS CYCLE's submission count, overwritten every `run_cycle` (Defect A3) — the correct half + /// of the `ClaimableButNotClaiming` predicate. + pub claims_submitted_this_cycle: u64, + pub claims_skipped_below_threshold: u64, + pub claims_skipped_fee_ceiling: u64, + /// Defect C2: lifetime count of claims skipped because the per-cycle aggregate fee budget was + /// already exhausted this cycle. + pub claims_skipped_cycle_budget: u64, + /// Defect E: lifetime count of claims REFUSED because the port returned an entry for a puzzle + /// hash other than this node's own — see [`ClaimOutcome::PayoutPuzzleHashMismatch`]. A + /// per-distributor counted fault (Defect B3), never [`Self::fault_reported`]. + pub claims_refused_payout_mismatch: u64, + /// Defect B3: THIS CYCLE's twin of [`Self::claims_refused_payout_mismatch`] — without it a + /// reader could not tell an ONGOING misdirection from an old, no-longer-recurring one, the same + /// per-cycle-vs-lifetime gap Defect A3 named for the other counters. + pub payout_hash_mismatches_this_cycle: u32, + /// THIS CYCLE's count of distributors observed with no entry slot (Defect B) — no longer a + /// lifetime blacklist size, because the engine no longer blacklists a launcher id permanently; + /// see [`super::engine::ClaimEngine`]'s module doc. + /// + /// # Defect R2: renamed from `terminal_no_entry_slot` + /// That name quoted SPEC §12.5 clause 1's "terminal, non-error" language to justify behaviour + /// that is deliberately non-terminal since the Defect B fix — a doc claim born false in the + /// commit that fixed the code. Renamed before #3268 publishes it over RPC. + /// + /// SPEC v0.1.3 §12.5 (the amendment R1 flagged as pending is now merged and tagged) confirms + /// this reading directly: an absent entry slot is terminal for ONE claim attempt, never for the + /// distributor, MUST NOT be cached, and MUST NOT accumulate into a permanent exclusion set — + /// this field satisfies v0.1.3 clause 6's "surfaced, not silently absorbed" requirement without + /// a tenth named [`ClaimLoopState`] variant: it is a per-cycle count, dated by + /// [`Self::last_attempt_at`] -- the field stamped unconditionally every cycle, the true + /// analogue of §2.3's `observed_at` -- and reset at the TOP of every `run_cycle` alongside the + /// other per-cycle counters, before any early return, so a stalled writer can never leave a + /// stale count sitting under a fresh timestamp (never a lifetime latch). + pub no_entry_slot_this_cycle: u32, + /// Set when a chain call THIS CYCLE returned `ClaimPortError::Other(_)` — reset at the start of + /// every `run_cycle` (Defect A1: this used to latch true for the rest of the process's life, + /// which would have permanently suppressed every other state once tripped once). + pub fault_reported: bool, + /// Consecutive cycles (including this one, if `fault_reported`) that have reported a fault — + /// resets to 0 the moment a cycle reports no fault. Surfaced via [`ClaimLoopState::Faulted`]. + pub consecutive_faulted_cycles: u32, + pub state: ClaimLoopState, +} + +impl Default for ClaimStatus { + fn default() -> Self { + ClaimStatus { + chain_unavailable_this_cycle: false, + distributors_known: 0, + distributors_with_own_entry: 0, + distributors_claimable: 0, + distributors_faulted: 0, + last_discovery_at: None, + last_cycle_at: None, + last_attempt_at: None, + claims_submitted: 0, + claims_submitted_this_cycle: 0, + claims_skipped_below_threshold: 0, + claims_skipped_fee_ceiling: 0, + claims_skipped_cycle_budget: 0, + claims_refused_payout_mismatch: 0, + payout_hash_mismatches_this_cycle: 0, + no_entry_slot_this_cycle: 0, + fault_reported: false, + consecutive_faulted_cycles: 0, + state: ClaimLoopState::Idle, + } + } +} + +impl ClaimStatus { + /// Derives [`ClaimLoopState`] from the status fields alone — a pure computation, so a test can + /// assert `ClaimableButNotClaiming` (or `Faulted`) directly against hand-built fields without + /// driving a whole engine cycle, and so a stalled writer can never manufacture a healthier state + /// than its own numbers support (SPEC §2.4's reasoning, applied to this loop's own surface). + /// + /// Precedence, most urgent first: `ChainSourceUnavailable` > `Faulted` > + /// `ClaimableButNotClaiming` > `Idle` > `Nominal`. See [`ClaimLoopState`]'s doc for why a fault + /// must never be absorbed into `Nominal` (Defect A1) and why a per-distributor fault (Defect B3) + /// must never set it. + /// + /// # F1: reads `chain_unavailable_this_cycle`, never `self.state` + /// The old guard compared against `self.state` -- last cycle's OWN computed output -- which + /// made `ChainSourceUnavailable` a process-lifetime latch (see [`ClaimLoopState`]'s "F1" doc + /// section). `chain_unavailable_this_cycle` is reset every cycle, so this reading is live. + #[must_use] + pub fn compute_state(&self) -> ClaimLoopState { + if self.chain_unavailable_this_cycle { + return ClaimLoopState::ChainSourceUnavailable; + } + if self.last_attempt_at.is_none() && self.last_cycle_at.is_none() { + return ClaimLoopState::Idle; + } + if self.fault_reported { + return ClaimLoopState::Faulted { + cycles: self.consecutive_faulted_cycles.max(1), + }; + } + // Defect B1: a magnitude comparison, not a zero-test -- `claims_submitted_this_cycle < 10` + // fires just as much when 1 of 10 claimable was submitted as when 0 were; a partial + // shortfall must never be masked by whichever claims did go through. + // + // F2: `payout_hash_mismatches_this_cycle` folds into the RIGHT side of the comparison. A + // mismatching distributor never enters `eligible`, so it is counted in NEITHER + // `claims_submitted_this_cycle` NOR `distributors_claimable` -- the shortfall was in + // neither term of this comparison. All-K-mismatching used to read `submitted = 0, + // claimable = 0` -> healthy. An ongoing mismatch is a real per-cycle shortfall exactly like + // an unmet `claimable`, so it belongs in the same predicate, never a separate signal + // nothing reads. + let shortfall_denominator = u64::from(self.distributors_claimable) + + u64::from(self.payout_hash_mismatches_this_cycle); + if self.claims_submitted_this_cycle < shortfall_denominator { + // F13: report the SAME quantity the predicate above just used, not the un-folded + // `distributors_claimable` alone. Before this fix, all-K-mismatching produced + // `ClaimableButNotClaiming { claimable: 0, submitted: 0 }` -- the name was right (F2 + // already folded mismatches into firing the state at all) but the payload said + // nothing was wrong, because it reported the term the mismatches were never counted + // in. The payload must carry the full shortfall the name is claiming, or it is a + // state whose numbers contradict its own name. + return ClaimLoopState::ClaimableButNotClaiming { + claimable: u32::try_from(shortfall_denominator).unwrap_or(u32::MAX), + submitted: u32::try_from(self.claims_submitted_this_cycle).unwrap_or(u32::MAX), + }; + } + ClaimLoopState::Nominal + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claimable_but_not_claiming_is_computed_from_fields_alone() { + let status = ClaimStatus { + distributors_claimable: 3, + claims_submitted_this_cycle: 0, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::ClaimableButNotClaiming { + claimable: 3, + submitted: 0 + } + ); + } + + /// Defect B1 regression: a magnitude comparison, not a zero-test. `claimable = 10, + /// submitted_this_cycle = 1` used to read `Nominal` because the old predicate only checked + /// `submitted_this_cycle == 0` -- one submission masked nine same-cycle skips. + #[test] + fn a_partial_shortfall_is_claimable_but_not_claiming_not_nominal() { + let status = ClaimStatus { + distributors_claimable: 10, + claims_submitted_this_cycle: 1, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::ClaimableButNotClaiming { + claimable: 10, + submitted: 1 + }, + "1 of 10 claimable submitted must still read the shortfall, never Nominal" + ); + } + + /// Defect B1 regression: the other half of the fix -- every claimable distributor submitted + /// must read `Nominal`, not a false-positive shortfall. + #[test] + fn claiming_every_claimable_distributor_is_nominal() { + let status = ClaimStatus { + distributors_claimable: 10, + claims_submitted_this_cycle: 10, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!(status.compute_state(), ClaimLoopState::Nominal); + } + + /// Defect A1/A2: this test used to assert `Nominal` here, encoding the bug (a reported fault + /// was silently absorbed into the healthy state) as intended behaviour. Inverted per the fix + /// brief: a fault must surface its own named state, never masquerade as either + /// `ClaimableButNotClaiming` or `Nominal`. + #[test] + fn a_reported_fault_surfaces_as_faulted_not_nominal() { + let status = ClaimStatus { + distributors_claimable: 3, + claims_submitted_this_cycle: 0, + fault_reported: true, + consecutive_faulted_cycles: 1, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::Faulted { cycles: 1 } + ); + } + + /// Defect A3 regression: `distributors_claimable` is a per-cycle snapshot and + /// `claims_submitted` (cumulative) only ever grows, so comparing the two lets one success in + /// the process's lifetime mask every later cycle where the submit path has since broken. The + /// fix compares against `claims_submitted_this_cycle` instead. + #[test] + fn a_lifetime_submission_does_not_mask_a_later_cycle_that_submits_nothing() { + let status = ClaimStatus { + distributors_claimable: 1, + claims_submitted: 7, // non-zero lifetime total from an earlier successful cycle + claims_submitted_this_cycle: 0, // but THIS cycle submitted nothing + fault_reported: false, + last_cycle_at: Some(2), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::ClaimableButNotClaiming { + claimable: 1, + submitted: 0 + } + ); + } + + #[test] + fn nothing_claimable_and_nothing_submitted_is_nominal() { + let status = ClaimStatus { + distributors_claimable: 0, + claims_submitted_this_cycle: 0, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!(status.compute_state(), ClaimLoopState::Nominal); + } + + #[test] + fn chain_source_unavailable_wins_over_every_other_reading() { + let status = ClaimStatus { + distributors_claimable: 5, + claims_submitted: 0, + fault_reported: false, + last_cycle_at: Some(1), + chain_unavailable_this_cycle: true, + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::ChainSourceUnavailable + ); + } + + /// F1 regression at the `compute_state` level: a PAST cycle's `ChainSourceUnavailable` must + /// never leak into THIS cycle's reading once `chain_unavailable_this_cycle` is false again -- + /// proving the fix reads the per-cycle flag, never `self.state` (which this struct literal + /// deliberately still carries as `ChainSourceUnavailable`, simulating what a stale `self.state` + /// would look like if the old guard were still in place). + #[test] + fn a_past_cycles_chain_unavailable_state_does_not_latch_the_next_computation() { + let status = ClaimStatus { + distributors_claimable: 1, + claims_submitted_this_cycle: 1, + fault_reported: false, + last_cycle_at: Some(2), + chain_unavailable_this_cycle: false, + state: ClaimLoopState::ChainSourceUnavailable, + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::Nominal, + "chain_unavailable_this_cycle is false this cycle -- a stale self.state must not win" + ); + } + + /// Defect B3 regression: a per-distributor payout-hash mismatch count, with no cycle-wide + /// `fault_reported`, must read the `ClaimableButNotClaiming` shortfall it actually represents, + /// never `Faulted` -- `engine.rs` is the one that decides `fault_reported`, but this proves the + /// state computation itself no longer has any path from "a mismatch happened" to `Faulted`. + #[test] + fn a_payout_mismatch_count_alone_does_not_force_faulted() { + let status = ClaimStatus { + distributors_claimable: 2, + claims_submitted_this_cycle: 1, + payout_hash_mismatches_this_cycle: 1, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + // F13: `claimable` is now the FOLDED shortfall (2 claimable + 1 mismatch = 3), not + // the un-folded `distributors_claimable` alone -- see the F13 regression below for + // the case (all-K-mismatching) that made the un-folded reading actively misleading. + ClaimLoopState::ClaimableButNotClaiming { + claimable: 3, + submitted: 1 + } + ); + } + + /// F13 regression: all-K-mismatching must report the shortfall it actually represents, not a + /// payload that contradicts its own state name. Before the fix, this read `claimable: 0, + /// submitted: 0` -- a name saying something is wrong next to numbers saying nothing is. Must + /// go red with only the `claimable: shortfall_denominator` fix reverted to + /// `claimable: self.distributors_claimable`. + #[test] + fn all_k_mismatching_reports_the_folded_shortfall_not_zero() { + let status = ClaimStatus { + distributors_claimable: 0, + claims_submitted_this_cycle: 0, + payout_hash_mismatches_this_cycle: 4, + fault_reported: false, + last_cycle_at: Some(1), + ..ClaimStatus::default() + }; + assert_eq!( + status.compute_state(), + ClaimLoopState::ClaimableButNotClaiming { + claimable: 4, + submitted: 0 + }, + "the payload must carry the same shortfall the predicate fired on, never 0" + ); + } +}