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
99 changes: 85 additions & 14 deletions crates/dig-node-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<rewards::funded::FundedDistributorRegistry>,
/// The chain seam `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments`
/// (dig_ecosystem#3269 units 1-2) read through — [`rewards::port::RewardsChainPort`].
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions crates/dig-node-core/src/rewards/funded.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -819,8 +817,12 @@ mod tests {
);
}

/// **Catches:** a future variant added to the not-an-answer half of
/// [`FundedDistributorsRead`] that `determined` reports as a renderable set.
/// **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 = [
Expand Down
21 changes: 16 additions & 5 deletions crates/dig-node-core/src/seams/dig_rpc/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -922,10 +922,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(&params) {
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`.
Expand Down Expand Up @@ -980,7 +991,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)
Expand Down
21 changes: 21 additions & 0 deletions crates/dig-node-service/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading