Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
eac8e10
feat(mirror): persist mirror-bond coin ids (#575)
MichaelTaylor3d Sep 6, 2026
08261a4
fix(peer): count accepted relayed circuits in the connected pool (#579)
MichaelTaylor3d Sep 6, 2026
1ad1c51
fix(cli): guard the exit-code namespace shared with diga against coll…
MichaelTaylor3d Sep 6, 2026
194a016
fix(hygiene): port the lost-continuation guard to 4 crates, fix 48 co…
MichaelTaylor3d Sep 6, 2026
93fb452
feat(mirror): detect an IP change daily and reconcile mirror coins to…
MichaelTaylor3d Sep 7, 2026
e11434a
feat(serve): content hosting + serve path batch, v0.255.0 (dig_ecosys…
MichaelTaylor3d Sep 7, 2026
2b1b5d6
chore(develop): merge main (v0.254.89) back into develop after the #5…
MichaelTaylor3d Sep 7, 2026
b27180b
chore(develop): sync develop to main after the v0.255.0 cut (#588 squ…
MichaelTaylor3d Sep 7, 2026
bc9767d
chore: untrack gitnexus-generated agent files (#590)
MichaelTaylor3d Sep 8, 2026
d562aad
feat(rewards): always-on prover loop engine -- honest liveness, type-…
MichaelTaylor3d Sep 9, 2026
8573ecf
feat: serve dig.getRewardProverStatus at Tier::Control (#595)
MichaelTaylor3d Sep 10, 2026
e9f07c4
feat(rewards): peer-side claim loop -- watch distributors, claim on c…
MichaelTaylor3d Sep 10, 2026
56f398d
feat(rewards): chain port + listRewardDistributors (unit 2) (#604)
MichaelTaylor3d Sep 10, 2026
90fff0c
feat(rewards): durable funder-ownership registry (identity only) (#606)
MichaelTaylor3d Sep 10, 2026
49ae2c6
feat(rewards): wire the peer claim loop onto a cadence driver from re…
MichaelTaylor3d Sep 10, 2026
3d2d55b
chore: record main in develop after the v0.257.0 cut (#608)
MichaelTaylor3d Sep 11, 2026
cd8ce7b
chore: record main in develop after the v0.257.0 cut (ancestry link)
MichaelTaylor3d Sep 11, 2026
24cde57
feat(rpc): serve the five reward-distributor RPC methods (#3269)
MichaelTaylor3d Sep 14, 2026
8b81583
chore(release): bump workspace version to 0.258.0
MichaelTaylor3d Sep 14, 2026
0ae3ef4
ci: propagate main's release-cut commitlint depth to develop
MichaelTaylor3d Sep 14, 2026
7902ad7
fix(rewards): clamp an out-of-range claim schedule to the max (#611)
MichaelTaylor3d Sep 14, 2026
22ba90b
feat(rewards): construct and install a real RewardsChainPort over dig…
MichaelTaylor3d Sep 14, 2026
98b29eb
test(peer): lock inbound dial+accept counted once (#591)
MichaelTaylor3d Sep 16, 2026
a2d5c38
fix(rewards): split the three conditions behind REWARD_CHAIN_UNAVAILA…
MichaelTaylor3d Sep 17, 2026
7c9f46b
fix(rewards): reward-distributor reads are OPEN on POST /, doc + pin …
MichaelTaylor3d Sep 18, 2026
4e102c6
fix(rewards-claim): derive both cadences from one raw config value an…
MichaelTaylor3d Sep 18, 2026
44230a7
feat(rewards-claim): real ClaimChainPort over dig-rewards-coin 0.8.0 …
MichaelTaylor3d Sep 20, 2026
713b2ae
chore(release): v0.260.0 -- the claim loop pays a peer
MichaelTaylor3d Sep 23, 2026
ea370d6
chore(release): record main v0.259.0 in release/v0.260.0 (ancestry fo…
MichaelTaylor3d Sep 23, 2026
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
6 changes: 3 additions & 3 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ edition = "2021"
# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet)
# keep their own independent versions — only the released binary tracks the workspace version.

version = "0.259.0"
version = "0.260.0"
# Release hardening, matching digstore: keep integer-overflow checks ON in release.
# The node parses untrusted serialized input and does offset/length arithmetic over
# it, so silent wrapping in release would turn a length bug into a memory/logic hazard.
Expand Down
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
77 changes: 59 additions & 18 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":{
"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 @@ -878,7 +907,8 @@ impl RpcDispatch for Node {
"count": set.len()}});
}
// dig.getRewardProverStatus (dig_ecosystem#3269, dig-rewards-coin SPEC.md
// §2.3/§2.4) — CONTROL plane: loopback admin / in-process FFI ONLY, NEVER over the
// §2.3/§2.4) — CONTROL plane: loopback admin / in-process FFI ONLY (the token tier of
// this NODE-LOCAL read is dig_ecosystem#3352's decision, not #3351's), NEVER over the
// mTLS peer surface (absent from `is_peer_reachable_method`;
// `reward_methods_tier_guard.rs` fails closed on that). Reads the node's live
// `reward_prover_statuses` registry (empty until dig_ecosystem#3265 spawns a prover
Expand Down Expand Up @@ -968,20 +998,30 @@ impl RpcDispatch for Node {
};
return json!({"jsonrpc":"2.0","id":id,"result": result});
}
// dig.getRewardDistributor (dig_ecosystem#3269 unit 2, SPEC §2.6/§12.4) — CONTROL
// plane: loopback admin / in-process FFI ONLY, absent from `is_peer_reachable_method`
// (`reward_methods_tier_guard.rs` fails closed on that). Chain-derived state ONLY —
// never the local prover loop's self-reported state (see `GetRewardProverStatus`
// above for that). Goes entirely through `rewards::port::RewardsChainPort`: this
// crate never calls `dig-rewards-coin` itself (dig_ecosystem#3269 unit 0).
// dig.getRewardDistributor (dig_ecosystem#3269 unit 2, SPEC §2.6/§12.4) — `Tier::Control`
// in dig-rpc-protocol's sense: served ONLY by the local `handle_rpc` dispatch (the
// service's `POST /` and the in-process FFI), NEVER over the mTLS peer surface (absent
// from `is_peer_reachable_method`; `reward_methods_tier_guard.rs` fails closed on that).
// NOT token-gated (dig_ecosystem#3351): an OPEN read of public on-chain state keyed by
// the caller's `launcher_id`, answered to any caller that reaches `POST /` with no token:
// only the `control.` prefix is token-gated (SPEC §7.2 `is_control_method`; §5.5
// `requires_auth: false` for every non-`control.*` method), and the read passes §7.2's
// WHO-NAMES-THE-SUBJECT test the same way `control.wallet.balance` does (#1851,
// `control::is_open_control_read`): the subject arrives in the request, so the answer
// discloses no node-local association.
// Pinned by `reward_distributor_reads_answer_on_post_slash_without_a_token` in
// dig-node-service `tests/server.rs`. Chain-derived state ONLY — never the local prover
// loop's self-reported state (see `GetRewardProverStatus` above for that). Goes entirely
// through `rewards::port::RewardsChainPort`: this crate never calls `dig-rewards-coin`
// itself (dig_ecosystem#3269 unit 0).
Some(Method::GetRewardDistributor) => {
let params = req.get("params").cloned().unwrap_or(json!({}));
let launcher_id = match parse_launcher_id_arg(&params) {
Ok(id) => id,
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 @@ -1012,7 +1052,8 @@ impl RpcDispatch for Node {
return json!({"jsonrpc":"2.0","id":id,"result": result});
}
// dig.listRewardDistributorCommitments (dig_ecosystem#3269 unit 2, SPEC §7.4 clause 5)
// — CONTROL plane, same guard shape as `GetRewardDistributor` above. `commitments`
// — same guard shape as `GetRewardDistributor` above (`Tier::Control`, not token-gated,
// OPEN on `POST /`; dig_ecosystem#3351). `commitments`
// empty is legitimate (a donation-only distributor); `recoverable_base_units` per slot
// is ALWAYS the port's pre-computed figure -- this handler never recomputes it (see
// `rewards::port::CommitmentSlot`'s doc for why that arithmetic never lives here).
Expand All @@ -1023,7 +1064,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 +1150,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