diff --git a/Cargo.lock b/Cargo.lock index cf278e7a..9d400072 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2689,9 +2689,9 @@ dependencies = [ [[package]] name = "dig-download" -version = "0.25.0" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ca48cd305747310b4f760fb260b309b4a2ebddf6cb2a2933679fc3153ea48d1" +checksum = "8586f4d17592fccaf88e26b0f98e162cf79edbaf8cd27cdb2e29f90f405b682b" dependencies = [ "async-trait", "dig-constants 0.11.2", @@ -3151,9 +3151,9 @@ dependencies = [ [[package]] name = "dig-peer" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "779734f3bb8e29d4c94fbbe85b33602e228a51d843033c1104d5ced23f1c2743" +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.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7ca508a390233dffd654ef241dc4743cd273f690147c4ebeea175777c05b51" +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.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef77b4f6f4d6be8f8d479202867ce4e8e33b0f20c34c3e46a0a2d532e727997" +checksum = "1160e00673f442119f53557896bc155ec808da0df5eb9d09fc1b0e03f8c6b133" dependencies = [ "serde", "serde_json", diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index b75df00c..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.14" +# 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.25" +# 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.25" # # 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.16" +# 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.16" # # 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.14" +# 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.25", 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 146c1efb..e2f56e48 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -10043,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!( @@ -10844,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]); @@ -10895,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/port.rs b/crates/dig-node-core/src/rewards/port.rs index eff33be0..5434e321 100644 --- a/crates/dig-node-core/src/rewards/port.rs +++ b/crates/dig-node-core/src/rewards/port.rs @@ -215,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 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 3bda4c13..0c92119a 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -1112,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 { diff --git a/crates/dig-node-core/tests/dependency_tree.rs b/crates/dig-node-core/tests/dependency_tree.rs index 8576f8d3..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.14 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 (#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 +/// **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.14."), + versions[0].starts_with("0.15."), "the availability contract plus the #3269 reward RPC surface this node adopted ship in \ - dig-rpc-protocol 0.14; 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 0148bd17..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.14" +# 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.14" +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 ff768927..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; @@ -174,36 +173,7 @@ where })?; let first_epoch_start = first_epoch_state.round_time_info.last_update; - // 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)) + report_from_snapshot(&snapshot, launcher_id, comment, first_epoch_start) } /// Maps a [`DistributorSnapshot`] plus the launch comment onto the port's [`DistributorReport`]. @@ -214,9 +184,14 @@ 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; + // 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; @@ -245,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) 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/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)); +}