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
119 changes: 119 additions & 0 deletions crates/dig-node-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10353,6 +10353,125 @@ mod tests {
}
}

/// **Proves (dig_ecosystem#3342, gate H1):** the wire actually carries the not-a-distributor /
/// chain-unavailable split, through the REAL dispatch path — not just the port-level enum. An
/// installed port answering `Err(NotADistributor)` must reach `data.code ==
/// "REWARD_NOT_A_DISTRIBUTOR"`; an installed port answering `Err(Unavailable)` must reach
/// `data.code == "REWARD_CHAIN_UNAVAILABLE"`; the two must differ; and neither response body
/// may contain the substring `"adapter is wired"` — that sentence is reserved for the ONE case
/// where no port is installed at all.
/// **Mutation-probe:** in `seams::dig_rpc::dispatch::reward_chain_port_error_response`, point
/// the `NotADistributor` arm's `data.code` at `REWARD_CHAIN_UNAVAILABLE_MACHINE` (re-collapsing
/// the split) and this test's `assert_ne!` on the two codes fails.
/// **Catches:** a future edit that re-merges the two wire codes while the port-level enum
/// variant, and everything else, stays green. Tests BOTH `dig.getRewardDistributor` and
/// `dig.listRewardDistributorCommitments` -- separate handlers that could drift independently.
#[test]
fn reward_distributor_methods_pin_the_not_a_distributor_wire_code_distinct_from_unavailable() {
let absent_launcher_id = [0x90u8; 32];
let missing_launcher_id = [0x91u8; 32];
let outage_launcher_id = [0x92u8; 32];

for method in [
"dig.getRewardDistributor",
"dig.listRewardDistributorCommitments",
] {
// A fresh node per method: `install_reward_chain_port` is once-only (backed by a
// `OnceLock`), and the "no port installed" case below must be true independently for
// each method, not just the first one through the loop.
let (node, _td) = test_node(None);

// Case 1: no port installed at all -- the ONE case allowed to say "adapter is wired".
let absent_resp = rt().block_on(handle_rpc(
&node,
json!({"jsonrpc":"2.0","id":1,"method":method,
"params":{"launcher_id": hex::encode(absent_launcher_id)}}),
crate::download::ReadOrigin::Local,
crate::download::RequestProvenance::FirstParty,
));
assert_eq!(
absent_resp["error"]["data"]["code"],
json!("REWARD_CHAIN_UNAVAILABLE"),
"{method}"
);
assert!(
absent_resp["error"]["message"]
.as_str()
.unwrap()
.contains("adapter is wired"),
"{method}: no-port-installed case must say so: {absent_resp}"
);

assert!(
node.install_reward_chain_port(Arc::new(FakeRewardsChainPort {
reports: std::collections::HashMap::from([
(
missing_launcher_id,
Err(crate::rewards::port::ChainPortError::NotADistributor),
),
(
outage_launcher_id,
Err(crate::rewards::port::ChainPortError::Unavailable),
),
]),
}))
);

// Case 2: the chain answered -- no distributor there.
let missing_resp = rt().block_on(handle_rpc(
&node,
json!({"jsonrpc":"2.0","id":2,"method":method,
"params":{"launcher_id": hex::encode(missing_launcher_id)}}),
crate::download::ReadOrigin::Local,
crate::download::RequestProvenance::FirstParty,
));
assert!(
missing_resp.get("result").is_none(),
"{method}: {missing_resp}"
);
assert_eq!(
missing_resp["error"]["data"]["code"],
json!("REWARD_NOT_A_DISTRIBUTOR"),
"{method}"
);
assert!(
!missing_resp.to_string().contains("adapter is wired"),
"{method}: an installed adapter's own answer must never claim none is wired: \
{missing_resp}"
);

// Case 3: the chain source itself could not be reached.
let outage_resp = rt().block_on(handle_rpc(
&node,
json!({"jsonrpc":"2.0","id":3,"method":method,
"params":{"launcher_id": hex::encode(outage_launcher_id)}}),
crate::download::ReadOrigin::Local,
crate::download::RequestProvenance::FirstParty,
));
assert!(
outage_resp.get("result").is_none(),
"{method}: {outage_resp}"
);
assert_eq!(
outage_resp["error"]["data"]["code"],
json!("REWARD_CHAIN_UNAVAILABLE"),
"{method}"
);
assert!(
!outage_resp.to_string().contains("adapter is wired"),
"{method}: an installed adapter's own outage must never claim none is wired: \
{outage_resp}"
);

// The wire distinction actually exists: these two must differ.
assert_ne!(
missing_resp["error"]["data"]["code"], outage_resp["error"]["data"]["code"],
"{method}: not-a-distributor and chain-unavailable must be distinguishable on \
the wire"
);
}
}

