Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 9 additions & 5 deletions crates/dig-node-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>.
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.
Expand Down Expand Up @@ -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<u64>.
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
Expand All @@ -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<u64>.
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
Expand Down Expand Up @@ -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<u64>.
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
Expand Down Expand Up @@ -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.
Expand Down
70 changes: 67 additions & 3 deletions crates/dig-node-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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<u64>) -> 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
Expand Down
10 changes: 7 additions & 3 deletions crates/dig-node-core/src/rewards/port.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,
}

/// One distributor's chain-derived report — everything `dig.getRewardDistributor` and
Expand Down
6 changes: 5 additions & 1 deletion crates/dig-node-core/src/seams/dig_rpc/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1112,7 +1112,11 @@ impl RpcDispatch for Node {
Ok(report) => report,
Err(e) => return reward_chain_port_error_response(&id, &e),
};
let commitments: Vec<dig_rpc_protocol::types::RewardDistributorCommitment> = report
// dig-rpc-protocol 0.15 (dig_ecosystem#3442): the wire carries
// `recoverable_base_units` as `Option<u64>`, 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 {
Expand Down
14 changes: 7 additions & 7 deletions crates/dig-node-core/tests/dependency_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand All @@ -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]
);
Expand Down
7 changes: 4 additions & 3 deletions crates/dig-node-service/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`, 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<u64>.
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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
62 changes: 17 additions & 45 deletions crates/dig-node-service/src/rewards/chain_port.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<S: ChainSource>(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`].
Expand All @@ -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<DistributorReport, ChainPortError> {
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;

Expand Down Expand Up @@ -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::<Result<Vec<_>, ChainPortError>>()?;
.collect();

let observed_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
Expand Down
Loading
Loading