diff --git a/Cargo.lock b/Cargo.lock index 7f57fc7f..cf278e7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2689,9 +2689,9 @@ dependencies = [ [[package]] name = "dig-download" -version = "0.24.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d705dda562d63c03a1a481087c3b9f632b90982676ea8bb4e55f2cd04c4465fc" +checksum = "2ca48cd305747310b4f760fb260b309b4a2ebddf6cb2a2933679fc3153ea48d1" dependencies = [ "async-trait", "dig-constants 0.11.2", @@ -3151,9 +3151,9 @@ dependencies = [ [[package]] name = "dig-peer" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01657d5ef42a4ebf038d53b3b398997057417558cd6c059f4f58716e68676f34" +checksum = "779734f3bb8e29d4c94fbbe85b33602e228a51d843033c1104d5ced23f1c2743" 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.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3be02a4acba35b9580f3655c95cf4e127031e100e684069a17fb2829bb4e68b" +checksum = "ac7ca508a390233dffd654ef241dc4743cd273f690147c4ebeea175777c05b51" dependencies = [ "dig-dht", "dig-nat", @@ -3237,9 +3237,9 @@ dependencies = [ [[package]] name = "dig-rpc-protocol" -version = "0.12.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb22a4741239303c9558b00d20c0cb6d9afad965382656bece3d4bbe9641f24" +checksum = "5ef77b4f6f4d6be8f8d479202867ce4e8e33b0f20c34c3e46a0a2d532e727997" dependencies = [ "serde", "serde_json", diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 656af50a..b75df00c 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -198,7 +198,7 @@ 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" +dig-rpc-protocol = "0.14" # 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 +471,7 @@ 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" +dig-download = "0.25" # -- 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 +489,7 @@ 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" +dig-peer = "0.16" # -- 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 +525,7 @@ 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" +dig-peer-selector = "0.14" # 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 +609,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.25", 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 53728f35..aadec070 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -9906,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, } } @@ -9983,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" @@ -10016,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 @@ -10063,6 +10074,8 @@ mod tests { "epoch_seconds", "commitments", "observed_at", + "chain_peak_height", + "chain_peak_timestamp", ]) ); assert_eq!( @@ -10075,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] @@ -10269,8 +10287,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; @@ -10302,7 +10324,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")); @@ -10311,6 +10333,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` diff --git a/crates/dig-node-core/src/rewards/port.rs b/crates/dig-node-core/src/rewards/port.rs index 1a98205e..eff33be0 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) @@ -254,6 +261,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/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index 4f1ecf55..e3dc6741 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" } + }}), } } @@ -1064,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}); } @@ -1108,6 +1128,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}); } @@ -1221,12 +1246,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..8576f8d3 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.14 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.14 directly; `dig-peer` +/// (0.16.0), `dig-download` (0.25.0) and `dig-peer-selector` (0.14.0) all now resolve +/// `dig-rpc-protocol` 0.14 too, so `cargo metadata` resolves exactly one line. This assertion is +/// deliberately left at exactly-one/0.14 (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.14."), "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.14; 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..0148bd17 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -194,7 +194,7 @@ 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" +dig-rpc-protocol = "0.14" # 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 @@ -353,7 +353,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.14" # 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..ff768927 100644 --- a/crates/dig-node-service/src/rewards/chain_port.rs +++ b/crates/dig-node-service/src/rewards/chain_port.rs @@ -174,7 +174,36 @@ where })?; let first_epoch_start = first_epoch_state.round_time_info.last_update; - report_from_snapshot(&snapshot, launcher_id, comment, first_epoch_start) + // dig-rpc-protocol 0.14 chain-view anchor (dig_ecosystem#3262/#3329, SPEC §4.5): read the peak + // HERE, in the same synchronous `spawn_blocking` body that produced `snapshot` above, never + // later at the RPC seam. A peak read independently of the snapshot would anchor the answer to + // a height the rest of the report was never read against -- a plausible number beside + // possibly-stale data, with nothing erroring. Both reads MUST succeed or the whole call + // refuses; see `ChainPortError::ChainPeakUnavailable`'s doc for why `0` is never a stand-in. + let chain_peak_height = read_chain_peak(source)?; + + report_from_snapshot( + &snapshot, + launcher_id, + comment, + first_epoch_start, + chain_peak_height, + ) +} + +/// Reads the chain peak height and its block timestamp as one pair, refusing rather than +/// substituting `0` if either leg of the read fails -- SPEC §4.5 forbids a zeroed anchor, and `0` +/// height is a claim about genesis, not an absence. +fn read_chain_peak(source: &S) -> Result<(u64, u64), ChainPortError> { + let height = source + .peak_height() + .map_err(|e| ChainPortError::Other(format!("peak height read failed: {e}")))? + .ok_or(ChainPortError::ChainPeakUnavailable)?; + let timestamp = source + .block_timestamp(height) + .map_err(|e| ChainPortError::Other(format!("peak timestamp read failed: {e}")))? + .ok_or(ChainPortError::ChainPeakUnavailable)?; + Ok((u64::from(height), timestamp)) } /// Maps a [`DistributorSnapshot`] plus the launch comment onto the port's [`DistributorReport`]. @@ -185,7 +214,9 @@ fn report_from_snapshot( launcher_id: chia_protocol::Bytes32, comment: dig_rewards_coin::comment::LaunchComment, first_epoch_start: u64, + chain_peak: (u64, u64), ) -> Result { + let (chain_peak_height, chain_peak_timestamp) = chain_peak; let distributor = snapshot.distributor(); let constants = distributor.info.constants; @@ -251,6 +282,8 @@ fn report_from_snapshot( entry_set_stale: snapshot.entry_set_stale(), commitments, observed_at, + chain_peak_height, + chain_peak_timestamp, }) }