/// **Proves:** when the port refuses because `withdrawal_share_bps` is out of range (either
/// side: doesn't fit `u16`, the caller narrows before calling this port, or the adapter's own
/// `0..=10_000` domain check), BOTH methods refuse the WHOLE call with a distinct machine code
Expand Down
6 changes: 6 additions & 0 deletions crates/dig-node-core/src/rewards/port.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ pub enum ChainPortError {
/// No chain source is wired yet — the [`unavailable`] adapter's only answer, and what any real
/// adapter should answer for an unreachable chain too (SPEC §12.2 clause 4).
Unavailable,
/// dig_ecosystem#3342: the chain source ANSWERED, and no reward distributor exists at the
/// requested `launcher_id`. This is deliberately NOT [`ChainPortError::Unavailable`]: a funder
/// deciding whether to claw back must be able to tell "you have nothing there" (this variant)
/// apart from "we cannot see the chain" (`Unavailable`) — collapsing both onto one shape turns
/// that decision into a guess on a money surface.
NotADistributor,
/// dig_ecosystem#3269/#3284/#3303: the distributor's `withdrawal_share_bps` (a `u64` on the
/// puzzle) either does not fit the wire's `u16` domain or exceeds the legitimate `0..=10_000`
/// bps range. The adapter MUST refuse the WHOLE [`RewardsChainPort::distributor_report`] call
Expand Down
49 changes: 39 additions & 10 deletions crates/dig-node-core/src/seams/dig_rpc/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,26 @@ use crate::*;
/// own surface.
const ENGINE_WARMING: i64 = -32002;

/// `REWARD_CHAIN_UNAVAILABLE` (dig_ecosystem#3269): no reward-distributor chain-read adapter is
/// wired yet (`rewards::port::ChainPortError::Unavailable`, or no adapter installed at all).
/// `REWARD_CHAIN_UNAVAILABLE` (dig_ecosystem#3269, corrected by dig_ecosystem#3342): the
/// reward-distributor chain read could not complete — either no adapter is installed at all (the
/// `let Some(port) = … else` arms below, via [`reward_chain_port_absent_response`]), or an
/// installed adapter's `ChainPortError::Unavailable` means the chain source itself could not
/// answer. It no longer means "the chain answered and there is nothing there" — that is
/// [`ChainPortError::NotADistributor`], reported under [`REWARD_NOT_A_DISTRIBUTOR_MACHINE`].
/// Distinct from [`REWARD_INVALID_WITHDRAWAL_SHARE_MACHINE`] below — a caller must be able to tell
/// "ask me again once the adapter lands" apart from "this distributor's own constant is out of
/// range". Reuses [`CONTROL_ERROR`]'s numeric code (both are control-plane runtime errors,
/// `-32032`), but carries its own `data.code` machine string so the two are still distinguishable
/// in the body.
/// "the chain could not be reached" apart from "this distributor's own constant is out of range".
/// Reuses [`CONTROL_ERROR`]'s numeric code (both are control-plane runtime errors, `-32032`), but
/// carries its own `data.code` machine string so the two are still distinguishable in the body.
const REWARD_CHAIN_UNAVAILABLE_MACHINE: &str = "REWARD_CHAIN_UNAVAILABLE";

/// `REWARD_NOT_A_DISTRIBUTOR` (dig_ecosystem#3342): the chain source answered, and no reward
/// distributor exists at the requested launcher id. Kept distinct from
/// [`REWARD_CHAIN_UNAVAILABLE_MACHINE`] on purpose — see [`ChainPortError::NotADistributor`]'s own
/// doc for why collapsing the two is a money-surface defect, not a cosmetic one. Reuses
/// [`CONTROL_ERROR`]'s numeric code, matching every other reward-distributor machine code here; no
/// wire-protocol change is needed since `data.code` alone carries the distinction.
const REWARD_NOT_A_DISTRIBUTOR_MACHINE: &str = "REWARD_NOT_A_DISTRIBUTOR";

/// `REWARD_INVALID_WITHDRAWAL_SHARE` (dig_ecosystem#3269/#3284/#3303): the distributor's
/// `withdrawal_share_bps` does not fit the wire's `u16` domain or exceeds the legitimate
/// `0..=10_000` range. Refuses the WHOLE call — see `rewards::port::ChainPortError::InvalidWithdrawalShare`'s
Expand Down Expand Up @@ -71,9 +82,14 @@ fn reward_chain_port_error_response(id: &Value, error: &ChainPortError) -> Value
match error {
ChainPortError::Unavailable => json!({"jsonrpc":"2.0","id":id,"error":{
"code": CONTROL_ERROR,
"message": "reward-distributor chain read is unavailable: no chain-read adapter is wired yet",
"message": "reward-distributor chain read is unavailable: the chain source could not answer",
"data": { "code": REWARD_CHAIN_UNAVAILABLE_MACHINE, "origin": "control" }
}}),
ChainPortError::NotADistributor => json!({"jsonrpc":"2.0","id":id,"error":{
Comment thread
MichaelTaylor3d marked this conversation as resolved.
"code": CONTROL_ERROR,
"message": "no reward distributor exists at this launcher id on chain",
"data": { "code": REWARD_NOT_A_DISTRIBUTOR_MACHINE, "origin": "control" }
}}),
ChainPortError::InvalidWithdrawalShare => json!({"jsonrpc":"2.0","id":id,"error":{
"code": CONTROL_ERROR,
"message": "distributor's withdrawal_share_bps is out of range (must fit u16 and be <= 10000)",
Expand All @@ -92,6 +108,19 @@ fn reward_chain_port_error_response(id: &Value, error: &ChainPortError) -> Value
}
}

/// The response for the ONE case where "no chain-read adapter is wired yet" is actually true: no
/// `rewards::port::RewardsChainPort` has been installed on this `Node` at all
/// (dig_ecosystem#3342). Kept separate from [`reward_chain_port_error_response`] so that
/// function's `Unavailable` arm never has to carry a sentence that is false whenever an installed
/// adapter reports its own `Unavailable` for a chain-source outage.
fn reward_chain_port_absent_response(id: &Value) -> Value {
json!({"jsonrpc":"2.0","id":id,"error":{
"code": CONTROL_ERROR,
"message": "reward-distributor chain read is unavailable: no chain-read adapter is wired yet",
"data": { "code": REWARD_CHAIN_UNAVAILABLE_MACHINE, "origin": "control" }
}})
}

/// The largest legitimate `withdrawal_share_bps`: 10,000 basis points IS 100%, so this is an
/// inclusive bound and `10_000` itself is a valid distributor constant, not an error.
const MAX_WITHDRAWAL_SHARE_BPS: u16 = 10_000;
Expand Down Expand Up @@ -981,7 +1010,7 @@ impl RpcDispatch for Node {
Err(msg) => return rpc_err(&id, -32602, &msg),
};
let Some(port) = node.reward_chain_port() else {
return reward_chain_port_error_response(&id, &ChainPortError::Unavailable);
return reward_chain_port_absent_response(&id);
};
// Range-check at THIS seam, not only in the adapter: see
// `range_checked_report` for why an out-of-range share must refuse here.
Expand Down Expand Up @@ -1023,7 +1052,7 @@ impl RpcDispatch for Node {
Err(msg) => return rpc_err(&id, -32602, &msg),
};
let Some(port) = node.reward_chain_port() else {
return reward_chain_port_error_response(&id, &ChainPortError::Unavailable);
return reward_chain_port_absent_response(&id);
};
// Range-check at THIS seam, not only in the adapter: see
// `range_checked_report` for why an out-of-range share must refuse here.
Expand Down Expand Up @@ -1109,7 +1138,7 @@ impl RpcDispatch for Node {
let mut funded_refs = Vec::with_capacity(identities.len());
for identity in identities {
let Some(port) = node.reward_chain_port() else {
return reward_chain_port_error_response(&id, &ChainPortError::Unavailable);
return reward_chain_port_absent_response(&id);
};
let report = match port
.distributor_report(identity.launcher_id)
Expand Down
Loading
Loading