diff --git a/CHANGELOG.md b/CHANGELOG.md index 46528fdc..bd03e708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to this project are documented here. This project adheres to [Semantic Versioning](https://semver.org) and [Conventional Commits](https://www.conventionalcommits.org). +## [0.262.0] - 2026-10-02 + +### BREAKING +- `dig.listRewardDistributorCommitments`: `recoverable_base_units` is now `null` (key present) when a commitment is + not recoverable; `0` remains a real zero. Clients that decode it as a plain integer must accept `null`; dig-app + `nightly-20261002` or later is required to render it. Cascades dig-rpc-protocol 0.15, dig-peer 0.17, + dig-download 0.26, dig-peer-selector 0.15 (#631, dig_ecosystem#3442) + +### Features +- **rewards:** `recoverable_base_units` can say "not recoverable" instead of collapsing to 0 (#631) + +### Bug Fixes +- **rewards:** Install FundedDistributorRegistry at startup; refuse a malformed launcher_id (#626, dig_ecosystem#3292) +- **rewards:** Fold observed_at to the oldest report; log IoFailed (#628, dig_ecosystem#3323, dig_ecosystem#3324) + +### Documentation +- **spec:** Section 26 prover loop spawn, four controls, kill switch (#627, dig_ecosystem#3265) + +### Chores +- **deps:** Bump the dig-rpc-protocol cascade to 0.14.0 (#629, dig_ecosystem#3329) + ## [0.255.0] - 2026-09-07 ### Chores diff --git a/Cargo.lock b/Cargo.lock index 7f57fc7f..021d92b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2689,9 +2689,9 @@ dependencies = [ [[package]] name = "dig-download" -version = "0.24.0" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d705dda562d63c03a1a481087c3b9f632b90982676ea8bb4e55f2cd04c4465fc" +checksum = "8586f4d17592fccaf88e26b0f98e162cf79edbaf8cd27cdb2e29f90f405b682b" dependencies = [ "async-trait", "dig-constants 0.11.2", @@ -3041,7 +3041,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.261.0" +version = "0.262.0" dependencies = [ "async-trait", "axum", @@ -3151,9 +3151,9 @@ dependencies = [ [[package]] name = "dig-peer" -version = "0.15.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01657d5ef42a4ebf038d53b3b398997057417558cd6c059f4f58716e68676f34" +checksum = "dd816ee4dde0fa218bcc6a5b260b16a0d69132f172a2a57202f1b9b4d67bfd6e" dependencies = [ "chia-protocol 0.36.1", "chia-traits 0.36.1", @@ -3191,9 +3191,9 @@ dependencies = [ [[package]] name = "dig-peer-selector" -version = "0.13.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3be02a4acba35b9580f3655c95cf4e127031e100e684069a17fb2829bb4e68b" +checksum = "286da77c5d95fc7ba60e81626c6f7f20fda49e4f7d36b38d48ebf7b37b6cdf72" dependencies = [ "dig-dht", "dig-nat", @@ -3217,9 +3217,9 @@ dependencies = [ [[package]] name = "dig-rewards-coin" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7afdbc8cf70e84ad13779824948d165577df91e0409cd65a40130f5421e99f5b" +checksum = "aa83ab0ad468d538c78bcd5fda3abb87116432bb97b115f256860ef81b923bec" dependencies = [ "chia-bls 0.36.1", "chia-consensus 0.36.1", @@ -3237,9 +3237,9 @@ dependencies = [ [[package]] name = "dig-rpc-protocol" -version = "0.12.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb22a4741239303c9558b00d20c0cb6d9afad965382656bece3d4bbe9641f24" +checksum = "1160e00673f442119f53557896bc155ec808da0df5eb9d09fc1b0e03f8c6b133" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 5d41b816..cf3f5508 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.261.0" +version = "0.262.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over # it, so silent wrapping in release would turn a length bug into a memory/logic hazard. diff --git a/SPEC.md b/SPEC.md index 6d7d6ace..7f11aa55 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1090,6 +1090,9 @@ payee claim state, failing §7.2's WHO-NAMES-THE-SUBJECT test). Those keep their carry `requires_auth: true`. `requires_auth` is the COMPILED statement of the HTTP token gate: the set of catalogued methods with `requires_auth: true` MUST equal the set `server.rs` refuses `-32030 UNAUTHORIZED` without a master or paired token (`requires_http_token`), and a test pins the equality. +The loop whose records `dig.getRewardProverStatus` reads — the reward prover loop — and the four +controls it is gated on (default-off, an opt-in that names the daily XCH bound, dry-run, a +file-driven kill switch) are specified in §26. The two chain-keyed reward reads `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments` are OPEN (dig_ecosystem#3351) and rate-bounded per source (§10, `-32034`). @@ -10182,3 +10185,667 @@ the way dig-node#574 raised for mirror-bond ids. * The one direction that fails EXPENSIVE is the unbonded window (§25.13.6), bounded to one confirmation plus one round in the common case, identical in kind to the rollover window the lifecycle already opens weekly. + +--- + +## 26. Reward prover loop — the spawn, the four controls, and the kill switch (dig_ecosystem#3265) + +`dig-node-core::rewards` (dig-node#593, dig_ecosystem#3250) is the funder-side prover ENGINE for +`dig-rewards-coin`'s reward distributor: admission, the mirror-coin gate, possession challenges, +bounded entry-set writes, the status record, the cycle deadline and the heartbeat. At tip `69c793a7` +it is a library with no call site: nothing spawns a periodic loop, nothing composes +`admit → gate → challenge → writes`, and `Node::register_reward_prover_status` +(`crates/dig-node-core/src/lib.rs:621-627`) carries `allow(dead_code)` because no production caller +exists. That absence is the only reason the engine was safe to merge. The PR that adds the spawn is +the PR in which a system that spends the operator's XCH on a timer is born, and this section is the +contract that PR is gated on: it locks the shape of the spawn, the configuration file, the four +controls (default-off, an opt-in that names the number, dry-run, a kill switch), the honesty of the +status record under every failure the composed system can have, the test that proves the loop runs +more than once, and the order in which the remaining port work lands relative to the controls. + +**The hard truth this section states rather than assumes.** At tip `69c793a7` a spawned prover loop +faults on EVERY cycle with `ChainSourceUnavailable`: the production `RewardsChainPort`, +`RealRewardsChainPort` (`crates/dig-node-service/src/rewards/chain_port.rs:77-97`), implements only +`distributor_report`; `funded_distributors`, `distributor_state`, `submit_entry_writes` and +`spend_new_epoch` answer `ChainPortError::Unavailable`. No production `MirrorCoinReader` +(`gate.rs:52`), no production `ChallengeTransport` (`challenge.rs:185`) and no candidate-discovery +source exist. Those are dig_ecosystem#3421 and dig_ecosystem#3423. The controls in this section are +verifiable at this tip; distribution is not, and a reader MUST NOT conclude from a spawned loop that +anything is being distributed (§26.12). + +Three times in this epic a loop has spawned, reported an unremarkable state, and distributed nothing: +the funded-distributor registry that was never installed (dig_ecosystem#3292), the claim loop whose +`NoHintSource` returned empty in production, and this chain port. A status surface that cannot say +what it does not know is the epic's signature defect, and every clause in §26.9 exists to make that +shape unsatisfiable here: a cycle that could not see what it needed to see MUST fault, MUST NOT +advance `last_cycle_completed_at`, and MUST name the missing seam. + +Clauses marked **(target)** are specified here and not implemented at tip `69c793a7`; §26.14 lists +them with the step that lands each. Every other clause cites the code that already satisfies it. + +**Units, named once.** XCH fees are in **mojos** (`1 XCH = 1_000_000_000_000 mojos`, 1e12). Reward +figures are in **$DIG base units** (`1 $DIG = 1_000 base units`, 3 decimals). Time is Unix seconds. +A day is `86_400` s (`crates/dig-node-core/src/rewards/writes.rs:27`). Nothing in this section +converts between XCH and $DIG: the node holds no exchange rate and MUST NOT print one. + +### 26.1. Scope, and what this section does NOT specify + +This section specifies, for the `dig-node` SERVICE crate: + +1. the spawn site and the seam that decides whether to spawn (§26.3); +2. the operator configuration file `rewards-prover.json`, every key and every default (§26.4); +3. the four controls, as behaviour an operator can observe in a running node (§26.5-§26.8); +4. the cycle composition's HONESTY obligations — what a cycle must report when a seam it depends on + is absent or unavailable, and what it must never report (§26.9); +5. the tests the spawning PR MUST carry (§26.10) and the gate the composed system MUST pass before + live mode is released (§26.11). + +It does NOT specify, and a reader MUST NOT read it as specifying: + +- the engine's own rules — cycle period, heartbeat, deadline, admission, gate, challenge windows, + strike accounting, the four write bounds, eviction economics. Those are + `dig-rewards-coin/SPEC.md` (the published crate's copy, currently 0.8.0) §2-§6 and §12, transcribed + as constants in `crates/dig-node-core/src/rewards/spec_constants.rs`. This section names those + constants and functions; it restates no formula they own. +- the wire shape of `dig.getRewardProverStatus`, `RewardProverStatus`, `ProverCounters` or + `ProverState`. Those are `dig-rpc-protocol` 0.12.0 (`types.rs:1466-1484`, `:1529`, `:1573`, + `Half` at `:1659-1677`), pinned by `crates/dig-node-core/Cargo.toml:201`. **This section adds no + RPC method and no wire field.** Two things it would like to say on the wire — that a loop is in + dry-run, and that a loop is stopped by the switch rather than by an operator pause — cannot be said + in 0.12.0 (`ProverState` is a closed nine-member set that rejects unknown strings; + `RewardProverStatus` has no mode field), and this section therefore says them in the LOG only + (§26.7 clause 6, §26.8 clause 9). A `mode` field is a `dig-rpc-protocol` change routed through the + protocol lane, not through this section. +- the realisation of the four `RewardsChainPort` methods (dig_ecosystem#3421), the + `MirrorCoinReader`, `ChallengeTransport` and discovery source (dig_ecosystem#3423), the `writes.rs` + hardening items (dig_ecosystem#3422), or the persistence of the process-local counters + (dig_ecosystem#3274). §26.2 orders them; §26.9 states what the composed loop MUST report while + each is absent. +- any signing authority. Live-mode entry-set writes are distributor-singleton spends that need the + manager singleton's authority (`dig-rewards-coin/SPEC.md` §7.2). WHERE that key lives and WHO + signs is a custody question for dig_ecosystem#3421 and the protocol lane; nothing in this section + places a signer in the node, and the spawning PR MUST NOT. + +### 26.2. Sequencing — the controls land first, and live mode is released by a gate, not by a merge + +1. The spawning PR (dig_ecosystem#3265) MUST land the four controls, the composer, the status + honesty of §26.9 and the tests of §26.10 BEFORE any PR realises a `RewardsChainPort` write + method, a `MirrorCoinReader`, a `ChallengeTransport` or a discovery source. The controls gate the + port work, not the reverse: a port PR that arrives first would make a loop with no switch and no + stated bound the first thing an operator could turn on. +2. At that tip every cycle of a spawned loop faults `ChainSourceUnavailable` at + `funded_distributors` (§26.9 clause 4). That is the CORRECT observable behaviour of the spawning + PR and MUST be what its review verifies — not "the loop runs", but "the loop runs, and says on + every cycle that it cannot see its distributors". +3. **Live mode is released by a code constant that only the composed-system gate may flip.** + `crates/dig-node-service/src/rewards/config.rs` MUST declare + `pub const PROVER_LIVE_MODE_RELEASED: bool = false;` **(target)**. `decide_prover_driver` + (§26.3) MUST refuse `mode = live` with `ProverDriverRefusal::LiveModeNotReleased` while it is + `false`. The constant MAY be set to `true` only by a one-line PR whose description links the + passing verdict of the composed-system gate (§26.11) and which lands AFTER dig_ecosystem#3421, + #3422 and #3423. A reader MUST NOT conclude from `mode: "live"` in a configuration file that the + node will spend: until the constant is `true`, that file produces the named refusal and no loop. +4. Order of landing, normative: (a) this section's controls + composer (dig_ecosystem#3265); + (b) the funded-distributor registry install (dig_ecosystem#3292, a live lane at the time of + writing); (c) the four port methods, the state-dir `WriteBoundStore` of §26.9 clause 12, and the + §23 audit entry for a live bundle (dig_ecosystem#3421); (d) the `writes.rs` hardening + (dig_ecosystem#3422); (e) the reader, transport and discovery source (dig_ecosystem#3423); + (f) the composed-system gate (§26.11); (g) the one-line release of clause 3. (c), (d) and (e) MAY + land in any order among themselves; (f) MUST follow all of them; (g) MUST follow (f). + +### 26.3. The spawn site and the three-function seam + +1. The prover loop is spawned from `serve_with_shutdown` + (`crates/dig-node-service/src/server.rs:2198`), immediately after the claim-loop spawn + `crate::rewards_claim::spawn_claim_driver_from_config(..)` (`server.rs:2353-2356`) + **(target)**. By that point the funder-side chain port has been installed on `Node` inside + `if config.enable_chain_sync` (`server.rs:2247-2274`), so `Node::reward_chain_port()` + (`crates/dig-node-core/src/lib.rs:708`) is `Some` exactly when chain sync is on. +2. The driver and its configuration live in the SERVICE crate's existing + `crates/dig-node-service/src/rewards/` (`mod.rs:24-27` today exports `chain_port`, + `chain_source`, `RealRewardsChainPort`): `rewards/config.rs`, `rewards/driver.rs`, + `rewards/dry_run.rs` **(target)**. They MUST NOT live in `dig-node-core`: core owns the engine; + the service owns spawning, files in the state directory and chain adapters — the same split + `rewards_claim/` already has. +3. The seam MUST mirror the claim loop's three functions at + `crates/dig-node-service/src/rewards_claim/driver.rs:645-651`, `:663-687`, `:695-706` + **(target)**: + - `decide_prover_driver(inputs) -> ProverDriverDecision` — pure, no I/O, no logging; every + branch unit-testable. Its inputs are the loaded `RewardsProverConfig` (or its corrupt marker), + `enable_chain_sync` (`crates/dig-node-service/src/config.rs:155`), whether the stop sentinel of + §26.8 exists, whether `Node::reward_chain_port()` is `Some`, and `PROVER_LIVE_MODE_RELEASED`. + - `spawn_prover_driver_if(decision, handle, spawn)` — invokes `spawn` exactly when the decision + is `Spawn`; every other branch records its `ProverDriverRefusal` on the injected + `ProverLoopHandle` and logs it by name at `warn!` (`target: "rewards_prover"`), never + silently. A refused spawn registers NOTHING on `Node`. + - `spawn_prover_driver_from_config(enable_chain_sync, node, wallet, ..)` — the ONLY reader of + the process-wide handle singleton and the one call `serve_with_shutdown` makes. +4. `ProverDriverRefusal` MUST be the closed set, in the order the decision checks them: + `Disabled` (`enabled = false`, including a missing file), `ConfigCorrupt` (file present, not + parseable — §26.4 clause 6), `StopSentinelPresent` (§26.8 clause 4), `ChainSyncDisabled`, + `NoStandardFee` (`standard_fee_mojos = 0`), `StandardFeeAboveMaximum`, `LiveModeNotReleased` + (§26.2 clause 3), `NoChainPort`, `NoOperatorWallet` (§26.9 clause 7). Each MUST be + distinguishable in the log and on the handle; three of them (`Disabled`, `ChainSyncDisabled`, + `NoChainPort`) would otherwise collapse into the same "nothing registered" reading, which is the + collapse `ClaimDriverRefusal` (`rewards_claim/driver.rs:80-99`) already exists to prevent. +5. `ProverLoopHandle` **(target)** mirrors `ClaimLoopHandle` (`rewards_claim/driver.rs:65-70`): + `cycles_driven() -> u64` (incremented once per scheduler pass, whatever the pass produced — an + OBSERVED count, never "the task was spawned"), `refusal() -> Option`, + `mode() -> Option`, and `inventory() -> ProverInventory` (§26.9 clause 5). It is + in-process only: nothing in this section puts it on the wire (dig_ecosystem#3261 — no `*Reward*` + method may be peer-reachable or non-`Tier::Control`, and this section adds no method at all). +6. Every clause of `rewards_claim/driver.rs` that this section says to mirror is mirrored for its + SHAPE. **Its default is NOT mirrored**: `RewardsClaimConfig::default_enabled()` returns `true` + (`rewards_claim/config.rs:140-142`), and a prover configuration copying that line violates + §26.5. The claim loop's cadence is operator-configurable and sanitised at three layers + (`config.rs:232-239` floors to 60 s at load; `driver.rs:459-500` substitutes the default for a + ZERO cadence and CLAMPS an over-maximum cadence or jitter to `CLAIM_SCHEDULE_SECONDS_MAX`, + `driver.rs:420`); the prover's cadence is not configurable at all (§26.4 clause 8), so none of + that sanitiser is copied either. + +### 26.4. The configuration file `rewards-prover.json` — every key, every default + +1. **Path.** `/rewards-prover.json`, where `` is + `crate::state::state_dir()` (`crates/dig-node-service/src/state.rs:352`; `DIG_NODE_STATE_DIR` + overrides, `state.rs:34`) — a sibling of `rewards-claim.json` (`rewards_claim/config.rs:54`). + **(target)** +2. **The node never writes this file.** It carries operator INTENT only. No runtime state — no + cursor, no spend, no timestamp — is ever persisted into it; the write bounds live in the store + of §26.9 clause 12. A file the node never writes cannot be corrupted by the node, cannot re-grant + a budget on a crash, and needs no "corrupt spend" arithmetic of the kind `rewards_claim/config.rs` + F8/F14 had to add. +3. **Keys, types, defaults** — every key `#[serde(default = ..)]`, so a file written before a key + existed loads that key's DEFAULT and never a fabricated choice: + + | key | type | default | meaning | + |---|---|---|---| + | `enabled` | bool | **`false`** | Whether a loop is spawned at all. §26.5. | + | `mode` | `"dryRun"` \| `"live"` | **`"dryRun"`** | §26.7. Any other string is a parse error (clause 6), never a default. | + | `standard_fee_mojos` | u64 | **`0`** | The operator's standard per-bundle transaction fee, in mojos. `0` means "not stated" and refuses the spawn (`NoStandardFee`). §26.6. | + + There are exactly three keys. A fourth key is a SPEC amendment to this table first. +4. **Serde shape** **(target)**: `RewardsProverConfig { enabled, mode, standard_fee_mojos }`; + `ProverMode { DryRun, Live }` with `#[serde(rename_all = "camelCase")]` (wire strings `dryRun`, + `live`), no `#[serde(other)]`, no `Default` that could coerce an unknown string. `enabled`'s + default MUST be the constant `PROVER_ENABLED_DEFAULT: bool = false`, and the crate MUST carry a + compile-time assertion `const _: () = assert!(!PROVER_ENABLED_DEFAULT, "..");` so the default + cannot be flipped without deleting the assertion in the same diff. A `#[test]` MUST additionally + prove `RewardsProverConfig::load_from().enabled == false` and that + `decide_prover_driver` on that load is `Disabled`. +5. **Maximum fee.** `PROVER_STANDARD_FEE_MOJOS_MAX = 100_000_000_000` (0.1 XCH per bundle; + 2.4 XCH/day/distributor at the §26.6 ceiling) **(target)**. A `standard_fee_mojos` above it MUST + be REFUSED (`StandardFeeAboveMaximum`), never clamped: a fee that large is a typo, and clamping + a money number silently hides the typo from the person who made it. The maximum is ten times + the congested figure §26.6 prints and twenty thousand times a routine one; it exists so the + saturation direction noted at `writes.rs:66-78` is unreachable from configuration + (dig_ecosystem#3422 owns the arithmetic itself). +6. **A missing file is a clean default** (`enabled = false`, nothing spawns, nothing logged above + `debug!`). **A present file the node cannot parse is a different fact**: `load_from` MUST return + a corrupt marker (never `default()`), the spawn MUST be refused as `ConfigCorrupt`, and the + refusal MUST be logged at `warn!` with the path — the operator who wrote the file is exactly the + person who needs to learn it was not read. Unknown keys are ignored (a mistyped `enable` yields + `enabled = false`; a mistyped `mode` yields `dryRun`; a mistyped fee yields `0` and + `NoStandardFee`): every typo fails toward not spawning or not spending, which is why + `deny_unknown_fields` is not required. +7. **Example, the smallest file that produces a loop:** + + ```json + { "enabled": true, "mode": "dryRun", "standard_fee_mojos": 5000000 } + ``` + + produces a dry-run loop at a 0.000005 XCH standard fee. Changing `"dryRun"` to `"live"` produces + `LiveModeNotReleased` until §26.2 clause 3 is satisfied, and a live loop after. +8. **Cadence is a constant, not a key.** The cycle period is + `PROVER_CYCLE_PERIOD_SECONDS = 3_600`, the heartbeat `PROVER_HEARTBEAT_SECONDS = 60`, the + deadline `PROVER_CYCLE_DEADLINE_SECONDS = 900` (`spec_constants.rs:19-26`; + `dig-rewards-coin/SPEC.md` §2.5). The scheduler's interval MUST be + `rewards_claim::next_interval_seconds(PROVER_CYCLE_PERIOD_SECONDS, PROVER_CYCLE_JITTER_SECONDS_MAX, jitter)` + (`rewards_claim/cadence.rs:31`, re-exported at `rewards_claim/mod.rs:57`) with + `PROVER_CYCLE_JITTER_SECONDS_MAX = 300` **(target)** and an OS-entropy `JitterSource` in + production (`FixedJitter(0)` in tests, `cadence.rs:18`). Jitter exists so funders do not converge + on the same second; it MUST NOT be configurable, and a reader MUST NOT conclude the period is. +9. **Runtime re-read** — §26.8 clause 5: the switch task re-reads `enabled` and the sentinel every + `PROVER_HEARTBEAT_SECONDS`. `mode` and `standard_fee_mojos` are read ONCE, at spawn. A runtime + change to either is NOT honoured until the node restarts, and the spawn-time line of §26.6 is + the only place the operator's mode and fee are ever confirmed back to them: a mode that could flip + from dry-run to live without that line being printed would be a silent money escalation. + +### 26.5. Control 1 — default off, and what the status surface says when off + +1. With no `rewards-prover.json`, or with `enabled = false`, NO loop is spawned, NO + `StatusHandle` is registered on `Node`, and `Node::reward_prover_status_snapshots()` + (`lib.rs:632`) stays empty. `dig.getRewardProverStatus` then answers + `statuses: { outcome: "consulted", observed_at, items: [] }` + (`crates/dig-node-core/src/seams/dig_rpc/dispatch.rs:923-1000`, `Half::Consulted` at `:995`), + which the wire contract defines as "this node runs no prover loops" — a TRUE statement, read from + a real registry, and it MUST stay one: the spawning PR MUST NOT register a placeholder record for + a disabled loop, and MUST NOT register anything before `decide_prover_driver` returns `Spawn`. +2. A refused spawn (any `ProverDriverRefusal`) is observable in exactly two places: the `warn!` line + of §26.3 clause 3 and `ProverLoopHandle::refusal()`. It is NOT observable on the wire. That is a + limitation of `dig-rpc-protocol` 0.12.0, stated here so nobody papers over it with a fabricated + record. +3. An upgrade MUST NOT start a loop. A node upgraded from a version without this section, with no + file, has `enabled = false`. This is the whole content of control 1 and the reason clause 4 of + §26.4 makes the default a compile-time assertion rather than a convention. + +### 26.6. Control 2 — the opt-in names the number + +The enabling surface is the configuration file plus ONE log line the node prints when it spawns. +`dig-rewards-coin/SPEC.md` §2.2 clause 5 is the governing precedent: *"a risk with a stated bound is +a decision a funder can make, and a risk without one is only alarming."* + +1. **The operator states the fee; the node derives the bound.** `standard_fee_mojos` is the operand + `FeeBudget::new(standard_fee_mojos, now)` and `FeeBudget::daily_limit_for(standard_fee_mojos)` + take (`writes.rs:47`, `:65`). The daily ceiling is THAT function's answer — the node MUST NOT + restate the product anywhere else, and the operator is never asked to type a ceiling and have the + node divide it (a second formula is how two paths bound one spend differently, `writes.rs:68-71`). + `0` MUST refuse the spawn (`NoStandardFee`): a loop whose logged bundles carry `fee_mojos = 0` + would reconcile against nothing. +2. **The spawn-time line** **(target)**: exactly one `tracing::warn!` (money, not chatter) with + `target: "rewards_prover"`, emitted after `decide_prover_driver` returns `Spawn` and before the + first cycle, carrying at least these structured fields, each in the unit its name states: + + | field | value | source | + |---|---|---| + | `mode` | `dryRun` \| `live` | §26.4 | + | `standard_fee_mojos` | the configured fee | §26.4 | + | `bundles_per_day` | `24` | `writes.rs:33` (`SECONDS_PER_DAY / ENTRY_WRITE_MIN_INTERVAL_SECONDS`) | + | `entry_actions_per_day` | `192` | `24 × MAX_ENTRY_WRITES_PER_BUNDLE` (`spec_constants.rs:53`) | + | `daily_fee_ceiling_mojos_per_distributor` | `FeeBudget::daily_limit_for(standard_fee_mojos)` | `writes.rs:65` | + | `daily_fee_ceiling_xch_per_distributor` | the same figure over 1e12, printed to 6 decimals | — | + | `yearly_fee_ceiling_xch_per_distributor` | the daily XCH figure × 365 | — | + | `max_removals_per_day` | `192` | every bundle all `Remove` | + | `max_churns_per_day` | `96` | one `Remove` + one `Add` per churn | + | `full_set_flush_days_pure_eviction` | `1.3` | `250 / 192` (`MAX_ENTRIES_PER_DISTRIBUTOR`, `spec_constants.rs:75`) | + | `full_set_flush_days_churn` | `2.6` | `250 / 96` | + | `eviction_settles_full_accrued_balance` | `true` | `dig-rewards-coin/SPEC.md` §6.4 | + + and the message text MUST state, in words, that every `Remove` pays the evicted entry its FULL + accrued balance from the reserve, ignoring `payout_threshold` (§6.4), so that sustained eviction + can flush a full 250-entry set's accrued balance in about 1.3 days, and that every figure is + PER FUNDED DISTRIBUTOR — the node's total is that figure times the number of distributors it + funds, which is not known until the first inventory read (§26.9 clause 5) and MUST be printed on + the per-cycle line once it is. +3. **The worked figures**, which a test MUST pin (`#[test]` over the rendered fields, not over the + prose): at the congested fee `standard_fee_mojos = 10_000_000_000` (0.01 XCH) the ceiling is + `240_000_000_000` mojos = **0.24 XCH/day = 87.6 XCH/year** per distributor; at a typical + `5_000_000` (0.000005 XCH) it is `120_000_000` mojos = 0.00012 XCH/day = 0.0438 XCH/year. +4. **The two flush figures are two different numbers and MUST both be printed.** 192 `Remove` + actions/day is the pure-eviction ceiling and yields ~1.3 days for 250 entries; 96/day is the + evict-and-re-add CHURN ceiling and yields ~2.6 days. The ticket text pairs "96 evictions/day" with + "~1.3 days"; that pairing is arithmetically false (250 / 96 = 2.6) and MUST NOT be printed. + `crates/dig-node-core/src/rewards/mod.rs` carries the 2.6-day figure today and MUST gain the + 1.3-day figure in the same PR **(target)**. +5. **The rate bound and the fee ceiling are ONE control**, as `mod.rs` already states: 24 bundles/day + is simultaneously the rate limit and the fee ceiling, and `FeeBudget` adds no second protection + on top of it. The spawn-time line MUST NOT describe them as two independent limits. + +### 26.7. Control 3 — dry-run + +Dry-run runs the full cycle — inventory, chain state, discovery, gate, challenge, decision, the +write scheduler with its real bounds — and logs the bundle it WOULD submit without submitting it. +An operator watches the loop make decisions about their money before authorising it to act. + +1. `mode = dryRun` is the default (§26.4). A dry-run loop needs `enable_chain_sync = true` and an + installed chain port exactly as a live one does: its reads are real. +2. **The decorator** **(target)**: `DryRunChainPort` in + `crates/dig-node-service/src/rewards/dry_run.rs`, wrapping the installed port. It delegates the + three reads (`funded_distributors`, `distributor_state`, `distributor_report`) unchanged. For the + two writes it MUST NOT call the inner port at all: + - `submit_entry_writes(bundle)` MUST log ONE `warn!` (`target: "rewards_prover"`, + `mode = "dryRun"`) carrying `launcher_id` (hex), `fee_mojos`, `adds`, `removes`, and for every + action its kind and `payout_puzzle_hash` (hex) (`EntryAction`, `port.rs:124-136`; + `EntryWriteBundle`, `port.rs:139-144`), with the message stating that each `Remove` WOULD settle + that entry's full accrued balance (§6.4); then return `Ok(())`. + - `spend_new_epoch(launcher_id)` MUST log the same way and return `Ok(())`. + A test MUST prove, with an inner port that panics on either write, that a dry-run cycle reaching + a `Bundle` outcome completes without the inner write being called. +3. **Selection is by construction, once, at spawn**: `spawn_prover_driver_from_config` wraps the + installed port in `DryRunChainPort` when `mode = dryRun` and passes the installed port bare when + `mode = live`. There is no runtime toggle (§26.4 clause 9). +4. **Dry-run MUST NOT mutate the live write bounds.** `PersistedEntryWriter::commit` + (`writes.rs:408-414`) persists bounds "once the caller has confirmed the chain accepted the + bundle"; in dry-run the chain accepted nothing. The dry-run composer therefore runs + `PersistedEntryWriter` over a DRY-RUN `WriteBoundStore` **(target)**: an in-memory store, + process-lifetime, seeded per distributor by one read-only `load` from the live store of §26.9 + clause 12 on first use, and never calling the live store's `save`. Consequences a reader MUST + hold: the logged bundles honour the live cooldowns and today's live spend as they stood at first + use; dry-run accounting resets on restart; switching to live starts from the live bounds exactly + as they were. Dry-run MUST NOT advance `spent_mojos_today` in the live store — a dry-run that + burned tomorrow's live budget would record mojos never spent, which is a money lie in persisted + state. +5. **Dry-run MUST NOT increment `entries_added` / `entries_removed`** and MUST NOT write a §23 + audit entry: nothing moved. `last_entry_write_at` is chain-derived (`port.rs:117-119`) and stays + honest by construction. The log line of clause 2 is the ONLY record of a would-be bundle. +6. **Dry-run is log-only, and this section says so rather than implying otherwise.** `dig-rpc-protocol` + 0.12.0 cannot carry a mode: `ProverState` is a closed set (`types.rs:1466-1484`, and the + protocol's own test `prover_state_covers_the_closed_set_and_rejects_unknown`) and + `RewardProverStatus` has no mode field. A dry-run loop and a live loop are therefore + INDISTINGUISHABLE over `dig.getRewardProverStatus`; the spawn-time line (§26.6) and every + per-cycle line (§26.9 clause 10) carry `mode`. Making dry-run wire-visible is a protocol-lane + change and MUST NOT be attempted by adding a state string or a field on the node side. + +### 26.8. Control 4 — the kill switch + +The switch stops the loop without stopping the node, and it MUST be reachable while the loop is +wedged. It needs no RPC method. + +1. **Two tasks, one channel** **(target)**. `spawn_prover_driver` starts a + `tokio::sync::watch::channel(false)` and two detached tasks: + - the **switch task**, which owns the `Sender`: every `PROVER_HEARTBEAT_SECONDS` it calls + `heartbeat_tick` (`cycle.rs:79-81`) on every registered `StatusHandle`, re-reads the two stop + conditions of clause 4, and on either sends `true`; + - the **cycle task**, which holds a `Receiver`: it sleeps the §26.4 clause 8 interval, then runs + the cycle of §26.9 inside `tokio::select!` against `stop.changed()`, so a cycle in flight is + DROPPED the instant the switch flips, not at the 900 s deadline. + The core `heartbeat_loop` (`cycle.rs:86-105`) is the receiver-side precedent — it already takes + `watch::Receiver` and exits on `true` — but it is not the production ticker here because it + cannot read the switch; the switch task calls the same `heartbeat_tick` it does. +2. **Reachability while wedged.** The switch task never awaits the cycle task and never takes a lock + the cycle holds across an `.await`; it is an independent task on the runtime, so a cycle future + parked on a socket cannot delay it. The cycle task's `select!` polls `stop.changed()` on every + wake, so a parked cycle future is cancelled by drop. The one way this argument fails is a cycle + future that BLOCKS its executor thread (a synchronous `ChainSource` call on the async runtime): + the `select!` in that task can then not be polled. Therefore every synchronous chain read the + cycle makes MUST run under `tokio::task::spawn_blocking`, as `distributor_report` already does + (`chain_port.rs:110`), and a review of the spawning PR MUST check the composed cycle for a + blocking call on the runtime thread as a blocking finding. +3. **Latency bound.** From the moment a stop condition becomes true on disk to `Stopped` on every + record: at most `PROVER_HEARTBEAT_SECONDS` (60 s) plus one scheduler wake. A test MUST pin it + (§26.10 clause 3). +4. **The two stop conditions**, both read from the state directory by the switch task: + - `rewards-prover.json` no longer says `enabled: true` — including the file being missing, + unreadable or unparsable at re-read. Fail closed: a file the node cannot read at re-read stops + the loop (logged by name); a stop is recoverable and a spend is not. + - a sentinel file `/rewards-prover.STOP` exists. Its content is ignored; existence is + the signal. It is the switch an operator reaches for when they do not want to edit JSON while + something is going wrong, and it is honoured at SPAWN too (`StopSentinelPresent`, §26.3 + clause 4): a sentinel left in place after a restart keeps the loop off. +5. **What is re-read and what is not.** Only `enabled` and the sentinel. §26.4 clause 9 governs the + other keys. +6. **The stop is durable and one-way.** On `true`: the switch task writes `prover_state = Stopped` + and `prover_state_since = now` on every registered record in one `update`, logs the stop by + cause, and both tasks exit. `Stopped` is the wire's "will not run again without an explicit + restart" (`dig-rpc-protocol` 0.12.0 `types.rs:1483`), and the restart is a NODE PROCESS restart + with the stop conditions cleared. Removing the sentinel or re-writing `enabled: true` while the + process runs MUST NOT resume the loop, and the spawning PR MUST NOT add a resume path: a switch + that can be un-flipped by the same file it watches is a switch a wedged operator cannot trust. +7. **The final record is frozen, and that is honest.** After the stop, `observed_at` MUST NOT + advance: the record reflects the chain view as of the stop, which is exactly what a stopped loop + has. A reader deriving staleness from `observed_at` (`is_wedged`, `cycle.rs:111-113`) will find a + `Stopped` record stale, and it is; `prover_state = Stopped` is what tells that reader why. The + research note that `observed_at` "keeps ticking" after a stop is WITHDRAWN by this clause — a + stopped loop that kept refreshing its chain-view timestamp would be reporting observations it is + no longer making. +8. **`Paused` is not produced.** No operator action in this section yields `ProverState::Paused`; + the switch yields `Stopped`. A reader MUST NOT conclude `Paused` is reachable at this tip. +9. **Why not an RPC.** `control::is_control_method` is a string-prefix check, so a node-local + `control.rewardProver.stop` would DISPATCH without a protocol bump — but every `control.*` method + is enumerated in `dig-rpc-protocol::Method` with a `tier()` (0.12.0 `method.rs:94-107`, + `:162-167`, `:188`), dig_ecosystem#3261 holds every `*Reward*` method at `Tier::Control`, and + adding one is a protocol-lane release-first cascade. The file-driven switch removes that + cross-repo blocker deliberately; a `dig-node` CLI subcommand that writes the sentinel is a + nice-to-have and, if added, MUST write the file and nothing else. A reader MUST NOT conclude that + the absence of an RPC makes the switch weaker: it is reachable by any process that can write the + state directory, which is the same trust boundary the configuration file already sits on. + +### 26.9. The cycle, the status record, and restart honesty + +The composer is the per-cycle function the cycle task runs. Its steps are the engine's, in the +engine's order; this section specifies only what each step MUST REPORT and what it MUST NOT. + +1. **Deadline wrapper.** Every per-distributor cycle runs inside `run_cycle_with_deadline` + (`cycle.rs:31-77`). Its `cycle_fn` today returns `()` and the wrapper marks every in-time return + as a completed cycle (`cycle.rs:58-64`). That is insufficient for a composed cycle that can fail + to see the chain: the wrapper MUST take `Fut: Future` **(target)** with + `CycleOutcome::Completed(end_state)` for `end_state ∈ { Idle, Unfunded, EntrySetFull, + FeeBudgetExhausted }` and `CycleOutcome::Faulted(fault)` for + `fault ∈ { ChainSourceUnavailable, LocalCopyMissing }`. On `Completed`: `prover_state = + end_state`, `last_cycle_completed_at = now`, `next_cycle_due_at = now + + PROVER_CYCLE_PERIOD_SECONDS`, `consecutive_cycle_failures = 0`. On `Faulted`, and on the + deadline: `prover_state = fault` (or `Idle` on the deadline, as today), `consecutive_cycle_failures + += 1`, and `last_cycle_completed_at` MUST NOT advance. The deadline arm at `cycle.rs:66-75` is + unchanged. +2. **`prover_state_since` moves with `prover_state`.** Every transition MUST set + `prover_state_since = now` in the same `StatusHandle::update` (`state.rs:82-90` documents the + closure for exactly this). The wrapper does not do so today (`cycle.rs:45-49`, `:58-64`) + **(target)**. +3. **A faulted cycle is a prover fault, never a peer strike.** `StrikeTracker::record_prover_fault` + (`challenge.rs:270`) and `dig-rewards-coin/SPEC.md` §2.5 clause 3 / §3.6 clause 4 govern: no + candidate's strike count moves in a cycle that faulted. +4. **Inventory.** The cycle begins with `port.funded_distributors()` (`port.rs:264`). At tip + `69c793a7` this answers `Unavailable` (`chain_port.rs:77-82`); dig_ecosystem#3421 realises it + over `Node::funded_distributors_read()` (`lib.rs:675`; `FundedDistributorsRead`, + `funded.rs:94-115`) plus `distributor_report` for the `(store_id, root)` of each launcher, and + MUST map `FundsNothing` to `Ok(vec![])`, and `NotConfigured(_)`, `PersistedStateCorrupt { .. }` + and `IoFailed { .. }` to `Err(Unavailable)` — an unknown set is never an empty set + (`funded.rs:122-131`). The composer MUST NOT read the registry directly, bypassing the port. +5. **The inventory outcome is a Node-level fact** **(target)**: core gains + `rewards::state::ProverInventory { Undetermined { since, reason }, Determined { count, observed_at } }` + and `Node::set_reward_prover_inventory(..)`; the composer sets `Undetermined` at spawn and after + every failed inventory read, `Determined` after every successful one. While a loop is spawned and + the inventory is `Undetermined`, the `Method::GetRewardProverStatus` arm MUST answer + `statuses: Half::NotConsulted { observed_at }` (0.12.0 `types.rs:1671-1677`) — "nothing looked, + so there is no answer here to read as none" — and MUST NOT answer `Consulted { items: [] }`, + which the wire defines as "this node runs no prover loops" and which would be false. With no loop + spawned the arm keeps answering `Consulted` (§26.5 clause 1). Records already registered stay + registered and are returned under `Consulted` once the inventory is determined again. This is the + node-side answer to the signature defect; it needs no wire change because `Half::NotConsulted` + exists for exactly this distinction. +6. **Registration is on first sight, with a real identity.** The first cycle that sees a + `DistributorRef` registers `idle_status(launcher_id, store_id, root, now)` (`state.rs:159-166`) + through `Node::register_reward_prover_status`, which MUST become `pub` and lose its + `allow(dead_code)` **(target)** (`lib.rs:621-627`). `store_id` and `root` come from the ref, never + from zeros: the handler refuses a zeroed identity for the whole call (`dispatch.rs:923-1000`). + `next_cycle_due_at` at registration is `Some(now)` — due now — because the first cycle runs + immediately (clause 8). A distributor is registered once; the registry is append-only + (`lib.rs:613-627`) and a stopped loop's records remain, showing `Stopped`. +7. **Own identity.** `OwnIdentity` (`admission.rs:35-42`) is this node's own `peer_id` and EVERY + puzzle hash its wallet controls. A node with no operator wallet MUST refuse the spawn + (`NoOperatorWallet`), and a wallet that enumerates ZERO puzzle hashes MUST be treated the same + way — never as an empty exclusion set, because an empty set silently disables the + payout-coordinate half of self-exclusion (`dig-rewards-coin/SPEC.md` §5.2; dig-node#261 is the + precedent for a self-exclusion honoured on one path and not another). +8. **The first cycle runs immediately after the spawn-time line**, not after a first interval. The + claim loop waits one full cadence first (`rewards_claim/driver.rs:167-189`) as restart-loop + protection; the prover's restart protection is the persisted write bounds (clause 12), which + already refuse a bundle within `ENTRY_WRITE_MIN_INTERVAL_SECONDS` of the last one, so an + immediate first cycle can read and decide but cannot double-spend. An operator who enabled + dry-run to watch the loop sees its first decision line within the cycle deadline, not an hour + later. +9. **Absent seams fault; they never complete.** A cycle in which any seam it needs is absent or + answers unavailable — a chain-port method (`Unavailable`), the discovery source, the + `MirrorCoinReader` (`GateError::EpochOrdinalUnavailable` → `AdmissionDecision::ChainSourceUnavailable`, + `admission.rs:123`), the `ChallengeTransport`, or the write-bound store + (`PersistedWriteOutcome::PersistenceUnavailable`, `writes.rs:279-288`) — MUST end + `Faulted(ChainSourceUnavailable)`. `ChainSourceUnavailable` is the engine's own name for "a + dependency this decision needs is not reachable, and this is a prover-side fault" + (`writes.rs:280-286`), and it is the ONLY state a loop with a missing seam may show. The local + capsule bytes a challenge compares against being absent is `Faulted(LocalCopyMissing)` + (`dig-rewards-coin/SPEC.md` §1.4). This clause is what makes "spawns, reports Idle, distributes + nothing" unrepresentable: a loop that cannot see cannot say it looked. +10. **The per-cycle line** **(target)**: after every scheduler pass, ONE `tracing` record + (`target: "rewards_prover"`) at `info!` when every distributor ended `Idle` and at `warn!` + otherwise, carrying `cycles_driven`, `mode`, the inventory outcome (and, when determined, the + count and the node-total daily fee ceiling = count × the per-distributor figure of §26.6), and + per distributor its `launcher_id`, end state, `consecutive_cycle_failures` and + `pending_entry_writes`. At tip `69c793a7` this line reads, every cycle, `inventory = + undetermined(ChainSourceUnavailable)` — which is the truth. +11. **Counters: chain-derived figures are never local counters.** On every completed cycle, + `counters.reserve_base_units`, `counters.total_paid_out_base_units` and `counters.entry_count` + MUST be SET from `DistributorChainState` (`port.rs:110-121`: `reserve_base_units`, + `total_paid_out_base_units`, `entries.len()`), and `last_entry_write_at` from its + `last_entry_write_at`. They MUST NOT be incremented locally and MUST NOT be persisted by the + node: a restart cannot understate them because the next completed cycle re-reads them, and until + a cycle completes they are `0` beside `last_cycle_completed_at = None`, which + `dig-rewards-coin/SPEC.md` §2.4 clause 2 defines as "never ran" and which the wire MUST render + as such. The process-local counters (`mirrors_seen`, `challenges_issued`, `challenges_passed`, + `challenges_failed`, `entries_added`, `entries_removed`) reset on restart; dig_ecosystem#3274 + owns whether they persist, and a reader MUST NOT read them as lifetime totals (§26.12). +12. **The live write-bound store** **(target, dig_ecosystem#3421)**: a `WriteBoundStore` + (`writes.rs:236-239`) over `/rewards-prover/bounds/.json`, one file + per distributor, carrying `WriteBoundState` (`writes.rs:222-228`) plus `format_version: 1`. A + missing file loads `WriteBoundState::default()` (a fresh distributor). An unreadable, unparsable + or wrong-version file MUST load as `StoreError` — never as default — so the writer answers + `PersistenceUnavailable` and the cycle faults (clause 9), exactly the fail-closed direction + `NoPersistence` (`writes.rs:247-261`) has today. Until this store exists the composer MUST be + constructed over `NoPersistence`, and the write step of every cycle faults; it MUST NOT be + constructed over an in-memory store in production. +13. **Write ordering and the audit record** **(target, dig_ecosystem#3421/#3422)**. The ordering of + reserve (persist the advanced bounds) and broadcast is dig_ecosystem#3422's to fix; whatever + ordering it lands, the bounds MUST never be LOOSER after a crash than before it. A live + `submit_entry_writes` that broadcast MUST produce a §23 audit entry with `kind = "reward-prover"` + (a new producer word, additive to §23.1), `amount_mojos = fee_mojos`, `asset` = XCH, + `authority.grant` naming `rewards-prover.json`'s `enabled`/`mode = live`, and a `purpose` that + states the adds, the removes, and that each remove settled that entry's accrued balance from the + reserve (§6.4). Dry-run produces none (§26.7 clause 5). +14. **What this PR calls, by name.** The composer MUST reach the engine only through: `admit` + (`admission.rs:112`), `SpecMirrorCoinGate` (`gate.rs:120`) over an injected `MirrorCoinReader` + and an `EpochContext` (`gate.rs:27-36`) whose `current_epoch` source is dig_ecosystem#3423's to + specify (until then `None`, which faults per clause 9 — the composer MUST NOT guess an ordinal, + dig_ecosystem#3259), `select_window` / `run_cycle` / `NoRepeatMemory` / `StrikeTracker` + (`challenge.rs:124`, `:217`, `:62`, `:234`), `is_entry_set_full` / `is_unfunded` + (`writes.rs:418`, `:423`), `PersistedEntryWriter` (`writes.rs:295`), and the port trait + (`port.rs:262-297`). It MUST NOT re-derive any bound, window, strike or share those own. + +### 26.10. The periodicity test — required shape + +An always-on loop is trivially easy to keep green while it never runs. The spawning PR MUST carry +these tests in `crates/dig-node-service/src/rewards/driver.rs` **(target)**, each under +`#[tokio::test(start_paused = true)]`, each driving the PRODUCTION body through an injected +`RewardsChainPort` fake, an injected `Clock` (`state.rs:97-99`, `TestClock`), `FixedJitter(0)` and a +temporary state directory — never a hand-assembled inner loop that the production body does not use. +The claim loop's `zero_cycles_before_the_interval_elapses_then_a_counted_number_after` +(`rewards_claim/driver.rs:1051-1104`) is the reusable shape: `settle()` (eight `yield_now`s) before +the first `advance`, because the scheduler's timer must be registered before virtual time moves +(`cycle.rs:223-226` explains the same hazard). Core's `heartbeat_loop_fires_on_its_own_timer` +(`cycle.rs:215-241`) proves ONE tick and is not a substitute. + +1. **Runs more than once, and stops.** With a fake port whose `funded_distributors` answers + `Unavailable`: after `settle()`, `cycles_driven() == 1` (the immediate first cycle, §26.9 + clause 8) and the inventory is `Undetermined`; `advance(PROVER_CYCLE_PERIOD_SECONDS)` + settle + → `2`; again → `3`; then flip the switch — ONE variant sends `true` on the channel directly, a + SECOND variant writes `/rewards-prover.STOP` and advances `PROVER_HEARTBEAT_SECONDS` + — and a further `advance(PROVER_CYCLE_PERIOD_SECONDS)` MUST leave `cycles_driven() == 3`, both + task handles resolved, and every registered record `Stopped`. This is the one assertion the + claim test lacks and the one that turns "runs periodically" into "runs periodically AND can be + stopped". +2. **A wedged cycle is killed by the switch before the deadline.** With a fake port whose + `funded_distributors` is `std::future::pending()`, after the cycle starts (`prover_state == + Running`), send `true`; the cycle task MUST resolve within one `settle()` without advancing + virtual time to the 900 s deadline, `last_cycle_completed_at` MUST be `None`, and the record MUST + read `Stopped`. +3. **Latency of the file switch.** Write the sentinel at virtual `t`; the record MUST read `Stopped` + by `t + PROVER_HEARTBEAT_SECONDS` plus one settle. +4. **Default off.** `load_from()` → `enabled == false`; `decide_prover_driver` → + `Disabled`; `spawn_prover_driver_if` calls `spawn` zero times; `Node::reward_prover_status_snapshots()` + stays empty. Plus the compile-time assertion of §26.4 clause 4. +5. **The decision table.** One test per `ProverDriverRefusal` variant, and one for `Spawn`, each + through `decide_prover_driver` with every other input at its spawning value — including + `mode = Live` with `PROVER_LIVE_MODE_RELEASED == false` → `LiveModeNotReleased`, and + `standard_fee_mojos = PROVER_STANDARD_FEE_MOJOS_MAX + 1` → `StandardFeeAboveMaximum`. +6. **Dry-run never writes.** §26.7 clause 2's panicking inner port, driven to a `Bundle` outcome + through the composer, completes; the live store's `save` is never called (a counting store). +7. **The figures.** §26.6 clause 3's two worked cases, asserted over the structured fields of the + spawn-time line (a `tracing` test subscriber), not over prose. +8. **A faulted cycle does not complete.** Through `run_cycle_with_deadline` with a `cycle_fn` + returning `Faulted(ChainSourceUnavailable)`: `prover_state == ChainSourceUnavailable`, + `prover_state_since` advanced, `consecutive_cycle_failures == 1`, `last_cycle_completed_at == + None`, `next_cycle_due_at` unchanged (core, `cycle.rs`). +9. **The status arm under an undetermined inventory** answers `NotConsulted`, and under a determined + one `Consulted` with the registered records (core, `dispatch.rs` tests beside the existing + `dig.getRewardProverStatus` tests at `lib.rs:9363-9600`). + +### 26.11. The composed-system gate + +dig-node#593's gates could audit only unreachable library code and said so. The gate that authorises +live mode MUST be a full triple gate — independent review, security audit, and an adversarial +`loop-decider` leg — on the COMPOSED system running in a node, after dig_ecosystem#3292, #3421, +#3422 and #3423 have landed, and MUST observe, in a running node against a Chia simulator or +testnet, at least: + +1. a fresh state directory: no loop, no record, `Consulted { items: [] }` (§26.5); +2. the spawn-time line with §26.6's figures for the configured fee, and the per-cycle line's + node-total once the inventory is determined; +3. a dry-run cycle that reaches a `Bundle` outcome, logs it, and leaves the simulator mempool empty + and the live bounds untouched (§26.7); +4. the sentinel written while a cycle is parked on a transport that never answers, and `Stopped` + within 60 s with the node still serving (§26.8); +5. a cycle with one seam deliberately removed reporting `ChainSourceUnavailable`, not `Idle` + (§26.9 clause 9); +6. one live bundle, its §23 audit entry, and the persisted bounds after a forced restart being no + looser than before it (§26.9 clauses 12-13). + +Its passing verdict is the only authority for §26.2 clause 3's one-line release. The gate's ticket +is filed when the last of (c)-(e) in §26.2 clause 4 lands; it is not this section's to file. + +### 26.12. What a reader may NOT conclude + +* That a spawned loop distributes anything at tip `69c793a7`. It faults every cycle (§26.2 + clause 2). +* That `mode: "live"` in the file makes the node spend. It produces `LiveModeNotReleased` until + §26.2 clause 3 is satisfied. +* That `{"outcome":"consulted","items":[]}` means the loop is off. With a loop spawned and its + inventory undetermined the answer is `NotConsulted`; `Consulted` + empty means off OR + determined-and-funds-nothing, and the log line distinguishes those two. +* That `dig.getRewardProverStatus` shows whether a loop is dry-run or live. It cannot (§26.7 + clause 6). The log does. +* That `Idle` means "nothing to do". `Idle` means a cycle COMPLETED with every seam answering; a + loop with a missing seam never shows it (§26.9 clause 9). +* That the rate bound and the fee ceiling are two protections. They are one (§26.6 clause 5). +* That "96 evictions/day" pairs with "~1.3 days". 96/day is churn and pairs with ~2.6 days; ~1.3 + days pairs with 192 removals/day (§26.6 clause 4). +* That the per-distributor figures are the node's total. Multiply by the determined inventory + count, printed on the per-cycle line. +* That `mirrors_seen`, `challenges_*`, `entries_added` or `entries_removed` are lifetime totals. + They are process-lifetime (§26.9 clause 11). `reserve_base_units`, `total_paid_out_base_units` and + `entry_count` ARE chain truths as of `last_cycle_completed_at`, and are meaningless beside + `last_cycle_completed_at = None`. +* That `Paused` is reachable, or that `Stopped` can be undone without a process restart. +* That an `observed_at` frozen on a `Stopped` record is a wedge. It is a stop (§26.8 clause 7). +* That the operator's fee is validated against anything but its maximum. A fee the mempool would + reject is the chain's refusal to observe, reported through the port, never pre-judged here. +* That a `control.rewardProver.*` method exists, or that adding one is a node-side change. + +### 26.13. Failure directions, stated + +* Missing file, missing key, mistyped key, `standard_fee_mojos = 0`, fee above maximum, corrupt + file, chain sync off, no port, no wallet, no wallet puzzle hashes, live mode unreleased, sentinel + present: every one fails toward NOT SPAWNING, by name. +* An unreadable file or a sentinel at re-read fails toward STOPPING, by name, within 60 s. +* A missing or unavailable seam fails toward a FAULTED cycle that names `ChainSourceUnavailable`, + never toward a completed one. +* A missing, unparsable or wrong-version write-bound file fails toward `PersistenceUnavailable`, + never toward fresh bounds. +* Dry-run fails toward the log: nothing is broadcast, nothing is persisted, nothing is audited. +* The one direction that fails EXPENSIVE is a released live loop with a congested fee, and that + direction is bounded by the figure the operator was shown when they enabled it: 24 bundles/day, + `FeeBudget::daily_limit_for(standard_fee_mojos)` mojos/day, per funded distributor. + +### 26.14. Clause status at tip `69c793a7` + +| clause | status | where | +|---|---|---| +| §26.2 cl. 3 `PROVER_LIVE_MODE_RELEASED` | target | dig_ecosystem#3265 | +| §26.3 cl. 1-5 spawn site, seam, refusals, handle | target | dig_ecosystem#3265 | +| §26.3 cl. 6 the precedent's default is `true` | implemented (as the trap) | `rewards_claim/config.rs:140-142` | +| §26.4 file, keys, defaults, maximum, corrupt marker, jitter constant | target | dig_ecosystem#3265 | +| §26.4 cl. 8 period/heartbeat/deadline constants | implemented | `spec_constants.rs:19-26` | +| §26.5 cl. 1 empty registry answers `Consulted { [] }` | implemented | `dispatch.rs:923-1000` | +| §26.6 cl. 1 `daily_limit_for` is the one product | implemented | `writes.rs:65-79` | +| §26.6 cl. 2-4 spawn-time line, worked figures, the 1.3-day figure in `mod.rs` | target | dig_ecosystem#3265 | +| §26.7 `DryRunChainPort`, dry-run store, no counters | target | dig_ecosystem#3265 | +| §26.8 cl. 1 receiver-side stop precedent | implemented | `cycle.rs:86-105` | +| §26.8 cl. 1-7 switch task, sentinel, re-read, `Stopped` | target | dig_ecosystem#3265 | +| §26.8 cl. 2 `spawn_blocking` precedent | implemented | `chain_port.rs:110` | +| §26.9 cl. 1-2 `CycleOutcome`, `prover_state_since` | target | dig_ecosystem#3265 (core) | +| §26.9 cl. 4 four port methods real | target | dig_ecosystem#3421 | +| §26.9 cl. 5 `ProverInventory`, `NotConsulted` arm | target | dig_ecosystem#3265 (core) | +| §26.9 cl. 6 `register_reward_prover_status` `pub` | target | dig_ecosystem#3265 (core) | +| §26.9 cl. 9 fault mapping of `PersistenceUnavailable` | implemented | `writes.rs:279-288` | +| §26.9 cl. 11 chain-derived counters | target | dig_ecosystem#3265 | +| §26.9 cl. 12 state-dir `WriteBoundStore` | target | dig_ecosystem#3421 | +| §26.9 cl. 13 ordering, §23 entry | target | dig_ecosystem#3421 / #3422 | +| §26.9 cl. 14 reader, transport, discovery, epoch source | target | dig_ecosystem#3423 | +| §26.10 tests | target | dig_ecosystem#3265 | +| §26.11 gate | target | filed after §26.2 cl. 4 (e) | diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 656af50a..961fb3ec 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -198,7 +198,8 @@ serde_json = "1" # (0.15.0), `dig-download` (0.24.0) and `dig-peer-selector` (0.13.0) below all moved onto this line # in the same batch, so exactly one `dig-rpc-protocol` still resolves (asserted by # `crates/dig-node-core/tests/dependency_tree.rs`). -dig-rpc-protocol = "0.12" +# Moved to 0.15 (dig_ecosystem#3442): recoverable_base_units becomes Option. +dig-rpc-protocol = "0.15" # 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. @@ -471,7 +472,8 @@ dig-pex = "0.1.1" # # Moved to 0.24 (dig_ecosystem#3269, final leg): 0.24.0 is on `dig-rpc-protocol` 0.12, matching this # crate's own move to 0.12 above. -dig-download = "0.24" +# Moved to 0.26 (dig_ecosystem#3442): recoverable_base_units becomes Option. +dig-download = "0.26" # -- 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 @@ -489,7 +491,8 @@ dig-download = "0.24" # # Moved to 0.15 (dig_ecosystem#3269, final leg): 0.15.0 is on `dig-rpc-protocol` 0.12, matching this # crate's own move to 0.12 above. -dig-peer = "0.15" +# Moved to 0.17 (dig_ecosystem#3442): recoverable_base_units becomes Option. +dig-peer = "0.17" # -- 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 @@ -525,7 +528,8 @@ dig-peer = "0.15" # # Moved to 0.13 (dig_ecosystem#3269, final leg): 0.13.0 is on `dig-peer ^0.15`, matching this crate's # own move to `dig-peer = "0.15"` above and closing the cascade at `dig-rpc-protocol` 0.12. -dig-peer-selector = "0.13" +# Moved to 0.15 (dig_ecosystem#3442): recoverable_base_units becomes Option. +dig-peer-selector = "0.15" # 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 @@ -609,7 +613,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.24", features = ["testkit"] } +dig-download = { version = "0.26", 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 c30c1d58..e2f56e48 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -582,15 +582,16 @@ pub struct Node { /// /// A slot rather than a constructor argument for the same reason [`Node::mirror_pointers`] is /// one: the FFI/browser path has no state directory and must keep constructing a `Node` - /// without one. Nothing installs it in production yet — nothing in dig-node funds a - /// distributor today (`rewards::port`'s module doc, blocker 2). WHICH ticket owns the startup - /// wiring that would call [`Node::install_funded_distributor_registry`] with the node's state - /// directory is tracked separately, and it is NOT dig_ecosystem#3268, whose scope is the claim - /// loop and `ClaimStatus` and which names neither this registry nor that call. Until a ticket - /// wires it the slot stays empty, and + /// without one. `dig-node-service`'s startup path installs a real, state-dir-backed registry + /// (dig_ecosystem#3292); until that install runs (e.g. a harness with `enable_chain_sync: + /// false`, or the FFI/browser path) the slot stays empty and /// [`Node::funded_distributors_read`] answers /// [`rewards::funded::NotConfiguredReason::NoStateDirectory`] — UNKNOWN, deliberately never an - /// empty funded set. + /// empty funded set. Even once installed, the registry starts with no record on disk (writing + /// one is dig_ecosystem#3291, a separate operator-declaration ticket — the node cannot observe + /// its own funding because funding spends from a wallet it never holds), so a fresh production + /// node reads [`rewards::funded::NotConfiguredReason::NoRecordWritten`] — still UNKNOWN, by + /// design, not a defect of the installer. funded_distributors: OnceLock, /// The chain seam `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments` /// (dig_ecosystem#3269 units 1-2) read through — [`rewards::port::RewardsChainPort`]. @@ -642,13 +643,12 @@ impl Node { /// if a registry is already installed, in which case NOTHING changed — a second install must /// not be able to swap a live registry for an inert one behind a caller's back. /// - /// Called from tests today: no production startup path installs one, so clippy's non-test - /// lib target sees no production caller and `allow(dead_code)` stands in for it. Remove the - /// attribute when that wiring lands. Its owning ticket is tracked separately and is NOT - /// dig_ecosystem#3268 (claim loop + `ClaimStatus`), which names neither this registry nor this - /// call — do not read the attribute as a claim about #3268's scope. - #[cfg_attr(not(test), allow(dead_code))] - pub(crate) fn install_funded_distributor_registry( + /// `pub` because this is the INJECTION POINT, mirroring + /// [`Node::install_reward_chain_port`]: `dig-node-service`'s startup path builds a + /// state-dir-backed registry and installs it here (dig_ecosystem#3292). Being callable from + /// outside does NOT relax the single-install discipline: a second install still returns + /// `false` and changes nothing. + pub fn install_funded_distributor_registry( &self, registry: rewards::funded::FundedDistributorRegistry, ) -> bool { @@ -9488,6 +9488,77 @@ mod tests { } } + /// **Proves:** dig_ecosystem#3280 — a malformed `launcher_id` is refused with `-32602`, the + /// same validator `dig.getRewardDistributor` already uses (`parse_launcher_id_arg`), instead + /// of silently filtering to an empty `items` list that reads exactly like "I looked and found + /// nothing" (SPEC §12.5 clause 6's "reassuring zero", on the input side rather than the read + /// side). Registers a handle first so a filter bug that matches everything cannot pass this + /// test by accident: the malformed request must be refused before any filter runs. + /// **Catches:** a malformed `launcher_id` degrading to `Consulted { items: [] }`. + #[test] + fn get_reward_prover_status_with_a_malformed_launcher_id_is_refused() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status([0x11u8; 32]), + )); + + let resp = rt.block_on(handle_rpc( + &node, + json!({ + "jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus", + "params": {"launcher_id": "zz"} + }), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + + assert_eq!( + resp["error"]["code"], + json!(-32602), + "a malformed launcher_id must be refused, not filtered to an empty list: {resp}" + ); + assert!( + resp.get("result").is_none(), + "an error response must carry no result: {resp}" + ); + } + + /// **Proves:** a well-formed but UNKNOWN `launcher_id` still answers `consulted` with an + /// empty `items` list — distinct from the malformed case above, which must be `-32602`. + /// **Catches:** widening the malformed-input refusal to also swallow legitimate misses. + #[test] + fn get_reward_prover_status_with_an_unknown_well_formed_launcher_id_is_empty_not_refused() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (node, _td) = test_node(None); + node.register_reward_prover_status(crate::rewards::state::StatusHandle::new( + sample_reward_prover_status([0x11u8; 32]), + )); + + let resp = rt.block_on(handle_rpc( + &node, + json!({ + "jsonrpc":"2.0","id":1,"method":"dig.getRewardProverStatus", + "params": {"launcher_id": hex::encode([0x99u8; 32])} + }), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + + assert_eq!(resp["result"]["statuses"]["outcome"], json!("consulted")); + assert_eq!( + resp["result"]["statuses"]["items"], + json!([]), + "an unknown but well-formed id is a legitimate empty answer, not an error: {resp}" + ); + } + /// **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 @@ -9835,6 +9906,10 @@ mod tests { ), commitments, observed_at, + // Distinct from `observed_at` (a wall clock) by construction, so a test asserting the + // two fields are threaded independently cannot pass by accident on equal values. + chain_peak_height: 9_000_000 + seed as u64, + chain_peak_timestamp: 1_700_190_000 + seed as u64, } } @@ -9912,6 +9987,8 @@ mod tests { "last_entry_write_at", "entry_set_stale", "observed_at", + "chain_peak_height", + "chain_peak_timestamp", ]), "the wire body's key SET must be exactly this — a struct assertion cannot see a wrong \ key name or an extra field" @@ -9945,6 +10022,11 @@ mod tests { ); assert_eq!(result["entry_set_stale"], json!(report.entry_set_stale)); assert_eq!(result["observed_at"], json!(report.observed_at)); + assert_eq!(result["chain_peak_height"], json!(report.chain_peak_height)); + assert_eq!( + result["chain_peak_timestamp"], + json!(report.chain_peak_timestamp) + ); } /// **Proves:** `dig.listRewardDistributorCommitments` answers with the port's real values @@ -9961,7 +10043,7 @@ mod tests { epoch_start: 42, clawback_puzzle_hash: [0x33u8; 32], rewards_base_units: 1_000, - recoverable_base_units: 900, + recoverable_base_units: Some(900), }; let report = sample_distributor_report(0x22, vec![slot.clone()]); assert!( @@ -9992,6 +10074,8 @@ mod tests { "epoch_seconds", "commitments", "observed_at", + "chain_peak_height", + "chain_peak_timestamp", ]) ); assert_eq!( @@ -10004,6 +10088,11 @@ mod tests { ); assert_eq!(result["epoch_seconds"], json!(report.epoch_seconds)); assert_eq!(result["observed_at"], json!(report.observed_at)); + assert_eq!(result["chain_peak_height"], json!(report.chain_peak_height)); + assert_eq!( + result["chain_peak_timestamp"], + json!(report.chain_peak_timestamp) + ); let commitments = result["commitments"].as_array().unwrap(); assert_eq!(commitments.len(), 1); let row_keys: std::collections::BTreeSet<&str> = commitments[0] @@ -10156,6 +10245,65 @@ mod tests { ); } + /// **Proves:** `funded.observed_at` dates the CONSULTATION (the oldest per-item chain read), + /// never the assembly (dig_ecosystem#3323). The handler's pre-loop clock predates every read + /// `port.distributor_report` performs, so stamping it there understates staleness; the fix + /// folds the reports' own `observed_at` and keeps the oldest (a collection is only as fresh + /// as its stalest member). `claimable.observed_at` is untouched — no read happens for it, so + /// the handler's own clock remains the honest answer for that `NotConsulted` arm. + #[test] + fn list_reward_distributors_funded_observed_at_is_the_oldest_report_stamp() { + let state_dir = tempfile::tempdir().unwrap(); + let registry = + crate::rewards::funded::FundedDistributorRegistry::with_state_dir(state_dir.path()); + for launcher_id in [[0x55u8; 32], [0x66u8; 32]] { + assert_eq!( + registry.record(&crate::rewards::funded::FundedDistributor { + launcher_id, + store_id: None, + }), + crate::rewards::funded::RecordOutcome::Recorded + ); + } + let (node, _td) = test_node(None); + assert!(node.install_funded_distributor_registry(registry)); + + let report_a = sample_distributor_report(0x55, vec![]); + let report_b = sample_distributor_report(0x66, vec![]); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([ + ([0x55u8; 32], Ok(report_a.clone())), + ([0x66u8; 32], Ok(report_b.clone())), + ]), + })) + ); + + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributors"}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert_eq!(resp["result"]["funded"]["outcome"], json!("consulted")); + assert_eq!( + resp["result"]["funded"]["observed_at"], + json!(1_700_200_000u64 + 0x55) + ); + assert_eq!( + resp["result"]["funded"]["observed_at"], + json!(report_a.observed_at) + ); + assert_ne!( + resp["result"]["funded"]["observed_at"], + json!(report_b.observed_at) + ); + assert_eq!( + resp["result"]["claimable"]["outcome"], + json!("not_consulted") + ); + } + /// **Proves:** a funded identity whose per-item chain report fails refuses the WHOLE call /// (the same `ChainPortError` response the sibling reward-distributor handlers use), rather /// than emitting a partial list or a fabricated ref (dig_ecosystem#3308/#3309). @@ -10198,8 +10346,12 @@ mod tests { /// **Proves:** `dig.getPayeeRewardClaimStatus` is CONTROL-tier, NOT peer-reachable, dispatched /// through the `Method` enum match, and its exact serialized JSON body: `subject` is the - /// literal `"payee"`, `claim_log` is `NotConsulted` (no claim log exists in this crate yet), - /// and there is never a monetary amount or payout puzzle hash anywhere in the body. + /// literal `"payee"`, `claim_log` and `claim_loop` are both `NotConsulted` (neither a claim + /// log nor a claim loop exists in this crate yet — the loop lives in `dig-node-service`'s + /// `src/rewards_claim/**`, dig_ecosystem#3268 (wiring, landed) / #3432 (the SPEC §13.2 + /// off-chain seam), never dig_ecosystem#3421 (that ticket is the prover's + /// `RewardsChainPort`) — and + /// there is never a monetary amount or payout puzzle hash anywhere in the body. #[test] fn get_payee_reward_claim_status_answers_the_exact_wire_shape() { use dig_rpc_protocol::Method; @@ -10231,7 +10383,7 @@ mod tests { .collect(); assert_eq!( keys, - std::collections::BTreeSet::from(["subject", "claim_log"]), + std::collections::BTreeSet::from(["subject", "claim_log", "claim_loop"]), "no monetary amount, no payout puzzle hash — ever: {resp}" ); assert_eq!(result["subject"], json!("payee")); @@ -10240,6 +10392,11 @@ mod tests { result["claim_log"].get("claims_submitted_count").is_none(), "claims_submitted_count must live INSIDE Consulted only, never beside NotConsulted: {resp}" ); + assert_eq!(result["claim_loop"]["outcome"], json!("not_consulted")); + assert!( + result["claim_loop"].get("claims_submitted_count").is_none(), + "claim_loop's count must live INSIDE Consulted only, never beside NotConsulted: {resp}" + ); } /// **Proves:** dig_ecosystem#3269 unit 5 — a chain-derived report with a ZEROED `launcher_id` @@ -10687,13 +10844,13 @@ mod tests { epoch_start: 1, clawback_puzzle_hash: [0xaau8; 32], rewards_base_units: 5_000, - recoverable_base_units: 4_500, + recoverable_base_units: Some(4_500), }; let slot_b = crate::rewards::port::CommitmentSlot { epoch_start: 2, clawback_puzzle_hash: [0xbbu8; 32], rewards_base_units: 7_000, - recoverable_base_units: 6_300, + recoverable_base_units: Some(6_300), }; let report_a = sample_distributor_report(0x70, vec![slot_a]); let report_b = sample_distributor_report(0x71, vec![slot_b]); @@ -10738,6 +10895,70 @@ mod tests { assert_ne!(recoverable_b, swapped_b); } + /// Serves ONE commitment carrying `recoverable` through `dig.listRewardDistributorCommitments` + /// and returns the serialized `commitments[0]` object, so a test asserts on the wire JSON. + fn serve_one_commitment(recoverable: Option) -> serde_json::Value { + let (node, _td) = test_node(None); + let launcher = [0x72u8; 32]; + let slot = crate::rewards::port::CommitmentSlot { + epoch_start: 3, + clawback_puzzle_hash: [0xccu8; 32], + rewards_base_units: 5_000, + recoverable_base_units: recoverable, + }; + let report = sample_distributor_report(0x72, vec![slot]); + assert!( + node.install_reward_chain_port(Arc::new(FakeRewardsChainPort { + reports: std::collections::HashMap::from([(launcher, Ok(report))]), + })) + ); + let resp = rt().block_on(handle_rpc( + &node, + json!({"jsonrpc":"2.0","id":1,"method":"dig.listRewardDistributorCommitments", + "params":{"launcher_id": hex::encode(launcher)}}), + crate::download::ReadOrigin::Local, + crate::download::RequestProvenance::FirstParty, + )); + assert!( + resp.get("error").is_none(), + "a commitment must never turn the whole call into an error: {resp}" + ); + resp["result"]["commitments"][0].clone() + } + + /// **Guards dig_ecosystem#3439 / #3442:** a `None` (the chain REFUSES this clawback, its epoch + /// has started) mapped to `0` tells a user they can claw back nothing when the chain actually + /// refuses; mapped to an error it hides every other commitment. It must serialize as the key + /// PRESENT with JSON `null`. + #[test] + fn unrecoverable_commitment_serializes_recoverable_as_present_null() { + let c = serve_one_commitment(None); + let obj = c.as_object().unwrap(); + assert!( + obj.contains_key("recoverable_base_units"), + "key must be present: {c}" + ); + assert!( + c["recoverable_base_units"].is_null(), + "None must be null, not 0: {c}" + ); + } + + /// **Guards dig_ecosystem#3439:** `Some(0)` (recoverable, but the share is zero) is a different + /// statement from `None` and must stay the number `0`. + #[test] + fn zero_recoverable_commitment_serializes_as_zero() { + let c = serve_one_commitment(Some(0)); + assert_eq!(c["recoverable_base_units"], json!(0), "{c}"); + } + + /// **Guards dig_ecosystem#3439:** a positive recoverable figure passes through verbatim. + #[test] + fn positive_recoverable_commitment_serializes_verbatim() { + let c = serve_one_commitment(Some(4_500)); + assert_eq!(c["recoverable_base_units"], json!(4_500), "{c}"); + } + /// **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 diff --git a/crates/dig-node-core/src/rewards/funded.rs b/crates/dig-node-core/src/rewards/funded.rs index 8f4ea39e..56dcd1e2 100644 --- a/crates/dig-node-core/src/rewards/funded.rs +++ b/crates/dig-node-core/src/rewards/funded.rs @@ -176,7 +176,6 @@ impl FundedDistributorRegistry { /// A registry persisting to `dir`. The directory is created on first write, not here, so /// constructing one is infallible and side-effect free. - #[cfg_attr(not(test), allow(dead_code))] #[must_use] pub fn with_state_dir(dir: &Path) -> Self { Self { @@ -201,7 +200,6 @@ impl FundedDistributorRegistry { /// [`FundedDistributorsRead::PersistedStateCorrupt`] until a human resolves it. That is the /// same "leave the corrupt file exactly as it is on disk" posture /// `rewards_claim::engine::ClaimEngine::persist_fee_window` takes, plus a forensic copy. - #[cfg_attr(not(test), allow(dead_code))] #[must_use] pub fn read(&self) -> FundedDistributorsRead { let Some(path) = self.record_path() else { @@ -215,7 +213,7 @@ impl FundedDistributorRegistry { }, Ok(None) => FundedDistributorsRead::NotConfigured(self.absent_record_reason()), Err(LoadFailure::Corrupt(reason)) => self.report_corrupt(&path, &reason), - Err(LoadFailure::Io(error)) => FundedDistributorsRead::IoFailed { path, error }, + Err(LoadFailure::Io(error)) => self.report_io_failed(path, error), } } @@ -233,7 +231,7 @@ impl FundedDistributorRegistry { }, Ok(None) => Vec::new(), Err(LoadFailure::Corrupt(reason)) => return self.refuse_corrupt(&path, &reason), - Err(LoadFailure::Io(error)) => return RecordOutcome::IoFailed { path, error }, + Err(LoadFailure::Io(error)) => return self.refuse_io_failed(path, error), }; let outcome = match merge(&mut set, distributor) { @@ -245,7 +243,7 @@ impl FundedDistributorRegistry { } match self.save(&path, &set) { Ok(()) => outcome, - Err(error) => RecordOutcome::IoFailed { path, error }, + Err(error) => self.refuse_io_failed(path, error), } } @@ -329,6 +327,31 @@ impl FundedDistributorRegistry { } } + /// Log and report an unreadable/unwritable record to a READER. Mirrors [`Self::report_corrupt`] + /// so an operator sees the same signal for either fault: `PersistedStateCorrupt` has logged + /// since v0.257.0, but `IoFailed` was constructed bare beside it, and `dispatch.rs` collapses + /// both into one wire `NotConsulted` with no log of its own (dig_ecosystem#3324) — so the + /// operator saw nothing for a permissions error, a missing mount, or any other I/O fault. + fn report_io_failed(&self, path: PathBuf, error: String) -> FundedDistributorsRead { + tracing::error!( + path = %path.display(), + error, + "the funded-distributor record could not be read" + ); + FundedDistributorsRead::IoFailed { path, error } + } + + /// Log and report an unreadable/unwritable record to a WRITER. Mirrors [`Self::refuse_corrupt`]; + /// see [`Self::report_io_failed`] for why this arm was silent (dig_ecosystem#3324). + fn refuse_io_failed(&self, path: PathBuf, error: String) -> RecordOutcome { + tracing::error!( + path = %path.display(), + error, + "the funded-distributor record could not be read or written" + ); + RecordOutcome::IoFailed { path, error } + } + /// Copy the corrupt record beside itself for the operator, leaving the original in place. /// `None` when the copy failed — the corrupt verdict does not depend on it. fn quarantine(&self, path: &Path) -> Option { @@ -819,8 +842,75 @@ mod tests { ); } - /// **Catches:** a future variant added to the not-an-answer half of - /// [`FundedDistributorsRead`] that `determined` reports as a renderable set. + /// **Proves:** an `IoFailed` read logs the path and the error, mirroring + /// [`FundedDistributorRegistry::report_corrupt`]'s `tracing::error!` (dig_ecosystem#3324 — the + /// corrupt arm has logged since v0.257.0; the I/O arm was constructed bare, so an operator saw + /// nothing for a permissions error or a missing mount). Forces `IoFailed` by making the record + /// PATH a directory, so `read_to_string` fails with a non-`NotFound` error — the wildcard + /// `PersistedStateCorrupt`/`IoFailed` collapse this fixture must not trip is + /// `dispatch.rs`'s wire mapping, untouched here; this test drives the registry directly (not + /// `handle_rpc`), because `rt()` may run the handler on a worker thread a thread-scoped + /// subscriber never sees. + #[test] + fn an_io_failed_read_is_logged_with_its_path_and_error() { + /// An in-memory sink a `tracing_subscriber::fmt` layer writes formatted records into. + #[derive(Clone, Default)] + struct LogCapture(std::sync::Arc>>); + + impl std::io::Write for LogCapture { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture { + type Writer = LogCapture; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } + } + + let dir = TempDir::new().expect("temp dir"); + std::fs::create_dir(record_path(&dir)).expect("make the record path a directory"); + let registry = FundedDistributorRegistry::with_state_dir(dir.path()); + + let buffer = LogCapture::default(); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) // plain text: the assertions read the fields as an operator would + .with_writer(buffer.clone()) + .finish(); + + // Scoped to this thread only, deliberately: `read()` runs synchronously here, never on a + // worker thread a thread-scoped subscriber would miss (dig_ecosystem#3324's note on why + // this test does not go through `handle_rpc`). + let read = tracing::subscriber::with_default(subscriber, || registry.read()); + + assert!( + matches!(read, FundedDistributorsRead::IoFailed { .. }), + "a directory at the record path must fail as IoFailed, got {read:?}" + ); + let logged = String::from_utf8(buffer.0.lock().unwrap().clone()).unwrap(); + let expected_path = record_path(&dir); + assert!( + logged.contains(&expected_path.display().to_string()), + "log did not name the record path: {logged}" + ); + assert!( + logged.contains("error"), + "log did not name the error field: {logged}" + ); + } + + /// **Catches:** a regression on the not-an-answer half of [`FundedDistributorsRead`] listed + /// below reporting a renderable set. It does NOT by itself catch a future variant added to the + /// enum — that would need adding here too. The guard that actually forces the issue is + /// [`FundedDistributorsRead::determined`]'s wildcard-free match (this module, above): a new + /// variant left unhandled there fails to COMPILE, which is what makes adding a variant without + /// updating this array a build error rather than a silent gap. #[test] fn every_not_an_answer_outcome_is_undetermined() { let undetermined = [ diff --git a/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs index 1a98205e..5434e321 100644 --- a/crates/dig-node-core/src/rewards/port.rs +++ b/crates/dig-node-core/src/rewards/port.rs @@ -176,6 +176,13 @@ pub enum ChainPortError { /// SPEC §3.7 clause 4 applies to every attacker-adjacent string, and a chain error is not /// exempt). Other(String), + /// dig-rpc-protocol 0.14 (dig_ecosystem#3262/#3329): the adapter completed its distributor + /// read but could not obtain a chain peak height/timestamp from the SAME read to fill + /// [`DistributorReport::chain_peak_height`]/[`DistributorReport::chain_peak_timestamp`]. Per + /// SPEC §4.5 both fields are required and never `0`-as-absence, so a responder that cannot + /// anchor its answer to a chain view MUST refuse the whole call rather than answer with an + /// invented, stale, or independently-read peak. + ChainPeakUnavailable, } /// One clawback commitment slot, as `dig.listRewardDistributorCommitments` (SPEC §7.4 clause 5) @@ -208,9 +215,13 @@ pub struct CommitmentSlot { pub clawback_puzzle_hash: Bytes32, /// The committed amount, in base units, as the puzzle records it. pub rewards_base_units: u64, - /// The amount actually recoverable on clawback, in base units. See the type doc: always - /// pre-computed by the adapter, never by a caller of this trait. - pub recoverable_base_units: u64, + /// The amount actually recoverable on clawback, in base units, pre-computed by the adapter + /// (see the type doc), never by a caller of this trait. Three states, all distinct: + /// `Some(n)` with `n > 0` is the recoverable share; `Some(0)` is a genuine zero the chain + /// accepts (e.g. `withdrawal_share_bps == 0`); `None` means the chain REFUSES the clawback + /// (the epoch has already started). An adapter MUST NOT map `None` to `0` or `Some(0)` to + /// `None`: `0` would claim a recoverable-nothing the chain never said (dig_ecosystem#3439). + pub recoverable_base_units: Option, } /// One distributor's chain-derived report — everything `dig.getRewardDistributor` and @@ -254,6 +265,19 @@ pub struct DistributorReport { pub commitments: Vec, /// Unix seconds this report was assembled. pub observed_at: u64, + /// The chain peak height the adapter's chain read was taken against — dig-rpc-protocol 0.14's + /// chain-view anchor (dig_ecosystem#3262/#3329, `GetRewardDistributorResult::chain_peak_height` + /// SPEC §4.5). MUST come from the SAME chain read that produced this report, not a later, + /// independent `peak_height()` call: a peak read separately from the snapshot names a height + /// the data did not come from, which is wrong in the most convincing possible way — a plausible + /// number beside stale data, with nothing erroring. Required, never `0`-as-absence: an adapter + /// that cannot obtain the peak alongside its read MUST refuse the whole call + /// (`ChainPortError::ChainPeakUnavailable`) instead of reporting one. + pub chain_peak_height: u64, + /// `block_timestamp(chain_peak_height)` from that SAME chain read — the chain clock + /// `entry_set_stale` is computed against, never the wall clock `observed_at` uses. Same + /// same-read requirement and refusal-not-zero rule as `chain_peak_height` above. + pub chain_peak_timestamp: u64, } /// Reads and the one write this engine needs from the reward-distributor chain state. Derived from diff --git a/crates/dig-node-core/src/seams/chia_peer/coinset_resolver.rs b/crates/dig-node-core/src/seams/chia_peer/coinset_resolver.rs index 7aadd0a5..01d7c1f2 100644 --- a/crates/dig-node-core/src/seams/chia_peer/coinset_resolver.rs +++ b/crates/dig-node-core/src/seams/chia_peer/coinset_resolver.rs @@ -415,7 +415,7 @@ mod tests { async fn unspent_coins_by_hint(&self, _hint: ChiaBytes32) -> ChainResult> { match self .answers_left - .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| { + .try_update(Ordering::SeqCst, Ordering::SeqCst, |left| { left.checked_sub(1) }) { Ok(_) => Ok(Vec::new()), diff --git a/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs b/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs index aebc7808..675658c5 100644 --- a/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs +++ b/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs @@ -1622,7 +1622,7 @@ mod tests { ) -> Result, dig_download::DownloadError> { let spend = self .budget - .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| { + .try_update(Ordering::SeqCst, Ordering::SeqCst, |left| { (left > 0).then(|| left.saturating_sub(1)) }); if spend.is_err() { 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 256d08f2..0c92119a 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -74,6 +74,13 @@ const REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE: &str = "REWARD_INVALID_WITHDRAWAL /// `REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE`'s sibling shape. const REWARD_ZERO_IDENTITY_MACHINE: &str = "REWARD_ZERO_IDENTITY"; +/// dig_ecosystem#3262/#3329: the machine code for [`ChainPortError::ChainPeakUnavailable`] — the +/// adapter's distributor read succeeded but it could not anchor a `chain_peak_height`/ +/// `chain_peak_timestamp` from that SAME read, so the whole call is refused rather than answered +/// with an invented or independently-read peak (SPEC §4.5). Sibling shape to the other +/// reward-distributor refusals above. +const REWARD_CHAIN_PEAK_UNAVAILABLE_MACHINE: &str = "REWARD_CHAIN_PEAK_UNAVAILABLE"; + /// Maps a [`ChainPortError`] to the JSON-RPC error response for both reward-distributor read /// methods (dig_ecosystem#3269 unit 2) — one mapping so `dig.getRewardDistributor` and /// `dig.listRewardDistributorCommitments` can never disagree about how a given port failure reads @@ -105,6 +112,12 @@ fn reward_chain_port_error_response(id: &Value, error: &ChainPortError) -> Value "message": format!("reward-distributor chain read failed: {msg}"), "data": { "code": "CONTROL_ERROR", "origin": "control" } }}), + ChainPortError::ChainPeakUnavailable => json!({"jsonrpc":"2.0","id":id,"error":{ + "code": CONTROL_ERROR, + "message": "distributor read succeeded but no chain peak height/timestamp from that \ + same read was available to anchor the result", + "data": { "code": REWARD_CHAIN_PEAK_UNAVAILABLE_MACHINE, "origin": "control" } + }}), } } @@ -922,10 +935,21 @@ impl RpcDispatch for Node { // 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); + // dig_ecosystem#3280: `launcher_id` is OPTIONAL here (unlike + // `GetRewardDistributor`'s required param), so absence is "no filter" and is not + // itself an error. A PRESENT value goes through the same validator + // `GetRewardDistributor` (below) and `ListRewardDistributorCommitments` already + // use, so a malformed value is refused with `-32602` instead of silently + // filtering to an empty list — the same "reassuring zero" defect this epic exists + // to kill, just on the input side rather than the read side. + let filter_launcher_id: Option<[u8; 32]> = if params.get("launcher_id").is_some() { + match parse_launcher_id_arg(¶ms) { + Ok(id) => Some(id), + Err(msg) => return rpc_err(&id, -32602, &msg), + } + } else { + None + }; let snapshots = node.reward_prover_status_snapshots(); // dig_ecosystem#3269 fix939: a zeroed `launcher_id` or `store_id` is never a real // distributor's or module's IDENTITY — see `is_missing_identity`/`zeroed_fields`. @@ -980,7 +1004,7 @@ impl RpcDispatch for Node { true }) .filter(|s| match &filter_launcher_id { - Some(want) => hex::encode(s.launcher_id).eq_ignore_ascii_case(want), + Some(want) => &s.launcher_id == want, None => true, }) .map(reward_prover_status_to_wire) @@ -1053,6 +1077,13 @@ impl RpcDispatch for Node { last_entry_write_at: report.last_entry_write_at, entry_set_stale: report.entry_set_stale, observed_at: report.observed_at, + // dig-rpc-protocol 0.14 chain-view anchor (dig_ecosystem#3262/#3329, SPEC §4.5): + // both come straight from `report`, i.e. the SAME chain read + // `RewardsChainPort::distributor_report` performed — never a fresh + // `peak_height()` call at this seam, which would anchor the answer to a height + // the rest of the data was never read against. + chain_peak_height: report.chain_peak_height, + chain_peak_timestamp: report.chain_peak_timestamp, }; return json!({"jsonrpc":"2.0","id":id,"result": result}); } @@ -1081,7 +1112,11 @@ impl RpcDispatch for Node { Ok(report) => report, Err(e) => return reward_chain_port_error_response(&id, &e), }; - let commitments: Vec = report + // dig-rpc-protocol 0.15 (dig_ecosystem#3442): the wire carries + // `recoverable_base_units` as `Option`, so the port's three-state figure + // maps straight across -- `None` stays `None` (never `0`, never an error), + // `Some(0)` stays `Some(0)`. + let commitments = report .commitments .iter() .map(|c| dig_rpc_protocol::types::RewardDistributorCommitment { @@ -1097,6 +1132,11 @@ impl RpcDispatch for Node { epoch_seconds: report.epoch_seconds, commitments, observed_at: report.observed_at, + // dig-rpc-protocol 0.14 chain-view anchor, same rule as + // `GetRewardDistributorResult` above: straight from `report`, the SAME chain + // read that produced everything else in this result. + chain_peak_height: report.chain_peak_height, + chain_peak_timestamp: report.chain_peak_timestamp, }; return json!({"jsonrpc":"2.0","id":id,"result": result}); } @@ -1156,6 +1196,12 @@ impl RpcDispatch for Node { }; let mut funded_refs = Vec::with_capacity(identities.len()); + // `observed_at` must date the CONSULTATION, never the assembly (dig_ecosystem#3323, + // dig-rpc-protocol SPEC §4.4.2's first bullet): each `report.observed_at` postdates + // the pre-loop `now` above (the chain port stamps it after its own read, uncached), + // so stamping the handler's clock here understates staleness. Fold the reports' own + // stamps and keep the OLDEST — a collection is only as fresh as its stalest member. + let mut oldest_observed_at: Option = None; for identity in identities { let Some(port) = node.reward_chain_port() else { return reward_chain_port_absent_response(&id); @@ -1168,6 +1214,10 @@ impl RpcDispatch for Node { Ok(report) => report, Err(e) => return reward_chain_port_error_response(&id, &e), }; + oldest_observed_at = Some(match oldest_observed_at { + Some(oldest) => oldest.min(report.observed_at), + None => report.observed_at, + }); funded_refs.push(dig_rpc_protocol::types::RewardDistributorRef { launcher_id: hex::encode(report.launcher_id), store_id: hex::encode(report.store_id), @@ -1177,7 +1227,10 @@ impl RpcDispatch for Node { let result = dig_rpc_protocol::types::ListRewardDistributorsResult { funded: dig_rpc_protocol::types::Half::Consulted { - observed_at: now, + // No reads happened for `FundsNothing` (empty `identities`), so the + // pre-loop handler clock is the honest stamp for that case — it IS the + // consultation. Both `NotConsulted` arms above keep `now` unchanged. + observed_at: oldest_observed_at.unwrap_or(now), items: funded_refs, }, claimable: dig_rpc_protocol::types::Half::NotConsulted { observed_at: now }, @@ -1210,12 +1263,25 @@ impl RpcDispatch for Node { // // No monetary amount, ever, and no payout puzzle hash — see `PayeeClaimStatus`'s doc. // No params type: this call takes none. + // + // `claim_loop` (dig-rpc-protocol 0.13.0, required on the 0.14 wire this crate now + // targets, dig_ecosystem#3329): this crate holds no claim loop either -- it lives in + // `dig-node-service`'s `src/rewards_claim/**` (dig_ecosystem#3268 wired it, landed; + // #3432 is the SPEC §13.2 off-chain seam), which this ticket's brief fences off. Same + // honesty rule as `claim_log` right above: the loop was never + // constructed from here, so the answer is `NotConsulted`, dated at the moment this + // responder established it has nothing to read -- never a manufactured `Consulted` + // with invented counts. Some(Method::GetPayeeRewardClaimStatus) => { use crate::rewards::state::Clock as _; + let now = crate::rewards::state::SystemClock.now_unix_seconds(); let result = dig_rpc_protocol::types::PayeeClaimStatus { subject: dig_rpc_protocol::types::PayeeSubject::Payee, claim_log: dig_rpc_protocol::types::ClaimLogObservation::NotConsulted { - observed_at: crate::rewards::state::SystemClock.now_unix_seconds(), + observed_at: now, + }, + claim_loop: dig_rpc_protocol::types::ClaimLoopObservation::NotConsulted { + observed_at: now, }, }; return json!({"jsonrpc":"2.0","id":id,"result": result}); diff --git a/crates/dig-node-core/tests/dependency_tree.rs b/crates/dig-node-core/tests/dependency_tree.rs index 2665d69f..0a752689 100644 --- a/crates/dig-node-core/tests/dependency_tree.rs +++ b/crates/dig-node-core/tests/dependency_tree.rs @@ -96,7 +96,7 @@ fn locked_versions(crate_name: &str) -> Vec<&str> { .collect() } -/// **Proves:** exactly ONE `dig-rpc-protocol` resolves in the workspace, and it is the 0.12 line that +/// **Proves:** exactly ONE `dig-rpc-protocol` resolves in the workspace, and it is the 0.15 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`), AND (#3269) the @@ -110,10 +110,10 @@ fn locked_versions(crate_name: &str) -> Vec<&str> { /// 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, final leg):** `dig-node-core` depends on 0.12 directly; `dig-peer` -/// (0.15.0), `dig-download` (0.24.0) and `dig-peer-selector` (0.13.0) all now resolve -/// `dig-rpc-protocol` 0.12 too, so `cargo metadata` resolves exactly one line. This assertion is -/// deliberately left at exactly-one/0.12 (never widened to accept a set — see #836/#1576); if a +/// **Cascade closed (#3329, final leg):** `dig-node-core` depends on 0.15 directly; `dig-peer` +/// (0.17.0), `dig-download` (0.26.0) and `dig-peer-selector` (0.15.0) all now resolve +/// `dig-rpc-protocol` 0.15 too, so `cargo metadata` resolves exactly one line. This assertion is +/// deliberately left at exactly-one/0.15 (never widened to accept a set — see #836/#1576/#3269); 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() { @@ -125,9 +125,9 @@ 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.12."), + versions[0].starts_with("0.15."), "the availability contract plus the #3269 reward RPC surface this node adopted ship in \ - dig-rpc-protocol 0.12; the workspace resolved {} — on an earlier line the canonical items \ + dig-rpc-protocol 0.15; 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-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 5674b4ea..44c3d3fa 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -194,7 +194,8 @@ getrandom = "0.2" # Moved to 0.12 (dig_ecosystem#3269), matching `dig-node-core`'s move to 0.12.0 — 0.12 renamed # `RewardSubject` -> `PayeeSubject` and replaced `HalfObservation` with `Half`, and this line # staying at 0.11 would duplicate the wire types the paragraph above warns against. -dig-rpc-protocol = "0.12" +# Moved to 0.15 (dig_ecosystem#3442): recoverable_base_units becomes Option. +dig-rpc-protocol = "0.15" # 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 @@ -216,7 +217,7 @@ dig-wallet = { path = "../dig-wallet" } # is what makes `RealClaimChainPort::own_entry` and `submit_initiate_payout` real instead of # refusals. Still the same chia 0.36 line (chia-sdk-driver `=0.36.0`, chia-protocol 0.36.1) every # other dependency in this crate is already pinned to. -dig-rewards-coin = "0.8" +dig-rewards-coin = "0.10" # HTTP stack: the same axum/tokio the node itself uses, so there is one async runtime # and one server framework across the node and the service shell. `ws` enables @@ -353,7 +354,7 @@ windows-sys = { version = "0.61", features = [ # 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.12" line so the guard can # never compare against a different catalogue than the shell compiles against. -dig-rpc-protocol = "0.12" +dig-rpc-protocol = "0.15" # 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/rewards/chain_port.rs b/crates/dig-node-service/src/rewards/chain_port.rs index e1f6b49d..fe0c9b07 100644 --- a/crates/dig-node-service/src/rewards/chain_port.rs +++ b/crates/dig-node-service/src/rewards/chain_port.rs @@ -15,7 +15,6 @@ use dig_node_core::rewards::port::{ Bytes32 as PortBytes32, ChainPortError, CommitmentSlot, DistributorChainState, DistributorRef, DistributorReport, EntryWriteBundle, RewardsChainPort, }; -use dig_rewards_coin::clawback::recoverable_base_units; use dig_rewards_coin::state::DistributorSnapshot; use dig_rewards_coin::RewardsError; use dig_wallet::sage::corroborated_source::CorroboratedChainSource; @@ -186,6 +185,13 @@ fn report_from_snapshot( comment: dig_rewards_coin::comment::LaunchComment, first_epoch_start: u64, ) -> Result { + // dig-rpc-protocol 0.14 chain-view anchor (dig_ecosystem#3262/#3329, SPEC §4.5/§4.6 cl.6): the + // peak comes from the SAME `ChainObservation` that decided every commitment's presence and + // recoverability below, never from a second read -- a row can then never contradict its own + // anchor. `dig-rewards-coin` itself refuses an absent peak, so `0` is never a stand-in here. + let observed = snapshot.observed(); + let chain_peak_height = u64::from(observed.peak_height()); + let chain_peak_timestamp = observed.peak_timestamp(); let distributor = snapshot.distributor(); let constants = distributor.info.constants; @@ -214,21 +220,18 @@ fn report_from_snapshot( // `Ok(Some(..))`. let current_distributor_epoch = epoch_ordinal(epoch_end, first_epoch_start, epoch_seconds); + // The chain's own answer, carried unchanged: `None` is a VALUE ("the chain refuses this + // clawback", dig_ecosystem#3442), not an error and never `0`. let commitments = snapshot - .slots() - .commitments + .commitments() .iter() - .map(|commitment| { - let recoverable = recoverable_base_units(commitment.rewards, withdrawal_share_bps) - .ok_or(ChainPortError::InvalidWithdrawalShare)?; - Ok(CommitmentSlot { - epoch_start: commitment.epoch_start, - clawback_puzzle_hash: commitment.clawback_ph.into(), - rewards_base_units: commitment.rewards, - recoverable_base_units: recoverable, - }) + .map(|commitment| CommitmentSlot { + epoch_start: commitment.distributor_epoch_start(), + clawback_puzzle_hash: commitment.clawback_authority().into(), + rewards_base_units: commitment.rewards_base_units(), + recoverable_base_units: commitment.recoverable_base_units(), }) - .collect::, ChainPortError>>()?; + .collect(); let observed_at = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -251,6 +254,8 @@ fn report_from_snapshot( entry_set_stale: snapshot.entry_set_stale(), commitments, observed_at, + chain_peak_height, + chain_peak_timestamp, }) } diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index e8543ba3..c4a9edb2 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -2227,6 +2227,27 @@ where // node in the network — a correct answer, and a permanently unchanging one. bring_up_collateral_records(); + // This node's funder-ownership registry (dig_ecosystem#3285), installed against the same + // hardened state dir the control token lives in (`state.state_dir`, resolved above). Unlike + // the chain-reading installs below, this is pure local state — no chain source, no + // `enable_chain_sync` gate — so it installs unconditionally, including under an integration + // harness with sync disabled. Until this call existed nothing installed a registry in any + // shipped binary, so `dig.listRewardDistributors`'s `funded` half answered `not_consulted` on + // every production node regardless of what an operator had funded (dig_ecosystem#3292). + // `install_funded_distributor_registry`'s `OnceLock` means a second call here (there is none) + // would simply be refused, not double-installed. The registry itself starts EMPTY on a fresh + // node — no record on disk until dig_ecosystem#3291's writer runs — so the correct read right + // after this call is `NotConfigured(NoRecordWritten)`, not a funded set; that is success, not + // a bug. + if !state.node.install_funded_distributor_registry( + dig_node_core::rewards::funded::FundedDistributorRegistry::with_state_dir(&state.state_dir), + ) { + tracing::warn!( + "install_funded_distributor_registry declined a second install: a funder-ownership \ + registry was already installed on this Node" + ); + } + // The CENSUS half (#400). `bring_up_collateral_records` writes epoch 1, which is derivable // from nothing; this is what lets the node record epoch n. It runs detached and on a timer // because a census depends on the chain having moved: an epoch that has begun by the clock is diff --git a/crates/dig-node-service/tests/common/rewards_fixture.rs b/crates/dig-node-service/tests/common/rewards_fixture.rs index b8fcd0ff..e38dd80d 100644 --- a/crates/dig-node-service/tests/common/rewards_fixture.rs +++ b/crates/dig-node-service/tests/common/rewards_fixture.rs @@ -479,6 +479,22 @@ pub fn launch_funded_admitted_fixture( pub fn launch_funded_admitted_fixture_with_approval( payout_puzzle_hash: Bytes32, require_payout_approval: bool, +) -> Result> { + launch_funded_admitted_fixture_with_shape( + payout_puzzle_hash, + require_payout_approval, + WITHDRAWAL_SHARE_BPS, + ) +} + +/// Same as [`launch_funded_admitted_fixture_with_approval`], but with the clawback +/// `withdrawal_share_bps` curried into the distributor -- dig_ecosystem#3442 needs a REAL launch +/// with `0` to prove a genuine zero share is carried as `Some(0)`, distinct from "not recoverable". +#[allow(dead_code)] +pub fn launch_funded_admitted_fixture_with_shape( + payout_puzzle_hash: Bytes32, + require_payout_approval: bool, + withdrawal_share_bps: u64, ) -> Result> { let ctx = &mut SpendContext::new(); let mut sim = Simulator::new(); @@ -577,7 +593,7 @@ pub fn launch_funded_admitted_fixture_with_approval( PAYOUT_THRESHOLD_BASE_UNITS, require_payout_approval, 0, - WITHDRAWAL_SHARE_BPS, + withdrawal_share_bps, source_cat.info.asset_id, ); @@ -722,6 +738,18 @@ pub fn launch_funded_admitted_fixture_with_approval( /// general `sim`/`singleton_members`/`extra_coin_ids` form, `tests/simulator.rs`). #[allow(dead_code)] // rustc compiles `mod common` separately per integration-test binary; this is reachable only from rewards_claim_chain_port_3347.rs, not rewards_chain_port_a3.rs pub fn mock_chain_source_for_funded_fixture(fixture: &FundedFixture) -> MockChainSource { + mock_chain_source_for_funded_fixture_with_clock(fixture, |height| u64::from(height) * 1_000 + 1) +} + +/// Same as [`mock_chain_source_for_funded_fixture`], but the chain's own clock (the block +/// timestamp served for each height) is chosen by the caller. dig_ecosystem#3442 needs the SAME +/// real commitment read once AFTER its epoch started (`None`) and once BEFORE (`Some(share)`), +/// and the only difference between those reads is the chain clock. +#[allow(dead_code)] +pub fn mock_chain_source_for_funded_fixture_with_clock( + fixture: &FundedFixture, + clock: impl Fn(u32) -> u64, +) -> MockChainSource { let eve_coin_id = fixture .sim .children(fixture.launcher_id) @@ -762,7 +790,7 @@ pub fn mock_chain_source_for_funded_fixture(fixture: &FundedFixture) -> MockChai let peak = fixture.sim.height(); for height in 0..=peak { - source = source.with_timestamp(height, u64::from(height) * 1_000 + 1); + source = source.with_timestamp(height, clock(height)); } source.with_peak(peak) } diff --git a/crates/dig-node-service/tests/funded_distributor_registry_startup_3292.rs b/crates/dig-node-service/tests/funded_distributor_registry_startup_3292.rs new file mode 100644 index 00000000..c6ce54db --- /dev/null +++ b/crates/dig-node-service/tests/funded_distributor_registry_startup_3292.rs @@ -0,0 +1,219 @@ +//! dig_ecosystem#3292: `Node::install_funded_distributor_registry` has a real, non-test caller. +//! +//! Before this ticket the installer was `pub(crate)`, so `dig-node-service` -- the crate that +//! owns `state.state_dir` and constructs the real `Node` -- could not call it at all. This file +//! does not compile against the pre-fix source (a `pub(crate)` method is invisible to this +//! integration-test crate, a separate compilation unit), which is this ticket's RED. + +use dig_node_core::rewards::funded::{FundedDistributorsRead, NotConfiguredReason}; +use dig_node_core::Node; + +/// Mirrors `rewards_chain_port_a3.rs`'s `install_reward_chain_port_refuses_a_second_install` +/// shape for the funder-ownership registry: install from a tempdir, once-only, and the read +/// after install is the correct "no record written yet" UNKNOWN -- never a reassuring empty +/// funded set (dig_ecosystem#3285's whole point; #3291's writer, not this ticket, is what would +/// ever populate it). +#[test] +fn install_funded_distributor_registry_installs_a_tempdir_backed_registry_once() { + let dir = tempfile::tempdir().expect("temp dir"); + let registry = + dig_node_core::rewards::funded::FundedDistributorRegistry::with_state_dir(dir.path()); + + // Sanity on the registry itself, independent of `Node`: a fresh state dir with no record + // file reads `NotConfigured(NoRecordWritten)`, not `FundsNothing` -- the property the + // installer must not collapse. + assert_eq!( + registry.read(), + FundedDistributorsRead::NotConfigured(NotConfiguredReason::NoRecordWritten), + "a fresh state dir with no record file must read as UNKNOWN, never a funded set" + ); + + // `Node` exposes no lighter test constructor to an external integration-test crate -- + // `Node::from_env()` is the same constructor `rewards_chain_port_a3.rs`'s own test uses for + // the identical reason. + let node = Node::from_env(); + + let first_install = node.install_funded_distributor_registry(registry.clone()); + assert!(first_install, "the first install must be accepted"); + + let second_install = node.install_funded_distributor_registry(registry); + assert!( + !second_install, + "the second install must be refused, changing nothing" + ); +} + +/// The slice of a source file before its own `#[cfg(test)]` module -- i.e. what actually ships. +/// Duplicated from `rewards_chain_port_a3.rs`'s identical helper because this integration test is +/// a separate compilation unit and cannot import a private helper from that file. +fn production_region(source: &str) -> &str { + match source.find("#[cfg(test)]") { + Some(test_module_start) => &source[..test_module_start], + None => source, + } +} + +/// The offset of `needle`'s first occurrence in `haystack`, or a panic naming `msg` -- so a +/// deleted call site fails LOUDLY (with a reason) rather than as a silent `None` swallowed by +/// `.unwrap_or`. +fn must_find(haystack: &str, needle: &str, msg: &str) -> usize { + haystack.find(needle).unwrap_or_else(|| panic!("{msg}")) +} + +/// Closes the gap `server_startup_calls_install_funded_distributor_registry_in_production_code` +/// (above) cannot: that test is a pure CONTAINS check, so it is blind to the install call being +/// *moved* rather than deleted. dig_ecosystem#3292 originally shipped with exactly that shape -- +/// the call existed (as a `pub(crate)` no-op nobody drove), just not on a path that ran. The +/// mutation this guards is moving the call inside `if config.enable_chain_sync { .. }`: an +/// integration harness (and every test in this crate) runs with `enable_chain_sync: false` +/// specifically so nothing dials the network, so a call gated behind that flag would silently +/// stop running under the harness AND on any deployment that (for whatever future reason) ships +/// with sync disabled -- reintroducing dig_ecosystem#3292 with a passing `contains(..)` test +/// beside it. Ordering position in the source is the only signal available to a black-box, +/// separate-compilation-unit integration test: see this file's module doc below for why a true +/// runtime distinction between "installed" and "never installed" is not reachable from here. +#[test] +fn install_call_precedes_the_enable_chain_sync_gate_in_production_code() { + let server_source = production_region(include_str!("../src/server.rs")); + + let install_offset = must_find( + server_source, + "install_funded_distributor_registry(", + "server.rs's production startup path must call \ + `Node::install_funded_distributor_registry`, or dig_ecosystem#3292 has regressed \ + (mutation (a): the call was deleted)", + ); + let chain_sync_gate_offset = must_find( + server_source, + "if config.enable_chain_sync {", + "server.rs no longer spells its chain-sync gate as `if config.enable_chain_sync {` -- \ + update this test's needle to match, it is not itself evidence of a regression", + ); + + assert!( + install_offset < chain_sync_gate_offset, + "install_funded_distributor_registry's call site ({install_offset}) is no longer before \ + the `enable_chain_sync` gate ({chain_sync_gate_offset}): it has moved to or past that \ + gate, which means a node/harness running with chain sync disabled would start with NO \ + funder-ownership registry installed -- dig_ecosystem#3292's exact regression shape \ + (mutation (b))" + ); +} + +/// Boots the REAL production startup path (`serve_with_shutdown`, the same function `dig-node +/// run` calls) end to end on an ephemeral loopback port, with chain sync disabled (as every +/// harness must -- see [`must_find`]'s caller's doc). Proves the install call site is reachable +/// and executes without panicking or erroring as part of an actual startup, which +/// `install_funded_distributor_registry_installs_a_tempdir_backed_registry_once` (calling the +/// installer directly, never through `serve_with_shutdown`) cannot: that test would still pass if +/// `server.rs` never called the installer at all. +/// +/// # Why this test cannot ALSO assert "installed, not merely not-panicking" +/// +/// It would if it could. `Node::funded_distributor_registry` / `Node::funded_distributors_read` +/// are `pub(crate)` to `dig-node-core` -- invisible to `dig-node-service` itself, let alone to +/// this separate integration-test compilation unit -- so there is no accessor this test can call. +/// `AppState`'s `node: Arc` field is private with no getter, and `serve_with_shutdown` +/// returns only `io::Result<()>` once shutdown resolves, so no caller outside `server.rs` ever +/// holds the `Node` `serve_with_shutdown` built. +/// +/// The one externally-reachable read, `dig.listRewardDistributors`, does not help either: +/// `dispatch.rs`'s handler matches +/// `FundedDistributorsRead::NotConfigured(_) | PersistedStateCorrupt { .. } | IoFailed { .. }` as +/// ONE wildcarded arm and answers `Half::NotConsulted` for all three, with no `reason` field on +/// the wire. A registry that was never installed reads `NotConfigured(NoStateDirectory)`; one +/// installed against a fresh, empty state dir (exactly what `serve_with_shutdown` produces, since +/// dig_ecosystem#3291's writer does not exist yet) reads `NotConfigured(NoRecordWritten)` -- two +/// DIFFERENT `NotConfiguredReason` variants, genuinely distinguishable at the type level (see +/// `install_funded_distributor_registry_installs_a_tempdir_backed_registry_once`'s assertion on +/// exactly this), but the RPC response for both is byte-for-byte identical +/// `{"funded":{"status":"not_consulted","observed_at":..},"claimable":{..}}`. So today, "installed +/// and empty" and "never installed" are NOT distinguishable from any observer this crate's tests +/// can reach -- not the RPC surface, not the filesystem (the registry writes nothing at +/// construction or install; only a future recorded funding act would), and not a public accessor. +/// Closing that gap needs either a `#[cfg(test)]`-only accessor on `Node`/`AppState` or a `reason` +/// field on the wire response -- both are production-surface changes, out of this ticket's scope +/// (see this file's brief: "do not change production behaviour... stop and ask me first"). +/// +/// So this test proves reachability-without-panic, and +/// `install_call_precedes_the_enable_chain_sync_gate_in_production_code` (above) supplies the +/// actual mutation-(b) RED signal, via source position rather than a runtime read. +#[tokio::test] +async fn production_startup_reaches_the_install_call_site_without_panicking() { + // Serializes with every other test in this crate/process that reads the process-global + // DIG_NODE_CACHE / DIG_NODE_STATE_DIR env vars live (mirrors `tests/server.rs`'s `env_guard`). + static ENV_LOCK: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + let lock = ENV_LOCK + .get_or_init(|| std::sync::Arc::new(tokio::sync::Mutex::new(()))) + .clone(); + let _hold = lock.lock_owned().await; + + let base = tempfile::Builder::new() + .prefix("dig-node-3292-startup-") + .tempdir() + .expect("a scratch dir"); + let cache = base.path().join("cache"); + std::fs::create_dir_all(&cache).expect("create test cache dir"); + std::env::set_var("DIG_NODE_CACHE", &cache); + std::env::set_var("DIG_NODE_CACHE_CAP", "67108864"); + // Isolates the #501 control-token/paired-token state dir -- also where this ticket's + // registry persists (`state.state_dir`) -- so this test never touches a real machine's + // state and no concurrent test shares it. + std::env::set_var("DIG_NODE_STATE_DIR", base.path()); + // Opts out of the §14 peer network bring-up so this stays hermetic (no gossip/DHT/relay + // reach), mirroring `tests/server.rs`'s dual-listener test. + std::env::set_var("DIG_PEER_NETWORK", "off"); + + let free = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind an ephemeral loopback port to learn a free one"); + let port = free.local_addr().expect("local_addr").port(); + drop(free); // release it so serve_with_shutdown can bind the same port + + let config = dig_node_service::Config { + port, + dig_local: false, // skip the privileged :80 attempt entirely + enable_chain_sync: false, // never dial mainnet from this harness (#2501) + ..dig_node_service::Config::default() + }; + + let stop = std::sync::Arc::new(tokio::sync::Notify::new()); + let stop_for_server = stop.clone(); + let server = tokio::spawn(async move { + dig_node_service::server::serve_with_shutdown(config, async move { + stop_for_server.notified().await; + }) + .await + }); + + // Poll /health until the real startup path (including this ticket's install call, which + // runs before any listener binds) has completed and the server is actually serving. + let url = format!("http://127.0.0.1:{port}/health"); + let client = reqwest::Client::new(); + let mut served = false; + for _ in 0..50 { + if let Ok(resp) = client.get(&url).send().await { + if resp.status().is_success() { + served = true; + break; + } + } + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + } + assert!( + served, + "serve_with_shutdown never reached a serving state -- production startup (which includes \ + this ticket's install_funded_distributor_registry call) did not complete cleanly" + ); + + stop.notify_waiters(); + let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), server).await; + let join_result = + outcome.expect("serve_with_shutdown must stop within 5s of the shutdown signal"); + let io_result = join_result.expect("the server task must not panic"); + assert!( + io_result.is_ok(), + "serve_with_shutdown returned an error: {io_result:?}" + ); +} diff --git a/crates/dig-node-service/tests/rewards_chain_port_a3.rs b/crates/dig-node-service/tests/rewards_chain_port_a3.rs index d5ad5d32..def878a1 100644 --- a/crates/dig-node-service/tests/rewards_chain_port_a3.rs +++ b/crates/dig-node-service/tests/rewards_chain_port_a3.rs @@ -46,7 +46,8 @@ use dig_node_service::rewards::RealRewardsChainPort; use dig_rewards_coin::constants::WITHDRAWAL_SHARE_BPS; use common::rewards_fixture::{ - launch_fixture, mock_chain_source, FIRST_EPOCH_START, TEST_EPOCH_SECONDS, + launch_fixture, launch_funded_admitted_fixture_with_shape, mock_chain_source, + mock_chain_source_for_funded_fixture_with_clock, FIRST_EPOCH_START, TEST_EPOCH_SECONDS, }; /// A3: `RealRewardsChainPort::distributor_report` — the real production adapter, driven by a @@ -158,3 +159,45 @@ fn production_region(source: &str) -> &str { None => source, } } + +/// Reads the one commitment of a real funded launch (committed into the first epoch, then rolled +/// past it) through the real adapter, with the chain clock chosen by the caller. +async fn read_first_commitment( + withdrawal_share_bps: u64, + clock: impl Fn(u32) -> u64, +) -> dig_node_core::rewards::port::CommitmentSlot { + let payout = chia_protocol::Bytes32::from([0x77; 32]); + let fixture = launch_funded_admitted_fixture_with_shape(payout, false, withdrawal_share_bps) + .expect("a funded, admitted distributor launches cleanly in the simulator"); + let source = mock_chain_source_for_funded_fixture_with_clock(&fixture, clock); + let port = RealRewardsChainPort::::new(Arc::new(source)); + let report = port + .distributor_report(fixture.launcher_id.into()) + .await + .expect("a real funded distributor must report"); + report + .commitments + .into_iter() + .find(|c| c.epoch_start == FIRST_EPOCH_START) + .expect("the fixture committed rewards into the first epoch") +} + +/// **Guards dig_ecosystem#3439 / #3442:** a commitment whose epoch has STARTED on the chain's own +/// clock is one the chain refuses to claw back. The adapter must carry that as `None`; a `None` +/// mapped to `0` tells a user they can recover nothing when the chain actually refuses. +#[tokio::test(flavor = "multi_thread")] +async fn an_epoch_started_commitment_is_reported_as_none_not_zero() { + // Clock past FIRST_EPOCH_START at every height: the epoch has started. + let slot = read_first_commitment(WITHDRAWAL_SHARE_BPS, |h| u64::from(h) * 1_000 + 1_235).await; + assert_eq!(slot.recoverable_base_units, None); +} + +/// **Guards dig_ecosystem#3439 / #3442:** a NOT-started commitment on a distributor whose real +/// `withdrawal_share_bps` is 0 has a genuine zero share: `Some(0)`, distinct from the +/// refused-claw-back `None`. Collapsing the two is the #3439 defect in the other direction. +#[tokio::test(flavor = "multi_thread")] +async fn a_not_started_commitment_with_zero_share_is_reported_as_some_zero() { + // Clock before FIRST_EPOCH_START at every height: the epoch has not started. + let slot = read_first_commitment(0, u64::from).await; + assert_eq!(slot.recoverable_base_units, Some(0)); +}